From 93b13e975fb4fda490842e3bc8b8132f1cebb0fe Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 5 Nov 2026 17:34:02 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(790-3):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EC=8B=9C=ED=8A=B8=20=EC=88=AB=EC=9E=90=20=EB=84=93?= =?UTF-8?q?=ED=9E=98=20=ED=99=94=EB=A9=B4=20=E2=80=94=20=EC=8B=9D=20?= =?UTF-8?q?=EC=86=8D=20=EC=83=81=EC=88=98=20=C2=B7=20=ED=91=9C=EA=B0=92=20?= =?UTF-8?q?=EC=B9=B8=20=C2=B7=20=EA=B8=80=20=EC=A4=84=20=C2=B7=20=EA=B9=8C?= =?UTF-8?q?=EB=8B=AD=20=C2=B7=20M01=20=EC=9B=90=EB=9E=98=EB=A1=9C=20=C2=B7?= =?UTF-8?q?=20M01=20=EB=B0=94=EB=80=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 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 --- .../M02_MasterTemplete_Sangeun_Sheet.css | 40 +++ .../M02_MasterTemplete_Sangeun_Sheet.ts | 94 ++++-- .../M02_MasterTemplete_Sangeun_SheetEdit.ts | 295 ++++++++++++++++++ .../logic/M02_Sangeun_Sheet_Model.ts | 139 ++++++++- resources/tester/test_790_1_sangeun_sheet.py | 94 ++++++ 5 files changed, 633 insertions(+), 29 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Sangeun_SheetEdit.ts 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 From 193c3992a90456db8c006f59526098e6d5f7519a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 5 Nov 2026 17:37:26 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(793-2a):=20=EA=B2=BD=EC=9F=81=EC=82=AC?= =?UTF-8?q?=20=EC=82=B0=EA=B7=BC=20=EC=8B=9D=20=E2=86=92=20=EC=97=94?= =?UTF-8?q?=EC=A7=84=20=EB=A1=9C=EC=A7=81=20=EC=A4=84=20=EA=BC=B4=20?= =?UTF-8?q?=EB=82=B4=EB=B3=B4=EB=82=B4=EA=B8=B0=20=C2=B7=20=EC=97=AC?= =?UTF-8?q?=EB=8D=9F=20=ED=8C=8C=EC=9D=BC=20=EC=97=94=EC=A7=84=20=EA=B8=88?= =?UTF-8?q?=EC=95=A1=20=3D=20=EC=97=91=EC=85=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 M02_San_Emit.py — 793-1 줄 나무 → 로직 JSON(중간 · 호표 값묶음 · 덧줄 · 번지 · 단계 · 합밖 · 자리 · 끝전 · 조정 · 글줄 · 가져옴 검산 · 계약) - 자원 이음 열쇠(품명 · 규격 · 단위 · 경쟁사 코드 · 1차 줄 판정 M01 키) · reprice 단가 전파 - STmate 누계 % 밑 = 그 줄 앞까지(뒤 호표 빼고 · 안 빠진 제외 단계 더함) · 율 범위 C = 앞 줄 부름 합 - 지킴 시험 test_793_2 — STmate 233 · EST 222 블록 엔진 셈 = 엑셀 = 참조 평가기 · 줄 4,218 · 단계 695 · 전파 견본 7 Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_San_Emit.py | 545 ++++++++++++++++++++++++ resources/tester/test_793_2_san_emit.py | 249 +++++++++++ 2 files changed, 794 insertions(+) create mode 100644 M02_MasterTemplete/M02_San_Emit.py create mode 100644 resources/tester/test_793_2_san_emit.py diff --git a/M02_MasterTemplete/M02_San_Emit.py b/M02_MasterTemplete/M02_San_Emit.py new file mode 100644 index 000000000..302dc9bea --- /dev/null +++ b/M02_MasterTemplete/M02_San_Emit.py @@ -0,0 +1,545 @@ +"""경쟁사 산근 → 우리 엔진 로직 줄 꼴 내보내기(PLAN 793-2a · 요구서_652 · eval 없음). + +793-1 줄 나무(`M02_San_Stmate` · `M02_San_Est`) → 로직 JSON 한 개(`_틀.md` 7 장 GX 꼴 · +셈 = `master_formula.run` → `master_cut.engine`). + +내보낸 로직 꼴: +- 키 · 이름 · 결과단위 · 입력 [] · 끝전 · 조정 · 단계 [{이름, 단, 줄, 제외?, 원문, 검산}] +- 중간 [{이름, 식, 표시?, 원문}] — STmate = 식(같은 이름 다시 대입 = `이름_2`) · EST = 얼린 값 +- 호표 [{종류 로직, 요소 None, 이름, 단위, 수량, 값묶음, 자원, 번지?, 합밖?, 자리?, 원문, 검산}] + · 값묶음 = 「엑셀 단가 그대로」 · 자원 = 이음 열쇠 {꼴, 갈래, 코드?, 이름, 규격, 단위, + 단가 {L, M, S}, 비목?, 옮김?, M01?} + (M01 = 1차 줄 판정 `M02_Hopyo_Row.m01_picks` 유일 짝 · 나중 [우리 단가로]) +- 덧줄 [{이름, 식, 비목, 번지?, 합밖?, 자리?, 원문, 검산}] — 율 · 특수 % · 앞줄 참조 · 값 줄 +- 글줄 [{자리, 글, 행?, 표지?}] — 셈 없는 원본 글(자리 = 그 앞 줄 이름 · `mid:이름` · "" = 맨 위) +- 가져옴 {꼴, 코드, 검산 {노무비, 재료비, 경비, 계}, 계약?, 표지 [..]} +검산 = 엑셀 값(줄마다 · 블록) · 표지 = 못 옮긴 까닭(0 이 목표). +EST 는 블록 성분을 낸 줄만 합에 듦(나머지 줄 = 합밖) · +STmate 계약단가(낙찰률)는 B09 몫 — 가져옴.계약. +""" + +from __future__ import annotations + +import re +from decimal import Decimal +from typing import Any + +from M02_MasterTemplete import M02_San_Eval as ev +from M02_MasterTemplete import M02_San_Expr as ex + +COMP = {"L": "노무비", "M": "재료비", "S": "경비"} +_CODE = {v: k for k, v in COMP.items()} +_SUM = "@합{}@" # 누계 % 밑 자리(블록 끝에 `finish` 가 채움) +KIND = {"L": "인력", "M": "재료", "X": "기계", "D": "단가산출", "B": "단가산출"} +KIND_EST = {"노임": "인력", "자재": "재료", "기계": "기계", "견적": "고정", "산근": "단가산출"} +RESERVED = {"노무비", "재료비", "경비", "계", "번지", "단계", "줄", "찾기", "로직", "그리고"} +RESERVED |= {"또는", "만약", "올림", "버림", "반올림", "최소", "최대"} +_KEY = re.compile(r"[A-Z]{2}\d{6}") +_LABEL = re.compile(r"'([^']*)'") + + +class EmitError(Exception): + """엔진 식으로 못 옮김 — 줄 표지.""" + + +# ── 이름 · 수 ────────────────────────────────────────────────────────── +def num(v: float) -> str: + """수 → 엔진 수 글(지수 없음 · 정수는 소수점 없이).""" + if float(v).is_integer(): + return str(int(v)) + return format(Decimal(repr(float(v))), "f") + + +class Names: + """블록 안 이름 — 줄 · 중간은 한 벌(덧줄 · 중간 = 식 이름 꼴) · 단계는 따로.""" + + def __init__(self) -> None: + self.used: set[str] = set(RESERVED) + self.steps: set[str] = set() + + def line(self, base: str) -> str: + """식 이름 꼴(덧줄 · 중간 · 호표 모두 — `줄.'이름'` · 단계 줄 목록).""" + s = re.sub(r"[^\w가-힣]", "", base or "") or "줄" + if s[0].isdigit() or _KEY.fullmatch(s): + s = "줄" + s + out, n = s, 1 + while out in self.used: + n += 1 + out = f"{s}_{n}" + self.used.add(out) + return out + + def item(self, base: str) -> str: + """호표 이름(읽는 글 그대로 · 작은따옴표만 뺌 · `줄.'이름'` 으로 부름).""" + s = (base or "").replace("'", "").strip() or "줄" + out, n = s, 1 + while out in self.used: + n += 1 + out = f"{s} {n}" + self.used.add(out) + return out + + def step(self, base: str) -> str: + s = (base or "").replace("'", "").strip() or "계" + out, n = s, 1 + while out in self.steps: + n += 1 + out = f"{s} {n}" + self.steps.add(out) + return out + + +# ── 식 나무 → 엔진 글 ────────────────────────────────────────────────── +def text(node: tuple, names: dict[str, str], swap: dict | None = None, kept: dict | None = None): + """`M02_San_Expr` 나무 → 엔진 식 글 · names = {원 이름: 엔진 이름} · swap = {수 토큰 차례: 글} · + kept = {id(끼움): 엔진 이름}.""" + kind = node[0] + if kind == "오류": + raise EmitError(node[1]) + if kind == "수": + return swap[node[2]] if swap and node[2] in swap else num(node[1]) + if kind == "이름": + if node[1] not in names: + raise EmitError(f"미정의 변수 {node[1]}") + return names[node[1]] + if kind == "참조": + return "1" + if kind == "끼움": + if kept and id(node) in kept: + return kept[id(node)] + return "(" + text(node[2], names, swap, kept) + ")" + if kind == "음": + return "-(" + text(node[1], names, swap, kept) + ")" + if kind == "함수": + args = [text(a, names, swap, kept) for a in node[2]] + if node[1] == "int": + return f"버림({args[0]}, 0)" + return f"반올림({args[0]}, {args[1] if len(args) > 1 else 0})" + a, b = text(node[2], names, swap, kept), text(node[3], names, swap, kept) + if node[1] == "^": + k = node[3][1] if node[3][0] == "수" else None + if k is None or not float(k).is_integer() or not 0 <= k <= 12: + raise EmitError("거듭제곱 — 엔진에 없음") + return "(" + " * ".join([f"({a})"] * int(k)) + ")" if k else "1" + if node[1] == "*" and "1" in (a, b): # 단가 자리(1) 곱은 뺌 + return b if a == "1" else a + return f"({a} {node[1]} {b})" + + +def _place(rule: str | None) -> dict | None: + """줄 자리 규칙 이름 → 엔진 `자리`(버림1 = 엔진 기본 = 칸 없음).""" + if not rule or rule == "버림1": + return None + m = re.fullmatch(r"(버림|반올림|올림)(\d)", rule) + if not m: + raise EmitError(f"줄 자리 「{rule}」 — 엔진에 없음") + return {"방법": m.group(1), "자리": int(m.group(2))} + + +def bundle(src: dict) -> dict[str, Decimal]: + """자원 이음 열쇠 → 값묶음 — 비목 하나(옮김이면 그 비목으로) · 없으면 셋.""" + price, one, to = src["단가"], src.get("비목"), src.get("옮김") + out = dict.fromkeys(COMP.values(), Decimal(0)) + if one: + out[COMP[to or one]] = Decimal(str(price.get(one) or 0)) + else: + for c, name in COMP.items(): + out[name] = Decimal(str(price.get(c) or 0)) + return out + + +def reprice(logic: dict, where: dict, price: dict[str, float]) -> int: + """단가 바꿈 — 자원이 where 칸과 같은 호표 줄 단가 {L, M, S} 고침 · 값묶음 다시 · 고친 줄 수.""" + n = 0 + for item in logic.get("호표", []): + src = item.get("자원") or {} + if all(src.get(k) == v for k, v in where.items()): + src["단가"] = {**src["단가"], **price} + item["값묶음"] = bundle(src) + n += 1 + return n + + +def _m01(kind: str, src: dict) -> str | None: + """1차 줄 판정 유일 짝 M01 키 — 없거나 여럿 = None.""" + if kind not in ("인력", "재료", "기계"): + return None + from M02_MasterTemplete import M02_Hopyo_Row as hrow + + got = hrow.m01_picks(kind, {k: src.get(k) or "" for k in ("이름", "규격", "단위")}) + keys = {x.get("키") for x in got if x.get("키")} + return keys.pop() if len(keys) == 1 else None + + +# ── 내보낼 로직 틀 ───────────────────────────────────────────────────── +class Out: + def __init__(self, key: str, name: str, unit: str, form: str, code: Any) -> None: + self.names = Names() + self.mid: list[dict] = [] + self.items: list[dict] = [] + self.adds: list[dict] = [] + self.steps: list[dict] = [] + self.texts: list[dict] = [] + self.marks: list[str] = [] + self.step_src: dict[str, dict] = {} # 단계 이름 → {원문, 검산}(엔진 단계 칸 밖) + self.last = "" # 글줄 자리 + self.row = {"키": key, "이름": name or key, "결과단위": f"원/{unit or '식'}", "입력": []} + self.head = {"꼴": form, "코드": code} + + def text_line(self, glyph: str, at: Any = None, mark: str | None = None) -> None: + where = "행" if self.head["꼴"] == "STmate" else "차례" + one = {"자리": self.last, "글": glyph} | ({where: at} if at is not None else {}) + self.texts.append(one | ({"표지": mark} if mark else {})) + if mark: + self.marks.append(f"{at}: {mark}") + + def middle(self, name: str, formula: str, show: int | None, source: dict) -> None: + self.mid.append({"이름": name, "식": formula, **({"표시": show} if show else {})}) + self.mid[-1]["원문"] = source + self.last = f"mid:{name}" + + def add_line(self, one: dict, item: bool) -> dict: + (self.items if item else self.adds).append(one) + self.last = one["이름"] + return one + + def done(self, end: str, adjust: dict | None, check: dict, **more: Any) -> dict: + out = {**self.row, "중간": self.mid, "호표": self.items, "덧줄": self.adds} + if self.steps: + out["단계"] = self.steps + out |= {"끝전": end, **({"조정": adjust} if adjust else {})} + out |= {"글줄": self.texts, "가져옴": {**self.head, "검산": check, "표지": self.marks}} + if self.step_src: + out["가져옴"]["단계"] = self.step_src + out["가져옴"] |= more + return out + + +def _money(d: dict | None) -> dict[str, Any]: + d = d or {} + return {COMP[c]: d.get(c) or 0 for c in COMP} + + +# ── STmate ───────────────────────────────────────────────────────────── +def _label(node: dict) -> str: + """원문 첫 글 조각 「'합판(내수) :'」 → 「합판(내수)」.""" + m = _LABEL.search(node.get("글") or "") + return m.group(1).split(":")[0].strip() if m else "" + + +class _Stm: + """STmate 블록 한 벌 옮김 — 변수 이름 짝 · 그림자 셈(엑셀 셈 실패 줄 가름) · 계 단계.""" + + def __init__(self, o: Out, block: dict, prices: dict, keys: bool) -> None: + self.o, self.prices, self.keys = o, prices, keys + self.rows = {ln["행"]: ln for ln in block["몸"]} + self.V: dict[str, float] = {} # 그림자 셈(엑셀 셈과 같은 값 · 실패 자리 가름) + self.names: dict[str, str] = {} # STmate 변수 → 엔진 중간 이름 + self.pending: list[str] = [] # 마지막 계 뒤 합에 드는 줄 + self.calls: dict[str, tuple[str, set]] = {} # 줄 이름 → (엔진 부름 글, 값 든 비목) + self.tsum: dict[str, str] = {} # 누계번지 → 단계 이름 + self.addr: set[str] = set() + self.last_step: str | None = None + self.order: list[str] = [] # 합에 드는 줄(원문 차례) + self.open: list[tuple[int, list[str]]] = [] # 위 단계가 아직 안 담은 단계(엔진 `_cover`) + self.out: dict[str, int] = {} # 제외 단계 줄 → 그 계 줄까지 줄 수(합에서 빠지는 때) + self.fix: list[tuple[dict, int]] = [] # 누계 % 덧줄 · 그때 줄 수 + + def xl(self, at: Any) -> dict: + ln = self.rows.get(at) or {} + return {c: ln.get(k) or 0 for c, k in (("L", "D"), ("M", "E"), ("S", "F"))} + + def brackets(self, node: tuple, src: dict, kept: dict) -> None: + """끼움 `[Q=0.208]` → 중간(왼쪽부터 · 원문 차례).""" + kind = node[0] + if kind == "끼움": + self.brackets(node[2], src, kept) + if node[1]: + body = text(node[2], self.names, kept=kept) + eng = self.o.names.line(node[1].lower()) + self.names[node[1].lower()] = eng + kept[id(node)] = eng + self.o.middle(eng, body, None, src) + elif kind == "셈": + self.brackets(node[2], src, kept) + self.brackets(node[3], src, kept) + elif kind == "음": + self.brackets(node[1], src, kept) + elif kind == "함수": + for a in node[2]: + self.brackets(a, src, kept) + + def coef(self, node: tuple, src: dict) -> str: + kept: dict = {} + self.brackets(node, src, kept) + return text(node, self.names, kept=kept) + + def shadow(self, node: tuple) -> float: + trial = dict(self.V) + v = ex.value(node, {"V": trial, "R": 1.0}) + self.V = trial + return v + + def assign(self, node: dict, src: dict) -> None: + for part in node["대입"]: + try: + v = self.shadow(part["식"]) + body = self.coef(part["식"], src) + except (ex.ExprError, EmitError) as error: + if part["이름"] is None: + continue + self.o.text_line(node["글"], src["행"], f"셈 못 함 — {error}") + return + if part["이름"] is None: + continue + self.V[part["이름"]] = ex.rnd(v, 2) if part["찍기"] else v + eng = self.o.names.line(part["이름"]) + self.names[part["이름"]] = eng + self.o.middle(eng, body, 2 if part["찍기"] else None, src) + + def total(self, node: dict, src: dict) -> None: + o = self.o + name = o.names.step(str(self.rows.get(src["행"], {}).get("B") or "계")) + step = {"이름": name, "단": node["단"], "줄": self.pending} + step |= {"제외": True} if node["제외"] else {} + mine, keep = list(self.pending), [] + for low, got in self.open: + (mine.extend(got) if low < node["단"] else keep.append((low, got))) + self.open = keep if node["제외"] else [*keep, (node["단"], mine)] + for n in mine if node["제외"] else []: + self.out[n] = len(self.order) + o.step_src[name] = {"원문": src, "검산": _money(self.xl(src["행"]))} + o.steps.append(step) + self.pending = [] + if node["누계번지"]: + self.tsum[node["누계번지"]] = name + self.last_step = name + o.last = f"단계:{name}" + + def line(self, one: dict, node: dict, item: bool) -> None: + if node.get("번지") and node["번지"].startswith("A"): + one["번지"] = node["번지"] + self.addr.add(node["번지"]) + money = one.get("값묶음") or {} + comps = {c for c in COMP if money.get(COMP[c])} if item else {_CODE[one["비목"]]} + self.calls[one["이름"]] = (f"줄.'{one['이름']}'" if item else one["이름"], comps) + if node.get("빼기"): + one["합밖"] = True + else: + self.pending.append(one["이름"]) + self.order.append(one["이름"]) + self.o.add_line(one, item) + + def ref(self, node: dict, src: dict, cf: str) -> None: + o, code = self.o, node["자원"]["코드"] + pr = self.prices[code] + unt = (pr.get("meta") or {}).get("UNT") + one_, to = node["자원"]["비목"], node["자원"]["옮김"] + name = _label(node) or str(pr.get("이름") or code) + xl = self.xl(src["행"]) + if unt in ev.SPECIAL: # 특수 % — 처음부터 비목 누계 · 앞 계표시 + dst = to or {"L": "L", "M": "M", "S": "S"}.get(code[0]) + if unt == "T%": + last = self.last_step + base = f"단계.'{last}'.계" if last else f"({' + '.join(map(_SUM.format, COMP))})" + formula = f"{base} * ({cf})" + else: + formula = f"{_SUM.format(unt[0])} * ({cf}) / 100" + one = {"이름": o.names.line(name), "식": formula, "비목": COMP[dst]} + one |= {"원문": src, "검산": {COMP[dst]: xl[dst]}} + self.fix.append((one, len(self.order))) + return self.line(one, node, False) + res = {"꼴": "STmate", "갈래": KIND.get(code[0], "고정"), "코드": code} + res |= {k: pr.get(k) for k in ("이름", "규격", "단위")} + res |= {"단가": {c: pr.get(c) or 0 for c in COMP}} + res |= {"비목": one_, "옮김": to} if one_ else {} + if self.keys and (m01 := _m01(res["갈래"], res)): + res["M01"] = m01 + one = {"종류": "로직", "요소": None, "이름": o.names.item(name)} + one |= {"단위": pr.get("단위") or "", "수량": cf, "값묶음": bundle(res), "자원": res} + self.line(one | {"원문": src, "검산": _money(xl)}, node, True) + + def rate(self, node: dict, src: dict, cf: str) -> None: + rate, o = node["율"], self.o + area = rate["범위"] + + def base(c: str) -> str: + if area == "A": + got = [f"번지.{s}.{COMP[c]}" for s in rate["번지"] if s in self.addr] + return " + ".join(got) or "0" + if area == "T": + step = self.tsum.get(rate["번지"][0]) + return f"단계.'{step}'.{COMP[c]}" if step else "0" + if area == "C": # 마지막 계 뒤 줄 — 비목 하나인 줄만 부름으로 더함 + got = [] + for n in self.pending: + call, comps = self.calls[n] + if len(comps) > 1: + raise EmitError("율 범위 C 에 비목 여럿인 줄 — 엔진 꼴 없음") + got += [call] if comps == {c} else [] + return " + ".join(got) or "0" + return _SUM.format(c) + + pct = f"({cf})" if rate["곱"] else f"({cf}) / 100" + src_, dst = rate["비목"], rate["옮김"] + if src_: + parts = [(dst or src_, base(src_))] + elif dst: + parts = [(dst, " + ".join(f"({base(c)})" for c in COMP))] + else: + parts = [(c, base(c)) for c in COMP] + label = _label(node) or "율" + xl = self.xl(src["행"]) + for c, b in parts: + nm = o.names.line(label if len(parts) == 1 else f"{label}_{COMP[c]}") + one = {"이름": nm, "식": f"({b}) * {pct}", "비목": COMP[c]} + one |= {"원문": src, "검산": {COMP[c]: xl[c]}} + if "@합" in b: + self.fix.append((one, len(self.order))) + self.line(one, node, False) + + def finish(self) -> None: + """누계 % 밑 = 그 줄 앞까지 합 — 엔진 비목 합(호표 다 · 앞 덧줄 · 제외 단계 뺌)에서 + 뒤에 오는 호표를 빼고 아직 안 빠진 제외 단계 줄을 더함.""" + for one, pos in self.fix: + for c, name in COMP.items(): + mark = _SUM.format(c) + if mark not in one["식"]: + continue + minus = [n for n in self.order[pos:] if n not in self.out] + plus = [n for n in self.order[:pos] if self.out.get(n, -1) >= pos] + terms = [] + for sign, group in (("-", minus), ("+", plus)): + for n in group: + call, comps = self.calls[n] + if c not in comps or (sign == "-" and not call.startswith("줄.")): + continue + if len(comps) > 1: + raise EmitError(f"누계 % 밑에 비목 여럿인 줄 「{n}」 — 엔진 꼴 없음") + terms.append(f" {sign} {call}") + one["식"] = one["식"].replace(mark, f"({name}{''.join(terms)})" if terms else name) + + +def _cols(row: dict) -> dict[str, Any]: + return _money({"L": row["D"], "M": row["E"], "S": row["F"]}) | {"계": row["C"] or 0} + + +def stmate(block: dict, nodes: list[dict], prices: dict, rule: str, key: str, keys: bool = False): + """STmate 블록(`M02_San_Book.stmate_blocks` 한 개) + 줄 나무 + 같은 책 단가 → 로직 JSON. + rule = 블록 끝전(비목버림 | 합버림_큰나머지 · 파일 설정) · keys = 자원 M01 키 찾기.""" + me = prices.get(block["코드"]) or {} + meta = me.get("meta") or {} + o = Out(key, block.get("이름"), me.get("단위"), "STmate", block["코드"]) + st = _Stm(o, block, prices, keys) + for node in nodes: + kind, src = node["종류"], {"행": node.get("행"), "글": node.get("글") or ""} + if kind in ("빈", "글"): + if src["글"].strip(): + o.text_line(src["글"], src["행"]) + elif kind == "대입": + st.assign(node, src) + elif kind == "계": + st.total(node, src) + else: + try: + if node.get("오류"): + raise EmitError(node["오류"]) + if kind == "참조" and node["자원"]["코드"] not in prices: + raise EmitError(f"단가 없음 {node['자원']['코드']}") + st.shadow(node["식"]) + cf = st.coef(node["식"], src) + (st.rate if kind == "율" else st.ref)(node, src, cf) + except (ex.ExprError, EmitError) as error: + o.text_line(src["글"], src["행"], f"셈 못 함 — {error}") + try: + st.finish() + except EmitError as error: + o.marks.append(str(error)) + tails = [t for t in block["꼬리"] if t["A"].startswith("계") and not t["A"].startswith("계약")] + kye = (tails or [block["머리"]])[0] + adjust, sbm = None, (meta.get("SBM") or "").strip() + if meta.get("ADJ") == "T" and sbm in COMP: + adjust = {"옮김": COMP[sbm]} + more = {} + if meta.get("UC") == "T" and meta.get("NAG"): # 계약단가 = B09 몫(엔진 밖) + more["계약"] = {"낙찰률": float(meta["NAG"]), "금액": _cols(block["머리"])} + end = "큰나머지" if rule.startswith("합버림") else "비목버림" + return o.done(end, adjust, _cols(kye), **more) + + +# ── EST ──────────────────────────────────────────────────────────────── +_VAR = re.compile(r"([A-Za-z][A-Za-z0-9_]*)\s*(?:\([^)]*\))?\s*=") + + +def _est_res(role: dict, value: float, comp: str | None, keys: bool) -> dict: + """EST 단가 토큰 정체 → 자원 이음 열쇠(옮김 = 성분 비목이 단가 비목과 다를 때).""" + p = role["정체"] + pc = p["비목"] if p["비목"] in COMP else (comp or "S") + res = {"꼴": "EST", "갈래": KIND_EST.get(p["종류"], "고정"), "종류": p["종류"]} + res |= {"이름": p["이름"], "규격": p["규격"], "단위": None} + res |= {"단가": {pc: value}} + res |= {"비목": pc, "옮김": comp if comp and comp != pc else None, "열쇠비목": p["비목"]} + if role.get("모호"): + res["모호"] = True + if keys and (m01 := _m01(res["갈래"], res)): + res["M01"] = m01 + return res + + +def est(block: dict, lines: list[dict], want: dict, key: str, keys: bool = False) -> dict: + """EST 블록(`M02_San_Book.est_blocks` 한 개) + 복원 줄(`M02_San_Est.parse_block`) + + 총괄표 성분 want {T, L, M, S} → 로직 JSON · 성분을 낸 줄만 합(나머지 = 합밖).""" + des = ev.designate(lines, want) + mine = {k: c for c, k in des.items() if isinstance(k, int)} + o = Out(key, block.get("이름"), block.get("단위"), "EST", block.get("번")) + for c, k in des.items(): + if k is None: + o.marks.append(f"성분 {COMP[c]} {want[c]} 을 낸 줄 없음") + val = ev.run_est(lines) + ref: dict[int, str] = {} # 차례 → 엔진 부름 글(앞줄 · 숨은 합) + for L in lines: + k, form, kind = L["차례"], L["꼴"], L.get("종류") + src = {"차례": k, "글": L["원글"]} + comp = mine.get(k) + if form == "빈": + continue + if form == "변수" and L.get("값") is not None: + m = _VAR.search(L["원글"]) + eng = o.names.line(m.group(1) if m else "변수") + o.middle(eng, num(L["값"]), None, src) # 얼린 값(금액엔 보인 값이 박힘) + continue + if form not in ("돈", "값"): + o.text_line(L["원글"], k) + continue + head = {"원문": src, "검산": {COMP[comp or L.get("비목") or "S"]: L["값"]}} + head |= {} if comp else {"합밖": True} + try: + place = _place(L.get("규칙")) + if k not in val: + raise EmitError("보인 값이 되는 식 · 자리 못 찾음 — 보인 값으로 얼림") + if kind == "단가줄": + at, role = next((i, r) for i, r in L["역할"].items() if r["갈래"] == "단가") + price = next(n[1] for n in ex.numbers(L["식"]) if n[2] == at) + res = _est_res(role, price, comp, keys) + one = {"종류": "로직", "요소": None, "이름": o.names.item(L.get("라벨") or "")} + one |= {"단위": "", "수량": text(L["식"], {}, {at: "1"}), "값묶음": bundle(res)} + one |= {"자원": res, **head, **({"자리": place} if place else {})} + o.add_line(one, True) + ref[k] = f"줄.'{one['이름']}'" + continue + if kind == "값줄": + parts = [ref.get(j) or num(lines[j]["값"]) for j in L["숨은"]["줄"]] + formula = " + ".join(parts) + else: + swap = {i: ref[r["줄"]] for i, r in L["역할"].items() if r.get("줄") in ref} + formula = text(L["식"], {}, swap) + except EmitError as error: + o.marks.append(f"{k}: {error}") + formula, place = num(L["값"]), None + name = o.names.line(L.get("라벨") or "줄") + one = {"이름": name, "식": formula, "비목": COMP[comp or L.get("비목") or "S"]} + o.add_line(one | head | ({"자리": place} if place else {}), False) + if k in val: + ref[k] = name + check = _money(want) | {"계": want["T"]} + return o.done("비목버림", None, check) diff --git a/resources/tester/test_793_2_san_emit.py b/resources/tester/test_793_2_san_emit.py new file mode 100644 index 000000000..a4b46ec45 --- /dev/null +++ b/resources/tester/test_793_2_san_emit.py @@ -0,0 +1,249 @@ +"""793-2a 지킴 — 경쟁사 산근 → 우리 엔진 로직 줄 꼴(`M02_San_Emit`) · 엔진 셈 = 엑셀. + +여덟 파일 STmate 233 · EST 222 블록을 로직 JSON 으로 내보내 `master_formula.run` → +`master_cut.engine` 으로 셈 — 노 · 재 · 경 · 계 = 엑셀(STmate 계 꼬리 · EST 총괄표) = +참조 평가기(`M02_San_Eval`) · 줄마다(STmate 금액 줄 · EST 돈 · 값 줄) · 계표시(단계) 같음. +단가 전파 견본 = `_분석_649-G2` 4 장(보통인부 → 180,000) · 계약단가(낙찰률)는 B09 몫 — 엔진 계 × +낙찰률 원 버림 = 엑셀 머리만 확인. 엑셀 = `helper_m02_hopyo_golden.FILES`(읽기만 · 없으면 skip). +""" + +from __future__ import annotations + +import importlib +import math +from functools import lru_cache + +import helper_m02_hopyo_golden as golden +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 # noqa: F401 — scripts 길을 둠 +from M02_MasterTemplete import M02_San_Book as book +from M02_MasterTemplete import M02_San_Emit as em +from M02_MasterTemplete import M02_San_Est as est +from M02_MasterTemplete import M02_San_Eval as ev +from M02_MasterTemplete import M02_San_Stmate as stm + +mf = importlib.import_module("master_formula") +mcut = importlib.import_module("master_cut") +ms = importlib.import_module("master_formula_sum") + +STMATE = { + "봉화": (62, "비목버림"), + "동부청영월": (69, "합버림_큰나머지"), + "울진대흥": (47, "비목버림"), + "영덕계류": (15, "비목버림"), + "울진소광": (40, "비목버림"), +} +EST = {"북부청임도": 94, "북부청사방": 67, "거창": 61} +# 엑셀 글 식이 보인 값과 안 맞는 줄(793-1 틀린 줄 2) — 보인 값으로 얼림 · 블록 금액은 같음 +EST_FROZEN = {"북부청임도": {27: ["37"], 33: ["51"]}} +K = ("노무비", "재료비", "경비", "계") +LABOR = {"종류": "노임", "이름": "보통인부", "규격": "", "열쇠비목": "L"} + + +def engine(logic: dict) -> dict: + master = mf.Master({"로직.json": {"그룹": "로직", "줄": [logic]}}) + return mcut.engine(master, logic["키"], mf.run(master, logic["키"], {})) + + +def money(got: dict) -> tuple: + return tuple(int(got[k]) for k in K) + + +@lru_cache(maxsize=8) +def _load(tag: str) -> dict: + path = golden.FILES[tag] + if not path.exists(): + pytest.skip(f"실무 엑셀 없음 {path.name}") + return book.load(path) + + +def _stmate(tag: str, blk: dict) -> tuple[list, dict]: + nodes = stm.parse_block(blk["몸"]) + return nodes, em.stmate(blk, nodes, _load(tag)["단가"], STMATE[tag][1], "GX990001") + + +def _lines_equal(logic: dict, got: dict) -> list: + """줄마다 엔진 비목 금액 = 엑셀 · 단계 = 계표시 줄.""" + bad = [] + for item, line in zip([*logic["호표"], *logic["덧줄"]], got["줄"], strict=True): + for k, want in item["검산"].items(): + if abs(float(line["비목"].get(k) or 0) - want) >= 0.05: + bad.append((item["이름"], k, line["비목"].get(k), want)) + for step in got.get("단계", []): + want = logic["가져옴"]["단계"][step["이름"]]["검산"] + if any(abs(float(step[k]) - want[k]) >= 0.15 for k in want): + bad.append((step["이름"], want)) + return bad + + +@pytest.mark.parametrize("tag", list(STMATE)) +def test_stmate_engine_equals_excel(tag: str) -> None: + data = _load(tag) + n, wrong, lines, steps = 0, [], 0, 0 + for blk in data["블록"]: + if not any(ln["G"].strip() for ln in blk["몸"]): + continue + n += 1 + nodes, logic = _stmate(tag, blk) + got = engine(logic) + ref = ev.stmate_block(blk, nodes, data["단가"], STMATE[tag][1])["계"] + want = logic["가져옴"]["검산"] + same = money(got) == tuple(round(want[k]) for k in K) + same &= money(got) == (ref["L"], ref["M"], ref["S"], ref["T"]) + bad = _lines_equal(logic, got) + ms.check(logic, logic["키"]) + logic["가져옴"]["표지"] + if deal := logic["가져옴"].get("계약"): # B09 몫 — 엔진 계 × 낙찰률 원 버림 = 엑셀 머리 + cut = [math.floor(round(int(got[k]) * deal["낙찰률"] / 100, 6)) for k in K[:3]] + same &= cut == [deal["금액"][k] for k in K[:3]] + if not same or bad: + wrong.append((blk["코드"], money(got), bad[:3])) + lines += len(logic["호표"]) + len(logic["덧줄"]) + steps += len(got.get("단계", [])) + assert (n, wrong) == (STMATE[tag][0], []) + assert lines >= 1 and steps >= 1 + + +@pytest.mark.parametrize("tag", list(EST)) +def test_est_engine_equals_excel(tag: str) -> None: + data = _load(tag) + n, wrong = 0, [] + for blk in data["블록"]: + want = data["총괄"].get(blk["번"]) + if not want or not (want["T"] or any(want[c] for c in "LMS")): + continue + n += 1 + parsed = est.parse_block(blk["글"], data["단가"]) + logic = em.est(blk, parsed, want, "GX990002") + got = engine(logic) + ref = ev.est_block(parsed, ev.designate(parsed, want))["금액"] + same = money(got) == (want["L"], want["M"], want["S"], want["T"]) + same &= money(got) == (ref["L"], ref["M"], ref["S"], ref["T"]) + frozen = [m.split(":")[0] for m in logic["가져옴"]["표지"]] + bad = _lines_equal(logic, got) + ms.check(logic, logic["키"]) + if not same or bad or frozen != EST_FROZEN.get(tag, {}).get(blk["번"], []): + wrong.append((blk["번"], money(got), bad[:3], frozen)) + assert (n, wrong) == (EST[tag], []) + + +@pytest.mark.parametrize( + "tag, code, labor, before, after", + [ + ("봉화", "D00102", "L00007", (34119, 11192, 341), (34697, 11192, 346)), + ("동부청영월", "D00003", "L00002", (4764, 310, 397), (5097, 309, 397)), + ("울진소광", "D01489", "L00016", (182223, 230359, 8152), (185067, 230359, 8209)), + ], +) +def test_stmate_price_propagates(tag, code, labor, before, after) -> None: + blk = next(b for b in _load(tag)["블록"] if b["코드"] == code) + _, logic = _stmate(tag, blk) + assert money(engine(logic))[:3] == before + assert em.reprice(logic, {"코드": labor}, {"L": 180000}) >= 1 + assert money(engine(logic))[:3] == after + + +@pytest.mark.parametrize( + "tag, no, after", + [ + ("북부청사방", 31, (14877, 5491, 0)), + ("북부청임도", 2, (22774, 3235, 3830)), + ("거창", 1, (3485, 832, 1049)), + ("북부청사방", 3, (20147, 391, 0)), + ], +) +def test_est_price_propagates(tag, no, after) -> None: + data = _load(tag) + blk = next(b for b in data["블록"] if b["번"] == no) + logic = em.est(blk, est.parse_block(blk["글"], data["단가"]), data["총괄"][no], "GX990002") + assert em.reprice(logic, LABOR, {"L": 180000.0}) >= 1 + assert money(engine(logic))[:3] == after + + +def test_link_keys_m01() -> None: + """자원 이음 열쇠 — 엑셀 품명 · 규격 · 단위 · 경쟁사 코드 · 1차 줄 판정 M01 키.""" + tag = "봉화" + blk = next(b for b in _load(tag)["블록"] if b["코드"] == "D00102") + logic = em.stmate( + blk, stm.parse_block(blk["몸"]), _load(tag)["단가"], "비목버림", "GX990001", True + ) + labor = next(x for x in logic["호표"] if x["이름"] == "보통인부") + assert labor["자원"] | {"M01": labor["자원"].get("M01")} == { + "꼴": "STmate", + "갈래": "인력", + "코드": "L00007", + "이름": "보통인부", + "규격": None, + "단위": "인", + "단가": {"L": 165545, "M": 0, "S": 0}, + "M01": "LB000002", + } + assert labor["값묶음"] == {"노무비": 165545, "재료비": 0, "경비": 0} + + +# ── 엑셀 없이 도는 문법 견본(793-1 `test_stmate_grammar_sample` 그대로) ── +def test_stmate_grammar_sample_engine() -> None: + prices = { + "M00081": {"T": 11468, "L": 0, "M": 11468, "S": 0, "meta": {}, "이름": "합판(내수)"}, + "L00007": {"T": 165545, "L": 165545, "M": 0, "S": 0, "meta": {}, "이름": "보통인부"}, + "L00099": {"T": 2, "L": 2, "M": 0, "S": 0, "meta": {"UNT": "L%"}, "이름": "공구"}, + } + text = [ + "'합판(내수) :'&M00081& * 1.03 * 0.327 :=A01", + ";;Q = 2.5 / 3 =", + "'보통인부' &L00007& * [Q] / 4 :=A02", + "&PA01M:M& * 11'%' =", + "&PA02L:S& * 1'%' = // 공구손료", + "(=)", + "&L00099& * 2 =", + "(-=)", + "'별도' &M00081& * 2 =", + "Q = 1 / 3 =", + "&L00099& * [Q] =", + "'뒤 인부' &L00007& * 0.01 =", + ] + rows = [{"행": i, "G": g} for i, g in enumerate(text)] + nodes = stm.parse_block(rows) + block = {"코드": "D00001", "이름": "견본", "머리": {}, "꼬리": [], "몸": rows} + block["머리"] = {"A": "", "B": "", "C": 0, "D": 0, "E": 0, "F": 0} + logic = em.stmate(block, nodes, prices, "비목버림", "GX990003") + assert [x["이름"] for x in logic["중간"]] == ["q", "q_2"] # 다시 대입 = 새 이름 + assert logic["중간"][0] == { + "이름": "q", + "식": "(2.5 / 3)", + "표시": 2, + "원문": {"행": 1, "글": text[1]}, + } + assert ms.check(logic, "견본") == [] and logic["가져옴"]["표지"] == [] + got = engine(logic) + lines, tot = ev.run_stmate(nodes, prices) + want = ev.block_total(tot, "비목버림") + assert money(got) == (want["L"], want["M"], want["S"], want["T"]) + by = {x["이름"]: x for x in got["줄"]} + assert by["보통인부"]["비목"]["노무비"] == 34350.5 # 165,545 × 0.83 / 4 + assert by["공구"]["비목"]["노무비"] == 687 # 노무 누계 × 2 % · (-=) 로 합에서 뺌 + assert logic["덧줄"][-1]["식"] == "(노무비 - 줄.'뒤 인부') * ((q_2)) / 100" # 뒤 줄을 뺀 누계 + assert ( + float(by["공구_2"]["비목"]["노무비"]) == lines[10]["값"]["L"] == 113.3 + ) # 34,350.5 × 0.33 % + + +def test_est_grammar_sample_engine() -> None: + prices = [{"종류": "노임", "이름": "보통인부", "규격": "", "비목": "L", "값": 172068.0}] + text = [ + "보통인부 : 172,068 × 0.09 인 = 15,486.1", + "공구손료 : 15,486.1 × 3 % = 464.5", + "소 계 : 15,950.6", + "노무비 : 15,950.6 × 1 = 15,950", + ] + lines = est.parse_block(text, prices) + want = {"T": 15950.0, "L": 15950.0, "M": 0.0, "S": 0.0} + logic = em.est({"번": 1, "이름": "견본", "단위": "㎡"}, lines, want, "GX990004") + assert [x["식"] for x in logic["덧줄"]] == [ + "((줄.'보통인부' * 3) / 100)", + "줄.'보통인부' + 공구손료", + "소계", + ] + assert logic["덧줄"][2]["자리"] == {"방법": "버림", "자리": 0} + assert [x.get("합밖") for x in (*logic["호표"], *logic["덧줄"])] == [True, True, True, None] + assert money(engine(logic)) == (15950, 0, 0, 15950) + em.reprice(logic, LABOR, {"L": 180000.0}) + assert money(engine(logic)) == (16686, 0, 0, 16686) # 793-1 run_est 전파와 같음 From 09a67ce91f4b7cd61650cefe465a706fa97e288a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 5 Nov 2026 17:40:07 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(792):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EC=8B=9C=ED=8A=B8=20=EC=8B=9D=20=EC=B9=B8=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=E2=80=94=20=EC=A4=84=20=EC=8B=9D=20?= =?UTF-8?q?=EA=B3=A0=EC=B9=A8=20=C2=B7=20=EC=B9=A0=20=EB=95=8C=EB=A7=88?= =?UTF-8?q?=EB=8B=A4=20=EA=B2=80=EC=82=AC=20=C2=B7=20=EB=B9=A8=EA=B0=84=20?= =?UTF-8?q?=EC=B9=B8=20=EA=B9=8C=EB=8B=AD=20=C2=B7=20=EB=B3=80=EC=88=98=20?= =?UTF-8?q?=EC=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 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 --- .../M02_MasterTemplete_Sangeun_Detail.ts | 2 +- .../M02_MasterTemplete_Sangeun_Sheet.css | 16 + .../M02_MasterTemplete_Sangeun_Sheet.ts | 51 +++- .../M02_MasterTemplete_Sangeun_SheetEdit.ts | 5 +- ...M02_MasterTemplete_Sangeun_SheetFormula.ts | 287 ++++++++++++++++++ .../logic/M02_Sangeun_Sheet_Model.ts | 6 + resources/tester/test_790_1_sangeun_sheet.py | 36 +++ 7 files changed, 394 insertions(+), 9 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Sangeun_SheetFormula.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts index 1e5a92170..82a59a2e2 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts @@ -105,7 +105,7 @@ export function mountSangeunDetail( const editor = el("div", { className: "m01-logic__editor" }); const sheet = createSangeunSheet(editor, { ...{ edit, opened: () => opened(), touch: () => touch(), onInput: (n, v) => onInput(n, v) }, - ...{ addLine: () => addButton.click(), addRate: () => rateButton.click() }, // 790-1 시트 첫 탭 + ...{ addLine: () => addButton.click(), addRate: () => rateButton.click(), scope }, // 790-1 · 792 식 검사 층 }); const calcHost = el("div", { className: "m01lab__calc" }); const ownHost = el("div"); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Sheet.css b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Sheet.css index ef81928fd..cde103da8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Sheet.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Sheet.css @@ -124,3 +124,19 @@ color: var(--color-text-secondary); font-style: italic; } + +/* 792 식 칸 — 칸 + 검사 알림 한 줄 */ +.m02-sks__fx { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + min-width: 0; +} + +.m02-sks__fx .ui-field { + width: 100%; +} + +.m02-sks__fx .ui-text:empty { + display: none; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Sheet.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Sheet.ts index 2612038cf..be62c99fe 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Sheet.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Sheet.ts @@ -7,6 +7,7 @@ * → SK 문서(덮개 · 더한줄 · 뺀줄 = `M02_Sangeun_Cover_Model`) → 상세 `touch` = 서버 셈 한 번(최신 응답만) * 상태줄 = M01 원래 · 지금 · 고치기 전(화면 캐시) + 차이 · 자동저장 없음([저장] = 상세 그대로) * 790-3 숫자 넓힘(식 속 상수 · 표값 · 글 줄 · 까닭 · [M01 원래로] · M01 바뀜) = `_Sangeun_SheetEdit` + * 792 식 칸(줄 식 고침 · 칠 때마다 검사 · 변수 줄) = `_Sangeun_SheetFormula` * 부품 = `ui_template_calc_row` 시트 줄 · 입력 · 고르기 · 단추 · 배지 · 글 — 날 단추 · 입력 없음 * ========================================================================== */ @@ -32,8 +33,15 @@ import { removedReasons, restoreAll, spotTail, - type EditCtx, } from "./M02_MasterTemplete_Sangeun_SheetEdit"; +import { + addVarButton, + formulaToggle, + rowFormula, + varRows, + type FormulaCtx, +} from "./M02_MasterTemplete_Sangeun_SheetFormula"; +import type { Layer } from "./M02_MasterTemplete_Api_Fetch"; import { coverKinds, dropAddKeys, type ConstDoc } from "./logic/M02_Sangeun_Const_Model"; import { dropLine, @@ -117,6 +125,8 @@ export interface SheetOptions { onInput: (name: string, value: string) => void; addLine: () => void; addRate: () => void; + /** 792 식 검사 길 층 */ + scope: { layer: Layer; owner: string | null }; } type View = "sheet" | "ho" | "est" | "calc" | "formula"; @@ -132,7 +142,9 @@ 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 ctx: FormulaCtx | null = null; // 790-3 숫자 넓힘 · 792 식 칸 — 그릴 때마다 새로 + const open = new Set(); // 식 칸 연 자리 + const drafts = new Map(); // 치는 중 식 글 let focusNext = ""; // 새 글 줄 칸 — 다음 그림 뒤 초점 const show = (v: View): void => { host.hidden = v !== "sheet"; @@ -239,8 +251,15 @@ export function createSangeunSheet( if (!opt.edit) return []; const parts = Object.keys(r.parts); const part = PART_OF[parts[0] ?? ""]; + const fx = coverKinds(d.doc as ConstDoc, r.cid).includes("식"); // 식 덮개 = 품 칸 안 둠(792) const cells = [ - numCell(`${r.cid}|qty`, r.pct ? t("Rate") : t("Qty"), r.수량, (x) => setCell(d, r, "qty", x)), + ...(fx + ? [] + : [ + numCell(`${r.cid}|qty`, r.pct ? t("Rate") : t("Qty"), r.수량, (x) => + setCell(d, r, "qty", x), + ), + ]), ...(r.priceEdit && part && parts.length === 1 ? [numCell(`${r.cid}|price`, t("Price"), r.단가, (x) => setCell(d, r, part, x))] : []), @@ -254,6 +273,7 @@ export function createSangeunSheet( ? [createBadge(it.비목, it.비목 === "별도" ? "warning" : "neutral")] : []; if (!nums) for (const tag of it.표) kids.push(createBadge(`${t("Table")} ${tag}`, "info")); + if (ctx && fx(it)) kids.push(...formulaToggle(ctx, it.자리, ctx.spots?.자리[it.자리])); if (ctx) kids.push(...spotTail(ctx, it.자리, !!r && r.added !== null)); if (r && opt.edit) { const drop = createButton({ @@ -310,8 +330,13 @@ export function createSangeunSheet( return el("span", { className: `ui-calc-row__out${key ? " is-key" : ""}`, text: String(text) }); }; + /** 식 칸 되는 줄 — 율 · 로직 더한줄 · 둘째 기초 세부 줄 뺌 */ + const fx = (it: SheetItem): boolean => + !!it.자리 && !(it.row?.sub || (it.row?.added != null && it.row.pct)); + function rowOf(d: SheetDraw, it: SheetItem): HTMLElement[] { - const nums = ctx && it.자리 ? formulaEditor(ctx, it.자리) : null; + const own = ctx && fx(it) ? rowFormula(ctx, it.자리, ctx.spots?.자리[it.자리]) : null; + const nums = ctx && it.자리 && !own ? formulaEditor(ctx, it.자리) : null; const shown = it.식 || (it.kind === "계산" ? t("Hidden") : ""); const middle = it.kind === "옵션" @@ -321,6 +346,7 @@ export function createSangeunSheet( children: [ ...(it.row || !nums ? [createCalcFormula(shown)] : []), ...(nums ? [nums] : []), + ...(own ? [own] : []), ...(it.row ? editLine(d, it.row) : []), ...(ctx ? reasonCell(ctx, it.자리, it.changed) : []), ], @@ -331,7 +357,8 @@ export function createSangeunSheet( if (it.자리) row.dataset.sheetSpot = it.자리; if (it.changed) row.classList.add("is-changed"); const notes = ctx && it.자리 ? noteRows(ctx, (x) => x.자리 === it.자리) : []; - return [row, ...notes]; + const after = ctx && it.자리 ? varRows(ctx, (v) => v.자리 === it.자리) : []; + return [row, ...notes, ...after]; } /** 790-3 — 새 문서로 바꿔 끼움(상세가 쥔 같은 객체) → 다시 셈 */ @@ -360,6 +387,10 @@ export function createSangeunSheet( covered: (s) => coverKinds(d.doc as ConstDoc, s).length > 0, }); const spots = new Set(items.map((it) => it.자리).filter(Boolean)); + const names = new Set( + ((d.doc as { 변수줄?: { 이름: string }[] }).변수줄 ?? []).map((v) => v.이름), + ); + const varSpot = (s: string): boolean => s.startsWith("mid:") && names.has(s.slice(4)); const out: HTMLElement[] = ctx ? lostBanner(ctx) : []; const add = (label: keyof typeof TEXT, click: () => void): HTMLElement[] => opt.edit @@ -375,7 +406,9 @@ export function createSangeunSheet( ? add("AddRate", opt.addRate) : sec === "근거" && ctx ? addTopNote(ctx) - : []; + : sec === "상수" && ctx + ? addVarButton(ctx) + : []; if (!mine.length && !tools.length) continue; out.push(head(sec, tools)); if (sec === "자원") @@ -383,6 +416,8 @@ export function createSangeunSheet( ...removedStrip(d.removed, opt.edit, (cid) => revive(d, cid)), ...(ctx ? removedReasons(ctx, d.removed) : []), ); + if (sec === "상수" && ctx) + out.push(...varRows(ctx, (v) => !v.자리 || (!spots.has(v.자리) && !varSpot(v.자리)))); for (const it of mine) out.push(...rowOf(d, it)); if (sec === "근거" && ctx) out.push(...noteRows(ctx, (x) => !spots.has(x.자리))); } @@ -418,6 +453,10 @@ export function createSangeunSheet( spots: spotsOf(d.ans), apply: (next) => apply(d, next), focus: (key) => void (focusNext = key), + scope: opt.scope, + open, + drafts, + redraw: () => last && draw(last), }; drawStatus(d); const active = document.activeElement as HTMLElement | null; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_SheetEdit.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_SheetEdit.ts index 2698fc4c1..f1f459a35 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_SheetEdit.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_SheetEdit.ts @@ -103,7 +103,7 @@ function numBox( /** 식 칸 — 숫자 · 표 값을 칸으로(편집 · 서버 차례와 맞을 때만) · 못 그리면 null */ export function formulaEditor(c: EditCtx, spot: string): HTMLElement | null { const info = c.spots?.자리[spot]; - const parts = c.edit ? formulaParts(info) : null; + const parts = c.edit && !info?.식덮개 ? formulaParts(info) : null; if (!info || !parts) return null; const nums = c.doc.상수덮개?.[spot] ?? {}; const finds = c.doc.표값덮개?.[spot] ?? {}; @@ -251,7 +251,8 @@ export function restoreAll(c: EditCtx): HTMLElement[] { Object.keys(d.덮개 ?? {}).length + (d.뺀줄 ?? []).length + Object.keys(d.상수덮개 ?? {}).length + - Object.keys(d.표값덮개 ?? {}).length; + Object.keys(d.표값덮개 ?? {}).length + + Object.keys((d.식덮개 as object | undefined) ?? {}).length; if (!c.edit || !any) return []; const b = createButton({ label: t("RestoreAll"), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_SheetFormula.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_SheetFormula.ts new file mode 100644 index 000000000..d95517573 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_SheetFormula.ts @@ -0,0 +1,287 @@ +/* ============================================================================= + * M02_MasterTemplete_Sangeun_SheetFormula.ts + * 단가산출(SK) 시트 식 칸(PLAN 792 · E4 · _분석_650-H3 2 장 「식 고침」) — 시트(`_Sangeun_Sheet`)가 부름. + * 식 칸 = 이름 꼴 글(`[노임 보통인부] × 무게 / 운반량`) · 칠 때마다 검사 길(`formula-check` · 옛 요청 끊기) + * → 틀리면 빨간 칸 + 까닭 한 줄 · 되면 바꿀 때(Enter · 떠남) 엔진 식을 문서에(`setFormula`) → 서버 셈 + * 변수 줄 = 이름 · 식 · 값 · [+ 변수 줄] · 빼기(`addVar` · `editVar` · `dropVar`) + * 빈 칸 · M01 식 그대로 = 식 덮개 풂 · 같은 자리 상수 · 표값 · 품 덮개는 식 덮개와 같이 못 둠 → 걷고 넣음 + * 문서 고치기 = `logic/M02_Sangeun_Formula_Model`(792-1 짝) · 금액은 늘 서버 셈 · 자동저장 없음 + * ========================================================================== */ + +import { createBadge } from "@ui/ui_template_badge"; +import { createButton } from "@ui/ui_template_button"; +import { 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 { call, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import type { EditCtx } from "./M02_MasterTemplete_Sangeun_SheetEdit"; +import { + addVar, + dropVar, + editVar, + setFormula, + type FormulaDoc, + type VarLine, +} from "./logic/M02_Sangeun_Formula_Model"; +import { num, type SpotInfo } from "./logic/M02_Sangeun_Sheet_Model"; + +const TEXT = { + Formula: ["식", "Formula"], + Edit: ["식 고침", "Edit formula"], + Close: ["식 닫기", "Close formula"], + Hint: ["예) 무게 / 운반량 × (1 + 할증율 / 100)", "e.g. weight / load × 1.2"], + Var: ["변수", "Variable"], + VarName: ["변수 이름", "Variable name"], + AddVar: ["+ 변수 줄", "+ Variable"], + DropVar: ["변수 빼기", "Remove variable"], + Bad: ["식이 틀려 안 넣음", "Invalid formula — not applied"], + Checking: ["검사 중", "Checking…"], +} as const satisfies Record; +const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; + +export interface CheckAnswer { + 되나: boolean; + 까닭: string[]; + 알림: string[]; + /** 엔진 식(문서에 넣을 것) */ + 식: string; + /** 이름 꼴 글 */ + 글: string; +} + +export interface FormulaCtx extends EditCtx { + scope: { layer: Layer; owner: string | null }; + /** 식 칸 연 자리(화면 캐시) */ + open: Set; + /** 치는 중 글(다시 그려도 안 잃음) — 넣으면 지움 */ + drafts: Map; + redraw: () => void; +} + +const q = (c: FormulaCtx): string => + c.scope.owner && c.scope.layer !== "system" ? `?owner=${encodeURIComponent(c.scope.owner)}` : ""; + +/** 검사 길(792-1) — 셈 · 저장 없음 */ +export const checkFormula = ( + c: FormulaCtx, + spot: string, + text: string, + signal?: AbortSignal, +): Promise => + call(`/layers/${c.scope.layer}/sangeun/formula-check${q(c)}`, { + method: "POST", + body: JSON.stringify({ 문서: c.doc, 자리: spot, 식: text }), + signal, + }); + +/** 식 덮개와 같이 못 두는 같은 자리 상수 · 표값 · 품 덮개 걷음(배율은 둠) */ +export function clearNums(doc: T, spot: string): T { + const out: FormulaDoc = { ...doc }; + for (const name of ["상수덮개", "표값덮개"] as const) { + const all = { ...(doc[name] ?? {}) }; + delete all[spot]; + if (Object.keys(all).length) out[name] = all; + else delete out[name]; + } + const one = doc.덮개?.[spot]; + if (one && "qty" in one) { + const rest = { ...one }; + delete rest.qty; + const covers = { ...doc.덮개 }; + if (Object.keys(rest).length) covers[spot] = rest; + else delete covers[spot]; + out.덮개 = covers; + } + return out as T; +} + +/** 식 칸 — 칠 때마다 검사(앞 요청 끊음) · 바꿀 때 넣음 · `put(엔진 식 | null)` */ +function formulaBox( + c: FormulaCtx, + spot: string, + key: string, + shown: string, + origin: string, + put: (engine: string | null) => void, +): HTMLElement { + const box = createInputField({ + value: c.drafts.get(key) ?? shown, + compact: true, + size: 24, + placeholder: t("Hint"), + }); + box.input.setAttribute("aria-label", t("Formula")); + box.input.dataset.sheetCell = key; + box.input.spellcheck = false; + const note = createText("", "muted", 12); + note.dataset.sheetCheck = key; + let timer = 0; + let ctl: AbortController | null = null; + let last: Promise | null = null; + const run = (text: string): Promise => { + ctl?.abort(); + const mine = (ctl = new AbortController()); + note.textContent = t("Checking"); + last = checkFormula(c, spot, text, mine.signal).then( + (got) => { + if (mine !== ctl) return got; + box.setError(got.되나 ? undefined : got.까닭.join(" · ")); + note.textContent = got.알림.join(" · "); + return got; + }, + (error: unknown) => { + if (mine !== ctl || mine.signal.aborted) return null; + box.setError(error instanceof Error ? error.message : String(error)); + note.textContent = ""; + return null; + }, + ); + return last; + }; + box.input.addEventListener("input", () => { + c.drafts.set(key, box.input.value); + window.clearTimeout(timer); + const text = box.input.value.trim(); + if (!text) return void (ctl?.abort(), box.setError(undefined), (note.textContent = "")); + timer = window.setTimeout(() => void run(text), 150); + }); + box.input.addEventListener("change", async () => { + window.clearTimeout(timer); + const text = box.input.value.trim(); + if (!text || text === origin) { + c.drafts.delete(key); + return put(null); + } + const got = await run(text); + if (!got?.되나) return void (note.textContent = t("Bad")); + c.drafts.delete(key); + put(got.식); + }); + return el("div", { + className: "m02-sks__fx", + attrs: { "data-sheet-fx": spot }, + children: [box.root, note], + }); +} + +/** 줄 식 칸 — 열었거나 식 덮개 있는 자리만 · 못 고치는 자리(로직 · 율 더한줄) = null */ +export function rowFormula( + c: FormulaCtx, + spot: string, + info: SpotInfo | undefined, +): HTMLElement | null { + if (!c.edit || !spot || !info || (!c.open.has(spot) && !info.식덮개)) return null; + const shown = info.식글 ?? info.식 ?? ""; + const origin = info.원식글 ?? ""; + return formulaBox(c, spot, `${spot}|f`, shown, origin, (engine) => { + const doc = engine ? clearNums(c.doc as FormulaDoc, spot) : (c.doc as FormulaDoc); + c.apply(setFormula(doc, spot, engine)); + }); +} + +/** 꼬리 [식 고침] · [식 닫기] — 식 덮개 있으면 늘 열림(닫기 없음) */ +export function formulaToggle( + c: FormulaCtx, + spot: string, + info: SpotInfo | undefined, +): HTMLElement[] { + const addOne = /^add:#\d+$/.test(spot); // 더한줄 수량식(율 · 로직 줄은 부르는 쪽이 뺌) + if (!c.edit || !spot || !info || info.식덮개 || (info.식 == null && !addOne)) return []; + const on = c.open.has(spot); + const b = createButton({ + label: on ? t("Close") : t("Edit"), + variant: "ghost", + size: 22, + onClick: () => { + if (on) c.open.delete(spot); + else c.open.add(spot); + c.drafts.delete(`${spot}|f`); + c.focus(on ? "" : `${spot}|f`); + c.redraw(); + }, + }); + b.dataset.sheetFxOpen = spot; + return [b]; +} + +const vars = (c: FormulaCtx): VarLine[] => (c.doc as FormulaDoc).변수줄 ?? []; + +/** 자리 뒤 변수 줄들(이어 붙은 변수까지) */ +export function varRows(c: FormulaCtx, after: (v: VarLine) => boolean): HTMLElement[] { + return vars(c).flatMap((v) => + after(v) ? [varRow(c, v), ...varRows(c, (x) => x.자리 === `mid:${v.이름}`)] : [], + ); +} + +function varRow(c: FormulaCtx, v: VarLine): HTMLElement { + const spot = `mid:${v.이름}`; + const info = c.spots?.자리[spot]; + const label = c.edit + ? (() => { + const box = createInputField({ value: v.이름, compact: true, size: 24 }); + box.input.setAttribute("aria-label", t("VarName")); + box.input.dataset.sheetCell = `var|${v.이름}`; + box.input.addEventListener("change", () => { + const to = box.input.value.trim(); + if (!to || to === v.이름 || vars(c).some((x) => x.이름 === to)) + return void (box.input.value = v.이름); + c.focus(`var|${to}`); + c.apply(editVar(c.doc as FormulaDoc, v.이름, { 이름: to })); + }); + return box.root; + })() + : el("span", { className: "ui-calc-row__label", text: v.이름 }); + const middle = c.edit + ? formulaBox(c, spot, `${spot}|f`, info?.식글 ?? v.식, "", (engine) => + c.apply(editVar(c.doc as FormulaDoc, v.이름, { 식: engine ?? "0" })), + ) + : el("span", { className: "ui-calc-formula", text: info?.식글 ?? v.식 }); + const value = el("span", { + className: "ui-calc-row__out", + text: typeof info?.지금 === "number" ? num(info.지금) : "—", + }); + const tail = el("span", { + children: [ + createBadge(t("Var"), "accent"), + ...(c.edit + ? [ + createButton({ + label: t("DropVar"), + variant: "danger-ghost", + size: 22, + onClick: () => c.apply(dropVar(c.doc as FormulaDoc, v.이름)), + }), + ] + : []), + ], + }); + const row = createCalcSheetRow(label, middle, value, tail); + row.dataset.sheetKind = "변수"; + row.dataset.sheetSpot = spot; + row.classList.add("is-changed"); + return row; +} + +/** [+ 변수 줄] — 맨 위(자리 "") · 이름 = 안 겹치는 「변수n」 */ +export function addVarButton(c: FormulaCtx): HTMLElement[] { + if (!c.edit) return []; + const b = createButton({ + label: t("AddVar"), + variant: "ghost", + size: 22, + onClick: () => { + const used = new Set([ + ...vars(c).map((v) => v.이름), + ...(c.spots?.이름표 ?? []).map((x) => x.글), + ]); + let n = 1; + while (used.has(`변수${n}`)) n++; + c.focus(`var|변수${n}`); + c.apply(addVar(c.doc as FormulaDoc, { 이름: `변수${n}`, 식: "0", 자리: "" }, 0)); + }, + }); + b.dataset.sheetAddVar = ""; + return [b]; +} diff --git a/M02_MasterTemplete/logic/M02_Sangeun_Sheet_Model.ts b/M02_MasterTemplete/logic/M02_Sangeun_Sheet_Model.ts index ea5f709b6..82e3ecce3 100644 --- a/M02_MasterTemplete/logic/M02_Sangeun_Sheet_Model.ts +++ b/M02_MasterTemplete/logic/M02_Sangeun_Sheet_Model.ts @@ -232,12 +232,18 @@ export interface SpotInfo { 덮개?: string[]; 까닭?: string | null; M01바뀜?: boolean; + /** 792-1 — 지금 식 · M01 식(이름 꼴) · 식 덮개 있음 */ + 식글?: string; + 원식글?: string; + 식덮개?: boolean; } export interface SheetSpots { 자리: Record; 글줄: NoteLine[]; 짝잃음: { cid: string; 까닭: string }[]; + /** 792-1 식 칸에 쓸 수 있는 이름 */ + 이름표?: { 글: string; 갈래: string }[]; } export const spotsOf = (ans: SkAnswer | null): SheetSpots | null => diff --git a/resources/tester/test_790_1_sangeun_sheet.py b/resources/tester/test_790_1_sangeun_sheet.py index 1894c766d..be9d5658b 100644 --- a/resources/tester/test_790_1_sangeun_sheet.py +++ b/resources/tester/test_790_1_sangeun_sheet.py @@ -272,3 +272,39 @@ def test_790_3_식_조각_글줄_산출서_열쇠_밂(): 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 + + +# ── 792 식 칸 화면(줄 식 고침 · 칠 때마다 검사 · 변수 줄) ──────────────────────────── +FX = M02 / "M02_MasterTemplete_Sangeun_SheetFormula.ts" + + +def test_792_식_칸_정적(): + s = _text(FX) + assert " v.자리 === it.자리)", + "addVarButton(ctx)", + "scope: opt.scope,", + 'coverKinds(d.doc as ConstDoc, r.cid).includes("식")', # 식 덮개 = 품 칸 안 둠 + ): + assert call in sheet, call + assert "addRate: () => rateButton.click(), scope }" in _text(DETAIL) # 탭 등록 줄에 층만 + assert "fetch(" not in s # 길 = Api_Fetch.call(로그인 쿠키) From b8cad4f92e0c21d40dcfd623b7a926c8166844d8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 5 Nov 2026 17:43:20 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat(793-2a):=20=EB=82=B4=EB=B3=B4=EB=82=B4?= =?UTF-8?q?=EA=B8=B0=20=EA=BC=B4=EC=9D=84=20793-2=20=EA=B0=80=EC=A0=B8?= =?UTF-8?q?=EC=98=A8=20=EC=8B=9D=20SK=20=EB=A7=8C=EB=93=A4=EA=B8=B0=20?= =?UTF-8?q?=EB=AA=B8=EC=97=90=20=EB=A7=9E=EC=B6=A4=20=E2=80=94=20{?= =?UTF-8?q?=EB=A1=9C=EC=A7=81,=20=EC=9B=90=EB=B3=B8}?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 호표 줄 = 비목 하나 인력 · 재료 · 기계 → 종류 · 비목 · 단가(엑셀) · 이음(M01 키) / 그 밖 = 로직 값묶음 - 원본 = 파일 · 시트 · 행 · 갈래 · 엑셀금액 · 글줄 {행, 글, 자리, 값} · 표지 · 계약 · 로직 키 · 요소 뺌 - STmate 비목 꼬리 없는 참조도 값 든 비목 하나면 단가 줄 - test_793_2 — 원본 글줄 줄마다 엔진 = 엑셀(금액 줄 1,315 · 계표시 695 · EST 2,903) · 단가 줄은 서버처럼 값을 얹어 셈 Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_San_Emit.py | 204 +++++++++++++----------- resources/tester/test_793_2_san_emit.py | 179 +++++++++++++-------- 2 files changed, 216 insertions(+), 167 deletions(-) diff --git a/M02_MasterTemplete/M02_San_Emit.py b/M02_MasterTemplete/M02_San_Emit.py index 302dc9bea..1177dd9a0 100644 --- a/M02_MasterTemplete/M02_San_Emit.py +++ b/M02_MasterTemplete/M02_San_Emit.py @@ -1,21 +1,21 @@ """경쟁사 산근 → 우리 엔진 로직 줄 꼴 내보내기(PLAN 793-2a · 요구서_652 · eval 없음). -793-1 줄 나무(`M02_San_Stmate` · `M02_San_Est`) → 로직 JSON 한 개(`_틀.md` 7 장 GX 꼴 · -셈 = `master_formula.run` → `master_cut.engine`). - -내보낸 로직 꼴: -- 키 · 이름 · 결과단위 · 입력 [] · 끝전 · 조정 · 단계 [{이름, 단, 줄, 제외?, 원문, 검산}] -- 중간 [{이름, 식, 표시?, 원문}] — STmate = 식(같은 이름 다시 대입 = `이름_2`) · EST = 얼린 값 -- 호표 [{종류 로직, 요소 None, 이름, 단위, 수량, 값묶음, 자원, 번지?, 합밖?, 자리?, 원문, 검산}] - · 값묶음 = 「엑셀 단가 그대로」 · 자원 = 이음 열쇠 {꼴, 갈래, 코드?, 이름, 규격, 단위, - 단가 {L, M, S}, 비목?, 옮김?, M01?} - (M01 = 1차 줄 판정 `M02_Hopyo_Row.m01_picks` 유일 짝 · 나중 [우리 단가로]) -- 덧줄 [{이름, 식, 비목, 번지?, 합밖?, 자리?, 원문, 검산}] — 율 · 특수 % · 앞줄 참조 · 값 줄 -- 글줄 [{자리, 글, 행?, 표지?}] — 셈 없는 원본 글(자리 = 그 앞 줄 이름 · `mid:이름` · "" = 맨 위) -- 가져옴 {꼴, 코드, 검산 {노무비, 재료비, 경비, 계}, 계약?, 표지 [..]} -검산 = 엑셀 값(줄마다 · 블록) · 표지 = 못 옮긴 까닭(0 이 목표). -EST 는 블록 성분을 낸 줄만 합에 듦(나머지 줄 = 합밖) · -STmate 계약단가(낙찰률)는 B09 몫 — 가져옴.계약. +793-1 줄 나무(`M02_San_Stmate` · `M02_San_Est`) → `{로직, 원본}` — 꼴 = 793-2 가져온 식 SK +만들기 몸(`M02_Sangeun_Imported.py` 머리) · 로직 = `_틀.md` 7 장 꼴(셈 = `master_formula.run` → +`master_cut.engine` · `요소` 는 SK 서버가 채움). +- 로직 = {이름, 결과단위, 입력 [], 중간 [{이름, 식, 표시?}], 호표, + 덧줄 [{이름, 식, 비목, 번지?, 합밖?, 자리?}], 단계? [{이름, 단, 줄, 제외?}], 끝전, 조정?} · + STmate 변수 = 식(다시 대입 = `이름_2`) · EST 변수 = 얼린 값 · EST 는 블록 성분을 낸 줄만 합 + (나머지 = 합밖) +- 호표 줄 = {종류 인력|재료|기계, 이름, 규격, 단위, 수량, 비목, 단가(엑셀), 이음(M01 키 | null)} + (비목 하나 자원) | {종류 로직, 이름, 단위, 수량, 값묶음(엑셀), 이음 null}(기계 셋 · 산근 · 고정) · + 번지? · 합밖? · 자리? · 덧칸 `자원` {꼴, 갈래, 코드?, 종류?, 이름, 규격, 단위, 단가 {L, M, S}, + 비목?, 옮김?}(경쟁사 코드 · 엑셀 단가 원본 · `reprice`) · 이음 = 1차 줄 판정 + `M02_Hopyo_Row.m01_picks` 유일 짝(keys=True 일 때) +- 원본 = {파일, 시트, 행 [처음, 끝], 갈래 STmate|EST, 엑셀금액 {노무비, 재료비, 경비, 계}(1 단위 · + 계약 전), 글줄 [{행, 글, 자리?, 자리들?, 값?, 표지?}], 코드, 표지 [..], 계약? {낙찰률, 금액}} + · 글줄 자리 = 셈 줄 이름 | `mid:이름` | `단계:이름` · 값 = 그 줄 엑셀 금액(계) +표지 = 못 옮긴 까닭(0 이 목표) · STmate 계약단가(낙찰률)는 B09 몫. """ from __future__ import annotations @@ -148,14 +148,27 @@ def bundle(src: dict) -> dict[str, Decimal]: return out +def resource(name: str, unit: Any, qty: str, res: dict, link: str | None) -> dict: + """자원 → 호표 줄 — 비목 하나인 인력 · 재료 · 기계 = 단가 줄 · 그 밖 = 로직 값묶음 줄.""" + one = {"이름": name, "규격": res.get("규격") or "", "단위": unit or "", "수량": qty} + if res.get("비목") and res["갈래"] in ("인력", "재료", "기계"): + price = Decimal(str(res["단가"].get(res["비목"]) or 0)) + one |= {"종류": res["갈래"], "비목": COMP[res.get("옮김") or res["비목"]], "단가": price} + return {**one, "이음": link, "자원": res} + return {**one, "종류": "로직", "값묶음": bundle(res), "이음": None, "자원": res} + + def reprice(logic: dict, where: dict, price: dict[str, float]) -> int: - """단가 바꿈 — 자원이 where 칸과 같은 호표 줄 단가 {L, M, S} 고침 · 값묶음 다시 · 고친 줄 수.""" + """단가 바꿈 — where 칸이 같은 자원 단가 {L, M, S} 고침 · 줄 단가 · 값묶음 다시 · 고친 줄 수.""" n = 0 for item in logic.get("호표", []): src = item.get("자원") or {} if all(src.get(k) == v for k, v in where.items()): src["단가"] = {**src["단가"], **price} - item["값묶음"] = bundle(src) + if "단가" in item: + item["단가"] = Decimal(str(src["단가"].get(src["비목"]) or 0)) + else: + item["값묶음"] = bundle(src) n += 1 return n @@ -173,46 +186,52 @@ def _m01(kind: str, src: dict) -> str | None: # ── 내보낼 로직 틀 ───────────────────────────────────────────────────── class Out: - def __init__(self, key: str, name: str, unit: str, form: str, code: Any) -> None: + def __init__(self, name: str, unit: str, form: str, code: Any) -> None: self.names = Names() self.mid: list[dict] = [] self.items: list[dict] = [] self.adds: list[dict] = [] self.steps: list[dict] = [] - self.texts: list[dict] = [] + self.texts: list[dict] = [] # 원본 글줄(원문 차례) + self.at: dict[Any, dict] = {} # 행 → 글줄 self.marks: list[str] = [] - self.step_src: dict[str, dict] = {} # 단계 이름 → {원문, 검산}(엔진 단계 칸 밖) - self.last = "" # 글줄 자리 - self.row = {"키": key, "이름": name or key, "결과단위": f"원/{unit or '식'}", "입력": []} - self.head = {"꼴": form, "코드": code} + self.row = {"이름": name or str(code), "결과단위": f"원/{unit or '식'}", "입력": []} + self.head = {"갈래": form, "코드": code} - def text_line(self, glyph: str, at: Any = None, mark: str | None = None) -> None: - where = "행" if self.head["꼴"] == "STmate" else "차례" - one = {"자리": self.last, "글": glyph} | ({where: at} if at is not None else {}) - self.texts.append(one | ({"표지": mark} if mark else {})) + def glyph(self, at: Any, glyph: str, where: str | None = None, value: Any = None, mark=None): + """원본 글줄 한 줄(같은 행이면 자리만 더함).""" + one = self.at.get(at) + if one is None: + one = {"행": at, "글": glyph} + self.texts.append(one) + self.at[at] = one + if where and "자리" in one: + one.setdefault("자리들", [one["자리"]]).append(where) + elif where: + one["자리"] = where + if value is not None: + one["값"] = value if mark: + one["표지"] = mark self.marks.append(f"{at}: {mark}") - def middle(self, name: str, formula: str, show: int | None, source: dict) -> None: + def middle(self, name: str, formula: str, show: int | None, at: Any, glyph: str) -> None: self.mid.append({"이름": name, "식": formula, **({"표시": show} if show else {})}) - self.mid[-1]["원문"] = source - self.last = f"mid:{name}" + self.glyph(at, glyph, f"mid:{name}") - def add_line(self, one: dict, item: bool) -> dict: + def add_line(self, one: dict, item: bool, at: Any, glyph: str, value: Any) -> dict: (self.items if item else self.adds).append(one) - self.last = one["이름"] + self.glyph(at, glyph, one["이름"], value) return one - def done(self, end: str, adjust: dict | None, check: dict, **more: Any) -> dict: - out = {**self.row, "중간": self.mid, "호표": self.items, "덧줄": self.adds} - if self.steps: - out["단계"] = self.steps - out |= {"끝전": end, **({"조정": adjust} if adjust else {})} - out |= {"글줄": self.texts, "가져옴": {**self.head, "검산": check, "표지": self.marks}} - if self.step_src: - out["가져옴"]["단계"] = self.step_src - out["가져옴"] |= more - return out + def done(self, end: str, adjust: dict | None, check: dict, sheet: str, file: str, **more): + logic = {**self.row, "중간": self.mid, "호표": self.items, "덧줄": self.adds} + logic |= {"단계": self.steps} if self.steps else {} + logic |= {"끝전": end, **({"조정": adjust} if adjust else {})} + rows = [x["행"] for x in self.texts if isinstance(x["행"], int)] or [0] + source = {"파일": file, "시트": sheet, "행": [min(rows), max(rows)], **self.head} + source |= {"엑셀금액": check, "글줄": self.texts, "표지": self.marks, **more} + return {"로직": logic, "원본": source} def _money(d: dict | None) -> dict[str, Any]: @@ -245,10 +264,6 @@ class _Stm: self.out: dict[str, int] = {} # 제외 단계 줄 → 그 계 줄까지 줄 수(합에서 빠지는 때) self.fix: list[tuple[dict, int]] = [] # 누계 % 덧줄 · 그때 줄 수 - def xl(self, at: Any) -> dict: - ln = self.rows.get(at) or {} - return {c: ln.get(k) or 0 for c, k in (("L", "D"), ("M", "E"), ("S", "F"))} - def brackets(self, node: tuple, src: dict, kept: dict) -> None: """끼움 `[Q=0.208]` → 중간(왼쪽부터 · 원문 차례).""" kind = node[0] @@ -259,7 +274,7 @@ class _Stm: eng = self.o.names.line(node[1].lower()) self.names[node[1].lower()] = eng kept[id(node)] = eng - self.o.middle(eng, body, None, src) + self.o.middle(eng, body, None, src["행"], src["글"]) elif kind == "셈": self.brackets(node[2], src, kept) self.brackets(node[3], src, kept) @@ -288,14 +303,14 @@ class _Stm: except (ex.ExprError, EmitError) as error: if part["이름"] is None: continue - self.o.text_line(node["글"], src["행"], f"셈 못 함 — {error}") + self.o.glyph(src["행"], src["글"], mark=f"셈 못 함 — {error}") return if part["이름"] is None: continue self.V[part["이름"]] = ex.rnd(v, 2) if part["찍기"] else v eng = self.o.names.line(part["이름"]) self.names[part["이름"]] = eng - self.o.middle(eng, body, 2 if part["찍기"] else None, src) + self.o.middle(eng, body, 2 if part["찍기"] else None, src["행"], src["글"]) def total(self, node: dict, src: dict) -> None: o = self.o @@ -308,27 +323,26 @@ class _Stm: self.open = keep if node["제외"] else [*keep, (node["단"], mine)] for n in mine if node["제외"] else []: self.out[n] = len(self.order) - o.step_src[name] = {"원문": src, "검산": _money(self.xl(src["행"]))} + o.glyph(src["행"], src["글"], f"단계:{name}", self.rows.get(src["행"], {}).get("C")) o.steps.append(step) self.pending = [] if node["누계번지"]: self.tsum[node["누계번지"]] = name self.last_step = name - o.last = f"단계:{name}" - def line(self, one: dict, node: dict, item: bool) -> None: + def line(self, one: dict, node: dict, item: bool, src: dict) -> None: if node.get("번지") and node["번지"].startswith("A"): one["번지"] = node["번지"] self.addr.add(node["번지"]) money = one.get("값묶음") or {} - comps = {c for c in COMP if money.get(COMP[c])} if item else {_CODE[one["비목"]]} + comps = {c for c in COMP if money.get(COMP[c])} if "값묶음" in one else {_CODE[one["비목"]]} self.calls[one["이름"]] = (f"줄.'{one['이름']}'" if item else one["이름"], comps) if node.get("빼기"): one["합밖"] = True else: self.pending.append(one["이름"]) self.order.append(one["이름"]) - self.o.add_line(one, item) + self.o.add_line(one, item, src["행"], src["글"], self.rows.get(src["행"], {}).get("C")) def ref(self, node: dict, src: dict, cf: str) -> None: o, code = self.o, node["자원"]["코드"] @@ -336,7 +350,6 @@ class _Stm: unt = (pr.get("meta") or {}).get("UNT") one_, to = node["자원"]["비목"], node["자원"]["옮김"] name = _label(node) or str(pr.get("이름") or code) - xl = self.xl(src["행"]) if unt in ev.SPECIAL: # 특수 % — 처음부터 비목 누계 · 앞 계표시 dst = to or {"L": "L", "M": "M", "S": "S"}.get(code[0]) if unt == "T%": @@ -346,18 +359,18 @@ class _Stm: else: formula = f"{_SUM.format(unt[0])} * ({cf}) / 100" one = {"이름": o.names.line(name), "식": formula, "비목": COMP[dst]} - one |= {"원문": src, "검산": {COMP[dst]: xl[dst]}} self.fix.append((one, len(self.order))) - return self.line(one, node, False) + return self.line(one, node, False, src) res = {"꼴": "STmate", "갈래": KIND.get(code[0], "고정"), "코드": code} res |= {k: pr.get(k) for k in ("이름", "규격", "단위")} res |= {"단가": {c: pr.get(c) or 0 for c in COMP}} + sole = [c for c in COMP if res["단가"][c]] + if not one_ and len(sole) == 1: # 비목 꼬리 없어도 값 든 비목 하나면 단가 줄(셈 같음) + one_, to = sole[0], None res |= {"비목": one_, "옮김": to} if one_ else {} - if self.keys and (m01 := _m01(res["갈래"], res)): - res["M01"] = m01 - one = {"종류": "로직", "요소": None, "이름": o.names.item(name)} - one |= {"단위": pr.get("단위") or "", "수량": cf, "값묶음": bundle(res), "자원": res} - self.line(one | {"원문": src, "검산": _money(xl)}, node, True) + link = _m01(res["갈래"], res) if self.keys else None + one = resource(o.names.item(name), pr.get("단위"), cf, res, link) + self.line(one, node, True, src) def rate(self, node: dict, src: dict, cf: str) -> None: rate, o = node["율"], self.o @@ -389,14 +402,12 @@ class _Stm: else: parts = [(c, base(c)) for c in COMP] label = _label(node) or "율" - xl = self.xl(src["행"]) for c, b in parts: nm = o.names.line(label if len(parts) == 1 else f"{label}_{COMP[c]}") one = {"이름": nm, "식": f"({b}) * {pct}", "비목": COMP[c]} - one |= {"원문": src, "검산": {COMP[c]: xl[c]}} if "@합" in b: self.fix.append((one, len(self.order))) - self.line(one, node, False) + self.line(one, node, False, src) def finish(self) -> None: """누계 % 밑 = 그 줄 앞까지 합 — 엔진 비목 합(호표 다 · 앞 덧줄 · 제외 단계 뺌)에서 @@ -424,18 +435,20 @@ def _cols(row: dict) -> dict[str, Any]: return _money({"L": row["D"], "M": row["E"], "S": row["F"]}) | {"계": row["C"] or 0} -def stmate(block: dict, nodes: list[dict], prices: dict, rule: str, key: str, keys: bool = False): - """STmate 블록(`M02_San_Book.stmate_blocks` 한 개) + 줄 나무 + 같은 책 단가 → 로직 JSON. - rule = 블록 끝전(비목버림 | 합버림_큰나머지 · 파일 설정) · keys = 자원 M01 키 찾기.""" +def stmate( + block: dict, nodes: list[dict], prices: dict, rule: str, keys: bool = False, file: str = "" +) -> dict: + """STmate 블록(`M02_San_Book.stmate_blocks` 한 개) + 줄 나무 + 같은 책 단가 → {로직, 원본}. + rule = 블록 끝전(비목버림 | 합버림_큰나머지 · 파일 설정) · keys = 자원 M01 키(이음) 찾기.""" me = prices.get(block["코드"]) or {} meta = me.get("meta") or {} - o = Out(key, block.get("이름"), me.get("단위"), "STmate", block["코드"]) + o = Out(block.get("이름"), me.get("단위"), "STmate", block["코드"]) st = _Stm(o, block, prices, keys) for node in nodes: kind, src = node["종류"], {"행": node.get("행"), "글": node.get("글") or ""} if kind in ("빈", "글"): if src["글"].strip(): - o.text_line(src["글"], src["행"]) + o.glyph(src["행"], src["글"]) elif kind == "대입": st.assign(node, src) elif kind == "계": @@ -450,7 +463,7 @@ def stmate(block: dict, nodes: list[dict], prices: dict, rule: str, key: str, ke cf = st.coef(node["식"], src) (st.rate if kind == "율" else st.ref)(node, src, cf) except (ex.ExprError, EmitError) as error: - o.text_line(src["글"], src["행"], f"셈 못 함 — {error}") + o.glyph(src["행"], src["글"], mark=f"셈 못 함 — {error}") try: st.finish() except EmitError as error: @@ -464,14 +477,14 @@ def stmate(block: dict, nodes: list[dict], prices: dict, rule: str, key: str, ke if meta.get("UC") == "T" and meta.get("NAG"): # 계약단가 = B09 몫(엔진 밖) more["계약"] = {"낙찰률": float(meta["NAG"]), "금액": _cols(block["머리"])} end = "큰나머지" if rule.startswith("합버림") else "비목버림" - return o.done(end, adjust, _cols(kye), **more) + return o.done(end, adjust, _cols(kye), "단가산출근거", file, **more) # ── EST ──────────────────────────────────────────────────────────────── _VAR = re.compile(r"([A-Za-z][A-Za-z0-9_]*)\s*(?:\([^)]*\))?\s*=") -def _est_res(role: dict, value: float, comp: str | None, keys: bool) -> dict: +def _est_res(role: dict, value: float, comp: str | None) -> dict: """EST 단가 토큰 정체 → 자원 이음 열쇠(옮김 = 성분 비목이 단가 비목과 다를 때).""" p = role["정체"] pc = p["비목"] if p["비목"] in COMP else (comp or "S") @@ -479,19 +492,16 @@ def _est_res(role: dict, value: float, comp: str | None, keys: bool) -> dict: res |= {"이름": p["이름"], "규격": p["규격"], "단위": None} res |= {"단가": {pc: value}} res |= {"비목": pc, "옮김": comp if comp and comp != pc else None, "열쇠비목": p["비목"]} - if role.get("모호"): - res["모호"] = True - if keys and (m01 := _m01(res["갈래"], res)): - res["M01"] = m01 - return res + return res | ({"모호": True} if role.get("모호") else {}) -def est(block: dict, lines: list[dict], want: dict, key: str, keys: bool = False) -> dict: +def est(block: dict, lines: list[dict], want: dict, keys: bool = False, file: str = "") -> dict: """EST 블록(`M02_San_Book.est_blocks` 한 개) + 복원 줄(`M02_San_Est.parse_block`) + - 총괄표 성분 want {T, L, M, S} → 로직 JSON · 성분을 낸 줄만 합(나머지 = 합밖).""" + 총괄표 성분 want {T, L, M, S} → {로직, 원본} · 성분을 낸 줄만 합(나머지 = 합밖).""" des = ev.designate(lines, want) mine = {k: c for c, k in des.items() if isinstance(k, int)} - o = Out(key, block.get("이름"), block.get("단위"), "EST", block.get("번")) + o = Out(block.get("이름"), block.get("단위"), "EST", block.get("번")) + rows = block.get("글행") or [] for c, k in des.items(): if k is None: o.marks.append(f"성분 {COMP[c]} {want[c]} 을 낸 줄 없음") @@ -499,32 +509,32 @@ def est(block: dict, lines: list[dict], want: dict, key: str, keys: bool = False ref: dict[int, str] = {} # 차례 → 엔진 부름 글(앞줄 · 숨은 합) for L in lines: k, form, kind = L["차례"], L["꼴"], L.get("종류") - src = {"차례": k, "글": L["원글"]} + at, glyph = rows[k] if k < len(rows) else k, L["원글"] comp = mine.get(k) if form == "빈": continue if form == "변수" and L.get("값") is not None: m = _VAR.search(L["원글"]) eng = o.names.line(m.group(1) if m else "변수") - o.middle(eng, num(L["값"]), None, src) # 얼린 값(금액엔 보인 값이 박힘) + o.middle(eng, num(L["값"]), None, at, glyph) # 얼린 값(금액엔 보인 값이 박힘) continue if form not in ("돈", "값"): - o.text_line(L["원글"], k) + o.glyph(at, glyph) continue - head = {"원문": src, "검산": {COMP[comp or L.get("비목") or "S"]: L["값"]}} - head |= {} if comp else {"합밖": True} + head = {} if comp else {"합밖": True} try: place = _place(L.get("규칙")) if k not in val: raise EmitError("보인 값이 되는 식 · 자리 못 찾음 — 보인 값으로 얼림") if kind == "단가줄": - at, role = next((i, r) for i, r in L["역할"].items() if r["갈래"] == "단가") - price = next(n[1] for n in ex.numbers(L["식"]) if n[2] == at) - res = _est_res(role, price, comp, keys) - one = {"종류": "로직", "요소": None, "이름": o.names.item(L.get("라벨") or "")} - one |= {"단위": "", "수량": text(L["식"], {}, {at: "1"}), "값묶음": bundle(res)} - one |= {"자원": res, **head, **({"자리": place} if place else {})} - o.add_line(one, True) + i, role = next((i, r) for i, r in L["역할"].items() if r["갈래"] == "단가") + price = next(n[1] for n in ex.numbers(L["식"]) if n[2] == i) + res = _est_res(role, price, comp) + link = _m01(res["갈래"], res) if keys else None + qty = text(L["식"], {}, {i: "1"}) + one = resource(o.names.item(L.get("라벨") or ""), None, qty, res, link) + one |= {**head, **({"자리": place} if place else {})} + o.add_line(one, True, at, glyph, L["값"]) ref[k] = f"줄.'{one['이름']}'" continue if kind == "값줄": @@ -534,12 +544,12 @@ def est(block: dict, lines: list[dict], want: dict, key: str, keys: bool = False swap = {i: ref[r["줄"]] for i, r in L["역할"].items() if r.get("줄") in ref} formula = text(L["식"], {}, swap) except EmitError as error: - o.marks.append(f"{k}: {error}") + o.glyph(at, glyph, mark=str(error)) formula, place = num(L["값"]), None name = o.names.line(L.get("라벨") or "줄") one = {"이름": name, "식": formula, "비목": COMP[comp or L.get("비목") or "S"]} - o.add_line(one | head | ({"자리": place} if place else {}), False) + o.add_line(one | head | ({"자리": place} if place else {}), False, at, glyph, L["값"]) if k in val: ref[k] = name check = _money(want) | {"계": want["T"]} - return o.done("비목버림", None, check) + return o.done("비목버림", None, check, "단가산출", file) diff --git a/resources/tester/test_793_2_san_emit.py b/resources/tester/test_793_2_san_emit.py index a4b46ec45..98462c758 100644 --- a/resources/tester/test_793_2_san_emit.py +++ b/resources/tester/test_793_2_san_emit.py @@ -27,21 +27,32 @@ mf = importlib.import_module("master_formula") mcut = importlib.import_module("master_cut") ms = importlib.import_module("master_formula_sum") -STMATE = { - "봉화": (62, "비목버림"), - "동부청영월": (69, "합버림_큰나머지"), - "울진대흥": (47, "비목버림"), - "영덕계류": (15, "비목버림"), - "울진소광": (40, "비목버림"), +STMATE = { # 파일: (블록, 금액 줄, 계표시 줄, 끝전) + "봉화": (62, 349, 184, "비목버림"), + "동부청영월": (69, 411, 230, "합버림_큰나머지"), + "울진대흥": (47, 261, 128, "비목버림"), + "영덕계류": (15, 86, 41, "비목버림"), + "울진소광": (40, 208, 112, "비목버림"), } -EST = {"북부청임도": 94, "북부청사방": 67, "거창": 61} +EST = {"북부청임도": (94, 1252), "북부청사방": (67, 849), "거창": (61, 802)} # (블록, 돈 · 값 줄) # 엑셀 글 식이 보인 값과 안 맞는 줄(793-1 틀린 줄 2) — 보인 값으로 얼림 · 블록 금액은 같음 -EST_FROZEN = {"북부청임도": {27: ["37"], 33: ["51"]}} +EST_FROZEN = {"북부청임도": {27: 1, 33: 1}} K = ("노무비", "재료비", "경비", "계") LABOR = {"종류": "노임", "이름": "보통인부", "규격": "", "열쇠비목": "L"} -def engine(logic: dict) -> dict: +def engine(out: dict) -> dict: + """내보낸 로직 → 엔진 셈 — 단가 줄은 SK 서버처럼 값을 얹음(요소 대신 값묶음 {비목: 단가}).""" + logic = {**out["로직"], "키": "GX990001"} + items = [] + for item in logic["호표"]: + one = {k: v for k, v in item.items() if k not in ("이음", "자원", "단가", "규격")} + if "단가" in item: + money = dict.fromkeys(K[:3], 0) | {item["비목"]: item["단가"]} + one |= {"종류": "로직", "값묶음": money} + del one["비목"] + items.append(one | {"요소": None}) + logic["호표"] = items master = mf.Master({"로직.json": {"그룹": "로직", "줄": [logic]}}) return mcut.engine(master, logic["키"], mf.run(master, logic["키"], {})) @@ -60,69 +71,79 @@ def _load(tag: str) -> dict: def _stmate(tag: str, blk: dict) -> tuple[list, dict]: nodes = stm.parse_block(blk["몸"]) - return nodes, em.stmate(blk, nodes, _load(tag)["단가"], STMATE[tag][1], "GX990001") + return nodes, em.stmate(blk, nodes, _load(tag)["단가"], STMATE[tag][3]) -def _lines_equal(logic: dict, got: dict) -> list: - """줄마다 엔진 비목 금액 = 엑셀 · 단계 = 계표시 줄.""" - bad = [] - for item, line in zip([*logic["호표"], *logic["덧줄"]], got["줄"], strict=True): - for k, want in item["검산"].items(): - if abs(float(line["비목"].get(k) or 0) - want) >= 0.05: - bad.append((item["이름"], k, line["비목"].get(k), want)) - for step in got.get("단계", []): - want = logic["가져옴"]["단계"][step["이름"]]["검산"] - if any(abs(float(step[k]) - want[k]) >= 0.15 for k in want): - bad.append((step["이름"], want)) - return bad +def _lines_equal(out: dict, got: dict) -> tuple[list, int, int]: + """원본 글줄마다 엔진 줄 금액(계) = 엑셀 · 단계 = 계표시 줄 → (틀린 것, 줄 수, 단계 수).""" + lines = {x["이름"]: x for x in got["줄"]} + steps = {x["이름"]: x for x in got.get("단계", [])} + bad, n_line, n_step = [], 0, 0 + for g in out["원본"]["글줄"]: + if g.get("값") is None or not g.get("자리") or g["자리"].startswith("mid:"): + continue + if g["자리"].startswith("단계:"): + n_step += 1 + have = float(steps[g["자리"][3:]]["계"]) + tol = 0.15 + else: + n_line += 1 + have = sum(float(lines[n]["금액"] or 0) for n in g.get("자리들") or [g["자리"]]) + tol = 0.05 + if abs(have - g["값"]) >= tol: + bad.append((g["행"], g["글"].strip(), have, g["값"])) + return bad, n_line, n_step @pytest.mark.parametrize("tag", list(STMATE)) def test_stmate_engine_equals_excel(tag: str) -> None: data = _load(tag) + blocks, n_lines, n_steps, rule = STMATE[tag] n, wrong, lines, steps = 0, [], 0, 0 for blk in data["블록"]: if not any(ln["G"].strip() for ln in blk["몸"]): continue n += 1 - nodes, logic = _stmate(tag, blk) - got = engine(logic) - ref = ev.stmate_block(blk, nodes, data["단가"], STMATE[tag][1])["계"] - want = logic["가져옴"]["검산"] + nodes, out = _stmate(tag, blk) + got = engine(out) + ref = ev.stmate_block(blk, nodes, data["단가"], rule)["계"] + want = out["원본"]["엑셀금액"] same = money(got) == tuple(round(want[k]) for k in K) same &= money(got) == (ref["L"], ref["M"], ref["S"], ref["T"]) - bad = _lines_equal(logic, got) + ms.check(logic, logic["키"]) + logic["가져옴"]["표지"] - if deal := logic["가져옴"].get("계약"): # B09 몫 — 엔진 계 × 낙찰률 원 버림 = 엑셀 머리 + bad, a, b = _lines_equal(out, got) + bad += ms.check(out["로직"], blk["코드"]) + out["원본"]["표지"] + lines, steps = lines + a, steps + b + if deal := out["원본"].get("계약"): # B09 몫 — 엔진 계 × 낙찰률 원 버림 = 엑셀 머리 cut = [math.floor(round(int(got[k]) * deal["낙찰률"] / 100, 6)) for k in K[:3]] same &= cut == [deal["금액"][k] for k in K[:3]] if not same or bad: wrong.append((blk["코드"], money(got), bad[:3])) - lines += len(logic["호표"]) + len(logic["덧줄"]) - steps += len(got.get("단계", [])) - assert (n, wrong) == (STMATE[tag][0], []) - assert lines >= 1 and steps >= 1 + assert (n, wrong) == (blocks, []) + assert (lines, steps) == (n_lines, n_steps) @pytest.mark.parametrize("tag", list(EST)) def test_est_engine_equals_excel(tag: str) -> None: data = _load(tag) - n, wrong = 0, [] + n, wrong, lines = 0, [], 0 for blk in data["블록"]: want = data["총괄"].get(blk["번"]) if not want or not (want["T"] or any(want[c] for c in "LMS")): continue n += 1 parsed = est.parse_block(blk["글"], data["단가"]) - logic = em.est(blk, parsed, want, "GX990002") - got = engine(logic) + out = em.est(blk, parsed, want) + got = engine(out) ref = ev.est_block(parsed, ev.designate(parsed, want))["금액"] same = money(got) == (want["L"], want["M"], want["S"], want["T"]) same &= money(got) == (ref["L"], ref["M"], ref["S"], ref["T"]) - frozen = [m.split(":")[0] for m in logic["가져옴"]["표지"]] - bad = _lines_equal(logic, got) + ms.check(logic, logic["키"]) - if not same or bad or frozen != EST_FROZEN.get(tag, {}).get(blk["번"], []): - wrong.append((blk["번"], money(got), bad[:3], frozen)) - assert (n, wrong) == (EST[tag], []) + bad, a, _ = _lines_equal(out, got) + bad += ms.check(out["로직"], str(blk["번"])) + lines += a + frozen = len(out["원본"]["표지"]) + if not same or bad or frozen != EST_FROZEN.get(tag, {}).get(blk["번"], 0): + wrong.append((blk["번"], money(got), bad[:3], out["원본"]["표지"])) + assert (n, wrong, lines) == (EST[tag][0], [], EST[tag][1]) @pytest.mark.parametrize( @@ -135,10 +156,10 @@ def test_est_engine_equals_excel(tag: str) -> None: ) def test_stmate_price_propagates(tag, code, labor, before, after) -> None: blk = next(b for b in _load(tag)["블록"] if b["코드"] == code) - _, logic = _stmate(tag, blk) - assert money(engine(logic))[:3] == before - assert em.reprice(logic, {"코드": labor}, {"L": 180000}) >= 1 - assert money(engine(logic))[:3] == after + _, out = _stmate(tag, blk) + assert money(engine(out))[:3] == before + assert em.reprice(out["로직"], {"코드": labor}, {"L": 180000}) >= 1 + assert money(engine(out))[:3] == after @pytest.mark.parametrize( @@ -153,20 +174,29 @@ def test_stmate_price_propagates(tag, code, labor, before, after) -> None: def test_est_price_propagates(tag, no, after) -> None: data = _load(tag) blk = next(b for b in data["블록"] if b["번"] == no) - logic = em.est(blk, est.parse_block(blk["글"], data["단가"]), data["총괄"][no], "GX990002") - assert em.reprice(logic, LABOR, {"L": 180000.0}) >= 1 - assert money(engine(logic))[:3] == after + out = em.est(blk, est.parse_block(blk["글"], data["단가"]), data["총괄"][no]) + assert em.reprice(out["로직"], LABOR, {"L": 180000.0}) >= 1 + assert money(engine(out))[:3] == after def test_link_keys_m01() -> None: - """자원 이음 열쇠 — 엑셀 품명 · 규격 · 단위 · 경쟁사 코드 · 1차 줄 판정 M01 키.""" + """호표 줄 = 793-2 꼴(종류 · 단가 · 이음 = 1차 줄 판정 M01 키) · 덧칸 자원 · 원본.""" tag = "봉화" blk = next(b for b in _load(tag)["블록"] if b["코드"] == "D00102") - logic = em.stmate( - blk, stm.parse_block(blk["몸"]), _load(tag)["단가"], "비목버림", "GX990001", True - ) - labor = next(x for x in logic["호표"] if x["이름"] == "보통인부") - assert labor["자원"] | {"M01": labor["자원"].get("M01")} == { + out = em.stmate(blk, stm.parse_block(blk["몸"]), _load(tag)["단가"], "비목버림", True) + labor = next(x for x in out["로직"]["호표"] if x["이름"] == "보통인부") + assert {k: v for k, v in labor.items() if k != "자원"} == { + "이름": "보통인부", + "규격": "", + "단위": "인", + "수량": "(2 / 50)", + "종류": "인력", + "비목": "노무비", + "단가": 165545, + "이음": "LB000002", + "번지": "A02", + } + assert labor["자원"] == { "꼴": "STmate", "갈래": "인력", "코드": "L00007", @@ -174,12 +204,21 @@ def test_link_keys_m01() -> None: "규격": None, "단위": "인", "단가": {"L": 165545, "M": 0, "S": 0}, - "M01": "LB000002", + "비목": "L", + "옮김": None, } - assert labor["값묶음"] == {"노무비": 165545, "재료비": 0, "경비": 0} + src = out["원본"] + assert {k: src[k] for k in ("시트", "갈래", "코드", "행")} == { + "시트": "단가산출근거", + "갈래": "STmate", + "코드": "D00102", + "행": [2912, 2937], + } + assert src["엑셀금액"] == {"노무비": 34119, "재료비": 11192, "경비": 341, "계": 45652} + assert next(g for g in src["글줄"] if g.get("자리") == "보통인부")["값"] == 6621.8 -# ── 엑셀 없이 도는 문법 견본(793-1 `test_stmate_grammar_sample` 그대로) ── +# ── 엑셀 없이 도는 문법 견본(793-1 `test_stmate_grammar_sample` + 다시 대입 · 뒤 줄) ── def test_stmate_grammar_sample_engine() -> None: prices = { "M00081": {"T": 11468, "L": 0, "M": 11468, "S": 0, "meta": {}, "이름": "합판(내수)"}, @@ -202,18 +241,16 @@ def test_stmate_grammar_sample_engine() -> None: ] rows = [{"행": i, "G": g} for i, g in enumerate(text)] nodes = stm.parse_block(rows) - block = {"코드": "D00001", "이름": "견본", "머리": {}, "꼬리": [], "몸": rows} + block = {"코드": "D00001", "이름": "견본", "꼬리": [], "몸": rows} block["머리"] = {"A": "", "B": "", "C": 0, "D": 0, "E": 0, "F": 0} - logic = em.stmate(block, nodes, prices, "비목버림", "GX990003") - assert [x["이름"] for x in logic["중간"]] == ["q", "q_2"] # 다시 대입 = 새 이름 - assert logic["중간"][0] == { - "이름": "q", - "식": "(2.5 / 3)", - "표시": 2, - "원문": {"행": 1, "글": text[1]}, - } - assert ms.check(logic, "견본") == [] and logic["가져옴"]["표지"] == [] - got = engine(logic) + out = em.stmate(block, nodes, prices, "비목버림") + logic = out["로직"] + assert logic["중간"] == [ + {"이름": "q", "식": "(2.5 / 3)", "표시": 2}, + {"이름": "q_2", "식": "(1 / 3)", "표시": 2}, # 다시 대입 = 새 이름 + ] + assert ms.check(logic, "견본") == [] and out["원본"]["표지"] == [] + got = engine(out) lines, tot = ev.run_stmate(nodes, prices) want = ev.block_total(tot, "비목버림") assert money(got) == (want["L"], want["M"], want["S"], want["T"]) @@ -236,14 +273,16 @@ def test_est_grammar_sample_engine() -> None: ] lines = est.parse_block(text, prices) want = {"T": 15950.0, "L": 15950.0, "M": 0.0, "S": 0.0} - logic = em.est({"번": 1, "이름": "견본", "단위": "㎡"}, lines, want, "GX990004") + out = em.est({"번": 1, "이름": "견본", "단위": "㎡"}, lines, want) + logic = out["로직"] assert [x["식"] for x in logic["덧줄"]] == [ "((줄.'보통인부' * 3) / 100)", "줄.'보통인부' + 공구손료", "소계", ] + assert logic["호표"][0]["종류"] == "인력" and logic["호표"][0]["단가"] == 172068 assert logic["덧줄"][2]["자리"] == {"방법": "버림", "자리": 0} assert [x.get("합밖") for x in (*logic["호표"], *logic["덧줄"])] == [True, True, True, None] - assert money(engine(logic)) == (15950, 0, 0, 15950) + assert money(engine(out)) == (15950, 0, 0, 15950) em.reprice(logic, LABOR, {"L": 180000.0}) - assert money(engine(logic)) == (16686, 0, 0, 16686) # 793-1 run_est 전파와 같음 + assert money(engine(out)) == (16686, 0, 0, 16686) # 793-1 run_est 전파와 같음