Files
Aislo/M02_MasterTemplete/logic/M02_Sangeun_Sheet_Model.ts
T
eomsangdonandClaude Opus 5.5 09a67ce91f feat(792): 단가산출 시트 식 칸 화면 — 줄 식 고침 · 칠 때마다 검사 · 빨간 칸 까닭 · 변수 줄
- 새 M02_MasterTemplete_Sangeun_SheetFormula.ts — 줄마다 [식 고침] → 이름 꼴 식 칸 · 칠 때마다 formula-check(옛 요청 끊기) · 틀리면 빨간 칸 + 까닭 · 바꿀 때 엔진 식을 식덮개로(같은 자리 상수 · 표값 · 품 덮개 걷음) · 빈 칸 · M01 식 = 풂 · 더한줄 수량식(율 · 로직 줄 뺌)
- 변수 줄 — [+ 변수 줄] · 이름 · 식 · 값 · 빼기(Formula_Model addVar · editVar · dropVar) · 자리 뒤에 그림
- Sheet — 식 덮개 줄은 품 칸 안 둠 · 숫자 칸 대신 식 칸 · [전체 M01 원래로]에 식 덮개 셈
- Detail — 시트 옵션에 층(scope)만(줄 수 그대로)
- test_790_1 — 식 칸 정적 단언

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:40:07 +09:00

329 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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;
/** 790-2 자리(바탕 cid · 중간 `mid:이름` · `add:#k`) · 없으면 "" */
자리: string;
}
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;
/** 790-3 자리에 새 덮개(상수 · 표값 · 뺌 …)가 있나 — 캐시 문서 기준 */
covered?: (spot: string) => boolean;
}): 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;
const 자리 = `mid:${f.이름}`;
const one = { 이름: f.이름, 값, 자리, changed: !!o.covered?.(자리) };
if (NUM_RE.test(raw)) item({ ...one, kind: "상수", 값: 값 ?? Number(raw) });
else if (LOOKUP_RE.test(raw)) item({ ...one, kind: "표", 표: tablesOf(raw) });
else item({ ...one, kind: "계산", 식: 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 || !!o.covered?.(r.cid),
자리: r.cid,
});
}
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,
notes: NoteLine[] = [],
): 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 : [];
const shown = new Set(groups.flatMap((g) => g.줄.flatMap((l) => (l.옵션꺼짐 ? [] : [l.cid]))));
const note = (spot: string): PaperLine[] =>
notes.filter((x) => x.글 && x.자리 === spot).map((x) => ({ kind: "글", 글: x.글 }));
out.push(
...notes
.filter((x) => x.글 && !shown.has(x.자리))
.map((x) => ({ kind: "글" as const, 글: x.글 })),
);
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}` }, ...(l.cid ? note(l.cid) : []));
}
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))}`;
// ── 790-3 숫자 넓힘 화면 이음(790-2 셈 답 `시트` · 문서 `글줄`) ──────────────
/** 글 줄(셈 없음) — 자리 뒤 · "" = 맨 위 */
export interface NoteLine {
자리: string;
글: string;
}
/** 셈 답 `시트.자리[자리]`(790-2) */
export interface SpotInfo {
갈래: string;
이름: string;
식?: string;
상수?: { n: number; 값: number | null; 원값: number | null }[];
표값?: { n: number; 글: string; 값: unknown; 원값: unknown }[];
원래?: number | null;
지금?: unknown;
덮개?: string[];
까닭?: string | null;
M01바뀜?: boolean;
/** 792-1 — 지금 식 · M01 식(이름 꼴) · 식 덮개 있음 */
식글?: string;
원식글?: string;
식덮개?: boolean;
}
export interface SheetSpots {
자리: Record<string, SpotInfo>;
글줄: NoteLine[];
짝잃음: { cid: string; 까닭: string }[];
/** 792-1 식 칸에 쓸 수 있는 이름 */
이름표?: { 글: string; 갈래: string }[];
}
export const spotsOf = (ans: SkAnswer | null): SheetSpots | null =>
((ans as { 시트?: SheetSpots } | null)?.시트 ?? null) || null;
export type FormulaPart =
{ t: "글"; 글: string } | { t: "수"; n: number } | { t: "표"; n: number; 표: string[] };
const IDENT = /^[\p{L}_][\p{L}\p{N}_]*/u;
const DIGITS = /^\d+(\.\d+)?/;
/** 식 글 → 숫자 자리(왼쪽부터 · 따옴표 글 · 이름 안 숫자 뺌) — 서버 `scan` 숫자 차례와 같음 */
export function numSpots(s: string): [number, number][] {
const out: [number, number][] = [];
let i = 0;
while (i < s.length) {
const c = s[i];
if (c === "'" || c === '"') {
const j = s.indexOf(c, i + 1);
i = j < 0 ? s.length : j + 1;
continue;
}
const rest = s.slice(i);
const id = IDENT.exec(rest);
const d = id ? null : DIGITS.exec(rest);
if (d) out.push([i, i + d[0].length]);
i += id ? id[0].length : d ? d[0].length : 1;
}
return out;
}
/** 엔진 식을 조각으로 — 글 · 숫자(n) · 표 찾기(n) · 서버 차례와 안 맞으면 null(숫자 칸 안 그림) */
export function formulaParts(info: SpotInfo | undefined): FormulaPart[] | null {
const s = info?.식 ?? "";
const nums = info?.상수 ?? [];
const finds = info?.표값 ?? [];
if (!s || (!nums.length && !finds.length)) return null;
const at = numSpots(s);
if (at.length !== nums.length) return null;
if (at.some(([a, b], i) => Number(s.slice(a, b)) !== Number(nums[i].원값))) return null;
const cuts: { a: number; b: number; p: FormulaPart }[] = [];
let from = 0;
for (const f of finds) {
const a = s.indexOf(f.글, from);
if (a < 0) return null;
from = a + f.글.length;
cuts.push({ a, b: from, p: { t: "표", n: f.n, 표: tablesOf(f.글) } });
}
at.forEach(([a, b], n) => {
if (!cuts.some((c) => c.a <= a && b <= c.b)) cuts.push({ a, b, p: { t: "수", n } });
});
cuts.sort((x, y) => x.a - y.a);
const out: FormulaPart[] = [];
const text = (x: string): void => {
const 글 = x.replace(/\s*\*\s*/g, " × ");
if (글) out.push({ t: "글", 글 });
};
let pos = 0;
for (const c of cuts) {
text(s.slice(pos, c.a));
out.push(c.p);
pos = c.b;
}
text(s.slice(pos));
return out;
}
/** 뺀 더한줄 되살림(차례 k 에 다시 끼움) 뒤 — 까닭 · 글줄 열쇠 `add:#j`(j ≥ k) 한 칸 밂 · `dropAddKeys` 거꾸로 */
export function liftAddKeys<T extends { 까닭?: Record<string, string>; 글줄?: NoteLine[] }>(
doc: T,
k: number,
): T {
const move = (key: string): string =>
key.replace(/^add:#(\d+)/, (all, j: string) =>
Number(j) >= k ? `add:#${Number(j) + 1}` : all,
);
const out = { ...doc };
if (doc.까닭)
out.까닭 = Object.fromEntries(Object.entries(doc.까닭).map(([x, v]) => [move(x), v]));
if (doc.글줄) out.글줄 = doc.글줄.map((x) => ({ ...x, 자리: move(x.자리) }));
return out;
}