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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user