diff --git a/M01_MasterData/M01_MasterData_UI_Combo_Preview.ts b/M01_MasterData/M01_MasterData_UI_Combo_Preview.ts index 3ac16a3c..71765bf8 100644 --- a/M01_MasterData/M01_MasterData_UI_Combo_Preview.ts +++ b/M01_MasterData/M01_MasterData_UI_Combo_Preview.ts @@ -17,7 +17,8 @@ const LABEL: Record<(typeof COSTS)[number], ComboTextKey> = { }; const num = (v: string): number | string => (v.trim() !== "" && !isNaN(Number(v)) ? Number(v) : v); -const fmt = (n: number): string => n.toLocaleString("ko-KR", { maximumFractionDigits: 4 }); +/** 원 단위 소수점 0 — 조합 미리 보기는 모두 돈 자리(노무비·재료비·경비·계) */ +const fmt = (n: number): string => n.toLocaleString("ko-KR", { maximumFractionDigits: 0 }); /** 로직 입력 정의 → 처음 값(고르기 첫째 · 범위 아래끝) */ const initial = (i: LogicInput): string => String(i.고르기?.[0] ?? i.범위?.[0] ?? ""); diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts index 03e2b0f3..03fbe43d 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts @@ -19,9 +19,53 @@ import { buildEditor, formatNumber, qtyKind } from "./M01_MasterData_UI_Logic_Ed import { openPicker } from "./M01_MasterData_UI_Logic_Pick"; import { tx } from "./M01_MasterData_UI_Logic_Text"; import { buildFormula } from "./M01_MasterData_UI_LogicLab_Formula"; -import { openMaterialModal } from "./M01_MasterData_UI_LogicLab_Modal"; +import { openMaterialModal, openTableModal } from "./M01_MasterData_UI_LogicLab_Modal"; +import { fragmentRow, type Fragment } from "./M01_MasterData_UI_LogicLab_Pill"; import { tl, type LabTextKey } from "./M01_MasterData_UI_LogicLab_Text"; import "./M01_MasterData_UI_LogicLab_Style.css"; +import "./M01_MasterData_UI_LogicLab_Pill.css"; + +/** 원 단위 소수점 0 — 돈 자리(단가·금액·소계·계)만. 수량·비율은 `formatNumber` 그대로 둠. */ +const formatMoney = (value: unknown): string => + typeof value === "number" + ? value.toLocaleString("ko-KR", { maximumFractionDigits: 0 }) + : formatNumber(value); + +/** 조각 계약(`ref/_분석_알약_상세줄.md` ㉮ 표) — 수량 식 안 찾기(...) 표 호출만 알약, + * 그 밖(숫자·연산기호·중간값·설계입력 이름)은 글자. 서버가 조각 목록을 아직 안 주므로 + * 여기서 임시로 쪼갬(`Logic_Note.ts` 찾기() 정규식과 같은 규칙) — 서버가 붙으면 이 파싱을 걷어냄. */ +const FIND_RE = /찾기\(\s*([A-Za-z0-9_]+)\s*,([^)]*)\)\s*\.\s*([A-Za-z0-9_가-힣]+)/g; + +function splitFindConds(raw: string): [string, string][] { + return raw + .split(",") + .map((p) => p.trim()) + .filter(Boolean) + .map((p): [string, string] => { + const i = p.indexOf("="); + return i < 0 ? [p, p] : [p.slice(0, i).trim(), p.slice(i + 1).trim()]; + }); +} + +function qtyFragments(expr: string, values: Record): Fragment[] { + const out: Fragment[] = []; + let last = 0; + for (const m of expr.matchAll(FIND_RE)) { + const at = m.index ?? 0; + if (at > last) out.push({ text: expr.slice(last, at) }); + const table = m[1]; + const label = `${tl("Pill_Table")} ${table}`; + out.push({ + pill: { + label, + onClick: () => openTableModal({ title: label, table, conds: splitFindConds(m[2]), values }), + }, + }); + last = at + m[0].length; + } + if (last < expr.length) out.push({ text: expr.slice(last) }); + return out.length ? out : [{ text: expr }]; +} export interface DetailContext { row: LogicRow; @@ -56,6 +100,7 @@ interface Entry { unit: string; qty: string; qtyTag: string; + qtyFrag: Fragment[]; price: string; amount: number | null; /** 덧줄이면 true */ @@ -168,7 +213,8 @@ function entries(ctx: DetailContext): Entry[] { unit: item.단위 ?? "", qty: item.수량, qtyTag: qtyKind(item.수량) === tx("Ho_QtyTable") ? tl("Tag_Table") : qtyKind(item.수량), - price: price === undefined || price === null ? "" : formatNumber(price), + qtyFrag: qtyFragments(item.수량, ctx.values), + price: price === undefined || price === null ? "" : formatMoney(price), amount: line ? line.금액 : null, extra: false, open: () => @@ -192,6 +238,7 @@ function entries(ctx: DetailContext): Entry[] { unit: "", qty: extra.식, qtyTag: tl("Extra"), + qtyFrag: qtyFragments(extra.식, ctx.values), price: "", amount: line ? line.금액 : null, extra: true, @@ -224,13 +271,14 @@ function lineRow(e: Entry): HTMLElement { }), cellOf(e.unit), el("td", { + className: "m01lab__qty-cell", children: [ el("span", { className: "m01-logic__tag", text: e.qtyTag }), - el("span", { className: "m01lab__qty", text: ` ${e.qty}` }), + fragmentRow(e.qtyFrag), ], }), cellOf(e.price, "m01-logic__money"), - cellOf(e.amount === null ? "" : formatNumber(e.amount), "m01-logic__money"), + cellOf(e.amount === null ? "" : formatMoney(e.amount), "m01-logic__money"), ], }); tr.addEventListener("click", e.open); @@ -254,7 +302,7 @@ function groupTable(group: Group, mine: Entry[], calculated: boolean): HTMLEleme attrs: { "data-group": group, "data-subtotal": calculated ? String(sub) : "" }, children: [ el("td", { attrs: { colspan: "4" }, text: `${groupName(group)} ${tl("Subtotal")}` }), - cellOf(calculated ? formatNumber(sub) : "—", "m01-logic__money"), + cellOf(calculated ? formatMoney(sub) : "—", "m01-logic__money"), ], }); const headers = [ @@ -326,7 +374,7 @@ function hoBox(ctx: DetailContext): HTMLElement { const totalLine = el("p", { className: "m01lab__total", attrs: { "data-sum": calculated ? String(total) : "" }, - text: `${tl("Total")} ${calculated ? formatNumber(total) : "—"}`, + text: `${tl("Total")} ${calculated ? formatMoney(total) : "—"}`, }); // 자체 로직 — 고치는 자리는 편집기 하나 · 읽기용 묶음 표는 접어 둠(펼치면 소계가 보임) const fold = el("details", { diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts index 7fd3ca41..69288127 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts @@ -1,25 +1,25 @@ /* ============================================================================= * M01_MasterData_UI_LogicLab_Modal.ts - * 호표 줄 자료 모달 — 그 줄이 쓰는 자료를 보임 - * 표(소요량·계수)는 표 그대로 + 지금 넣은 값으로 걸린 줄 강조 · 단일 값(단가)은 값과 출처 + * 자료 모달 둘 — + * ① 줄 모달(`openMaterialModal`) — 줄을 누르면 그 줄의 단가(재료·인력·기계 또는 다른 로직)를 + * 바로 보임(알약 목록 없이) · 그 아래 한 줄 풀이 + 원문 수량 식 + * ② 표 모달(`openTableModal`) — 수량 칸 안 알약(찾기(...) 표 참조)을 누르면 그 표만 보임 + * 표는 표 그대로 + 지금 넣은 값으로 걸린 줄 강조 · 단일 값(단가)은 값과 출처 * 표 읽기 = `/elements` → `/table`(`Logic_Note.loadTable`) · 단가 = 로직 화면이 이미 받은 `prices` * ========================================================================== */ import { createButton, el } from "@ui/ui_template_elements"; import type { ElementBrief, NamedFormula } from "./M01_MasterData_UI_Logic_Api"; import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; -import { - explain, - loadTable, - matchRow, - relatedFinds, - type RelatedFind, - type TableRow, -} from "./M01_MasterData_UI_Logic_Note"; +import { explain, loadTable, matchRow, type TableRow } from "./M01_MasterData_UI_Logic_Note"; import { jumpToMaster, type MasterJump } from "./M01_MasterData_UI_Side"; -import { pillBar, type Pill } from "./M01_MasterData_UI_LogicLab_Pill"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; -import "./M01_MasterData_UI_LogicLab_Pill.css"; + +/** 원 단위 소수점 0 — 돈 자리만(수량·비율은 `formatNumber` 그대로) */ +const formatMoney = (value: unknown): string => + typeof value === "number" + ? value.toLocaleString("ko-KR", { maximumFractionDigits: 0 }) + : formatNumber(value); export interface ModalTarget { title: string; @@ -33,19 +33,25 @@ export interface ModalTarget { values: Record; } +/** 수량 칸 표 알약 하나가 여는 자료 — 조각 계약의 「표 찾기」 자리 */ +export interface TableTarget { + title: string; + table: string; + conds: [string, string][]; + values: Record; +} + function tableView( - find: RelatedFind, table: TableRow, + conds: [string, string][], values: Record, ): HTMLElement { - // 표 줄의 빈 칸은 「무엇이든」 — 서버 찾기()와 같게 그 칸은 조건에서 뺌 + // 표 줄의 빈 칸은 「무엇이든」 — 서버 찾기()와 같게 그 칸은 조건에서 뺌(줄마다 따로 봄) const hit = (table.줄 ?? []).find((line) => matchRow( { ...table, 줄: [line] }, - find.find.conds.filter( - ([col]) => line[col] !== undefined && line[col] !== null && line[col] !== "", - ), + conds.filter(([col]) => line[col] !== undefined && line[col] !== null && line[col] !== ""), { values, middle: {} }, ), ) ?? null; @@ -71,7 +77,7 @@ function tableView( }), el("p", { className: "m01-logic__muted", - text: `${find.source} → ${find.find.col} · ${hit ? tl("Modal_Hit") : tl("Modal_NoHit")}`, + text: hit ? tl("Modal_Hit") : tl("Modal_NoHit"), }), goButton({ kind: "table", key: table.키 }), el("div", { @@ -117,7 +123,7 @@ function priceView(ref: string, brief: ElementBrief): HTMLElement { ? [el("dt", { text: tl("Modal_Spec") }), el("dd", { text: brief.규격 })] : []), el("dt", { text: tl("Modal_Value") }), - el("dd", { className: "m01-logic__money", text: formatNumber(brief.값) }), + el("dd", { className: "m01-logic__money", text: formatMoney(brief.값) }), ...(brief.file ? [el("dt", { text: tl("Modal_Source") }), el("dd", { text: brief.file })] : []), @@ -129,18 +135,18 @@ function priceView(ref: string, brief: ElementBrief): HTMLElement { }); } -/** 모달을 엶 */ -export function openMaterialModal(target: ModalTarget): void { +/** 모달 겉틀(배경·닫기·포커스) — 몸은 `mount` 이 채움 */ +function openDialog(title: string, mount: (body: HTMLElement) => void): void { const body = el("div", { className: "m01lab__modal-body" }); const close = (): void => backdrop.remove(); const dialog = el("div", { className: "m01-logic__pick m01lab__modal", - attrs: { role: "dialog", "aria-label": target.title }, + attrs: { role: "dialog", "aria-label": title }, children: [ el("div", { className: "m01lab__modal-top", children: [ - el("h3", { text: target.title }), + el("h3", { text: title }), createButton({ label: tl("Modal_Close"), variant: "ghost", onClick: close }), ], }), @@ -150,64 +156,62 @@ export function openMaterialModal(target: ModalTarget): void { const backdrop = el("div", { className: "m01-logic__backdrop", children: [dialog] }); backdrop.addEventListener("click", (ev) => ev.target === backdrop && close()); backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close()); - - const finds = relatedFinds(target.expr, target.middles); - const base = target.element?.split(".")[0] ?? ""; - const price = target.element ? (target.prices[target.element] ?? target.prices[base]) : undefined; - const logicRef = target.element?.startsWith("로직(") ? target.element : ""; - const pills: Pill[] = []; - if (price && target.element) { - pills.push({ - label: `${groupOf(base)} ${price.이름 ?? base}`.trim(), - view: () => priceView(target.element as string, price), - }); - } else if (target.element && !logicRef) { - pills.push({ - label: `${groupOf(base)} ${target.title}`.trim(), - view: () => - el("p", { - className: "m01-logic__muted", - text: `${target.element} — ${tl(target.element?.includes("{") ? "Pill_NoPick" : "Pill_NoValue")}`, - }), - }); - } - if (logicRef) { - pills.push({ - label: `${tl("Pill_Logic")} ${logicRef.slice(3).split(",")[0].replace(/\)$/, "")}`, - view: () => el("p", { text: `${tl("Modal_Logic")} · ${logicRef}` }), - }); - } - for (const f of finds) { - pills.push({ - label: `${tl("Pill_Table")} ${f.find.table}`, - view: async () => { - const table = await loadTable(f.find.table); - return table - ? tableView(f, table, target.values) - : el("p", { className: "m01-logic__bad", text: `${f.find.table} ?` }); - }, - }); - } - const unit = price ? `${tl("Modal_UnitPrice")} ${formatNumber(price.값)} × ` : ""; - const parts: HTMLElement[] = [ - el("p", { className: "m01-logic__muted", text: tl("Modal_Sum") }), - el("p", { - className: "m01lab__sum", - text: `${target.title} = ${unit}${explain(target.expr)}`, - }), - ...(pills.length - ? [el("p", { className: "m01-logic__muted", text: tl("Pill_Hint") }), pillBar(pills)] - : [el("p", { className: "m01-logic__muted", text: tl("Modal_NoData") })]), - el("details", { - className: "m01lab__raw", - children: [ - el("summary", { text: tl("Modal_Qty") }), - el("pre", { className: "m01lab__expr", text: target.expr }), - ], - }), - ]; - body.replaceChildren(...parts); + mount(body); document.body.append(backdrop); dialog.tabIndex = -1; dialog.focus(); } + +/** 줄을 눌렀을 때 — 그 줄의 단가(재료·인력·기계 또는 다른 로직)를 바로 보임(알약 없이) */ +export function openMaterialModal(target: ModalTarget): void { + openDialog(target.title, (body) => { + const base = target.element?.split(".")[0] ?? ""; + const price = target.element + ? (target.prices[target.element] ?? target.prices[base]) + : undefined; + const logicRef = target.element?.startsWith("로직(") ? target.element : ""; + const rateParts: HTMLElement[] = []; + if (price && target.element) { + rateParts.push(priceView(target.element, price)); + } else if (logicRef) { + rateParts.push(el("p", { text: `${tl("Modal_Logic")} · ${logicRef}` })); + } else if (target.element) { + rateParts.push( + el("p", { + className: "m01-logic__muted", + text: `${target.element} — ${tl(target.element.includes("{") ? "Pill_NoPick" : "Pill_NoValue")}`, + }), + ); + } + const unit = price ? `${tl("Modal_UnitPrice")} ${formatMoney(price.값)} × ` : ""; + body.replaceChildren( + ...rateParts, + el("p", { className: "m01-logic__muted", text: tl("Modal_Sum") }), + el("p", { + className: "m01lab__sum", + text: `${target.title} = ${unit}${explain(target.expr)}`, + }), + el("details", { + className: "m01lab__raw", + children: [ + el("summary", { text: tl("Modal_Qty") }), + el("pre", { className: "m01lab__expr", text: target.expr }), + ], + }), + ); + }); +} + +/** 수량 칸 표 알약을 눌렀을 때 — 그 표만 보임(걸린 줄 강조) */ +export function openTableModal(target: TableTarget): void { + openDialog(target.title, (body) => { + body.replaceChildren(el("p", { className: "m01-logic__muted", text: "…" })); + void loadTable(target.table).then((table) => { + body.replaceChildren( + table + ? tableView(table, target.conds, target.values) + : el("p", { className: "m01-logic__bad", text: `${target.table} ?` }), + ); + }); + }); +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Pill.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Pill.ts index 7282dd4c..4e28b0ba 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Pill.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Pill.ts @@ -1,44 +1,44 @@ /* ============================================================================= * M01_MasterData_UI_LogicLab_Pill.ts - * 줄 모달의 알약 단추 — 쓰인 자료를 알약으로만 늘어놓고, 누른 것 하나만 펼침(다시 누르면 접힘) + * 알약 단추 — 수량 칸 안 조각(표 찾기 등)을 누르면 곧장 그 자료 모달을 엶. + * (이전에는 알약을 늘어놓고 아래 자리에서 펼쳤으나, 알약이 수량 칸 안으로 들어가며 + * 「누르면 모달」 로 바뀜 — 모달 안에 알약 목록을 또 두지 않음.) * ========================================================================== */ import { el } from "@ui/ui_template_elements"; export interface Pill { - /** 알약 글 — 보기 「표 QF000421」 · 「인력 석공」 */ + /** 알약 글 — 보기 「표 QF000421」 */ label: string; - /** 펼칠 때 그림 — 표처럼 읽어 와야 하면 Promise */ - view: () => HTMLElement | Promise; + /** 누르면 할 일 — 그 자료 모달을 엶 */ + onClick: () => void; } -export function pillBar(pills: Pill[]): HTMLElement { - const stage = el("div", { className: "m01lab__pill-stage" }); - const buttons: HTMLButtonElement[] = []; - let open = -1; - let turn = 0; - const show = async (i: number): Promise => { - const mine = ++turn; - open = open === i ? -1 : i; - buttons.forEach((b, k) => { - b.setAttribute("aria-expanded", String(k === open)); - b.classList.toggle("is-open", k === open); - }); - if (open < 0) return stage.replaceChildren(); - const view = await pills[open].view(); - if (mine === turn) stage.replaceChildren(view); // 빨리 눌러도 마지막 것만 - }; - pills.forEach((p, i) => { - const b = el("button", { - className: "m01lab__pill", - text: p.label, - attrs: { type: "button", "aria-expanded": "false", "data-pill": p.label }, - }); - b.addEventListener("click", () => void show(i)); - buttons.push(b); +/** 알약 하나 — 글자 조각 사이에 끼워 쓰는 단추 */ +export function pillButton(p: Pill): HTMLButtonElement { + const b = el("button", { + className: "m01lab__pill", + text: p.label, + attrs: { type: "button", "data-pill": p.label }, }); - return el("div", { - className: "m01lab__pills", - children: [el("div", { className: "m01lab__pill-row", children: buttons }), stage], + b.addEventListener("click", (ev) => { + ev.stopPropagation(); // 줄 전체 누르기(행 모달)와 안 겹치게 + p.onClick(); + }); + return b; +} + +/** 수량 칸 조각 — 알약 아니면 그냥 글자(연산기호·숫자·중간값 이름) */ +export type Fragment = { pill: Pill } | { text: string }; + +/** 조각을 이어 그림 — 칸 폭을 넘으면 자동으로 줄바꿈(`flex-wrap`) */ +export function fragmentRow(fragments: Fragment[]): HTMLElement { + return el("span", { + className: "m01lab__qty-frags", + children: fragments.map((f) => + "pill" in f + ? pillButton(f.pill) + : el("span", { className: "m01lab__qty-text", text: f.text }), + ), }); } diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css index 4d973fd1..4e2d49a3 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css @@ -39,13 +39,27 @@ margin: var(--spacing-8) 0 var(--spacing-4); } +/* 칸 폭 고정(`table-layout: fixed`) — 인력·재료·경비 세 표가 저마다 딴 `` 이라 + 내용 기준으로 폭이 갈리던 것을 막고 셋이 같은 폭으로 맞춰짐(일감35 이어 — 수량 칸 알약화). */ +.m01lab__ho { + table-layout: fixed; +} + +.m01lab__ho th:nth-child(1) { + width: 26%; +} + .m01lab__ho th:nth-child(2) { - width: 64px; + width: 8%; +} + +.m01lab__ho th:nth-child(3) { + width: 40%; } .m01lab__ho th:nth-child(4), .m01lab__ho th:nth-child(5) { - width: 110px; + width: 13%; } .m01lab__line { @@ -61,7 +75,6 @@ font-style: italic; } -.m01lab__qty, .m01lab__expr { font-family: var(--font-mono, monospace); font-size: var(--text-body-sm); @@ -69,6 +82,25 @@ word-break: break-all; } +/* 수량 칸 — 알약 + 사칙연산 글자를 이어 붙이고, 칸 폭을 넘으면 그 칸 폭 기준으로 줄바꿈 */ +.m01lab__qty-cell { + overflow-wrap: anywhere; +} + +.m01lab__qty-frags { + display: inline-flex; + flex-wrap: wrap; + align-items: baseline; + gap: 2px 4px; + min-width: 0; +} + +.m01lab__qty-text { + font-family: var(--font-mono, monospace); + font-size: var(--text-body-sm); + white-space: pre-wrap; +} + .m01lab__subtotal td { font-weight: 600; background: var(--color-paper); diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts index 9582a37b..471f8675 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts @@ -8,6 +8,7 @@ import { createButton, createSelectField, el, showToast } from "@ui/ui_template_ import { runCalc, runText, + searchElements, type CalcAnswer, type CalcLine, type TextAnswer, @@ -16,6 +17,28 @@ import { import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; import { tx } from "./M01_MasterData_UI_Logic_Text"; +/** 고르기 값이 마스터 키(자재·기계·직종)면 이름으로 바꿔 보임 — 값 칸은 키 그대로 보냄 */ +const KEY_RE = /^[A-Z]{2}\d{6}$/; +const KEY_GROUP: Record = { MT: "재료", EQ: "기계", LB: "인력" }; +/** 로직마다 다시 그려도 남는 이름 캐시 — 같은 키를 두 번 묻지 않음 */ +const nameCache = new Map(); + +async function keyName(key: string): Promise { + if (nameCache.has(key)) return nameCache.get(key)!; + const group = KEY_GROUP[key.slice(0, 2)]; + if (!group) return null; + let name: string | null = null; + try { + const found = await searchElements(group, key); // q = 키 그대로 — 「키」 칸 부분일치라 그 줄만 걸림 + const hit = found.items.find((it) => it.ref === key); + name = hit ? `${hit.이름 ?? ""}${hit.규격 ? ` ${hit.규격}` : ""}`.trim() || null : null; + } catch { + name = null; + } + nameCache.set(key, name); + return name; +} + export interface CalcContext { /** 저장된 키 — 새 로직은 null */ savedKey: string | null; @@ -33,17 +56,31 @@ export interface CalcContext { const SUMS = ["노무비", "재료비", "경비", "계"]; export function buildCalc(host: HTMLElement, ctx: CalcContext): void { + // 그릇 폭 280px 고정 칸은 옛 테스트 컨테이너(UI_Test*, 걷힘)의 죽은 CSS 규칙 — + // 지금 쓰는 LogicLab 화면은 그 칸에 안 걸려 폭이 이미 넓음(실측 730px) · 따로 안 풂 + const optionText = (o: string | number): string => + (typeof o === "string" && nameCache.get(o)) || String(o); const fields = (ctx.row.입력 ?? []).map((spec) => { const name = spec.이름; let control: HTMLElement; - if (spec.고르기?.length) { + const picks = spec.고르기; + if (picks?.length) { const pick = createSelectField({ - options: ["", ...spec.고르기.map(String)].map((o) => ({ value: o, text: o })), + options: ["", ...picks.map(String)].map((o) => ({ value: o, text: optionText(o) })), value: ctx.values[name] ?? "", compact: true, onChange: (v) => (ctx.values[name] = v), }); control = pick.root; + const keys = picks.filter((o): o is string => typeof o === "string" && KEY_RE.test(o)); + if (keys.length) { + void Promise.all(keys.map(keyName)).then(() => { + pick.setOptions( + ["", ...picks.map(String)].map((o) => ({ value: o, text: optionText(o) })), + ctx.values[name] ?? "", + ); + }); + } } else { const box = el("input", { className: "m01-logic__input", @@ -55,8 +92,10 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void { control = box; } const label = spec.단위 ? `${name} (${spec.단위})` : name; + // 긴 이름 줄만 한 칸 다 씀 — 고르기 줄은 짧으면 그대로 2열에 맞춰 나란히 + const wide = label.length > 10; return el("label", { - className: "m01-logic__field", + className: `m01-logic__field${wide ? " m01-logic__field--wide" : ""}`, children: [el("span", { text: label }), control], }); }); @@ -89,7 +128,7 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void { host.replaceChildren( el("h3", { text: tx("Calc_Title") }), ...(fields.length - ? fields + ? [el("div", { className: "m01-logic__head", children: fields })] // 요소 표가 쓰는 auto-fill 그리드 재사용 — 2열 : [el("p", { className: "m01-logic__muted", text: tx("Calc_NoInputs") })]), createButton({ label: tx("Calc_Run"), onClick: () => void run() }), out, diff --git a/resources/master_data/scripts/master_text.py b/resources/master_data/scripts/master_text.py index f234e6c2..8acea2af 100644 --- a/resources/master_data/scripts/master_text.py +++ b/resources/master_data/scripts/master_text.py @@ -7,6 +7,11 @@ · `이름글` 이름 꼴: 「석공노임 * 메쌓기인력.석공 * (1 + 증가율) = 석공비」 — 표 찾기·로직 부르기는 별칭 이름(`master_copy` 3-4 규칙) · 중간 값·입력은 그 이름 그대로 두 꼴은 같은 줄에 나란히 있고 `짝` 번호가 붙음 — 화면 두 칸이 줄 맞춤으로 놓임. +줄마다 `조각`(PLAN 2-1 조각 계약) 도 같이 줌 — kind(요소·표·로직·값·기호·글) · 글 · 참조. +알약(요소·표·로직)이 될 자리는 구조만 보고 짓고, `만약()` 갈래만 골라야 해서 계산이 필요함 — +계산이 안 되면(FormulaError 따위) 갈래 양쪽을 다 내보내고 깊이를 적어 접을 수 있게 함. +호표 줄 계산 자체가 막혀도(재료 단가 없음 따위) 이름 식·조각은 `row` 구조만으로 그리고 +`글`·`금액`만 비움 — `만약() 과 무관` 인 그 60개 로직도 이름 식은 보이게. 끝수는 줄이 아니라 소계에 녹여 적음. 계산 자체는 `master_formula.run` 이 한 벌만 함. """ @@ -24,6 +29,7 @@ ELEM_TAIL = {"LB": "노임", "EQ": "손료"} # 식 안 요소 키의 이름 꼬 _PREC = {"또는": 1, "그리고": 2, "==": 3, "!=": 3, "<": 3, "<=": 3, ">": 3, ">=": 3} _PREC |= {"+": 4, "-": 4, "*": 5, "/": 5} _RIGHT_TIGHT = ("-", "/") # 오른쪽 짝은 같은 세기라도 괄호가 필요함 +_CALC_ERRORS = (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) # 계산 못 함 def fmt(value) -> str: @@ -125,6 +131,133 @@ def _side(show, real, parent, env: dict, master, depth: int, draw, right: bool) return text +# ── 조각(알약) — PLAN 2-1 조각 계약 · 2-2 「이름 식을 계산 없이」 ────────── +_PIECE_SYM = {"+": "+", "-": "-", "*": "×", "/": "÷"} + + +def _p_val(text: str) -> dict: + return {"kind": "값", "글": text} + + +def _p_sym(text: str) -> dict: + return {"kind": "기호", "글": text} + + +def _alias_index(marks: list[dict]) -> dict[str, dict]: + """별칭 이름 → 별칭 한 줄(`master_copy.aliases` 결과) — 조각이 참조를 되찾을 때 씀.""" + return {m["이름"]: m for m in marks} + + +def _elem_piece(master, ref: str) -> dict: + """요소 알약 — 글은 이름(못 찾아도 이름만). 자리 표시(`{…}`) 든 참조 14파일은 + 값(참조 키)을 못 구하므로 이름만 적고 참조 자리는 비움.""" + piece = {"kind": "요소", "글": _elem_name(master, ref)} + if "{" in str(ref): + return piece + try: + row = master.get(ref) + except mf.FormulaError: + return piece + key = str(row.get("키") or ref) + piece["참조"] = {"테이블": key[:2], "키": key} + return piece + + +def _alias_piece(mark: dict, env: dict, master, column: str | None) -> dict: + """표 찾기·로직 부르기 알약 — 글은 별칭 이름. 참조는 다시 찾아 봐서 되면만 붙임.""" + piece = {"kind": "표" if mark["종류"] == "찾기" else "로직", "글": mark["이름"]} + try: + node = mf.parse(mark["원문"]) + ref = mf.fill(node[1], env) + if mark["종류"] == "찾기": + table = master.get(ref) + conds = {k: mf.evaluate(v, env, master, 0) for k, v in node[2]} + row = mf.find(table, conds, column) + piece["참조"] = { + "테이블": ref[:2], + "키": ref, + "행": row.get("키") or row.get("원문번호"), + "열": column, + } + else: + piece["참조"] = {"테이블": ref[:2], "키": ref} + except _CALC_ERRORS: + pass # 다시 못 찾음 — 별칭 이름만 남김(값 자리 비움) + return piece + + +def _if_pieces(show, real, env: dict, master, alias_by_name: dict, ifdepth: int) -> list[dict]: + """만약() 조각 — 계산되면 걸린 가지만 · 안 되면 양쪽 다(깊이를 적어 접을 수 있게).""" + try: + taken = mf.evaluate(real[2][0], env, master, 0) + except _CALC_ERRORS: + deeper = ifdepth + 1 + left = _pieces(show[2][1], real[2][1], env, master, alias_by_name, deeper) + right = _pieces(show[2][2], real[2][2], env, master, alias_by_name, deeper) + for one in (*left, *right): + one["깊이"] = deeper + return [*left, _p_sym("/"), *right] + at = 1 if taken else 2 + return _pieces(show[2][at], real[2][at], env, master, alias_by_name, ifdepth) + + +def _side_pieces( + show, real, parent_show, env: dict, master, alias_by_name: dict, ifdepth: int, right: bool +): + inner = _pieces(show, real, env, master, alias_by_name, ifdepth) + if show[0] != "bin": + return inner + mine, up = _PREC[show[1]], _PREC[parent_show[1]] + if mine < up or (mine == up and right and parent_show[1] in _RIGHT_TIGHT): + return [_p_sym("("), *inner, _p_sym(")")] + return inner + + +def _bin_pieces(show, real, env: dict, master, alias_by_name: dict, ifdepth: int) -> list[dict]: + op = show[1] + if op == "/" and show[3][0] == "num" and show[3][1] == 100: # 「… / 100」 은 퍼센트로 + left = _side_pieces(show[2], real[2], show, env, master, alias_by_name, ifdepth, False) + return [*left, {"kind": "글", "글": "%"}] + left = _side_pieces(show[2], real[2], show, env, master, alias_by_name, ifdepth, False) + right = _side_pieces(show[3], real[3], show, env, master, alias_by_name, ifdepth, True) + return [*left, _p_sym(_PIECE_SYM.get(op, op)), *right] + + +def _pieces(show, real, env: dict, master, alias_by_name: dict, ifdepth: int = 0) -> list[dict]: + """식 한 마디 → 조각 목록. 요소·표·로직 부르기는 알약(참조가 됨) · 나머지는 값·기호·글.""" + kind = show[0] + if kind == "num": + return [_p_val(fmt(show[1]))] + if kind == "str": + return [_p_val(f"'{show[1]}'")] + if kind == "neg": + return [_p_sym("-"), *_pieces(show[1], real[1], env, master, alias_by_name, ifdepth)] + if kind == "bin": + return _bin_pieces(show, real, env, master, alias_by_name, ifdepth) + if kind == "call" and show[1] == "만약": + return _if_pieces(show, real, env, master, alias_by_name, ifdepth) + if kind == "ref": + return [_elem_piece(master, show[1])] + if kind == "field" and show[1][0] == "name" and show[1][1] in alias_by_name: + return [_alias_piece(alias_by_name[show[1][1]], env, master, show[2])] + if kind == "name" and show[1] in alias_by_name: + return [_alias_piece(alias_by_name[show[1]], env, master, None)] + try: + return [_p_val(_value(real, env, master, 0))] + except _CALC_ERRORS: + return [_p_val("")] # 그 밖(값 계산 자체가 막힘) — 빈 값 조각으로 줄이 안 죽게 + + +def _formula_pieces(text, env: dict, master, name_of: dict, alias_by_name: dict) -> list[dict]: + """식 글 한 줄의 조각 — 별칭을 씌운 나무와 원래 나무를 나란히 읽음(`_names` 와 짝).""" + real = mf.parse(text) + try: + show = mf.parse(mc.mask(text, name_of)) + except mf.FormulaError: + show = real + return _pieces(show, real, env, master, alias_by_name) + + # ── 줄 세우기 ───────────────────────────────────────────────────────── def _env_of(master, row: dict, given: dict, depth: int = 0) -> dict: """입력 + 중간 — `run` 과 같은 차례로 다시 셈(덧줄 전 상태).""" @@ -197,56 +330,117 @@ def _names(text, env: dict, master, name_of: dict) -> str: def lines(files: dict, key: str, given: dict) -> dict: - """로직 한 줄의 읽는 식 — 비목 묶음마다 줄과 소계 · 돈 아닌 로직은 「결과」 묶음 하나.""" + """로직 한 줄의 읽는 식 — 비목 묶음마다 줄과 소계 · 돈 아닌 로직은 「결과」 묶음 하나. + 호표 줄 계산이 막히면(재료 단가 없음 따위) `row` 구조만으로 이름 식·조각을 짓고 + `글`·`금액`은 비움(입력 자체가 없는 경우는 지금처럼 그대로 막힘 — `_env_of` 에서 걸림).""" master = mf.Master(files) row = master.logic(key) - result = mf.run(master, key, given) env = _env_of(master, row, given) marks = mc.aliases(row, master) name_of = mc.names(marks) + alias_by_name = _alias_index(marks) + try: + result = mf.run(master, key, given) + except _CALC_ERRORS: + result = None + if "결과" in row: - text = f"{render(mf.parse(row['결과']['식']), env, master)} = {fmt(result['결과'])}" + 조각 = _formula_pieces(row["결과"]["식"], env, master, name_of, alias_by_name) got = mc.plain(row.get("이름")) or "결과" - one = { - "이름": row.get("이름") or "", - "글": text, - "이름글": f"{_names(row['결과']['식'], env, master, name_of)} = {got}", - "금액": result["결과"], - } - group = { - "비목": "결과", - "줄": [one], - "소계": result["결과"], - "끝수": _cut_note(row, "결과"), - } - return _paired({"ok": True, "groups": [group], "계": result["결과"], "별칭": marks}) - made: dict[str, list] = {name: [] for name in mf.COST_ITEMS} - shown = result["줄"] - env["줄"] = {r["이름"]: r["금액"] for r in shown if r["이름"]} - items = row.get("호표") or [] - for item, line in zip(items, shown): - for part, money in _shares(line): - split = part if len(line["비목"]) > 1 else None + 이름글 = f"{_names(row['결과']['식'], env, master, name_of)} = {got}" + if result is not None: one = { - "이름": _label(item, line), - "글": _line_text(item, line, env, master, split), - "이름글": _line_names(item, line, env, master, split, name_of), - "금액": money, + "이름": row.get("이름") or "", + "글": f"{render(mf.parse(row['결과']['식']), env, master)} = {fmt(result['결과'])}", + "이름글": 이름글, + "금액": result["결과"], + "조각": 조각, } - why = _why(item, line) - made[part].append({**one, "까닭": why} if why else one) - _extras(row, shown[len(items) :], made, env, master, name_of) + group = { + "비목": "결과", + "줄": [one], + "소계": result["결과"], + "끝수": _cut_note(row, "결과"), + } + 계 = result["결과"] + else: + one = { + "이름": row.get("이름") or "", + "글": "", + "이름글": 이름글, + "금액": None, + "조각": 조각, + } + group = {"비목": "결과", "줄": [one], "소계": None, "끝수": ""} + 계 = None + return _paired({"ok": True, "groups": [group], "계": 계, "별칭": marks}) + + made: dict[str, list] = {name: [] for name in mf.COST_ITEMS} + items = row.get("호표") or [] + if result is not None: + shown = result["줄"] + env["줄"] = {r["이름"]: r["금액"] for r in shown if r["이름"]} + for item, line in zip(items, shown): + 조각 = _formula_pieces(item["수량"], env, master, name_of, alias_by_name) + for part, money in _shares(line): + split = part if len(line["비목"]) > 1 else None + one = { + "이름": _label(item, line), + "글": _line_text(item, line, env, master, split), + "이름글": _line_names(item, line, env, master, split, name_of), + "금액": money, + "조각": 조각, + } + why = _why(item, line) + made[part].append({**one, "까닭": why} if why else one) + _extras(row, shown[len(items) :], made, env, master, name_of, alias_by_name) + 소계 = {name: result[name] for name in mf.COST_ITEMS} + 끝수 = _cut_note(row) + 계 = result["계"] + else: + for item in items: + parts = ( + list(mf.COST_ITEMS) + if item.get("종류") == "로직" and "비목" not in item + else [item.get("비목")] + ) + 조각 = _formula_pieces(item["수량"], env, master, name_of, alias_by_name) + for part in parts: + if part not in mf.COST_ITEMS: + continue + split = part if len(parts) > 1 else None + made[part].append( + { + "이름": _label(item, {}), + "글": "", + "이름글": _line_names(item, {}, env, master, split, name_of), + "금액": None, + "조각": 조각, + } + ) + for extra in row.get("덧줄") or []: + made[extra["비목"]].append( + { + "이름": extra["이름"], + "글": "", + "이름글": ( + f"{_names(extra['식'], env, master, name_of)} = " + f"{mc.plain(extra.get('이름')) or '덧줄'}" + ), + "금액": None, + "조각": _formula_pieces(extra["식"], env, master, name_of, alias_by_name), + } + ) + 소계 = dict.fromkeys(mf.COST_ITEMS, None) + 끝수 = "" + 계 = None + groups = [ - { - "비목": name, - "줄": made[name], - "소계": result[name], - "끝수": _cut_note(row), - } + {"비목": name, "줄": made[name], "소계": 소계[name], "끝수": 끝수} for name in mf.COST_ITEMS if made[name] ] - return _paired({"ok": True, "groups": groups, "계": result["계"], "별칭": marks}) + return _paired({"ok": True, "groups": groups, "계": 계, "별칭": marks}) def _paired(answer: dict) -> dict: @@ -259,7 +453,9 @@ def _paired(answer: dict) -> dict: return answer -def _extras(row: dict, shown: list, made: dict, env: dict, master, name_of: dict) -> None: +def _extras( + row: dict, shown: list, made: dict, env: dict, master, name_of: dict, alias_by_name: dict +) -> None: """덧줄 — 앞선 줄까지의 비목 합을 넣고 한 줄씩(식 안의 비목 이름은 그 수로 바뀜).""" sums = {name: sum((one["금액"] for one in made[name]), Decimal(0)) for name in mf.COST_ITEMS} for extra, line in zip(row.get("덧줄") or [], shown): @@ -269,6 +465,7 @@ def _extras(row: dict, shown: list, made: dict, env: dict, master, name_of: dict "글": _extra_text(extra, line, env, master), "이름글": _extra_names(extra, env, master, name_of), "금액": line["금액"], + "조각": _formula_pieces(extra["식"], env, master, name_of, alias_by_name), } made[extra["비목"]].append(one) sums[extra["비목"]] += line["금액"] diff --git a/resources/tester/test_m01_text.py b/resources/tester/test_m01_text.py index 4ff426c0..b83e6fb3 100644 --- a/resources/tester/test_m01_text.py +++ b/resources/tester/test_m01_text.py @@ -160,3 +160,83 @@ def test_이름_꼴_찾기_별칭이_표_열쇠_그대로면_밑이름으로_풀 assert "QC001882.요율 %" in extra["이름글"] assert "잔디깎기공구손료" not in extra["이름글"] assert any(one["이름"] == "QC001882" for one in got["별칭"]) + + +# ── PLAN 2-1 · 2-2 — 조각(알약) ───────────────────────────────────────── +def test_조각이_이름글_금액_까닭_곁에_칸으로_더해진다(client: TestClient) -> None: + """지금 주던 이름글·글·금액·까닭은 그대로 두고 조각 칸만 더함(계약 확인).""" + got, calc = _both(client, STACK) + mason = _all_lines(got)[0] + assert mason["이름"] == "석공" and mason["글"].startswith("268,908 * ") + assert Decimal(str(got["계"])) == Decimal(str(calc["sums"]["계"])) + assert isinstance(mason["조각"], list) and mason["조각"] + kinds = {p["kind"] for p in mason["조각"]} + assert kinds <= {"요소", "표", "로직", "값", "기호", "글"} + 표 = next(p for p in mason["조각"] if p["kind"] == "표") + assert ( + 표["참조"]["테이블"] == "QF" + and 표["참조"]["키"] == "QF000421" + and 표["참조"]["열"] == "석공" + ) + assert any(p["kind"] == "글" and p["글"] == "%" for p in mason["조각"]) + + +def test_조각_요소는_알약이고_참조에_테이블과_키가_있다() -> None: + files = { + "재료_test.json": { + "그룹": "재료", + "줄": [{"키": "MT000001", "이름": "테스트자재", "값": Decimal("100")}], + } + } + master = mt.mf.Master(files) + piece = mt._elem_piece(master, "MT000001") + assert piece == { + "kind": "요소", + "글": "테스트자재단가", + "참조": {"테이블": "MT", "키": "MT000001"}, + } + + +def test_조각_자리_표시_든_요소_참조는_이름만_있고_참조는_빔() -> None: + """PLAN 2-2 ② — 자리 표시(`{…}`) 든 요소 참조는 값(참조)을 못 구해 이름만.""" + master = mt.mf.Master({}) + piece = mt._elem_piece(master, "LB:{직종}") + assert piece["kind"] == "요소" and "참조" not in piece + assert piece["글"] # 이름 자리는 비지 않음(못 풀어도 원문 그대로) + + +def test_조각_만약은_계산되면_걸린_가지만() -> None: + node = mt.mf.parse("만약(1 > 0, 2, 3)") + pieces = mt._pieces(node, node, {}, mt.mf.Master({}), {}) + assert pieces == [{"kind": "값", "글": "2"}] + assert not any("깊이" in p for p in pieces) + + +def test_조각_만약은_계산_안_되면_양쪽_다_깊이가_붙는다() -> None: + """PLAN 2-2 ① — 계산 안 되면(모르는 이름) 양쪽 다 · 깊이를 적어 접을 수 있게.""" + node = mt.mf.parse("만약(모르는이름 > 0, 2, 3)") + pieces = mt._pieces(node, node, {}, mt.mf.Master({}), {}) + vals = [(p["kind"], p["글"], p.get("깊이")) for p in pieces] + assert vals == [("값", "2", 1), ("기호", "/", None), ("값", "3", 1)] + + +def test_조각_로직_부르는_줄_990개_세_비목에_다_걸치고_계산_전엔_금액이_빈다( + client: TestClient, +) -> None: + """PLAN 2-2 ③ — 계산이 막히면(재료 단가 없음 따위) 이름 식·조각은 구조로 남고 금액만 빔. + 비목이 안 정해진 로직 부르는 줄은 세 비목에 다 걸쳐 보여야 하나, 이 견본(STACK)은 + 호표 둘 다 비목이 「노무비」로 정해진 인력 줄이라 한 비목에만 걸림 — 그 좁은 경우 확인.""" + got = client.get("/api/m01/logic", params={"key": STACK}).json() + row = dict(got["logic"]) + row["호표"] = [dict(row["호표"][0], 요소="LB099999"), dict(row["호표"][1])] + body = {"key": STACK, "inputs": _inputs(STACK), "row": row, "file": got["file"]} + text = client.post("/api/m01/text", json=body) + assert text.status_code == 200, text.text + out = text.json() + assert out["ok"] is True and out["계"] is None + rows = _all_lines(out) + assert [one["이름"] for one in rows] == ["석공", "보통인부"] + for one in rows: + assert one["금액"] is None and one["글"] == "" and "까닭" not in one + assert one["조각"] # 수량 식은 깨진 요소와 무관해 구조로 그대로 남음 + assert [g["소계"] for g in out["groups"]] == [None]