diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts index d7ac3df33..328bc0264 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts @@ -111,6 +111,7 @@ const TEXT = { Fold: ["누르면 펼침", "Click to expand"], Per: ["{n} — {v} 원/{u}", "{n} — {v} KRW/{u}"], Edited: ["시험 고침", "Trial edit"], + EditedM02: ["변경 항목", "Override"], } as const satisfies Record; const th = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0]; const boxName = (b: string): string => (b in TEXT ? th(b as keyof typeof TEXT) : b); @@ -120,8 +121,14 @@ export interface HoEdit { covers: Record; set: (idx: number, cover: LineCover | null) => void; scope?: "m01" | "m02"; + /** (M02) 모달 고침 칸 아래 덧 동작(줄 빼기 · 별도계상 등) */ + more?: (idx: number, close: () => void) => HTMLElement[]; } +/** 고친 줄 꼬리표 — M01 = 시험 고침 · M02 = 변경 항목 · 고침 없음 = "" */ +const editTag = (ctx: HoContext, idx: number): string => + ctx.edit?.covers[idx] ? th(ctx.edit.scope === "m02" ? "EditedM02" : "Edited") : ""; + export interface HoContext { row: LogicRow; prices: Record; @@ -266,22 +273,28 @@ function modalOf(e: Entry, ctx: HoContext, extra: { cond?: PickCond; item?: HoLi ...(item?.종류 === "재료" && ctx.calcHost ? { onPick: (it) => pickMaterial(ctx.calcHost!, ctx.values, e.idx, it) } : {}), - ...(edit && item && !e.extra + // 덧줄(%) = M02 덮개만 율 고침(M01 시험 값은 호표 줄만) + ...(edit && (e.extra ? edit.scope === "m02" : item) ? { - edit: (close: () => void) => - coverForm({ - qty: line ? line.수량 : null, - prices: e.logic - ? { - labor: perUnit("노무비"), - material: perUnit("재료비"), - expense: perUnit("경비"), - } - : { [e.part]: line ? line.단가 : null }, + edit: (close: () => void) => { + const form = coverForm({ + qty: line ? pctView(line).qty : null, + prices: e.extra + ? {} + : e.logic + ? { + labor: perUnit("노무비"), + material: perUnit("재료비"), + expense: perUnit("경비"), + } + : { [e.part]: line ? line.단가 : null }, cover: edit.covers[e.idx], scope: edit.scope, onApply: (c) => (close(), edit.set(e.idx, c)), - }), + }); + const more = edit.more?.(e.idx, close) ?? []; + return more.length ? el("div", { children: [form, ...more] }) : form; + }, } : {}), }); @@ -429,23 +442,23 @@ function formulaCell(e: Entry, ctx: HoContext, text = ""): HTMLElement { return cell; } -function nameCell(e: Entry, label: string, covered: boolean): HTMLElement { +function nameCell(e: Entry, label: string, tag: string): HTMLElement { return el("div", { children: [ el("div", { text: label }), ...(e.share && label === e.name ? [createBadge(`${e.share} ${tl("Share")}`, "accent")] : []), ...(e.spec ? [el("div", { className: "ui-text ui-text--muted", text: e.spec })] : []), ...(e.warn ? [createBadge(`${tx("Calc_UnitWarn")} · ${e.warn}`, "warning")] : []), - ...(covered ? [createBadge(th("Edited"), "accent")] : []), + ...(tag ? [createBadge(tag, "accent")] : []), ], }); } /** 보통 줄 · 몫 줄 — 누르면 그 줄 모달 */ function lineRow(e: Entry, ctx: HoContext, label = e.name): HTMLTableRowElement { - const covered = !!ctx.edit?.covers[e.idx]; + const tag = editTag(ctx, e.idx); const tr = tableRow(COLS, [ - nameCell(e, label, covered && !e.share), + nameCell(e, label, e.share ? "" : tag), e.qtyValue, e.unit, formulaCell(e, ctx), @@ -454,7 +467,7 @@ function lineRow(e: Entry, ctx: HoContext, label = e.name): HTMLTableRowElement ]); tr.classList.add("m01lab__line"); if (e.extra) tr.classList.add("m01lab__line--extra"); - if (covered) tr.classList.add("is-changed"); + if (tag) tr.classList.add("is-changed"); tr.tabIndex = 0; tr.dataset.line = e.name; tr.addEventListener("click", () => e.open(e)); @@ -486,9 +499,9 @@ function mergedRows(m: HoMerged, ctx: HoContext): HTMLTableRowElement[] { .replace("{a}", share("노무비")) .replace("{b}", share("재료비")) .replace("{c}", share("경비")); - const covered = !!ctx.edit?.covers[m.idx]; + const tag = editTag(ctx, m.idx); const head = tableRow(COLS, [ - nameCell(first, `▸ ${first.name}`, covered), + nameCell(first, `▸ ${first.name}`, tag), first.qtyValue, first.unit, formulaCell(first, ctx, short), @@ -496,7 +509,7 @@ function mergedRows(m: HoMerged, ctx: HoContext): HTMLTableRowElement[] { money(m.금액), ]); head.classList.add("m01lab__line"); - if (covered) head.classList.add("is-changed"); + if (tag) head.classList.add("is-changed"); head.dataset.equip = first.name; head.title = th("Fold"); head.tabIndex = 0; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index 9e2eb9ce3..67d1e1d51 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -12,7 +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 type { LinePatch } from "./logic/M02_Sangeun_Cover_Model"; import { call, getActiveLayer, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_RowSk.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_RowSk.ts index 1b8839822..219d92261 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_RowSk.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_RowSk.ts @@ -2,11 +2,10 @@ * M02_MasterTemplete_Labor_RowSk.ts * 일위대가 줄 모달 안 단가산출 상세 = 717-2b 단가산출(SK) 상세 「줄 모드」(PLAN 723-3 · 정함 4 · 5). * · 줄 → 이름 없는 SK 문서(`lineDoc` · 고름 · 자재표시 = 바탕 줄 정체 cid) → `mountSangeunDetail` 그대로 얹음 - * (보기 넷 · 품 · 단가 덮개 · 줄 빼기 · 더하기 · 시험 입력 칩) + * (보기 넷 · 공용 상세(724-1 `LogicLab_Ho`) 줄 모달 품 · 단가 덮개 · 줄 빼기 · 더하기 · 박스 옵션 · 입력 칩) * · `take()` = [적용] 때 그 줄에 남길 것 — 패치 `{덮개, 뺀줄, 더한줄}`(M02 변경 항목 · M01 안 바뀜 · 내역 17-2) + * 옵션(시험 입력 → 그 줄 `입력값` · 정함 5) * · [단가산출로도 저장](기본 꺼짐) = `saveAsSk` — 개인 층 새 SK(`from-line`) - * 공용 부품 갈아 끼움은 724(sub2) — 지금은 717-2b 상세를 그대로 씀. * ========================================================================== */ import { el } from "@ui/ui_template_elements"; @@ -15,13 +14,7 @@ import { fetchAuto } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; import type { LaborRow } from "./M02_MasterTemplete_Labor_Api"; import { calcSangeun, sangeunFromLine, type SkScope } from "./M02_MasterTemplete_Sangeun_Cover"; import { mountSangeunDetail } from "./M02_MasterTemplete_Sangeun_Detail"; -import { - cleanPatch, - lineDoc, - needsIds, - type LinePatch, - type SkCalc, -} from "./M02_Sangeun_Cover_Model"; +import { cleanPatch, lineDoc, needsIds, type LinePatch } from "./logic/M02_Sangeun_Cover_Model"; export interface RowSkHandle { /** 고친 것(패치 · 옵션) — 없으면 null */ @@ -49,7 +42,7 @@ export function mountRowSk( let ids: string[] | null = null; try { if (needsIds(o.row)) - ids = (await calcSangeun(o.scope, lineDoc(o.row, null, null))).정체 ?? null; + ids = (await calcSangeun(o.scope, lineDoc(o.row, null, null))).정체 ?? null; } catch { ids = null; // 정체 못 받음 = 고름 · 자재표시 없이(셈 까닭은 상세가 보임) } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_SangeunModal.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_SangeunModal.ts index be3e84df1..a5145e4d3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_SangeunModal.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_SangeunModal.ts @@ -1,57 +1,26 @@ /* ============================================================================= * M02_MasterTemplete_Labor_SangeunModal.ts - * 일위대가 줄 [단가산출 보기] 모달(PLAN 717-3 · 요구서 내역12 4장 4 · 16 · 24 · 4-1 확정 5) — - * 로직 줄 · 단가산출(SK) 참조 줄 하나의 단가산출을 보고 그 일위대가에서만 고침. - * · 위 = 일위대가 머리(「1 ㎡ 당」) · 이 줄 수량 · 1 단위 금액 · 짝 잃은 덮개 알림 - * · 보기 고르기 = 인력·재료·경비(기본 · 공용 계산서 `calc_sheet`) | 단가산출 상세(품 · 단가 칸 고침 · `Sangeun_Cover`) - * · 고침 = 그 일위대가 줄 칸 `패치 {덮개, 뺀줄, 더한줄}` · [적용] = 화면 캐시(저장은 페이지 [저장] · - * 원값은 저장 때 서버가 셈) · 적용 안 하고 닫으면 물음 + * 일위대가 줄 [단가산출 보기] 모달(PLAN 717-3 · 724-1 · 요구서 내역12 4장 4 · 16 · 24 · 내역 16 · 17-2) — + * 구조물 조합 일위대가 상세(`Labor_Detail`)의 로직 줄 · 단가산출(SK) 참조 줄 하나의 단가산출을 보고 그 일위대가에서만 고침. + * · 위 = 일위대가 머리(「1 ㎡ 당」) · 이 줄 수량 + * · 몸 = 단가산출 상세 줄 모드(`Labor_RowSk` — 717-2b 보기 넷 · 공용 상세 줄 모달 그 자리 고침) + * · [적용] = 그 일위대가 줄 칸 `패치 {덮개, 뺀줄, 더한줄}`(M02 변경 항목 · M01 안 바뀜) · 화면 캐시(저장은 페이지 [저장]) · + * 적용 안 하고 닫으면 물음 * · [단가산출로 저장] = 개인 층 새 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"; +import { mountRowSk, saveAsSk } from "./M02_MasterTemplete_Labor_RowSk"; +import type { SkScope } from "./M02_MasterTemplete_Sangeun_Cover"; +import type { LinePatch } from "./logic/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)"], @@ -82,130 +51,63 @@ export interface RowSangeunOptions { 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( + const info = el("div", { + className: "m02-std-sheet__body", + attrs: { "data-sk-head": "" }, + children: [ 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 host = el("div", { attrs: { "data-sk-body": "" } }); + // 읽기만이어도 고쳐 볼 수 있음([단가산출로 저장]으로만 남김) — 717-3 그대로 + const sk = mountRowSk(host, { + row: o.row, + scope: o.scope, + title: o.title, + unit: o.unit, + readOnly: false, + }); + /** 지금 패치(고침 없으면 그 줄 원래 패치) */ + const now = (): LinePatch | null => { + const got = sk.take(); + return got ? got.patch : (o.row.패치 ?? null); }; - - /** 서버 셈 — 늦게 온 답은 버림 */ - 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 => {}; + let done = false; const applyBtn = createButton({ label: t("Apply"), variant: "filled", onClick: () => { - o.apply(patch); - applied = patch; + if (sk.take()) o.apply(now()); + done = true; close(); }, }); applyBtn.dataset.skApply = ""; + applyBtn.hidden = o.readOnly; 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"); + const key = await saveAsSk(o.row, now(), name, o.unit); + showToast(t("Saved").replace("{k}", key), "success"); if (!o.replace) return; - o.replace({ 로직: "", 입력값: {}, 메모: null, 별칭: null, 수량: o.row.수량, ...got.줄 }); - showToast(t("Replaced").replace("{k}", got.이름), "info"); - applied = patch; + o.replace({ + 로직: "", + 입력값: {}, + 메모: null, + 별칭: null, + 수량: o.row.수량, + 참조: { 층: "personal", 주인: null, 키: key }, + }); + showToast(t("Replaced").replace("{k}", key), "info"); + done = true; close(); } catch (error) { showToast(error instanceof Error ? error.message : String(error), "error"); @@ -218,24 +120,13 @@ export function openRowSangeun(o: RowSangeunOptions): void { 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), + actions: [saveBtn, applyBtn], + beforeClose: async () => { + if (!done && sk.take() && !(await askConfirm({ message: t("Discard"), danger: true }))) + return false; + sk.destroy(); + return true; + }, + mount: (body) => body.append(info, host), })); - 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_Sangeun_Cover.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Cover.ts index 48a4063b1..013d7171e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Cover.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Cover.ts @@ -1,36 +1,18 @@ /* ============================================================================= * M02_MasterTemplete_Sangeun_Cover.ts - * 단가산출(SK) 고침 표 · 셈 길 부름(PLAN 717-2b · 717-3 · 요구서 내역12 4장 4 · 24) — 두 표가 셈 길 한 벌을 씀. + * 단가산출(SK) 셈 길 부름 · 뺀 줄 띠(PLAN 717-2b · 717-3 · 724-1) * · 셈 = `calcSangeun`(`POST /layers/{층}/sangeun/calc` · 저장 안 함 · 부르는 쪽이 최신 응답만 받음) · * [단가산출로 저장] = `sangeunFromLine`(`/sangeun/from-line`) - * · `coverTable`(717-3 일위대가 모달) = 이름 · 품 · 단위 · 단가 · 금액 한 표 · 고친 줄 = 표 부품 「바뀜」 - * (tr.is-changed) + 원래 값 곁 + 꼬리표 · 단가 칸은 한 비목 줄만 · 덧줄(%) = 율만(서버 `_covered` 규칙) - * · `skCoverTable`(717-2b SK 상세) = M01 「단가산출 상세」 꼴(인력 · 재료 · 경비 · 하위 묶음 + 소계 · 계 · - * 열 = 이름 · 수량 · 단위 · 로직 · 단가 · 금액) · 품 · 단가 칸 고침 · 줄 빼기 · 고친 칸 = `is-edited` - * + 바탕 값 곁 글 + 꼬리표 · 값 = 서버 셈 답 그대로 + * · 고침 표는 공용 상세(`M01_MasterData_UI_LogicLab_Ho` · 724-1) — 여기는 뺀 줄 되살림 띠만 * ========================================================================== */ import { createBadge } from "@ui/ui_template_badge"; import { createButton } from "@ui/ui_template_button"; -import { createCheckbox } from "@ui/ui_template_checkbox"; 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 } from "./logic/M02_Sangeun_Cover_Model"; -import { - priceKey, - qtyOf, - type CoverKey, - type LinePatch, - type PatchRow, - type SkCalc, - type SkLine, -} from "./M02_Sangeun_Cover_Model"; +import type { PatchRow, SkAnswer } from "./logic/M02_Sangeun_Cover_Model"; import "./M02_MasterTemplete_Sangeun.css"; export interface SkScope { @@ -40,8 +22,7 @@ export interface SkScope { const q = (scope: SkScope): string => scope.owner && scope.layer !== "system" ? `?owner=${encodeURIComponent(scope.owner)}` : ""; -/** 셈(저장 안 함) — 문서 = 화면 문서 · `given` = 시험 입력(빈 칸 뺌) · 서버가 M01 엔진 한 벌로 셈 · - * 답 꼴 = 717-2b `SkAnswer`(기본) · 717-3 `SkCalc` */ +/** 셈(저장 안 함) — 문서 = 화면 문서 · `given` = 시험 입력(빈 칸 뺌) · 서버가 M01 엔진 한 벌로 셈 */ export function calcSangeun( scope: SkScope, doc: Record, @@ -66,295 +47,38 @@ export const sangeunFromLine = ( 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; -} - -/* ── 717-2b SK 상세 고침 표 ── */ -export interface SkCoverOptions { - /** 고칠 수 있음(쓰기 층 · 읽기만 아님) */ - 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; - /** 더한 재료 줄 별도계상 켬 · 끔 */ - onApart: (row: CoverRow, on: boolean) => 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: SkCoverOptions): (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")] : []), - ...(can && r.apartPick - ? [ - createCheckbox(L("M02_SangeunApart"), { - chip: true, - size: 22, - checked: r.apart, - onChange: (on) => o.onApart(r, on), - }).root, - ] - : 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), - }); +/** 뺀 줄(cid · 이름) 띠 — 고칠 수 있으면 [되살림] */ +export function removedStrip( + removed: { cid: string; 이름: string }[], + edit: boolean, + onRestore: (cid: string) => void, +): HTMLElement[] { + if (!removed.length) return []; 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] : []), + el("div", { + className: "m02-sk__removed", + attrs: { "data-removed": "" }, + children: [ + createText(L("M02_SangeunRemoved")), + ...removed.map((x) => + el("span", { + className: "m02-sk__removed-item", + children: [ + createBadge(x.이름, "neutral"), + ...(edit + ? [ + createButton({ + label: L("M02_SangeunRestore"), + variant: "ghost", + size: 22, + onClick: () => onRestore(x.cid), + }), + ] + : []), + ], + }), + ), + ], + }), ]; } - -/** 묶음 표 넷 + 계 + 뺀 줄 */ -export function skCoverTable(rows: CoverRow[], o: SkCoverOptions): 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 index c1e87d73e..a7f97bb36 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts @@ -1,8 +1,9 @@ /* ============================================================================= * 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 로직 식) · 기본정보 = 바탕 로직(맨 위 접힘) + * M02 단가산출(SK) 상세(PLAN 717-2b · 724-1 · 요구서 4장 3 · 4 · 13 · 17 · 24) — M01 「단가산출 로직」 상세 부품을 M02 길로. + * 보기 넷(`LogicLab_View`) = 단가산출 상세(공용 상세 `LogicLab_Ho` · SK 셈 답 줄 = `hoView` · 줄 모달 그 자리 고침 = + * HoEdit → SK 덮개 · 더한줄 · 줄 빼기 · 별도계상) · 인력·재료·경비(공용 계산서 · SK 셈 답) · + * 시험 계산(`Logic_Calc` · 바탕 M01 로직 + 덮개 품 · 단가 사본) · 텍스트 수식(같은 사본 식) · 기본정보 = 바탕 로직(맨 위 접힘) * 고침 = SK 덮개 · 더한줄(`Sangeun_Pick`) · 뺀줄(꼴 = 서버 `M02_Sangeun_Doc`) · 값 = 서버 셈 길 최신 응답만 * 시험 입력 = SK 셈 `입력값`(저장 안 함) · [저장] = 화면 문서 → 서버가 다시 셈(원값 · 바탕판) * 바탕 없는 SK = [기본 로직 선택] → 바탕 · 단위 · 바탕 없이 줄만도 됨 @@ -11,6 +12,7 @@ import { createBadge } from "@ui/ui_template_badge"; import { createBanner } from "@ui/ui_template_banner"; import { createButton } from "@ui/ui_template_button"; +import { createCheckbox } from "@ui/ui_template_checkbox"; import { el } from "@ui/ui_template_elements"; import { createInputField } from "@ui/ui_template_input"; import { t as L } from "@ui/ui_template_locale"; @@ -20,24 +22,31 @@ import { buildCalc } from "../M01_MasterData/M01_MasterData_UI_Logic_Calc"; import { fetchLogic, setLogicApiPaths, + type CalcLine, type LogicOne, type LogicRow, type TextAnswer, } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; +import { applyCovers } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Cover"; import { buildLabDetail } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Detail"; +import { buildHo, type HoEdit } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Ho"; import { createViewPicker } from "../M01_MasterData/M01_MasterData_UI_LogicLab_View"; import type { Layer } from "./M02_MasterTemplete_Api_Fetch"; import { openFindPicker } from "./M02_MasterTemplete_Find_Pick"; -import { calcSangeun, skCoverTable } from "./M02_MasterTemplete_Sangeun_Cover"; +import { calcSangeun, removedStrip } from "./M02_MasterTemplete_Sangeun_Cover"; import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick"; import { + baseCovers, dropLine, + dropText, + hoView, restoreLine, - setApart, rowsOf, - setPrice, - setQty, + setApart, + setLineCover, sheetGroups, + type CoverRow, + type HoView, type SkAnswer, type SkDoc, } from "./logic/M02_Sangeun_Cover_Model"; @@ -227,19 +236,31 @@ export function mountSangeunDetail( 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 = skCoverTable(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()), - onApart: (r, on) => (setApart(doc, r, on), touch()), - }); + const removed = (doc.뺀줄 ?? []).map((cid) => ({ cid, 이름: names.get(cid) ?? cid })); const row = base?.logic ?? standIn(); + const view = ans ? hoView(ans, bare, base?.logic ?? null, doc) : null; + const baseNames = [...(row.호표 ?? []), ...(row.덧줄 ?? [])].map((x) => + String(x.이름 ?? ("요소" in x ? x.요소 : "")), + ); + const cut = (t: T): T => + (t ? dropText(t, baseNames, bare, doc) : t) as T; + const ho = el("div", { + attrs: { "data-sk-cover": "" }, + children: [ + ...removedStrip(removed, edit, (cid) => (restoreLine(doc, cid), touch())), + ...buildHo({ + row: view ? { ...row, 호표: view.호표, 덧줄: view.덧줄 } : row, + prices: base?.prices ?? {}, + lines: (view?.lines ?? null) as CalcLine[] | null, + text: cut(text ?? undefined) ?? null, + lastText: cut(lastText), + values, + onInput, + calcHost, + edit: view && edit ? hoEdit(view, rows) : undefined, + }), + ], + }); const est = el("div", { attrs: { "data-est": "" }, children: ans @@ -271,6 +292,51 @@ export function mountSangeunDetail( }); } + /** 공용 상세 고침 고리 — 열쇠 = Ho 줄 차례 → cid · 고침 = SK 덮개 · 더한줄(M01 안 바뀜 · 내역 17-2) · + * 모달 덧 동작 = 줄 빼기 · 더한 재료 별도계상 */ + function hoEdit(view: HoView, rows: CoverRow[]): HoEdit { + const by = new Map(rows.map((r) => [r.cid, r])); + const at = (idx: number): CoverRow | undefined => by.get(view.cids[idx]); + return { + covers: view.covers, + scope: "m02", + set: (idx, c) => { + const r = at(idx); + if (!r) return; + setLineCover(doc, r, c, opened()); + touch(); + if (base) rerunCalc(); // 시험 계산 · 텍스트 수식 = 덮개 사본으로 + }, + more: (idx, close) => { + const r = at(idx); + if (!r) return []; + const drop = createButton({ + label: L("M02_SangeunDrop"), + variant: "danger-ghost", + size: 28, + onClick: () => (close(), dropLine(doc, r), touch()), + }); + drop.dataset.skDrop = ""; + const apart = r.apartPick + ? [ + createCheckbox(L("M02_SangeunApart"), { + chip: true, + size: 28, + checked: r.apart, + onChange: (on) => (close(), setApart(doc, r, on), touch()), + }).root, + ] + : []; + return [el("div", { className: "m02-sk__tags", children: [...apart, drop] })]; + }, + }; + } + + function rerunCalc(): void { + drawCalc(); + calcHost.querySelector("[data-calc-run]")?.click(); + } + /** 텍스트 수식 · 계산서 입력 단추 — 시험 입력 고침 → 시험 계산 다시 그림 · 셈 */ function onInput(name: string, v: string): void { values[name] = v; @@ -283,12 +349,14 @@ export function mountSangeunDetail( calcHost.replaceChildren(createText(L("M02_SangeunNoInputs"))); return; } + const covers = baseCovers(bare, base.logic.호표?.length ?? 0, doc); + const covered = Object.keys(covers).length > 0; buildCalc(calcHost, { savedKey: base.logic.키, file: base.file, - row: base.logic, + row: covered ? applyCovers(base.logic, covers) : base.logic, prices: base.prices, - dirty: () => false, + dirty: () => covered, // 덮개 사본 = 저장 안 한 줄로 셈(메모리만) values, onText: (t) => { text = t; @@ -311,9 +379,9 @@ export function mountSangeunDetail( } } if (!alive) return; - drawCalc(); draw(); - await recalc(); + await recalc(); // 바탕 정체(bare) 먼저 — 시험 계산 · 텍스트 수식 = 덮개 사본 + if (alive) drawCalc(); } void loadBase(); diff --git a/M02_MasterTemplete/M02_Sangeun_Cover_Model.ts b/M02_MasterTemplete/M02_Sangeun_Cover_Model.ts deleted file mode 100644 index 37c0bd9f1..000000000 --- a/M02_MasterTemplete/M02_Sangeun_Cover_Model.ts +++ /dev/null @@ -1,166 +0,0 @@ -/* ============================================================================= - * 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/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts b/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts index 22cd4cfff..5dfacef15 100644 --- a/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts +++ b/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts @@ -1,10 +1,12 @@ /* ============================================================================= * M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts - * 단가산출(SK) 고침 꼴(PLAN 717-2b · 717-3 같이 씀) — 서버 셈 답(`/layers/{층}/sangeun/calc`) → 표 줄 · - * 고친 칸 → SK 문서 덮개 · 더한줄 · 뺀줄(꼴 = 서버 `M02_Sangeun_Doc`). 금액은 늘 서버가 셈 — - * 여기는 줄 모양 · 문서 칸 옮김만(값 셈 없음 · 로직 줄 단가를 노 · 재 · 경 몫 비로 나누는 것만). + * 단가산출(SK) 고침 꼴(PLAN 717-2b · 717-3 · 724-1 — SK 상세 · 일위대가 줄 모드 같이 씀) — + * 서버 셈 답(`/layers/{층}/sangeun/calc`) → 공용 상세(`M01_MasterData_UI_LogicLab_Ho`) 줄 · 계산서 줄 · + * 고친 칸(`LineCover` · 호표 차례 열쇠) → SK 문서 덮개 · 더한줄 · 뺀줄(꼴 = 서버 `M02_Sangeun_Doc`). + * 금액은 늘 서버가 셈 — 여기는 줄 모양 · 문서 칸 옮김만. * 줄 정체 cid = `kind:키#n`(바탕 줄) · `add:#k`(더한줄 k 째) · 덮개 원값 = 저장 때 서버가 다시 적음 * (화면은 고친 첫때 바탕 값을 적어 둠 — 곁 글용) · 열린입력 있는 SK = 품은 배율만 + * 일위대가 줄 → 이름 없는 SK 문서 = `lineDoc`(서버 `M02_Labor_Patch.as_doc` 꼴) * 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장). * ========================================================================== */ @@ -113,7 +115,6 @@ export interface CoverRow { /* ── 순수 셈 ── */ const BUNDLE_OF: Record = { 노무비: "인력", 재료비: "재료", 경비: "경비" }; -const OWN_PART: Record = { 인력: "노무비", 재료: "재료비" }; const ADD_RE = /^add:#(\d+)$/; const COVER_KEYS = ["qty", "배율", ...PARTS] as const; @@ -215,40 +216,28 @@ function putCover(doc: SkDoc, r: CoverRow, cover: SkCover): void { 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); -} +/** 공용 상세 고침 칸(`LogicLab_Cover.LineCover` 와 같은 꼴) — 품 · 비목 단가(로직 줄 = 1 단위 노 · 재 · 경) */ +export type LineCover = Pick; -/** 단가 고침 — 요소 줄 = 그 비목 칸 · 로직 줄 = 노 · 재 · 경 몫 비로 나눔 · 더한 요소 줄 = 그 줄 단가 */ -export function setPrice(doc: SkDoc, r: CoverRow, v: number | null): void { - if (!r.priceEdit) return; +/** 공용 상세 고침 한 줄 → 문서 — 더한줄 = 그 줄 수량 · 단가(요소 줄만) · 바탕 줄 = 덮개 + * (열린입력 SK = 품 대신 배율 = 고친 품 ÷ 바탕 품 · 바탕 값과 같은 품 = 덮개 안 둠) · `c` null = 고침 풂 */ +export function setLineCover(doc: SkDoc, r: CoverRow, c: LineCover | null, opened: boolean): void { + const price = r.priceEdit ? PARTS.map((k) => c?.[k]).find((v) => v != null) : undefined; if (r.added !== null) { const one = doc.더한줄?.[r.added]; if (!one) return; - if (v === null) delete one.단가; - else one.단가 = v; + if (c?.qty != null) one.수량 = c.qty; + if (price == null) delete one.단가; + else one.단가 = price; 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; - } + const cover: SkCover = {}; + const qty = c?.qty; + // ponytail: 열린입력 배율 = 고친 품 ÷ 바탕 품(바탕 품 0 · 없음 = 품 고침 못 함) + if (qty != null && opened && r.was?.qty) cover.배율 = qty / r.was.qty; + if (qty != null && !opened && !near(qty, r.was?.qty)) cover.qty = qty; + if (r.priceEdit) for (const k of PARTS) if (c?.[k] != null) cover[k] = c[k]; + if (cover.배율 != null && near(cover.배율, 1)) delete cover.배율; putCover(doc, r, cover); } @@ -309,4 +298,221 @@ export function sheetGroups(rows: CoverRow[]): SheetGroup[] { const order = Object.keys(PART_OF); return [...groups.values()].sort((a, b) => order.indexOf(a.비목) - order.indexOf(b.비목)); } + +/* ── 공용 상세(Ho) 줄 ── */ +/** Ho 호표 · 덧줄 꼴(`M01_MasterData_UI_Logic_Api` HoLine · NamedFormula 중 쓰는 칸) */ +export interface HoItem { + 종류: string; + 요소: string; + 이름?: string; + 규격?: string; + 단위?: string; + 수량: string; + 비목?: string; +} +export interface HoExtra { + 이름: string; + 식: string; + 비목?: string; +} +export interface HoView { + 호표: HoItem[]; + 덧줄: HoExtra[]; + /** Ho 줄 차례(호표 → 덧줄)의 셈 줄 — 셈 전 · 못 셈 = null · 별도계상 빠진 줄 = 빈 칸 */ + lines: (SkLine | undefined)[] | null; + /** Ho 줄 차례 → cid(고침 열쇠 옮김) */ + cids: string[]; + /** Ho 줄 차례 → 지금 고침(덮개 · 더한줄) */ + covers: Record; +} + +const KIND_OF: Record = { labor: "인력", material: "재료", machine: "기계" }; +const ELEMENT_KINDS = ["인력", "재료", "기계"]; + +/** SK 셈 답 → 공용 상세 줄 — 줄 차례 = 서버 `정체`(바탕 남은 호표 · 더한줄 · 바탕 남은 덧줄) · + * 바탕 로직 줄을 알면(`base` + 바탕 셈 `bare.정체` 차례) 그 줄 그대로(식 · 요소 · 규격) · + * 더한줄 = 문서 줄 · 그 밖(참조 바탕) = 셈 줄로 지음 */ +export function hoView( + ans: SkAnswer, + bare: SkAnswer | null, + base: { 호표?: HoItem[]; 덧줄?: HoExtra[] } | null, + doc: SkDoc, +): HoView { + const head = base?.호표 ?? []; + const known = new Map(); + if (base && bare) + bare.정체.forEach((cid, i) => { + const it = i < head.length ? head[i] : base.덧줄?.[i - head.length]; + if (it) known.set(cid, it); + }); + const lineBy = new Map(ans.줄.map((l) => [l.cid, l])); + const single = (l?: SkLine): string | undefined => { + const parts = Object.entries(l?.비목 ?? {}).filter(([, v]) => v); + return parts.length === 1 ? parts[0][0] : undefined; + }; + const ho: [string, HoItem][] = []; + const extra: [string, HoExtra][] = []; + for (const cid of ans.정체) { + const l = lineBy.get(cid); + const name = l?.이름 ?? cid; + if (cid.startsWith("pct:")) { + extra.push([cid, (known.get(cid) as HoExtra) ?? { 이름: name, 식: "" }]); + continue; + } + const hit = ADD_RE.exec(cid); + const a = hit ? doc.더한줄?.[Number(hit[1])] : undefined; + const kind = a + ? ELEMENT_KINDS.includes(String(a.종류)) + ? String(a.종류) + : "로직" + : (KIND_OF[cid.split(":")[0]] ?? "로직"); + const made: HoItem = { + 종류: kind, + 요소: typeof a?.요소 === "string" ? a.요소 : "", + 이름: name, + 단위: l?.단위 ?? String(a?.단위 ?? ""), + 수량: String(a?.수량 ?? l?.율 ?? l?.수량 ?? ""), + 비목: typeof a?.비목 === "string" ? a.비목 : single(l), + }; + ho.push([cid, a ? made : ((known.get(cid) as HoItem) ?? made)]); + } + const cids = [...ho, ...extra].map(([cid]) => cid); + const covers: Record = {}; + cids.forEach((cid, i) => { + const hit = ADD_RE.exec(cid); + const a = hit ? doc.더한줄?.[Number(hit[1])] : undefined; + const part = PART_OF[String(a?.비목 ?? "")]; + if (a) covers[i] = { qty: a.수량, ...(a.단가 != null && part ? { [part]: a.단가 } : {}) }; + const c = hit ? undefined : doc.덮개?.[cid]; + if (!c) return; + const l = lineBy.get(cid); + const out: LineCover = {}; + if (c.qty != null) out.qty = c.qty; + else if (c.배율 != null && l) out.qty = l.율 ?? l.수량 ?? undefined; + for (const k of PARTS) if (c[k] != null) out[k] = c[k]; + covers[i] = out; + }); + return { + 호표: ho.map(([, x]) => x), + 덧줄: extra.map(([, x]) => x), + lines: ans.줄.length ? cids.map((c) => lineBy.get(c)) : null, + cids, + covers, + }; +} + +/** 바탕 로직 호표 차례 고침(덮개 qty · 비목 — 배율 뺌) — 시험 계산 · 텍스트 수식 줄 사본(`applyCovers`)용 */ +export function baseCovers( + bare: SkAnswer | null, + count: number, + doc: SkDoc, +): Record { + const out: Record = {}; + (bare?.정체 ?? []).slice(0, count).forEach((cid, i) => { + const c = doc.덮개?.[cid]; + const one: LineCover = {}; + if (c?.qty != null) one.qty = c.qty; + for (const k of PARTS) if (c?.[k] != null) one[k] = c[k]; + if (Object.keys(one).length) out[i] = one; + }); + return out; +} + +/** 텍스트 수식 답에서 뺀 바탕 줄 글 거름 — 공용 상세는 줄 글을 이름 차례로 붙임(같은 이름 더한줄이 + * 뺀 줄 글을 받지 않게) · 차례 = 바탕 이름(호표 → 덧줄)을 따라감(`Ho.attachPieces` 와 같은 길) */ +export function dropText( + text: T | null, + names: string[], + bare: SkAnswer | null, + doc: SkDoc, +): T | null { + const drop = new Set(doc.뺀줄 ?? []); + const gone = new Set((bare?.정체 ?? []).flatMap((cid, i) => (drop.has(cid) ? [i] : []))); + if (!text?.ok || !text.groups || !gone.size) return text; + const groups = text.groups.map((g) => { + let from = 0; + const 줄 = g.줄.filter((line) => { + const at = names.findIndex((n, i) => i >= from && n === line.이름); + if (at < 0) return true; + from = at + 1; + return !gone.has(at); + }); + return { ...g, 줄 }; + }); + return { ...text, groups }; +} + +/* ── 일위대가 줄 → SK 문서(717-3 · 723-3 줄 모드) ── */ +export interface LinePatch { + 덮개?: Record; + 뺀줄?: string[]; + 더한줄?: unknown[]; +} + +/** 줄 꼴(일위대가 `담은로직[]` 한 줄 · 쓰는 칸만) */ +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 = 그 일위대가 고침 없는 원래 값 · + * 고름 · 자재표시(호표 차례 열쇠)는 바탕 줄 정체(`ids` · 셈 답 `정체`)로 cid 열쇠로 */ +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 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; +} /* ── 순수 셈 끝 ── */ diff --git a/resources/tester/test_717_2b_sangeun_detail.py b/resources/tester/test_717_2b_sangeun_detail.py index 685c70b5f..d22602679 100644 --- a/resources/tester/test_717_2b_sangeun_detail.py +++ b/resources/tester/test_717_2b_sangeun_detail.py @@ -1,7 +1,9 @@ """717-2b M02 단가산출 상세 · 고침 지킴 시험 — 순수 꼴(node) · 글 · 서버 저장 왕복(임시 storage). -- 고친 칸 → SK 덮개(qty · 배율 · 비목 칸 · 원값 곁 글) · 더한줄 수량 · 단가 · 뺀줄 · 되살림 -- 로직 줄 단가 = 노 · 재 · 경 몫 비로 나눔 · 계산서 묶음 = 표 줄 그대로(별도계상 재료 = 금액 없음) +- 공용 상세 고침(724-1 `setLineCover`) → SK 덮개(qty · 배율 · 비목 칸 · 원값 곁 글) · + 더한줄 수량 · 단가 · 뺀줄 · 되살림 +- SK 셈 답 → 공용 상세 줄(`hoView` — 정체 차례 · 바탕 줄 그대로 · 더한줄 · 고침 열쇠) · + 계산서 묶음 = 표 줄 그대로 - 화면 문서 꼴이 서버 저장(`clean_doc`)을 지나고 서버가 원값 · 바탕판을 다시 적음 · 셈 = 손셈 - 상세 = M01 상세 부품(M02 길) · 부품 상자만 · 마스터 이동 단추 = 등록 없으면 숨김 """ @@ -44,7 +46,7 @@ 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"], + 정체: ["labor:A#0", "logic:G#0", "material:M#0", "pct:공구#0"], 줄: [line("labor:A#0", "석공", 0.1, 1000, { 노무비: 100 }, "인력"), line("logic:G#0", "굴착기", 2, 50, { 노무비: 60, 재료비: 30, 경비: 10 }, "경비")], 재료: [{ 차례: 2, 이름: "모르타르", 수량: 0.5, 단가: 200, 금액: 100 }] }; @@ -56,23 +58,25 @@ def test_고침_꼴(): body = ( SAMPLE + """ -const doc = { 덮개: {}, 더한줄: [{ 종류: "재료", 요소: "MT1", 수량: 1 }], 뺀줄: [] }; +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); +setLineCover(doc, by("labor:A#0"), { qty: 0.2 }, false); out.qty = doc.덮개["labor:A#0"]; -setQty(doc, by("labor:A#0"), 0.1, false); // 바탕 값과 같음 = 덮개 지움 +setLineCover(doc, by("labor:A#0"), { qty: 0.1 }, false); // 바탕 값과 같음 = 덮개 지움 out.qtyBack = doc.덮개["labor:A#0"] ?? null; -setPrice(doc, by("labor:A#0"), 1200); +setLineCover(doc, by("labor:A#0"), { labor: 1200 }, false); out.price = doc.덮개["labor:A#0"]; -setPrice(doc, by("logic:G#0"), 100); // 노 60 · 재 30 · 경 10 비 +setLineCover(doc, by("labor:A#0"), null, false); // 고침 풂 +out.unset = doc.덮개["labor:A#0"] ?? null; +setLineCover(doc, by("logic:G#0"), { labor: 60, material: 30, expense: 10 }, false); out.split = doc.덮개["logic:G#0"]; -setQty(doc, by("add:#0"), 3, false); -setPrice(doc, by("add:#0"), 350); +setLineCover(doc, by("add:#0"), { qty: 3, material: 350 }, false); out.added = doc.더한줄[0]; out.pick = [by("add:#0").apartPick, by("labor:A#0").apartPick]; setApart(doc, by("add:#0"), true); @@ -80,9 +84,9 @@ out.apartOn = doc.더한줄[0].별도계상 ?? null; setApart(doc, by("add:#0"), false); out.apartOff = doc.더한줄[0].별도계상 ?? null; const opened = { 덮개: {} }; -setQty(opened, by("labor:A#0"), 1.5, true); +setLineCover(opened, by("labor:A#0"), { qty: 0.15 }, true); // 바탕 품 0.1 → 배율 1.5 out.scale = opened.덮개["labor:A#0"]; -setQty(opened, by("labor:A#0"), 1, true); +setLineCover(opened, by("labor:A#0"), { qty: 0.1 }, true); out.scaleBack = opened.덮개["labor:A#0"] ?? null; dropLine(doc, by("labor:A#0")); out.drop = [doc.뺀줄, doc.덮개["labor:A#0"] ?? null]; @@ -94,6 +98,24 @@ const covered = { 덮개: { "labor:A#0": { qty: 0.3, 원값: { qty: 0.09, 단가 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.금액)]); +// 공용 상세 줄 — 바탕 줄 그대로 · 뺀 줄 없음 · 더한줄 · 별도계상 빠진 줄 = 빈 칸 · 덧줄 +const base = { 호표: [{ 종류: "인력", 요소: "A", 이름: "석공", 수량: "0.1*x" }, + { 종류: "로직", 요소: "로직(G)", 수량: "2" }, { 종류: "재료", 요소: "M", 수량: "0.5" }], + 덧줄: [{ 이름: "공구손료", 식: "노무비 * 3 / 100" }] }; +const doc2 = { 덮개: { "labor:A#0": { qty: 0.3, 원값: { qty: 0.1 } } }, 뺀줄: ["logic:G#0"], + 더한줄: [{ 종류: "인력", 요소: "LB2", 이름: "보통인부", 비목: "노무비", + 수량: 0.01, 단가: 500 }] }; +const ans2 = { ...bare, 정체: ["labor:A#0", "material:M#0", "add:#0", "pct:공구#0"], + 줄: [line("labor:A#0", "석공", 0.3, 1000, { 노무비: 300 }, "인력"), + line("add:#0", "보통인부", 0.01, 500, { 노무비: 5 }, "인력"), + { ...line("pct:공구#0", "공구손료", 9.15, 1, { 경비: 9.15 }, "경비"), 율: 3 }] }; +const v = hoView(ans2, bare, base, doc2); +out.view = [v.호표.map((x) => [x.종류, x.요소, x.수량, x.비목 ?? null]), v.덧줄, v.cids, + v.lines.map((l) => l?.cid ?? null), v.covers]; +out.viewRef = hoView(ans2, bare, null, doc2).호표[0]; +out.baseCovers = baseCovers(bare, 3, doc2); +const text = { ok: true, groups: [{ 비목: "노무비", 줄: [{ 이름: "석공" }, { 이름: "굴착기" }] }] }; +out.cut = dropText(text, ["석공", "굴착기", "모르타르"], bare, doc2).groups[0].줄.map((x) => x.이름); console.log(JSON.stringify(out)); """ ) @@ -107,13 +129,14 @@ console.log(JSON.stringify(out)); 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["unset"] is None assert got["split"]["labor"] == 60 and got["split"]["material"] == 30 assert got["split"]["expense"] == 10 assert got["added"]["수량"] == 3 and got["added"]["단가"] == 350 # 더한 재료 줄만 별도계상 고름 · 켬 = 칸 true · 끔 = 칸 지움(기본 계 안) assert got["pick"] == [True, False] assert got["apartOn"] is True and got["apartOff"] is None - assert got["scale"]["배율"] == 1.5 and "qty" not in got["scale"] + assert got["scale"]["배율"] == pytest.approx(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 @@ -124,6 +147,21 @@ console.log(JSON.stringify(out)); ["재료비", 30, [30, None]], ["경비", 10, [10]], ] + ho, extra, cids, lines, covers = got["view"] + assert ho == [ + ["인력", "A", "0.1*x", None], # 바탕 줄 그대로(식) + ["재료", "M", "0.5", None], + ["인력", "LB2", "0.01", "노무비"], # 더한줄 + ] + assert extra == [{"이름": "공구손료", "식": "노무비 * 3 / 100"}] + assert cids == ["labor:A#0", "material:M#0", "add:#0", "pct:공구#0"] + assert lines == ["labor:A#0", None, "add:#0", "pct:공구#0"] # 별도계상 빠진 재료 = 빈 칸 + assert covers == {"0": {"qty": 0.3}, "2": {"qty": 0.01, "labor": 500}} + assert got["viewRef"] == { # 참조 바탕 = 셈 줄로 지음 + "종류": "인력", "요소": "", "이름": "석공", "단위": "인", "수량": "0.3", "비목": "노무비", + } # fmt: skip + assert got["baseCovers"] == {"0": {"qty": 0.3}} + assert got["cut"] == ["석공"] # 뺀 바탕 줄 글 거름(같은 이름 더한줄이 안 받음) def test_상세_부품_길_부품상자(): @@ -135,6 +173,11 @@ def test_상세_부품_길_부품상자(): 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 # 최신 응답만 + # 724-1 — 단가산출 상세 = 공용 상세(Ho) · 고침 고리 = SK 덮개 · 옛 고침 표 걷음 + assert "...buildHo({" in detail and 'scope: "m02",' in detail + assert "setLineCover(doc, r, c, opened());" in detail and "skCoverTable" not in detail + ho = _text(ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Ho.ts") + assert "edit.more?.(e.idx, close)" in ho and '"EditedM02"' in ho for s in (detail, cover, _text(MODEL)): assert "