- 새 M02_MasterTemplete_Sangeun_Sheet.ts(+ .css) — 보기 고르기(시트 + 옛 넷 그대로) · 줄 갈래(근거 · 옵션 · 변수 · 계산 · 자원 · 율 · 소계 · 합계) · 칸에서 옵션 · 품 · 단가 · 율 % · 빼기 · 되살림 · 되돌림 · [+ 자원 줄] · [+ 율 줄] · 고칠 때마다 서버 셈(최신 응답만) · M01 과 다른 줄 색 띠 - 새 logic/M02_Sangeun_Sheet_Model.ts — 시트 줄 · 읽기 쉬운 식(텍스트 수식 이름글 · 별칭) · 엔진 날식 숨김 · 산출서 한 줄 글 · 상태줄(M01 원래 · 지금 · 고치기 전) - ui_template_calc_row 시트 줄(createCalcSheetRow · is-changed) + 부품 모음 견본 한 줄 - Sangeun_Detail = 탭 등록만(createViewPicker → createSangeunSheet) - 시험 test_790_1 새 · test_721 단추 자리 단언을 새 보기 고르기 이름으로 고침(createViewPicker 걷음) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
198 lines
8.1 KiB
TypeScript
198 lines
8.1 KiB
TypeScript
/* =============================================================================
|
||
* M02_MasterTemplete/logic/M02_Sangeun_Sheet_Model.ts
|
||
* 단가산출(SK) 줄 식 시트 꼴(PLAN 790-1 · 요구 650~652 · 요구서_652 1 장 4) — 셈 답 → 시트 줄 · 산출서 글 · 상태줄 금액.
|
||
* 줄 갈래 = 근거 글 · 옵션 · 상수 · 표 값 · 계산(바탕 로직 중간) · 자원 · 율(셈 답 줄) · 소계 · 합계
|
||
* 식 칸 = 읽기 쉬운 이름 꼴 — 자원 · 율 = 서버 텍스트 수식 「이름글」(「= 이름」 뗌 · 「*」 → 「×」) ·
|
||
* 중간 = 찾기 원문을 서버 별칭 이름으로 바꾼 식 · 엔진 날식(찾기 · 만약 · 로직)이 남으면 식 숨김(표 꼬리표만)
|
||
* 금액은 늘 서버 셈 — 여기는 줄 모양만 · 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장)
|
||
* ========================================================================== */
|
||
|
||
import type {
|
||
LogicInput,
|
||
NamedFormula,
|
||
TextAnswer,
|
||
TextLine,
|
||
} from "../../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||
import type { CoverRow, SkAnswer } from "./M02_Sangeun_Cover_Model";
|
||
|
||
export type SheetKind = "근거" | "옵션" | "상수" | "표" | "계산" | "자원" | "율" | "소계" | "합계";
|
||
|
||
export interface SheetItem {
|
||
kind: SheetKind;
|
||
이름: string;
|
||
/** 읽기 쉬운 식 · 빈 글 = 식 없음(숨김 포함) */
|
||
식: string;
|
||
값: number | string | null;
|
||
/** 비목 줄임(노 · 재 · 경) · 별도계상 = 「별도」 */
|
||
비목: string;
|
||
/** 표 꼬리표(QC######) */
|
||
표: string[];
|
||
/** 자원 · 율 줄 = 고침 열쇠 */
|
||
row?: CoverRow;
|
||
/** 옵션 줄 = 입력 꼴 */
|
||
input?: LogicInput;
|
||
/** M01 과 다름(덮개 · 더한줄) */
|
||
changed: boolean;
|
||
}
|
||
|
||
export interface SheetBase {
|
||
출처?: string;
|
||
원문번호?: string;
|
||
비고?: string;
|
||
중간?: NamedFormula[];
|
||
}
|
||
|
||
const SHORT: Record<string, string> = { 노무비: "노", 재료비: "재", 경비: "경" };
|
||
const TABLE_RE = /[A-Z]{2}\d{6}/g;
|
||
const RAW_RE = /(찾기|만약|로직)\s*\(/;
|
||
const NUM_RE = /^-?\d+(\.\d+)?$/;
|
||
const LOOKUP_RE = /^찾기\([^()]*\)\.[^\s*/+\-()]+$/;
|
||
|
||
type Alias = { 이름: string; 원문: string };
|
||
const aliasesOf = (text: TextAnswer | null): Alias[] =>
|
||
text?.ok ? (((text as { 별칭?: Alias[] }).별칭 ?? []) as Alias[]) : [];
|
||
|
||
/** 엔진 식 → 읽기 쉬운 식(찾기 원문 → 별칭 이름 · 「*」 → 「×」) · 날식이 남으면 "" */
|
||
export function readable(formula: string, aliases: Alias[]): string {
|
||
let s = formula.trim();
|
||
for (const a of [...aliases].sort((x, y) => y.원문.length - x.원문.length))
|
||
if (a.원문) s = s.split(a.원문).join(a.이름);
|
||
if (RAW_RE.test(s)) return "";
|
||
return s.replace(/\s*\*\s*/g, " × ");
|
||
}
|
||
|
||
/** 텍스트 수식 「이름글」 → 식 칸 글(「= 이름」 뗌 · 「*」 → 「×」) */
|
||
export const nameFormula = (line: TextLine | undefined): string =>
|
||
(line?.이름글 ?? "").replace(/\s*=\s*[^=]*$/, "").replace(/\s*\*\s*/g, " × ");
|
||
|
||
/** 텍스트 수식 줄 cid 표 */
|
||
export function textLines(text: TextAnswer | null): Map<string, TextLine> {
|
||
const out = new Map<string, TextLine>();
|
||
if (text?.ok) for (const g of text.groups) for (const l of g.줄) if (l.cid) out.set(l.cid, l);
|
||
return out;
|
||
}
|
||
|
||
const tablesOf = (formula: string): string[] => [...new Set(formula.match(TABLE_RE) ?? [])];
|
||
|
||
const partTag = (r: CoverRow): string =>
|
||
r.apart
|
||
? "별도"
|
||
: Object.keys(r.parts)
|
||
.map((k) => SHORT[k] ?? k)
|
||
.join("·");
|
||
|
||
/** 시트 줄 — 근거 · 옵션 · 중간(상수 · 표 · 계산) · 자원 · 율 · 소계 · 합계 */
|
||
export function sheetItems(o: {
|
||
base: SheetBase | null;
|
||
inputs: LogicInput[];
|
||
values: Record<string, string>;
|
||
ans: SkAnswer | null;
|
||
rows: CoverRow[];
|
||
text: TextAnswer | null;
|
||
}): SheetItem[] {
|
||
const out: SheetItem[] = [];
|
||
const item = (x: Partial<SheetItem> & { kind: SheetKind; 이름: string }): void =>
|
||
void out.push({ 식: "", 값: null, 비목: "", 표: [], changed: false, ...x });
|
||
const b = o.base;
|
||
if (b?.출처) item({ kind: "근거", 이름: b.원문번호 || "", 식: b.출처 });
|
||
for (const line of (b?.비고 ?? "").split(/\r?\n/).filter((x) => x.trim()))
|
||
item({ kind: "근거", 이름: "", 식: line.trim() });
|
||
for (const input of o.inputs)
|
||
item({ kind: "옵션", 이름: input.이름, 값: o.values[input.이름] ?? "", input });
|
||
const mid = (o.ans?.중간 ?? {}) as Record<string, unknown>;
|
||
const aliases = aliasesOf(o.text);
|
||
const asked = new Set(o.inputs.map((i) => i.이름));
|
||
for (const f of b?.중간 ?? []) {
|
||
if (asked.has(f.이름)) continue;
|
||
const raw = String(f.식 ?? "").trim();
|
||
const v = mid[f.이름];
|
||
const 값 = typeof v === "number" || typeof v === "string" ? v : null;
|
||
if (NUM_RE.test(raw)) item({ kind: "상수", 이름: f.이름, 값: 값 ?? Number(raw) });
|
||
else if (LOOKUP_RE.test(raw)) item({ kind: "표", 이름: f.이름, 값, 표: tablesOf(raw) });
|
||
else item({ kind: "계산", 이름: f.이름, 식: readable(raw, aliases), 값, 표: tablesOf(raw) });
|
||
}
|
||
const lines = textLines(o.text);
|
||
for (const pct of [false, true])
|
||
for (const r of o.rows) {
|
||
if (r.off || r.pct !== pct) continue;
|
||
const line = lines.get(r.cid);
|
||
out.push({
|
||
kind: pct ? "율" : "자원",
|
||
이름: r.단위 && !pct ? `${r.이름} (${r.단위})` : r.이름,
|
||
식: nameFormula(line) || (pct ? `${num(r.수량)} %` : `${num(r.단가)} × ${num(r.수량)}`),
|
||
값: r.금액,
|
||
비목: partTag(r),
|
||
표: tablesOf(line?.이름글 ?? ""),
|
||
row: r,
|
||
changed: r.covered.qty || r.covered.price || r.added !== null,
|
||
});
|
||
}
|
||
const sums = o.ans?.기준 ?? null;
|
||
for (const k of Object.keys(SHORT))
|
||
if (sums && k in sums) item({ kind: "소계", 이름: k, 값: sums[k], 비목: SHORT[k] });
|
||
if (sums) item({ kind: "합계", 이름: "계", 값: sums.계 ?? null });
|
||
return out;
|
||
}
|
||
|
||
const NUM = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 4 });
|
||
export const num = (v: number | null | undefined): string => (v == null ? "—" : NUM.format(v));
|
||
|
||
/** 산출서 한 줄 글 — 경쟁사 꼴 「형틀목공 : 275,790 × 5 / 45 = 30,643.3」 · 소계 · 계 */
|
||
export interface PaperLine {
|
||
kind: "줄" | "소계" | "합계" | "머리";
|
||
글: string;
|
||
}
|
||
export function paperLines(
|
||
text: TextAnswer | null,
|
||
rows: CoverRow[],
|
||
ans: SkAnswer | null,
|
||
): PaperLine[] {
|
||
if (!ans) return [];
|
||
const out: PaperLine[] = [];
|
||
const by = new Map(rows.map((r) => [r.cid, r]));
|
||
const sums = ans.기준 ?? {};
|
||
const groups = text?.ok ? text.groups : [];
|
||
for (const g of groups) {
|
||
out.push({ kind: "머리", 글: g.비목 });
|
||
for (const l of g.줄) {
|
||
if (l.옵션꺼짐) continue;
|
||
const r = l.cid ? by.get(l.cid) : undefined;
|
||
const body = l.글
|
||
? l.글.replace(/\s*\*\s*/g, " × ")
|
||
: r
|
||
? `${r.pct ? `${num(r.수량)} %` : `${num(r.단가)} × ${num(r.수량)}`} = ${num(r.금액)}`
|
||
: num(l.금액);
|
||
out.push({ kind: "줄", 글: `${l.이름} : ${body}` });
|
||
}
|
||
const sub = sums[g.비목] ?? g.소계;
|
||
out.push({ kind: "소계", 글: `${g.비목} 소계 = ${num(sub)}${g.끝수 ? ` (${g.끝수})` : ""}` });
|
||
}
|
||
out.push({ kind: "합계", 글: `계 = ${num(sums.계 ?? null)}` });
|
||
return out;
|
||
}
|
||
|
||
/** 상태줄 금액 셋 — M01 원래(790-2 셈 답 `원래.계` · 없으면 바탕 셈 계) · 지금 · 고치기 전(화면 캐시) */
|
||
export interface SheetStatus {
|
||
원래: number | null;
|
||
지금: number | null;
|
||
전: number | null;
|
||
}
|
||
export function statusOf(
|
||
ans: SkAnswer | null,
|
||
bare: SkAnswer | null,
|
||
before: number | null,
|
||
): SheetStatus {
|
||
const origin = (ans as (SkAnswer & { 원래?: { 계?: number } | null }) | null)?.원래?.계;
|
||
return {
|
||
원래: origin ?? bare?.기준?.계 ?? ans?.기준?.계 ?? null,
|
||
지금: ans?.기준?.계 ?? null,
|
||
전: before,
|
||
};
|
||
}
|
||
|
||
/** 차이 글 「+383」 · 「−1,200」 · 같으면 "" */
|
||
export const diffText = (now: number | null, was: number | null): string =>
|
||
now == null || was == null || now === was
|
||
? ""
|
||
: `${now > was ? "+" : "−"}${NUM.format(Math.abs(now - was))}`;
|