From 53bca68ea47a2071180fbcd0ec2ffa31cd66bf7b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 1 Nov 2026 17:23:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(717-2b):=20M02=20=EB=8B=A8=EA=B0=80?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=20=EC=83=81=EC=84=B8=20=C2=B7=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=A8=20=E2=80=94=20M01=20=EC=83=81=EC=84=B8=20=EB=B6=80?= =?UTF-8?q?=ED=92=88(=EB=B3=B4=EA=B8=B0=20=EB=84=B7)=EC=9D=84=20M02=20?= =?UTF-8?q?=EA=B8=B8=EB=A1=9C=20=C2=B7=20=ED=92=88=20=C2=B7=20=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=20=EC=B9=B8=20=EB=8D=AE=EA=B0=9C=20=C2=B7=20=EC=A4=84?= =?UTF-8?q?=20=EB=8D=94=ED=95=98=EA=B8=B0=20=C2=B7=20=EB=B9=BC=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EB=B0=94=ED=83=95=20M01=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B3=A0=EB=A6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 Sangeun_Detail(mountSangeunDetail) · Sangeun_Cover(고침 표 · 셈 길 최신 응답만) · logic/M02_Sangeun_Cover_Model(순수 꼴) - 고친 칸 = 칸 색 + 바탕 값 곁 글 + 「단가산출에서 고침」 · M01 개정 배지(바탕판 · 원값) · 짝잃음 띠 · 별도계상 재료 = 계 밖 표기 - M01 buildLabDetail 덧칸 cover(단가산출 상세 · 인력·재료·경비 덩이 갈음) · 마스터 이동 단추 = 등록 없으면 숨김(hasMasterJump) - Sangeun_View 자리표 · 옛 글 열쇠(준비 중 · 덮개 · 더한 줄 · 열린 입력) 걷음 - 시험 test_717_2b_sangeun_detail 3 Co-Authored-By: Claude Opus 5.5 --- .../M01_MasterData_UI_LogicLab_Detail.ts | 15 +- .../M01_MasterData_UI_LogicLab_Modal.ts | 6 +- M01_MasterData/M01_MasterData_UI_Side.ts | 5 +- .../M02_MasterTemplete_Sangeun.css | 53 +++ .../M02_MasterTemplete_Sangeun_Cover.ts | 213 ++++++++++++ .../M02_MasterTemplete_Sangeun_Detail.ts | 324 ++++++++++++++++++ .../M02_MasterTemplete_Sangeun_View.ts | 47 +-- .../logic/M02_Sangeun_Cover_Model.ts | 301 ++++++++++++++++ .../tester/test_717_2b_sangeun_detail.py | 184 ++++++++++ ui_template/ui_template_locale_m2.ts | 44 ++- 10 files changed, 1136 insertions(+), 56 deletions(-) create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Sangeun.css create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Sangeun_Cover.ts create mode 100644 M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts create mode 100644 M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts create mode 100644 resources/tester/test_717_2b_sangeun_detail.py diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts index bedd8c879..ba75ddea0 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts @@ -84,6 +84,8 @@ export interface DetailContext { admin?: boolean; /** 714-3 텍스트 수식 입력 단추 — 시험 계산 입력 고침 + 다시 셈 */ onInput?: (name: string, value: string) => void; + /** 717-2b M02 단가산출 — 단가산출 상세 · 인력·재료·경비 덩이를 이 칸으로(SK 셈 답 · 고침 표) */ + cover?: { ho: HTMLElement; est: HTMLElement }; } type Group = "labor" | "material" | "cost" | "other"; @@ -526,12 +528,19 @@ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void { ...infoBox(ctx), block(section(tl("Calc_Title"), [ctx.calcHost]), "calc"), block( - section(tl("View_Est"), [buildEst({ text: ctx.text, unit: ctx.row.결과단위, ...fx })]), + section(tl("View_Est"), [ + ctx.cover?.est ?? buildEst({ text: ctx.text, unit: ctx.row.결과단위, ...fx }), + ]), "est", ), - ...(ctx.flowHost + ...(ctx.flowHost && !ctx.cover ? [block(nowInputs(ctx.values), "ho"), ctx.flowHost] - : [hoBox(ctx), block(section(tl("Formula_Title"), [formula]), "formula")]), + : [ + ctx.cover + ? block(section(tl("Ho_Title"), [nowInputs(ctx.values), ctx.cover.ho]), "ho") + : hoBox(ctx), + block(section(tl("Formula_Title"), [formula]), "formula"), + ]), ); const own = kept?.node.dataset.own; // 기본정보 칸은 새로 만들어지므로 이름표로 다시 찾음 const back = own ? host.querySelector(`[data-own="${own}"]`) : kept?.node; diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts index f28b870fd..bb3270f83 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts @@ -23,7 +23,7 @@ import { createStat, statGrid } from "@ui/ui_template_stat"; import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; import { formatMoney, formatPrice } from "./M01_MasterData_UI_Logic_Money"; import { explain, loadTable, type TableRow } from "./M01_MasterData_UI_Logic_Note"; -import { jumpToMaster, type MasterJump } from "./M01_MasterData_UI_Side"; +import { hasMasterJump, jumpToMaster, type MasterJump } from "./M01_MasterData_UI_Side"; import { fragmentRow, type Fragment } from "./M01_MasterData_UI_LogicLab_Pill"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; import { createText } from "@ui/ui_template_text"; @@ -180,11 +180,13 @@ const groupOf = (ref: string): string => ref.startsWith("LB") ? "인력" : ref.startsWith("M") ? "재료" : ref.startsWith("EQ") ? "기계" : ""; function goButton(target: MasterJump): HTMLElement { - return createButton({ + const go = createButton({ label: tl("Pill_Go"), variant: "ghost", onClick: () => jumpToMaster(target), }); + go.hidden = !hasMasterJump(); + return go; } /** 자재품목 값 칸 넷 — 위 가로 줄(관급은 뺌) */ diff --git a/M01_MasterData/M01_MasterData_UI_Side.ts b/M01_MasterData/M01_MasterData_UI_Side.ts index a0ffb87c4..7a83aa253 100644 --- a/M01_MasterData/M01_MasterData_UI_Side.ts +++ b/M01_MasterData/M01_MasterData_UI_Side.ts @@ -79,11 +79,12 @@ export function takePendingLogicKey(): string | null { export type MasterJump = { kind: "table"; key: string } | { kind: "element"; group: string; ref: string; file?: string }; -let masterJump: (target: MasterJump) => void = () => {}; +let masterJump: ((target: MasterJump) => void) | null = null; export const setMasterJump = (fn: (target: MasterJump) => void): void => { masterJump = fn; }; -export const jumpToMaster = (target: MasterJump): void => masterJump(target); +export const jumpToMaster = (target: MasterJump): void => masterJump?.(target); +export const hasMasterJump = (): boolean => masterJump !== null; // 등록 없으면(M02) 이동 단추 숨김 /** 요소 화면에서 열 것 — 파일 · 하위 거름(구분·세부분류) · 상세구분 · 제목 */ export interface Pick { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun.css b/M02_MasterTemplete/M02_MasterTemplete_Sangeun.css new file mode 100644 index 000000000..07dc98951 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun.css @@ -0,0 +1,53 @@ +/* M02 단가산출(SK) 상세 · 고침 표(PLAN 717-2b) — 표 · 단추 · 배지는 부품 상자 · 여기는 자리 · 고친 칸 색만 */ + +.m02-sk { + display: flex; + flex-direction: column; + gap: 12px; +} + +.m02-sk__head { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 8px; +} + +.m02-sk__head .ui-field { + min-width: 120px; +} + +.m02-sk__cell { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; +} + +.m02-sk__cell .ui-input { + width: 9em; + text-align: end; +} + +.m02-sk__tags, +.m02-sk__removed, +.m02-sk__removed-item { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; +} + +.m02-sk__removed { + gap: 8px; +} + +/* 고친 칸 — B09 고친 칸과 같은 꼴(색) · 곁 글 = 바탕 값 */ +.m02-sk__cover td.is-edited { + background: color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas)); +} + +.m02-sk__total { + text-align: end; + font-weight: var(--font-weight-bold); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Cover.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Cover.ts new file mode 100644 index 000000000..5c24f7c39 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Cover.ts @@ -0,0 +1,213 @@ +/* ============================================================================= + * M02_MasterTemplete_Sangeun_Cover.ts + * 단가산출(SK) 고침 표(PLAN 717-2b · 717-3 일위대가 모달도 씀) — M01 「단가산출 상세」 꼴(인력 · 재료 · 경비 · + * 하위 묶음 + 소계 · 계 · 열 = 이름 · 수량 · 단위 · 로직 · 단가 · 금액)에 품 · 단가 칸 고침 · 줄 빼기. + * 고친 칸 = 칸 색(`is-edited`) + 바탕 값 곁 글 + 「단가산출에서 고침」 꼬리표 · 값 = 서버 셈 답 그대로 + * 셈 길 = `calcSangeun`(`POST /layers/{층}/sangeun/calc` · 저장 안 함) — 부르는 쪽이 최신 응답만 받음 + * ========================================================================== */ + +import { createBadge } from "@ui/ui_template_badge"; +import { createButton } from "@ui/ui_template_button"; +import { el } from "@ui/ui_template_elements"; +import { createInputField } from "@ui/ui_template_input"; +import { t as L } from "@ui/ui_template_locale"; +import { tableRow, tableShell, type Column } from "@ui/ui_template_table"; +import { createText } from "@ui/ui_template_text"; +import { formatNumber } from "../M01_MasterData/M01_MasterData_UI_Logic_Edit"; +import { formatMoney, formatPrice } from "../M01_MasterData/M01_MasterData_UI_Logic_Money"; +import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import type { CoverRow, SkAnswer, SkDoc } from "./logic/M02_Sangeun_Cover_Model"; +import "./M02_MasterTemplete_Sangeun.css"; + +/** SK 셈(저장 안 함) — 문서 = 화면 문서 · 입력값 = 시험 입력 */ +export function calcSangeun( + scope: { layer: Layer; owner: string | null }, + doc: SkDoc, + given: Record = {}, +): Promise { + const q = + scope.owner && scope.layer !== "system" ? `?owner=${encodeURIComponent(scope.owner)}` : ""; + const 입력값 = Object.fromEntries(Object.entries(given).filter(([, v]) => String(v).trim())); + return call(`/layers/${scope.layer}/sangeun/calc${q}`, { + method: "POST", + body: JSON.stringify({ 문서: doc, 입력값 }), + }); +} + +export interface CoverTableOptions { + /** 고칠 수 있음(쓰기 층 · 읽기만 아님) */ + edit: boolean; + /** 열린입력 있는 SK — 품 칸 = 배율 */ + opened: boolean; + /** 고친 칸 꼬리표(「단가산출에서 고침」 · 717-3 = 「이 일위대가에서 고침」) */ + tag: string; + total: number | null; + /** 뺀 줄(cid · 이름) — 되살림 */ + removed: { cid: string; 이름: string }[]; + onQty: (row: CoverRow, value: number | null) => void; + onPrice: (row: CoverRow, value: number | null) => void; + onDrop: (row: CoverRow) => void; + onRestore: (cid: string) => void; +} + +const ORDER = ["인력", "재료", "경비", "하위"]; +/** 칸에 넣을 값 — 유효 자리만(긴 소수 줄임) */ +const shown = (v: number | null, digits: number): string => + v == null ? "" : String(Number(v.toPrecision(digits))); +const parse = (raw: string): number | null => { + const v = Number(raw.replaceAll(",", "").trim()); + return raw.trim() === "" || !Number.isFinite(v) || v < 0 ? null : v; +}; + +/** 고침 칸 — 숫자 입력(바뀜 = Enter · 칸 떠남) + 고쳤으면 바탕 값 곁 글 */ +function editCell( + value: string, + was: string | null, + covered: boolean, + unit: string, + done: (v: number | null) => void, +): HTMLElement { + const box = createInputField({ value, compact: true, size: 24, ...(unit ? { unit } : {}) }); + box.input.inputMode = "decimal"; + box.input.dataset.cover = ""; + const start = parse(value); // 보인 값 그대로면 고침 아님(자리 줄인 값) + box.input.addEventListener("change", () => { + const v = parse(box.input.value); + if (v !== start) done(v); + }); + box.input.addEventListener("keydown", (ev) => ev.key === "Enter" && box.input.blur()); + return el("div", { + className: "m02-sk__cell", + children: [ + box.root, + ...(covered && was !== null ? [createText(`${L("M02_SangeunWas")} ${was}`)] : []), + ], + }); +} + +function rowCells(r: CoverRow, o: CoverTableOptions): (string | Node)[] { + const can = o.edit; + const qtyText = r.수량 == null ? "—" : formatNumber(r.수량); + const wasQty = r.was?.qty == null ? null : formatNumber(r.was.qty); + const wasPrice = r.was?.단가 == null ? null : formatPrice(r.was.단가); + const scaled = o.opened && r.added === null; // 열린입력 SK = 품 칸이 배율 + const qty = can + ? editCell( + scaled ? String(r.배율 ?? 1) : shown(r.수량, 6), + wasQty, + r.covered.qty, + scaled ? "×" : "", + (v) => o.onQty(r, v), + ) + : qtyText; + const price = + can && r.priceEdit + ? editCell(shown(r.단가, 9), wasPrice, r.covered.price, "", (v) => o.onPrice(r, v)) + : r.단가 == null + ? "" + : formatPrice(r.단가); + const tags = [ + ...(r.covered.qty || r.covered.price ? [createBadge(o.tag, "warning")] : []), + ...(r.added !== null ? [createBadge(L("M02_SangeunAddedTag"), "accent")] : []), + ...(r.pct ? [createBadge(L("M02_SangeunPct"), "neutral")] : []), + ...(r.apart ? [createBadge(L("M02_SangeunApart"), "neutral")] : []), + ...(r.revised ? [createBadge(L("M02_SangeunRevised"), "danger")] : []), + ]; + const drop = createButton({ + label: L("M02_SangeunDrop"), + variant: "danger-ghost", + size: 22, + onClick: () => o.onDrop(r), + }); + return [ + r.이름, + qty, + r.단위, + el("div", { className: "m02-sk__tags", children: tags }), + price, + r.apart || r.금액 == null + ? r.금액 == null + ? "" + : `(${formatMoney(r.금액)})` + : formatMoney(r.금액), + ...(o.edit ? [drop] : []), + ]; +} + +/** 묶음 표 넷 + 계 + 뺀 줄 */ +export function coverTable(rows: CoverRow[], o: CoverTableOptions): HTMLElement { + const cols: Column[] = [ + { label: L("M02_SangeunColName") }, + { label: L("M02_SangeunColQty"), align: "end" }, + { label: L("M02_SangeunColUnit") }, + { label: L("M02_SangeunColLogic") }, + { label: L("M02_SangeunColPrice"), align: "end" }, + { label: L("M02_SangeunColAmount"), align: "end" }, + ...(o.edit ? [{ label: "", width: "5em" }] : []), + ]; + const names = [...new Set([...ORDER, ...rows.map((r) => r.묶음)])]; + const blocks = names.flatMap((name) => { + const mine = rows.filter((r) => r.묶음 === name); + if (!mine.length && !ORDER.slice(0, 3).includes(name)) return []; + const { root, tbody } = tableShell(cols, { density: "data" }); + for (const r of mine) { + const tr = tableRow(cols, rowCells(r, o)); + tr.dataset.cid = r.cid; + tr.querySelectorAll("td")[1]?.classList.toggle("is-edited", r.covered.qty); + tr.querySelectorAll("td")[4]?.classList.toggle("is-edited", r.covered.price); + tbody.append(tr); + } + const sub = mine.reduce((s, r) => s + (r.apart ? 0 : (r.금액 ?? 0)), 0); + const subRow = tableRow(cols, [ + { content: `${name} ${L("M02_SangeunSubtotal")}`, colSpan: 5 }, + formatMoney(sub), + ...(o.edit ? [""] : []), + ]); + subRow.dataset.subtotal = String(sub); + subRow.dataset.group = name; + tbody.append(subRow); + return [el("h4", { className: "m01lab__group", text: name }), root]; + }); + const removed = o.removed.length + ? [ + el("div", { + className: "m02-sk__removed", + attrs: { "data-removed": "" }, + children: [ + createText(L("M02_SangeunRemoved")), + ...o.removed.map((x) => + el("span", { + className: "m02-sk__removed-item", + children: [ + createBadge(x.이름, "neutral"), + ...(o.edit + ? [ + createButton({ + label: L("M02_SangeunRestore"), + variant: "ghost", + size: 22, + onClick: () => o.onRestore(x.cid), + }), + ] + : []), + ], + }), + ), + ], + }), + ] + : []; + return el("div", { + className: "m02-sk__cover", + attrs: { "data-sk-cover": "" }, + children: [ + ...blocks, + el("p", { + className: "m02-sk__total", + attrs: { "data-sum": o.total == null ? "" : String(o.total) }, + text: `${L("M02_SangeunTotal")} ${o.total == null ? "—" : formatMoney(o.total)}`, + }), + ...removed, + ], + }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts new file mode 100644 index 000000000..6b4a2276b --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts @@ -0,0 +1,324 @@ +/* ============================================================================= + * M02_MasterTemplete_Sangeun_Detail.ts + * M02 단가산출(SK) 상세(PLAN 717-2b · 요구서 4장 3 · 4 · 13 · 17 · 24) — M01 「단가산출 로직」 상세 부품을 M02 길로. + * 보기 넷(`LogicLab_View`) = 단가산출 상세(고침 표 · `Sangeun_Cover`) · 인력·재료·경비(공용 계산서 · SK 셈 답) · + * 시험 계산(`Logic_Calc` · 바탕 M01 로직) · 텍스트 수식(바탕 M01 로직 식) · 기본정보 = 바탕 로직(맨 위 접힘) + * 고침 = SK 덮개 · 더한줄(`Sangeun_Pick`) · 뺀줄(꼴 = 서버 `M02_Sangeun_Doc`) · 값 = 서버 셈 길 최신 응답만 + * 시험 입력 = SK 셈 `입력값`(저장 안 함) · [저장] = 화면 문서 → 서버가 다시 셈(원값 · 바탕판) + * 바탕 없는 SK = [M01 로직 고름] → 바탕 · 단위 · 바탕 없이 줄만도 됨 + * ========================================================================== */ + +import { createBadge } from "@ui/ui_template_badge"; +import { createBanner } from "@ui/ui_template_banner"; +import { createButton } from "@ui/ui_template_button"; +import { el } from "@ui/ui_template_elements"; +import { createInputField } from "@ui/ui_template_input"; +import { t as L } from "@ui/ui_template_locale"; +import { createText } from "@ui/ui_template_text"; +import { calcSheetRows } from "../A00_Common/calc_sheet"; +import { buildCalc } from "../M01_MasterData/M01_MasterData_UI_Logic_Calc"; +import { + fetchLogic, + setLogicApiPaths, + type LogicOne, + type LogicRow, + type TextAnswer, +} from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; +import { buildLabDetail } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Detail"; +import { createViewPicker } from "../M01_MasterData/M01_MasterData_UI_LogicLab_View"; +import type { Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; +import { calcSangeun, coverTable } from "./M02_MasterTemplete_Sangeun_Cover"; +import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick"; +import { + dropLine, + restoreLine, + rowsOf, + setPrice, + setQty, + sheetGroups, + type SkAnswer, + type SkDoc, +} from "./logic/M02_Sangeun_Cover_Model"; + +export interface SangeunDetailOptions { + layer: Layer; + owner: string | null; + readOnly: boolean; + onChanged: (dirty: boolean) => void; +} + +const LIB = { read: "/m02/logic-lib", calc: "/m02/logic-lib" }; +const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; +/** 결과단위 「원/10㎡」 → SK 단위 「㎡」(1 단위 값 · 배는 서버가 나눔) */ +const unitOf = (result: string): string => result.replace(/^원\s*\/\s*[\d.,]*\s*/, "").trim(); + +export function mountSangeunDetail( + host: HTMLElement, + doc0: unknown, + opt: SangeunDetailOptions, +): { getDoc: () => SkDoc; destroy: () => void } { + setLogicApiPaths(LIB); // 상세 부품 길 = M02 로그인 길(717-1) + const doc = clone((doc0 ?? {}) as SkDoc); + let saved = JSON.stringify(doc); + const edit = !opt.readOnly; + const scope = { layer: opt.layer, owner: opt.owner }; + const values: Record = Object.fromEntries( + Object.entries(doc.입력값 ?? {}).map(([k, v]) => [k, String(v)]), + ); + let base: LogicOne | null = null; + let text: TextAnswer | null = null; + let lastText: TextAnswer | undefined; + let ans: SkAnswer | null = null; + let bare: SkAnswer | null = null; + let fail = ""; + let seq = 0; + let alive = true; + + const head = el("div", { className: "m02-sk__head", attrs: { "data-sk-head": "" } }); + const notes = el("div"); + const editor = el("div", { className: "m01-logic__editor" }); + const views = createViewPicker(editor); + const calcHost = el("div", { className: "m01lab__calc" }); + const ownHost = el("div"); + const addButton = createButton({ + label: L("M02_SangeunAddLine"), + variant: "ghost", + size: 28, + onClick: () => + openSangeunLinePicker(scope, (line) => { + doc.더한줄 = [ + ...(doc.더한줄 ?? []), + line as unknown as NonNullable[number], + ]; + touch(); + }), + }); + addButton.hidden = !edit; + const baseHint = createText(L("M02_SangeunBaseViews")); + host.replaceChildren( + el("div", { + className: "m02-sk", + attrs: { "data-sangeun-detail": doc.키 ?? "" }, + children: [ + head, + notes, + el("div", { className: "m02-sk__head", children: [views.root, addButton, baseHint] }), + editor, + ], + }), + ); + + const opened = (): boolean => (doc.열린입력 ?? []).length > 0; + const baseKey = (): string | null => doc.바탕?.로직 ?? null; + + /** 바탕 없는 · 참조 바탕 SK 의 기본정보 자리 줄 */ + const standIn = (): LogicRow => ({ + 키: doc.키 ?? "", + 원문번호: "", + 이름: doc.이름 ?? "", + 결과단위: `원/${doc.단위 ?? ""}`, + 출처: doc.바탕?.참조?.키 ?? L("M02_SangeunNoBase"), + 입력: [], + 중간: [], + 호표: [], + 덧줄: [], + }); + + const touch = (again = true): void => { + opt.onChanged(edit && JSON.stringify(doc) !== saved); + if (again) void recalc(); + else drawHead(); + }; + + async function recalc(): Promise { + const mine = ++seq; + const plain = { ...doc, 덮개: {}, 뺀줄: [], 더한줄: [] }; + try { + const [got, was] = await Promise.all([ + calcSangeun(scope, doc, values), + doc.바탕 ? calcSangeun(scope, plain, values) : Promise.resolve(null), + ]); + if (mine !== seq || !alive) return; // 늦게 온 답 — 버림 + [ans, bare, fail] = [got, was, ""]; + } catch (error) { + if (mine !== seq || !alive) return; + [ans, fail] = [null, error instanceof Error ? error.message : String(error)]; + } + draw(); + } + + function drawHead(): void { + const field = (label: string, key: "이름" | "단위"): HTMLElement => { + const box = createInputField({ label, value: doc[key] ?? "", compact: true, size: 28 }); + box.input.dataset.sk = key; + box.input.disabled = !edit; + box.input.addEventListener("change", () => { + doc[key] = box.input.value.trim(); + box.setError(doc[key] ? undefined : L("M02_SangeunNeed")); + touch(key === "단위"); + }); + return box.root; + }; + const baseName = base ? `${base.logic.키} ${base.logic.이름}` : null; + const baseText = baseName ?? doc.바탕?.참조?.키 ?? L("M02_SangeunNoBase"); + const revised = !!(doc.바탕판 && ans?.바탕판 && doc.바탕판 !== ans.바탕판); + const pick = createButton({ + label: L("M02_SangeunPickBase"), + variant: "filled", + size: 28, + onClick: () => + openLogicPicker((x) => { + doc.바탕 = { 로직: x.키 }; + delete doc.바탕판; // 새 바탕 — 개정 견줄 판 없음 + if (!doc.단위 || doc.단위 === "식") doc.단위 = unitOf(x.결과단위) || doc.단위; + touch(false); + void loadBase(); + }), + }); + pick.hidden = !edit || !!doc.바탕 || !!doc.뺀줄?.length || !!Object.keys(doc.덮개 ?? {}).length; + head.replaceChildren( + field(L("M02_Name"), "이름"), + field(L("M02_SangeunUnit"), "단위"), + el("div", { + className: "m02-sk__tags", + attrs: { "data-sk-base": baseKey() ?? "" }, + children: [ + createText(`${L("M02_SangeunBase")} · ${baseText}`, "muted", 14), + ...(ans?.머리 ? [createBadge(ans.머리, "info")] : []), + ...(revised ? [createBadge(L("M02_SangeunRevisedBase"), "danger")] : []), + ...(opened() ? [createBadge(L("M02_SangeunScaleOnly"), "warning")] : []), + ], + }), + pick, + ); + } + + function drawNotes(): void { + const lost = ans?.짝잃음 ?? []; + const revised = !!(doc.바탕판 && ans?.바탕판 && doc.바탕판 !== ans.바탕판); + notes.replaceChildren( + ...(fail || ans?.까닭 + ? [createBanner({ tone: "danger", text: fail || String(ans?.까닭) })] + : []), + ...(revised ? [createBanner({ tone: "warning", text: L("M02_SangeunRevisedNote") })] : []), + ...(lost.length + ? [ + createBanner({ + tone: "warning", + text: L("M02_SangeunLost"), + details: lost.map((x) => `${x.cid} — ${x.까닭}`).join("\n"), + }), + ] + : []), + ); + } + + function draw(): void { + if (!alive) return; + drawHead(); + drawNotes(); + baseHint.hidden = !base; + const rows = ans ? rowsOf(ans, bare, doc) : []; + const names = new Map((bare ? rowsOf(bare, null, {}) : []).map((r) => [r.cid, r.이름])); + const ho = coverTable(rows, { + edit, + opened: opened(), + tag: L("M02_SangeunEditedHere"), + total: ans?.기준?.계 ?? null, + removed: (doc.뺀줄 ?? []).map((cid) => ({ cid, 이름: names.get(cid) ?? cid })), + onQty: (r, v) => (setQty(doc, r, v, opened()), touch()), + onPrice: (r, v) => (setPrice(doc, r, v), touch()), + onDrop: (r) => (dropLine(doc, r), touch()), + onRestore: (cid) => (restoreLine(doc, cid), touch()), + }); + const row = base?.logic ?? standIn(); + const est = el("div", { + attrs: { "data-est": "" }, + children: ans + ? calcSheetRows({ + 묶음: sheetGroups(rows), + 계: ans.기준?.계 ?? null, + sumLabel: `${L("M02_SangeunTotal")} (${row.결과단위 || "원"})`, + bundle: true, + inputs: row.입력, + values, + onInput, + }) + : [createText(L("M02_SangeunWait"))], + }); + buildLabDetail(editor, { + row, + file: base?.file ?? "", + reasons: base?.reasons ?? [], + prices: base?.prices ?? {}, + lines: null, + text, + lastText, + values, + ownHost, + onChange: () => undefined, + calcHost, + onInput, + cover: { ho, est }, + }); + } + + /** 텍스트 수식 · 계산서 입력 단추 — 시험 입력 고침 → 시험 계산 다시 그림 · 셈 */ + function onInput(name: string, v: string): void { + values[name] = v; + drawCalc(); + calcHost.querySelector("[data-calc-run]")?.click(); + } + + function drawCalc(): void { + if (!base) { + calcHost.replaceChildren(createText(L("M02_SangeunNoInputs"))); + return; + } + buildCalc(calcHost, { + savedKey: base.logic.키, + file: base.file, + row: base.logic, + prices: base.prices, + dirty: () => false, + values, + onText: (t) => { + text = t; + if (t.ok) lastText = t; + draw(); + }, + onLines: () => void recalc(), // 시험 입력이 바뀜 — SK 도 같은 입력으로 + }); + } + + async function loadBase(): Promise { + const key = baseKey(); + base = null; + text = null; + if (key) { + try { + base = await fetchLogic(key); + } catch (error) { + fail = error instanceof Error ? error.message : String(error); + } + } + if (!alive) return; + drawCalc(); + draw(); + await recalc(); + } + + void loadBase(); + return { + getDoc: () => { + if (ans?.바탕판 && doc.바탕) doc.바탕판 = ans.바탕판; // 저장 = 지금 바탕 확인(서버가 다시 적음) + saved = JSON.stringify(doc); + return clone(doc); + }, + destroy: () => { + alive = false; + host.replaceChildren(); + }, + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts index da3307701..e31ca48ec 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts @@ -1,61 +1,22 @@ /* ============================================================================= * M02_MasterTemplete_Sangeun_View.ts * 단가산출(SK) 상세 자리(717-2a) — 편집 칸 종류 sangeun 이 여는 곳(UI_Main 갈래 한 줄). - * 지금 = 이름 · 단위 · 바탕 · 고친 줄 요약(덮개 · 더한 줄 · 뺀 줄 · 열린 입력) + 상세 자리표. - * 717-2b(sub2) = 자리표 줄을 `mountSangeunDetail(slot, doc, opt)`(`Sangeun_Detail.ts`)로 갈음 · - * [저장]은 그 손잡이 `getDoc` · 고침 알림은 `opt.onChanged`. 지금 [저장] = 읽은 문서 그대로. + * 717-2b = 상세 · 고침(`Sangeun_Detail.ts` mountSangeunDetail) · [저장]은 그 손잡이 `getDoc` · 고침 알림 `opt.onChanged`. * ========================================================================== */ -import { el } from "@ui/ui_template_elements"; -import { createEmpty } from "@ui/ui_template_empty"; -import { createKv } from "@ui/ui_template_kv"; -import { t as L } from "@ui/ui_template_locale"; -import { tc } from "./M02_MasterTemplete_Labor_Text"; +import { mountSangeunDetail } from "./M02_MasterTemplete_Sangeun_Detail"; import type { Selection } from "./M02_MasterTemplete_UI_Main"; -/** SK 문서 중 이 자리가 읽는 칸(꼴 = 서버 `M02_Sangeun_Doc`) */ -export interface SangeunDoc { - 키?: string; - 이름?: string; - 단위?: string; - 바탕?: { 로직?: string; 참조?: { 키?: string } } | null; - 덮개?: Record; - 더한줄?: unknown[]; - 뺀줄?: unknown[]; - 열린입력?: string[]; -} - /** 고름(층 · 주인 · SK 키 = name) + 읽기만 · 고침 알림 */ export type SangeunViewOptions = Pick & { readOnly: boolean; onChanged: (dirty: boolean) => void; }; -/** 바탕 한 줄 — 로직 키 · 참조 SK 키 · 없으면 「바탕 없음」 */ -export const baseText = (doc: SangeunDoc): string => - doc.바탕?.로직 ?? doc.바탕?.참조?.키 ?? L("M02_SangeunNoBase"); - export function mountSangeunView( host: HTMLElement, doc: unknown, - _opt: SangeunViewOptions, + opt: SangeunViewOptions, ): { getDoc: () => unknown; destroy: () => void } { - const sk = (doc ?? {}) as SangeunDoc; - const count = (v: unknown[] | Record | undefined): number => - Array.isArray(v) ? v.length : Object.keys(v ?? {}).length; - const head = createKv([ - { label: L("M02_Name"), value: sk.이름 ?? "" }, - { label: tc("Lib_Unit"), value: sk.단위 ?? "" }, - { label: L("M02_SangeunBase"), value: baseText(sk) }, - { label: L("M02_SangeunCover"), value: count(sk.덮개) }, - { label: L("M02_SangeunAdded"), value: count(sk.더한줄) }, - { label: L("M02_SangeunRemoved"), value: count(sk.뺀줄) }, - { label: L("M02_SangeunOpened"), value: (sk.열린입력 ?? []).join(" · ") || null }, - ]); - const slot = el("div", { - attrs: { "data-sangeun-detail": sk.키 ?? "" }, - children: [createEmpty({ text: L("M02_SangeunDetailSoon") })], - }); - host.replaceChildren(el("div", { attrs: { "data-sangeun-view": "" }, children: [head, slot] })); - return { getDoc: () => doc, destroy: () => host.replaceChildren() }; + return mountSangeunDetail(host, doc, { ...opt, owner: opt.owner ?? null }); } diff --git a/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts b/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts new file mode 100644 index 000000000..13a288be4 --- /dev/null +++ b/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts @@ -0,0 +1,301 @@ +/* ============================================================================= + * M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts + * 단가산출(SK) 고침 꼴(PLAN 717-2b · 717-3 같이 씀) — 서버 셈 답(`/layers/{층}/sangeun/calc`) → 표 줄 · + * 고친 칸 → SK 문서 덮개 · 더한줄 · 뺀줄(꼴 = 서버 `M02_Sangeun_Doc`). 금액은 늘 서버가 셈 — + * 여기는 줄 모양 · 문서 칸 옮김만(값 셈 없음 · 로직 줄 단가를 노 · 재 · 경 몫 비로 나누는 것만). + * 줄 정체 cid = `kind:키#n`(바탕 줄) · `add:#k`(더한줄 k 째) · 덮개 원값 = 저장 때 서버가 다시 적음 + * (화면은 고친 첫때 바탕 값을 적어 둠 — 곁 글용) · 열린입력 있는 SK = 품은 배율만 + * 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장). + * ========================================================================== */ + +import type { SheetGroup } from "../../A00_Common/calc_sheet"; + +export type CoverPart = "labor" | "material" | "expense"; +export const PART_OF: Record = { + 노무비: "labor", + 재료비: "material", + 경비: "expense", +}; +const PARTS: CoverPart[] = ["labor", "material", "expense"]; + +export interface SkCover { + qty?: number; + 배율?: number; + labor?: number; + material?: number; + expense?: number; + 원값?: { qty?: number; 단가?: number }; +} + +export interface SkAdded { + 종류?: string; + 수량: number; + 단가?: number; + [k: string]: unknown; +} + +/** SK 문서 중 고침이 만지는 칸 */ +export interface SkDoc { + 키?: string; + 이름?: string; + 단위?: string; + 바탕?: { 로직?: string; 참조?: { 층?: string; 주인?: string | null; 키?: string } } | null; + 입력값?: Record; + 열린입력?: string[]; + 덮개?: Record; + 더한줄?: SkAdded[]; + 뺀줄?: string[]; + 고름?: Record; + 자재표시?: Record; + 바탕판?: string; + [k: string]: unknown; +} + +export interface SkLine { + 이름: string; + 단위?: string; + 수량: number | null; + 단가: number | null; + 금액: number | null; + 비목?: Record; + 묶음?: string | null; + cid: string; + 율?: number; +} + +export interface SkMat { + 차례: number; + 이름: string; + 단위?: string; + 수량: number | null; + 단가: number | null; + 금액: number | null; +} + +export interface SkAnswer { + 기준: Record | null; + 줄: SkLine[]; + 재료?: SkMat[]; + 까닭?: string | null; + 정체: string[]; + 짝잃음: { cid: string; 까닭: string; 바탕?: string }[]; + 바탕판: string; + 머리?: string; +} + +export interface CoverRow { + cid: string; + 묶음: string; + 이름: string; + 단위: string; + 수량: number | null; + 단가: number | null; + 금액: number | null; + /** 비목 몫 금액 */ + parts: Record; + /** % 덧줄 — 수량 = 율 · 단가 못 고침 */ + pct: boolean; + /** 더한줄 차례 · 바탕 줄 = null */ + added: number | null; + /** 기준 밖 재료(일위대가 자재목록으로 셈) — 금액이 계에 안 듦 */ + apart: boolean; + covered: { qty: boolean; price: boolean }; + /** 덮개 배율(열린입력 SK) */ + 배율: number | null; + /** 바탕 값(덮개 · 더한줄 · 뺀줄 없이 셈) · 없으면 null */ + was: { qty: number | null; 단가: number | null } | null; + /** 저장 때 적힌 원값이 지금 바탕 값과 다름(M01 개정) */ + revised: boolean; + priceEdit: boolean; +} + +/* ── 순수 셈 ── */ +const BUNDLE_OF: Record = { 노무비: "인력", 재료비: "재료", 경비: "경비" }; +const OWN_PART: Record = { 인력: "노무비", 재료: "재료비" }; +const ADD_RE = /^add:#(\d+)$/; +const COVER_KEYS = ["qty", "배율", ...PARTS] as const; + +const partsOf = (l: { 비목?: Record }): Record => + Object.fromEntries(Object.entries(l.비목 ?? {}).map(([k, v]) => [k, v ?? 0])); + +/** 바탕 값 — 바탕 셈(덮개 · 더한줄 · 뺀줄 없이)의 줄마다 품(덧줄 = 율) · 단가 */ +export function baseValues( + bare: SkAnswer | null, +): Map { + const out = new Map(); + if (!bare) return out; + for (const l of bare.줄) out.set(l.cid, { qty: l.율 ?? l.수량, 단가: l.단가 }); + for (const m of bare.재료 ?? []) { + const cid = bare.정체[m.차례]; + if (cid && !out.has(cid)) out.set(cid, { qty: m.수량, 단가: m.단가 }); + } + return out; +} + +const near = (a: number | null | undefined, b: number | null | undefined): boolean => + a != null && b != null && Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(a), Math.abs(b)); + +/** 셈 답 → 표 줄(엔진 줄 + 기준 밖 재료) */ +export function rowsOf(ans: SkAnswer, bare: SkAnswer | null, doc: SkDoc): CoverRow[] { + const was = baseValues(bare); + const rows: CoverRow[] = []; + const row = ( + cid: string, + l: { + 이름: string; + 단위?: string; + 수량: number | null; + 단가: number | null; + 금액: number | null; + }, + parts: Record, + extra: { 묶음?: string | null; 율?: number; apart?: boolean }, + ): CoverRow => { + const hit = ADD_RE.exec(cid); + const added = hit ? Number(hit[1]) : null; + const one = added === null ? undefined : doc.더한줄?.[added]; + const cover = added === null ? doc.덮개?.[cid] : undefined; + const pct = cid.startsWith("pct:"); + const keys = Object.keys(parts); + const base = added === null ? (was.get(cid) ?? null) : null; + const old = cover?.원값; + return { + cid, + 묶음: extra.묶음 || BUNDLE_OF[keys[0] ?? ""] || "하위", + 이름: l.이름, + 단위: pct ? "%" : (l.단위 ?? ""), + 수량: pct ? (extra.율 ?? l.수량) : l.수량, + 단가: pct ? null : l.단가, + 금액: l.금액, + parts, + pct, + added, + apart: !!extra.apart, + covered: { + qty: !!cover && ("qty" in cover || "배율" in cover), + price: added === null ? PARTS.some((k) => cover?.[k] != null) : one?.단가 != null, + }, + 배율: cover?.배율 ?? null, + was: base, + revised: + !!old && + !!base && + ((old.qty != null && !near(old.qty, base.qty)) || + (old.단가 != null && !near(old.단가, base.단가))), + priceEdit: !pct && (added === null || ["인력", "재료", "기계"].includes(String(one?.종류))), + }; + }; + for (const l of ans.줄) rows.push(row(l.cid, l, partsOf(l), l)); + const seen = new Set(rows.map((r) => r.cid)); + for (const m of ans.재료 ?? []) { + const cid = ans.정체[m.차례]; + if (!cid || seen.has(cid)) continue; + rows.push(row(cid, m, { 재료비: m.금액 ?? 0 }, { 묶음: "재료", apart: true })); + } + return rows; +} + +const memo = (base: CoverRow["was"]): SkCover["원값"] | undefined => { + if (!base) return undefined; + const out: { qty?: number; 단가?: number } = {}; + if (base.qty != null) out.qty = base.qty; + if (base.단가 != null) out.단가 = base.단가; + return Object.keys(out).length ? out : undefined; +}; + +function putCover(doc: SkDoc, r: CoverRow, cover: SkCover): void { + const covers = { ...(doc.덮개 ?? {}) }; + if (COVER_KEYS.some((k) => cover[k] != null)) { + const old = cover.원값 ?? memo(r.was); + covers[r.cid] = { ...cover, ...(old ? { 원값: old } : {}) }; + } else delete covers[r.cid]; + doc.덮개 = covers; +} + +/** 품 고침 — 더한줄 = 그 줄 수량 · 바탕 줄 = 덮개 qty(열린입력 SK = 배율 · 1 = 지움) · 바탕 값과 같거나 빈 값 = 덮개 지움 */ +export function setQty(doc: SkDoc, r: CoverRow, v: number | null, opened: boolean): void { + if (r.added !== null) { + const one = doc.더한줄?.[r.added]; + if (one && v !== null) one.수량 = v; + return; + } + const cover: SkCover = { ...(doc.덮개?.[r.cid] ?? {}) }; + delete cover.qty; + delete cover.배율; + if (v !== null && opened && v !== 1) cover.배율 = v; + if (v !== null && !opened && !near(v, r.was?.qty)) cover.qty = v; + putCover(doc, r, cover); +} + +/** 단가 고침 — 요소 줄 = 그 비목 칸 · 로직 줄 = 노 · 재 · 경 몫 비로 나눔 · 더한 요소 줄 = 그 줄 단가 */ +export function setPrice(doc: SkDoc, r: CoverRow, v: number | null): void { + if (!r.priceEdit) return; + if (r.added !== null) { + const one = doc.더한줄?.[r.added]; + if (!one) return; + if (v === null) delete one.단가; + else one.단가 = v; + return; + } + const cover: SkCover = { ...(doc.덮개?.[r.cid] ?? {}) }; + for (const k of PARTS) delete cover[k]; + if (v !== null && !near(v, r.was?.단가)) { + const keys = Object.keys(r.parts).filter((k) => PART_OF[k]); + const sum = keys.reduce((s, k) => s + r.parts[k], 0); + const own = PART_OF[keys[0] ?? ""] ?? PART_OF[OWN_PART[r.묶음] ?? "경비"]; + if (keys.length === 1 || !sum) cover[own] = v; + else for (const k of keys) cover[PART_OF[k]] = Math.round(((v * r.parts[k]) / sum) * 1e4) / 1e4; + } + putCover(doc, r, cover); +} + +/** 줄 빼기 — 더한줄 = 그 줄 지움 · 바탕 줄 = 뺀줄에 cid(덮개 · 고름 · 자재표시도 지움) */ +export function dropLine(doc: SkDoc, r: CoverRow): void { + if (r.added !== null) { + doc.더한줄 = (doc.더한줄 ?? []).filter((_, i) => i !== r.added); + return; + } + doc.뺀줄 = [...(doc.뺀줄 ?? []).filter((c) => c !== r.cid), r.cid]; + for (const k of ["덮개", "고름", "자재표시"] as const) { + const map = { ...((doc[k] as Record | undefined) ?? {}) }; + delete map[r.cid]; + (doc as Record)[k] = map; + } +} + +/** 뺀 줄 되살림 */ +export function restoreLine(doc: SkDoc, cid: string): void { + doc.뺀줄 = (doc.뺀줄 ?? []).filter((c) => c !== cid); +} + +const NUM = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 4 }); +const num = (v: number | null): string => (v == null ? "—" : NUM.format(v)); + +/** 표 줄 → 공용 계산서(`calc_sheet`) 비목 묶음 — 줄 글 「단가 * 품 = 금액」 · 기준 밖 재료 = 금액 없음 */ +export function sheetGroups(rows: CoverRow[]): SheetGroup[] { + const groups = new Map(); + const take = (k: string): SheetGroup => { + let g = groups.get(k); + if (!g) groups.set(k, (g = { 비목: k, 소계: 0, 줄: [] })); + return g; + }; + for (const r of rows) { + const entries = Object.entries(r.parts); + for (const [k, v] of entries.length ? entries : [["경비", 0] as [string, number]]) { + const share = entries.length > 1 && r.금액 ? (r.단가 ?? 0) * (v / r.금액) : r.단가; + const amount = r.apart ? null : v; + const g = take(k); + g.소계 = (g.소계 ?? 0) + (amount ?? 0); + g.줄.push({ + 이름: r.이름, + 글: r.pct ? `${num(r.수량)} %` : `${num(share)} * ${num(r.수량)} = ${num(amount)}`, + 금액: amount, + 묶음: r.묶음, + 장비: entries.length > 1 && r.cid.startsWith("logic:"), + }); + } + } + const order = Object.keys(PART_OF); + return [...groups.values()].sort((a, b) => order.indexOf(a.비목) - order.indexOf(b.비목)); +} +/* ── 순수 셈 끝 ── */ diff --git a/resources/tester/test_717_2b_sangeun_detail.py b/resources/tester/test_717_2b_sangeun_detail.py new file mode 100644 index 000000000..1318a36ab --- /dev/null +++ b/resources/tester/test_717_2b_sangeun_detail.py @@ -0,0 +1,184 @@ +"""717-2b M02 단가산출 상세 · 고침 지킴 시험 — 순수 꼴(node) · 글 · 서버 저장 왕복(임시 storage). + +- 고친 칸 → SK 덮개(qty · 배율 · 비목 칸 · 원값 곁 글) · 더한줄 수량 · 단가 · 뺀줄 · 되살림 +- 로직 줄 단가 = 노 · 재 · 경 몫 비로 나눔 · 계산서 묶음 = 표 줄 그대로(별도계상 재료 = 금액 없음) +- 화면 문서 꼴이 서버 저장(`clean_doc`)을 지나고 서버가 원값 · 바탕판을 다시 적음 · 셈 = 손셈 +- 상세 = M01 상세 부품(M02 길) · 부품 상자만 · 마스터 이동 단추 = 등록 없으면 숨김 +""" + +import json +import shutil +import subprocess +from decimal import Decimal +from pathlib import Path + +import pytest + +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_Calc as calc +from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +ROOT = Path(__file__).resolve().parents[2] +M02 = ROOT / "M02_MasterTemplete" +MODEL = M02 / "logic" / "M02_Sangeun_Cover_Model.ts" +BASE = "GC000278" + + +def _text(p: Path) -> str: + return p.read_text(encoding="utf-8") + + +def _node(body: str) -> object: + src = _text(MODEL) + "\n" + body + out = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", src], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert out.returncode == 0, out.stderr + return json.loads(out.stdout) + + +SAMPLE = """ +const line = (cid, 이름, 수량, 단가, 비목, 묶음) => ({ cid, 이름, 단위: "인", 수량, 단가, + 금액: Object.values(비목).reduce((a, b) => a + b, 0), 비목, 묶음 }); +const bare = { 기준: null, 짝잃음: [], 바탕판: "x", + 정체: ["labor:A#0", "logic:G#0", "material:M#0"], + 줄: [line("labor:A#0", "석공", 0.1, 1000, { 노무비: 100 }, "인력"), + line("logic:G#0", "굴착기", 2, 50, { 노무비: 60, 재료비: 30, 경비: 10 }, "경비")], + 재료: [{ 차례: 2, 이름: "모르타르", 수량: 0.5, 단가: 200, 금액: 100 }] }; +""" + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_고침_꼴(): + body = ( + SAMPLE + + """ +const doc = { 덮개: {}, 더한줄: [{ 종류: "재료", 요소: "MT1", 수량: 1 }], 뺀줄: [] }; +const ans = { ...bare, 정체: [...bare.정체, "add:#0"], + 줄: [...bare.줄, line("add:#0", "더한 재료", 1, 300, { 재료비: 300 }, "재료")] }; +const rows = rowsOf(ans, bare, doc); +const by = (cid) => rowsOf(ans, bare, doc).find((r) => r.cid === cid); +const out = {}; +out.rows = rows.map((r) => [r.cid, r.묶음, r.apart, r.added, r.priceEdit]); +setQty(doc, by("labor:A#0"), 0.2, false); +out.qty = doc.덮개["labor:A#0"]; +setQty(doc, by("labor:A#0"), 0.1, false); // 바탕 값과 같음 = 덮개 지움 +out.qtyBack = doc.덮개["labor:A#0"] ?? null; +setPrice(doc, by("labor:A#0"), 1200); +out.price = doc.덮개["labor:A#0"]; +setPrice(doc, by("logic:G#0"), 100); // 노 60 · 재 30 · 경 10 비 +out.split = doc.덮개["logic:G#0"]; +setQty(doc, by("add:#0"), 3, false); +setPrice(doc, by("add:#0"), 350); +out.added = doc.더한줄[0]; +const opened = { 덮개: {} }; +setQty(opened, by("labor:A#0"), 1.5, true); +out.scale = opened.덮개["labor:A#0"]; +setQty(opened, by("labor:A#0"), 1, true); +out.scaleBack = opened.덮개["labor:A#0"] ?? null; +dropLine(doc, by("labor:A#0")); +out.drop = [doc.뺀줄, doc.덮개["labor:A#0"] ?? null]; +restoreLine(doc, "labor:A#0"); +out.restore = doc.뺀줄; +dropLine(doc, by("add:#0")); +out.dropAdded = doc.더한줄.length; +const covered = { 덮개: { "labor:A#0": { qty: 0.3, 원값: { qty: 0.09, 단가: 1000 } } } }; +out.revised = rowsOf(bare, bare, covered).find((r) => r.cid === "labor:A#0").revised; +out.sheet = sheetGroups(rowsOf(bare, bare, {})) + .map((g) => [g.비목, g.소계, g.줄.map((x) => x.금액)]); +console.log(JSON.stringify(out)); +""" + ) + got = _node(body) + assert got["rows"] == [ + ["labor:A#0", "인력", False, None, True], + ["logic:G#0", "경비", False, None, True], + ["add:#0", "재료", False, 0, True], + ["material:M#0", "재료", True, None, True], + ] + assert got["qty"] == {"qty": 0.2, "원값": {"qty": 0.1, "단가": 1000}} + assert got["qtyBack"] is None + assert got["price"] == {"labor": 1200, "원값": {"qty": 0.1, "단가": 1000}} + assert got["split"]["labor"] == 60 and got["split"]["material"] == 30 + assert got["split"]["expense"] == 10 + assert got["added"]["수량"] == 3 and got["added"]["단가"] == 350 + assert got["scale"]["배율"] == 1.5 and "qty" not in got["scale"] + assert got["scaleBack"] is None + assert got["drop"] == [["labor:A#0"], None] + assert got["restore"] == [] and got["dropAdded"] == 0 + assert got["revised"] is True + # 계산서 = 표 줄 그대로 · 별도계상 재료 금액 없음(계에 안 듦) + assert got["sheet"] == [ + ["노무비", 160, [100, 60]], + ["재료비", 30, [30, None]], + ["경비", 10, [10]], + ] + + +def test_상세_부품_길_부품상자(): + view = _text(M02 / "M02_MasterTemplete_Sangeun_View.ts") + detail = _text(M02 / "M02_MasterTemplete_Sangeun_Detail.ts") + cover = _text(M02 / "M02_MasterTemplete_Sangeun_Cover.ts") + assert "mountSangeunDetail(host, doc" in view and "createEmpty" not in view + assert 'read: "/m02/logic-lib"' in detail and "setLogicApiPaths(LIB)" in detail + assert "buildLabDetail(editor" in detail and "cover: { ho, est }" in detail + assert "buildCalc(calcHost" in detail and "openSangeunLinePicker(scope" in detail + assert "if (mine !== seq || !alive) return" in detail # 최신 응답만 + for s in (detail, cover, _text(MODEL)): + assert "