feat(790-3): 단가산출 시트 숫자 넓힘 화면 — 식 속 상수 · 표값 칸 · 글 줄 · 까닭 · M01 원래로 · M01 바뀜

- 새 M02_MasterTemplete_Sangeun_SheetEdit.ts — 790-2 셈 답 시트.자리 를 칸으로: 식 속 숫자 = 상수 덮개(식 모양 그대로 · 옵션 따라감) · 표 꼬리표 값 = 표값 덮개(「표 연결 끊김」) · 글 줄 더함 · 고침 · 지움(줄마다 [+ 글] · 근거 머리 [+ 근거 글]) · 까닭 칸(고친 줄 · 뺀 줄 · 더한 줄) · [M01 원래로] 줄마다 · 상태줄 전체 · 「M01 바뀜」 배지 · 짝잃음 띠
- Sheet_Model — 식 조각(서버 scan 숫자 · 표 찾기 차례 · 안 맞으면 칸 안 그림) · 산출서에 글 줄 · 되살린 더한줄 열쇠 밂(liftAddKeys)
- Sheet — 옛 [되돌림] 단추를 [M01 원래로]로 · 더한줄 빼면 까닭 · 글줄 열쇠 당김(dropAddKeys) · 문서 고침 = 캐시 → 서버 셈 · 자동저장 없음
- 더한줄 줄 사이 끼움은 SK 모델에 자리 칸이 없어 덩이 끝 그대로(글 줄만 자리 뒤)
- test_790_1 늘림(정적 단언 + 식 조각 · 글줄 산출서 · 열쇠 밂)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-05 17:34:02 +09:00
co-authored by Claude Opus 5.5
parent d26f7a7e1a
commit 93b13e975f
5 changed files with 633 additions and 29 deletions
@@ -33,6 +33,8 @@ export interface SheetItem {
input?: LogicInput;
/** M01 과 다름(덮개 · 더한줄) */
changed: boolean;
/** 790-2 자리(바탕 cid · 중간 `mid:이름` · `add:#k`) · 없으면 "" */
자리: string;
}
export interface SheetBase {
@@ -89,10 +91,12 @@ export function sheetItems(o: {
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 });
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()))
@@ -107,9 +111,11 @@ export function sheetItems(o: {
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 자리 = `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])
@@ -124,7 +130,8 @@ export function sheetItems(o: {
비목: partTag(r),
표: tablesOf(line?.이름글 ?? ""),
row: r,
changed: r.covered.qty || r.covered.price || r.added !== null,
changed: r.covered.qty || r.covered.price || r.added !== null || !!o.covered?.(r.cid),
자리: r.cid,
});
}
const sums = o.ans?.기준 ?? null;
@@ -139,19 +146,28 @@ export const num = (v: number | null | undefined): string => (v == null ? "—"
/** 산출서 한 줄 글 — 경쟁사 꼴 「형틀목공 : 275,790 × 5 / 45 = 30,643.3」 · 소계 · 계 */
export interface PaperLine {
kind: "줄" | "소계" | "합계" | "머리";
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.줄) {
@@ -162,7 +178,7 @@ export function paperLines(
: r
? `${r.pct ? `${num(r.수량)} %` : `${num(r.단가)} × ${num(r.수량)}`} = ${num(r.금액)}`
: num(l.금액);
out.push({ kind: "줄", 글: `${l.이름} : ${body}` });
out.push({ kind: "줄", 글: `${l.이름} : ${body}` }, ...(l.cid ? note(l.cid) : []));
}
const sub = sums[g.비목] ?? g.소계;
out.push({ kind: "소계", 글: `${g.비목} 소계 = ${num(sub)}${g.끝수 ? ` (${g.끝수})` : ""}` });
@@ -195,3 +211,112 @@ 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;
}
export interface SheetSpots {
자리: Record<string, SpotInfo>;
글줄: NoteLine[];
짝잃음: { cid: 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;
}