diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index 6907c5790..9e2eb9ce3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -12,6 +12,7 @@ import { type SubBrief, } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; import type { LaborCalc, LaborFixed, LaborRef, LaborSpec } from "./M02_MasterTemplete_Labor_Calc"; +import type { LinePatch } from "./M02_Sangeun_Cover_Model"; import { call, getActiveLayer, @@ -184,6 +185,8 @@ export interface LaborRow { 자재표시?: Record; /** 고정 줄(705-1 · 705-2 붙여넣기) — 숫자 그대로 · 로직 · 참조 없음 */ 고정?: LaborFixed; + /** 그 줄 덮개(715-3 · 717-3 단가산출 모달) — 로직 줄 · SK 참조 줄 · 서버가 검사 */ + 패치?: LinePatch; } /** 자재 세 칸(148-4 · 서버 `M02_Labor_Doc.MARKS` 와 같음) — 빈 칸 = 읽을 때 기본값 · 금액 무관 */ export const MAT_MARKS = { @@ -264,6 +267,7 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc { ...(r.승격 === true && !r.참조 ? { 승격: true } : {}), ...(r.참조 || r.고정 ? {} : cleanShown(r.자재표시)), ...(r.고정 ? { 고정: r.고정 } : {}), // 705-1 — 읽을 때 떨구면 [저장]에서 줄이 사라짐 + ...(r.패치 ? { 패치: r.패치 } : {}), // 717-3 — 떨구면 [저장]에서 줄 덮개가 사라짐 })), 자재: (d.자재 ?? []).map((m) => ({ 코드: m.코드, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index d4707fb31..d79b2c472 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -54,6 +54,7 @@ import { import { openLaborInput } from "./M02_MasterTemplete_Labor_Input"; import { openQtyModal } from "./M02_MasterTemplete_Labor_QtyModal"; import { openRefEdit } from "./M02_MasterTemplete_Labor_RefEdit"; +import { openRowSangeun } from "./M02_MasterTemplete_Labor_SangeunModal"; import { afterRowDrag } from "./M02_MasterTemplete_Labor_RowDrag"; import { openLogicPicker, withHopyo } from "./M02_MasterTemplete_Labor_Pick"; import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats"; @@ -206,8 +207,9 @@ export function mountLaborDetail( alias: setAlias, // 134-1 — 구조물 일위대가 로직 줄만(사전조합 안 로직 줄엔 없음) ...(lib ? {} : { promote: setPromote }), + ...sk, } - : { open: pick, option: openRow, ref: openRef }, + : { open: pick, option: openRow, ref: openRef, ...sk }, ), ), ); @@ -455,6 +457,33 @@ export function mountLaborDetail( }); } + /** [단가산출 보기](717-3) — 고침 = 그 줄 패치(화면 캐시) · 프로젝트 복사본(셈 주입)엔 없음 */ + const sk = opt.calc ? {} : { sangeun: openSk }; + function openSk(i: number): void { + const row = cur.담은로직[i]; + const line = lineOf(lineId(row) ?? ""); + const name = (cur as { 이름?: string }).이름 || opt.column; + openRowSangeun({ + scope: { layer: opt.layer, owner: library.owner }, + row, + title: + `${row.참조?.키 ?? row.로직} ${(row.참조 ? line?.이름 : logics.get(row.로직)?.이름) ?? ""}`.trim(), + head: [name, cur.단위 ? `1 ${cur.단위} 당` : ""].filter(Boolean).join(" · "), + qty: qtyShort(line, line?.분모 ?? "", cells()), + unit: line?.분모 ?? "", + readOnly: !edit, + apply: (p) => { + if (p) row.패치 = p; + else delete row.패치; + changed(); + }, + // 새 SK 참조 줄로 = 자기 개인 층 일위대가만(위 층 일위대가는 개인 SK 를 못 가리킴) + ...(edit && opt.layer === "personal" && !library.owner + ? { replace: (next: LaborRow) => ((cur.담은로직[i] = next), changed()) } + : {}), + }); + } + /** 수량산출 칸 누름 — 로직 줄 */ function openQty(i: number): void { const row = cur.담은로직[i]; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index d3bc1ad1c..eb0cceb42 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -215,6 +215,8 @@ export interface RowHandlers { alias?: (i: number, value: string | null) => void; /** 구분 드롭다운(134-1 · 로직 줄 단가산출 ↔ 단가승격) — 없으면 글만 */ promote?: (i: number, on: boolean) => void; + /** [단가산출 보기](717-3 · 로직 줄 · SK 참조 줄 모달) — 없으면 단추 없음 */ + sangeun?: (i: number) => void; /** 자재 세 칸(148-4) — 없으면 글만 */ mark?: (i: number, key: MatMarkKey, v: string) => void; } @@ -330,9 +332,9 @@ function subRows(line: LaborCalcLine | undefined, wait: boolean, tools: boolean) /** 담은 로직 표 — 보기 갈래는 `on` 없이 */ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement { const wait = !data.calc && !data.fail; - const { move, remove, option, ref } = on ?? {}; + const { move, remove, option, ref, sangeun } = on ?? {}; // 옵션 · 삭제만(구조물 조합 카드 · 43-4)이어도 끝 칸 — 순서 단추는 `move` 가 있을 때만 - const tools = !!(move || remove || option || ref); + const tools = !!(move || remove || option || ref || sangeun); const group = (children: HTMLElement[]): HTMLElement => el("div", { className: "ui-btn-group", children }); const lineOf = (key: string): LaborCalcLine | undefined => @@ -457,6 +459,9 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement ] : []), group([ + ...(sangeun && !fx && (!row.참조 || row.참조.키.startsWith("SK-")) + ? [small(tc("Labor_Sangeun"), () => sangeun(i))] + : []), ...(row.참조 ? ref && !row.참조.키.startsWith("SP-") ? [small(tc("Labor_Edit"), () => ref(i))] diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_SangeunModal.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_SangeunModal.ts new file mode 100644 index 000000000..c4ef25f98 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_SangeunModal.ts @@ -0,0 +1,241 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_SangeunModal.ts + * 일위대가 줄 [단가산출 보기] 모달(PLAN 717-3 · 요구서 내역12 4장 4 · 16 · 24 · 4-1 확정 5) — + * 로직 줄 · 단가산출(SK) 참조 줄 하나의 단가산출을 보고 그 일위대가에서만 고침. + * · 위 = 일위대가 머리(「1 ㎡ 당」) · 이 줄 수량 · 1 단위 금액 · 짝 잃은 덮개 알림 + * · 보기 고르기 = 인력·재료·경비(기본 · 공용 계산서 `calc_sheet`) | 단가산출 상세(품 · 단가 칸 고침 · `Sangeun_Cover`) + * · 고침 = 그 일위대가 줄 칸 `패치 {덮개, 뺀줄, 더한줄}` · [적용] = 화면 캐시(저장은 페이지 [저장] · + * 원값은 저장 때 서버가 셈) · 적용 안 하고 닫으면 물음 + * · [단가산출로 저장] = 개인 층 새 SK(이름 받음 · `from-line`) · 일위대가가 자기 개인 층이면 그 줄을 새 SK 참조 줄로 + * · 셈 = 서버 셈 길(로그인 · 저장 안 함 · 최신 응답만 · CLAUDE.md 5장) + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { askConfirm, askText, openModal } from "@ui/ui_template_modal"; +import { createSegment } from "@ui/ui_template_segment"; +import { createText } from "@ui/ui_template_text"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { calcSheetRows } from "../A00_Common/calc_sheet"; +import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money"; +import type { LaborRow } from "./M02_MasterTemplete_Labor_Api"; +import { + calcSangeun, + coverTable, + sangeunFromLine, + type SkScope, +} from "./M02_MasterTemplete_Sangeun_Cover"; +import { + cleanPatch, + lineDoc, + needsIds, + setCover, + sheetGroups, + type LinePatch, + type SkCalc, + type SkLine, +} from "./M02_Sangeun_Cover_Model"; + +const TEXT = { + Title: ["단가산출", "Unit-price build-up"], + Sheet: ["인력·재료·경비", "Labor · material · expense"], + Detail: ["단가산출 상세", "Detail"], + Qty: ["이 줄 수량", "Line quantity"], + Per: ["1 {u} 당", "per 1 {u}"], + Sum: ["계", "Total"], + Name: ["이름", "Name"], + Poom: ["품 · 수량", "Qty"], + Unit: ["단위", "Unit"], + Price: ["단가", "Unit price"], + Amount: ["금액", "Amount"], + Was: ["원래", "was"], + Tag: ["이 일위대가에서 고침", "Edited in this unit price"], + Empty: ["줄 없음", "No lines"], + Loading: ["셈하는 중…", "Calculating…"], + Lost: ["덮개 짝 잃음", "Override lost"], + Apply: ["적용", "Apply"], + ToSk: ["단가산출로 저장", "Save as unit-price build-up"], + AskName: ["새 단가산출 이름(내 개인 층)", "New build-up name (my layer)"], + Saved: ["{k} 저장함(내 개인 층)", "{k} saved (my layer)"], + Replaced: ["이 줄을 {k} 참조로 바꿈 — [저장]으로 남김", "Line now refers to {k} — press [Save]"], + Discard: ["적용 안 한 고침이 있음 — 버리고 닫을까?", "Discard unapplied edits?"], + Close: ["닫기", "Close"], + ReadOnly: ["읽기만 — 고친 값은 [단가산출로 저장]으로만 남김", "Read only — use [Save as]"], +} as const satisfies Record; +const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; + +export interface RowSangeunOptions { + /** 그 일위대가 층(셈 길 · 참조 위 층 검사) */ + scope: SkScope; + row: LaborRow; + /** 줄 이름(로직 키 · 이름) */ + title: string; + /** 일위대가 머리(이름 · 「1 ㎡ 당」) */ + head: string; + /** 이 줄 지금 수량 글(「수기 5 ㎥」) */ + qty: string; + /** 줄 결과 단위(분모 · SK 단위 기본) */ + unit: string; + readOnly: boolean; + /** [적용] — 그 줄 패치(null = 고침 없음) */ + apply: (patch: LinePatch | null) => void; + /** [단가산출로 저장] 뒤 그 줄을 새 SK 참조 줄로(없으면 안 바꿈) */ + replace?: (row: LaborRow) => void; +} + +const same = (a: LinePatch | null, b: LinePatch | null): boolean => + JSON.stringify(a) === JSON.stringify(b); + +export function openRowSangeun(o: RowSangeunOptions): void { + let applied = cleanPatch(o.row.패치); + let patch = applied; + let ids: string[] | null = null; + let orig = new Map(); + let calc: SkCalc | null = null; + let fail: string | null = null; + let view = "sheet"; + let seq = 0; + let timer = 0; + const info = el("div", { className: "m02-std-sheet__body", attrs: { "data-sk-head": "" } }); + const main = el("div", { className: "m02-std-sheet__body", attrs: { "data-sk-body": "" } }); + const seg = createSegment({ + options: [ + { value: "sheet", label: t("Sheet") }, + { value: "detail", label: t("Detail") }, + ], + value: view, + ariaLabel: t("Title"), + onChange: (v) => { + view = v; + draw(); + }, + }); + + const draw = (): void => { + const unit = o.unit || "식"; + const one = calc?.["1단위"]?.계; + info.replaceChildren( + createText(o.head, "muted", 14), + createText(`${o.title} · ${t("Qty")} ${o.qty}`, "muted", 14), + ...(one != null ? [createText(`${t("Per").replace("{u}", unit)} ${formatMoney(one)}`)] : []), + ...(o.readOnly ? [createText(t("ReadOnly"), "hint")] : []), + ...(calc?.짝잃음 ?? []).map((x) => + createText(`${t("Lost")} ${x.cid} · ${x.까닭}`, "warning"), + ), + ); + if (!calc) + return void main.replaceChildren(createText(fail ?? t("Loading"), fail ? "error" : "muted")); + const base = calc.배 && calc.배 !== 1 ? `${calc.배} ${unit}` : `1 ${unit}`; + main.replaceChildren( + ...(fail ? [createText(fail, "error")] : []), + ...(view === "sheet" + ? calcSheetRows({ + 묶음: sheetGroups(calc), + 계: calc.기준?.계 ?? null, + sumLabel: `${t("Sum")} (${base})`, + }) + : [ + coverTable(calc, orig, patch, { + edit: true, + text: { + name: t("Name"), + qty: t("Poom"), + unit: t("Unit"), + price: t("Price"), + amount: t("Amount"), + was: t("Was"), + tag: t("Tag"), + empty: t("Empty"), + }, + onCover: (cid, key, value) => { + patch = setCover(patch, cid, key, value); + recalc(); + }, + }), + ]), + ); + applyBtn.disabled = o.readOnly || same(patch, applied); + }; + + /** 서버 셈 — 늦게 온 답은 버림 */ + const recalc = (): void => { + window.clearTimeout(timer); + timer = window.setTimeout(async () => { + const mine = ++seq; + try { + const got = await calcSangeun(o.scope, lineDoc(o.row, ids, patch)); + if (mine !== seq) return; + calc = got; + fail = null; + } catch (error) { + if (mine !== seq) return; + fail = error instanceof Error ? error.message : String(error); + } + draw(); + }, 150); + }; + + let close = (): void => {}; + const applyBtn = createButton({ + label: t("Apply"), + variant: "filled", + onClick: () => { + o.apply(patch); + applied = patch; + close(); + }, + }); + applyBtn.dataset.skApply = ""; + const saveBtn = createButton({ + label: t("ToSk"), + variant: "ghost", + onClick: async () => { + const name = (await askText({ message: t("AskName"), value: o.title }))?.trim(); + if (!name) return; + const line: Record = { ...o.row }; + delete line.패치; + if (patch) line.패치 = patch; + try { + const got = await sangeunFromLine( + { layer: "personal", owner: null }, + line, + name, + o.unit || "식", + ); + showToast(t("Saved").replace("{k}", got.이름), "success"); + if (!o.replace) return; + o.replace({ 로직: "", 입력값: {}, 메모: null, 별칭: null, 수량: o.row.수량, ...got.줄 }); + showToast(t("Replaced").replace("{k}", got.이름), "info"); + applied = patch; + close(); + } catch (error) { + showToast(error instanceof Error ? error.message : String(error), "error"); + } + }, + }); + saveBtn.dataset.skSave = ""; + ({ close } = openModal({ + title: `${t("Title")} — ${o.title}`, + closeLabel: t("Close"), + width: "1100px", + wide: true, + actions: [seg.root, saveBtn, applyBtn], + beforeClose: () => same(patch, applied) || askConfirm({ message: t("Discard"), danger: true }), + mount: (body) => body.append(info, main), + })); + draw(); + + // 원래 값(그 일위대가 고침 없음) 먼저 — 고름 · 자재표시가 있으면 바탕 줄 정체부터 + void (async () => { + try { + if (needsIds(o.row)) + ids = (await calcSangeun(o.scope, lineDoc(o.row, null, null))).정체 ?? null; + const base = await calcSangeun(o.scope, lineDoc(o.row, ids, null)); + orig = new Map(base.줄.map((x) => [x.cid, x])); + if (patch) return recalc(); + calc = base; + } catch (error) { + fail = error instanceof Error ? error.message : String(error); + } + draw(); + })(); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index 3e898c675..2c48354bb 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -37,6 +37,7 @@ const TEXT = { Remove: ["삭제", "Delete"], Labor_Option: ["옵션", "Options"], Labor_Edit: ["수정", "Edit"], + Labor_Sangeun: ["단가산출 보기", "Build-up"], Ref_Shared: [ "서고 원본을 고침 — 이 문서를 쓰는 모든 구조물에 따라감", "Edits the library original — every structure using it follows", diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Cover.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Cover.ts new file mode 100644 index 000000000..e07b2d484 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Cover.ts @@ -0,0 +1,151 @@ +/* ============================================================================= + * M02_MasterTemplete_Sangeun_Cover.ts + * 단가산출 상세 고침 표 · 셈 길 부름(PLAN 717-3 · 요구서 내역12 4장 4 · 24) — 일위대가 모달 · 717-2b 상세가 같이 씀. + * · 표 = 이름 · 품 · 단위 · 단가 · 금액(서버 셈 값) · 품 · 단가 칸 = 글 칸(Enter · 떠남 때 `onCover`) · + * 고친 줄 = 표 부품 「바뀜」(tr.is-changed) + 원래 값 곁 + 꼬리표(「이 일위대가에서 고침」 등) + * · 셈 = `POST /layers/{층}/sangeun/calc`(로그인 · 저장 안 함) · [단가산출로 저장] = `/sangeun/from-line` + * · 단가 칸은 한 비목 줄만 · 덧줄(%) = 율만(서버 `_covered` 규칙 · 틀리면 서버 까닭 그대로) + * ========================================================================== */ + +import { createBadge } from "@ui/ui_template_badge"; +import { el } from "@ui/ui_template_elements"; +import { createInputField } from "@ui/ui_template_input"; +import { tableRow, tableShell, type Column } from "@ui/ui_template_table"; +import { createText } from "@ui/ui_template_text"; +import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money"; +import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import { + priceKey, + qtyOf, + type CoverKey, + type LinePatch, + type PatchRow, + type SkCalc, + type SkLine, +} from "./M02_Sangeun_Cover_Model"; + +export interface SkScope { + layer: Layer; + owner: string | null; +} +const q = (owner: string | null): string => (owner ? `?owner=${encodeURIComponent(owner)}` : ""); + +/** 셈(저장 안 함) — 문서 = 화면 문서 · 서버가 M01 엔진 한 벌로 셈 */ +export const calcSangeun = (scope: SkScope, doc: Record): Promise => + call(`/layers/${scope.layer}/sangeun/calc${q(scope.owner)}`, { + method: "POST", + body: JSON.stringify({ 문서: doc }), + }); + +/** [단가산출로 저장] — 그 층 새 SK · 답 = 새 키 + 그 SK 참조 줄 */ +export const sangeunFromLine = ( + scope: SkScope, + line: PatchRow & Record, + 이름: string, + 단위: string, +): Promise<{ 이름: string; 줄: Record }> => + call(`/layers/${scope.layer}/sangeun/from-line${q(scope.owner)}`, { + method: "POST", + body: JSON.stringify({ 줄: line, 이름, 단위 }), + }); + +const num = (v: number | null | undefined): string => + v == null ? "—" : Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 }); + +export interface CoverTableText { + name: string; + qty: string; + unit: string; + price: string; + amount: string; + /** 원래 값 곁 글 머리(「원래」) */ + was: string; + /** 고친 줄 꼬리표(「이 일위대가에서 고침」) */ + tag: string; + empty: string; +} + +export interface CoverTableOptions { + edit: boolean; + text: CoverTableText; + /** 칸 고침 — 빈 글 = null(그 칸 원래 값) */ + onCover: (cid: string, key: CoverKey, value: number | null) => void; +} + +/** 고칠 칸 — 글 칸(Enter · 떠남 때 넘김 · 숫자 아니면 그대로 되돌림) · 곁에 원래 값 */ +function cell( + shown: number | null | undefined, + was: number | null | undefined, + covered: boolean, + o: CoverTableOptions, + set: ((v: number | null) => void) | null, +): HTMLElement { + const note = + covered && was != null && was !== shown ? [createText(`${o.text.was} ${num(was)}`)] : []; + if (!o.edit || !set) return el("div", { children: [el("span", { text: num(shown) }), ...note] }); + const box = createInputField({ value: shown == null ? "" : String(shown), compact: true }); + box.input.inputMode = "decimal"; + box.input.dataset.coverCell = ""; + box.input.addEventListener("keydown", (e) => e.key === "Enter" && box.input.blur()); + box.input.addEventListener("change", () => { + const raw = box.input.value.replace(/,/g, "").trim(); + const v = raw === "" ? null : Number(raw); + if (v !== null && (!Number.isFinite(v) || v < 0)) { + box.input.value = shown == null ? "" : String(shown); + return; + } + set(v); + }); + return el("div", { children: [box.root, ...note] }); +} + +/** 고침 표 — `calc` = 지금 셈 답 · `orig` = 고치기 전 셈 줄(cid → 줄) · `patch` = 지금 줄 패치 */ +export function coverTable( + calc: SkCalc, + orig: ReadonlyMap, + patch: LinePatch | null, + o: CoverTableOptions, +): HTMLElement { + const cols: Column[] = [ + { label: o.text.name }, + { label: o.text.qty, align: "end", width: "10rem" }, + { label: o.text.unit, width: "5rem" }, + { label: o.text.price, align: "end", width: "10rem" }, + { label: o.text.amount, align: "end", width: "9rem" }, + ]; + const { root, tbody } = tableShell(cols, { density: "data" }); + for (const line of calc.줄) { + const mine = patch?.덮개?.[line.cid] ?? {}; + const was = orig.get(line.cid); + const pk = priceKey(line); + const covered = Object.keys(mine).length > 0 || (line.덮개?.length ?? 0) > 0; + const name = el("div", { + children: [ + el("span", { text: line.이름, attrs: { title: line.출처 ?? line.이름 } }), + ...(Object.keys(mine).length ? [createBadge(o.text.tag, "warning", { size: 18 })] : []), + ], + }); + const tr = tableRow(cols, [ + name, + // 고친 칸 = 적은 값(서버가 아직 안 받는 칸도 보임) · 그 밖 = 서버 셈 값 + cell(mine.qty ?? qtyOf(line), was && qtyOf(was), "qty" in mine, o, (v) => + o.onCover(line.cid, "qty", v), + ), + line.율 != null ? "%" : (line.단위 ?? ""), + cell( + (pk && mine[pk]) ?? line.단가, + was?.단가, + !!pk && pk in mine, + o, + pk ? (v) => o.onCover(line.cid, pk, v) : null, + ), + line.금액 == null ? "—" : formatMoney(line.금액), + ]); + tr.dataset.cid = line.cid; + if (covered) tr.classList.add("is-changed"); + tbody.append(tr); + } + if (!calc.줄.length) + tbody.append(tableRow(cols, [{ content: calc.까닭 ?? o.text.empty, colSpan: cols.length }])); + return root; +} diff --git a/M02_MasterTemplete/M02_Sangeun_Cover_Model.ts b/M02_MasterTemplete/M02_Sangeun_Cover_Model.ts new file mode 100644 index 000000000..37c0bd9f1 --- /dev/null +++ b/M02_MasterTemplete/M02_Sangeun_Cover_Model.ts @@ -0,0 +1,166 @@ +/* ============================================================================= + * M02_Sangeun_Cover_Model.ts + * 단가산출 고친 칸(덮개) 순수 함수(PLAN 717-3 · 요구서 내역12 4장 4 · 16 · 24) — 화면 · fetch 없음. + * · `lineDoc` = 일위대가 줄 → 이름 없는 단가산출(SK) 문서(서버 `M02_Labor_Patch.as_doc` 꼴 · 셈 길 몸) · + * 줄 `고름` · `자재표시`(호표 차례 열쇠)는 바탕 줄 정체(`ids` · 셈 답 `정체`)로 cid 열쇠로 바꿈 + * · `setCover` · `cleanPatch` = 줄 칸 `패치 {덮개, 뺀줄, 더한줄}` 고침(숫자만 · `원값`은 서버가 저장 때 셈) + * · `sheetGroups` = 셈 답 줄 → 공용 계산서(`A00_Common/calc_sheet`) 묶음(값 = 서버 셈 그대로 · 다시 안 셈) + * 계산 자리 = 서버(`M02_Sangeun_Calc`) 단독 — 여기는 문서 모양만. + * ========================================================================== */ + +export const PARTS = ["노무비", "재료비", "경비"] as const; +export type Part = (typeof PARTS)[number]; +/** 덮개 칸(서버 `M02_Sangeun_Doc.COVER_KEYS`) — qty = 품 · labor · material · expense = 줄 단가 */ +export const PART_KEY = { 노무비: "labor", 재료비: "material", 경비: "expense" } as const; +export type CoverKey = "qty" | "배율" | (typeof PART_KEY)[Part]; +export type Cover = Partial>; +export interface LinePatch { + 덮개?: Record; + 뺀줄?: string[]; + 더한줄?: unknown[]; +} +export type Money = Record; + +/** 셈 답 한 줄(M01 시험 계산 줄 + cid · 고친 칸 이름) */ +export interface SkLine { + cid: string; + 이름: string; + 단위?: string; + 수량: number; + 단가?: number; + 금액?: number; + 비목?: Partial>; + 율?: number | null; + 출처?: string; + 덮개?: string[]; +} +export interface SkCalc { + 기준: Money | null; + 줄: SkLine[]; + 까닭?: string | null; + "1단위"?: Money | null; + 배?: number; + 정체?: string[]; + 짝잃음?: { cid: string; 까닭: string }[]; + 머리?: string | null; +} + +/** 줄 꼴(일위대가 `담은로직[]` 한 줄 · 쓰는 칸만) */ +export interface PatchRow { + 로직?: string; + 참조?: { 층: string; 주인?: string | null; 키: string }; + 입력값?: Record; + 켜쌓기율?: number | null; + 고름?: Record; + 자재표시?: Record; + 패치?: LinePatch; +} + +const keyed = (value: Record | undefined, ids: string[]) => + Object.fromEntries( + Object.entries(value ?? {}).flatMap(([n, v]) => + /^\d+$/.test(n) && Number(n) < ids.length ? [[ids[Number(n)], v]] : [], + ), + ); + +/** 고름 · 자재표시가 있는 로직 줄 = 바탕 줄 정체가 먼저 있어야 함 */ +export const needsIds = (row: PatchRow): boolean => + !row.참조 && + (Object.keys(row.고름 ?? {}).length > 0 || Object.keys(row.자재표시 ?? {}).length > 0); + +/** 줄 → 이름 없는 SK 문서 — `patch` null = 그 일위대가 고침 없는 원래 값 */ +export function lineDoc( + row: PatchRow, + ids: string[] | null, + patch: LinePatch | null, +): Record { + const head = { + 키: "SK-000000", + 이름: "줄", + 단위: "식", + 덮개: patch?.덮개 ?? {}, + 뺀줄: patch?.뺀줄 ?? [], + 더한줄: patch?.더한줄 ?? [], + }; + if (row.참조) return { ...head, 바탕: { 참조: row.참조 } }; + return { + ...head, + ...(row.켜쌓기율 != null ? { 켜쌓기율: row.켜쌓기율 } : {}), + 바탕: { 로직: row.로직 ?? "" }, + 입력값: { ...(row.입력값 ?? {}) }, + 고름: ids ? keyed(row.고름, ids) : {}, + 자재표시: ids ? keyed(row.자재표시, ids) : {}, + }; +} + +/** 단가를 고칠 수 있는 비목 — 금액이 한 비목에만 있는 줄만(덧줄 % 줄 = 율만 · 없음 = null) */ +export function priceKey(line: SkLine): CoverKey | null { + if (line.율 != null || line.cid.startsWith("pct:")) return null; + const parts = PARTS.filter((p) => (line.비목?.[p] ?? 0) !== 0); + return parts.length === 1 ? PART_KEY[parts[0]] : null; +} + +/** 품 칸 값 — 덧줄 = 율 · 그 밖 = 수량 */ +export const qtyOf = (line: SkLine): number => line.율 ?? line.수량; + +/** 빈 칸 · 원값 뺀 패치 — 다 비면 null(줄 칸 안 둠) */ +export function cleanPatch(patch: LinePatch | null | undefined): LinePatch | null { + const covers = Object.fromEntries( + Object.entries(patch?.덮개 ?? {}).flatMap(([cid, c]) => { + const keep = Object.fromEntries( + Object.entries(c ?? {}).filter(([k, v]) => k !== "원값" && typeof v === "number"), + ); + return Object.keys(keep).length ? [[cid, keep]] : []; + }), + ); + const out: LinePatch = { + ...(Object.keys(covers).length ? { 덮개: covers } : {}), + ...(patch?.뺀줄?.length ? { 뺀줄: [...patch.뺀줄] } : {}), + ...(patch?.더한줄?.length ? { 더한줄: [...patch.더한줄] } : {}), + }; + return Object.keys(out).length ? out : null; +} + +/** 칸 하나 고침 — `value` null = 그 칸 덮개 풂(원래 값) · 같은 값도 덮개로 둠(사용자가 적은 값) */ +export function setCover( + patch: LinePatch | null, + cid: string, + key: CoverKey, + value: number | null, +): LinePatch | null { + const covers = { ...(patch?.덮개 ?? {}) }; + const one: Cover = { ...(covers[cid] ?? {}) }; + if (value === null) delete one[key]; + else one[key] = value; + if (key === "qty") delete one.배율; // 품은 qty · 배율 중 하나(서버 400) + covers[cid] = one; + return cleanPatch({ ...patch, 덮개: covers }); +} + +const fmt = (v: number | null | undefined): string => + v == null ? "-" : Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 }); + +/** 계산서 묶음 — 비목마다 그 비목 금액이 있는 줄 · 식 = 「단가 * 품 = 금액」(한 비목 줄) · 소계 = 서버 기준 */ +export function sheetGroups(calc: SkCalc): { + 비목: string; + 소계: number | null; + 줄: { 이름: string; 글: string; 금액: number | null }[]; +}[] { + return PARTS.flatMap((p) => { + const lines = calc.줄.filter((x) => (x.비목?.[p] ?? 0) !== 0); + if (!lines.length) return []; + return [ + { + 비목: p, + 소계: calc.기준?.[p] ?? null, + 줄: lines.map((x) => { + const amount = x.비목?.[p] ?? null; + const single = priceKey(x) !== null && x.단가 != null; + const pct = x.율 != null ? " %" : ""; // 덧줄 = 율 % + const text = single ? `${fmt(x.단가)} * ${fmt(qtyOf(x))}` : `${fmt(qtyOf(x))}${pct}`; + return { 이름: x.이름, 글: `${text} = ${fmt(amount)}`, 금액: amount }; + }), + }, + ]; + }); +} diff --git a/resources/tester/test_717_3_sangeun_modal.py b/resources/tester/test_717_3_sangeun_modal.py new file mode 100644 index 000000000..470d8fe55 --- /dev/null +++ b/resources/tester/test_717_3_sangeun_modal.py @@ -0,0 +1,198 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""일위대가 줄 [단가산출 보기] 모달(PLAN 717-3) — 임시 storage · 순수 함수는 Node 로. + +화면 문서(`M02_Sangeun_Cover_Model.lineDoc`) = 서버 `M02_Labor_Patch.as_doc` 꼴(같은 고름 · 자재표시 cid) · +모달 셈 길(`/sangeun/calc` = `calc_doc`) 값 = 일위대가 셈의 그 줄 값 · 고친 칸 = 덮개 칸 이름 · +덮개 고침 순수 함수 · 계산서 묶음 · `normalizeLabor` 가 줄 `패치` 를 지킴 · 화면 이음. +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Patch as patch +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_Calc as calc +from M02_MasterTemplete import M02_Sangeun_Doc as sd +from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +ROOT = Path(__file__).resolve().parents[2] +M02 = ROOT / "M02_MasterTemplete" +PCT = "GC001109" # 원/㎡ · 인력 둘 + 「노무비 * 3 / 100」 +TWO_MATS = "GC000565" # 원/m · 재료 줄 둘 +COVER = {"덮개": {"labor:LB000036#0": {"qty": 0.5}}} + +MODEL = r""" +const fs = require("fs"); +const path = require("path"); +const root = process.argv[1]; +const ts = require(path.join(root, "config", "node_modules", "typescript")); +const f = path.join(root, "M02_MasterTemplete", "M02_Sangeun_Cover_Model.ts"); +const js = ts.transpileModule(fs.readFileSync(f, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, () => ({})); +const m = box.exports; +const job = JSON.parse(fs.readFileSync(0, "utf8")); +const out = {}; +for (const [k, [fn, ...args]] of Object.entries(job)) out[k] = m[fn](...args); +process.stdout.write(JSON.stringify(out)); +""" + + +def _node(job: dict) -> dict: + node = shutil.which("node") + if not node: + pytest.skip("node 없음") + got = subprocess.run( + [node, "-e", MODEL, str(ROOT)], + input=json.dumps(job, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + ) + assert got.returncode == 0, got.stderr + return json.loads(got.stdout) + + +def _whole(): + return m01.loaded()[1] + + +def test_화면_문서_서버_as_doc_꼴(): + """고름 · 자재표시(호표 차례) → cid · 패치 · 참조 줄 = 서버 `as_doc` 와 같은 몸.""" + import master_pick as mp + + whole = _whole() + row = whole.logic(TWO_MATS) + item = row["호표"][2] + want = mp.search(whole, item["요소"], {}, item.get("단위"))[1] + drop = {"뺀줄": ["material:" + row["호표"][1]["이름"] + "#0"]} + one = { + "로직": TWO_MATS, + "입력값": {}, + "고름": {"2": want}, + "자재표시": {"2": {"별도계상": "직접계산"}}, + } + ref = {"참조": {"층": "personal", "주인": None, "키": "SK-000001"}, "입력값": {}} + ids = sd.line_ids(row) + got = _node( + { + "logic": ["lineDoc", one, ids, drop], + "ref": ["lineDoc", ref, None, COVER], + "bare": ["lineDoc", one, None, None], + "needs": ["needsIds", one], + "needsRef": ["needsIds", ref], + } + ) + assert got["logic"] == patch.as_doc({**one, "패치": drop}, whole) + assert got["ref"] == patch.as_doc({**ref, "패치": COVER}, whole) + assert got["bare"]["고름"] == {} and got["bare"]["덮개"] == {} + assert got["needs"] is True and got["needsRef"] is False + + +def test_모달_셈_길_값_일위대가_줄_값(world): + """모달 셈(`calc_doc` · 저장 안 함) 기준 = 일위대가 셈 그 줄 기준 · 고친 줄 덮개 칸 = qty · 정체 = 바탕 줄.""" + whole = _whole() + line = {"로직": PCT, "입력값": {}, "수량": {"손": 1}} + doc = _node({"d": ["lineDoc", line, None, COVER]})["d"] + bare = _node({"d": ["lineDoc", line, None, None]})["d"] + with store.at(world["personal"]): + got = calc.calc_doc(doc) + was = calc.calc_doc(bare) + mine = labor.calc(labor.clean({"담은로직": [{**line, "패치": COVER}]}), {})["줄"][0] + plain = labor.calc(labor.clean({"담은로직": [line]}), {})["줄"][0] + assert got["기준"] == pytest.approx(mine["기준"]) + assert was["기준"] == pytest.approx(plain["기준"]) + assert got["기준"]["계"] != was["기준"]["계"] # 품 0.5 로 바뀜 + assert was["정체"] == sd.line_ids(whole.logic(PCT)) + cover = [x for x in got["줄"] if x["cid"] == "labor:LB000036#0"][0] + assert cover["덮개"] == ["qty"] and cover["수량"] == pytest.approx(0.5) + + +def test_덮개_순수_함수_계산서_묶음(): + """칸 고침 · 풂 · 원값 뺌 · 단가 칸 비목 · 묶음 = 비목마다 서버 값 그대로.""" + lines = [ + {"cid": "labor:LB1#0", "이름": "보통인부", "수량": 0.2, "단가": 100, "금액": 20, + "비목": {"노무비": 20, "재료비": 0, "경비": 0}}, + {"cid": "logic:GC1#0", "이름": "하위", "수량": 1, "금액": 30, + "비목": {"노무비": 10, "재료비": 20, "경비": 0}}, + {"cid": "pct:공구#0", "이름": "공구손료", "수량": 0.6, "율": 3, "금액": 0.6, + "비목": {"노무비": 0, "재료비": 0, "경비": 0.6}}, + ] # fmt: skip + calc_ = {"기준": {"노무비": 30, "재료비": 20, "경비": 0.6, "계": 50.6}, "줄": lines} + one = {"덮개": {"labor:LB1#0": {"qty": 0.5, "원값": {"qty": 0.2}}}} + got = _node( + { + "set": ["setCover", None, "labor:LB1#0", "labor", 120], + "qty": ["setCover", {"덮개": {"x:a#0": {"배율": 2}}}, "x:a#0", "qty", 1], + "off": ["setCover", {"덮개": {"labor:LB1#0": {"qty": 1}}}, "labor:LB1#0", "qty", None], + "clean": ["cleanPatch", one], + "empty": ["cleanPatch", {"덮개": {}, "뺀줄": [], "더한줄": []}], + "keep": ["cleanPatch", {"뺀줄": ["a:b#0"]}], + "p0": ["priceKey", lines[0]], + "p1": ["priceKey", lines[1]], + "p2": ["priceKey", lines[2]], + "q2": ["qtyOf", lines[2]], + "groups": ["sheetGroups", calc_], + } + ) + assert got["set"] == {"덮개": {"labor:LB1#0": {"labor": 120}}} + assert got["qty"] == {"덮개": {"x:a#0": {"qty": 1}}} # 품은 qty · 배율 중 하나 + assert got["off"] is None and got["empty"] is None + assert got["clean"] == {"덮개": {"labor:LB1#0": {"qty": 0.5}}} # 원값 = 서버 몫 + assert got["keep"] == {"뺀줄": ["a:b#0"]} + assert (got["p0"], got["p1"], got["p2"], got["q2"]) == ("labor", None, None, 3) + groups = got["groups"] + assert [g["비목"] for g in groups] == ["노무비", "재료비", "경비"] + assert [g["소계"] for g in groups] == [30, 20, 0.6] + assert groups[0]["줄"][0] == {"이름": "보통인부", "글": "100 * 0.2 = 20", "금액": 20} + assert [x["이름"] for x in groups[0]["줄"]] == ["보통인부", "하위"] + assert groups[2]["줄"][0]["글"] == "3 % = 0.6" + + +def test_normalizeLabor_줄_패치_지킴(): + node = shutil.which("node") + if not node: + pytest.skip("node 없음") + doc = {"담은로직": [{"로직": PCT, "패치": COVER}, {"로직": TWO_MATS}]} + got = subprocess.run( + [node, str(ROOT / "resources/tester/helper_m02_labor_normalize.cjs")], + input=json.dumps({"doc": doc, "column": "c"}, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + ) + assert got.returncode == 0, got.stderr + rows = json.loads(got.stdout)["담은로직"] + assert rows[0]["패치"] == COVER and "패치" not in rows[1] + + +def test_화면_이음(): + """줄 단추 = 로직 줄 · SK 참조 줄만 · 셈 주입(프로젝트 복사본)엔 없음 · 적용 안 한 고침 물음 · 700 줄.""" + rows = (M02 / "M02_MasterTemplete_Labor_Rows.ts").read_text("utf-8") + assert 'sangeun && !fx && (!row.참조 || row.참조.키.startsWith("SK-"))' in rows + detail = (M02 / "M02_MasterTemplete_Labor_Detail.ts").read_text("utf-8") + assert "const sk = opt.calc ? {} : { sangeun: openSk };" in detail + assert 'edit && opt.layer === "personal" && !library.owner' in detail + modal = (M02 / "M02_MasterTemplete_Labor_SangeunModal.ts").read_text("utf-8") + assert "same(patch, applied) || askConfirm(" in modal + assert '{ layer: "personal", owner: null }' in modal # 새 SK = 내 개인 층 + assert "if (mine !== seq) return;" in modal # 최신 응답만 + cover = (M02 / "M02_MasterTemplete_Sangeun_Cover.ts").read_text("utf-8") + assert "/sangeun/calc" in cover and "/sangeun/from-line" in cover + assert 'tr.classList.add("is-changed")' in cover # 표 부품 「바뀜」 상태 + for name in ( + "M02_MasterTemplete_Labor_Rows.ts", + "M02_MasterTemplete_Labor_Detail.ts", + "M02_MasterTemplete_Labor_SangeunModal.ts", + "M02_MasterTemplete_Sangeun_Cover.ts", + "M02_Sangeun_Cover_Model.ts", + ): + assert len((M02 / name).read_text("utf-8").splitlines()) <= 700, name