diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts index 7a030dd2..5842ec45 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts @@ -106,6 +106,8 @@ interface Entry { /** 서버 요소조각의 첫 요소 — 자리 표시 든 줄이 입력으로 풀린 키 · 글 */ unitRef?: string; unitText?: string; + /** 자재 단위가 줄 단위와 성격이 다름 — 「줄 m · 자재 Ton」 */ + warn?: string; } /** 재료 고르기 줄 — `요소` 가 글 대신 {구분·상세구분·규격·대표} 객체 */ @@ -237,6 +239,7 @@ function entries(ctx: DetailContext): Entry[] { qtyFrag: [{ text: item.수량 }], price: price === undefined || price === null ? "" : formatPrice(price), amount: line ? line.금액 : null, + warn: line?.단위경고, extra: false, open: (self) => openMaterialModal({ @@ -338,6 +341,14 @@ function lineRow(e: Entry): HTMLElement { ? [el("div", { className: "m01-logic__tag", text: `${e.share} ${tl("Share")}` })] : []), ...(e.spec ? [el("div", { className: "m01-logic__muted", text: e.spec })] : []), + ...(e.warn + ? [ + el("div", { + className: "m01-logic__tag m01lab__warn", + text: `${tx("Calc_UnitWarn")} · ${e.warn}`, + }), + ] + : []), ], }), cellOf(e.unit), diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts index 76c267e9..1d197668 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts @@ -10,7 +10,6 @@ import { createButton, el } from "@ui/ui_template_elements"; import { - fetchMaterials, searchElements, type ElementBrief, type NamedFormula, @@ -46,7 +45,18 @@ export interface PickCond { export const condText = (cond: PickCond): string => ["구분", "상세구분", "이름", "규격", "대표", "지역", "계약종별"] .map((k) => String(cond[k] ?? "")) - .join("|"); + .join("|") + (cond["검색어"] ? `|${cond["검색어"]}` : ""); + +/** 재료 모달 검색 칸 글 — 검색어 첫 낱말의 괄호 앞(「합판(내수) 12t」 → 「합판」) */ +export const headWord = (text: unknown): string => + String(text ?? "") + .trim() + .split(/\s+/)[0] + .replace(/[((].*$/, ""); + +/** 고르기 줄 후보 찾기 글 — 검색어 → 이름 → 상세구분 → 구분 차례 첫 낱말 */ +export const seedOf = (cond: PickCond): string => + [cond["검색어"], cond.이름, cond.상세구분, cond.구분].map(headWord).find(Boolean) ?? ""; export interface ModalTarget { title: string; @@ -227,19 +237,12 @@ const muted = (text: string): HTMLElement => el("p", { className: "m01-logic__mu /** 재료 고르기 줄 후보 목록 — 잡힌 품목(`pickedRef`)을 노란 줄로 · 후보가 100건을 넘으면 앞 100건만 옴 */ function candidateView( cond: PickCond, - unit: string, pickedRef: string, onPicked: (item: ElementBrief | null) => void, ): HTMLElement { const box = el("div", { className: "m01lab__data", attrs: { "data-candidates": "1" } }); box.append(muted("…")); - void fetchMaterials({ - sub: String(cond["구분"] ?? ""), - detail: String(cond["상세구분"] ?? ""), - spec: String(cond["규격"] ?? ""), - region: "", - unit, - }) + void searchElements("재료", seedOf(cond)) .then((got) => { onPicked(got.items.find((it) => it.ref === pickedRef) ?? null); const rows = got.items.map((it) => @@ -301,12 +304,17 @@ export function openMaterialModal(target: ModalTarget): void { const element = target.element ?? ""; const base = element.split(".")[0]; // 고르기 줄 = 서버가 조건 글을 열쇠로 준 줄(대표가 안 정해졌으면 없음) + const priced = cond ? target.prices[condText(cond)] : undefined; const known = cond - ? target.prices[condText(cond)] + ? target.unitRef && priced?.ref !== target.unitRef + ? undefined // 서버가 잡은 품목이 다르면 그 품목으로 + : priced : element ? (target.prices[element] ?? target.prices[base]) : undefined; - const priceRef = cond ? pickedOf(cond, target.values, known?.ref ?? "") : element; + const priceRef = cond + ? target.unitRef || pickedOf(cond, target.values, known?.ref ?? "") + : element; const logicKey = /^로직\((\w+)/.exec(element)?.[1] ?? ""; const sum = el("p", { className: "m01lab__sum" }); const setSum = (price: ElementBrief | null | undefined): void => { @@ -366,7 +374,7 @@ export function openMaterialModal(target: ModalTarget): void { rate, ...(cond ? [ - candidateView(cond, target.unit ?? "", priceRef, (item) => { + candidateView(cond, priceRef, (item) => { if (known || !priceRef) return; const show = (brief: ElementBrief | null): void => { rate.replaceChildren( diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts index 809d64f6..c61432a3 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts @@ -90,6 +90,8 @@ export interface CalcLine { 출처?: string; /** 자재 단위가 줄 단위와 성격이 달라 환산 못 하고 셈 — 「줄 m · 자재 Ton」 */ 단위경고?: string; + /** 재료 줄이 잡은 품목 — 키 · 이름 · 규격 · 단위 · 값 칸 */ + 품목?: { 키: string; 이름?: string; 규격?: string; 단위?: string; 값칸?: string }; } export type CalcAnswer = | { diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts index 0f1fdd8f..a8e22451 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts @@ -21,6 +21,7 @@ import { machineControl, materialRows, pickBody, + showItems, } from "./M01_MasterData_UI_Logic_CalcPick"; import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; import { formatMoney } from "./M01_MasterData_UI_Logic_Money"; @@ -183,6 +184,7 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void { const answer = await runCalc(body); if (ctx.onText) ctx.onText(await runText(body)); ctx.onLines(answer.ok ? (answer.lines ?? null) : null); + if (answer.ok && answer.lines) showItems(host, ctx.values, answer.lines); out.replaceChildren(...answerView(answer, draft, (ctx.row.결과단위 ?? "").startsWith("원"))); } catch (error) { showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); diff --git a/M01_MasterData/M01_MasterData_UI_Logic_CalcPick.ts b/M01_MasterData/M01_MasterData_UI_Logic_CalcPick.ts index b24842fe..ebf5111e 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_CalcPick.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_CalcPick.ts @@ -6,10 +6,10 @@ import { createButton, el } from "@ui/ui_template_elements"; import { fetchRows, type Row } from "./M01_MasterData_Api_Fetch"; -import type { ElementBrief, LogicInput, LogicRow } from "./M01_MasterData_UI_Logic_Api"; +import type { CalcLine, ElementBrief, LogicInput, LogicRow } from "./M01_MasterData_UI_Logic_Api"; import { openPickTable } from "./M01_MasterData_UI_Logic_PickTable"; import { tx } from "./M01_MasterData_UI_Logic_Text"; -import { condText, type PickCond } from "./M01_MasterData_UI_LogicLab_Modal"; +import { condText, headWord, type PickCond } from "./M01_MasterData_UI_LogicLab_Modal"; interface Chosen { ref: string; @@ -33,7 +33,6 @@ export function pickBody(values: Record): { 고름?: Record `${row["이름"] ?? ""} ${row["규격"] ?? ""}`.trim(); -const firstWord = (text: string): string => text.trim().split(/\s+/)[0] ?? ""; const MATERIAL_KEYS = /^MT\d{6}$/; const MACHINE_CODE = /^\d{4}-\d{4}$/; @@ -73,17 +72,18 @@ export function materialRows( ? (item.요소 as unknown as PickCond) : null; const brief = (cond ? prices[condText(cond)] : prices[item.요소]) ?? null; - const label = item.이름 ?? (brief?.이름 as string | undefined) ?? ""; + const named = item.이름 && !/^[A-Z]{2}\d{6}/.test(item.이름) ? item.이름 : ""; + const label = named || (brief?.이름 as string | undefined) || ""; const held = mine.get(i); - const nowRef = held?.ref ?? brief?.ref; const nowName = held?.label ?? (brief ? `${brief.이름 ?? ""} ${brief.규격 ?? ""}`.trim() : "") ?? ""; const seeds = [cond?.["검색어"], cond?.이름, cond?.상세구분, cond?.구분, label] - .map((w) => firstWord(String(w ?? ""))) + .map(headWord) .filter((w, k, all) => w && all.indexOf(w) === k); const current = el("span", { className: "m01-logic__muted", text: `${nowName || tx("Mat_NoPrice")} `, + attrs: { "data-mat-idx": String(i), "data-ref": brief?.ref ?? "" }, }); const button = pickButton(tx("Pick_Item"), () => openPickTable({ @@ -91,7 +91,7 @@ export function materialRows( title: `${tx("Pick_MatTitle")} · ${label}`, seed: seeds[0] ?? "", alt: seeds.slice(1), - isCurrent: (r) => String(r["키"]) === nowRef, + isCurrent: (r) => String(r["키"]) === (mine.get(i)?.ref ?? current.dataset.ref), onPick: (r) => { mine.set(i, { ref: String(r["키"]), label: nameOf(r) }); current.textContent = `${nameOf(r)} `; @@ -103,6 +103,22 @@ export function materialRows( }); } +/** 계산 답의 `품목` 을 잡힌 품목 글로 — 고른 재료가 있는 줄은 그대로 */ +export function showItems( + host: HTMLElement, + values: Record, + lines: CalcLine[], +): void { + const mine = picksOf(values); + host.querySelectorAll("[data-mat-idx]").forEach((span) => { + const i = Number(span.dataset.matIdx); + const item = lines[i]?.품목; + if (!item || mine.has(i)) return; + span.dataset.ref = item.키; + span.textContent = `${`${item.이름 ?? ""} ${item.규격 ?? ""}`.trim() || item.키} `; + }); +} + /** 기계 입력 칸 — 표 모달로 고르면 입력값(원문 분류번호 · 키 목록이면 키) */ const machines = new Map(); async function machineRow(code: string): Promise { diff --git a/resources/master_data/scripts/master_text.py b/resources/master_data/scripts/master_text.py index 70d5a918..0d8f340e 100644 --- a/resources/master_data/scripts/master_text.py +++ b/resources/master_data/scripts/master_text.py @@ -21,6 +21,7 @@ from decimal import ROUND_HALF_UP, Decimal import master_copy as mc import master_formula as mf +import master_text_item as mti PLACES = Decimal("0.0001") # 화면 `formatNumber` 와 같음(소수 넷째 자리까지) PRICE_TAIL = {"인력": "노임", "기계": "손료"} # 단가 자리의 이름 꼬리(그 밖은 「단가」) @@ -545,9 +546,9 @@ def lines(files: dict, key: str, given: dict) -> dict: if result is not None: shown = result["줄"] env["줄"] = {r["이름"]: r["금액"] for r in shown if r["이름"]} - for item, line, label in zip(items, shown, labels): + for n, (item, line, label) in enumerate(zip(items, shown, labels)): 조각 = _formula_pieces(item["수량"], env, master, name_of, alias_by_name) - 요소 = _item_pieces(item, env, master) + 요소 = _item_pieces(item, env, master, line, n) for part, money in _shares(line): split = part if len(line["비목"]) > 1 else None one = { @@ -565,14 +566,14 @@ def lines(files: dict, key: str, given: dict) -> dict: 끝수 = _cut_note(row) 계 = result["계"] else: - for item, label in zip(items, labels): + for n, (item, label) in enumerate(zip(items, labels)): 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) - 요소 = _item_pieces(item, env, master) + 요소 = _item_pieces(item, env, master, None, n) for part in parts: if part not in mf.COST_ITEMS: continue @@ -628,13 +629,16 @@ def _arg_text(node, env: dict, master) -> str: return fmt(value) -def _item_pieces(item: dict, env: dict, master) -> list[dict]: +def _item_pieces( + item: dict, env: dict, master, line: dict | None = None, index: int = 0 +) -> list[dict]: """줄의 단가 자리(요소 · 로직 부르기) 조각 — PLAN 3-1 요소조각.""" ref = item.get("요소") if item.get("종류") != "로직": name = str(item.get("이름") or "") - if isinstance(ref, dict): # 재료 고르기 조건 — 품명만 - return [{"kind": "요소", "글": name}] + if isinstance(ref, dict): # 재료 고르기 조건 — 잡힌 품목 이름 · 참조(고름 · 검색어 · 대표) + found = mti.piece(item, line, index, env, master) + return [found or {"kind": "요소", "글": name}] piece = _elem_piece(master, ref, env) return [{**piece, "글": name or piece["글"]}] try: diff --git a/resources/master_data/scripts/master_text_item.py b/resources/master_data/scripts/master_text_item.py new file mode 100644 index 00000000..c58bc165 --- /dev/null +++ b/resources/master_data/scripts/master_text_item.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +"""재료 줄 요소조각 — 잡힌 자재품목 이름 · 참조(PLAN 8-5). `master_text._item_pieces` 가 부름.""" + +from __future__ import annotations + +import re + +import master_formula as mf +import master_pick as mp + +_ERRORS = (mf.FormulaError, KeyError, TypeError) + + +def picked(item: dict, line: dict | None, index: int, env: dict, master) -> dict | None: + """재료 줄이 잡은 자재품목 줄 — 계산한 줄의 출처(`MT000018.거래가격`) 먼저 · + 빈 줄이면 고름 · 검색어 · 대표로 다시 찾음. 못 찾으면 None.""" + got = str((line or {}).get("출처") or "")[:8] + try: + if got[:2] == "MT": + return master.get(got) + cond = {k: mf.fill(v, env) if isinstance(v, str) else v for k, v in item["요소"].items()} + key = (mp.CHOSEN.get() or {}).get(str(index)) + if not key and cond.get("검색어"): + key = mp.pick(master, cond, env, item.get("단위")) + key = key or cond.get("대표") + return master.get(str(key)) if key and str(key)[:2] == "MT" else None + except _ERRORS: + return None + + +def piece(item: dict, line: dict | None, index: int, env: dict, master) -> dict | None: + """요소조각 — 글 = 품목 이름(지역 포함) · 참조 = 테이블 MT · 그 키. 못 찾으면 None.""" + row = picked(item, line, index, env, master) + if row is None: + return None + key = str(row["키"]) + name = re.sub(r"\s+", " ", str(row.get("이름") or "")).strip() + return {"kind": "요소", "글": name, "참조": {"테이블": key[:2], "키": key}} diff --git a/resources/tester/test_m01_text.py b/resources/tester/test_m01_text.py index fea9c4d6..a7472a7e 100644 --- a/resources/tester/test_m01_text.py +++ b/resources/tester/test_m01_text.py @@ -520,3 +520,27 @@ def test_표에_없는_입력_조합은_그_줄만_비우고_글은_그대로() assert len(blank) < len(lines) or got["계"] is not None calc = store.calc("GC001000", {**store.auto("GC001000")["값"], "시공구분": "기계시공"}) assert got["계"] == calc["sums"]["계"] + + +def _mat_piece(lines: list[dict], name: str) -> dict: + return next(x for x in lines if x["이름"] == name)["요소조각"][0] + + +def test_재료_줄_요소조각은_잡힌_품목_이름과_MT_참조() -> None: + piece = _mat_piece(_text_of("GF000159"), "결속선 0.9㎜") + assert piece["글"].startswith("어닐링철선(결속선)") and piece["글"].endswith("서울") + assert piece["참조"] == {"테이블": "MT", "키": "MT000209"} + auto = {k: v for k, v in store.auto("GF000159")["값"].items() if v is not None} + assert store.text("GF000159", auto)["계"] == store.calc("GF000159", auto)["sums"]["계"] + + +def test_고름으로_바꾼_재료_줄_요소조각은_고른_품목() -> None: + import check_master as cm + import master_pick as mp + + auto = {k: v for k, v in store.auto("GF000159")["값"].items() if v is not None} + items = cm.master().logic("GF000159")["호표"] + at = next(i for i, h in enumerate(items) if h.get("이름") == "결속선 0.9㎜") + with mp.choosing({at: "MT000174"}): + got = _all_lines(store.text("GF000159", auto)) + assert _mat_piece(got, "결속선 0.9㎜")["참조"]["키"] == "MT000174"