diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Sheet.css b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Sheet.css index 7a78543a4..ef81928fd 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Sheet.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Sheet.css @@ -84,3 +84,43 @@ margin-top: var(--spacing-8); color: var(--color-text-secondary); } + +/* 790-3 숫자 넓힘 — 식 속 숫자 · 표 값 칸 · 까닭 · 글 줄 */ +.m02-sks__formula, +.m02-sks__find { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4); + font-size: var(--text-body-sm); + font-weight: var(--font-weight-regular); + color: var(--color-text-body); + font-variant-numeric: tabular-nums; +} + +.m02-sks__frag { + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.m02-sks__num { + width: 6.5em; +} + +.m02-sks__num[data-cover] { + outline: 2px solid var(--color-warning); + border-radius: var(--radius-sm); +} + +.m02-sks__reason .ui-field { + width: min(24em, 100%); +} + +.m02-sks__note .ui-field { + width: 100%; +} + +.m02-sks__paper--note { + color: var(--color-text-secondary); + font-style: italic; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Sheet.ts index 525c4e526..2612038cf 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Sheet.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Sheet.ts @@ -6,6 +6,7 @@ * 고침 = 지금 되는 것만(모델 안 바꿈) — 옵션 고름 · 품 · 단가 · 율 % · 줄 빼기 · 되살림 · [+ 자원 줄] · [+ 율 줄] * → SK 문서(덮개 · 더한줄 · 뺀줄 = `M02_Sangeun_Cover_Model`) → 상세 `touch` = 서버 셈 한 번(최신 응답만) * 상태줄 = M01 원래 · 지금 · 고치기 전(화면 캐시) + 차이 · 자동저장 없음([저장] = 상세 그대로) + * 790-3 숫자 넓힘(식 속 상수 · 표값 · 글 줄 · 까닭 · [M01 원래로] · M01 바뀜) = `_Sangeun_SheetEdit` * 부품 = `ui_template_calc_row` 시트 줄 · 입력 · 고르기 · 단추 · 배지 · 글 — 날 단추 · 입력 없음 * ========================================================================== */ @@ -22,6 +23,18 @@ import { formatLineMoney, formatMoney } from "../common_util/common_util_money"; import type { LogicRow, TextAnswer } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; import { tl } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Text"; import { removedStrip } from "./M02_MasterTemplete_Sangeun_Cover"; +import { + addTopNote, + formulaEditor, + lostBanner, + noteRows, + reasonCell, + removedReasons, + restoreAll, + spotTail, + type EditCtx, +} from "./M02_MasterTemplete_Sangeun_SheetEdit"; +import { coverKinds, dropAddKeys, type ConstDoc } from "./logic/M02_Sangeun_Const_Model"; import { dropLine, PART_OF, @@ -35,9 +48,11 @@ import { } from "./logic/M02_Sangeun_Cover_Model"; import { diffText, + liftAddKeys, num, paperLines, sheetItems, + spotsOf, statusOf, type SheetItem, type SheetKind, @@ -61,7 +76,6 @@ const TEXT = { Price: ["단가", "Price"], Rate: ["율 %", "Rate %"], Drop: ["빼기", "Remove"], - Undo: ["되돌림", "Revert"], AddRes: ["+ 자원 줄", "+ Resource"], AddRate: ["+ 율 줄", "+ Rate line"], Table: ["표", "Table"], @@ -118,6 +132,8 @@ export function createSangeunSheet( let mode: "sheet" | "paper" = "sheet"; let last: SheetDraw | null = null; let before: number | null = null; // 고치기 전 = 연 뒤 · 저장 뒤 첫 셈 계(화면 캐시) + let ctx: EditCtx | null = null; // 790-3 숫자 넓힘 칸 — 그릴 때마다 새로 + let focusNext = ""; // 새 글 줄 칸 — 다음 그림 뒤 초점 const show = (v: View): void => { host.hidden = v !== "sheet"; editor.hidden = v === "sheet"; @@ -169,6 +185,7 @@ export function createSangeunSheet( part("origin", t("Origin"), s.원래), part("now", t("Now"), s.지금, diffText(s.지금, s.원래)), part("before", t("Before"), s.전, diffText(s.지금, s.전)), + ...(ctx ? restoreAll(ctx) : []), ); } @@ -231,27 +248,23 @@ export function createSangeunSheet( return [el("div", { className: "m02-sks__edit", children: cells })]; } - function tailOf(d: SheetDraw, it: SheetItem): HTMLElement { + function tailOf(d: SheetDraw, it: SheetItem, nums: boolean): HTMLElement { const r = it.row; const kids: HTMLElement[] = it.비목 ? [createBadge(it.비목, it.비목 === "별도" ? "warning" : "neutral")] : []; - for (const tag of it.표) kids.push(createBadge(`${t("Table")} ${tag}`, "info")); + if (!nums) for (const tag of it.표) kids.push(createBadge(`${t("Table")} ${tag}`, "info")); + if (ctx) kids.push(...spotTail(ctx, it.자리, !!r && r.added !== null)); if (r && opt.edit) { - if (r.added === null && (r.covered.qty || r.covered.price)) - kids.push( - createButton({ - label: t("Undo"), - variant: "ghost", - size: 22, - onClick: () => (setLineCover(d.doc, r, null, opt.opened()), opt.touch()), - }), - ); const drop = createButton({ label: t("Drop"), variant: "danger-ghost", size: 22, - onClick: () => (dropLine(d.doc, r), opt.touch()), + onClick: () => { + dropLine(d.doc, r); + if (r.added !== null && !r.sub) apply(d, dropAddKeys(d.doc as ConstDoc, r.added)); + else opt.touch(); + }, }); drop.dataset.sheetDrop = r.cid; kids.push(drop); @@ -298,21 +311,35 @@ export function createSangeunSheet( }; function rowOf(d: SheetDraw, it: SheetItem): HTMLElement[] { + const nums = ctx && it.자리 ? formulaEditor(ctx, it.자리) : null; + const shown = it.식 || (it.kind === "계산" ? t("Hidden") : ""); const middle = it.kind === "옵션" ? optionCell(d, it) : el("div", { className: "m02-sks__mid", children: [ - createCalcFormula(it.식 || (it.kind === "계산" ? t("Hidden") : "")), + ...(it.row || !nums ? [createCalcFormula(shown)] : []), + ...(nums ? [nums] : []), ...(it.row ? editLine(d, it.row) : []), + ...(ctx ? reasonCell(ctx, it.자리, it.changed) : []), ], }); - const row = createCalcSheetRow(it.이름, middle, valueOf(it), tailOf(d, it)); + const row = createCalcSheetRow(it.이름, middle, valueOf(it), tailOf(d, it, !!nums)); row.dataset.sheetKind = it.kind; if (it.row) row.dataset.sheetCid = it.row.cid; + if (it.자리) row.dataset.sheetSpot = it.자리; if (it.changed) row.classList.add("is-changed"); - return [row]; + const notes = ctx && it.자리 ? noteRows(ctx, (x) => x.자리 === it.자리) : []; + return [row, ...notes]; + } + + /** 790-3 — 새 문서로 바꿔 끼움(상세가 쥔 같은 객체) → 다시 셈 */ + function apply(d: SheetDraw, next: ConstDoc): void { + const doc = d.doc as Record; + for (const k of Object.keys(doc)) if (!(k in next)) delete doc[k]; + Object.assign(doc, next); + opt.touch(); } const head = (key: keyof typeof TEXT, extra: HTMLElement[] = []): HTMLElement => @@ -330,8 +357,10 @@ export function createSangeunSheet( ans: d.ans, rows: d.rows, text: d.text, + covered: (s) => coverKinds(d.doc as ConstDoc, s).length > 0, }); - const out: HTMLElement[] = []; + const spots = new Set(items.map((it) => it.자리).filter(Boolean)); + const out: HTMLElement[] = ctx ? lostBanner(ctx) : []; const add = (label: keyof typeof TEXT, click: () => void): HTMLElement[] => opt.edit ? [createButton({ label: t(label), variant: "ghost", size: 22, onClick: click })] @@ -344,22 +373,35 @@ export function createSangeunSheet( ? add("AddRes", opt.addLine) : sec === "율" ? add("AddRate", opt.addRate) - : []; + : sec === "근거" && ctx + ? addTopNote(ctx) + : []; if (!mine.length && !tools.length) continue; out.push(head(sec, tools)); if (sec === "자원") out.push( - ...removedStrip(d.removed, opt.edit, (cid) => (restoreLine(d.doc, cid), opt.touch())), + ...removedStrip(d.removed, opt.edit, (cid) => revive(d, cid)), + ...(ctx ? removedReasons(ctx, d.removed) : []), ); for (const it of mine) out.push(...rowOf(d, it)); + if (sec === "근거" && ctx) out.push(...noteRows(ctx, (x) => !spots.has(x.자리))); } return out; } + /** 뺀 줄 되살림 — 뺀 더한줄(`gone:N`)이면 까닭 · 글줄 열쇠를 그 차례부터 한 칸 밂 */ + function revive(d: SheetDraw, cid: string): void { + const gone = /^gone:(\d+)$/.exec(cid); + const at = gone ? d.doc.뺀더한줄?.[Number(gone[1])]?.차례 : undefined; + restoreLine(d.doc, cid); + if (at != null) apply(d, liftAddKeys(d.doc as ConstDoc, at)); + else opt.touch(); + } + function drawPaper(d: SheetDraw): HTMLElement[] { - return paperLines(d.text, d.rows, d.ans).map((p) => { + return paperLines(d.text, d.rows, d.ans, (d.doc as ConstDoc).글줄).map((p) => { const line = el("p", { - className: `m02-sks__paper m02-sks__paper--${p.kind === "줄" ? "line" : "sum"}`, + className: `m02-sks__paper m02-sks__paper--${p.kind === "줄" ? "line" : p.kind === "글" ? "note" : "sum"}`, attrs: { "data-paper": p.kind }, text: p.글, }); @@ -370,9 +412,17 @@ export function createSangeunSheet( function draw(d: SheetDraw): void { last = d; if (before === null && d.ans?.기준?.계 != null) before = d.ans.기준.계; + ctx = { + doc: d.doc as ConstDoc, + edit: opt.edit, + spots: spotsOf(d.ans), + apply: (next) => apply(d, next), + focus: (key) => void (focusNext = key), + }; drawStatus(d); const active = document.activeElement as HTMLElement | null; - const keep = active && host.contains(active) ? active.dataset.sheetCell : undefined; + const keep = focusNext || (active && host.contains(active) ? active.dataset.sheetCell : ""); + focusNext = ""; body.dataset.mode = mode; body.replaceChildren( ...(d.ans diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_SheetEdit.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_SheetEdit.ts new file mode 100644 index 000000000..2698fc4c1 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_SheetEdit.ts @@ -0,0 +1,295 @@ +/* ============================================================================= + * M02_MasterTemplete_Sangeun_SheetEdit.ts + * 단가산출(SK) 시트 숫자 넓힘 칸(PLAN 790-3 · E2 · _분석_650-H3 1 · 2 장) — 시트(`_Sangeun_Sheet`)가 부름. + * 식 칸 숫자 = 상수 덮개(식 모양 그대로 · 옵션 따라감) · 표 꼬리표 값 = 표값 덮개(「표 연결 끊김」) + * 글 줄(근거 글 · 셈 없음 · 산출서에 찍힘) · 까닭 칸 · [M01 원래로] · 「M01 바뀜」 · 짝잃음 띠 + * 문서 고치기 = `logic/M02_Sangeun_Const_Model`(790-2 짝) · 금액은 늘 서버 셈(상세 touch) + * 부품 = 입력 · 단추 · 배지 · 띠 알림 · calc_row — 날 단추 · 입력 없음 + * ========================================================================== */ + +import { createBadge } from "@ui/ui_template_badge"; +import { createBanner } from "@ui/ui_template_banner"; +import { createButton } from "@ui/ui_template_button"; +import { createCalcFormula, createCalcSheetRow } from "@ui/ui_template_calc_row"; +import { el } from "@ui/ui_template_elements"; +import { createInputField } from "@ui/ui_template_input"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createText } from "@ui/ui_template_text"; +import { + addText, + coverKinds, + dropText, + editText, + restoreM01, + setConst, + setReason, + setTable, + type ConstDoc, +} from "./logic/M02_Sangeun_Const_Model"; +import { + formulaParts, + type NoteLine, + type SheetSpots, + type SpotInfo, +} from "./logic/M02_Sangeun_Sheet_Model"; + +const TEXT = { + Num: ["식 속 숫자", "Constant"], + TableVal: ["표 값", "Table value"], + Cut: ["표 연결 끊김", "Table link cut"], + Changed: ["M01 바뀜", "M01 changed"], + Restore: ["M01 원래로", "Reset to M01"], + RestoreAll: ["전체 M01 원래로", "Reset all to M01"], + Reason: ["까닭", "Reason"], + ReasonHint: ["고친 까닭(짧게)", "Why changed (short)"], + Note: ["글", "Note"], + NoteHint: ["근거 글", "Basis note"], + AddNote: ["+ 글", "+ Note"], + AddTop: ["+ 근거 글", "+ Basis note"], + DropNote: ["글 지움", "Delete note"], + Lost: ["짝 잃은 고침 — M01 이 바뀌어 붙일 자리가 없음", "Orphan edits — M01 changed"], +} as const satisfies Record; +const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; + +export interface EditCtx { + doc: ConstDoc; + edit: boolean; + spots: SheetSpots | null; + /** 새 문서로 바꿔 끼움(같은 객체 유지) → 다시 셈 */ + apply: (next: ConstDoc) => void; + /** 다음 그림 뒤 이 칸에 초점 */ + focus: (key: string) => void; +} + +const strip = (v: number | null | undefined): string => + v == null ? "" : String(Number(Number(v).toFixed(6))); + +/** 작은 칸 — 바꾸면(Enter · 떠남) `onSet(글)` */ +function smallCell( + key: string, + label: string, + v: string, + hint: string, + onSet: (raw: string) => void, +): HTMLElement { + const box = createInputField({ value: v, compact: true, size: 24, placeholder: hint }); + box.input.setAttribute("aria-label", label); + box.input.dataset.sheetCell = key; + box.input.addEventListener("change", () => onSet(box.input.value.trim())); + return box.root; +} + +/** 숫자 칸 — 빈 칸 · 원값과 같음 = null(덮개 풂) · 못 읽으면 되돌림 */ +function numBox( + key: string, + label: string, + v: number | null, + was: number | null, + cover: boolean, + onSet: (x: number | null) => void, +): HTMLElement { + const root = smallCell(key, label, strip(v), strip(was), (raw) => { + const x = raw ? Number(raw.replaceAll(",", "")) : null; + if (x !== null && !Number.isFinite(x)) return void (input.value = strip(v)); + onSet(x === null || (was != null && x === Number(was)) ? null : x); + }); + const input = root.querySelector("input")!; + input.inputMode = "decimal"; + root.classList.add("m02-sks__num"); + if (cover) root.dataset.cover = ""; + return root; +} + +/** 식 칸 — 숫자 · 표 값을 칸으로(편집 · 서버 차례와 맞을 때만) · 못 그리면 null */ +export function formulaEditor(c: EditCtx, spot: string): HTMLElement | null { + const info = c.spots?.자리[spot]; + const parts = c.edit ? formulaParts(info) : null; + if (!info || !parts) return null; + const nums = c.doc.상수덮개?.[spot] ?? {}; + const finds = c.doc.표값덮개?.[spot] ?? {}; + const kids = parts.map((p): HTMLElement => { + if (p.t === "글") return el("span", { className: "m02-sks__frag", text: p.글 }); + if (p.t === "수") { + const one = info.상수![p.n]; + const mine = nums[String(p.n)]; + return numBox( + `${spot}|n${p.n}`, + `${t("Num")} ${p.n + 1}`, + mine ?? one.값, + one.원값, + mine != null, + (x) => c.apply(setConst(c.doc, spot, p.n, x)), + ); + } + const one = info.표값!.find((x) => x.n === p.n); + const was = typeof one?.원값 === "number" ? one.원값 : null; + const mine = finds[String(p.n)]; + const shown = mine ?? (typeof one?.값 === "number" ? one.값 : null); + return el("span", { + className: "m02-sks__find", + children: [ + numBox(`${spot}|t${p.n}`, `${t("TableVal")} ${p.n + 1}`, shown, was, mine != null, (x) => + c.apply(setTable(c.doc, spot, p.n, x)), + ), + ...(p.표.length ? [createBadge(p.표.join(" "), "info")] : []), + ...(mine != null ? [createBadge(t("Cut"), "warning")] : []), + ], + }); + }); + const box = el("div", { + className: "m02-sks__formula", + attrs: { "data-sheet-formula": spot }, + children: kids, + }); + return box; +} + +/** 꼬리 표지 · 단추 — M01 바뀜 · 표 연결 끊김 · [M01 원래로] · [+ 글] */ +export function spotTail(c: EditCtx, spot: string, added: boolean): HTMLElement[] { + if (!spot) return []; + const info: SpotInfo | undefined = c.spots?.자리[spot]; + const kinds = coverKinds(c.doc, spot); + const out: HTMLElement[] = []; + if (info?.M01바뀜) out.push(createBadge(t("Changed"), "danger")); + if (!c.edit) return out; + if (!added && kinds.length) { + const back = createButton({ + label: t("Restore"), + variant: "ghost", + size: 22, + onClick: () => c.apply(restoreM01(c.doc, spot)), + }); + back.dataset.sheetRestore = spot; + out.push(back); + } + const note = createButton({ + label: t("AddNote"), + variant: "ghost", + size: 22, + onClick: () => addNote(c, spot), + }); + note.dataset.sheetAddNote = spot; + out.push(note); + return out; +} + +/** 까닭 칸 — 고친 자리(덮개 · 새 덮개 · 뺀줄 · 더한줄)만 */ +export function reasonCell(c: EditCtx, key: string, need: boolean): HTMLElement[] { + const v = c.doc.까닭?.[key] ?? ""; + if (!key || (!need && !v)) return []; + if (!c.edit) return [createCalcFormula(`${t("Reason")} · ${v}`)]; + return [ + el("span", { + className: "m02-sks__cell m02-sks__reason", + children: [ + smallCell(`${key}|why`, t("Reason"), v, t("ReasonHint"), (x) => + c.apply(setReason(c.doc, key, x)), + ), + ], + }), + ]; +} + +export const isCovered = (doc: ConstDoc, spot: string): boolean => + coverKinds(doc, spot).some((k) => k === "상수" || k === "표값"); + +function addNote(c: EditCtx, spot: string): void { + const lines = c.doc.글줄 ?? []; + let at = lines.length; // 같은 자리 글 줄 뒤 + for (let i = lines.length - 1; i >= 0; i--) + if (lines[i].자리 === spot) { + at = i + 1; + break; + } + if (spot === "" && !lines.some((x) => x.자리 === "")) at = 0; + c.focus(`note|${at}`); + c.apply(addText(c.doc, spot, "", at)); +} + +/** 자리 뒤 글 줄들 — 편집 = 글 칸 · 지움 */ +export function noteRows(c: EditCtx, pick: (x: NoteLine) => boolean): HTMLElement[] { + return (c.doc.글줄 ?? []).flatMap((x, i) => { + if (!pick(x)) return []; + const middle = c.edit + ? smallCell(`note|${i}`, t("Note"), x.글, t("NoteHint"), (v) => + c.apply(editText(c.doc, i, v)), + ) + : createCalcFormula(x.글); + const tail = c.edit + ? [ + createButton({ + label: t("DropNote"), + variant: "danger-ghost", + size: 22, + onClick: () => c.apply(dropText(c.doc, i)), + }), + ] + : []; + const row = createCalcSheetRow(t("Note"), middle, el("span"), el("span", { children: tail })); + row.dataset.sheetKind = "글"; + row.classList.add("m02-sks__note"); + return [row]; + }); +} + +export const addTopNote = (c: EditCtx): HTMLElement[] => + c.edit + ? [ + createButton({ + label: t("AddTop"), + variant: "ghost", + size: 22, + onClick: () => addNote(c, ""), + }), + ] + : []; + +/** 상태줄 [전체 M01 원래로] — 덮개 · 뺀줄 · 상수 · 표값 덮개가 있을 때만 */ +export function restoreAll(c: EditCtx): HTMLElement[] { + const d = c.doc; + const any = + Object.keys(d.덮개 ?? {}).length + + (d.뺀줄 ?? []).length + + Object.keys(d.상수덮개 ?? {}).length + + Object.keys(d.표값덮개 ?? {}).length; + if (!c.edit || !any) return []; + const b = createButton({ + label: t("RestoreAll"), + variant: "ghost", + size: 22, + onClick: () => c.apply(restoreM01(c.doc)), + }); + b.dataset.sheetRestore = "*"; + return [b]; +} + +/** 짝 잃은 새 덮개 띠(790-2 `시트.짝잃음`) */ +export function lostBanner(c: EditCtx): HTMLElement[] { + const gone = c.spots?.짝잃음 ?? []; + return gone.length + ? [ + createBanner({ + tone: "warning", + text: t("Lost"), + details: gone.map((x) => `${x.cid} — ${x.까닭}`).join("\n"), + }), + ] + : []; +} + +/** 뺀 바탕 줄 까닭 칸(뺀 줄 띠 아래) */ +export function removedReasons( + c: EditCtx, + removed: { cid: string; 이름: string }[], +): HTMLElement[] { + if (!c.edit) return []; + return removed + .filter((x) => (c.doc.뺀줄 ?? []).includes(x.cid)) + .map((x) => + el("div", { + className: "m02-sks__cell m02-sks__reason", + attrs: { "data-sheet-removed": x.cid }, + children: [createText(x.이름, "muted"), ...reasonCell(c, x.cid, true)], + }), + ); +} diff --git a/M02_MasterTemplete/logic/M02_Sangeun_Sheet_Model.ts b/M02_MasterTemplete/logic/M02_Sangeun_Sheet_Model.ts index e5333990c..ea5f709b6 100644 --- a/M02_MasterTemplete/logic/M02_Sangeun_Sheet_Model.ts +++ b/M02_MasterTemplete/logic/M02_Sangeun_Sheet_Model.ts @@ -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 & { 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; + 글줄: 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; 글줄?: 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; +} diff --git a/resources/tester/test_790_1_sangeun_sheet.py b/resources/tester/test_790_1_sangeun_sheet.py index 49f2fb25f..1894c766d 100644 --- a/resources/tester/test_790_1_sangeun_sheet.py +++ b/resources/tester/test_790_1_sangeun_sheet.py @@ -178,3 +178,97 @@ def test_줄_갈래_읽기_쉬운_식_산출서_상태줄(): assert got["status"] == {"원래": 51000, "지금": 51419, "전": 50000} # 원래 = 바탕 셈 계 assert got["origin"]["원래"] == 49000 # 790-2 `원래` 있으면 그 값 assert got["diff"] == ["+383", "−419", ""] + + +# ── 790-3 E2 화면 이음(식 속 상수 · 표값 · 글 줄 · 까닭 · M01 원래로 · M01 바뀜) ────────── +EDIT = M02 / "M02_MasterTemplete_Sangeun_SheetEdit.ts" + + +def test_790_3_숫자_넓힘_칸_정적(): + s = _text(EDIT) + assert " p && p.map((x) => x.t === '글' ? x.글 : x.t === '수' ? `[#${x.n}]` : `[표${x.n} ${x.표}]`).join('');" + "\nconst notes = [{ 자리: '', 글: '맨 위 글' }, { 자리: 'labor:LB000007#0', 글: '형틀 뒤 글' }," + " { 자리: 'material:폼타이#0', 글: '꺼진 줄 뒤' }, { 자리: 'labor:LB000007#0', 글: '' }];" + "\nconsole.log(JSON.stringify({" + "\n parts: show(formulaParts(S))," + "\n bad: formulaParts(B)," + "\n none: formulaParts({ 식: '형틀목공', 상수: [], 표값: [] })," + "\n spots: numSpots(\"만약(a == '3회', LB000007 * 2.5, 줄.'합판 12㎜' / 10)\")," + "\n paper: paperLines(F.text, F.rows, F.ans, notes).map((p) => p.kind + ':' + p.글)," + "\n lift: liftAddKeys({ 까닭: { 'add:#0': 'a', 'add:#1/x#0': 'b', 'mid:q': 'c' }," + " 글줄: [{ 자리: 'add:#2', 글: 'g' }] }, 1)," + "\n spotsNone: spotsOf(null)," + "\n}));" + ) # fmt: skip + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-"], + input=src + body, capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def test_790_3_식_조각_글줄_산출서_열쇠_밂(): + got = _run_e2() + assert got["parts"] == ( + "[표0 QC000431] × ([#0] - (높이감률 + 만약(지붕슬래브 == '아님', [#1], [#2])) / [#3])" + ) # 서버 scan 차례 그대로 · 표 찾기 = 한 칸 + assert got["bad"] is None and got["none"] is None # 차례가 안 맞으면 숫자 칸 안 그림 + assert len(got["spots"]) == 2 # 2.5 · 10 만 — 따옴표 글(3회 · 12㎜) · 이름(LB000007) 안 숫자 뺌 + paper = got["paper"] + assert paper[0] == "글:맨 위 글" and paper[1] == "글:꺼진 줄 뒤" # 맨 위 · 안 그린 줄 = 위로 + i = paper.index("줄:형틀목공 : 275,790 × 0.1111 = 30,643.3") + assert paper[i + 1] == "글:형틀 뒤 글" and "글:" not in paper[i + 2] # 빈 글 안 찍음 + assert got["lift"]["까닭"] == {"add:#0": "a", "add:#2/x#0": "b", "mid:q": "c"} + assert got["lift"]["글줄"] == [{"자리": "add:#3", "글": "g"}] + assert got["spotsNone"] is None