diff --git a/B09_Estimation/B09_Estimation_Labor_Source.py b/B09_Estimation/B09_Estimation_Labor_Source.py index 5d0f43c11..621602eee 100644 --- a/B09_Estimation/B09_Estimation_Labor_Source.py +++ b/B09_Estimation/B09_Estimation_Labor_Source.py @@ -289,10 +289,13 @@ class Builder: if bid is None: why = "단가산출을 못 셈(바탕 없음 · 순환)" return {"kind": "blank", "name": name, "qty": each, "why": why} - from M02_MasterTemplete.M02_Sangeun_Calc import times_of + from M02_MasterTemplete import M02_Sangeun_Calc as sc block = self.blocks[bid] # 블록 = 굳힌 줄 결과단위(배) 몫 · M02 참조 줄 = 1 단위 - per = float(times_of(whole.logic(doc["바탕"]["로직"]))) + try: # 배 = 바탕 기준단위 → SK 단위(728-2) + per = float(sc.ratio_of(doc, whole.logic(doc["바탕"]["로직"]), whole)) + except sc.sd.DocError as e: + return {"kind": "blank", "name": name, "qty": each, "why": str(e)} return {"kind": "daega", "key": bid, "name": block["name"], "unit": block["unit"], "qty": None if each is None else each / per} # fmt: skip if refs.kind_of(ref.get("키")) == "promote": # 옛 단가승격 참조 = 일위대가화 = 대가 diff --git a/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts index ec5c7764a..a1f8ae0cc 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts @@ -10,6 +10,7 @@ * · 부르는 곳 = M02 단가산출 [항목 추가](`Sangeun_Pick`) · 일위대가 [로직 추가] * (`Labor_Pick` 이 `lib.sangeun` 이면 이 창) — B09 · 불러오기는 옛 창(`Labor_Pick`) * · (727-1) 기초 = 로직마다 한 줄(`logicHeads` · 옵션은 넣은 뒤 고름 · 옛 창도 같이) · 서버 목록 그대로 + * · (728-2) 돈 아닌 기초(결과단위가 「원」 아님 · ㎥/시간 등 21 개) = 목록에서 뺌 * ========================================================================== */ import { createDataTable } from "@ui/ui_template_data_table"; @@ -153,6 +154,7 @@ export function openFindPicker(opt: FindPickOptions): void { const hit = matchLogic(q, { ...x, 이름: `${x.이름} ${x.규격}` }); // 공용 · 208-1 if (!hit.ok) return []; const logic = logics.find((y) => y.키 === x.키); + if (logic && !logic.결과단위.trim().startsWith("원")) return []; // 돈 아닌 기초(작업량 · 728-2) 뺌 return [ { 품명: join(x.이름, aliasNote(hit.alias)), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts index 9117b578f..6262c473e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts @@ -20,6 +20,7 @@ import { createInputField } from "@ui/ui_template_input"; import { t as L } from "@ui/ui_template_locale"; import { createText } from "@ui/ui_template_text"; import { calcSheetRows } from "../A00_Common/calc_sheet"; +import { swapUnit } from "../A00_Common/master_unit_swap"; import { buildCalc } from "../M01_MasterData/M01_MasterData_UI_Logic_Calc"; import { fetchLogic, @@ -37,6 +38,7 @@ import { createViewPicker } from "../M01_MasterData/M01_MasterData_UI_LogicLab_V import type { Layer } from "./M02_MasterTemplete_Api_Fetch"; import { calcSangeun, removedStrip } from "./M02_MasterTemplete_Sangeun_Cover"; import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick"; +import { baseUnitOf, buildUnitSwap, type SwapHow } from "./M02_MasterTemplete_Sangeun_Unit"; import { baseCovers, dropBase, @@ -65,8 +67,6 @@ export interface SangeunDetailOptions { const LIB = { read: "/m02/logic-lib", calc: "/m02/logic-lib" }; const clone = (v: T): T => JSON.parse(JSON.stringify(v)) as T; -/** 결과단위 「원/10㎡」 → SK 단위 「㎡」(1 단위 값 · 배는 서버가 나눔) */ -const unitOf = (result: string): string => result.replace(/^원\s*\/\s*[\d.,]*\s*/, "").trim(); export function mountSangeunDetail( host: HTMLElement, @@ -105,6 +105,7 @@ export function mountSangeunDetail( onClick: () => openSangeunLinePicker(scope, (line) => { if ("종류" in line && line.종류 === "로직" && !doc.바탕) return setBase(line.로직); + if ("종류" in line && line.종류 === "로직") return void addBase(line); doc.더한줄 = [ ...(doc.더한줄 ?? []), line as unknown as NonNullable[number], @@ -113,6 +114,25 @@ export function mountSangeunDetail( }), }); addButton.hidden = !edit; + /** 둘째 기초(728-2) — 수량 처음값 = 바탕 기준단위 1 개 = 그 기초 기준단위 몇 개(같은 성격 · 다르면 1) */ + async function addBase(line: { 로직: string; 수량: number }): Promise { + let qty = 1; + const have = base ? baseUnitOf(base.logic.결과단위) : ""; + try { + const swap = have + ? swapUnit(have, baseUnitOf((await fetchLogic(line.로직)).logic.결과단위)) + : null; + if (swap?.배 != null && swap.계수 === null) qty = swap.배; + } catch { + /* 못 읽음 = 1 그대로 */ + } + if (!alive) return; + doc.더한줄 = [ + ...(doc.더한줄 ?? []), + { ...line, 수량: qty } as unknown as NonNullable[number], + ]; + touch(); + } /** 첫 기초 = 바탕(옛 [기본 로직 선택] 동작 · 단위는 `loadBase` 가 기초 결과단위로) */ function setBase(key: string): void { doc.바탕 = { 로직: key }; @@ -215,9 +235,23 @@ export function mountSangeunDetail( const baseName = base ? `${base.logic.키} ${base.logic.이름}` : null; const baseText = baseName ?? doc.바탕?.참조?.키 ?? L("M02_SangeunNoBase"); const revised = revisedBase(); + const swap = base + ? buildUnitSwap({ + result: base.logic.결과단위, + unit: doc.단위 ?? "", + how: doc.환산 as SwapHow | undefined, + edit, + onChange: (how) => { + if (how) doc.환산 = how; + else delete doc.환산; + touch(); + }, + }) + : null; head.replaceChildren( field(L("M02_Name"), "이름"), field(L("M02_SangeunUnit"), "단위"), + ...(swap ? [swap] : []), el("div", { className: "m02-sk__tags", attrs: { "data-sk-base": baseKey() ?? "" }, @@ -303,7 +337,7 @@ export function mountSangeunDetail( ? calcSheetRows({ 묶음: sheetGroups(rows), 계: ans.기준?.계 ?? null, - sumLabel: `${L("M02_SangeunTotal")} (${row.결과단위 || "원"})`, + sumLabel: `${L("M02_SangeunTotal")} (${doc.단위 && doc.단위 !== "식" ? `원/${doc.단위}` : row.결과단위 || "원"})`, bundle: true, inputs: row.입력, values, @@ -423,7 +457,7 @@ export function mountSangeunDetail( }); } - /** `fresh` = 막 넣은 바탕 — 단위가 비거나 「식」이면 기초 결과단위(앞 수 뗌 · 배는 서버가 나눔) */ + /** `fresh` = 막 넣은 바탕 — 단위가 비거나 「식」이면 기초 기준단위(「100본」 · 바꾸면 서버가 환산 · 728-2) */ async function loadBase(fresh = false): Promise { const key = baseKey(); base = null; @@ -437,7 +471,7 @@ export function mountSangeunDetail( } if (!alive) return; if (fresh && base && (!doc.단위 || doc.단위 === "식")) { - doc.단위 = unitOf(base.logic.결과단위) || doc.단위; + doc.단위 = baseUnitOf(base.logic.결과단위) || doc.단위; touch(false); } draw(); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Unit.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Unit.ts new file mode 100644 index 000000000..e726ffb48 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Unit.ts @@ -0,0 +1,103 @@ +/* ============================================================================= + * M02_MasterTemplete_Sangeun_Unit.ts + * 단가산출(SK) 단위 환산 칸(PLAN 728-2 · 요구서_내역19 3장 5) — 바탕 기준단위(「100본」) → SK 단위 근거 글 · + * 같은 성격 = 배 자동(글만) · 성격 다름 = 환산 계수 칸 · 부피 ↔ 무게 = 단위중량 표(CC000337) 고르기. + * 칸 보임 · 근거 글 = `swapUnit`(짝 `A00_Common/master_unit_swap.ts`) · 셈 = 서버(`M02_Sangeun_Calc.ratio_of`) + * 문서 칸 `환산` {계수 | 단위중량 {종별, 형상}}(꼴 = 서버 `M02_Sangeun_Doc._swap_how`) · 바꿈 = 캐시 → [저장]. + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { createInputField, createSelectField } from "@ui/ui_template_input"; +import { t as L } from "@ui/ui_template_locale"; +import { createText } from "@ui/ui_template_text"; +import { swapUnit } from "../A00_Common/master_unit_swap"; +import { loadTable } from "../M01_MasterData/M01_MasterData_UI_Logic_Note"; + +export interface SwapHow { + 계수?: number; + 단위중량?: { 종별: string; 형상: string }; +} + +const WEIGHT_TABLE = "CC000337"; + +/** 결과단위 「원/100본」 → 기준단위 「100본」(SK 단위 처음 값) */ +export const baseUnitOf = (result: string): string => result.replace(/^원\s*\/\s*/, "").trim(); + +/** 단위중량 표 줄 → ㎏/㎥(위 끝 · 서버 `master_unit.unit_weight` 기본과 같음) */ +const rhoOf = (row: Record | undefined): number | null => + row && Number.isFinite(Number(row.위)) ? Number(row.위) : null; + +/** 환산 칸 — 「식」 · 빈 단위 · 바탕 없음 = null(옛 셈) */ +export function buildUnitSwap(opt: { + result: string; + unit: string; + how: SwapHow | undefined; + edit: boolean; + onChange: (how: SwapHow | undefined) => void; +}): HTMLElement | null { + const have = baseUnitOf(opt.result); + if (!have || !opt.unit || opt.unit === "식") return null; + const how = opt.how ?? {}; + const probe = swapUnit(have, opt.unit, 1000, how.계수); + const root = el("div", { className: "m02-sk__tags", attrs: { "data-sk-swap": "" } }); + const note = createText("", "muted", 14); + const say = (rho: number | null): void => { + const got = swapUnit(have, opt.unit, rho, how.계수); + note.textContent = got.근거; + note.dataset.skSwapBlocked = got.배 === null ? "1" : ""; + }; + if (probe.계수 === "사용자") { + const [, ua] = probe.가; + const [, ub] = probe.나; + const box = createInputField({ + label: `${L("M02_SangeunSwapCoef")} — 1 ${ua} = ? ${ub}`, + type: "number", + value: how.계수 != null ? String(how.계수) : "", + min: 0, + compact: true, + size: 28, + }); + box.input.dataset.skSwapCoef = ""; + box.input.disabled = !opt.edit; + box.input.addEventListener("change", () => { + const v = Number(box.input.value); + opt.onChange(Number.isFinite(v) && v > 0 ? { 계수: v } : undefined); + }); + root.append(box.root); + } else if (probe.계수 === "단위중량") { + const pick = how.단위중량; + const value = pick ? `${pick.종별}|${pick.형상}` : ""; + const select = createSelectField({ + label: `${L("M02_SangeunSwapWeight")} (${WEIGHT_TABLE})`, + options: [{ value: "", text: L("M02_SangeunSwapPick") }], + value, + compact: true, + size: 28, + disabled: !opt.edit, + onChange: (v) => { + const [종별, 형상] = v.split("|"); + opt.onChange(v ? { 단위중량: { 종별, 형상 } } : undefined); + }, + }); + select.select.dataset.skSwapWeight = ""; + root.append(select.root); + void loadTable(WEIGHT_TABLE).then((table) => { + const rows = table?.줄 ?? []; + select.setOptions( + [ + { value: "", text: L("M02_SangeunSwapPick") }, + ...rows.map((r) => ({ + value: `${String(r.종별)}|${String(r.형상)}`, + text: `${String(r.종별)} · ${String(r.형상)} (${String(r.위)}㎏/㎥)`, + })), + ], + value, + ); + say(rhoOf(rows.find((r) => r.종별 === pick?.종별 && r.형상 === pick?.형상))); + }); + } + say(null); + if (!root.childElementCount && !note.textContent) return null; + root.append(note); + return root; +} diff --git a/M02_MasterTemplete/M02_Sangeun_Calc.py b/M02_MasterTemplete/M02_Sangeun_Calc.py index 4cc7e3534..d46b8f5c4 100644 --- a/M02_MasterTemplete/M02_Sangeun_Calc.py +++ b/M02_MasterTemplete/M02_Sangeun_Calc.py @@ -11,6 +11,10 @@ (B09 굳힘도 그 값) · 화면 셈 길(`calc_doc`) = 그 줄 덧칸 `펼침`(세부 줄 × 수량) · `뺀세부` · `바탕판` · `꼬리표`(원문번호) · 답 `꼬리표` = 바탕 원문번호. +SK 단위 환산(728-2) = 배 = 바탕 기준단위(「100본」) → SK 단위(`master_unit.swap` · +「식」 · 빈 단위 = 앞 수 뗀 바탕 단위 = 옛 셈) · +다른 성격 = 문서 `환산` {계수 | 단위중량 {종별, 형상}} · 없으면 셈 막음(까닭) · +화면 셈 길 줄 · 계 = SK 1 단위 몫(`per_unit`). """ from __future__ import annotations @@ -56,6 +60,39 @@ def times_of(row: dict) -> Decimal: return per[0][0] if len(per) == 1 else Decimal(1) +def base_unit(row: dict) -> tuple[str, str] | None: + """바탕 기준단위 — 「원/100본」 → (「100본」, 「본」) · 분모 없거나 여럿 = None.""" + from M01_MasterData import M01_MasterData_Store_Combo as combo_store + + per = combo_store.mu.per(row.get("결과단위")) + if len(per) != 1: + return None + n, u = per[0] + return (u if n == 1 else f"{format(n, 'f')}{u}", u) + + +def ratio_of(doc: dict, row: dict, whole: Any) -> Decimal: + """SK 배(728-2) — 바탕 기준단위 1 개 = SK 단위 몇 개 · 1 단위 = 기준 ÷ 배 · + 못 바꿈 = `DocError`.""" + from M01_MasterData import M01_MasterData_Store_Combo as combo_store + + mu = combo_store.mu + got = base_unit(row) + if got is None: + return Decimal(1) + want = str(doc.get("단위") or "").strip() + want = ( + got[1] if want in ("", "식") else want + ) # 일위대가 줄 · 「식」 = 옛 셈(앞 수 뗀 바탕 단위) + how = doc.get("환산") or {} + pick = how.get("단위중량") or {} + rho = mu.unit_weight(whole, pick.get("종별"), pick.get("형상")) if pick else None + swap = mu.swap(got[0], want, rho, how.get("계수")) + if swap["배"] is None: + raise sd.DocError(f"단가산출 단위 · {swap['근거']}") + return swap["배"] + + def fingerprint(row: dict) -> str: raw = json.dumps(row, ensure_ascii=False, sort_keys=True, default=str) return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16] @@ -113,7 +150,7 @@ def resolve( "짝잃음": base["짝잃음"] + got["짝잃음"], "입력값": {**base["입력값"], **(doc.get("입력값") or {})}, "켜쌓기율": rate, - "배": times_of(base["줄"]), + "배": ratio_of(doc, base["줄"], whole), "바탕판": fingerprint(base["줄"]), } @@ -336,7 +373,34 @@ def unit(doc: dict, whole: Any, expand: Any, trail: tuple) -> dict[str, Any]: def head_of(doc: dict) -> str: - return f"1 {doc.get('단위')} 당" + """머리 띠 — 「1 본 당」 · 앞 수 있는 단위 = 「100본 당」.""" + unit = str(doc.get("단위") or "") + return f"{unit} 당" if unit[:1].isdigit() else f"1 {unit} 당" + + +def per_unit(got: dict, doc: dict) -> dict: + """화면 셈 답 줄 · 계 = SK 1 단위 몫(728-2) — 수량 · 금액 · 비목 ÷배(율 · 단가 그대로) · + 「식」 · 빈 단위(일위대가 줄 모드) = 옛 바탕 몫 그대로.""" + times = got.get("배") or Decimal(1) + if times == 1 or str(doc.get("단위") or "").strip() in ("", "식"): + return got + + def cut(v: Any) -> Any: + return None if v is None else Decimal(str(v)) / times + + def one(x: dict) -> dict: + out = {**x, "금액": cut(x.get("금액"))} + if "율" not in x: + out["수량"] = cut(x.get("수량")) + if x.get("비목"): + out["비목"] = {k: cut(v) for k, v in x["비목"].items()} + if x.get("펼침"): + out["펼침"] = [one(y) for y in x["펼침"]] + return out + + lines = [one(x) for x in got.get("줄") or []] + goods = [one(x) for x in got.get("재료") or []] + return {**got, "줄": lines, "재료": goods, "기준": got.get("1단위"), "화면배": times} # ── 저장 ────────────────────────────────────────────────────────────── @@ -374,6 +438,7 @@ def clean_doc(doc: Any, here_: refs.Here) -> dict[str, Any]: if cid in was: one["원값"] = _was(was[cid]) out["바탕판"] = fingerprint(base["줄"]) + ratio_of(out, base["줄"], whole) # 단위 못 바꿈(계수 없음) = 저장 막음(728-2) _stamp_adds(out, base, whole) except sd.DocError as e: raise _fail(e) from e @@ -413,7 +478,7 @@ def calc_doc(doc: Any, given: dict | None = None) -> dict[str, Any]: got = run(clean, whole, given, trail=(me,), detail=True) except sd.DocError as e: raise _fail(e) from e - return labor._json({**got, "머리": head_of(clean)}) + return labor._json({**per_unit(got, clean), "머리": head_of(clean)}) def from_line(line: Any, name: str, unit: str) -> dict[str, Any]: diff --git a/M02_MasterTemplete/M02_Sangeun_Doc.py b/M02_MasterTemplete/M02_Sangeun_Doc.py index d8e47fb4e..477d07e35 100644 --- a/M02_MasterTemplete/M02_Sangeun_Doc.py +++ b/M02_MasterTemplete/M02_Sangeun_Doc.py @@ -10,7 +10,9 @@ 별도계상?(재료 · 없음 = 계 안)} | {종류: 로직, 로직, 입력값, 수량, 덮개?, 뺀줄?, 바탕판?} | {참조 | 고정, 이름?, 단위?, 수량}], - 뺀줄 [cid], 바탕판} + 뺀줄 [cid], 바탕판, 환산?} +SK 단위 환산(728-2) = `환산` {계수: 양수}(성격 다름 · 바탕 앞 수 뗀 1 단위 = SK 몇 단위) | +{단위중량: {종별, 형상}}(부피 ↔ 무게 · 표 CC000337) — 셈 = `M02_Sangeun_Calc.ratio_of`. 둘째 기초(727-2) = 더한줄 로직 — 수량 = 그 기초 기준단위 몇 개 · `덮개` {세부 cid: 덮개} · `뺀줄` [세부 cid](세부 cid = 그 기초 줄 `kind:키#n` · 화면 열쇠 `add:#k/세부 cid`) · `바탕판` = 저장 때 서버가 그 기초 지문. @@ -282,6 +284,8 @@ def clean(doc: Any, base_row: dict | None = None, whole: Any = None) -> dict[str out["입력값"], out["열린입력"] = given, opened if doc.get("켜쌓기율") is not None: out["켜쌓기율"] = plain(_num(doc["켜쌓기율"], "켜쌓기율")) + if doc.get("환산"): + out["환산"] = _swap_how(doc["환산"]) picks = {} for cid, mt in (doc.get("고름") or {}).items(): mt = str(mt or "") @@ -304,6 +308,22 @@ def clean(doc: Any, base_row: dict | None = None, whole: Any = None) -> dict[str return out +def _swap_how(raw: Any) -> dict[str, Any]: + """단위 환산 칸(728-2) — 계수(양수) · 단위중량 {종별, 형상} 중 있는 것만.""" + if not isinstance(raw, dict): + raise DocError("환산이 묶음 아님") + out: dict[str, Any] = {} + if raw.get("계수") is not None: + coef = _num(raw["계수"], "환산 계수") + if coef <= 0: + raise DocError("환산 계수는 0 보다 커야 함") + out["계수"] = plain(coef) + pick = raw.get("단위중량") + if isinstance(pick, dict) and pick.get("종별") and pick.get("형상"): + out["단위중량"] = {k: str(pick[k]) for k in ("종별", "형상")} + return out + + def _marks(raw: Any) -> dict[str, dict[str, str]]: from M02_MasterTemplete import M02_Labor_Doc as labor diff --git a/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts b/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts index 0734af3ad..9a871b294 100644 --- a/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts +++ b/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts @@ -9,6 +9,7 @@ * 일위대가 줄 → 이름 없는 SK 문서 = `lineDoc`(서버 `M02_Labor_Patch.as_doc` 꼴) * 둘째 기초(727-2) = 더한줄 로직 줄을 서버 `펼침`(세부 줄)으로 바꿔 놓음 · 열쇠 `add:#k/세부 cid` · * 고침 = 그 더한줄의 `덮개` · `뺀줄`(품 = 화면 품 ÷ 수량 · 1 기준단위 몫) · 꼬리표 = 기초 둘 이상일 때만 + * SK 단위 환산(728-2) = 서버가 화면 줄을 SK 1 단위 몫으로(`화면배`) — 문서 품 = 화면 품 × 화면배(율 빼고) * 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장). * ========================================================================== */ @@ -102,6 +103,9 @@ export interface SkAnswer { 머리?: string; /** 바탕 원문번호(참조 바탕 = 그 키) */ 꼬리표?: string | null; + /** 바탕 기준단위 → SK 단위 배(728-2) · 화면 줄을 나눈 배(없으면 1 · 문서 품 = 화면 품 × 화면배) */ + 배?: number; + 화면배?: number; } export interface CoverRow { @@ -132,6 +136,8 @@ export interface CoverRow { apartPick: boolean; /** 둘째 기초 세부 줄 — 더한줄 차례 · 세부 cid · 수량(배) · 바탕 줄 · 그 밖 = null */ sub: { k: number; cid: string; scale: number } | null; + /** 화면배(728-2) — 문서 품 = 화면 품 × t · `was` = 문서 몫 */ + t: number; } /* ── 순수 셈 ── */ @@ -206,6 +212,7 @@ const near = (a: number | null | undefined, b: number | null | undefined): boole /** 셈 답 → 표 줄(엔진 줄 + 기준 밖 재료) */ export function rowsOf(ans: SkAnswer, bare: SkAnswer | null, doc: SkDoc): CoverRow[] { const was = baseValues(bare); + const t = ans.화면배 ?? 1; const rows: CoverRow[] = []; const row = ( cid: string, @@ -227,7 +234,12 @@ export function rowsOf(ans: SkAnswer, bare: SkAnswer | null, doc: SkDoc): CoverR const cover = at ? owner?.덮개?.[at.cid] : added === null ? doc.덮개?.[cid] : undefined; const pct = (at?.cid ?? cid).startsWith("pct:"); const keys = Object.keys(parts); - const base = at ? subWas(extra.바탕값) : added === null ? (was.get(cid) ?? null) : null; + const shown = added === null && !at ? (was.get(cid) ?? null) : null; + const base = at + ? subWas(extra.바탕값) + : shown && !pct && shown.qty != null + ? { ...shown, qty: shown.qty * t } + : shown; const old = cover?.원값; return { cid, @@ -255,6 +267,7 @@ export function rowsOf(ans: SkAnswer, bare: SkAnswer | null, doc: SkDoc): CoverR priceEdit: !pct && (added === null || ["인력", "재료", "기계"].includes(String(one?.종류))), apartPick: one?.종류 === "재료", sub: at ? { ...at, scale: Number(owner?.수량 ?? 1) } : null, + t: pct ? 1 : t, }; }; for (const l of flatLines(ans)) rows.push(row(l.cid, l, partsOf(l), l)); @@ -301,14 +314,15 @@ export function setLineCover(doc: SkDoc, r: CoverRow, c: LineCover | null, opene if (r.added !== null) { const one = doc.더한줄?.[r.added]; if (!one) return; - if (c?.qty != null) one.수량 = c.qty; + if (c?.qty != null) one.수량 = c.qty * r.t; if (price == null) delete one.단가; else one.단가 = price; return; } const cover: SkCover = {}; // 세부 줄 품 = 화면 품 ÷ 더한줄 수량(1 기준단위 몫 · 수량 0 = 그대로) - const qty = r.sub && c?.qty != null && r.sub.scale ? c.qty / r.sub.scale : c?.qty; + const raw = c?.qty == null ? c?.qty : c.qty * r.t; + const qty = r.sub && raw != null && r.sub.scale ? raw / r.sub.scale : raw; const open = opened && !r.sub; // 열린입력 = 바탕 입력 — 세부 줄은 품 그대로 // ponytail: 열린입력 배율 = 고친 품 ÷ 바탕 품(바탕 품 0 · 없음 = 품 고침 못 함) if (qty != null && open && r.was?.qty) cover.배율 = qty / r.was.qty; @@ -445,6 +459,7 @@ export function hoView( }); const lineBy = new Map(flatLines(ans).map((l) => [l.cid, l])); const tags = tagsOf(ans, doc); + const t = ans.화면배 ?? 1; const single = (l?: SkLine): string | undefined => { const parts = Object.entries(l?.비목 ?? {}).filter(([, v]) => v); return parts.length === 1 ? parts[0][0] : undefined; @@ -479,7 +494,7 @@ export function hoView( 요소: typeof a?.요소 === "string" ? a.요소 : "", 이름: name, 단위: l?.단위 ?? String(a?.단위 ?? ""), - 수량: String(a?.수량 ?? l?.율 ?? l?.수량 ?? ""), + 수량: String(a ? a.수량 / t : (l?.율 ?? l?.수량 ?? "")), 비목: typeof a?.비목 === "string" ? a.비목 : single(l), }; ho.push([cid, { ...(a ? made : ((known.get(cid) as HoItem) ?? made)), ...tag }]); @@ -490,14 +505,15 @@ export function hoView( 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.단가 } : {}) }; + if (a) covers[i] = { qty: a.수량 / t, ...(a.단가 != null && part ? { [part]: a.단가 } : {}) }; const at = subOf(cid); const owner = at ? doc.더한줄?.[at.k] : undefined; const c = hit ? undefined : at ? owner?.덮개?.[at.cid] : doc.덮개?.[cid]; if (!c) return; const l = lineBy.get(cid); const out: LineCover = {}; - if (c.qty != null) out.qty = at ? c.qty * Number(owner?.수량 ?? 1) : c.qty; + const per = (at?.cid ?? cid).startsWith("pct:") ? 1 : t; // 율 = 그대로 + if (c.qty != null) out.qty = (at ? c.qty * Number(owner?.수량 ?? 1) : c.qty) / per; 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; diff --git a/resources/tester/helper_728_2_cover_unit.cjs b/resources/tester/helper_728_2_cover_unit.cjs new file mode 100644 index 000000000..934bede74 --- /dev/null +++ b/resources/tester/helper_728_2_cover_unit.cjs @@ -0,0 +1,44 @@ +/* PLAN 728-2 헬퍼 — SK 고침 꼴(`logic/M02_Sangeun_Cover_Model.ts`)이 화면배(SK 1 단위 몫)를 문서 몫으로 되돌리는지. + * 짝 = `test_728_2_sk_unit.py`. 결과 = 표준출력 JSON. + */ +const path = require("path"); +const { makeLoader } = require("./helper_ts_stub_loader.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +const { load } = makeLoader({ root: ROOT }); +const m = load("M02_MasterTemplete", "logic", "M02_Sangeun_Cover_Model.ts"); + +// GF000287 단목베기 SK 단위 본 — 서버 화면 답 = 100본 몫 ÷100(화면배 100) +const line = (cid, 이름, 수량, 금액) => ({ + cid, + 이름, + 단위: "인", + 수량, + 단가: 금액 / 수량, + 금액, + 비목: { 노무비: 금액 }, +}); +const ans = { + 기준: { 계: 1538.894 }, + 줄: [line("labor:LB000101#0", "벌목부", 0.002, 506.486), line("add:#0", "보통인부", 0.5, 86034)], + 정체: ["labor:LB000101#0", "add:#0"], + 짝잃음: [], + 바탕판: "", + 배: 100, + 화면배: 100, +}; +const bare = { ...ans, 줄: [ans.줄[0]], 정체: ["labor:LB000101#0"] }; +const doc = { + 단위: "본", + 바탕: { 로직: "GF000287" }, + 더한줄: [{ 종류: "인력", 요소: "LB000002", 수량: 50 }], +}; +const rows = m.rowsOf(ans, bare, doc); +const out = { was: rows[0].was, t: rows.map((r) => r.t) }; +m.setLineCover(doc, rows[0], { qty: 0.003 }, false); // 화면 0.003 = 문서 0.3(100본 몫) +m.setLineCover(doc, rows[1], { qty: 0.6 }, false); // 더한 인력 화면 0.6 = 문서 60 +out.cover = doc.덮개; +out.added = doc.더한줄[0].수량; +const view = m.hoView(ans, bare, null, doc); +out.shown = view.covers; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_715_sangeun_engine.py b/resources/tester/test_715_sangeun_engine.py index 4961a6020..f7b17cdd9 100644 --- a/resources/tester/test_715_sangeun_engine.py +++ b/resources/tester/test_715_sangeun_engine.py @@ -46,7 +46,7 @@ def test_price_cover_is_hand_price_and_pct_follows(): def test_bundle_cover_on_logic_line_partial(): bare = labor.run_line(_whole(), SUB, {}) sub = bare["줄"][2] - got = _run(바탕={"로직": SUB}, 덮개={"logic:GC000275#0": {"expense": 1000}}) + got = _run(바탕={"로직": SUB}, 단위="주", 덮개={"logic:GC000275#0": {"expense": 1000}}) line = got["줄"][2] qty = Decimal(str(sub["수량"])) assert line["비목"]["경비"] == pytest.approx(float(qty * 1000)) @@ -57,7 +57,7 @@ def test_bundle_cover_on_logic_line_partial(): def test_untouched_lines_same_as_engine(): bare = labor.run_line(_whole(), SUB, {}) - got = _run(바탕={"로직": SUB}) + got = _run(바탕={"로직": SUB}, 단위="주") # 바탕 단위(728-2 — 성격 다르면 계수 칸) assert got["기준"]["계"] == pytest.approx(float(bare["기준"]["계"])) diff --git a/resources/tester/test_727_sk_base_add.py b/resources/tester/test_727_sk_base_add.py index 5bc9d0f35..8b4fda491 100644 --- a/resources/tester/test_727_sk_base_add.py +++ b/resources/tester/test_727_sk_base_add.py @@ -189,7 +189,13 @@ def test_727_3_옵션_저장_일위대가_그_옵션(world): # noqa: F811 assert float(up["기준"]["계"]) == pytest.approx(_m01(GF, rock), rel=1e-9) assert _m01(GF, rock) > _m01(GF, {"지역": "전국평균", "보정작업": "해당 없음"}) # 둘째 기초 입력(바탕에 없는 이름)도 SK 입력값에 남고 그 옵션으로 셈 - two = _sk(world["personal"], 바탕={"로직": PCT}, 더한줄=[{**SECOND, "로직": GF}], 입력값=rock) + two = _sk( # 단위 = 바탕 단위(728-2 — 성격 다르면 계수 칸) + world["personal"], + 바탕={"로직": PCT}, + 단위="㎡", + 더한줄=[{**SECOND, "로직": GF}], + 입력값=rock, + ) assert two["입력값"] == rock got = _run(world["personal"], two) assert _line(got, "add:#0")["금액"] == pytest.approx(_m01(GF, rock), rel=1e-9) diff --git a/resources/tester/test_728_2_sk_unit.py b/resources/tester/test_728_2_sk_unit.py new file mode 100644 index 000000000..545e47808 --- /dev/null +++ b/resources/tester/test_728_2_sk_unit.py @@ -0,0 +1,124 @@ +"""728-2 지킴 — SK 단위 환산: 바탕 기준단위 → SK 단위 배 · 화면 줄 · 계 = SK 1 단위 몫 · +계수 없으면 셈 막음 · 「식」 = 옛 셈 · 둘째 기초 수량 처음값 · 돈 아닌 기초 고르기 창에서 뺌 · +고침 꼴 화면배 되돌림.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from decimal import Decimal +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "resources/master_data/scripts")) + +import master_unit as mu # noqa: E402 + +from M01_MasterData import M01_MasterData_Store as m01 # noqa: E402 +from M02_MasterTemplete import M02_Sangeun_Calc as sc # noqa: E402 +from M02_MasterTemplete import M02_Sangeun_Doc as sd # noqa: E402 + + +@pytest.fixture(scope="module") +def whole(): + return m01.loaded()[1] + + +def _doc(base, unit, **more): + return {"키": "SK-999999", "이름": "sub3임시", "단위": unit, "바탕": {"로직": base}, **more} + + +def _shown(whole, doc): + clean = sd.clean(doc, None, whole) + return sc.per_unit(sc.run(clean, whole), clean) + + +def test_단목베기_100본_제안_본으로_바꾸면_1본_계(whole): + full = _shown(whole, _doc("GF000287", "100본")) + assert full["배"] == 1 and round(full["기준"]["계"], 2) == Decimal("153889.40") + assert sc.head_of({"단위": "100본"}) == "100본 당" + one = _shown(whole, _doc("GF000287", "본")) + assert one["배"] == 100 and round(one["기준"]["계"], 2) == Decimal("1538.89") + assert sc.head_of({"단위": "본"}) == "1 본 당" + pairs = [(x["수량"], y["수량"]) for x, y in zip(full["줄"], one["줄"], strict=True)] + assert all(b == a / 100 for a, b in pairs) and one["화면배"] == 100 # 세부 품 ÷100 + + +def test_사석_수상고르기_10제곱미터_에서_1제곱미터(whole): + one = _shown(whole, _doc("GC000421", "㎡")) + assert round(one["기준"]["계"], 2) == Decimal("44791.32") + + +def test_성격_다르면_계수_칸_없으면_셈_막음(whole): + with pytest.raises(sd.DocError, match="환산 계수"): + _shown(whole, _doc("GF000099", "m")) + base = _shown(whole, _doc("GF000099", "㎥"))["기준"]["계"] + two = _shown(whole, _doc("GF000099", "m", 환산={"계수": 2})) + assert two["배"] == 2 and two["기준"]["계"] == base / 2 + with pytest.raises(sd.DocError, match="단위중량"): + _shown(whole, _doc("GF000099", "톤")) + rock = _shown( + whole, _doc("GF000099", "톤", 환산={"단위중량": {"종별": "암석", "형상": "화강암"}}) + ) + assert rock["배"] == Decimal("2.7") # 1 ㎥ = 2.7 톤(2,700 ㎏/㎥) + with pytest.raises(sd.DocError, match="0 보다"): + sd.clean(_doc("GF000099", "m", 환산={"계수": 0}), None, whole) + + +def test_식_빈_단위는_옛_셈(whole): + old = _shown(whole, _doc("GF000287", "식")) + assert old["배"] == 100 and round(old["1단위"]["계"], 2) == Decimal("1538.89") + assert "화면배" not in old and old["기준"]["계"] == Decimal("153889.4") # 화면 = 바탕 몫 그대로 + + +def test_둘째_기초_수량_처음값_배(whole): + """바탕 10㎡(GC000421) + 둘째 기초 100㎡(GF000041) — 처음값 0.1 = 바탕 기준단위 몫.""" + qty = mu.swap("10㎡", "100㎡")["배"] + assert qty == Decimal("0.1") + alone = sc.run(sd.clean(_doc("GF000041", "100㎡"), None, whole), whole)["1단위"]["계"] + one = {"종류": "로직", "로직": "GF000041", "입력값": {}, "수량": float(qty)} + got = sc.run(sd.clean(_doc("GC000421", "㎡", 더한줄=[one]), None, whole), whole) + bare = sc.run(sd.clean(_doc("GC000421", "㎡"), None, whole), whole) + added = got["1단위"]["계"] - bare["1단위"]["계"] + assert abs(added - alone * qty / 10) < Decimal("0.01") # ㎡ 몫 = 100㎡ 값 ÷100 + src = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts").read_text( + encoding="utf-8" + ) + assert "swapUnit(" in src and "addBase(" in src and "baseUnitOf(" in src + + +def test_돈_아닌_기초_고르기_창에서_뺌(whole): + money = [ + r + for tid, d in whole.index.items() + if tid in ("GC", "GF", "QC", "QF") + for r in d.values() + if isinstance(r, dict) + and "결과단위" in r + and not str(r["결과단위"]).strip().startswith("원") + ] + assert len(money) == 21 + src = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts").read_text(encoding="utf-8") + assert '!logic.결과단위.trim().startsWith("원")' in src + + +def test_고침_꼴_화면배_되돌림(): + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_728_2_cover_unit.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + out = json.loads(proc.stdout) + assert out["was"]["qty"] == pytest.approx(0.2) # 바탕 값 = 문서 몫(100본) + assert out["cover"]["labor:LB000101#0"]["qty"] == pytest.approx(0.3) + assert out["added"] == pytest.approx(60) + assert out["shown"]["0"]["qty"] == pytest.approx(0.003) # 다시 그리면 화면 몫 + assert out["shown"]["1"]["qty"] == pytest.approx(0.6) diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index a25a1bf7a..b8fb14c9c 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -203,6 +203,9 @@ export const ui_locales_m2 = { M02_SangeunRemoved: ["뺀 줄", "Removed rows"], M02_SangeunUnit: ["단위(1 단위 값)", "Unit (value per 1)"], M02_SangeunNeed: ["비우면 저장 안 됨", "Required"], + M02_SangeunSwapCoef: ["환산 계수", "Conversion factor"], + M02_SangeunSwapWeight: ["단위중량", "Unit weight"], + M02_SangeunSwapPick: ["고르세요", "Choose"], M02_SangeunAddLine: ["항목 추가", "Add item"], M02_SangeunDrop: ["빼기", "Remove"], M02_SangeunDropBase: ["기초 빼기", "Remove base"],