diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts index f27f458d8..98b79eb28 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts @@ -203,7 +203,7 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void { } /** `money` = 결과가 돈(원) — 그때만 결과 값도 정수 · 수량·비율 결과는 소수 그대로 */ -function answerView(answer: CalcAnswer, draft: boolean, money: boolean): HTMLElement[] { +export function answerView(answer: CalcAnswer, draft: boolean, money: boolean): HTMLElement[] { const note = draft ? [el("p", { className: "m01-logic__muted", text: tx("Calc_Draft") })] : []; if (!answer.ok) { return [ diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index 51189cd41..55b2834fa 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -302,7 +302,10 @@ def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]: if "결과" in got: # 돈 아닌 로직 — 반영 없음 lines.append({**line, "결과": got["결과"], "기준": None, "까닭": None}) continue - lines.append({**line, "기준": {k: got[k] for k in (*COSTS, "계")}, "까닭": None}) + # 「줄」 = 그 로직 재료 · 노무 · 경비 줄(줄 누름 상세 · 50-4) + lines.append( + {**line, "기준": {k: got[k] for k in (*COSTS, "계")}, "줄": got["줄"], "까닭": None} + ) mats = [ { "코드": one["코드"], diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index d80e0e147..3dd6d2015 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -130,6 +130,17 @@ box-shadow: inset 3px 0 0 var(--color-primary); } +/* 구분 칸(50-4) — 넉 자 · 흐린 글 */ +.m02-labrow__kind { + white-space: nowrap; + color: var(--color-text-secondary); +} + +/* 줄 상세(50-4) — 표 아래 · 고른 줄만 */ +.m02-labor__linedet { + margin-top: var(--spacing-8); +} + /* 별칭 칸(44-6) — 칸 안 글상자 · 누르면 바로 고침 */ .m02-labrow__alias { min-width: 7rem; @@ -259,6 +270,12 @@ max-width: none; } + .m02-labrow .m02-labrow__kind { + grid-column: 1 / -1; + order: 1; + font-size: var(--text-caption); + } + .m02-labrow .m02-labrow__no, .m02-labrow .m02-labrow__unit:not([data-label]), .m02-labrow .m02-labrow__code, @@ -322,6 +339,7 @@ /* 옵션 값 줄 — 한 줄 통째 */ .m02-labrow__optrow .m02-labrow__no, + .m02-labrow__sum .m02-labrow__kind, .m02-labrow__sum .m02-labrow__alias { display: none; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts index dd9da6fe9..b038f3c29 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts @@ -5,6 +5,8 @@ * 기준(결과단위 그대로)은 서버 calc 길이 셈 · 여기는 받은 기준에 * 수량(손이 칸보다 앞섬 · 칸 = 알약 키 값) · 반영 = 기준 × 수량 ÷ 배 · 자재 = 재료비만. */ +import type { CalcLine as LogicLine } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; + export const MONEY_KEYS = ["노무비", "재료비", "경비", "계"] as const; export type Money = Record<(typeof MONEY_KEYS)[number], number>; export type QtySource = "손" | "칸" | "없음"; @@ -22,6 +24,8 @@ export interface CalcLine { 반영?: Money | null; 까닭?: string | null; 결과?: unknown; + /** 그 로직 재료 · 노무 · 경비 줄(M01 시험계산 표와 같은 모양 · 50-4) */ + 줄?: LogicLine[]; } export interface CalcMaterial { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 0e34d21ee..c02cc57ac 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -2,7 +2,8 @@ * M02_MasterTemplete_Labor_Detail.ts * 일위대가 상세(39-3) — 구조물 열마다 하나(`labor/<열>.json`) · UI_Main 종류 `labor` 가 이 부품을 붙임 * (양식 길 읽기 · [저장] · [다시 읽기]는 UI_Main). 차례 = 정보(단위 · 본뜬) → 담은 로직 → 자재목록 → 다이어그램. - * · 담은 로직 줄 누름 = 고름(하이라이트 · 다시 그려도 유지 · 47-5) · [옵션] = 입력 모달(`Labor_Input`) · + * · 담은 로직 줄 누름 = 고름(하이라이트 · 다시 그려도 유지 · 47-5) + 표 아래 그 줄 상세(50-4 · M01 시험계산 표) · + * 같은 줄 다시 누르면 닫힘 · [옵션] = 입력 모달(`Labor_Input`) · * [확인] = 화면 캐시만 · 저장은 [저장] * · 금액 = 서버 calc(기준 ③ · 최신 응답만 · 저장 안 함) · 고칠 때마다 뒤에서 다시 셈 * · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨 @@ -15,6 +16,9 @@ import { ROUTES } from "@config/config_frontend"; import { navigateTo } from "../A00_Common/router"; import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side"; import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source"; +import { answerView } from "../M01_MasterData/M01_MasterData_UI_Logic_Calc"; +import type { CalcAnswer } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; +import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money"; import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet"; import { recalcWorkbook } from "../A00_Common/spreadsheet/spreadsheet_recalc"; import { @@ -24,7 +28,7 @@ import { type Layer, } from "./M02_MasterTemplete_Api_Fetch"; import { linkAreaCell } from "./M02_MasterTemplete_AreaCellPick"; -import { settle } from "./M02_MasterTemplete_Labor_Calc"; +import { MONEY_KEYS, settle } from "./M02_MasterTemplete_Labor_Calc"; import { areaLinks, calcLabor, @@ -134,6 +138,46 @@ export function mountLaborDetail( const lineOf = (key: string) => calc?.줄.find((l) => l.로직 === key); const matOf = (code: string) => calc?.자재.find((m) => m.코드 === code); + /** 고른 줄 상세(50-4) — M01 시험계산 표(재료 · 노무 · 경비 줄 + 기준 합) + 반영 합 · 고른 줄 없으면 비움 */ + const lineDet = el("div", { + className: "m02-labor__linedet", + attrs: { "data-labor-linedet": "" }, + }); + function showLine(): void { + if (!picked) return void lineDet.replaceChildren(); + const logic = logics.get(picked); + const head = el("h4", { + text: tc("Labor_LineDetail").replace("{value}", `${picked} ${logic?.이름 ?? ""}`.trim()), + }); + const line = lineOf(picked); + if (!line) + return void lineDet.replaceChildren( + head, + el("p", { className: "m01-logic__muted", text: fail ?? "…" }), + ); + const answer: CalcAnswer = line.까닭 + ? { ok: false, reason: line.까닭 } + : { ok: true, lines: line.줄, sums: line.기준 ?? undefined, result: line.결과, middle: {} }; + // 반영 = 기준 × 수량 ÷ 배(표 아래 줄과 같음) · 수량 없으면 「—」 · 돈 아닌 로직은 없음 + const applied = line.기준 + ? [ + el("h4", { text: tc("Labor_LineApplied") }), + el("dl", { + className: "m01-logic__sums", + children: MONEY_KEYS.flatMap((k) => [ + el("dt", { text: k === "계" ? tc("Prev_Sum") : k }), + el("dd", { + className: "m01-logic__money", + text: line.반영 ? formatMoney(line.반영[k]) : "—", + }), + ]), + }), + ] + : []; + const money = (logic?.결과단위 ?? "").startsWith("원"); + lineDet.replaceChildren(head, ...answerView(answer, false, money), ...applied); + } + const drawDg = (): void => { if (opt.card || !dgBox.open) return; // 카드 갈래엔 다이어그램 없음 const data = buildLaborDiagram({ @@ -172,7 +216,9 @@ export function mountLaborDetail( } : { open: pick, option: openRow }, ), + lineDet, ); + showLine(); matHost.replaceChildren( matTable( { mats: cur.자재, calc, fail, cells: cells() }, @@ -293,11 +339,14 @@ export function mountLaborDetail( [cur.담은로직[i], cur.담은로직[j]] = [cur.담은로직[j], cur.담은로직[i]]; changed(); } - /** 줄 고름(47-5) — 하이라이트(표만 · 다이어그램 안 다시 그림) + 원문 패널(47-4) · 화면 캐시 안 바뀜 */ + /** 줄 고름(47-5) — 하이라이트(표만 · 다이어그램 안 다시 그림) + 줄 상세(50-4) + 원문 패널(47-4) · + * 고른 줄 다시 누름 = 닫음 · 화면 캐시 안 바뀜 */ function pick(i: number): void { - picked = cur.담은로직[i].로직; + const key = cur.담은로직[i].로직; + picked = picked === key ? null : key; for (const tr of logicHost.querySelectorAll("[data-labor-logic],[data-labor-opt]")) tr.classList.toggle("is-selected", (tr.dataset.laborLogic ?? tr.dataset.laborOpt) === picked); + showLine(); showSource(); } function removeRow(i: number): void { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index b6993e8eb..cdae92877 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M02_MasterTemplete_Labor_Rows.ts * 일위대가 담은 로직 표 · 자재목록 표(39-3) — 상세 · 보기 갈래(면적산출 오른쪽 패널 · 카드)가 같이 씀. - * 담은 로직 = 차례 · 로직(키 · 이름 + 「빈 값 n」 붉게) · 별칭(44-6) · 수량산출(「12.5(B6)」) · + * 담은 로직 = 차례 · 구분(키 머리에서 읽음 · 50-4) · 로직(키 · 이름 + 「빈 값 n」 붉게) · 별칭(44-6) · 수량산출(「12.5(B6)」) · * 결과 단위 · 금액 칸 넷(노무비 · 재료비 · 경비 · 합계 · 칸마다 위 기준 작게 / 아래 반영) · * 옵션 값(「지역 서울 · 규격 25mm」)은 줄 아래 이름 ~ 합계에 걸친 한 줄 · 맨 아래 합계 줄(전체 합계 강조). * 자재목록 = 코드 · 이름 · 규격 · 수량산출 · 단위 · 가격 다섯(기준 칸 강조) · 기준단가 / 반영 금액(43-3). @@ -34,8 +34,12 @@ const HEAD: Record<(typeof COSTS)[number], Parameters[0]> = { 계: "Prev_Sum", }; -/** 옵션 값 줄이 걸치는 칸 수 — 이름 · 별칭 · 수량산출 · 결과 단위 · 금액 넷 */ -const SPAN = 4 + COSTS.length; +/** 옵션 값 줄이 걸치는 칸 수 — 구분 · 이름 · 별칭 · 수량산출 · 결과 단위 · 금액 넷 */ +const SPAN = 5 + COSTS.length; + +/** 구분(50-4 · 저장 안 함) — 줄 모양(키 머리)에서 읽음 · SP- = 단가승격 · SC- = 일위조합(50-3) */ +export const kindOf = (key: string): string => + tc(key.startsWith("SP-") ? "Kind_Promote" : key.startsWith("SC-") ? "Kind_Combo" : "Kind_Logic"); const num = (v: number): string => Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 }); @@ -253,6 +257,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement attrs: { "data-labor-logic": row.로직 }, children: [ el("td", { className: "m02-labrow__no", text: String(i + 1) }), + el("td", { className: "m02-labrow__kind", text: kindOf(row.로직) }), titled( el("td", { className: "m02-labrow__name", @@ -331,6 +336,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement className: "m02-labrow__sum", children: [ el("td", { className: "m02-labrow__no", text: "" }), + el("td", { className: "m02-labrow__kind", text: "" }), el("td", { className: "m02-labrow__name", text: tc("Prev_Sum") }), el("td", { className: "m02-labrow__alias", text: "" }), el("td", { className: "m02-labrow__qty", text: "" }), @@ -352,6 +358,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement : []; const heads = [ tc("Col_Order"), + tc("Sub"), tc("Col_Name"), tc("Col_Alias"), tc("Labor_QtyCol"), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index db14ee6bc..ed3e78489 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -84,6 +84,12 @@ const TEXT = { Labor_BasePrice: ["기준 단가", "Base price"], Labor_AppliedSum: ["반영 금액", "Applied amount"], Labor_Calc_Fail: ["계산 못 함", "Calculation failed"], + // 구분 · 줄 상세(50-4) + Kind_Logic: ["단가산출", "Logic"], + Kind_Promote: ["단가승격", "Promoted"], + Kind_Combo: ["일위조합", "Combination"], + Labor_LineDetail: ["상세 — {value}", "Detail — {value}"], + Labor_LineApplied: ["반영(수량 곱함)", "Applied (× quantity)"], Labor_Empty: ["빈 값 {n}", "{n} empty"], Labor_Lost: [ "로직 입력이 바뀌어 저장 때 빠지는 값 — {value}", diff --git a/resources/tester/test_m02_labor_doc.py b/resources/tester/test_m02_labor_doc.py index a2a629fd0..c42ce06ed 100644 --- a/resources/tester/test_m02_labor_doc.py +++ b/resources/tester/test_m02_labor_doc.py @@ -90,6 +90,14 @@ def test_반영은_기준_곱_수량_나누기_배_돈_아닌_로직은_반영_ assert got["반영합"]["계"] == pytest.approx(ten["반영"]["계"]) +def test_돈_로직_줄에_재료_노무_경비_줄_실림_합은_기준(): + got = labor.calc(labor.clean(_doc(담은로직=[{"로직": TEN}, {"로직": PLAIN}])), {}) + ten, plain = got["줄"] + assert ten["줄"] and all({"이름", "수량", "단위", "금액"} <= set(x) for x in ten["줄"]) + assert sum(x["금액"] for x in ten["줄"]) == pytest.approx(ten["기준"]["계"]) + assert "줄" not in plain + + def test_수량_손이_칸보다_앞섬_칸_값은_알약_키로(): doc = labor.clean( _doc( diff --git a/resources/tester/ui_checks/50-4_일위대가_줄_상세.js b/resources/tester/ui_checks/50-4_일위대가_줄_상세.js new file mode 100644 index 000000000..11fecbbb2 --- /dev/null +++ b/resources/tester/ui_checks/50-4_일위대가_줄_상세.js @@ -0,0 +1,131 @@ +/* 50-4 — 일위대가 단가산출 로직 표 줄 누름 = 표 아래 그 줄 상세(M01 시험계산 표) · 다시 누르면 닫힘 · 「구분」 열 — 카드 · 상세 두 곳 + * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 · + * 나무에 item1(구조물 조합 · 일위대가 로직 둘 이상)이 있음 + * 부르는 법: orca eval --expression "$(tr '\n' ' ' < 이 파일)" (async 식 · JSON 글을 돌려줌) + * 하는 일: item1 카드 표 줄 누름 · 다른 줄 · 같은 줄 다시 → 일위대가 카드 [수정]으로 상세 → 같은 차례 → item1 다시(값 안 바뀜) + * 기대 수치(카드 · 상세 같음): + * - head[1] = "구분" · kinds = 줄마다 "단가산출" + * - first.open = 1(상세 상자 하나) · first.rows ≥ 1 · first.sum = first.total = first.base(줄 합 = 상세 계 = 표 기준 합계) + * - first.applied = first.appliedRow(상세 반영 계 = 표 반영 합계 · 수량 없으면 둘 다 「—」 = 0) + * - second.title 가 둘째 줄 키 · second.open = 1(한 줄만) + * - again.open = 0 · again.selected = 0(같은 줄 다시 = 닫힘) + * - back = true(카드 화면으로 돌아감) + */ +(async () => { + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 8000) => { + for (let t = 0; t < ms; t += 150) { + const v = fn(); + if (v) return v; + await w(150); + } + return null; + }; + const n = (s) => Number(String(s ?? "").replace(/[^0-9.-]/g, "")) || 0; + const rowsOf = (t) => [...t.querySelectorAll("tbody tr[data-labor-logic]")]; + const settled = (get) => + until(() => { + const t = get(); + return t && !/…/.test(t.querySelector("tbody").innerText) ? t : null; + }, 10000); + /* 상세 상자 읽기 — 표 줄 금액 합 · 기준 계 · 반영 계 */ + const det = (box) => { + const dl = [...box.querySelectorAll("dl.m01-logic__sums")]; + const total = (d) => { + const dt = d && [...d.querySelectorAll("dt")].find((x) => /계|합계|Total/.test(x.innerText)); + return dt ? n(dt.nextElementSibling.innerText) : null; + }; + const rows = [...box.querySelectorAll("table.m01-logic__grid tbody tr")]; + return { + title: box.querySelector("h4")?.innerText ?? null, + rows: rows.length, + sum: rows.reduce((a, r) => a + n(r.lastElementChild.innerText), 0), + total: total(dl[0]), + applied: dl[1] ? total(dl[1]) : "없음", + }; + }; + const measure = async (get, boxOf) => { + const t = await settled(get); + const rows = rowsOf(t); + const head = [...t.querySelectorAll("thead th")].map((th) => th.innerText.trim()); + const kinds = rows.map((r) => r.querySelector(".m02-labrow__kind")?.innerText.trim()); + const click = async (key) => { + get().querySelector(`tr[data-labor-logic="${key}"] .m02-labrow__name`).click(); + await w(300); + }; + const opened = () => + [...boxOf().querySelectorAll("[data-labor-linedet]")].filter((b) => b.childElementCount); + const k0 = rows[0].getAttribute("data-labor-logic"); + const k1 = rows[1]?.getAttribute("data-labor-logic") ?? k0; + const sumCell = (key) => { + const tds = get().querySelectorAll(`tr[data-labor-logic="${key}"] .m02-labrow__money`); + const last = tds[tds.length - 1]; + return { + base: n(last.querySelector(".m02-labrow__base").innerText), + applied: n(last.querySelector(".m02-labrow__applied").innerText), + }; + }; + await click(k0); + const d0 = det(opened()[0]); + const row0 = sumCell(k0); + const first = { + key: k0, + open: opened().length, + ...d0, + base: row0.base, + appliedRow: row0.applied, + }; + await click(k1); + const second = { + key: k1, + open: opened().length, + title: opened()[0]?.querySelector("h4")?.innerText, + }; + await click(k1); + const again = { + open: opened().length, + selected: rowsOf(get()).filter((r) => r.classList.contains("is-selected")).length, + }; + return { head, kinds, first, second, again }; + }; + const node = await until(() => document.querySelector('[data-node="i|item1"]')); + if (!node) return JSON.stringify({ error: "item1 없음 — 폴더를 펼쳐 둘 것" }); + node.click(); + const card = await until(() => + [...document.querySelectorAll(".m02-struct__card")].find((c) => + c.querySelector("table.m02-labrow tbody tr[data-labor-logic]"), + ), + ); + if (!card) return JSON.stringify({ error: "일위대가 카드 표 없음" }); + await w(1500); + const out = { + card: await measure( + () => card.querySelector("table.m02-labrow"), + () => card, + ), + }; + const edit = [...card.querySelectorAll(".m02-struct__card-tools button")].find( + (b) => b.innerText.trim() === "수정", + ); + if (!edit) return JSON.stringify({ ...out, error: "일위대가 [수정] 없음" }); + edit.click(); + const dt = await until(() => + [...document.querySelectorAll("table.m02-labrow")].find( + (t) => !t.closest(".m02-struct__card") && t.querySelector("tbody tr[data-labor-logic]"), + ), + ); + if (!dt) return JSON.stringify({ ...out, error: "상세 표 없음" }); + await w(2000); + const table = () => + [...document.querySelectorAll("table.m02-labrow")].find((t) => !t.closest(".m02-struct__card")); + out.detail = await measure(table, () => table().closest("[data-labor]")); + /* 카드 화면으로 돌아감(고름만 했으니 버림 물음 없음 — 뜨면 확인) */ + document.querySelector('[data-node="i|item1"]')?.click(); + await w(800); + [...document.querySelectorAll(".ui-modal button, dialog button")] + .find((b) => /확인|버림|떠나기/.test(b.innerText)) + ?.click(); + await w(1200); + out.back = !!document.querySelector(".m02-struct__card table.m02-labrow"); + return JSON.stringify(out); +})();