From 5d5f06eeedd041eb76d8a1f32a8dd863828dcbed Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 15 Nov 2026 15:08:46 +0900 Subject: [PATCH 1/8] =?UTF-8?q?chore(ledger):=20=EC=9A=94=EA=B5=AC=20865?= =?UTF-8?q?=20=EC=9E=A5=EB=B6=80=20=E2=80=94=20B09=20=EC=9B=90=EA=B0=80=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20=EB=B6=88=ED=95=84=EC=9A=94=20=C2=B7=20?= =?UTF-8?q?=EB=B3=84=EB=8F=84=20=EB=A1=9C=EC=A7=81=20=EC=9E=88=EC=9C=BC?= =?UTF-8?q?=EB=A9=B4=20=ED=9D=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- docs/raw/요구_1104/사용자요구_1104.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/raw/요구_1104/사용자요구_1104.md b/docs/raw/요구_1104/사용자요구_1104.md index b34185ef7..6e88c9751 100644 --- a/docs/raw/요구_1104/사용자요구_1104.md +++ b/docs/raw/요구_1104/사용자요구_1104.md @@ -1412,3 +1412,7 @@ GC000089의 단가품셈 기초의 항목을 내가 하나 삭제했음. 되돌 4. 우리 단가로가 기본이 될필요는 없음. 5. 문제없음. 너의 제안 선택 7. 경쟁사와 동일하게 반영 + +## 요구 865 (11-15 · 사용자 · B09 원가는 확인할 필요 없음 — M02 정보를 가져가 쓰기 때문 · B09 에 별도 로직이 있었다면 그것이 문제) + +b09원가에서는 확인할필요 없음. 어차피 m02의정보를 가져가 사용하기때문. 만약b09에 별도의로직이 있었다면 문제임. From 4f6ade2c2335a5b774af003f89a005405601cf9a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 15 Nov 2026 15:22:21 +0900 Subject: [PATCH 2/8] =?UTF-8?q?chore(ledger):=20=EC=9A=94=EA=B5=AC=20866?= =?UTF-8?q?=20=EC=9E=A5=EB=B6=80=20=E2=80=94=20B08=20=C2=B7=20B09=20?= =?UTF-8?q?=EA=B0=9C=EB=B3=84=20=EA=B3=84=EC=82=B0=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C(=ED=95=A9=EC=82=B0=EB=A7=8C)=20=C2=B7=20B09?= =?UTF-8?q?=20=EC=97=AD=ED=95=A0=20=EC=9B=90=EC=B9=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- docs/raw/요구_1104/사용자요구_1104.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/raw/요구_1104/사용자요구_1104.md b/docs/raw/요구_1104/사용자요구_1104.md index 6e88c9751..39e2b12ae 100644 --- a/docs/raw/요구_1104/사용자요구_1104.md +++ b/docs/raw/요구_1104/사용자요구_1104.md @@ -1416,3 +1416,7 @@ GC000089의 단가품셈 기초의 항목을 내가 하나 삭제했음. 되돌 ## 요구 865 (11-15 · 사용자 · B09 원가는 확인할 필요 없음 — M02 정보를 가져가 쓰기 때문 · B09 에 별도 로직이 있었다면 그것이 문제) b09원가에서는 확인할필요 없음. 어차피 m02의정보를 가져가 사용하기때문. 만약b09에 별도의로직이 있었다면 문제임. + +## 요구 866 (11-15 · 사용자 · B09 역할 = M02 에서 복사해 온 단가산출 · 일위대가 · 구조물 조합을 분류 · 합산해 보여 줌 · B08 · B09 에서 단가산출 · 일위대가를 개별로 계산하는 로직 삭제 · 합산 로직은 둠 · 기본값 설정은 사용자가 시점 잡음(잊음) · 코드 작업 진행) + +우리 프로그램은 m02의 단가산출, 일위대가, 구조물조합을 미리 만들어두고 프로젝트 내부에는 복사해서 가져다 씀 이때. 복사해간 데이터들을 분류하고 합산하여 사용자에게 보여주는게 m09의 역할임. 단가산출과 일위대가를 계산하는 로직은 일단 걷어내면 됨. 이후 아직 내가 설명하지 기본값 설정을 구현하고 들어갈 생각임. 아직 기본값 설정에 대해서는 내가 판단하여 시점을 잡을 생각이니 잊고 일단은 b08, b09에서 개별로 계산하는 로직은 삭제. 합산 로직은 그냥 둬둬됨. 코드 작업진행해. From f2d51252a09b55a3917e19e76bd78b00c930a570 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 15 Nov 2026 15:40:47 +0900 Subject: [PATCH 3/8] =?UTF-8?q?refactor(841-1):=20B09=20=EB=8C=80=EA=B0=80?= =?UTF-8?q?=20=C2=B7=20=EC=82=B0=EA=B7=BC=20=EB=8B=A8=EA=B0=80=20=EC=9B=90?= =?UTF-8?q?=EC=B2=9C=20=3D=20M02=20=EC=97=94=EC=A7=84=20=E2=80=94=20A=20?= =?UTF-8?q?=EA=B1=B8=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 산근 Solver 단가 = M02 엔진 줄 금액 ÷ 수량(labor_doc.run_line 날 값) · B09 단가 책 · 바탕 표 안 읽음 - 대가 블록 줄 노무 · 자재 · 기계 = 엔진 단가(own) · 자재 줄 = 엔진 기준단가 · 고정 줄 단가 책 덮어쓰기 걷음 - 대가 단가 고친 칸(daega_cover 노 · 재 · 경) 걷음 · 품(수량) 고친 칸 둠 - 기계 시간당 = M02 엔진 기계경비 값(운전사 계수 16/12 · 25/20 따로 곱 0) — 견본 노체포설 4,522 → 3,548 = M02 - 운반 단가 미리보기(logic-preview) = 엔진 단가 - 낡은 시험(B09 단가 책 입력 견본)은 C 걸음에서 정리 Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Calc_Labor.py | 49 ++++---- B09_Estimation/B09_Estimation_Calc_Labor.ts | 37 +++--- B09_Estimation/B09_Estimation_Labor_Source.py | 27 ++-- B09_Estimation/B09_Estimation_Router.py | 9 +- B09_Estimation/B09_Estimation_Sangeun.py | 116 ++++++++---------- 5 files changed, 115 insertions(+), 123 deletions(-) diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.py b/B09_Estimation/B09_Estimation_Calc_Labor.py index c445a5f6f..5858c2c6d 100644 --- a/B09_Estimation/B09_Estimation_Calc_Labor.py +++ b/B09_Estimation/B09_Estimation_Calc_Labor.py @@ -47,7 +47,7 @@ from B09_Estimation.B09_Estimation_Calc_Base import ( ) PARTS = ("labor", "material", "expense") -COVER_KEYS = ("qty", *PARTS) +COVER_KEYS = ("qty",) # 841-1 단가 고친 칸 걷음 — 품(수량)만 TAB = "dg" NO_LINES = "구성 줄 없음" # 줄 0 개 일위대가의 빈 줄 이름 COST = {"노무비": "labor", "재료비": "material", "경비": "expense"} @@ -55,9 +55,9 @@ WAYS = {"버림": rounddown, "올림": roundup, "반올림": round_half} COPY = "@copy" ADD = "@add" WHY = { - "labor": "노임 단가 없음", - "material": "자재 적용 단가 없음", - "machine": "중기 시간 단가 없음", + "labor": "노임 단가 없음(M02 엔진)", + "material": "자재 단가 없음(M02 엔진)", + "machine": "기계 시간당 없음(M02 엔진)", "daega": "부른 대가 합계 없음", "sangeun": "산근 단가 없음(145-1)", } @@ -96,16 +96,18 @@ def _prices(line: dict[str, Any], books: dict[str, Any], sums: dict) -> dict[str """줄 비목 단가 — 그 비목이 없는 줄은 0 · 원천이 없으면 None.""" kind, key = line["kind"], line.get("key") out: dict[str, float | None] = dict.fromkeys(PARTS, 0.0) - if kind == "labor": - out["labor"] = num((books["labor"].get(key) or {}).get("price")) - elif kind == "material": - row = books["material"].get(key) or {} - # 관급 · 별도계상 자재 = 대가 안 0원(자재대 · 관급 단락에서 셈 — 두 번 안 셈) - out["material"] = 0.0 if row.get("supply") in SUPPLY else num(row.get("price")) - elif kind in ("machine", "daega", "sangeun"): - row = {"machine": books["machine"], "daega": sums, "sangeun": books["sangeun"]}[kind].get( - key - ) or {} + if kind in ( + "labor", + "material", + "machine", + ): # 841-1 = M02 엔진 단가(`own`) · B09 단가 책 안 읽음 + own = line.get("own") + out = {p: num(own.get(p)) if p in own else 0.0 for p in PARTS} if own else _none() + row = (books["material"].get(key) or {}) if kind == "material" else {} + if row.get("supply") in SUPPLY: # 관급 · 별도계상 자재 = 대가 안 0원(자재대 · 관급 단락) + out["material"] = 0.0 + elif kind in ("daega", "sangeun"): + row = {"daega": sums, "sangeun": books["sangeun"]}[kind].get(key) or {} for part in PARTS: out[part] = None if row.get("blank") else num(row.get(part)) # 빈 줄 대가 = 빈칸 elif kind == "fixed": @@ -115,21 +117,18 @@ def _prices(line: dict[str, Any], books: dict[str, Any], sums: dict) -> dict[str return out +def _none() -> dict[str, float | None]: + return dict.fromkeys(PARTS, None) + + def _fixed(line: dict[str, Any], books: dict[str, Any]) -> dict[str, float | None]: - """고정 줄 단가(705-1) — 줄 단가 위에 이은 비목만 단가 책 값.""" + """고정 줄 단가(705-1) — 줄 단가 그대로 · 이은 자재가 관급 · 별도계상이면 재료 0(841-1 단가 책 + 값으로 다시 안 덮음).""" own, link = line.get("own") or {}, line.get("link") or {} out = {part: num(own.get(part)) or 0.0 for part in PARTS} - labor = books["labor"].get(link.get("labor")) if link.get("labor") else None - if labor and num(labor.get("price")) is not None: - out["labor"] = num(labor["price"]) row = books["material"].get(link.get("material")) if link.get("material") else None - if row and (row.get("supply") in SUPPLY or num(row.get("price")) is not None): - out["material"] = 0.0 if row.get("supply") in SUPPLY else num(row["price"]) - machine = books["machine"].get(link.get("machine")) if link.get("machine") else None - if machine and not machine.get("blank"): - for part in PARTS: - if num(machine.get(part)) is not None: - out[part] = num(machine[part]) + if row and row.get("supply") in SUPPLY: + out["material"] = 0.0 return out diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.ts b/B09_Estimation/B09_Estimation_Calc_Labor.ts index 9839ebf91..42202cd0a 100644 --- a/B09_Estimation/B09_Estimation_Calc_Labor.ts +++ b/B09_Estimation/B09_Estimation_Calc_Labor.ts @@ -39,16 +39,16 @@ import { } from "./B09_Estimation_Calc_Base"; const PARTS = ["labor", "material", "expense"] as const; -const COVER_KEYS = ["qty", ...PARTS] as const; +const COVER_KEYS = ["qty"] as const; // 841-1 단가 고친 칸 걷음 — 품(수량)만 const TAB = "dg"; const NO_LINES = "구성 줄 없음"; // 줄 0 개 일위대가의 빈 줄 이름(파이썬 짝) export const COPY = "@copy"; /** 붙인 줄 덮개 열쇠(705-2) */ export const ADD = "@add"; const WHY: Record = { - labor: "노임 단가 없음", - material: "자재 적용 단가 없음", - machine: "중기 시간 단가 없음", + labor: "노임 단가 없음(M02 엔진)", + material: "자재 단가 없음(M02 엔진)", + machine: "기계 시간당 없음(M02 엔진)", daega: "부른 대가 합계 없음", sangeun: "산근 단가 없음(145-1)", }; @@ -103,14 +103,17 @@ const blanks = (): Parts => ({ labor: null, material: null, expense: null }); function linePrices(line: Row, books: Row, sums: Record): Parts { const { kind, key } = line; - const out = zeros(); - if (kind === "labor") out.labor = num((books.labor[key] || {}).price); - else if (kind === "material") { - const row: Row = books.material[key] || {}; - // 관급 · 별도계상 자재 = 대가 안 0원(자재대 · 관급 단락에서 셈 — 두 번 안 셈) - out.material = (SUPPLY as readonly string[]).includes(row.supply) ? 0 : num(row.price); - } else if (kind === "machine" || kind === "daega" || kind === "sangeun") { - const src: Row = ({ machine: books.machine, daega: sums, sangeun: books.sangeun } as Row)[kind]; + let out = zeros(); + if (kind === "labor" || kind === "material" || kind === "machine") { + // 841-1 = M02 엔진 단가(`own`) · B09 단가 책 안 읽음 + const own: Row | undefined = line.own; + out = blanks(); + if (own) for (const part of PARTS) out[part] = part in own ? num(own[part]) : 0; + const row: Row = kind === "material" ? books.material[key] || {} : {}; + // 관급 · 별도계상 자재 = 대가 안 0원(자재대 · 관급 단락) + if ((SUPPLY as readonly string[]).includes(row.supply)) out.material = 0; + } else if (kind === "daega" || kind === "sangeun") { + const src: Row = ({ daega: sums, sangeun: books.sangeun } as Row)[kind]; const row: Row = src[key] || {}; for (const part of PARTS) out[part] = row.blank ? null : num(row[part]); // 빈 줄 대가 = 빈칸 } else if (kind === "fixed") return fixedPrices(line, books); @@ -118,20 +121,14 @@ function linePrices(line: Row, books: Row, sums: Record): Parts { return out; } -/** 고정 줄 단가(705-1) — 줄 단가 위에 이은 비목만 단가 책 값(짝 `_fixed`) */ +/** 고정 줄 단가(705-1) — 줄 단가 그대로 · 이은 자재가 관급 · 별도계상이면 재료 0(짝 `_fixed`) */ function fixedPrices(line: Row, books: Row): Parts { const own: Row = line.own || {}; const link: Row = line.link || {}; const out = zeros(); for (const part of PARTS) out[part] = num(own[part]) ?? 0; - const labor: Row | undefined = link.labor ? books.labor[link.labor] : undefined; - if (labor && num(labor.price) !== null) out.labor = num(labor.price); const row: Row | undefined = link.material ? books.material[link.material] : undefined; - const supplied = !!row && (SUPPLY as readonly string[]).includes(row.supply); - if (row && (supplied || num(row.price) !== null)) out.material = supplied ? 0 : num(row.price); - const machine: Row | undefined = link.machine ? books.machine[link.machine] : undefined; - if (machine && !machine.blank) - for (const part of PARTS) if (num(machine[part]) !== null) out[part] = num(machine[part]); + if (row && (SUPPLY as readonly string[]).includes(row.supply)) out.material = 0; return out; } diff --git a/B09_Estimation/B09_Estimation_Labor_Source.py b/B09_Estimation/B09_Estimation_Labor_Source.py index 5c8e8d096..b2bd13df0 100644 --- a/B09_Estimation/B09_Estimation_Labor_Source.py +++ b/B09_Estimation/B09_Estimation_Labor_Source.py @@ -243,18 +243,18 @@ class Builder: part = PART.get(str(add.get("비목")), "expense") return {**head, "kind": "pct", "part": part, "base": base, "spec": f"{label}의 %", "unit": "%", "qty": round(value / under * 100, 6)} # fmt: skip - kind = item.get("종류") + kind, own = item.get("종류"), _own(x, qty) if kind == "인력": key = goods.get("키") or str(item.get("요소") or "") - return {**head, "kind": "labor", "key": key, "qty": qty} + return {**head, "kind": "labor", "key": key, "qty": qty, "own": own} if kind == "재료": if not goods.get("키"): return {**head, "kind": "blank", "qty": qty, "why": x.get("까닭") or "품목 없음"} return {**head, "kind": "material", "key": goods["키"], "spec": goods.get("규격") or "", - "qty": qty} # fmt: skip + "qty": qty, "own": own} # fmt: skip # 로직 줄 — `Sangeun.call_of` 판정: 기계경비 로직 = 중기 · 그 밖 = 하위 산근 - if call and call.get("machine"): - return {**head, "kind": "machine", "key": call["machine"], "qty": qty} + if call and call.get("machine"): # 기계 시간당 = M02 엔진 값(841-1) + return {**head, "kind": "machine", "key": call["machine"], "qty": qty, "own": own} if call and call.get("code"): key = sg.sangeun_key(call["code"], call["given"]) return {**head, "kind": "sangeun", "key": key, "qty": qty} @@ -559,11 +559,24 @@ def _row_of(key: str, where: dict) -> str: return f"{where[line]}@{PART[cost]}" if cost else str(where[line]) +def _own(x: dict, qty: float | None) -> dict[str, float] | None: + """엔진 줄 비목 단가(841-1) = 엔진 금액 ÷ 엔진 수량 · 수량 0 = 0 · 못 셈 = None(빈칸).""" + money = x.get("비목") or {} + if not qty: + return dict.fromkeys(PART.values(), 0.0) if qty == 0 else None + return {PART[k]: (_f(v) or 0.0) / qty for k, v in money.items() if k in PART} + + def _material(whole: Any, mat: dict, qty: Any) -> dict[str, Any]: - """자재 줄 — 조합 사용자 자재 · 사전조합 자재(175-2) 한 꼴 · 이름 · 규격 · 단위 = 사본 단가.""" + """자재 줄 — 조합 사용자 자재 · 사전조합 자재(175-2) 한 꼴 · 이름 · 규격 · 단위 = 사본 단가 · + 단가 = M02 엔진 자재 기준단가(841-1 · 조합 셈 답 `기준단가` · 없으면 사본 단가 묶음으로).""" row = (whole.get(mat.get("코드")) if mat.get("코드") else None) or {} names = {k: row.get(v) or "" for k, v in (("name", "이름"), ("spec", "규격"), ("unit", "단위"))} - return {"kind": "material", "key": mat.get("코드"), **names, "qty": _f(qty)} + price = mat["기준단가"] if "기준단가" in mat else None + if price is None and mat.get("코드"): + price = labor_doc.material_price(whole, mat["코드"], mat.get("단위"))["기준단가"] + own = None if _f(price) is None else {"material": _f(price)} + return {"kind": "material", "key": mat.get("코드"), **names, "qty": _f(qty), "own": own} def _engine_bases(engine: list[dict], items: list[dict]) -> dict[str, float]: diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 340457c5f..1260ba3a3 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -160,7 +160,7 @@ def compute_sheets( 화면 짝 = `B09_Estimation_UI_Shell.computeSheets`(산근만 서버 답 그대로) · memo = 두 번 셈의 앞 산근 풀이(`Sangeun.collect`).""" base = compute_base(inputs, user) - sg = sangeun.build(root, base, user, memo) # 145-1 — 굳힌 단가로 풀이(+ 166-2 손 줄 로직) + sg = sangeun.build(root, user, memo) # 145-1 — M02 엔진 값으로 풀이(841-1 · + 166-2 손 줄 로직) sg = sg_cover.cover_sangeun(sg, user.get("sg_cover")) # 719-3 이 공사 산근 고친 칸 by_key = {row["key"]: row for row in sg["sg_list"]} labor = compute_labor( @@ -423,14 +423,11 @@ class PreviewBody(BaseModel): def logic_preview(root: Path, body: PreviewBody) -> dict[str, Any]: - """로직 한 산근 미리보기 — 단가 = 굳힌 책(없는 줄 = 지금 M01) · 사용자 대비표 고름.""" + """로직 한 산근 미리보기 — 단가 = M02 엔진(841-1 · 그 로직 닫힌 묶음 · B09 단가 책 안 읽음).""" src = {"code": body.code, "given": sangeun._clean_given(body.given), "rate": body.rate, "picks": body.picks} # fmt: skip hands = [("preview", "미리보기", src)] - keys = sangeun.hand_resource_keys({"haul_refs": {"preview": {"tab": "logic", **src}}}) - user = store.read_user(root) - base = compute_base(prices.build_inputs(store.read_book(root), keys), user) - solver = sangeun.Solver(base) + solver = sangeun.Solver() block = solver.add(sangeun.hand_whole(hands), src, None) text = [ *block["head"], diff --git a/B09_Estimation/B09_Estimation_Sangeun.py b/B09_Estimation/B09_Estimation_Sangeun.py index fa224c392..c18536c4f 100644 --- a/B09_Estimation/B09_Estimation_Sangeun.py +++ b/B09_Estimation/B09_Estimation_Sangeun.py @@ -62,6 +62,7 @@ import master_sheet_line as msl # noqa: E402 — 계산서 줄 덧칸(714-1) · PARTS = ("labor", "material", "expense") COST_PART = {"노무비": "labor", "재료비": "material", "경비": "expense"} +PART_COST = {v: k for k, v in COST_PART.items()} PCT_OF = {**COST_PART, "인건비": "people"} # % 덧줄 밑수 → 덮개 다시 셈(719-3) DEPTH = 4 # 하위 산근 깊이 상한 NO_SOURCE = "조합 밖 토공 · 운반 · 사토 · 준비공은 산근 없음" @@ -165,49 +166,15 @@ def rows_of(folder: Path, whole: Any = None) -> list[dict[str, Any]]: # ── 풀이 ────────────────────────────────────────────────────────────── -class _Seen(dict): - """읽은 키를 적는 단가 표 — 두 번 셈(183-1)에서 첫 풀이가 본 값만 견줌.""" - - def __init__(self, *args: Any) -> None: - super().__init__(*args) - self.seen: set[Any] = set() - - def __contains__(self, key: object) -> bool: - self.seen.add(key) - return super().__contains__(key) - - def __getitem__(self, key: Any) -> Any: - self.seen.add(key) - return super().__getitem__(key) - - def get(self, key: Any, default: Any = None) -> Any: - self.seen.add(key) - return super().get(key, default) - - def same(self, other: dict) -> bool: - """본 키마다 있음 · 값이 `other` 와 같음.""" - return all( - dict.__contains__(self, k) == dict.__contains__(other, k) - and dict.get(self, k) == dict.get(other, k) - for k in self.seen - ) - - class _Prices: - """B09 표 단가 — 노무 · 중기(노 · 재 · 경) · 자재 적용 단가(키 → 값).""" + """엔진 단가(841-1) — M02 엔진이 사본 단가 묶음으로 낸 줄 금액 ÷ 수량 · 텍스트 수식 글이 읽음 + (노무 · 자재 = 수 · 중기 = 노 · 재 · 경). B09 단가 책 · 바탕 표는 안 읽음.""" - def __init__(self, sheets: dict[str, Any]) -> None: - self.labor = _Seen({r["key"]: num(r.get("price")) for r in sheets.get("labor") or []}) - self.machine = _Seen({r["key"]: r for r in sheets.get("machine") or []}) - self.material = _Seen( - {r["key"]: num(r.get("applied")) for r in sheets.get("compare") or []} - ) - - def same(self, other: _Prices) -> bool: - """첫 풀이가 본 단가가 `other` 에서도 같음 — 같으면 풀이도 같음.""" - return all( - getattr(self, k).same(getattr(other, k)) for k in ("labor", "machine", "material") - ) + def __init__(self) -> None: + # ponytail: 키 하나 = 단가 하나(사본마다 기준 시점이 달라도 마지막 본 값) — 글 짓기 전용 + self.labor: dict[str, float | None] = {} + self.material: dict[str, float | None] = {} + self.machine: dict[str, dict[str, float | None]] = {} def _unit(row: dict) -> tuple[str, Decimal]: @@ -323,6 +290,10 @@ def spec_text(whole: Any, given: dict[str, Any]) -> tuple[str, str]: return ", ".join(f"{k}={shown(k, v)}" for k, v in given.items()), raw +def _float(value: Any) -> Any: + return float(value) if isinstance(value, Decimal) else value + + def _money(price: float | None, qty: float | None) -> float | None: """줄 금액 = ROUNDDOWN(단가 × 수량, 1)(엑셀 자리).""" return None if price is None or qty is None else rounddown(price * qty, 1) @@ -340,8 +311,9 @@ def block_totals(lines: list[dict[str, Any]]) -> dict[str, float | None]: class Solver: """한 번 셈 — 산근 블록을 첫 등장 차례로 모음(하위 산근 포함).""" - def __init__(self, sheets: dict[str, Any]) -> None: - self.prices = _Prices(sheets) + def __init__(self) -> None: + self.prices = _Prices() + self.eng: list[dict[str, Any]] = [] # 지금 푸는 로직의 엔진 줄(호표 차례 · 841-1) self.blocks: dict[str, dict[str, Any]] = {} def add( @@ -367,10 +339,13 @@ class Solver: unit, times = _unit(row) rate = Decimal(str(src["rate"])) / 100 if src.get("rate") not in (None, 100) else None lines: list[dict[str, Any]] = [] + # 841-1 단가 = M02 엔진 줄 금액 ÷ 수량(켜쌓기율 없이 · 날 값 · B09 단가 책 안 읽음) + eng = labor_doc.run_line(whole, code, src["given"], None, src.get("picks"), raw=True) try: env = _env(whole, row, src["given"]) with mf.mp.choosing(src.get("picks")): for n, item in enumerate(row.get("호표", [])): + self.eng = eng.get("줄") or [] # 하위 산근 풀이가 바꿔 놓아도 되돌림 lines.append(self._line(whole, item, n, env, times, rate, depth)) ss.places(row, lines) # 줄 자리(792-3) self._extras(whole, row, env, lines) @@ -434,6 +409,14 @@ class Solver: at += n return text[:start], text[-1] + def _each(self, n: int, part: str, q: float) -> float | None: + """엔진 줄 n 비목 단가 = 엔진 금액 ÷ 엔진 수량 · 안 쓰는 줄(수량 0) = 0 · 못 셈 = None.""" + x = self.eng[n] if n < len(self.eng) else {} + qty, money = num(_float(x.get("수량"))), (x.get("비목") or {}).get(PART_COST[part]) + if not qty: + return 0.0 if q == 0 else None + return None if money is None else float(money) / qty + def _blank(self, item: dict, why: str) -> dict[str, Any]: part = COST_PART.get(item.get("비목"), "material") return {"kind": "blank", "name": item.get("이름", ""), "spec": "", "qty": None, @@ -451,11 +434,12 @@ class Solver: zero = dict.fromkeys(PARTS, 0.0) kind = item.get("종류") if kind == "로직": - return self._logic_line(whole, item, env, q, base, zero, depth) + return self._logic_line(whole, item, n, env, q, base, zero, depth) if kind == "인력": key = mf.fill(item["요소"], env) if isinstance(item["요소"], str) else "" - price = self.prices.labor.get(key) - why = None if price is not None else f"노무비목록표에 없음 「{key}」" + price = self._each(n, "labor", q) + self.prices.labor[key] = price + why = None if price is not None else f"노임 단가 없음(M02 엔진) 「{key}」" return {**base, **zero, "kind": "labor", "ref": {"tab": "labor", "key": key}, "price": {"labor": price}, "labor": _money(price, q), "why": why} # fmt: skip ref = ( @@ -468,29 +452,33 @@ class Solver: key = str( goods.get("키") or (source or "").split(".")[0] or (ref if isinstance(ref, str) else "") ) - if key not in self.prices.material: # 자리표시 글(「MO:경유:전국평균」) = 대비표 키로 - try: - key = str((whole.get(key) or {}).get("키") or key) - except (mf.FormulaError, KeyError, TypeError, ValueError): - pass + try: # 자리표시 글(「MO:경유:전국평균」) = 그 줄 키로 + key = str((whole.get(key) or {}).get("키") or key) + except (mf.FormulaError, KeyError, TypeError, ValueError): + pass part = COST_PART.get(item.get("비목"), "material") - price = self.prices.material.get(key) - if q == 0: - price = price if price is not None else 0.0 # 안 쓰는 재료 줄 — 금액 0 - why = None if price is not None else f"자재단가대비표에 없음 「{key}」" + price = self._each(n, part, q) + self.prices.material[key] = price + why = None if price is not None else f"자재 단가 없음(M02 엔진) 「{key}」" spec = goods.get("규격") or "" ref = {"tab": "compare", "key": key} return {**base, **zero, "spec": spec, "kind": "material", "ref": ref, "price": {part: price}, part: _money(price, q), "why": why, "_goods": goods} # fmt: skip - def _logic_line(self, whole, item, env, q, base, zero, depth) -> dict[str, Any]: + def _logic_line(self, whole, item, n, env, q, base, zero, depth) -> dict[str, Any]: call = call_of(whole, item, env) if call and "machine" in call: keys = [call["machine"]] if call["machine"] else [] - m = self.prices.machine.get(keys[0]) if keys else None - price = {p: num((m or {}).get(p)) for p in PARTS} - why = None if m else f"중기목록표에 없음 「{keys[0] if keys else item.get('이름')}」" + price = {p: self._each(n, p, q) for p in PARTS} # 기계 시간당 = M02 엔진 값(841-1) + m = None not in price.values() + if keys: + self.prices.machine[keys[0]] = price + why = ( + None + if m + else f"기계 시간당 없음(M02 엔진) 「{keys[0] if keys else item.get('이름')}」" + ) amounts = {p: _money(price[p], q) if m else None for p in PARTS} ref = {"tab": "machine", "key": keys[0] if keys else ""} return {**base, "kind": "machine", "ref": ref, @@ -618,16 +606,15 @@ def hand_resource_keys(user: dict[str, Any] | None) -> list[str]: def collect( entries: list[tuple[str, Path]], - sheets: dict[str, Any], hands: list[tuple[str, str, dict[str, Any]]] | None = None, memo: dict[str, Any] | None = None, ) -> dict[str, Any]: """`entries` = [(구조물 id, 사본 폴더)] → 블록 · 목록 · 「연결 없음」 · `hands` = 손 줄 로직(166-2 · 사본 뒤 번호 · 마스터 로직) · memo = 같은 사본 · 손 줄의 앞 풀이(두 번 셈) — 앞 풀이가 본 단가가 같으면 그 풀이 사본.""" - solver = Solver(sheets) - if memo and memo["prices"].same(solver.prices): + if memo and "sg" in memo: # 841-1 풀이 = 사본 · 엔진만(바탕 표 안 읽음) → 앞 풀이 그대로 return copy.deepcopy(memo["sg"]) + solver = Solver() for owner, folder in entries: whole = item_prices.load_prices(read_json(folder / PRICE_FILE) or {}) for src in rows_of(folder, whole): @@ -652,13 +639,12 @@ def collect( "sg_hand": hand_keys, } if memo is not None: - memo.update(prices=solver.prices, sg=copy.deepcopy(out)) + memo.update(sg=copy.deepcopy(out)) return out def build( project_root: str | Path, - sheets: dict[str, Any], user: dict[str, Any] | None = None, memo: dict[str, Any] | None = None, ) -> dict[str, Any]: @@ -676,4 +662,4 @@ def build( entries += [ (f"서고 {(m.get('원본') or {}).get('name') or f.name}", f) for f, m in copies(project_root) ] - return collect(entries, sheets, hand_sources(user), memo) + return collect(entries, hand_sources(user), memo) From 39338d9e330b57a32f893f6f9a25b22ee40b4639 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 15 Nov 2026 15:45:07 +0900 Subject: [PATCH 4/8] =?UTF-8?q?refactor(840-1):=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=EC=97=90=EB=A7=8C=20=EC=9E=88=EB=8D=98=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EB=84=B7=20=ED=8C=90=EC=A0=95=20=C2=B7=20?= =?UTF-8?q?=EC=A3=BD=EC=9D=80=20=EC=BD=94=EB=93=9C=20=EA=B1=B7=EC=9D=8C=20?= =?UTF-8?q?=E2=80=94=20=EC=88=98=EC=8B=9D=20=EB=B3=B4=EA=B8=B0=EA=B0=80=20?= =?UTF-8?q?=EB=8C=80=EC=8B=A0=ED=95=98=EB=8A=94=20=EA=B2=83=20=EC=A7=80?= =?UTF-8?q?=EC=9B=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 판정 = 자체 로직 편집 · 시험 값 고침 · M02 줄 덮음 · 비목 박스 소계 모두 수식 보기로 됨(살릴 것 없음) - M01 상세: 숨은 단가산출 상세 덩이 · 자체 로직 시트 편집기 · 시험 고침 · 셈 구동(createDrive) · 보기 이름표(View.ts) · data-view CSS 걷음 - M02 불러오기 모달 머리 [항목 추가] · [율 줄 추가] 단추와 그 창(Rate.ts · openSangeunLinePicker) 걷음 - 글 정리 = Info_Own 새 뜻 · 죽은 글 키 · No_Key · 항목 추가 글 걷음 - 낡은 시험 고침 = 722(시험 고침 · 덮기 셈) · 799-2 · 804-2 · 804-6 · 804-7 · 806-1 · 717-2b · 717-4 · 721 · 728-2 — 걷은 것이 없음 확인으로 - 남김 = M02 수식 문서 없는 줄의 단가산출 상세 몸(cover · buildHo) Co-Authored-By: Claude Sonnet 5.5 --- A00_Common/formula_doc_text.ts | 4 - M01_MasterData/M01_MasterData_UI_LogicLab.ts | 80 +------------- .../M01_MasterData_UI_LogicLab_Cover.ts | 43 +------- .../M01_MasterData_UI_LogicLab_Detail.ts | 102 ++--------------- .../M01_MasterData_UI_LogicLab_Ho.ts | 9 +- .../M01_MasterData_UI_LogicLab_Style.css | 6 - .../M01_MasterData_UI_LogicLab_Text.ts | 15 +-- .../M01_MasterData_UI_LogicLab_View.ts | 15 --- .../M01_MasterData_UI_Logic_Drive.ts | 66 +---------- .../M02_MasterTemplete_Sangeun_Detail.ts | 78 +------------ .../M02_MasterTemplete_Sangeun_Pick.ts | 33 +----- .../M02_MasterTemplete_Sangeun_Rate.ts | 103 ------------------ .../M02_MasterTemplete_Sangeun_Tabs.ts | 1 - .../tester/test_717_2b_sangeun_detail.py | 6 +- resources/tester/test_717_4_sangeun_pick.py | 7 +- resources/tester/test_721_find_pick.py | 11 +- resources/tester/test_722_ho_detail.py | 37 ++----- resources/tester/test_728_2_sk_unit.py | 2 +- resources/tester/test_799_2_old_tabs_gone.py | 20 ++-- resources/tester/test_804_2_line_stack.py | 4 +- .../tester/test_804_6_formula_tab_gone.py | 5 +- resources/tester/test_804_7_line_stack_fix.py | 10 +- resources/tester/test_806_1_labor_view.py | 6 +- ui_template/ui_template_locale_m2.ts | 2 - 24 files changed, 72 insertions(+), 593 deletions(-) delete mode 100644 M01_MasterData/M01_MasterData_UI_LogicLab_View.ts delete mode 100644 M02_MasterTemplete/M02_MasterTemplete_Sangeun_Rate.ts diff --git a/A00_Common/formula_doc_text.ts b/A00_Common/formula_doc_text.ts index a20e06c60..2a4a8e06c 100644 --- a/A00_Common/formula_doc_text.ts +++ b/A00_Common/formula_doc_text.ts @@ -94,10 +94,6 @@ const TEXT = { "저장 안 한 고침이 있음 — [저장] 또는 [되돌림] 먼저", "Save or discard edits first", ], - No_Key: [ - "저장한 단가산출만 수식 문서를 엶 — [단가산출 상세] 탭", - "Saved items only — use the detail tab", - ], Read_Only: ["읽기만", "Read only"], Tools: ["수식 도구", "Equation tools"], Tool_Frac: ["분수", "Fraction"], diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index 2e60bc6e8..a406920eb 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -1,7 +1,6 @@ /* ============================================================================= * M01_MasterData_UI_LogicLab.ts - * 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 수식(줄 쌓기 `A00_Common/line_stack_screen.ts` · 804-6) · 단가산출 상세 / 오른쪽 원문 - * 셈 = 화면 칸 없이 구동(`_Logic_Drive.ts` · 799-2) — 열 때 첫 셈 · 입력 · 시험 고침 · 재료 고름이 `drive.run` + * 관리자 화면 — 로직 칸. 로직을 열면 두 칸 — 왼쪽 수식(줄 쌓기 `A00_Common/line_stack_screen.ts` · 804-6) / 오른쪽 원문 * 로직 목록(원문·장 고르기 · 찾기 · 막힘 거름)은 왼쪽 패널 「단가산출 로직」 컨테이너(`listHost`) 안 * * 데이터 흐름(CLAUDE.md 5장) — 고친 것은 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST /save`. @@ -29,18 +28,14 @@ import { fetchLogicUsers, removeLogic, saveFiles, - type CalcLine, - type TextAnswer, - type ElementBrief, type LogicRow, type LogicSummary, type SaveFile, } from "./M01_MasterData_UI_Logic_Api"; -import { createDrive } from "./M01_MasterData_UI_Logic_Drive"; import { buildList, logicId, type ListItem, type ListMark } from "./M01_MasterData_UI_Logic_List"; import type { SideHandle } from "./M01_MasterData_UI_Side"; import { tx } from "./M01_MasterData_UI_Logic_Text"; -import { buildLabDetail, isOwnLogic, trial } from "./M01_MasterData_UI_LogicLab_Detail"; +import { buildLabDetail } from "./M01_MasterData_UI_LogicLab_Detail"; import { setLogicOpener } from "./M01_MasterData_UI_LogicLab_Modal"; import { openCopyScreen } from "./M01_MasterData_UI_LogicLab_Copy"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; @@ -67,19 +62,11 @@ interface Opened extends Draft { id: string; row: LogicRow | null; original: string | null; - prices: Record; reasons: string[]; - lines: CalcLine[] | null; - text: TextAnswer | null; - /** 마지막으로 잘 받은 읽는 식 — 멈춘 뒤에도 수량 칸 조각을 이걸로 */ - lastText?: TextAnswer; ownHost: HTMLElement; - values: Record; } const OWN_FILE = "로직_자체.json"; -/** 시험 계산에 넘길 줄 — 시험 고침(722-2) 덮은 사본 */ -const calcRow = (row: LogicRow | null): LogicRow | null => trial.apply(row); const cacheKey = "m01_logic_drafts_lab"; const ownHost = (): HTMLElement => el("div", { className: "m01lab__own" }); @@ -105,12 +92,9 @@ export async function mountM01LogicLab( ): Promise { let drafts = loadDrafts(); let opened: Opened | null = null; - let calcInputs = ""; const admin = await fetchSessionUser() .then((u) => u?.role === "SYSTEM_ADMIN") .catch(() => false); - let recalc: number | undefined; - const drive = createDrive(); // 수식 = 줄 쌓기(804-2 · 804-6 옛 수식 문서 탭 걷음) — 수식 문서 · 캐시 m01_doc_drafts const stack = createStackScreen({ base: "/m01/logic/doc", @@ -122,7 +106,6 @@ export async function mountM01LogicLab( const editor = el("div", { className: "m01-logic__editor" }); // 로직 상세 — 왼쪽 지금 내용 · 오른쪽 접는 패널 「원문」 const panes = buildLogicSide(editor); - editor.dataset.view = "stack"; // 보기 = 수식 하나(836-3) — 「단가산출 상세」 덩이는 CSS 로 숨김 const errors = el("div", { className: "ui-banner ui-banner--danger m01-logic__reasons", attrs: { hidden: "" }, @@ -181,13 +164,6 @@ export async function mountM01LogicLab( if (opened.origKey !== null && now === opened.original) delete drafts[opened.id]; else drafts[opened.id] = pick(opened, clone(opened.row)); persist(); - const inputs = JSON.stringify(calcRow(opened.row)?.입력 ?? []); - if (inputs !== calcInputs) drawCalc(); - if (isOwnLogic(opened.row)) { - // 자체 로직 — 고친 뒤 잠시 쉬면 다시 계산해 호표 소계와 텍스트 수식을 새로 그림 - window.clearTimeout(recalc); - recalc = window.setTimeout(() => void drive.run(), 700); - } }; const pick = (o: Opened, row: LogicRow | null): Draft => ({ @@ -198,32 +174,6 @@ export async function mountM01LogicLab( row, }); - /** 구동을 지금 로직으로 (다시) 시작 — 읽는 식 미리보기 · 첫 셈 */ - const drawCalc = (): void => { - if (!opened?.row) { - drive.stop(); - return; - } - const current = opened; - calcInputs = JSON.stringify(calcRow(current.row)?.입력 ?? []); - drive.start({ - savedKey: current.origKey, - file: current.file, - row: calcRow(current.row) as LogicRow, - dirty: () => current.id in drafts || trial.has(current.row), - values: current.values, - onText: (text) => { - current.text = text; - if (text.ok) current.lastText = text; - drawEditor(); // [계산] 전 미리보기 응답도 왼쪽 이름 식 칸에 바로 비치게 — onLines 와 같은 자리 - }, - onLines: (lines) => { - current.lines = lines; - drawEditor(); - }, - }); - }; - const drawEditor = (): void => { const listing = !opened; // 고른 로직이 없으면 목록 · 있으면 호표 표 + 시험 계산 list.root.hidden = !listing || !loaded; @@ -242,37 +192,19 @@ export async function mountM01LogicLab( row: current.row as LogicRow, file: current.file, reasons: current.reasons, - prices: current.prices, - lines: current.lines, - text: current.text, - lastText: current.lastText, - values: current.values, ownHost: current.ownHost, onChange: touch, - onInput: (name, v) => syncLeft(current, { [name]: v }), - onTrial: drawCalc, - rerun: () => void drive.run(), admin, stack: stack.root, }); stack.draw(current.origKey ?? ""); }; - /** 단가산출 상세 옵션 입력 → 시험 입력(이름이 있는 칸만) · 바뀌면 다시 셈 */ - const syncLeft = (o: Opened, ex: Record): void => { - const names = new Set((calcRow(o.row)?.입력 ?? []).map((x) => x.이름)); - const changed = Object.entries(ex).filter(([k, v]) => names.has(k) && o.values[k] !== v); - if (!changed.length) return; - for (const [k, v] of changed) o.values[k] = v; - void drive.run(); - }; - const show = (next: Opened | null): void => { opened = next; panes.showSource(next?.row?.출처 ?? ""); errors.hidden = true; drawEditor(); - drawCalc(); }; const open = async (id: string, sub: string, key: string): Promise => { @@ -282,12 +214,8 @@ export async function mountM01LogicLab( ...draft, id, original: null, - prices: {}, reasons: [], - lines: null, - text: null, ownHost: ownHost(), - values: {}, }); return; } @@ -301,12 +229,8 @@ export async function mountM01LogicLab( origKey: key, row: draft ? (draft.row === null ? null : clone(draft.row)) : clone(one.logic), original: JSON.stringify(one.logic), - prices: one.prices, reasons: one.reasons, - lines: null, - text: null, ownHost: ownHost(), - values: {}, }); } catch (error) { showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Cover.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Cover.ts index deb5fec0e..5c0b98b30 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Cover.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Cover.ts @@ -2,9 +2,7 @@ * M01_MasterData_UI_LogicLab_Cover.ts * 단가산출 상세 줄 「그 자리 고침」(PLAN 722-2 · 내역 16 · 17-2) — 줄 모달 안 수량 · 단가 칸. * 고침 꼴 = M02 단가산출 덮개 칸과 같음(`qty` · `labor` · `material` · `expense` — `M02_Sangeun_Doc._covered` 규칙 중 품 · 단가) - * M01 = 시험 값만 — 고친 값은 화면 메모리(로직 줄 객체 열쇠)에만 · 셈 = 줄 사본에 덮어 서버로(`/calc` · `/text` row) - * · 정본 · 요소(노임 · 자재) · 캐시(초안) 안 바뀜 · 로직을 다시 열면 사라짐 - * M02(724) = 같은 고침 칸 · 같은 꼴을 고침 고리(`HoEdit.set`)로 받아 덮개로 보관 + * M02(724) = 고침 칸의 값을 고침 고리(`HoEdit.set`)로 받아 덮개로 보관 — M01 시험 고침은 840-1 에 걷음 * 서버 셈 = 줄 `단가`(손 단가) · 로직 줄 `값묶음`(노 · 재 · 경) · `수량` 글 — `master_pick._priced` · `bundled` 가 읽음 * ========================================================================== */ @@ -23,47 +21,8 @@ export interface LineCover { export const COVER_PARTS = { labor: "노무비", material: "재료비", expense: "경비" } as const; export type CoverPart = keyof typeof COVER_PARTS; -/** 지수 꼴 없는 수 글(엔진 식 칸) — 1e-7 → 0.0000001 */ -const plainNum = (v: number): string => - v.toLocaleString("en-US", { useGrouping: false, maximumFractionDigits: 20 }); - -/** 호표 줄(차례 열쇠)에 고침을 덮은 줄 사본 — 원본은 안 바뀜 · 덧줄은 고침 대상 밖 */ -export function applyCovers( - row: R, - covers: Record, -): R { - const ho = (row.호표 ?? []).map((raw, i) => { - const c = covers[i]; - if (!c) return raw; - const item = raw as Record; - const out: Record = { ...item }; - if (c.qty !== undefined) out.수량 = plainNum(c.qty); - const parts = (Object.keys(COVER_PARTS) as CoverPart[]).filter((k) => c[k] !== undefined); - if (parts.length && item.종류 === "로직") { - const given = parts.map((k) => [COVER_PARTS[k], c[k]]); - out.값묶음 = { ...((item.값묶음 as object) ?? {}), ...Object.fromEntries(given) }; - } else if (parts.length) out.단가 = c[parts[0]]; - return out; - }); - return { ...row, 호표: ho }; -} /* ── 순수 셈 끝 ── */ -/** 시험 고침 — 로직 줄 객체마다(다시 열면 새 객체 = 사라짐) */ -const trials = new WeakMap>(); -export const trial = { - of(row: object): Record { - let got = trials.get(row); - if (!got) trials.set(row, (got = {})); - return got; - }, - has: (row: object | null): boolean => !!row && Object.keys(trials.get(row) ?? {}).length > 0, - /** 시험 계산에 넘길 줄 — 고침 없으면 원본 그대로(같은 객체) */ - apply(row: R): R { - return row && trial.has(row) ? (applyCovers(row, trials.get(row)!) as R) : row; - }, -}; - const TEXT = { Head: ["그 자리 고침 (시험 값)", "Edit here (trial value)"], Note: [ diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts index f91ac4350..f711da5bb 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts @@ -1,41 +1,33 @@ /* ============================================================================= * M01_MasterData_UI_LogicLab_Detail.ts * 「단가산출 로직」 상세 화면 — 컨테이너를 위에서 아래로 - * ① 기본정보(정본은 읽기 전용 · 비고만 고침) ② 수식(줄 쌓기 804-2 · 804-6) ③ 단가산출 상세(공용 부품 `_LogicLab_Ho.ts` — 하위 박스 · 계산식 · 박스 옵션 · 그 자리 고침) - * 시트 · 인력/재료/경비 · 시험 계산 · 텍스트 수식 보기는 799-2 에 걷음 — 셈은 화면 칸 없이 `_Logic_Drive.ts` 구동 - * 그 자리 고침(722-2) = 시험 값만(`_LogicLab_Cover.ts` trial — 줄 사본으로 셈 · 정본 · 요소 · 캐시 안 바뀜) + * ① 기본정보(정본은 읽기 전용 · 비고만 고침 · 자체 로직은 이름 · 단위 · 출처도) ② 수식(줄 쌓기 804-2 · 804-6) + * ③ M02 수식 문서 없는 줄(일위대가 줄 모달 등)만 단가산출 상세 몸(`cover` — 공용 부품 `_LogicLab_Ho.ts`) + * 단가산출 상세 보기 · 자체 로직 시트 편집기 · 시험 고침은 840-1 에 걷음 — 자체 로직도 수식(줄 쌓기)에서 만들고 고침 * ========================================================================== */ import { el } from "@ui/ui_template_elements"; import type { LogicRow } from "./M01_MasterData_UI_Logic_Api"; -import { buildEditor } from "./M01_MasterData_UI_Logic_Edit"; -import { openPicker } from "./M01_MasterData_UI_Logic_Pick"; import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text"; -import { buildHo, lineOpener, type HoContext, type HoEdit } from "./M01_MasterData_UI_LogicLab_Ho"; -import { trial } from "./M01_MasterData_UI_LogicLab_Cover"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; import "./M01_MasterData_UI_LogicLab_Style.css"; import "./M01_MasterData_UI_LogicLab_Pill.css"; -import { block } from "./M01_MasterData_UI_LogicLab_View"; /** 원문 절 머리 개정 연도 글 — [2008, 2020, 2025] → 「'08 · '20 · '25」(없으면 빈 글) */ const revisionText = (row: LogicRow): string => (row.개정 ?? []).map((y) => `'${String(y).slice(-2)}`).join(" · "); -export { trial }; - -export interface DetailContext extends HoContext { +export interface DetailContext { + row: LogicRow; file: string; reasons: string[]; - /** 자체 로직 편집기 자리 — 다시 그려도 같은 칸을 씀(고치던 값이 안 날아감) */ + /** 기본정보 접힘 기억 자리 — 다시 그려도 같은 칸을 씀 */ ownHost: HTMLElement; onChange: () => void; /** 시스템 관리자 — 기본 로직 안내 글이 다름(시트에서 고침 / 보기만) */ admin?: boolean; - /** 722-2 시험 고침 뒤 — 구동을 고친 줄로 다시 시작(있으면 그 자리 고침 켜짐 · 셈은 `rerun`) */ - onTrial?: () => void; - /** 717-2b M02 단가산출 — 단가산출 상세 덩이를 이 칸으로(SK 셈 답 · 고침 표) */ - cover?: { ho: HTMLElement; open?: ReturnType }; + /** 717-2b M02 단가산출 — 수식 문서 없는 줄의 단가산출 상세 덩이(SK 셈 답 · 고침 표) */ + cover?: { ho: HTMLElement }; /** 수식 탭 = 줄 쌓기 그릇(`A00_Common/line_stack_screen.ts` · 804-2 · 804-6) */ stack?: HTMLElement; } @@ -148,88 +140,18 @@ function infoBox(ctx: DetailContext): HTMLElement[] { /** 자체 로직(키 GX…) — 정본은 아님 */ export const isOwnLogic = (row: LogicRow): boolean => row.키.startsWith("GX"); -/** 자체 로직은 정본 화면과 같은 편집기를 호표 아래에 폄(머리 칸은 기본정보에서 고치므로 숨김) */ -function ownEditor(ctx: DetailContext): HTMLElement { - if (!ctx.ownHost.childElementCount) - buildEditor(ctx.ownHost, { - row: ctx.row, - file: ctx.file, - isNew: false, - files: [], - reasons: [], - prices: ctx.prices, - lines: ctx.lines, - onChange: ctx.onChange, - onFile: () => undefined, - onPick: openPicker, - }); - return el("details", { - className: "m01lab__edit", - attrs: { open: "" }, - children: [ - el("summary", { text: tl("Edit_Title") }), - el("p", { className: "ui-text ui-text--muted", text: tl("Edit_SheetHint") }), - ctx.ownHost, - ], - }); -} - -/** M01 그 자리 고침 고리 — 시험 값(줄 객체 열쇠) · 고치면 시험 계산을 다시 그리고 셈 */ -function trialEdit(ctx: DetailContext): HoEdit | undefined { - const redraw = ctx.onTrial; - if (!redraw) return undefined; - const covers = trial.of(ctx.row); - return { - covers, - scope: "m01", - set: (idx, cover) => { - if (cover) covers[idx] = cover; - else delete covers[idx]; - redraw(); - ctx.rerun?.(); - }, - }; -} - -function hoBox(ctx: DetailContext): HTMLElement { - const money = !("결과" in ctx.row) && (ctx.row.결과단위 ?? "").startsWith("원"); - if (!money) { - return block( - section(tl("Ho_Title"), [ - el("p", { className: "ui-text ui-text--muted", text: tl("Not_Money") }), - el("pre", { className: "m01lab__expr", text: ctx.row.결과?.식 ?? "" }), - ]), - "ho", - ); - } - const body = buildHo({ ...ctx, edit: trialEdit(ctx) }); - const totalLine = body.pop()!; // 계 줄 — 자체 로직은 접힌 표 밖에 둠 - // 자체 로직 — 고치는 자리는 편집기 하나 · 읽기용 박스는 접어 둠(펼치면 소계가 보임) - const fold = el("details", { - className: "m01lab__fold", - attrs: ctx.ownHost.dataset.fold ? { open: "" } : {}, - children: [el("summary", { text: tl("Ho_Fold") }), ...body], - }); - fold.addEventListener("toggle", () => (ctx.ownHost.dataset.fold = fold.open ? "1" : "")); - const blocks = isOwnLogic(ctx.row) ? [ownEditor(ctx), fold, totalLine] : [...body, totalLine]; - const calculated = ctx.lines !== null; - const hint = calculated ? tl("Click_Hint") : `${tl("Click_Hint")} · ${tl("Calc_First")}`; - return block(section(tl("Ho_Title"), blocks, hint), "ho"); -} - -/** 상세 화면(기본정보 · 수식 · 단가산출 상세)을 `host` 에 쌓음 */ +/** 상세 화면(기본정보 · 수식 · M02 단가산출 상세 몸)을 `host` 에 쌓음 */ export function buildLabDetail(host: HTMLElement, ctx: DetailContext): void { - // 다시 그리면 포커스가 빠짐 — 고치던 칸(편집기 · 기본정보)을 기억해 되돌림 + // 다시 그리면 포커스가 빠짐 — 고치던 기본정보 칸을 기억해 되돌림 const active = document.activeElement; const kept = active instanceof HTMLInputElement || active instanceof HTMLTextAreaElement ? { node: active, from: active.selectionStart, to: active.selectionEnd } : null; - // 보기 고르기(`_View.ts`) — 덩이마다 이름표 · 고른 덩이만 보임 host.replaceChildren( ...infoBox(ctx), - ...(ctx.stack ? [block(ctx.stack, "stack")] : []), - ctx.cover ? block(section(tl("Ho_Title"), [ctx.cover.ho]), "ho") : hoBox(ctx), + ...(ctx.stack ? [ctx.stack] : []), + ...(ctx.cover ? [section(tl("Ho_Title"), [ctx.cover.ho])] : []), ); const own = kept?.node.dataset.own; // 기본정보 칸은 새로 만들어지므로 이름표로 다시 찾음 const back = own ? host.querySelector(`[data-own="${own}"]`) : kept?.node; diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts index babe52457..93f28b3c5 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts @@ -31,7 +31,6 @@ import { pctView } from "./M01_MasterData_UI_Logic_Api"; import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; import { formatLineMoney, formatMoney, formatPrice } from "./M01_MasterData_UI_Logic_Money"; import { tx } from "./M01_MasterData_UI_Logic_Text"; -import { pickMaterial } from "./M01_MasterData_UI_Logic_CalcPick"; import { coverForm, type CoverPart, type LineCover } from "./M01_MasterData_UI_LogicLab_Cover"; import { condText, @@ -144,8 +143,6 @@ export interface HoContext { values: Record; /** 박스 옵션 · 입력 칩 고침 — 시험 계산 입력 고침 + 다시 셈(없으면 고르기 · 칩 없음) */ onInput?: (name: string, value: string) => void; - /** 지금 값으로 다시 셈(구동) — 재료 후보 고르기가 씀(없으면 후보만 보임) */ - rerun?: () => void; /** 그 자리 고침 고리(없으면 모달은 보기만) */ edit?: HoEdit; /** (M02 750-2) Ho 줄 차례 → SK 줄 cid — 읽는 식 줄을 `cid` 로 이음(이름 차례 추측 안 함) */ @@ -279,11 +276,7 @@ function modalOf(e: Entry, ctx: HoContext, extra: { cond?: PickCond; item?: HoLi // 재료 줄 = 시험 계산 자재 칸과 같은 모달 — 고르면 시험 입력으로 다시 셈(정본 안 바뀜) kind: item?.종류, usedSlot: line?.품목?.값칸, - ...(item?.종류 === "재료" && ctx.pickMat - ? { onPick: ctx.pickMat(e.idx) } - : item?.종류 === "재료" && ctx.rerun - ? { onPick: (it) => pickMaterial(ctx.values, e.idx, it, ctx.rerun!) } - : {}), + ...(item?.종류 === "재료" && ctx.pickMat ? { onPick: ctx.pickMat(e.idx) } : {}), // 덧줄(%) = M02 덮개만 율 고침(M01 시험 값은 호표 줄만) ...(edit && (e.extra ? edit.scope === "m02" : item) ? { diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css index 8446e0132..5da731c80 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css @@ -177,12 +177,6 @@ font-weight: 600; } -/* 보기 고르기(710-2 · `_View.ts`) — 그릇 data-view 와 다른 이름표 덩이는 숨김 */ -.m01-logic__editor[data-view="stack"] [data-block]:not([data-block="stack"]), -.m01-logic__editor[data-view="ho"] [data-block]:not([data-block="ho"]) { - display: none; -} - /* 기본정보 — 늘 맨 위 접힌 한 줄 */ .m01lab__infofold > summary { display: flex; diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts index a5bc204c8..8498cea68 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts @@ -17,19 +17,11 @@ const TEXT = { "Base logic — view only · only the note can be edited", ], Info_Own: [ - "자체 로직 — 칸을 고치면 잠시 뒤 호표 소계가 다시 계산됨", - "Own logic — edits recalculate the subtotals shortly", + "자체 로직 — 이름 · 단위 · 출처 · 비고는 여기서 · 식은 수식에서 고침", + "Own logic — edit the info here · formulas in the Equation view", ], - Edit_Title: [ - "고치기 (호표 · 중간 값 · 덧줄 · 끝수)", - "Edit (lines · middles · extras · rounding)", - ], - Ho_Fold: ["묶음 소계 표 (읽기용 — 고치기는 위 편집기)", "Group subtotals (read-only)"], Ho_Title: ["단가산출 상세", "Unit-price detail"], - Calc_Title: ["시험 계산", "Trial calculation"], View_Equation: ["수식", "Equation"], - Edit_SheetHint: ["식은 「수식」 보기에서 고침", "Edit formulas in the Equation view"], - View_Label: ["보기", "View"], G_labor: ["인력", "Labor"], G_material: ["재료", "Material"], G_cost: ["경비", "Expense"], @@ -46,9 +38,6 @@ const TEXT = { Col_Price: ["단가", "Price"], Col_Amount: ["금액", "Amount"], Empty_Group: ["줄 없음", "No lines"], - Not_Money: ["호표가 없는 로직 — 결과 식", "No unit-cost lines — result formula"], - Calc_First: ["금액은 셈이 끝나면 채워짐", "Amounts fill when the calculation finishes"], - Click_Hint: ["줄을 누르면 그 줄이 쓰는 자료가 뜸", "Click a line to see the data it uses"], Modal_Close: ["닫기", "Close"], Pill_Tone: ["요소 연결", "Linked element"], Modal_Sum: ["한 줄 풀이", "In one line"], diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_View.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_View.ts deleted file mode 100644 index 9dabc3291..000000000 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_View.ts +++ /dev/null @@ -1,15 +0,0 @@ -/* ============================================================================= - * M01_MasterData_UI_LogicLab_View.ts - * 「단가산출 로직」 상세 보기 이름표 — 보기 = 수식(줄 쌓기 `A00_Common/line_stack_screen.ts`) 하나 · 고르기 단추 없음(836-3) - * 그릇 `data-view="stack"` 고정 — `data-block="ho"` 덩이(단가산출 상세 표)는 CSS 로 숨김 - * 덩이 = `data-block` 이름표 · 숨김 = 그릇 `data-view` + CSS(`LogicLab_Style.css`) — 다시 그려도 고름 유지 - * 셈 구동은 화면 칸 없이 `_Logic_Drive.ts` · 기본정보는 이름표 없음(늘 맨 위) - * ========================================================================== */ - -export type LabView = "stack" | "ho"; - -/** 덩이에 보기 이름표를 붙여 돌려줌 */ -export const block = (node: T, view: LabView): T => { - node.dataset.block = view; - return node; -}; diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Drive.ts b/M01_MasterData/M01_MasterData_UI_Logic_Drive.ts index de932e8bb..80d7d5255 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Drive.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Drive.ts @@ -1,12 +1,10 @@ /* ============================================================================= * M01_MasterData_UI_Logic_Drive.ts - * 셈 구동(PLAN 799-2) — 화면 없이 「입력값 → 서버 셈 → 답」을 돌림. 시험 계산 칸(`Logic_Calc`)과 로직 상세(`LogicLab`)가 같이 씀. - * `driveRun` = 한 번 셈(`/calc` + `/text`) · `previewText` = 열 때 읽는 식 미리보기 · `autoFill` = 자동값 채움 - * `createDrive` = 로직 상세용 묶음 — 열 때 미리보기 + 첫 셈(입력 없으면 바로 · 있으면 자동값 채운 뒤) · `run` 은 칸 없이 다시 셈 + * 셈 구동(PLAN 799-2) — 화면 없이 「입력값 → 서버 셈 → 답」을 돌림. 시험 계산 칸(`Logic_Calc`)이 씀 + * `driveRun` = 한 번 셈(`/calc` + `/text`) · `autoFill` = 자동값 채움 — 로직 상세 구동은 840-1 에 걷음 * 셈 자리 = 서버(CLAUDE.md 5 장 ③) · 화면 · sessionStorage 없음 · 고른 재료(`CalcPick`) 는 값 객체에 딸림 * ========================================================================== */ -import { showToast } from "@ui/ui_template_elements"; import { fetchAuto, runCalc, @@ -16,8 +14,7 @@ import { type LogicRow, type TextAnswer, } from "./M01_MasterData_UI_Logic_Api"; -import { isMaterialKeyInput, pickBody } from "./M01_MasterData_UI_Logic_CalcPick"; -import { tx } from "./M01_MasterData_UI_Logic_Text"; +import { pickBody } from "./M01_MasterData_UI_Logic_CalcPick"; export interface DriveContext { /** 저장된 키 — 새 로직은 null */ @@ -67,17 +64,6 @@ export async function driveRun( return { answer, draft }; } -/** 열 때 읽는 식 미리보기 — [계산] 전에도 이름 식 칸이 비치게 · 늦게 온 응답은 `live()` 로 거름 */ -export function previewText(ctx: DriveContext, live: () => boolean): void { - if (!ctx.onText) return; - const body = { key: ctx.savedKey ?? ctx.row.키, inputs: {}, ...draftBody(ctx) }; - void runText(body).then((answer) => { - if (!live()) return; - (answer as TextAnswer & { 계산됨?: boolean }).계산됨 = false; - ctx.onText?.(answer); - }); -} - /** 자동값으로 빈 칸 채움 — 채웠으면 true */ export async function autoFill(ctx: DriveContext): Promise { if (ctx.savedKey === null) return false; @@ -88,49 +74,3 @@ export async function autoFill(ctx: DriveContext): Promise { for (const n of names) ctx.values[n] = String(auto[n]); return names.length > 0; } - -/** 로직마다 첫 셈을 한 번만(다시 그려도 안 부름) */ -const first = new WeakSet>(); - -/** 로직 상세용 구동 — `start` = 로직을 열거나 입력 칸이 바뀔 때 · `run` = 지금 값으로 다시 셈 */ -export function createDrive(): { - start: (ctx: DriveContext) => void; - run: () => Promise; - stop: () => void; -} { - let seq = 0; // 로직을 바꾸면 늦게 온 응답을 거름 - let now: DriveContext | null = null; - let ran = false; // 셈 답이 이미 왔으면 뒤늦은 미리보기로 안 덮음 - const run = async (): Promise => { - const ctx = now; - if (!ctx) return; - ran = true; - try { - await driveRun(ctx); - } catch (error) { - showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error"); - } - }; - const start = (ctx: DriveContext): void => { - now = ctx; - ran = false; - const mine = ++seq; - previewText(ctx, () => !ran && mine === seq); - if (first.has(ctx.values)) return; - if (!(ctx.row.입력 ?? []).some((spec) => !isMaterialKeyInput(spec))) { - first.add(ctx.values); // 입력이 없으면 채울 것이 없음 — 열 때 한 번 바로 셈 - queueMicrotask(() => mine === seq && void run()); - } else if (ctx.savedKey !== null) { - first.add(ctx.values); - void autoFill(ctx).then((filled) => filled && mine === seq && void run()); - } - }; - return { - start, - run, - stop: () => { - now = null; - seq++; - }, - }; -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts index fb7a2c50a..8dd2e7d24 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts @@ -20,7 +20,6 @@ import { el } from "@ui/ui_template_elements"; import { createInputField } 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 { fetchLogic, setLogicApiPaths, @@ -31,12 +30,10 @@ import { type TextAnswer, } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; import { buildLabDetail } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Detail"; -import { buildHo, lineOpener, type HoEdit } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Ho"; +import { buildHo, type HoEdit } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Ho"; import { readTemplate, type Layer } from "./M02_MasterTemplete_Api_Fetch"; import { baseHeads } from "./M02_MasterTemplete_Sangeun_BaseHead"; import { calcSangeun, removedStrip } from "./M02_MasterTemplete_Sangeun_Cover"; -import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick"; -import { openRateLine } from "./M02_MasterTemplete_Sangeun_Rate"; import { createSangeunTabs } from "./M02_MasterTemplete_Sangeun_Tabs"; import { tl } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Text"; import { createSangeunSide, tabOf, type SourceTab } from "./M02_MasterTemplete_Sangeun_Side"; @@ -57,7 +54,7 @@ import { type SkAnswer, type SkDoc, } from "./logic/M02_Sangeun_Cover_Model"; -import { resetQty, setQty, withFirst } from "./logic/M02_Sangeun_Undo"; +import { resetQty, setQty } from "./logic/M02_Sangeun_Undo"; export interface SangeunDetailOptions { layer: Layer; @@ -138,74 +135,13 @@ export function mountSangeunDetail( } } const ownHost = el("div"); - const addButton = createButton({ - label: L("M02_SangeunAddLine"), - variant: "ghost", - size: 28, - onClick: () => - openSangeunLinePicker(scope, (line) => { - if ("종류" in line && line.종류 === "로직" && !doc.바탕) return setBase(line.로직); - if ("종류" in line && line.종류 === "로직") return void addBase(line); - doc.더한줄 = [ - ...(doc.더한줄 ?? []), - withFirst(line as unknown as NonNullable[number]), - ]; - touch(); - }), - }); - addButton.hidden = !edit || !!opt.source; - // 778-4 율 줄 — 기준 줄 후보 = 지금 셈 답 줄(세부 줄 뺌) · 새 줄은 맨 끝(앞 줄만 가리킴) - const rateButton = createButton({ - label: L("M02_SkRateAdd"), - variant: "ghost", - size: 28, - onClick: () => - openRateLine( - (ans?.줄 ?? []).map((l) => ({ cid: l.cid, 이름: l.이름 })), - (line) => ((doc.더한줄 = [...(doc.더한줄 ?? []), withFirst(line)]), touch()), - ), - }); - rateButton.hidden = !edit || !!opt.source; - /** 둘째 기초(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.더한줄 ?? []), - withFirst({ ...line, 수량: qty } as unknown as NonNullable[number]), - ]; - touch(); - } - /** 첫 기초 = 바탕(옛 [기본 로직 선택] 동작 · 단위는 `loadBase` 가 기초 결과단위로) */ - function setBase(key: string): void { - doc.바탕 = { 로직: key }; - delete doc.바탕판; // 새 바탕 — 개정 견줄 판 없음 - touch(false); - void loadBase(true); - } const main = el("div", { className: "m02-sk", attrs: { "data-sangeun-detail": doc.키 ?? "" }, children: [ head, notes, - ...(opt.slot - ? [] - : [ - el("div", { - className: "m02-sk__head", - children: [tabs.tools, addButton, rateButton], - }), - ]), + ...(opt.slot ? [] : [el("div", { className: "m02-sk__head", children: [tabs.tools] })]), tabs.host, editor, ], @@ -420,15 +356,9 @@ export function mountSangeunDetail( row: base?.logic ?? row, file: base?.file ?? "", reasons: base?.reasons ?? [], - prices: base?.prices ?? {}, - lines: null, - text, - lastText, - values, ownHost, onChange: () => undefined, - onInput, - cover: { ho, open: lineOpener(hoCtx) }, + cover: { ho }, }); // prettier-ignore side?.set([ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts index 0f925f56b..bf5a8920d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts @@ -4,13 +4,10 @@ * · `listLayerDocs` = 층 목록 길(`/layers/{층}/templates` · 종류 하나)을 마스터 › 회사 › 개인 차례로 이음 · * 금액 = 서버가 붙인 1 단위 `기준금액` · `listSangeun` = 종류 `sangeun` · 찾아 넣기 창(`Find_Pick`) 탭 · B09 재료 창고 출처가 씀 * · 고른 SK = 참조 `{층, 주인, 키: SK-…}` — 일위대가 줄은 옛 참조 줄 꼴(서버 `M02_Labor_Expand` 가 펼침) - * · `openSangeunLinePicker` = 단가산출 [항목 추가] — 찾아 넣기 창(721-2 · M01 로직 · 인력 · 재료 · 단가산출 · 일위대가) - * → SK 문서 `더한줄` 한 줄(PLAN 715 이음 꼴 · 인력 · 재료 = 요소 키 LB · MT · 수량 1) * ========================================================================== */ -import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch"; -import { fixedOf, openFindPicker } from "./M02_MasterTemplete_Find_Pick"; -import type { LaborFixed, LaborRef } from "./M02_MasterTemplete_Labor_Calc"; +import { call } from "./M02_MasterTemplete_Api_Fetch"; +import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc"; export const SANGEUN_KIND = "sangeun"; /** 층 차례 — 위에서 아래 */ @@ -68,29 +65,3 @@ export async function listLayerDocs( ); return lists.flat(); } - -/** 단가산출 더한줄 한 줄(PLAN 715 이음 꼴) */ -export type SangeunLine = - | { 종류: "로직"; 로직: string; 입력값: Record; 수량: number } - | { 참조: LaborRef; 수량: number } - | { 종류: "인력" | "재료"; 요소: string; 이름: string | null; 단위: string | null; 수량: number } - | { 고정: LaborFixed; 이름: string; 단위: string | null; 수량: number }; - -/** 단가산출 [항목 추가] — `scope` = 그 SK 문서 층(고를 수 있는 서고 = 위 층만) */ -export function openSangeunLinePicker( - scope: { layer: Layer; owner: string | null }, - done: (line: SangeunLine) => void, -): void { - openFindPicker({ - ...scope, - logic: (x) => done({ 종류: "로직", 로직: x.키, 입력값: {}, 수량: 1 }), - ref: (ref) => done({ 참조: ref, 수량: 1 }), - element: (e) => - (e.종류 === "인력" || e.종류 === "재료") && - done({ 종류: e.종류, 요소: e.코드, 이름: e.이름, 단위: e.단위, 수량: 1 }), - // 785-3 유가전력 = 더한줄 재료 요소 MO - oil: (e) => done({ 종류: "재료", 요소: e.코드, 이름: e.이름, 단위: e.단위, 수량: 1 }), - // 736-3 기계 = 고정 줄(이음 기계 EQ · 단가 = 한 시간 노 · 재 · 경) · 수량 1 - machine: (e) => done({ 고정: fixedOf(e), 이름: e.이름, 단위: e.단위, 수량: 1 }), - }); -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Rate.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Rate.ts deleted file mode 100644 index cfcf7e580..000000000 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Rate.ts +++ /dev/null @@ -1,103 +0,0 @@ -/* ============================================================================= - * M02_MasterTemplete_Sangeun_Rate.ts - * M02 단가산출(SK) 율 줄 넣기 창(PLAN 778-4 · 요구 623) — [율 줄 추가] → 이름 · 율 % · 기준 · 금액이 드는 칸. - * 기준 = 묶음(인력 · 재료 · 장비 줄 합) · 비목 전체(노무비 · 재료비 · 경비) · 줄 고르기(이 SK 줄 cid 목록 · - * 앞 율 줄 고르면 율 위 율) → 더한줄 `{고정: {이름, 단위 %, 단가 0, 이음 없음, 비율 {기준, 비목}}, 수량 = 율}` - * (꼴 = 서버 `M02_Sangeun_Doc` · 셈 = 서버 `M02_Sangeun_Calc._rated`) · 부품 상자만. - * ========================================================================== */ - -import { createButton } from "@ui/ui_template_button"; -import { createCheckbox } from "@ui/ui_template_checkbox"; -import { el } from "@ui/ui_template_elements"; -import { createFormGrid } from "@ui/ui_template_general_layout"; -import { createInputField, createSelectField } from "@ui/ui_template_input"; -import { t as L } from "@ui/ui_template_locale"; -import { openModal } from "@ui/ui_template_modal"; -import type { SkAdded } from "./logic/M02_Sangeun_Cover_Model"; - -const LINES = "줄"; - -/** 율 줄 넣기 — `rows` = 지금 셈 답 줄(기준 줄 고르기 후보) · `done` = 새 더한줄 한 줄 */ -export function openRateLine( - rows: { cid: string; 이름: string }[], - done: (line: SkAdded) => void, -): void { - const name = createInputField({ label: L("M02_SkRateName"), value: L("M02_SkRateDefault") }); - const rate = createInputField({ - label: L("M02_SkRateRate"), - type: "number", - value: "2", - unit: "%", - min: 0, - }); - const picks = rows.map((r) => ({ - cid: r.cid, - box: createCheckbox(r.이름 || r.cid, { chip: true, size: 28 }), - })); - const list = el("div", { - className: "m02-sk__tags", - attrs: { "data-sk-rate-lines": "" }, - children: picks.length - ? picks.map((p) => p.box.root) - : [el("span", { text: L("M02_SkRateNoLines") })], - }); - list.hidden = true; - const basis = createSelectField({ - label: L("M02_SkRateBasis"), - value: "인력", - options: [ - { value: "인력", text: L("M02_SkRateGroupLabor") }, - { value: "재료", text: L("M02_SkRateGroupMat") }, - { value: "장비", text: L("M02_SkRateGroupMach") }, - { value: "노무비", text: L("M02_SkRateCostLabor") }, - { value: "재료비", text: L("M02_SkRateCostMat") }, - { value: "경비", text: L("M02_SkRateCostExp") }, - { value: LINES, text: L("M02_SkRateBasisLines") }, - ], - onChange: (v) => (list.hidden = v !== LINES), - }); - const part = createSelectField({ - label: L("M02_SkRatePart"), - value: "경비", - options: [ - { value: "경비", text: L("M02_SkRatePartExp") }, - { value: "재료비", text: L("M02_SkRatePartMat") }, - { value: "노무비", text: L("M02_SkRatePartLabor") }, - ], - }); - openModal({ - title: L("M02_SkRateTitle"), - closeLabel: L("M02_SkRateClose"), - width: "40rem", - mount: (body, close, actions) => { - const ok = createButton({ - label: L("M02_SkRateOk"), - variant: "filled", - onClick: () => { - const label = name.input.value.trim(); - const q = Number(rate.input.value); - const keys = picks.filter((p) => p.box.input.checked).map((p) => p.cid); - const lines = basis.select.value === LINES; - name.setError(label ? undefined : L("M02_SkRateNeedName")); - rate.setError(rate.input.value.trim() && q >= 0 ? undefined : L("M02_SkRateBadRate")); - if (lines && !keys.length) return void rate.setError(L("M02_SkRateNeedLine")); - if (!label || !rate.input.value.trim() || !(q >= 0)) return; - const fx = { - 이름: label, - 단위: "%", - 단가: { 노무비: 0, 재료비: 0, 경비: 0 }, - 이음: { 노임: null, 자재: null, 기계: null }, - 비율: { 기준: lines ? keys : basis.select.value, 비목: part.select.value }, - }; - done({ 고정: fx, 이름: label, 단위: "%", 수량: q }); - close(); - }, - }); - ok.dataset.skRateOk = ""; - actions.append(ok); - const grid = createFormGrid([name.root, rate.root, basis.root, part.root]); - grid.dataset.skRate = ""; - body.append(grid, list); - }, - }); -} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts index 0875e1fb0..75df34578 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts @@ -63,7 +63,6 @@ export function createSangeunTabs( const show = (doc: boolean): void => { stack.root.hidden = tools.hidden = !doc; editor.hidden = doc; - if (!doc) editor.dataset.view = "ho"; }; show(true); return { diff --git a/resources/tester/test_717_2b_sangeun_detail.py b/resources/tester/test_717_2b_sangeun_detail.py index 72df8a4be..d4b2807db 100644 --- a/resources/tester/test_717_2b_sangeun_detail.py +++ b/resources/tester/test_717_2b_sangeun_detail.py @@ -165,9 +165,11 @@ def test_상세_부품_길_부품상자(): cover = _text(M02 / "M02_MasterTemplete_Sangeun_Cover.ts") assert "mountSangeunDetail(host, doc," in view and "createEmpty" not in view assert 'read: "/m02/logic-lib"' in detail and "setLogicApiPaths(LIB)" in detail - assert "buildLabDetail(editor" in detail and "cover: { ho, open: lineOpener(hoCtx) }" in detail + assert "buildLabDetail(editor" in detail and "cover: { ho }," in detail assert "buildCalc" not in detail # 799-2 시험 계산 보기 걷음 - assert "openSangeunLinePicker(scope" in detail + assert ( + "openSangeunLinePicker" not in detail and "openRateLine" not in detail + ) # 840-1 [항목 추가] · [율 줄 추가] 걷음 assert "if (mine !== seq || !alive) return" in detail # 최신 응답만 # 724-1 — 단가산출 상세 = 공용 상세(Ho) · 고침 고리 = SK 덮개 · 옛 고침 표 걷음 assert "...buildHo(hoCtx)" in detail and 'scope: "m02",' in detail diff --git a/resources/tester/test_717_4_sangeun_pick.py b/resources/tester/test_717_4_sangeun_pick.py index 2d0021bd3..445a1933c 100644 --- a/resources/tester/test_717_4_sangeun_pick.py +++ b/resources/tester/test_717_4_sangeun_pick.py @@ -38,10 +38,9 @@ def test_sk_list_layer_order_and_ref_shape(): ) # 일위대가 [로직 추가] → 찾아 넣기 창(785-7 `withSangeun`) store_ts = _text(ROOT / "B09_Estimation" / "B09_Estimation_UI_Store.ts") assert '["sangeun", "단가산출"],' in store_ts and "sangeun: sangeunItems," in store_ts - # 더한줄 고르기 — 715 이음 꼴 셋(인력 · 재료 = 요소 키) - assert 'logic: (x) => done({ 종류: "로직", 로직: x.키, 입력값: {}, 수량: 1 })' in pick - assert "ref: (ref) => done({ 참조: ref, 수량: 1 })" in pick - assert "done({ 종류: e.종류, 요소: e.코드, 이름: e.이름, 단위: e.단위, 수량: 1 })" in pick + assert ( + "openSangeunLinePicker" not in pick + ) # 840-1 단가산출 [항목 추가] 걷음 — 줄 더하기 = 수식 [+] def _sk(folder, name): diff --git a/resources/tester/test_721_find_pick.py b/resources/tester/test_721_find_pick.py index 43c00c285..bdec67e96 100644 --- a/resources/tester/test_721_find_pick.py +++ b/resources/tester/test_721_find_pick.py @@ -27,12 +27,12 @@ def _text(path: Path) -> str: def test_721_1_단추_이름과_자리(): detail = _text(M02 / "M02_MasterTemplete_Sangeun_Detail.ts") assert ( - "children: [tabs.picker, tabs.tools, addButton, rateButton]" in detail - ) # 750-2 안내 걷음 · 778-4 율 줄 추가 · 799-2 보기 · 808-1 [단가 갱신] + "addButton" not in detail and "rateButton" not in detail + ) # 840-1 [항목 추가] · [율 줄 추가] 걷음 assert "M02_SangeunPickBase" not in detail # 727-2 단추 걷음 locale = _text(ROOT / "ui_template" / "ui_template_locale_m2.ts") assert "M02_SangeunPickBase" not in locale - assert 'M02_SangeunAddLine: ["항목 추가"' in locale + assert "M02_SangeunAddLine" not in locale and "M02_SkRateAdd" not in locale def test_721_2_부르는_곳_셋만_새_창(): @@ -43,7 +43,7 @@ def test_721_2_부르는_곳_셋만_새_창(): assert "findSangeun" not in labor_pick # 옛 창의 단가산출 탭 걷음 assert "logicOnly" not in _text(M02 / "M02_MasterTemplete_Sangeun_Detail.ts") # 727-2 assert "logicOnly" not in find - assert "openFindPicker({" in _text(M02 / "M02_MasterTemplete_Sangeun_Pick.ts") + assert "openFindPicker" not in _text(M02 / "M02_MasterTemplete_Sangeun_Pick.ts") # 840-1 assert "sangeun: true }" in labor_pick # 일위대가(785-7 `withSangeun`) # 옛 창 그대로 — B09 단가 연결은 sangeun 없이 부름(736-1 불러오기 · 별칭 장부는 찾아 넣기 창으로) for f in ( @@ -171,6 +171,3 @@ def test_736_3_기계_고정_줄_금액(): and "이음: { 노임: null, 자재: null, 기계: e.코드 }" in find ) assert "machine: fixed &&" in _text(M02 / "M02_MasterTemplete_Labor_Pick.ts") - assert "machine: (e) => done({ 고정: fixedOf(e)" in _text( - M02 / "M02_MasterTemplete_Sangeun_Pick.ts" - ) diff --git a/resources/tester/test_722_ho_detail.py b/resources/tester/test_722_ho_detail.py index b24a171d7..0b9a26a18 100644 --- a/resources/tester/test_722_ho_detail.py +++ b/resources/tester/test_722_ho_detail.py @@ -38,7 +38,12 @@ def _node(code: str): def test_상세가_공용_부품을_씀(): detail = _text(M01 / "M01_MasterData_UI_LogicLab_Detail.ts") - assert "buildHo({ ...ctx, edit: trialEdit(ctx) })" in detail + assert ( + "buildHo" not in detail and "trialEdit" not in detail + ) # 840-1 M01 상세 보기 걷음 — M02 줄 모달만 씀 + assert "...buildHo(hoCtx)" in _text( + ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_Sangeun_Detail.ts" + ) ho = _text(HO) # 부품 상자만 — 표 · 카드 · 계산 줄 · 변수 칸(calc_sheet) · 입력 칩(텍스트 수식) for part in ( @@ -60,13 +65,13 @@ def test_상세가_공용_부품을_씀(): ) -def test_시험_고침은_캐시_밖(): +def test_시험_고침은_M01_에서_걷음(): lab = _text(M01 / "M01_MasterData_UI_LogicLab.ts") - # 시험 계산에만 덮은 사본 · 고침이 있으면 줄을 보냄 · drafts(캐시)에는 원본 줄 - assert "=> trial.apply(row);" in lab - assert "dirty: () => current.id in drafts || trial.has(current.row)," in lab - assert "onTrial: drawCalc," in lab and "rerun: () => void drive.run()," in lab # 799-2 셈 구동 + assert ( + "trial" not in lab and "drive" not in lab + ) # 840-1 — 시험 값 · 구동 걷음(수식에서 값을 바꾸고 [되돌림]) cover = _text(COVER) + assert "export const trial" not in cover and "applyCovers" not in cover assert "drafts" not in cover and "touch" not in cover and "/save" not in cover modal = _text(M01 / "M01_MasterData_UI_LogicLab_Modal.ts") assert "...(target.edit ? [target.edit(close, head)] : [])" in modal @@ -91,23 +96,3 @@ console.log(JSON.stringify(got)); assert got[3]["줄"] == [[2, 5, 2]] empty = _node(_pure(HO) + '\nconsole.log(JSON.stringify(boxesOf([], ["인력"])))') assert empty == [{"묶음": "인력", "줄": [], "소계": 0}] - - -@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") -def test_고침_덮기(): - body = """ -const row = { 키: "GF1", 호표: [ - { 종류: "인력", 요소: "LB1", 수량: "찾기(Q).수량" }, - { 종류: "로직", 요소: "로직(GC1)", 수량: "1 / 작업량", 값묶음: { 경비: 3 } }, - { 종류: "재료", 요소: "MT1", 수량: "2" } ], 덧줄: [{ 이름: "공구", 식: "노무비 * 3 / 100" }] }; -const out = applyCovers(row, { 0: { qty: 0.0000001, labor: 100000 }, 1: { labor: 50000 }, 9: { qty: 1 } }); -console.log(JSON.stringify({ out, same: row.호표[0].수량, keep: out.호표[2] === row.호표[2] })); -""" - got = _node(_pure(COVER) + "\n" + body) - out = got["out"] - assert ( - out["호표"][0]["수량"] == "0.0000001" and out["호표"][0]["단가"] == 100000 - ) # 지수 꼴 없음 · 손 단가 - assert out["호표"][1]["값묶음"] == {"경비": 3, "노무비": 50000} and "단가" not in out["호표"][1] - assert got["keep"] and got["same"] == "찾기(Q).수량" # 원본 안 바뀜 · 안 고친 줄 그대로 - assert out["덧줄"] == [{"이름": "공구", "식": "노무비 * 3 / 100"}] diff --git a/resources/tester/test_728_2_sk_unit.py b/resources/tester/test_728_2_sk_unit.py index bbf694329..826b3f7d7 100644 --- a/resources/tester/test_728_2_sk_unit.py +++ b/resources/tester/test_728_2_sk_unit.py @@ -90,7 +90,7 @@ def test_둘째_기초_수량_처음값_배(whole): 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 + assert "addBase(" not in src and "baseUnitOf(" in src # 840-1 [항목 추가] 걷음 def test_돈_아닌_기초_고르기_창에서_뺌(whole): diff --git a/resources/tester/test_799_2_old_tabs_gone.py b/resources/tester/test_799_2_old_tabs_gone.py index 671be4d3c..175583b17 100644 --- a/resources/tester/test_799_2_old_tabs_gone.py +++ b/resources/tester/test_799_2_old_tabs_gone.py @@ -57,27 +57,23 @@ def test_걷은_파일_없고_부르는_곳_없음(): def test_보기는_수식_하나_고르기_없음(): - m01 = _text(M01 / "M01_MasterData_UI_LogicLab_View.ts") m02 = _text(M02 / "M02_MasterTemplete_Sangeun_Tabs.ts") - for src in (m01, m02): - assert not re.findall(r'\{ value: "(\w+)", label: ', src) and "createSegment" not in src - assert "createViewPicker" not in _text(M01 / "M01_MasterData_UI_LogicLab.ts") # 836-3 - assert 'editor.dataset.view = "stack";' in _text(M01 / "M01_MasterData_UI_LogicLab.ts") - assert 'export type LabView = "stack" | "ho";' in m01 + assert not re.findall(r'\{ value: "(\w+)", label: ', m02) and "createSegment" not in m02 + assert not (M01 / "M01_MasterData_UI_LogicLab_View.ts").exists() # 840-1 보기 이름표 걷음 + lab = _text(M01 / "M01_MasterData_UI_LogicLab.ts") + assert "createViewPicker" not in lab and "dataset.view" not in lab # 836-3 · 840-1 css = _text(M01 / "M01_MasterData_UI_LogicLab_Style.css") - assert '[data-view="ho"] [data-block]:not([data-block="ho"])' in css - assert 'data-view="sheet"' not in css and 'data-view="calc"' not in css + assert "data-view" not in css and "data-block" not in css # 840-1 보기 고르기 규칙 걷음 def test_셈_구동은_화면_없이_상세는_구동만(): drive = _text(M01 / "M01_MasterData_UI_Logic_Drive.ts") assert "document." not in drive and "sessionStorage." not in drive - assert "export async function driveRun(" in drive and "export function createDrive(" in drive + assert "export async function driveRun(" in drive and "createDrive" not in drive # 840-1 lab = _text(M01 / "M01_MasterData_UI_LogicLab.ts") - assert "createDrive()" in lab and "buildCalc" not in lab and "data-calc-run" not in lab - assert "rerun: () => void drive.run()," in lab + assert "createDrive" not in lab and "buildCalc" not in lab and "data-calc-run" not in lab detail = _text(M01 / "M01_MasterData_UI_LogicLab_Detail.ts") - assert "calcHost" not in detail and "data-calc-run" not in detail and "ctx.rerun?.()" in detail + assert "calcHost" not in detail and "data-calc-run" not in detail and "rerun" not in detail calc = _text( M01 / "M01_MasterData_UI_Logic_Calc.ts" ) # 로직 복사 화면이 쓰는 시험 계산 칸 — 구동은 공용 diff --git a/resources/tester/test_804_2_line_stack.py b/resources/tester/test_804_2_line_stack.py index 67385f733..e9b159991 100644 --- a/resources/tester/test_804_2_line_stack.py +++ b/resources/tester/test_804_2_line_stack.py @@ -240,8 +240,8 @@ def test_화면_이음(): 'base: "/m01/logic/doc",' in lab and 'cache: "m01_doc_drafts",' in lab ) # 804-7 page = 페이지 [저장] assert "stack: stack.root" in lab and "=> stack.canLeave();" in lab # 804-6 수식 탭 = 줄 쌓기 - assert 'editor.dataset.view = "stack";' in lab # 836-3 보기 고르기 없음 · 수식 하나 - assert '[data-view="stack"]' in _src("M01_MasterData/M01_MasterData_UI_LogicLab_Style.css") + assert "dataset.view" not in lab # 836-3 보기 고르기 없음 · 840-1 이름표 · CSS 걷음 + assert "data-view" not in _src("M01_MasterData/M01_MasterData_UI_LogicLab_Style.css") tabs = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts") assert 'cache: "m02_sk_doc_drafts"' in tabs and tabs.count("skDocPath(opt.scope, k, p)") == 1 vite = _src("vite.config.ts") diff --git a/resources/tester/test_804_6_formula_tab_gone.py b/resources/tester/test_804_6_formula_tab_gone.py index 032cbaecd..ae42fde34 100644 --- a/resources/tester/test_804_6_formula_tab_gone.py +++ b/resources/tester/test_804_6_formula_tab_gone.py @@ -51,10 +51,7 @@ def test_걷은_파일_없고_부르는_곳_없음(): def test_보기_수식은_줄쌓기_첫탭(): - for rel in ( - "M01_MasterData/M01_MasterData_UI_LogicLab_View.ts", - "M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts", - ): + for rel in ("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts",): src = _text(rel) assert "createSegment" not in src and '"equation"' not in src, rel # 836-3 고르기 없음 lab = _text("M01_MasterData/M01_MasterData_UI_LogicLab.ts") diff --git a/resources/tester/test_804_7_line_stack_fix.py b/resources/tester/test_804_7_line_stack_fix.py index 573105ddd..ae1dc5be3 100644 --- a/resources/tester/test_804_7_line_stack_fix.py +++ b/resources/tester/test_804_7_line_stack_fix.py @@ -209,7 +209,10 @@ def test_시험_페이지_걷음_화면_차림() -> None: def test_M02_수식탭_M01과_같은_단추_저장_한_번() -> None: """804-8 — M02 단가산출 수식 탭 = M01 과 같은 공용 화면 · 페이지 [되돌림] · [저장] 한 번 · 머리 줄 한 줄.""" screen = _src("A00_Common/line_stack_screen.ts") - assert "...(cp && edit() && key && !mark?.자동 ? copyTools(true, (job) => void runJob(job)) : [])," in screen # 836-2 자동 층 = 단추 없음 · 프로젝트 층 = 있음 + assert ( + "...(cp && edit() && key && !mark?.자동 ? copyTools(true, (job) => void runJob(job)) : [])," + in screen + ) # 836-2 자동 층 = 단추 없음 · 프로젝트 층 = 있음 assert "notesOf" not in screen and "lstk__notes" not in screen # 못 고치는 칸 안내 줄 걷음 assert "Notes:" not in _src("A00_Common/line_stack_text.ts") tabs = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Tabs.ts") @@ -217,8 +220,9 @@ def test_M02_수식탭_M01과_같은_단추_저장_한_번() -> None: detail = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts") assert "page: opt.source ? () => report() : undefined," in detail assert "JSON.stringify(doc) !== saved || tabs.dirty()" in detail # 고침 알림 = 기본 + 수식 - assert "addButton.hidden = !edit || !!opt.source;" in detail # [항목 추가] · [율 줄 추가] 걷음 - assert "rateButton.hidden = !edit || !!opt.source;" in detail + assert ( + "addButton" not in detail and "rateButton" not in detail + ) # 840-1 [항목 추가] · [율 줄 추가] 걷음 assert "if (!(await tabs.save())) throw" in detail and "await refreshing;" in detail assert "if (tabs.dirty()) void tabs.discard();" in detail # 버리고 떠남 = 수식 캐시도 view = _src("M02_MasterTemplete/M02_MasterTemplete_Sangeun_View.ts") diff --git a/resources/tester/test_806_1_labor_view.py b/resources/tester/test_806_1_labor_view.py index 9472964a1..ffe0f1dca 100644 --- a/resources/tester/test_806_1_labor_view.py +++ b/resources/tester/test_806_1_labor_view.py @@ -133,8 +133,10 @@ def test_808_1_모달_머리_일원화(): assert "actions: row.참조 ? [slot, ...tools] : tools," in row and 't("FromSk")' in row assert "slot: o.slot," in _src("M02_MasterTemplete_Labor_RowSk.ts") detail = _src("M02_MasterTemplete_Sangeun_Detail.ts") - assert "...(opt.slot\n ? []" in detail # 머리 칸 있으면 옛 단추 줄 없음 + assert "...(opt.slot ? [] : [" in detail # 머리 칸 있으면 머리 줄 없음 assert "children: [tabs.tools]" in detail and "tabs.picker" not in detail assert r"/^SK-(?!0{6})\d{6}$/" in detail tabs = _src("M02_MasterTemplete_Sangeun_Tabs.ts") - assert "const doc = !!k || !!opt.page || !!opt.memory?.();" in tabs # 836-3 · 836-5 문서 없는 줄 = 상세 몸만 + assert ( + "const doc = !!k || !!opt.page || !!opt.memory?.();" in tabs + ) # 836-3 · 836-5 문서 없는 줄 = 상세 몸만 diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index 05a6bfbc9..b02ce51ea 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -187,7 +187,6 @@ export const ui_locales_m2 = { M02_SangeunSwapCoef: ["환산 계수", "Conversion factor"], M02_SangeunSwapWeight: ["단위중량", "Unit weight"], M02_SangeunSwapPick: ["고르세요", "Choose"], - M02_SangeunAddLine: ["항목 추가", "Add item"], M02_SangeunDrop: ["삭제", "Delete"], M02_SangeunDropBase: ["기초 빼기", "Remove base"], M02_SangeunBases: ["더한 기초", "Added bases"], @@ -368,7 +367,6 @@ export const ui_locales_m2 = { M02_ToneDanger: ["못 이음", "Not linked"], M02_ToneMuted: ["밖", "Outside"], M02_ToneMine: ["내가 고름", "My pick"], - M02_SkRateAdd: ["율 줄 추가", "Add % line"], M02_SkRateTitle: ["율 줄 — 기준 × 율 %", "% line — base × rate %"], M02_SkRateName: ["이름", "Name"], M02_SkRateDefault: ["공구손료", "Tool cost"], From 30c062bd484c98727ca983b191ea279643d0b9ea Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 15 Nov 2026 15:50:56 +0900 Subject: [PATCH 5/8] =?UTF-8?q?refactor(841-1):=20B09=20=EC=9E=AC=EB=A3=8C?= =?UTF-8?q?=20=EA=B4=80=EA=B8=89=20=C2=B7=20=EB=B3=84=EB=8F=84=20=3D=20M02?= =?UTF-8?q?=20=EC=9E=90=EC=9E=AC=ED=91=9C=EC=8B=9C=20=E2=80=94=20A=20?= =?UTF-8?q?=EA=B1=B8=EC=9D=8C=20=EC=9D=B4=EC=96=B4=EC=84=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 대가 · 산근 재료 줄 = M02 자재표시(기본 별도계상 · 관급) → 대가 · 산근 안 0 원(M02 셈과 같음) · % 덧줄 밑수는 엔진 값 그대로 - 자재단가대비표 기본 표시 = M02 자재표시(사용자 표시가 앞섬 · 「사급」 = 기본 끔 · 칸 누름 차례 관급 → 별도 → 사급) - 4 자재대 · 관급자재대 수량 = 대가 · 산근 밖 재료 역전개 수량(aside_qty) · 집계표에서 겹침 0 - 새 파일 B09_Estimation_Supply.py(표시 함수 · Router 700 줄 막음) - 견본 대가 = M02 반영합(1,527,221 · 1,267,314) · 전후 표 tmp/841_1/전후표.md Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Calc_Base.py | 2 + B09_Estimation/B09_Estimation_Calc_Base.ts | 4 +- B09_Estimation/B09_Estimation_Calc_Bill.py | 1 + B09_Estimation/B09_Estimation_Calc_Bill.ts | 1 + B09_Estimation/B09_Estimation_Calc_Labor.py | 15 ++++-- B09_Estimation/B09_Estimation_Calc_Labor.ts | 17 +++++-- B09_Estimation/B09_Estimation_Labor_Source.py | 17 +++++-- B09_Estimation/B09_Estimation_Router.py | 12 ++++- B09_Estimation/B09_Estimation_Sangeun.py | 23 +++++++-- B09_Estimation/B09_Estimation_Store.py | 2 +- B09_Estimation/B09_Estimation_Summary.py | 7 ++- B09_Estimation/B09_Estimation_Supply.py | 50 +++++++++++++++++++ B09_Estimation/B09_Estimation_UI_Tabs_Base.ts | 3 +- 13 files changed, 131 insertions(+), 23 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_Supply.py diff --git a/B09_Estimation/B09_Estimation_Calc_Base.py b/B09_Estimation/B09_Estimation_Calc_Base.py index 14748c60d..7f2158576 100644 --- a/B09_Estimation/B09_Estimation_Calc_Base.py +++ b/B09_Estimation/B09_Estimation_Calc_Base.py @@ -183,6 +183,8 @@ def compare_sheet( if applied is None: gaps.append(gap("compare", m["key"], m["name"], "적용 단가 없음(출처 없음)")) supply = next((v for v in (mine.get("supply"), m.get("supply")) if v in SUPPLY), None) + if mine.get("supply") == "사급": # 841-1 사용자가 기본 표시(M02 자재표시)를 끔 + supply = None rows.append( { **_head(m, no), diff --git a/B09_Estimation/B09_Estimation_Calc_Base.ts b/B09_Estimation/B09_Estimation_Calc_Base.ts index 011b21db0..ba951e0f0 100644 --- a/B09_Estimation/B09_Estimation_Calc_Base.ts +++ b/B09_Estimation/B09_Estimation_Calc_Base.ts @@ -158,7 +158,9 @@ function compareSheet(materials: Row[], user: Row, gaps: Gap[]): Row[] { else [applied, appliedFrom] = [low, lowFrom]; if (applied === null) gaps.push(gap("compare", m.key, m.name, "적용 단가 없음(출처 없음)")); const supply = - [mine.supply, m.supply].find((v) => (SUPPLY as readonly string[]).includes(v)) ?? null; + mine.supply === "사급" // 841-1 사용자가 기본 표시(M02 자재표시)를 끔 + ? null + : ([mine.supply, m.supply].find((v) => (SUPPLY as readonly string[]).includes(v)) ?? null); return { ...head(m, i + 1), unit: m.unit || "", diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.py b/B09_Estimation/B09_Estimation_Calc_Bill.py index a8e72890c..ae4cca570 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.py +++ b/B09_Estimation/B09_Estimation_Calc_Bill.py @@ -132,6 +132,7 @@ def _item_row(item: dict[str, Any], prices: dict[str, Any] | None) -> dict[str, def _material_items(inputs: dict[str, Any], compare: list[dict[str, Any]]) -> list[dict]: """대비표에서 「별도」 · 「관급」 표시한 수량 있는 자재 → 4 자재대 · 관급자재대 항목.""" qty_of = {m["key"]: m.get("qty") for m in inputs.get("materials") or [] if "qty" in m} + qty_of.update(inputs.get("aside_qty") or {}) # 841-1 대가 · 산근 밖 재료 = 그 역전개 수량 out = [] for row in compare: if row["supply"] is None or row["key"] not in qty_of: diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.ts b/B09_Estimation/B09_Estimation_Calc_Bill.ts index f18dde7d8..cb27950f7 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.ts +++ b/B09_Estimation/B09_Estimation_Calc_Bill.ts @@ -95,6 +95,7 @@ function itemRow(item: Row, prices: Row | null): Row { function materialItems(inputs: Row, compare: Row[]): Row[] { const qtyOf: Row = {}; for (const m of inputs.materials || []) if ("qty" in m) qtyOf[m.key] = m.qty; + Object.assign(qtyOf, inputs.aside_qty || {}); // 841-1 대가 · 산근 밖 재료 = 그 역전개 수량 const out: Row[] = []; for (const row of compare) { if (row.supply === null || !(row.key in qtyOf)) continue; diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.py b/B09_Estimation/B09_Estimation_Calc_Labor.py index 5858c2c6d..8514ce5f2 100644 --- a/B09_Estimation/B09_Estimation_Calc_Labor.py +++ b/B09_Estimation/B09_Estimation_Calc_Labor.py @@ -103,9 +103,6 @@ def _prices(line: dict[str, Any], books: dict[str, Any], sums: dict) -> dict[str ): # 841-1 = M02 엔진 단가(`own`) · B09 단가 책 안 읽음 own = line.get("own") out = {p: num(own.get(p)) if p in own else 0.0 for p in PARTS} if own else _none() - row = (books["material"].get(key) or {}) if kind == "material" else {} - if row.get("supply") in SUPPLY: # 관급 · 별도계상 자재 = 대가 안 0원(자재대 · 관급 단락) - out["material"] = 0.0 elif kind in ("daega", "sangeun"): row = {"daega": sums, "sangeun": books["sangeun"]}[kind].get(key) or {} for part in PARTS: @@ -117,6 +114,11 @@ def _prices(line: dict[str, Any], books: dict[str, Any], sums: dict) -> dict[str return out +def _apart(line: dict[str, Any], books: dict[str, Any]) -> bool: + """대가 밖 재료(841-1) — 대비표 표시(사용자 › M02 자재표시 기본) 관급 · 별도 = 자재대 · 관급 단락.""" + return (books["material"].get(line.get("key")) or {}).get("supply") in SUPPLY + + def _none() -> dict[str, float | None]: return dict.fromkeys(PARTS, None) @@ -137,8 +139,9 @@ def _base(spec: str, lines: list[dict[str, Any]], src: list) -> float | None: `line:이름` 그 줄 · `bundle:labor|material|machine` 그 종류 줄(+ 그 이음 고정 줄) 합 · `rows:0,2@labor` 블록 그 차례 줄(`src` cid · `@비목` = 그 몫) 합(778-4 · 짝 `.ts` `pctBase`).""" kind, _, name = spec.partition(":") - if kind == "part": - return total([ln["amounts"][name] for ln in lines if not ln.get("out")]) + if kind == "part": # 대가 밖 재료(841-1 `aside`)도 밑수에 — M02 엔진 「재료비의 %」 꼴 + aside = "aside" if name == "material" else "" + return total([ln.get(aside) or ln["amounts"][name] for ln in lines if not ln.get("out")]) if kind == "rows": # `3@labor` = 그 줄 그 비목 몫 out: list[float | None] = [] for one in dict.fromkeys(name.split(",")): @@ -195,6 +198,8 @@ def _block( part: None if p is None or qty is None else cut(p * qty, line.get("place")) for part, p in prices.items() } + if line["kind"] == "material" and _apart(line, books): # 관급 · 별도계상 = 대가 안 0원 + extra["aside"], prices["material"], amounts["material"] = amounts["material"], 0.0, 0.0 ref = {"ref": {"key": line["key"]}} if line.get("key") else {} # 집계표(145-5) 이음 ask = {"why": line["ask"]} if qty is None and line.get("ask") else {} # 723-4 lines.append( diff --git a/B09_Estimation/B09_Estimation_Calc_Labor.ts b/B09_Estimation/B09_Estimation_Calc_Labor.ts index 42202cd0a..14f7b8615 100644 --- a/B09_Estimation/B09_Estimation_Calc_Labor.ts +++ b/B09_Estimation/B09_Estimation_Calc_Labor.ts @@ -109,9 +109,6 @@ function linePrices(line: Row, books: Row, sums: Record): Parts { const own: Row | undefined = line.own; out = blanks(); if (own) for (const part of PARTS) out[part] = part in own ? num(own[part]) : 0; - const row: Row = kind === "material" ? books.material[key] || {} : {}; - // 관급 · 별도계상 자재 = 대가 안 0원(자재대 · 관급 단락) - if ((SUPPLY as readonly string[]).includes(row.supply)) out.material = 0; } else if (kind === "daega" || kind === "sangeun") { const src: Row = ({ daega: sums, sangeun: books.sangeun } as Row)[kind]; const row: Row = src[key] || {}; @@ -140,7 +137,12 @@ function pctBase(spec: string, lines: Row[], src: Row[]): Num { const kind = spec.slice(0, at); const name = spec.slice(at + 1); const whole = (ln: Row): Num => total(PARTS.map((p) => ln.amounts[p])); - if (kind === "part") return total(lines.filter((ln) => !ln.out).map((ln) => ln.amounts[name])); + // 대가 밖 재료(841-1 `aside`)도 밑수에 — M02 엔진 「재료비의 %」 꼴 + const aside = name === "material" ? "aside" : ""; + if (kind === "part") + return total( + lines.filter((ln) => !ln.out).map((ln) => (aside && ln[aside]) || ln.amounts[name]), + ); if (kind === "rows") { // `3@labor` = 그 줄 그 비목 몫 const out: Num[] = []; @@ -185,6 +187,13 @@ function block(b: Row, books: Row, sums: Record, gaps: Gap[], own: const p = prices[part]; amounts[part] = p === null || qty === null ? null : cut(p * qty, line.place); } + // 관급 · 별도계상 = 대가 안 0원(841-1 대비표 표시 = 사용자 › M02 자재표시) + const supply = (books.material[line.key] || {}).supply; + if (line.kind === "material" && (SUPPLY as readonly string[]).includes(supply)) { + extra.aside = amounts.material; + prices.material = 0; + amounts.material = 0; + } const ref = line.key ? { ref: { key: line.key } } : {}; // 집계표(145-5) 이음 const ask: Row = qty === null && line.ask ? { why: line.ask } : {}; // 723-4 lines.push({ ...line, ...ref, ...ask, qty, prices, amounts, ...extra }); diff --git a/B09_Estimation/B09_Estimation_Labor_Source.py b/B09_Estimation/B09_Estimation_Labor_Source.py index b2bd13df0..6e0b7c70f 100644 --- a/B09_Estimation/B09_Estimation_Labor_Source.py +++ b/B09_Estimation/B09_Estimation_Labor_Source.py @@ -38,6 +38,7 @@ from B09_Estimation import B09_Estimation_Labor_Rule as rule from B09_Estimation import B09_Estimation_Sangeun as sg from B09_Estimation import B09_Estimation_Sangeun_Text as st from B09_Estimation.B09_Estimation_Bill_Items import LAYER_WORD +from B09_Estimation.B09_Estimation_Supply import supply_mark from M02_MasterTemplete import M02_Item_Prices as item_prices from M02_MasterTemplete import M02_Labor_Doc as labor_doc from M02_MasterTemplete import M02_Labor_Fixed as fixed @@ -150,6 +151,10 @@ class Builder: for line in lines: if line["kind"] == "machine": line["spec"] = (eq.get(line["key"]) or {}).get("규격") or "" + marks = one.get("자재표시") or {} # 841-1 M02 자재표시(호표 차례) → 대비표 기본 표시 + for n, line in enumerate(lines): + if line["kind"] == "material": + line["mark"] = supply_mark(marks.get(str(n))) law = rule.mark(logic, lines, engine) # 새 의미 로직 = 줄 자리 · 합밖 · 번지 · 끝전(792-4) unit = str(logic.get("결과단위") or "").replace("원/", "") spec = " · ".join(f"{k} {v}" for k, v in sorted(given.items())) @@ -198,7 +203,8 @@ class Builder: return None except (sd.DocError, mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError): return None - one = {"로직": key, "입력값": got["입력값"], "켜쌓기율": got["켜쌓기율"]} + one = {"로직": key, "입력값": got["입력값"], "켜쌓기율": got["켜쌓기율"], + "자재표시": got.get("자재표시")} # fmt: skip where = {c: n for n, c in enumerate(got["정체"])} # 율 기준 줄 목록(778-4) → 블록 차례 block = self._poomsem(calc._Over(whole, key, row), {**one, "고름": got["고름"]}) if block is None: @@ -436,7 +442,11 @@ class Builder: if line.get("qty") is None and line["kind"] != "blank": line = {**line, "why": "수량 없음 — 면적산출 알약 칸 · 손 수량을 넣음"} lines.append(line) - lines += [_material(whole, mat, mat.get("수량")) for mat in result.get("자재") or []] + marks = {m.get("코드"): m for m in doc.get("자재") or [] if isinstance(m, dict)} + lines += [ + _material(whole, {**(marks.get(mat.get("코드")) or {}), **mat}, mat.get("수량")) + for mat in result.get("자재") or [] + ] manifest = read_json(folder / MANIFEST_NAME) or {} origin = manifest.get("원본") or {} return self.put( @@ -576,7 +586,8 @@ def _material(whole: Any, mat: dict, qty: Any) -> dict[str, Any]: if price is None and mat.get("코드"): price = labor_doc.material_price(whole, mat["코드"], mat.get("단위"))["기준단가"] own = None if _f(price) is None else {"material": _f(price)} - return {"kind": "material", "key": mat.get("코드"), **names, "qty": _f(qty), "own": own} + return {"kind": "material", "key": mat.get("코드"), **names, "qty": _f(qty), "own": own, + "mark": supply_mark(mat)} # fmt: skip def _engine_bases(engine: list[dict], items: list[dict]) -> dict[str, float]: diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 1260ba3a3..4acc03ef6 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -93,7 +93,13 @@ from B09_Estimation.B09_Estimation_Rates import rate_candidates, rate_table from B09_Estimation.B09_Estimation_Router_Import import router as import_router from B09_Estimation.B09_Estimation_Router_Link import router as link_router from B09_Estimation.B09_Estimation_Router_Quotes import router as quotes_router -from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines, used_materials +from B09_Estimation.B09_Estimation_Summary import ( + compute_summary, + hopyo_lines, + totals, + used_materials, +) +from B09_Estimation.B09_Estimation_Supply import supply_defaults from common_util.common_util_auth import verify_session from common_util.common_util_project_settings import reset_section from common_util.common_util_storage import resolve_stored_project_path @@ -159,8 +165,9 @@ def compute_sheets( 화면 짝 = `B09_Estimation_UI_Shell.computeSheets`(산근만 서버 답 그대로) · memo = 두 번 셈의 앞 산근 풀이(`Sangeun.collect`).""" + supply = supply_defaults(inputs, user) base = compute_base(inputs, user) - sg = sangeun.build(root, user, memo) # 145-1 — M02 엔진 값으로 풀이(841-1 · + 166-2 손 줄 로직) + sg = sangeun.build(root, user, memo, supply) # 145-1 M02 엔진 값으로 풀이(841-1 · 166-2 손 줄) sg = sg_cover.cover_sangeun(sg, user.get("sg_cover")) # 719-3 이 공사 산근 고친 칸 by_key = {row["key"]: row for row in sg["sg_list"]} labor = compute_labor( @@ -189,6 +196,7 @@ def compute_twice(root: Path, inputs: dict[str, Any], user: dict[str, Any]) -> t keep = used_materials(first, inputs, user) inputs["materials"] = [m for m in inputs.get("materials") or [] if m["key"] in keep] inputs["material_qty"] = {row["key"]: row["qty"] for row in summary["sum_material"]} + inputs["aside_qty"] = totals(first, hopyo_lines(first))[0].get("aside") or {} # 841-1 자재대 sheets = compute_sheets(root, inputs, user, memo) return sheets, compute_summary(sheets, hopyo_lines(sheets)) diff --git a/B09_Estimation/B09_Estimation_Sangeun.py b/B09_Estimation/B09_Estimation_Sangeun.py index c18536c4f..6296f730a 100644 --- a/B09_Estimation/B09_Estimation_Sangeun.py +++ b/B09_Estimation/B09_Estimation_Sangeun.py @@ -42,7 +42,7 @@ from typing import Any from B08_Quantity.B08_Quantity_Haul_Cycle import fill_given from B09_Estimation import B09_Estimation_Sangeun_Sum as ss -from B09_Estimation.B09_Estimation_Calc_Base import gap, num, rounddown, total +from B09_Estimation.B09_Estimation_Calc_Base import SUPPLY, gap, num, rounddown, total from B09_Estimation.B09_Estimation_Store import ref_rows from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Combo as combo_store @@ -311,7 +311,8 @@ def block_totals(lines: list[dict[str, Any]]) -> dict[str, float | None]: class Solver: """한 번 셈 — 산근 블록을 첫 등장 차례로 모음(하위 산근 포함).""" - def __init__(self) -> None: + def __init__(self, supply: Any = None) -> None: + self.supply = supply or (lambda key: None) # 자재 키 → 관급 · 별도 · None(841-1) self.prices = _Prices() self.eng: list[dict[str, Any]] = [] # 지금 푸는 로직의 엔진 줄(호표 차례 · 841-1) self.blocks: dict[str, dict[str, Any]] = {} @@ -349,6 +350,7 @@ class Solver: lines.append(self._line(whole, item, n, env, times, rate, depth)) ss.places(row, lines) # 줄 자리(792-3) self._extras(whole, row, env, lines) + self._aside(lines) _annotate(whole, row, env, lines) except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e: return self._stop(code, name, str(e) or type(e).__name__) @@ -409,6 +411,16 @@ class Solver: at += n return text[:start], text[-1] + def _aside(self, lines: list[dict[str, Any]]) -> None: + """관급 · 별도계상 재료 = 산근 안 0원(841-1 · % 덧줄 밑수는 엔진 값 그대로 · 자재대 · 관급 단락).""" + for ln in lines: + key = (ln.get("ref") or {}).get("key") + if ln["kind"] != "material" or self.supply(key) not in SUPPLY: + continue + part = next(iter(ln["price"]), "material") + ln["aside"], ln[part], ln["price"] = ln[part], 0.0, {part: 0.0} + self.prices.material[key] = 0.0 + def _each(self, n: int, part: str, q: float) -> float | None: """엔진 줄 n 비목 단가 = 엔진 금액 ÷ 엔진 수량 · 안 쓰는 줄(수량 0) = 0 · 못 셈 = None.""" x = self.eng[n] if n < len(self.eng) else {} @@ -608,13 +620,15 @@ def collect( entries: list[tuple[str, Path]], hands: list[tuple[str, str, dict[str, Any]]] | None = None, memo: dict[str, Any] | None = None, + supply: Any = None, ) -> dict[str, Any]: """`entries` = [(구조물 id, 사본 폴더)] → 블록 · 목록 · 「연결 없음」 · + supply = 자재 키 → 관급 · 별도(841-1 · 대비표 표시) · `hands` = 손 줄 로직(166-2 · 사본 뒤 번호 · 마스터 로직) · memo = 같은 사본 · 손 줄의 앞 풀이(두 번 셈) — 앞 풀이가 본 단가가 같으면 그 풀이 사본.""" if memo and "sg" in memo: # 841-1 풀이 = 사본 · 엔진만(바탕 표 안 읽음) → 앞 풀이 그대로 return copy.deepcopy(memo["sg"]) - solver = Solver() + solver = Solver(supply) for owner, folder in entries: whole = item_prices.load_prices(read_json(folder / PRICE_FILE) or {}) for src in rows_of(folder, whole): @@ -647,6 +661,7 @@ def build( project_root: str | Path, user: dict[str, Any] | None = None, memo: dict[str, Any] | None = None, + supply: Any = None, ) -> dict[str, Any]: """연결된 사본 전부(구조물 id 차례 → 설계내역서 서고 사본 · 702-3)의 산근 + 설계내역서 손 줄 로직(166-2 · 뒤 번호) — 차례 = `Labor_Source.daega_inputs` · @@ -662,4 +677,4 @@ def build( entries += [ (f"서고 {(m.get('원본') or {}).get('name') or f.name}", f) for f, m in copies(project_root) ] - return collect(entries, hand_sources(user), memo) + return collect(entries, hand_sources(user), memo, supply) diff --git a/B09_Estimation/B09_Estimation_Store.py b/B09_Estimation/B09_Estimation_Store.py index bd1402293..37d76abdc 100644 --- a/B09_Estimation/B09_Estimation_Store.py +++ b/B09_Estimation/B09_Estimation_Store.py @@ -328,7 +328,7 @@ def clean_user(raw: Any) -> dict[str, Any]: out: dict[str, Any] = {} if "applied" in cell: out["applied"] = _num(cell["applied"]) - if cell.get("supply") in SUPPLY: + if cell.get("supply") in (*SUPPLY, "사급"): # 사급 = 기본 표시 끔(841-1) out["supply"] = cell["supply"] if "quote" in cell: out["quote"] = _num(cell["quote"]) diff --git a/B09_Estimation/B09_Estimation_Summary.py b/B09_Estimation/B09_Estimation_Summary.py index 965c4ea12..a03cc0307 100644 --- a/B09_Estimation/B09_Estimation_Summary.py +++ b/B09_Estimation/B09_Estimation_Summary.py @@ -41,7 +41,7 @@ def expand( kind = line.get("kind") if kind == "hopyo": expand(lines_of, str(line.get("key")), qty * q, out, depth + 1) - elif kind in (*KINDS, "fixed"): + elif kind in (*KINDS, "fixed", "aside"): bucket = out.setdefault(kind, {}) bucket[str(line.get("key"))] = bucket.get(str(line.get("key")), 0.0) + qty * q @@ -154,6 +154,8 @@ def block_lines(blocks: list[dict[str, Any]] | None) -> dict[str, Any]: lines += _fixed_lines(block, line) continue kind = LINE_KIND.get(line.get("kind")) + if kind == "material" and "aside" in line: # 841-1 대가 · 산근 밖 재료 = 자재대 몫 + kind = "aside" key = (line.get("ref") or {}).get("key") if kind and key and num(line.get("qty")) is not None: lines.append({"kind": kind, "key": str(key), "qty": line["qty"]}) @@ -191,7 +193,8 @@ def used_materials(sheets: dict[str, Any], inputs: dict[str, Any], user: dict[st } marks = user.get("materials") or {} for m in inputs.get("materials") or []: - if "qty" in m or m.get("supply") or (marks.get(m["key"]) or {}).get("supply"): + own = m.get("supply") and m.get("origin") != "M01" # 841-1 M02 기본 표시는 남길 까닭 아님 + if "qty" in m or own or (marks.get(m["key"]) or {}).get("supply"): keep.add(m["key"]) return keep diff --git a/B09_Estimation/B09_Estimation_Supply.py b/B09_Estimation/B09_Estimation_Supply.py new file mode 100644 index 000000000..c2e8461be --- /dev/null +++ b/B09_Estimation/B09_Estimation_Supply.py @@ -0,0 +1,50 @@ +"""B09 자재 관급 · 별도 표시(PLAN 841-1) — M02 자재표시를 대비표 기본 표시로 · 서버 단독 · 순수 함수. + +M02 일위대가 · 단가산출 셈은 재료 줄을 자재표시(자재구분 관급 · 별도계상 — 빈 칸 = 별도계상)로 +합에서 뺌(`M02_Labor_Doc.apart`) → B09 도 같은 표시로 대가 · 산근 안 0 원 · 4 자재대 · 관급자재대로 +(`B09_Estimation_Calc_Labor._apart` · `Sangeun.Solver._aside` · `Calc_Bill` 자재대 수량 = `aside_qty`). +대비표 표시 = 사용자 › 이 기본 · 사용자 「사급」 = 기본을 끔. +""" + +from __future__ import annotations + +from typing import Any + +from M02_MasterTemplete import M02_Labor_Doc as labor_doc + + +def supply_mark(one: Any) -> str | None: + """M02 자재표시(841-1) → 대비표 기본 표시 — 관급 · 별도(별도계상 · M02 기본) · None(직접계산).""" + if labor_doc.mark_of(one, "자재구분") == "관급": + return "관급" + return "별도" if labor_doc.apart(one) else None + + +def material_marks(blocks: list[dict[str, Any]]) -> dict[str, str | None]: + """대가 블록 재료 줄 M02 표시를 자재 키마다 하나로(841-1) — 관급 하나라도 = 관급 · 별도 하나라도 = + 별도 · 모두 직접계산 = None. ponytail: 키 하나 = 표시 하나(줄마다 다르면 별도 쪽 · 자재대에 한 번).""" + out: dict[str, str | None] = {} + for block in blocks: + for line in block.get("lines") or []: + if line.get("kind") != "material" or not line.get("key") or "mark" not in line: + continue + key, was = str(line["key"]), out.get(str(line["key"])) + out[key] = "관급" if "관급" in (was, line["mark"]) else was or line["mark"] + return out + + +def supply_defaults(inputs: dict[str, Any], user: dict[str, Any]) -> Any: + """841-1 대비표 기본 표시 = M02 자재표시(대가 재료 줄 · 없으면 M02 기본 별도계상) — 로직 자재 + (B08 줄 밖 · 유가 밖) 입력 줄에 `supply` 를 얹음 → 자재 키 → 관급 · 별도(사용자 표시 › 기본).""" + marks = material_marks((inputs.get("daega") or {}).get("blocks") or []) + for row in inputs.get("materials") or []: + if row.get("origin") == "M01" and "supply" not in row and marks.get(row["key"], "별도"): + row["supply"] = marks.get(row["key"], "별도") + mine = user.get("materials") or {} + default = {row["key"]: row.get("supply") for row in inputs.get("materials") or []} + + def supply(key: Any) -> str | None: + got = (mine.get(key) or {}).get("supply") + return None if got == "사급" else got or default.get(key, marks.get(key, "별도")) + + return supply diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts index 458fbe168..8178fc13a 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts @@ -115,7 +115,8 @@ export const buildBasis: TabBuild = ({ sheets }): TabTable => { }; }; -const SUPPLY_NEXT: Record = { "": "관급", 관급: "별도", 별도: null }; +// 841-1 기본 표시(M02 자재표시)가 있어도 끌 수 있게 — 별도 다음 = 사급(표시 끔) +const SUPPLY_NEXT: Record = { "": "관급", 관급: "별도", 별도: "사급" }; /** 적용 단가 고르기 항목(183-3) — 값 있는 출처(견적 포함 · 0 뺌) 「출처 단가 · 최저」 + 「최저(자동)」 · * `pick(s)` = 그 출처 · null = 자동 */ From f822a4c36a0bc35c29a511d920ec3e6e57897d6a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 15 Nov 2026 15:52:13 +0900 Subject: [PATCH 6/8] =?UTF-8?q?feat(840-2):=20=EA=B8=B0=EA=B3=84=20?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EA=B3=B5=EC=9A=A9=20=E2=80=94=20M01=20?= =?UTF-8?q?=EA=B8=B0=EA=B3=84=20=EC=83=81=EC=84=B8=20=C2=B7=20=EB=82=B4=20?= =?UTF-8?q?=EA=B8=B0=EA=B3=84=EB=A5=BC=20=EC=99=BC=EC=AA=BD=20=EC=A0=9C?= =?UTF-8?q?=EC=96=B4=20=EA=B5=AC=EC=97=AD=20+=20=EC=98=A4=EB=A5=B8?= =?UTF-8?q?=EC=AA=BD=20=EC=8B=9C=EA=B0=84=EB=8B=B9=20=EA=B2=BD=EB=B9=84=20?= =?UTF-8?q?=EC=82=B0=EC=B6=9C=EA=B7=BC=EA=B1=B0=EB=A1=9C=20=C2=B7=20?= =?UTF-8?q?=E3=80=8C=EB=B3=B5=EC=82=AC=ED=95=B4=20=EB=82=B4=20=EA=B8=B0?= =?UTF-8?q?=EA=B3=84=EB=A1=9C=E3=80=8D=20=EA=B1=B7=EC=9D=8C=20=C2=B7=20?= =?UTF-8?q?=EA=B8=B0=EA=B3=84=20=EC=A4=84=20=ED=92=80=EA=B8=B0=20=EC=95=88?= =?UTF-8?q?=20=EB=82=B4=EB=86=93=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기계 보기 부품(A00_Common/line_stack_machine_sheet) — 836-1 제어 구역 그대로(기계 표 꼴 훅 sheet · 잠금 lock) + 기본 정보 · 손료 근거 접이 카드 + 비슷한 기계에서 시작 · 값 바꾸면 브라우저 계산기로 즉시 - 서버 POST /api/m02/machine-sheet(M02_MyMachine_Sheet) — 식 고름 + 식 문서 + 처음 줄 값 + 연결 기록 · 쓰기 없음 · 파이썬 bind ↔ TS bindDoc 짝 - 덮음 ↔ 기계 줄(common_util_machine_sheet) — 고침 없음 = 줄 바이트 같음 · 내 기계 바로 · 직접 자리 맞춤 - 내 기계 = M01 에 없는 기계만 — 화면 만들기 · 고치기에서 M01 과 같은 이름 · 규격 409(불러오기 자동 생성은 그대로) · [새 기계] · 고침 열림 - 옛 기계 양식 부품(ui_template_machine_form) 지움 — 쓰는 곳 0(부품 모음 견본 줄도 걷음) - 낡은 시험 test_821_3 test_stack_line_copy — 836-2 자동 따라옴(내 기계 고치면 SK 금액 따라감 · 갱신 할 것 없음)에 맞춤 - 지킴 시험 test_840_2 — M01 기계 655 대 박은 식 셈 = 요소 셈 · 줄 그대로 · 굴착기 덮음 · 내 기계 꼴 · M01 같은 이름 막음 Co-Authored-By: Claude Opus 5.5 --- A00_Common/line_stack_machine_ctl.ts | 50 +- A00_Common/line_stack_machine_sheet.css | 4 + A00_Common/line_stack_machine_sheet.ts | 556 ++++++++++++++++++ A00_Common/line_stack_rows.ts | 6 +- M01_MasterData/M01_MasterData_Machine_Api.ts | 2 +- .../M01_MasterData_UI_MachineDetail.ts | 28 +- M02_MasterTemplete/M02_MyMachine.py | 20 + M02_MasterTemplete/M02_MyMachine_Api.ts | 2 +- M02_MasterTemplete/M02_MyMachine_Form.ts | 163 +++-- M02_MasterTemplete/M02_MyMachine_Look.py | 25 +- M02_MasterTemplete/M02_MyMachine_Router.py | 6 +- M02_MasterTemplete/M02_MyMachine_Sheet.py | 90 +++ M02_MasterTemplete/M02_MyMachine_Text.ts | 5 +- M02_MasterTemplete/M02_MyMachine_View.ts | 23 +- common_util/common_util_machine_sheet.ts | 185 ++++++ .../tester/helper_840_2_machine_sheet.cjs | 35 ++ .../tester/test_821_3_my_machine_screen.py | 8 +- resources/tester/test_840_2_machine_sheet.py | 200 +++++++ ui_template/ui_template_locale_machine.ts | 15 +- ui_template/ui_template_machine_form.css | 112 ---- ui_template/ui_template_machine_form.ts | 520 ---------------- ui_template/ui_template_ui_kit_parts.ts | 19 - 22 files changed, 1283 insertions(+), 791 deletions(-) create mode 100644 A00_Common/line_stack_machine_sheet.css create mode 100644 A00_Common/line_stack_machine_sheet.ts create mode 100644 M02_MasterTemplete/M02_MyMachine_Sheet.py create mode 100644 common_util/common_util_machine_sheet.ts create mode 100644 resources/tester/helper_840_2_machine_sheet.cjs create mode 100644 resources/tester/test_840_2_machine_sheet.py delete mode 100644 ui_template/ui_template_machine_form.css delete mode 100644 ui_template/ui_template_machine_form.ts diff --git a/A00_Common/line_stack_machine_ctl.ts b/A00_Common/line_stack_machine_ctl.ts index 934482289..002bcb429 100644 --- a/A00_Common/line_stack_machine_ctl.ts +++ b/A00_Common/line_stack_machine_ctl.ts @@ -6,6 +6,8 @@ * ㉢ 잡품 = 재료 줄(재료 · 유가 고르기 · 수량 · 율 % · 기본 = 밑 재료 줄 · 품셈 잡재료율) * 고침 = 문서 칸 `기계덮음`(캐시 · [저장] 때 서버가 정본으로 다시 셈) · 원본과 같으면 지움(`putCover`) * 기본값 = 덮음 없이 셈한 그 기계 식 답(`defaultsOf`) · 읽기(M01 기초) = 칸 잠김 · 단추 없음 + * 기계 표 꼴(`sheet` · 840-2 기계 보기 `line_stack_machine_sheet`) = 조종원 한 줄까지 · 잡품 = 밑 줄 고정(율 하나) · + * `lock` = 잠긴 값 칸(품셈 방식 계수 등) * ========================================================================== */ import { createBadge } from "@ui/ui_template_badge"; @@ -42,6 +44,10 @@ export interface CtlHooks { name: (key: string) => string; /** 읽기(M01 기초 · 칸 잠김) */ readOnly: boolean; + /** 기계 표 꼴(840-2) — 기계 줄 칸에 담기는 만큼만(조종원 한 줄 · 잡품 율 하나 · 재료 고르기 없음) */ + sheet?: boolean; + /** 잠긴 값 칸 */ + lock?: string[]; } /** 기계 칸 단위(기계 표 `단위` 와 같음) */ @@ -116,6 +122,7 @@ export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElemen const { machine, sub, vals, base } = got; const entry: MachineCover = got.entry ?? {}; const ro = h.readOnly; + const table = !!h.sheet; const put = (next: MachineCover): void => h.change((d) => putCover(d, machine, next, base)); const parts = partsOf(sub); const lines = sub.금액 ?? []; @@ -125,6 +132,7 @@ export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElemen const was = base.값[col]; const mine = entry.값?.[col]; const now = mine ?? was; + const shut = ro || !!h.lock?.includes(col); const setVal = (v: number | string | null): void => { const 값 = { ...(entry.값 ?? {}) }; if (v === null || v === "") delete 값[col]; @@ -140,11 +148,11 @@ export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElemen value: String(now ?? ""), compact: true, size: 24, - disabled: ro, + disabled: shut, ariaLabel: col, onChange: (x) => setVal(x), }).root - : numBox(col, now, UNIT[col] ?? "", ro, setVal); + : numBox(col, now, UNIT[col] ?? "", shut, setVal); const row = el("div", { className: "lstk-frow", children: [ @@ -157,7 +165,7 @@ export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElemen const tag = createBadge(lt("Ctl_Changed"), "info", { size: 18 }); tag.title = `${lt("Ctl_Was")} ${was === undefined ? "—" : eqText(was)}`; row.append(tag); - if (!ro) + if (!shut) row.append( createButton({ label: lt("Ctl_Back"), @@ -230,21 +238,30 @@ export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElemen lineRow( lt("K_재료"), miscName(r), - ro, + ro || table, () => pickMat((p) => setMisc(swapAt(misc, i, asMat(r, p))), r.키 ? miscName(r) : ""), [ - numBox(lt("Qty"), r.수량, r.단위 ?? "", ro, (n) => { - const { 수량: _, ...rest } = r; - setMisc(swapAt(misc, i, n === null ? rest : { ...r, 수량: n })); - }, r.줄 && r.수량 === undefined ? `${baseQty(r) ?? ""} ${lt("Ctl_Follow")}` : ""), // prettier-ignore - numBox(lt("Ctl_Rate"), r.율, "%", ro, (n) => setMisc(swapAt(misc, i, { ...r, 율: n ?? 0 }))), // prettier-ignore + ...(table + ? [] + : [numBox(lt("Qty"), r.수량, r.단위 ?? "", ro, (n) => { + const { 수량: _, ...rest } = r; + setMisc(swapAt(misc, i, n === null ? rest : { ...r, 수량: n })); + }, r.줄 && r.수량 === undefined ? `${baseQty(r) ?? ""} ${lt("Ctl_Follow")}` : "")]), // prettier-ignore + numBox( + lt("Ctl_Rate"), + r.율, + "%", + ro, + (n) => + setMisc(table ? misc.map((x) => ({ ...x, 율: n ?? 0 })) : swapAt(misc, i, { ...r, 율: n ?? 0 })), // prettier-ignore + ), ], "재료비", () => setMisc(misc.filter((_, k) => k !== i)), ), ); - const head = (label: string, covered: boolean, add: () => void, reset: () => void) => + const head = (label: string, covered: boolean, add: (() => void) | null, reset: () => void) => el("div", { className: "lstk-fhead", children: [ @@ -253,7 +270,7 @@ export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElemen ...(ro ? [] : [ - createIconButton({ icon: "plus", title: `${label} ${lt("Ctl_Add")}`, size: 24, onClick: add }), // prettier-ignore + ...(add ? [createIconButton({ icon: "plus", title: `${label} ${lt("Ctl_Add")}`, size: 24, onClick: add })] : []), // prettier-ignore ...(covered ? [createButton({ label: lt("Ctl_Back"), variant: "ghost", size: 24, iconStart: "undo", onClick: reset })] // prettier-ignore : []), @@ -270,14 +287,21 @@ export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElemen head( lt("Ctl_Crew"), entry.조종원 !== undefined, - () => openStackPick("인력", (p) => setCrew([...crew, { 키: p.키, 이름: p.이름, 인: 1 }])), + table && crew.length + ? null + : () => + openStackPick("인력", (p) => setCrew([...crew, { 키: p.키, 이름: p.이름, 인: 1 }])), () => put({ ...entry, 조종원: undefined }), ), ...(crewRows.length ? crewRows : [el("div", { className: "lstk-empty", text: lt("Ctl_None") })]), // prettier-ignore head( lt("Ctl_Misc"), entry.잡품 !== undefined, - () => pickMat((p) => setMisc([...misc, asMat({ 율: 100 }, p)]), ""), + !table + ? () => pickMat((p) => setMisc([...misc, asMat({ 율: 100 }, p)]), "") + : parts.base.length && !misc.length + ? () => setMisc(parts.base.map((k) => ({ 줄: String(lines[k].이름 ?? ""), 율: 0 }))) + : null, () => put({ ...entry, 잡품: undefined }), ), ...(miscRows.length ? miscRows : [el("div", { className: "lstk-empty", text: lt("Ctl_None") })]), // prettier-ignore diff --git a/A00_Common/line_stack_machine_sheet.css b/A00_Common/line_stack_machine_sheet.css new file mode 100644 index 000000000..16f044378 --- /dev/null +++ b/A00_Common/line_stack_machine_sheet.css @@ -0,0 +1,4 @@ +/* 기계 보기(840-2) — 비슷한 기계 찾기 목록 높이만 · 색 · 크기는 공통 값 */ +.lstk-msheet-similar .ui-find__list { + max-height: 240px; +} diff --git a/A00_Common/line_stack_machine_sheet.ts b/A00_Common/line_stack_machine_sheet.ts new file mode 100644 index 000000000..cb512c184 --- /dev/null +++ b/A00_Common/line_stack_machine_sheet.ts @@ -0,0 +1,556 @@ +/* ============================================================================= + * line_stack_machine_sheet.ts + * 기계 보기(PLAN 840-2 · 요구 853 · 862 「기계 보기 공용」) — 기계 한 대를 단가산출 수식 보기와 닮은 꼴로: + * 왼쪽 = 기계 제어 구역(836-1 `line_stack_machine_ctl` 그대로 · 기계 표 꼴 `sheet` — 값 칸 · 조종원 인력 줄 · 잡품 줄) + + * 「기본 정보 · 손료 근거」 접이 카드(이름 · 규격 · 손료 방식 · 품셈 비율 · 식이 안 읽는 값 칸) + 비슷한 기계에서 시작(새 기계) + * 오른쪽 = 그 기계 시간당 경비 산출근거(`line_stack_out` · 노 · 재 · 경 · 계 합계 상자) — 값을 바꾸면 브라우저 계산기로 즉시 + * 서버(`POST /api/m02/machine-sheet` · `M02_MyMachine_Sheet`) = 식 고름 + 처음 줄 값을 박은 식 문서 + 연결 기록 · + * 고친 값은 덮음(`기계덮음` 꼴)으로 얹어 셈 · 캐시에 없는 연결 · 식이 바뀔 칸만 뒤에서 다시 물음(옛 요청 끊음) + * 줄 = 저장 꼴(M01 기계 줄 · 내 기계 줄) — 덮음 ↔ 줄은 `common_util_machine_sheet` · 저장은 부르는 쪽(M01 상세 · 내 기계) + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; +import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; +import { createInputField, createSelectField, el } from "@ui/ui_template_elements"; +import { createFindList } from "@ui/ui_template_find_list"; +import { t as L } from "@ui/ui_template_locale"; +import { attachPickSearch } from "@ui/ui_template_pick_search"; +import { createSegment } from "@ui/ui_template_segment"; +import { createText } from "@ui/ui_template_text"; +import type { FormulaDoc, Link } from "../common_util/common_util_formula_doc"; +import { calcDoc, type DocAnswer } from "../common_util/common_util_formula_doc_calc"; +import { createDocCache, type DocRecord } from "../common_util/common_util_formula_doc_cache"; +import { + COVER, + coverDoc, + coverOf, + defaultsOf, + partsOf, + putCover, + type CoverParts, + type MachineCover, +} from "../common_util/common_util_formula_doc_cover"; +import type { StackDoc } from "../common_util/common_util_line_stack"; +import { + MACHINE_COEFS, + MACHINE_INPUTS, + machineCoefOf, +} from "../common_util/common_util_machine_coef"; +import { + applyCover, + bindDoc, + coverFrom, + engineVals, + jobCode, + modeOf, + ownedCols, + rangeWarnings, + sameVal, + slotOf, + type MachineMode, + type MachinePick, + type MachineRow, + type MachineValues, +} from "../common_util/common_util_machine_sheet"; +import { machineCellName } from "../common_util/common_util_machine_symbol_name"; +import { docFoot } from "./formula_doc_foot"; +import { buildMachineCtl } from "./line_stack_machine_ctl"; +import { buildOut } from "./line_stack_out"; +import "./line_stack.css"; +import "./line_stack_machine_sheet.css"; + +export interface MachineSimilar { + key: string; + name: string; + spec: string; + /** 흐린 꼬리표(구분 · 내 기계 등) */ + tag?: string; + row: MachineRow; +} + +export interface MachineSheetOptions { + /** 처음 줄(저장본 · 새 기계 = 빈 줄) — 고친 칸 표지 · 되돌림 기준 */ + orig: MachineRow; + /** 지금 줄(초안) · 없으면 처음 줄 */ + cur?: MachineRow; + /** 내 기계(방식 바로 · 품셈 · 직접 · 그 사람 개인 층 기계 표) */ + mine: boolean; + readOnly: boolean; + lists: { fuels: MachinePick[]; jobs: MachinePick[] }; + findSimilar?: (q: string) => Promise; + onSimilar?: (item: MachineSimilar) => void; + onChange?: () => void; +} + +export interface MachineSheetState { + name: string; + spec: string; + /** 지금 줄(이름 · 규격 넣음) */ + row: MachineRow; + mode: MachineMode; + /** 숫자 아닌 칸 — 있으면 저장 막음 */ + errors: string[]; + changed: boolean; +} + +interface SheetInfo { + 키: string | null; + 이름?: string; + 까닭?: string; + 문서: FormulaDoc | null; + /** 처음 줄 셈 꼴 값(식에 박을 값) */ + 값?: MachineValues; + 연결?: DocRecord; + 직종: Record; +} + +/** 늘 보이는 기계 줄 칸(값이 없어도) — 그 밖 칸은 줄에 있으면 */ +const BASE_KEYS = [ + "가격", + "손료계수", + "주연료", + "주연료종류", + "잡재료율", + "조종원", + "조종원직종", + "전력", +]; +const UNIT: Record = { + 가격: "천원", 손료계수: "10⁻⁷/hr", 내용시간: "시간", 연간표준가동시간: "시간", 주연료: "ℓ/hr", + 잡재료율: "%", 조종원: "인", 전력: "kWh", +}; // prettier-ignore +const PICKS = ["주연료종류", "조종원직종"]; +/** 식이 바뀔 수 있는 칸(서버 식 고름) */ +const PICKY = ["주연료", "조종원", "조종원직종", "전력", "손료계수", ...MACHINE_COEFS]; + +const clone = (v: T): T => JSON.parse(JSON.stringify(v ?? null)) as T; +const text = (v: unknown): string => (v === null || v === undefined ? "" : String(v)); +const label = (k: string): string => machineCellName(k); + +export function createMachineSheet(o: MachineSheetOptions) { + const mine = o.mine; + const ro = o.readOnly; + let orig: MachineRow = clone(o.orig); + let cur: MachineRow = clone(o.cur ?? o.orig); + let name = text(cur.이름); + let spec = text(cur.규격); + const start = JSON.stringify([name, spec, cur]); + const no = text(cur.원문번호) || text(cur.키) || "새 기계"; + const given = { 기계: no }; + const link: Link = { 갈래: "단가산출", 입력: { 기계: `'${no.replace(/'/g, "")}'` } }; + const holder = { 꼴: "수식문서", 변수: [], 식: [], 금액: [] } as unknown as StackDoc; + const cache = createDocCache(); + const opened = new Set(); + const errors = new Map(); + let info: SheetInfo | null = null; + let jobs: Record = {}; + let parts: CoverParts | null = null; + let base: Required = { 값: {}, 조종원: [], 잡품: [] }; + + const left = el("div", { className: "lstk__left" }); + const right = el("div", { className: "lstk__right" }); + const foot = el("div", { className: "lstk__foot" }); + const notice = el("div"); + const warn = el("div"); + const ctlBox = el("div"); + const baseBox = el("div"); + const root = el("div", { + className: "lstk", + attrs: { "data-edit": "1", "data-machine-sheet": no }, + children: [el("div", { className: "lstk__areas", children: [left, right] }), foot], + }); + attachCollapsible(baseBox); + + const sub = (): FormulaDoc | null => info?.문서 ?? null; + const entry = (): MachineCover => coverOf(holder)[no] ?? {}; + /** 값 박은 셈(덮음 얹은 뒤 · 제어 구역 기본값) */ + const plain = (d: FormulaDoc, g: Record): DocAnswer => + raw(bindDoc(d, info?.값 ?? {}), g); + const raw = (d: FormulaDoc, g: Record): DocAnswer => + calcDoc(d, cache.links, g, [], new Map(), {}); + + /** 식이 바뀌거나 처음 줄 · 방식이 바뀐 뒤 — 덮음을 처음 줄 대 지금 줄로 다시 지음 */ + const rebuild = (): void => { + delete (holder as { [COVER]?: unknown })[COVER]; + const d = sub(); + parts = d ? partsOf(d) : null; + if (!d || !parts) return; + base = defaultsOf(d, (plain(d, given).값 ?? {}) as Record); + putCover(holder, no, coverFrom(parts, d.금액 ?? [], orig, cur, mine), base); + }; + + // ── 서버(뒤에서) ── + let seq = 0; + let timer: number | undefined; + let ctl: AbortController | null = null; + const ask = (now = false): void => { + window.clearTimeout(timer); + timer = window.setTimeout(() => void run(), now ? 0 : 300); + }; + const run = async (): Promise => { + ctl?.abort(); + ctl = new AbortController(); + const mineSeq = ++seq; + const body = { 줄: rowOut(), 본: orig, 덮음: coverOf(holder)[no] ?? null, 개인: mine }; + try { + const r = await fetch(`${API_BASE_URL}/m02/machine-sheet`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal: ctl.signal, + }); + const got = (await r.json().catch(() => ({}))) as SheetInfo & { detail?: unknown }; + if (mineSeq !== seq) return; + if (!r.ok) throw new Error(text(got.detail) || r.statusText); + cache.merge(got.연결); + jobs = got.직종 ?? jobs; + if (!info || got.키 !== info.키) { + info = got; + rebuild(); + drawAll(); + } else drawLive(); + } catch (e) { + if (mineSeq !== seq || (e as Error).name === "AbortError") return; + info = { 키: null, 까닭: e instanceof Error ? e.message : "", 문서: null, 직종: jobs }; + parts = null; + drawAll(); + } + }; + + const rowOut = (): MachineRow => ({ ...cur, 이름: name.trim(), 규격: spec.trim() }); + const changed = (refit = false): void => { + o.onChange?.(); + drawLive(); + ask(refit); + }; + + // ── 품셈 비율 → 계수(같으면 처음 값 · 제어 구역 칸은 덮음으로) ── + const recoef = (): void => { + if (modeOf(cur, mine) !== "품셈") return; + const v = (cur.값 ??= {}); + const same = MACHINE_INPUTS.every((k) => sameVal(v[k], orig.값?.[k])); + const got = same ? null : machineCoefOf(v); + if (!same && !got) return; + const owned = parts ? ownedCols(parts) : []; + const e = clone(entry()); + for (const k of MACHINE_COEFS) { + const want = same ? orig.값?.[k] : got![k]; + if (owned.includes(k)) { + e.값 = { ...(e.값 ?? {}) }; + if (want === undefined || same) delete e.값[k]; + else e.값[k] = want as number; + } else if (want === undefined) delete v[k]; + else if (!sameVal(v[k], want)) v[k] = want; + } + if (parts) { + putCover(holder, no, e, base); + cur = applyCover(parts, orig, cur, entry(), mine, jobs); + } + }; + + // ── 왼쪽 ── + const drawCtl = (): void => { + const d = sub(); + if (!d || !info?.키) return void ctlBox.replaceChildren(); + const mode = modeOf(cur, mine); + const lock = mode === "품셈" ? [...MACHINE_COEFS] : mine && mode === "바로" ? ["정비비계수", "관리비계수"] : []; // prettier-ignore + const zone = buildMachineCtl( + { + doc: holder, + answer: { 값: {} } as unknown as DocAnswer, + subDoc: () => d, + calc: plain, + change: (fn) => { + fn(holder); + const e = entry(); + if (e.조종원) { + e.조종원 = e.조종원.map((r) => ({ ...r, 키: `LB:${jobCode(r.키, jobs)}` })); + putCover(holder, no, e, base); + } + if (parts) cur = applyCover(parts, orig, cur, entry(), mine, jobs); + changed(); + }, + name: (k) => cache.names.get(k) ?? "", + readOnly: ro, + sheet: true, + lock, + }, + { ...link, 키: info.키 }, + ); + if (zone && lock.length && mode === "품셈") zone.title = L("MS_Locked"); + ctlBox.replaceChildren(...(zone ? [zone] : [])); + }; + + const numField = (k: string, read = false): HTMLElement => { + const v = cur.값?.[k]; + const f = createInputField({ label: label(k), value: text(v), size: 28, ...(UNIT[k] ? { unit: UNIT[k] } : {}) }); // prettier-ignore + f.input.inputMode = "decimal"; + f.input.dataset.key = k; + f.input.readOnly = ro || read; + f.root.classList.toggle("is-changed", !sameVal(v, orig.값?.[k])); + f.input.addEventListener("change", () => { + const t = f.input.value.replace(/,/g, "").trim(); + const box = (cur.값 ??= {}); + const n = Number(t); + if (t && !Number.isFinite(n)) { + errors.set(k, `${label(k)} · ${L("MF_NumberOnly")}`); + f.setError(L("MF_NumberOnly")); + return o.onChange?.(); + } + errors.delete(k); + f.setError(); + if (!t) { + if (k in (orig.값 ?? {})) box[k] = null; + else delete box[k]; + } else box[k] = n; + if ((MACHINE_INPUTS as readonly string[]).includes(k)) recoef(); + f.root.classList.toggle("is-changed", !sameVal(box[k], orig.값?.[k])); + changed(PICKY.includes(k)); + if ((MACHINE_INPUTS as readonly string[]).includes(k)) drawBase(); + }); + return f.root; + }; + + const pickField = (k: string): HTMLElement => { + const list = k === "주연료종류" ? o.lists.fuels : o.lists.jobs; + const now = text(cur.값?.[k]); + const options = [ + { value: "", text: L("MF_None") }, + ...list.map((p) => ({ value: p.value, text: p.text })), + ]; + if (now && !list.some((p) => p.value === now)) options.push({ value: now, text: `${now} (${L("MF_NoPrice")})` }); // prettier-ignore + const f = createSelectField({ label: label(k), options, value: now, size: 28 }); + f.select.dataset.key = k; + f.select.disabled = ro; + attachPickSearch(f.select, { placeholder: L("MF_PickFind") }); + f.root.classList.toggle("is-changed", !sameVal(now, orig.값?.[k])); + f.select.addEventListener("change", () => { + const box = (cur.값 ??= {}); + if (f.select.value) box[k] = f.select.value; + else if (k in (orig.값 ?? {})) box[k] = orig.값![k] === "" ? "" : null; + else delete box[k]; + f.root.classList.toggle("is-changed", !sameVal(box[k], orig.값?.[k])); + changed(true); + }); + return f.root; + }; + + const setMode = (m: MachineMode): void => { + const v = (cur.값 ??= {}); + const was = modeOf(cur, mine); + if (m === was) return; + if (m === "품셈") for (const k of MACHINE_INPUTS) if (orig.값?.[k] !== undefined) v[k] = clone(orig.값[k]); // prettier-ignore + if (m === "바로" && !mine) for (const k of MACHINE_INPUTS) delete v[k]; + if (mine) { + if (m === "바로" && blankNum(v.손료계수)) { + const e = engineVals(cur, true); + v.손료계수 = ["상각비계수", "정비비계수", "관리비계수"].reduce( + (s, k) => s + (Number(e[k]) || 0), + 0, + ); + } + if (m === "직접" && !cur.시간당) cur.시간당 = Object.fromEntries((["노무비", "재료비", "경비"] as const).map((k) => [k, Math.round(lastAnswer?.[k] ?? 0)])); // prettier-ignore + cur.방식 = m; + } + recoef(); + rebuild(); + drawAll(); + changed(true); + }; + const blankNum = (x: unknown): boolean => typeof x !== "number" || !x; + + const drawBase = (): void => { + const keep = baseBox.firstElementChild?.classList.contains("is-collapsed") ?? false; + const mode = modeOf(cur, mine); + const owned = new Set( + (parts && info?.키 ? ownedCols(parts) : []) + .map((c) => slotOf(c, mode, mine)) + .flatMap((s) => (s && s[0] === "값" ? [s[1]] : [])), + ); + const pumsem = mode === "품셈"; + const keys = + mode === "직접" + ? [] + : [...new Set([...BASE_KEYS, ...Object.keys(cur.값 ?? {})])].filter( + (k) => !owned.has(k) && !(MACHINE_INPUTS as readonly string[]).includes(k) && !(pumsem && (MACHINE_COEFS as readonly string[]).includes(k)), // prettier-ignore + ); + const textBox = ( + lab: string, + get: string, + was: string, + set: (v: string) => void, + ): HTMLElement => { + const f = createInputField({ label: lab, value: get, size: 28 }); + f.input.readOnly = ro; + f.root.classList.toggle("is-changed", get !== was); + f.input.addEventListener("input", () => { + set(f.input.value); + f.root.classList.toggle("is-changed", f.input.value !== was); + o.onChange?.(); + }); + return f.root; + }; + const seg = createSegment({ + options: [ + { value: "바로", label: L("MF_CoefDirect") }, + { value: "품셈", label: L("MF_CoefPumsem") }, + ...(mine ? [{ value: "직접", label: L("MF_HourlyDirect") }] : []), + ], + value: mode, + size: 24, + ariaLabel: L("MS_Mode"), + onChange: (v) => setMode(v as MachineMode), + }); + if (ro) seg.root.querySelectorAll("button").forEach((b) => (b.disabled = true)); + const row = (children: HTMLElement[]): HTMLElement => el("div", { className: "lstk-frow", children }); // prettier-ignore + const card = createPanelCard(L("MS_Base"), keep); + card.append( + row([ + textBox(L("MF_Name"), name, text(orig.이름), (v) => (name = v)), + textBox(L("MF_Spec"), spec, text(orig.규격), (v) => (spec = v)), + ]), + row([el("span", { className: "lstk-kind", text: L("MS_Mode") }), seg.root]), + ...(pumsem + ? [ + row(MACHINE_INPUTS.map((k) => numField(k))), + row([ + el("span", { className: "lstk-kind", text: L("MS_Coefs") }), + ...MACHINE_COEFS.filter((k) => !owned.has(k)).map((k) => numField(k, true)), + ]), + ] + : []), + ...(keys.length + ? [row(keys.map((k) => (PICKS.includes(k) ? pickField(k) : numField(k))))] + : []), + ); + baseBox.replaceChildren(card); + }; + + const similarBox = (): HTMLElement[] => { + if (!o.findSimilar || ro) return []; + const find = createFindList({ placeholder: L("MF_SimilarFind") }); + const card = createPanelCard(L("MF_Similar"), !!(orig.이름 || orig.값 && Object.keys(orig.값).length)); // prettier-ignore + card.classList.add("lstk-msheet-similar"); + card.append(find.root); + attachCollapsible(card); + let n = 0; + let wait: number | undefined; + const look = async (): Promise => { + const me = ++n; + const got = await o.findSimilar!(find.search.value.trim()).catch(() => []); + if (me !== n) return; + find.hint.textContent = got.length ? "" : L("MF_SimilarNone"); + find.setRows( + got.map((m) => ({ + key: m.key, + name: `${m.name} ${m.spec}`.trim(), + tag: m.tag, + sub: m.key, + onPick: () => { + orig = clone(m.row); + cur = clone(m.row); + name = m.name; + spec = m.spec; + errors.clear(); + info = null; + parts = null; + delete (holder as { [COVER]?: unknown })[COVER]; + card.classList.add("is-collapsed"); + setNotice(L("MF_SimilarCopied").replace("{value}", `${m.name} ${m.spec}`.trim())); + o.onSimilar?.(m); + drawAll(); + changed(true); + }, + })), + ); + }; + find.search.addEventListener("input", () => { + window.clearTimeout(wait); + wait = window.setTimeout(() => void look(), 250); + }); + void look(); + return [card]; + }; + + // ── 오른쪽 · 경고 ── + let lastAnswer: DocAnswer | null = null; + const drawRight = (): void => { + const d = sub(); + const head = el("div", { + className: "lstk__bar", + children: [ + el("strong", { text: L("MS_Out") }), + ...(info?.키 ? [el("span", { className: "ui-text ui-text--muted", text: `${info.키} ${info.이름 ?? ""}`.trim() })] : []), // prettier-ignore + ], + }); + if (!info) { + lastAnswer = null; + foot.replaceChildren(); + return void right.replaceChildren(head, createText(L("MS_Loading"), "muted")); + } + if (!d) { + lastAnswer = null; + foot.replaceChildren(); + const why = info.까닭 ? ` — ${info.까닭}` : ""; + return void right.replaceChildren(head, createText(`${L("MS_NoFormula")}${why}`, "warning")); + } + const shaped = coverDoc(d, given, coverOf(holder)); + const a = plain(shaped, given); + if (cache.takeMissing()) ask(); + lastAnswer = a; + right.replaceChildren( + head, + ...(info.까닭 ? [createText(info.까닭, "warning")] : []), + buildOut(bindDoc(shaped, info.값 ?? {}) as StackDoc, a, { + cache, + open: opened, + toggle: (at) => { + if (opened.has(at)) opened.delete(at); + else opened.add(at); + drawRight(); + }, + miss: () => ask(), + redraw: () => drawRight(), + }), + ); + foot.replaceChildren(docFoot(a)); + }; + const drawWarn = (): void => { + const vals: MachineValues = { ...engineVals(cur, mine), ...(cur.값 ?? {}) }; + warn.replaceChildren( + ...rangeWarnings(vals).map(([k, r]) => + createText(L("MS_Range").replace("{name}", label(k)).replace("{value}", r), "warning"), + ), + ); + }; + const drawLive = (): void => { + drawCtl(); + drawRight(); + drawWarn(); + }; + const drawAll = (): void => { + drawBase(); + drawLive(); + }; + const setNotice = (t: string): void => { + notice.replaceChildren(...(t ? [createText(t, "warning")] : [])); + }; + + left.append(...similarBox(), notice, warn, ctlBox, baseBox); + drawAll(); + ask(true); + + return { + root, + setNotice, + state: (): MachineSheetState => ({ + name: name.trim(), + spec: spec.trim(), + row: rowOut(), + mode: modeOf(cur, mine), + errors: [...errors.values()], + changed: JSON.stringify([name, spec, cur]) !== start, + }), + }; +} diff --git a/A00_Common/line_stack_rows.ts b/A00_Common/line_stack_rows.ts index 3011151b8..38086baee 100644 --- a/A00_Common/line_stack_rows.ts +++ b/A00_Common/line_stack_rows.ts @@ -5,7 +5,7 @@ * 고침(804-7 · 요구 687-14) = [식 고치기] 걷음 — 이름 · 식 글을 누르면 그 줄만 그 자리에서 이름 · 식 · 단위 칸 + 지움 · * 바꾸면 닫힘 · 머리 [+] = 식 줄 더함 — 이름 바꿈 = 문서 전부(`rename`) · 식 = 식 줄 · 수 = 숫자 연결 * 만약 안 탄 갈래 기호(`hide` · 요구 687-10) = 안 보임(식 글은 그대로) - * 틀 = 단가산출 결과 문서(굴착기 Q 등)를 이 문서 식으로 옮김(`unfold`) — 꼴이 바뀌면 「내 틀 (틀 이름 에서 변경)」 · + * 틀 = 단가산출 결과 문서(굴착기 Q 등)를 이 문서 식으로 옮김(`unfold` · 입력에 기계 있는 줄은 안 함 840-2) — 꼴이 바뀌면 「내 틀 (틀 이름 에서 변경)」 · * 빈 틀 = [찾아 넣기](M01 단가산출 결과 — 작업량 등) · [틀로 저장] · 내 틀 목록 걷음(요구 685) · 표 값만 = 머리 「표 값」 * 표 값(804-3) = [표 · 칸] 단추 → 표 고르기 창(`line_stack_pick.ts`) · 값 칸 누름 = 그 줄 · 칸(`pickCell`) * ========================================================================== */ @@ -23,6 +23,7 @@ import { type FormulaDoc, } from "../common_util/common_util_formula_doc"; import { isNum } from "../common_util/common_util_formula_doc_calc"; +import { machineKey } from "../common_util/common_util_formula_doc_cover"; import { decide, hasIf, tryParse } from "../common_util/common_util_formula_doc_view"; import { machineText } from "./formula_doc_options"; import { @@ -262,7 +263,8 @@ export function buildBlock( const srcTag = src === "원파일" ? lt("Src_Native") : src === "엑셀" ? lt("Src_Excel") : src; const tag = expr ? null : srcTag ? clipTag(srcTag) : sourceTag(h, id, link); const tools: HTMLElement[] = []; - if (sub?.결과 && link?.키) + // 기계 줄(입력에 기계) = 풀기 안 내놓음(840-2 · 요구 864 — 기계 값 고침은 제어 구역 한 길 · 풀린 옛 줄은 그대로 열림) + if (sub?.결과 && link?.키 && !machineKey(link.입력)) tools.push( createButton({ label: lt("Unfold"), diff --git a/M01_MasterData/M01_MasterData_Machine_Api.ts b/M01_MasterData/M01_MasterData_Machine_Api.ts index 8270f5854..b86155c3a 100644 --- a/M01_MasterData/M01_MasterData_Machine_Api.ts +++ b/M01_MasterData/M01_MasterData_Machine_Api.ts @@ -5,7 +5,7 @@ * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; -import type { MachinePick } from "@ui/ui_template_machine_form"; +import type { MachinePick } from "../common_util/common_util_machine_sheet"; import type { Row } from "./M01_MasterData_Api_Fetch"; export interface MachineLists { diff --git a/M01_MasterData/M01_MasterData_UI_MachineDetail.ts b/M01_MasterData/M01_MasterData_UI_MachineDetail.ts index 2ab3d096d..e398dcde7 100644 --- a/M01_MasterData/M01_MasterData_UI_MachineDetail.ts +++ b/M01_MasterData/M01_MasterData_UI_MachineDetail.ts @@ -1,6 +1,7 @@ /* ============================================================================= * M01_MasterData_UI_MachineDetail.ts - * M01 기계 상세 페이지(PLAN 821-2 · 요구 790-2 · 791) — 기계 양식 부품 + 원문번호 · 원문 · 구분 · + * M01 기계 상세 페이지(PLAN 821-2 · 840-2 · 요구 790-2 · 791 · 862) — 기계 보기(`line_stack_machine_sheet` · + * 왼쪽 제어 구역 + 오른쪽 시간당 경비 산출근거 · 고침 = M01 기계 줄 정본 값) + 원문번호 · 원문 · 구분 · * 상세구분 · 별칭 · 비고 · 쓰는 계산식(822-2 · 서버 식 고름) · 쓰는 곳 · 지우기(쓰는 곳 보이고 확인). * 고침은 초안(sessionStorage)에 바로 쌓임 → 페이지 [저장] 한 번에 서버로(자동저장 없음) · * 서버가 계수를 계수 짝으로 다시 셈 · 단위 · 출처 칸을 채움(`M01_MasterData_Machine.fix`). @@ -16,8 +17,8 @@ import { showToast, } from "@ui/ui_template_elements"; import { t as L } from "@ui/ui_template_locale"; -import { createMachineForm, type MachineSimilar } from "@ui/ui_template_machine_form"; import { createText } from "@ui/ui_template_text"; +import { createMachineSheet, type MachineSimilar } from "../A00_Common/line_stack_machine_sheet"; import { setLeaveGuard } from "../A00_Common/router"; import type { Row } from "./M01_MasterData_Api_Fetch"; import { addRow, editRow, peek, removeAdd, setAdd, toggleDelete } from "./M01_MasterData_Draft"; @@ -96,22 +97,19 @@ async function build(host: HTMLElement, opts: MachineDetailOptions, alive: () => name: text(r["이름"]), spec: text(r["규격"]), tag: text(r["상세구분"] || r["구분"]), - values: (r["값"] ?? {}) as Row, + row: { 이름: r["이름"], 규격: r["규격"], 값: (r["값"] ?? {}) as Row }, }; }); }; const notice = el("div"); - const form = createMachineForm({ - name: text(cur["이름"]), - spec: text(cur["규격"]), - values: (cur["값"] ?? {}) as Row, - coefMode: "pumsem", - fuels: lists.fuels, - jobs: lists.jobs, - power: lists.power, - hour: lists.hour, - findSimilar, + const form = createMachineSheet({ + orig: (orig ?? cur) as Row, + cur, + mine: false, + readOnly: false, + lists, + findSimilar: isNew ? findSimilar : undefined, onSimilar: (item) => { const r = found.get(item.key); if (!r || !isNew) return; @@ -131,7 +129,7 @@ async function build(host: HTMLElement, opts: MachineDetailOptions, alive: () => ]; notice.replaceChildren(...errors.map((e) => createText(e, "error"))); if (errors.length) return; - const row: Row = { ...base, ...cur, 이름: s.name, 규격: s.spec, 값: s.values }; + const row: Row = { ...base, ...cur, 이름: s.name, 규격: s.spec, 값: s.row.값 ?? {} }; for (const m of META) { if (orig && meta[m] === metaOrig[m]) { if (m in orig) row[m] = orig[m]; @@ -305,8 +303,6 @@ async function build(host: HTMLElement, opts: MachineDetailOptions, alive: () => }), ); paintFormula(); - if (opts.target === "new") - host.querySelector("details")?.setAttribute("open", ""); } /** 새 기계 바탕 — 칸 차례는 기계 줄과 같음 · 단위 · 출처는 서버가 채움 */ diff --git a/M02_MasterTemplete/M02_MyMachine.py b/M02_MasterTemplete/M02_MyMachine.py index 1992dcd6a..4d8f2f673 100644 --- a/M02_MasterTemplete/M02_MyMachine.py +++ b/M02_MasterTemplete/M02_MyMachine.py @@ -349,6 +349,26 @@ def _user() -> str: return user +def not_m01(raw: Any, skip: str = "") -> None: + """화면 만들기 · 고치기(840-2) — 이름 · 규격이 M01 기계와 같으면 409(내 기계 = M01 에 없는 기계만 · + 불러오기 자동 생성은 이 검사 없이 `create`).""" + raw = raw if isinstance(raw, dict) else {} + if any(h["출처"] == "M01" for h in same(raw.get("이름"), raw.get("규격"), skip)): + raise _bad( + "M01 에 있는 기계 — 값은 단가산출 안에서 고침 · 내 기계는 M01 에 없는 기계만", 409 + ) + + +def create_own(raw: Any = None, base: str | None = None) -> dict[str, Any]: + not_m01(raw) + return create(raw, base) + + +def update_own(key: str, raw: Any) -> dict[str, Any]: + not_m01(raw, key) + return update(key, raw) + + def create(raw: Any = None, base: str | None = None) -> dict[str, Any]: _user() seed = _seed(base) if base else {} diff --git a/M02_MasterTemplete/M02_MyMachine_Api.ts b/M02_MasterTemplete/M02_MyMachine_Api.ts index 7abc91b76..93fdb88f3 100644 --- a/M02_MasterTemplete/M02_MyMachine_Api.ts +++ b/M02_MasterTemplete/M02_MyMachine_Api.ts @@ -5,7 +5,7 @@ * 양식 목록 · 비슷한 기계 찾기 · 내 품목 = `M02_MyMachine_Look`(읽기만) * ========================================================================== */ -import type { MachinePick, MachineValues } from "@ui/ui_template_machine_form"; +import type { MachinePick, MachineValues } from "../common_util/common_util_machine_sheet"; import type { DocRecord } from "../common_util/common_util_formula_doc_cache"; import { call } from "./M02_MasterTemplete_Api_Fetch"; import type { M01Element } from "./M02_MasterTemplete_Find_Pick"; diff --git a/M02_MasterTemplete/M02_MyMachine_Form.ts b/M02_MasterTemplete/M02_MyMachine_Form.ts index ff48de821..6eeab3f11 100644 --- a/M02_MasterTemplete/M02_MyMachine_Form.ts +++ b/M02_MasterTemplete/M02_MyMachine_Form.ts @@ -1,26 +1,26 @@ /* ============================================================================= * M02_MyMachine_Form.ts - * M02 내 기계 양식 한 장(PLAN 821-3 · 요구서 796 1 · 3 장) — 기계 양식 부품(`ui_template_machine_form` · 821-2) + - * [저장] · [지우기] · 「개인 값」 표지 · 같은 이름 · 규격 알림 · 서버 정본 시간당 · 쓰는 곳. - * 기본 = 손료계수 「바로 넣기」 · 「시간당 값 직접」 꼴 · 직종 · 연료 = M01 목록 고르기 · 옆 계산식 즉시(계수 짝 TS) + * M02 내 기계 한 대(PLAN 821-3 · 840-2 · 요구 862) — 기계 보기(`A00_Common/line_stack_machine_sheet` · M01 기계 상세와 + * 같은 꼴: 왼쪽 제어 구역 + 기본 정보 · 손료 근거 · 오른쪽 시간당 경비 산출근거) + [저장] · [지우기] · 「개인 값」 표지 · + * 같은 이름 · 규격 알림 · 서버 정본 시간당 · 쓰는 곳. + * 내 기계 = M01 에 없는 기계를 등록하는 자리 — 이름 · 규격이 M01 기계와 같으면 알림 + 서버가 저장 막음 · + * M01 기계 값 고침은 단가산출 안 기계 제어 구역(836-1) · 「복사해 내 기계로」 걷음(840-2) * [저장] 때만 서버(자동저장 없음) — 서버가 정본으로 다시 셈한 시간당을 받아 보임 - * 새 기계 = 「비슷한 기계에서 시작」(M01 · 내 기계 · 고른 키 = 서버 `본뜬`) 또는 빈 양식 · `seed` = 품셈 기계 복사 + * 새 기계 = 「비슷한 기계에서 시작」(M01 · 내 기계 · 고른 키 = 서버 `본뜬`) 또는 빈 줄 * 지우기 = 쓰는 단가산출 이름 보이고 확인 + 「이미 만든 단가산출은 마지막 값 유지」 - * 부르는 곳 = 내 기계 화면(`M02_MyMachine_View` · 827-1 = 읽기 전용 · 지우기만) — 만들기 · 고치기는 - * 단가산출 기계 창(`A00_Common/line_stack_machine` · 초안 → 단가산출 [저장]) * ========================================================================== */ import { createBadge } from "@ui/ui_template_badge"; import { createButton } from "@ui/ui_template_button"; import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; -import { - createMachineForm, - HOURLY_KEY, - type MachineFormState, - type MachineSimilar, - type MachineValues, -} from "@ui/ui_template_machine_form"; +import { t as L } from "@ui/ui_template_locale"; import { createText } from "@ui/ui_template_text"; +import { + createMachineSheet, + type MachineSheetState, + type MachineSimilar, +} from "../A00_Common/line_stack_machine_sheet"; +import type { MachineRow } from "../common_util/common_util_machine_sheet"; import { createMyMachine, deleteMyMachine, @@ -29,7 +29,6 @@ import { readMyMachine, updateMyMachine, type MyHourly, - type MyMachine, type MyMachineBody, type MyMachineOne, type SimilarRow, @@ -41,12 +40,6 @@ export interface MyMachineFormOptions { owner: string | null; /** 고칠 내 기계 키 — 없으면 새 기계 */ key?: string; - /** 새 기계 바탕(복사해 내 기계로) — 값 · 이름 · 규격 · 본뜬 키 */ - seed?: SimilarRow; - /** 새 기계면 「비슷한 기계에서 시작」 을 펼침 */ - openSimilar?: boolean; - /** 827-1 보기만(양식 읽기 전용 · [저장] 없음 · [지우기] 그대로) */ - readOnly?: boolean; onSaved: (got: MyMachineOne) => void; onDeleted?: () => void; onBack?: () => void; @@ -60,29 +53,32 @@ export interface MyMachineFormHandle { const won = (v: number | undefined): string => `${(v ?? 0).toLocaleString("ko-KR", { maximumFractionDigits: 2 })}${mt("Won")}`; +const RATIOS = ["내용시간", "연간표준가동시간", "상각비율", "정비비율", "연간관리비율"]; -/** 줄(저장 꼴) → 양식 값 묶음 — 직접 = 시간당 칸 하나 */ -export function valuesOf(row: Pick): MachineValues { - return row.방식 === "직접" ? { [HOURLY_KEY]: { ...(row.시간당 ?? {}) } } : { ...row.값 }; +/** 찾은 줄 → 내 기계 줄 꼴(M01 줄 = 비율 다섯 있으면 품셈 · 없으면 바로 — 손료계수 = 계수 셋 합) */ +export function rowOfSimilar(r: SimilarRow): MachineRow { + if (r.출처 === "개인") return { 이름: r.이름, 규격: r.규격, 방식: r.방식 ?? "바로", 값: { ...(r.값 ?? {}) }, 시간당: r.시간당 }; // prettier-ignore + const v = { ...(r.값 ?? {}) }; + const how = RATIOS.every((k) => typeof v[k] === "number" && v[k]) ? "품셈" : "바로"; + if (how === "바로" && typeof v.손료계수 !== "number") + v.손료계수 = ["상각비계수", "정비비계수", "관리비계수"].reduce( + (s, k) => s + (Number(v[k]) || 0), + 0, + ); + return { 이름: r.이름, 규격: r.규격, 방식: how, 값: v }; } -/** 양식 상태 → 보낼 몸(틀림 = 글 목록) — 내 기계 양식 · 기계 창 [확인] 한 벌 */ -export function bodyOfForm(s: MachineFormState): { body: MyMachineBody | null; errors: string[] } { +/** 보기 상태 → 보낼 몸(틀림 = 글 목록) */ +export function bodyOfSheet(s: MachineSheetState): { + body: MyMachineBody | null; + errors: string[]; +} { const errors = [...s.errors, ...(s.name ? [] : [mt("Name_Needed")])]; if (errors.length) return { body: null, errors }; - if (s.hourly) - return { - body: { - 이름: s.name, - 규격: s.spec, - 방식: "직접", - 시간당: (s.values[HOURLY_KEY] ?? {}) as MyMachineBody["시간당"], - }, - errors, - }; - const { [HOURLY_KEY]: _drop, ...vals } = s.values; - const how = s.mode === "pumsem" ? "품셈" : "바로"; - return { body: { 이름: s.name, 규격: s.spec, 방식: how, 값: vals }, errors }; + const head = { 이름: s.name, 규격: s.spec }; + if (s.mode === "직접") + return { body: { ...head, 방식: "직접", 시간당: (s.row.시간당 ?? {}) as MyMachineBody["시간당"] }, errors }; // prettier-ignore + return { body: { ...head, 방식: s.mode, 값: s.row.값 ?? {} }, errors }; } /** 서버 시간당 한 줄 글 */ @@ -100,41 +96,30 @@ export async function mountMyMachineForm( opt.key ? readMyMachine(opt.owner, opt.key) : Promise.resolve(null), ]); const row = one?.줄; - const seed = opt.seed; - let base = seed && seed.출처 === "M01" ? seed.키 : undefined; // 서버 `본뜬` - const found = new Map(); + let base: string | undefined; // 서버 `본뜬` const notice = el("div", { className: "m02-mymach__notice" }); const server = el("div", { className: "m02-mymach__server" }); const usesBox = el("div", { className: "m02-mymach__uses" }); + const start: MachineRow = row + ? { 키: row.키, 원문번호: row.원문번호, 이름: row.이름, 규격: row.규격, 방식: row.방식, 값: row.값, ...(row.시간당 ? { 시간당: row.시간당 } : {}) } // prettier-ignore + : { 이름: "", 규격: "", 방식: "바로", 값: {} }; - const form = createMachineForm({ - name: row?.이름 ?? seed?.이름 ?? "", - spec: row?.규격 ?? seed?.규격 ?? "", - values: row - ? valuesOf(row) - : seed - ? valuesOf({ 방식: seed.방식 ?? "바로", 값: seed.값 ?? {}, 시간당: seed.시간당 }) - : {}, - coefMode: "direct", - allowHourly: true, - fuels: lists.fuels, - jobs: lists.jobs, - power: lists.power, - hour: lists.hour, - readOnly: opt.readOnly, + let m01Same = false; + const sheet = createMachineSheet({ + orig: start, + mine: true, + readOnly: false, + lists, findSimilar: row ? undefined : async (q): Promise => - (await findSimilar(opt.owner, q)).map((r) => { - found.set(r.키, r); - return { - key: r.키, - name: r.이름, - spec: r.규격, - tag: r.출처 === "개인" ? mt("Mine") : r.구분, - values: valuesOf({ 방식: r.방식 ?? "바로", 값: r.값 ?? {}, 시간당: r.시간당 }), - }; - }), + (await findSimilar(opt.owner, q)).map((r) => ({ + key: r.키, + name: r.이름, + spec: r.규격, + tag: r.출처 === "개인" ? mt("Mine") : r.구분, + row: rowOfSimilar(r), + })), onSimilar: (item) => { base = item.key; checkSame(); @@ -146,42 +131,42 @@ export async function mountMyMachineForm( }, }); - let saved = !opt.key && seed ? false : true; - const dirty = (): boolean => form.state().changed || !saved; + let saved = true; + const dirty = (): boolean => sheet.state().changed || !saved; - /* --- 같은 이름 · 규격 알림(M01 · 내 기계) --- */ + /* --- 같은 이름 · 규격 알림(M01 = 막음 · 내 기계 = 알림) --- */ let timer: number | undefined; let seq = 0; const sameText = (hits: { 키: string; 출처: string }[]): string => hits.length - ? mt("Same").replace("{value}", hits.map((h) => `${h.출처} ${h.키}`).join(" · ")) + ? [ + ...(hits.some((h) => h.출처 === "M01") ? [L("MS_M01Same")] : []), + mt("Same").replace("{value}", hits.map((h) => `${h.출처} ${h.키}`).join(" · ")), + ].join(" — ") : ""; const checkSame = (): void => { window.clearTimeout(timer); timer = window.setTimeout(async () => { const mine = ++seq; - const s = form.state(); + const s = sheet.state(); const rows = s.name ? await findSimilar(opt.owner, s.name).catch(() => []) : []; if (mine !== seq) return; const hits = rows.filter( (r) => r.키 !== opt.key && r.이름.trim() === s.name && (r.규격 ?? "").trim() === s.spec, ); - form.setNotice(sameText(hits)); + m01Same = hits.some((h) => h.출처 === "M01"); + saveBtn.disabled = m01Same; + sheet.setNotice(sameText(hits)); }, 300); }; /* --- 저장 --- */ - const bodyOf = (): MyMachineBody | null => { - const { body, errors } = bodyOfForm(form.state()); - notice.replaceChildren(...errors.map((e) => createText(e, "error"))); - return body; - }; const saveBtn = createButton({ label: mt("Save"), variant: "filled", size: 28 }); saveBtn.dataset.mymachSave = ""; - saveBtn.hidden = !!opt.readOnly; saveBtn.addEventListener("click", async () => { - const body = bodyOf(); - if (!body) return; + const { body, errors } = bodyOfSheet(sheet.state()); + notice.replaceChildren(...errors.map((e) => createText(e, "error"))); + if (!body || m01Same) return; saveBtn.disabled = true; try { const got = opt.key @@ -196,7 +181,7 @@ export async function mountMyMachineForm( createText(error instanceof Error ? error.message : mt("Failed"), "error"), ); } finally { - saveBtn.disabled = false; + saveBtn.disabled = m01Same; } }); @@ -238,7 +223,11 @@ export async function mountMyMachineForm( server.replaceChildren( ...(one ? [createText(`${mt("Server_Hourly")} ${hourlyText(one.시간당)}`)] : []), ); - if (one?.같은이름.length) form.setNotice(sameText(one.같은이름)); + if (one?.같은이름.length) { + m01Same = one.같은이름.some((h) => h.출처 === "M01"); + saveBtn.disabled = m01Same; + sheet.setNotice(sameText(one.같은이름)); + } const uses = one?.쓰는곳 ?? []; usesBox.replaceChildren( el("strong", { text: `${mt("Uses")} ${uses.length}` }), @@ -261,19 +250,13 @@ export async function mountMyMachineForm( delBtn, ], }), - createText(mt(opt.readOnly ? "View_Note" : "Own_Note"), "muted"), + createText(mt("Own_Note"), "muted"), notice, server, - form.root, + sheet.root, ...(opt.key ? [usesBox] : []), ], }); host.replaceChildren(root); - if (!row && (opt.openSimilar ?? !seed)) - root.querySelector("details")?.setAttribute("open", ""); - if (seed) { - form.setNotice(mt("Copied").replace("{value}", `${seed.이름} ${seed.규격}`.trim())); - opt.onDirty?.(true); - } return { root, dirty }; } diff --git a/M02_MasterTemplete/M02_MyMachine_Look.py b/M02_MasterTemplete/M02_MyMachine_Look.py index e288f4955..4b5638572 100644 --- a/M02_MasterTemplete/M02_MyMachine_Look.py +++ b/M02_MasterTemplete/M02_MyMachine_Look.py @@ -2,12 +2,14 @@ GET `/api/m02/my-machine-lists` = 양식 고르기(연료 · 직종 · 전력 단가 · 시간환산) — M01 기계 화면과 같은 값(`M01_MasterData_Machine.lists` · 그 길은 시스템 관리자만). -GET `/api/m02/layers/{층}/my-machine-find?q=&owner=` = 「비슷한 기계에서 시작」 · - 「복사해 내 기계로」 — 그 층 내 기계(앞) + M01 기계(`M01_MasterData_Machine.find`) · +GET `/api/m02/layers/{층}/my-machine-find?q=&owner=` = 「비슷한 기계에서 시작」 · 같은 이름 알림 — + 그 층 내 기계(앞) + M01 기계(`M01_MasterData_Machine.find`) · q 가 M01 원문번호와 같으면 그 기계 먼저. GET `/api/m02/layers/{층}/my-items?owner=` = 내 품목 — 그 층 단가산출 수식 문서 재료비 줄 가운데 단가가 우리 자재에 안 이어진 것(단가 변수 연결 = 숫자 · 입력) · (이름 · 규격 · 단위 · 값) 묶음마다 쓰는 단가산출. +POST `/api/m02/machine-sheet?owner=` 몸 {줄, 본?, 덮음?, 개인?} = 기계 보기(840-2 · `M02_MyMachine_Sheet` · 쓰기 없음 · + 개인 = 그 개인 층 · 아니면 M01 만). 권한 = 내 기계 길과 같음(`M02_MyMachine_Router._folder` — 개인 층 · 본인 · 맡은 회사 관리자 읽기). ⚠ 등록은 `main.py`(로그인만). """ @@ -18,6 +20,7 @@ import json from typing import Any from fastapi import APIRouter, Depends +from pydantic import BaseModel from common_util.common_util_auth import verify_session from M01_MasterData import M01_MasterData_Store as m01 @@ -125,3 +128,21 @@ def get_items( layer: str, owner: str | None = None, session: Session = Depends(verify_session) ) -> dict: return {"rows": _call(_folder(session, layer, owner), items)} + + +class SheetBody(BaseModel): + 줄: dict[str, Any] + 본: dict[str, Any] | None = None + 덮음: dict[str, Any] | None = None + 개인: bool = False + + +@router.post("/machine-sheet") +def post_sheet( + body: SheetBody, owner: str | None = None, session: Session = Depends(verify_session) +) -> dict: + """기계 보기(840-2 · `M02_MyMachine_Sheet`) — 개인 = 내 기계(그 층) · 아니면 M01 기계만 · 쓰기 없음.""" + from M02_MasterTemplete import M02_MyMachine_Sheet as ms + + folder = _folder(session, "personal", owner) if body.개인 else store.FOLDER + return _call(folder, ms.sheet, body.줄, body.본, body.덮음, body.개인) diff --git a/M02_MasterTemplete/M02_MyMachine_Router.py b/M02_MasterTemplete/M02_MyMachine_Router.py index 45efc3d96..25a9c78fd 100644 --- a/M02_MasterTemplete/M02_MyMachine_Router.py +++ b/M02_MasterTemplete/M02_MyMachine_Router.py @@ -1,7 +1,7 @@ """M02 내 기계 길(PLAN 821-1 · `M02_MyMachine`) — `/api/m02/layers/personal/my-machines`(?owner=). GET 목록(줄마다 시간당 · 쓰는곳수) · GET `/{키}` 하나(+시간당 · 쓰는곳 · 같은이름) · -POST 만들기 몸 {줄?, 본뜬?: M01 · 내 기계 키(「비슷한 기계에서 시작」 · 「M01 기계 복사해 만들기」)} · +POST 만들기 몸 {줄?, 본뜬?: M01 · 내 기계 키(「비슷한 기계에서 시작」)} · 만들기 · 고치기 = M01 과 같은 이름 · 규격 409(840-2) · PUT `/{키}` 몸 {줄} · DELETE `/{키}`(답 = 쓰는곳 — 그 단가산출은 사본의 마지막 값 그대로) · POST `/draft` 몸 {줄, 키?, 본뜬?} = 단가산출 기계 창 [확인](827-1 · 쓰기 없음 · 층 쓰기 권한) → {줄, 셈값, 시간당}. 권한 = 층 권한 한 벌(`Router_Items._root` — 본인 개인 층 · 맡은 회사 관리자) · 개인 층만(그 밖 404). @@ -66,7 +66,7 @@ def my_machine_create( owner: str | None = None, session: Session = Depends(verify_session), ): - return _call(_folder(session, layer, owner, True), mym.create, body.줄, body.본뜬) + return _call(_folder(session, layer, owner, True), mym.create_own, body.줄, body.본뜬) @router.put(PATH + "/{key}") @@ -77,7 +77,7 @@ def my_machine_update( owner: str | None = None, session: Session = Depends(verify_session), ): - return _call(_folder(session, layer, owner, True), mym.update, key, body.줄) + return _call(_folder(session, layer, owner, True), mym.update_own, key, body.줄) @router.delete(PATH + "/{key}") diff --git a/M02_MasterTemplete/M02_MyMachine_Sheet.py b/M02_MasterTemplete/M02_MyMachine_Sheet.py new file mode 100644 index 000000000..fe0b254ea --- /dev/null +++ b/M02_MasterTemplete/M02_MyMachine_Sheet.py @@ -0,0 +1,90 @@ +"""기계 보기 서버 몫(PLAN 840-2 · 요구 853 · 862) — M01 기계 상세 · 내 기계가 같이 씀 · 쓰기 없음. + +`sheet(줄, 본, 덮음, 개인)` → {키, 이름, 까닭, 문서, 값, 연결, 직종} +- 식 고름 = `M02_Machine_Hourly.pick`(지금 줄 · 내 기계 = 셈 꼴 `M02_MyMachine.engine`) · 못 고름 = 키 없음 + 까닭 +- 문서 = 그 식 수식 문서(그대로) · 값 = `본`(처음 줄 · 없으면 줄)의 셈 꼴 값 칸 — 셈 차례 = 덮음(`기계덮음` 꼴 · + 836-1) 얹음 → 값 박음(`bind` — 남은 요소 `EQ:{S_기계}` 칸 → 숫자 · 저장 전 새 기계 · 내 기계도 같은 계산기로 셈) +- 파이썬 · TS 짝 — `bind` ↔ `common_util/common_util_machine_sheet.ts` `bindDoc` · 거울 시험 test_840_2_machine_sheet.py +- 연결 = 덮음 얹은 문서 셈이 쓴 요소 · 표 · 문서(`master_doc_record`) — 화면이 같은 계산기로 즉시 다시 셈 +- 직종 = 노임 요소 코드 → 인력 원문번호(조종원 줄 → 기계 줄 `조종원직종`) +""" + +from __future__ import annotations + +import copy +import json +from decimal import Decimal +from typing import Any + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_MyMachine as mym + + +def _plain(v: Any) -> Any: + if isinstance(v, Decimal): + return int(v) if v == v.to_integral_value() else float(v) + return v + + +def bind(doc: dict, vals: dict) -> dict: + """식 문서 → 기계 값 칸을 숫자로 박은 새 문서(그 칸이 없는 줄은 요소 그대로).""" + out = copy.deepcopy(doc) + for v in out.get("변수") or []: + link = v.get("연결") or {} + key = str(link.get("키") or "") + col = link.get("칸") + if ( + link.get("갈래") == "요소" + and key.startswith("EQ:") + and "{S_기계}" in key + and col in vals + ): + v["연결"] = {"갈래": "숫자", "값": vals[col]} + return out + + +def _shape(row: dict, mine: bool) -> dict: + if mine: + return mym.engine(row) + return {**row, "값": m01._dec(row.get("값") or {})} + + +def _jobs() -> dict[str, str]: + rows = (m01.loaded()[0].get("인력.json") or {}).get("줄") or [] + return {str(r["키"]): str(r.get("원문번호")) for r in rows if r.get("키")} + + +def sheet(row: Any, base: Any = None, cover: Any = None, mine: bool = False) -> dict: + import master_doc_calc as mc + import master_doc_cover as mcv + import master_doc_link as ml + import master_doc_record as mr + + from M01_MasterData import M01_MasterData_Doc as m01doc + from M02_MasterTemplete import M02_Machine_Hourly as mh + + if not isinstance(row, dict): + raise m01.StoreError(400, "기계 줄이 묶음 아님") + cur = _shape(row, mine) + logic = mh.pick(cur) + out: dict[str, Any] = {"직종": _jobs()} + doc = m01doc.docs().get(str(logic["키"])) if logic else None + if doc is None: + why = mh.why(cur, logic) or ("식 문서 없음" if logic else "식 없음") + return out | {"키": None, "까닭": why, "문서": None, "값": {}, "연결": {}} + vals = _shape(base if isinstance(base, dict) else row, mine).get("값") or {} + no = str(row.get("원문번호") or row.get("키") or "새 기계") + given = {"기계": no} + entry = cover if isinstance(cover, dict) and cover else None + try: + covered = mcv.cover_doc(doc, given, mcv.clean({no: m01._dec(entry)})) if entry else doc + except ValueError as e: + raise m01.StoreError(400, str(e)) from None + shaped = bind(covered, vals) + links = ml.Links(mym.whole(), m01doc.docs()) + rec = mr.Rec(links) + rec.prefetch(shaped) + mc.calc(shaped, rec, given) + got = {"키": logic["키"], "이름": logic.get("이름"), "까닭": mh.why(cur, logic), "문서": doc, + "값": vals, "연결": rec.out()} # fmt: skip + return json.loads(json.dumps(out | got, ensure_ascii=False, default=_plain)) diff --git a/M02_MasterTemplete/M02_MyMachine_Text.ts b/M02_MasterTemplete/M02_MyMachine_Text.ts index b4f313ab9..2461e0a48 100644 --- a/M02_MasterTemplete/M02_MyMachine_Text.ts +++ b/M02_MasterTemplete/M02_MyMachine_Text.ts @@ -18,7 +18,7 @@ const TEXT = { Uses_Count: "쓰는 곳", Made: "넣은 날", Edited: "고친 날", - None: "내 기계 없음 — 단가산출의 기계 줄에서 [고쳐 쓰기] 로 만듦", + None: "내 기계 없음 — [새 기계] 로 M01 에 없는 기계를 등록", Save: "저장", Saved: "내 기계 저장 — 서버가 시간당을 다시 셈", Delete: "지우기", @@ -50,7 +50,8 @@ const TEXT = { // 기계 고르기 창 · 줄 쌓기 Pick_Group: "내 기계", Pick_Direct: "시간당 경비 식이 없는 기계 — 줄 쌓기에 못 넣음", - View_Note: "보기 · 쓰는 곳 · 지우기만 — 만들기 · 고치기는 단가산출의 기계 줄에서", + View_Note: + "M01 에 없는 기계를 등록하는 자리 · 줄 누름 = 열고 고침 · M01 기계 값은 단가산출 안 기계 제어 구역에서 고침", // 단가산출 기계 창(827-1) Win_Title: "기계", Win_Kind: "구분", diff --git a/M02_MasterTemplete/M02_MyMachine_View.ts b/M02_MasterTemplete/M02_MyMachine_View.ts index d0dcd6a0f..7c777a8a2 100644 --- a/M02_MasterTemplete/M02_MyMachine_View.ts +++ b/M02_MasterTemplete/M02_MyMachine_View.ts @@ -2,11 +2,12 @@ * M02_MyMachine_View.ts * M02 「내 기계」 화면(PLAN 821-3 · 요구서 796 3 장) — 좌측 「개인관리」 › [기계] 단추로 가운데에 뜸(개인 층). * 목록 = 이름 · 규격 · 방식 · 시간당 경비(서버 셈) · 쓰는 곳 수 · 넣은 날 · 고친 날 · 찾기 - * 줄 누름 = 양식 한 장(`M02_MyMachine_Form` · 읽기 전용) · 쓰는 곳 · [지우기] → 목록 다시 - * 만들기 · 고치기 없음(827-1) — 단가산출 기계 줄의 기계 창 [고쳐 쓰기] → 단가산출 [저장] 때 내 기계로 + * 줄 누름 = 기계 보기 한 장(`M02_MyMachine_Form` · 840-2 M01 기계 상세와 같은 꼴) · 고침 · [저장] · 쓰는 곳 · [지우기] + * [새 기계] = M01 에 없는 기계 등록(비슷한 기계에서 시작 · M01 과 같은 이름 · 규격 = 막음) · 불러오기 자동 생성은 그대로 * ========================================================================== */ import { createDataTable } from "@ui/ui_template_data_table"; +import { createButton } from "@ui/ui_template_button"; import { createInputField, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { createText } from "@ui/ui_template_text"; import { listMyMachines, type MyMachineRow } from "./M02_MyMachine_Api"; @@ -71,7 +72,20 @@ export function buildMyMachineView(opt: MyMachineViewOptions): HTMLElement { const list = el("div", { className: "m02-mymach-view__list", children: [ - el("div", { className: "ui-toolbar", children: [search.root, hint] }), + el("div", { + className: "ui-toolbar", + children: [ + search.root, + hint, + createButton({ + label: mt("New"), + variant: "filled", + size: 28, + iconStart: "plus", + onClick: () => void openForm(), + }), + ], + }), createText(mt("View_Note"), "muted"), fail, table.root, @@ -94,14 +108,13 @@ export function buildMyMachineView(opt: MyMachineViewOptions): HTMLElement { draw(); } - async function openForm(key: string): Promise { + async function openForm(key?: string): Promise { const slot = el("div"); host.replaceChildren(slot); try { const h = await mountMyMachineForm(slot, { owner: opt.owner, key, - readOnly: true, onSaved: (got) => void openForm(got.줄.키), onDeleted: () => void showList(), onBack: async () => { diff --git a/common_util/common_util_machine_sheet.ts b/common_util/common_util_machine_sheet.ts new file mode 100644 index 000000000..8ca59f4bf --- /dev/null +++ b/common_util/common_util_machine_sheet.ts @@ -0,0 +1,185 @@ +/* ============================================================================= + * common_util_machine_sheet.ts + * 기계 보기 셈 꼴(PLAN 840-2 · 요구 853 · 862) — 기계 줄(저장 꼴) ↔ 식이 읽는 값 · 덮음(836-1 `기계덮음` 꼴) ↔ 줄 + * 셈 꼴(`engineVals`) = M01 = 값 그대로 · 내 기계 = 바로 → 상각비계수 ← 손료계수 · 정비 · 관리 0 · + * 직접 → 노무비 · 재료비 · 경비 · 빈 연료 · 조종원 칸 = 0(서버 `M02_MyMachine.engine` 과 같은 꼴) + * 덮음 ← 줄 차이(`coverFrom` — 처음 줄 대 지금 줄) · 지금 줄 ← 덮음(`applyCover` — 제어 구역이 맡은 칸만 · + * 덮음이 없는 칸 = 처음 줄 값 그대로 = 안 고치고 닫으면 줄 바이트 같음) + * 제어 구역이 맡은 칸(`ownedCols`) = 식이 읽는 값 칸 + 조종원 · 조종원직종 + (잡품 줄 있는 식) 잡재료율 + * 값 박음(`bindDoc`) = 식 문서 요소 `EQ:{S_기계}` 칸 → 숫자(처음 줄 값) — 셈 차례 = 덮음 얹음(`coverDoc`) → 값 박음 + * 파이썬 · TS 짝(`bindDoc`) — 짝 = M02_MasterTemplete/M02_MyMachine_Sheet.py `bind` · 거울 시험 test_840_2_machine_sheet.py + * · 화면 · DOM · fetch 없음 + * ========================================================================== */ + +import type { FormulaDoc, MoneyLine } from "./common_util_formula_doc"; +import type { CoverParts, MachineCover } from "./common_util_formula_doc_cover"; +import { MACHINE_INPUTS } from "./common_util_machine_coef"; + +export type MachineValues = Record; +/** 고르기 목록 한 칸 — 연료(값 = 종류) · 직종(값 = 인력 원문번호) */ +export interface MachinePick { + value: string; + text: string; + price: number | null; +} +export type MachineMode = "바로" | "품셈" | "직접"; +/** 기계 줄(M01 기계 줄 · 내 기계 줄 꼴) — 이름 · 규격 · 값 · (내 기계) 방식 · 시간당 */ +export interface MachineRow { + 이름?: unknown; + 규격?: unknown; + 값?: MachineValues; + 방식?: MachineMode; + 시간당?: MachineValues; + [k: string]: unknown; +} + +export const HOURLY = ["노무비", "재료비", "경비"] as const; +const CREW = ["조종원", "조종원직종"]; +const MISC = "잡재료율"; + +/** 보통 범위(옛 기계 양식과 같음) — 밖이면 경고만 */ +export const RANGE: Record = { + 가격: [0, 5_000_000], 손료계수: [1, 100_000], 내용시간: [100, 100_000], 연간표준가동시간: [100, 8_760], + 상각비율: [0, 1], 정비비율: [0, 3], 연간관리비율: [0, 1], 주연료: [0, 2_000], 잡재료율: [0, 100], + 조종원: [0, 20], 전력: [0, 10_000], +}; // prettier-ignore + +const clone = (v: T): T => JSON.parse(JSON.stringify(v ?? null)) as T; +const blank = (v: unknown): boolean => v === undefined || v === null || v === ""; + +/** 식 문서 → 기계 값 칸을 숫자로 박은 새 문서(그 칸이 없는 줄은 요소 그대로) */ +export function bindDoc(doc: FormulaDoc, vals: MachineValues): FormulaDoc { + const out = clone(doc); + for (const v of out.변수 ?? []) { + const l = v.연결 ?? {}; + const key = String(l.키 ?? ""); + const col = l.칸; + if ( + l.갈래 === "요소" && + key.startsWith("EQ:") && + key.includes("{S_기계}") && + col && + col in vals + ) + v.연결 = { 갈래: "숫자", 값: vals[col] as number | string }; + } + return out; +} + +/** 같은 값 — 수는 작은 차이 무시 · 글은 글로 */ +export function sameVal(a: unknown, b: unknown): boolean { + if (blank(a) && blank(b)) return true; + if (typeof a === "number" && typeof b === "number") + return Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(a), Math.abs(b)); + return String(a ?? "") === String(b ?? ""); +} + +export function modeOf(row: MachineRow, mine: boolean): MachineMode { + if (mine) return row.방식 ?? "바로"; + return MACHINE_INPUTS.some((k) => typeof row.값?.[k] === "number") ? "품셈" : "바로"; +} + +/** 식이 읽는 값 묶음(셈 꼴) */ +export function engineVals(row: MachineRow, mine: boolean): MachineValues { + const v: MachineValues = { ...(row.값 ?? {}) }; + if (!mine) return v; + const how = row.방식 ?? "바로"; + if (how === "직접") return Object.fromEntries(HOURLY.map((k) => [k, row.시간당?.[k] ?? 0])); + if (how === "바로") + Object.assign(v, { 상각비계수: v.손료계수 || 0, 정비비계수: 0, 관리비계수: 0 }); + return { 주연료: 0, 잡재료율: 0, 조종원: 0, 주연료종류: "", 조종원직종: "", ...v }; +} + +/** 셈 꼴 칸 → 저장 꼴 자리 · null = 저장 안 함(내 기계 바로의 정비 · 관리 = 늘 0) */ +export function slotOf( + col: string, + mode: MachineMode, + mine: boolean, +): ["값" | "시간당", string] | null { + if (!mine) return ["값", col]; + if (mode === "직접") return (HOURLY as readonly string[]).includes(col) ? ["시간당", col] : null; + if (mode === "바로" && col === "상각비계수") return ["값", "손료계수"]; + if (mode === "바로" && (col === "정비비계수" || col === "관리비계수")) return null; + return ["값", col]; +} + +/** 제어 구역이 맡은 칸(셈 꼴 이름) */ +export const ownedCols = (parts: CoverParts): string[] => [ + ...parts.fields.map((f) => f.칸), + ...CREW, + ...(parts.misc !== null ? [MISC] : []), +]; + +/** 처음 줄 대 지금 줄 → 덮음(조종원 = 「LB:직종」 한 줄 · 잡품 = 밑 줄마다 같은 율) — 정리는 `putCover` 몫 */ +export function coverFrom( + parts: CoverParts, + lines: MoneyLine[], + orig: MachineRow, + cur: MachineRow, + mine: boolean, +): MachineCover { + const o = engineVals(orig, mine); + const c = engineVals(cur, mine); + const out: MachineCover = {}; + const 값: Record = {}; + for (const { 칸 } of parts.fields) + if (!blank(c[칸]) && !sameVal(c[칸], o[칸])) 값[칸] = c[칸] as number | string; + if (Object.keys(값).length) out.값 = 값; + if (CREW.some((k) => !sameVal(c[k], o[k]))) { + const n = Number(c.조종원) || 0; + out.조종원 = n > 0 && !blank(c.조종원직종) ? [{ 키: `LB:${String(c.조종원직종)}`, 인: n }] : []; + } + if (parts.misc !== null && !sameVal(c[MISC], o[MISC])) { + const r = Number(c[MISC]) || 0; + out.잡품 = r ? parts.base.map((i) => ({ 줄: String(lines[i]?.이름 ?? ""), 율: r })) : []; + } + return out; +} + +/** 노임 요소 키 → 인력 원문번호(`LB:1048` → 1048 · `LB000048` → 직종 표) */ +export const jobCode = (key: string, jobs: Record): string => + key.startsWith("LB:") ? key.slice(3) : (jobs[key] ?? key); + +/** 덮음 → 지금 줄(제어 구역이 맡은 칸만 · 덮음 없는 칸 = 처음 줄 저장 값으로) — 새 줄 */ +export function applyCover( + parts: CoverParts, + orig: MachineRow, + cur: MachineRow, + entry: MachineCover, + mine: boolean, + jobs: Record, +): MachineRow { + const mode = modeOf(cur, mine); + const next = clone(cur); + const put = (col: string, v: unknown): void => { + const at = slotOf(col, mode, mine); + if (!at) return; + const [part, key] = at; + const box = (next[part] ??= {}) as MachineValues; + if (v === undefined) { + const was = (orig[part] ?? {}) as MachineValues; + if (key in was) box[key] = clone(was[key]); + else delete box[key]; + } else if (!(key in box) || !sameVal(box[key], v)) box[key] = v; + }; + for (const { 칸 } of parts.fields) put(칸, entry.값?.[칸]); + if (entry.조종원 === undefined) CREW.forEach((k) => put(k, undefined)); + else { + const r = entry.조종원[0]; + put("조종원", r ? r.인 : 0); + put("조종원직종", r ? jobCode(r.키, jobs) : undefined); + } + if (parts.misc !== null) + put(MISC, entry.잡품 === undefined ? undefined : (entry.잡품[0]?.율 ?? 0)); + return next; +} + +/** 범위 밖 칸 — [칸, 범위 글] */ +export function rangeWarnings(values: MachineValues): [string, string][] { + return Object.entries(RANGE).flatMap(([k, [lo, hi]]) => { + const v = values[k]; + return typeof v === "number" && (v < lo || v > hi) + ? [[k, `${lo}~${hi}`] as [string, string]] + : []; + }); +} diff --git a/resources/tester/helper_840_2_machine_sheet.cjs b/resources/tester/helper_840_2_machine_sheet.cjs new file mode 100644 index 000000000..e83f17167 --- /dev/null +++ b/resources/tester/helper_840_2_machine_sheet.cjs @@ -0,0 +1,35 @@ +/* 840-2 기계 보기 셈 꼴 헬퍼 — common_util_machine_sheet.ts · common_util_formula_doc_cover.ts 를 그 자리에서 트랜스파일해 Node 로 돌림. + * stdin = {문서들: {식 키: 식 문서}, 대상: [{식, 처음, 지금?, 덮음?, 개인, 직종?, 박음?: 값}]} + * stdout = [{덮음: coverFrom(처음, 지금 | 처음), 줄: applyCover(처음, 지금 | 처음, 덮음 | coverFrom), 박음?: bindDoc(식, 박음)}] */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const cache = new Map(); +function load(at) { + if (cache.has(at)) return cache.get(at); + const js = ts.transpileModule(fs.readFileSync(at, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(at, box.exports); + const need = (p) => + p.startsWith(".") ? load(path.resolve(path.dirname(at), `${p}.ts`)) : require(p); + new Function("exports", "module", "require", js)(box.exports, box, need); + return box.exports; +} +const root = path.join(__dirname, "..", "..", "common_util"); +const cover = load(path.join(root, "common_util_formula_doc_cover.ts")); +const sheet = load(path.join(root, "common_util_machine_sheet.ts")); + +const ask = JSON.parse(fs.readFileSync(0, "utf8")); +const out = ask.대상.map((t) => { + const doc = ask.문서들[t.식]; + const parts = cover.partsOf(doc); + const cur = t.지금 ?? t.처음; + const made = sheet.coverFrom(parts, doc.금액 ?? [], t.처음, cur, t.개인); + const use = t.덮음 ?? made; + const row = sheet.applyCover(parts, t.처음, cur, use, t.개인, t.직종 ?? {}); + return { 덮음: made, 줄: row, ...(t.박음 ? { 박음: sheet.bindDoc(doc, t.박음) } : {}) }; +}); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_821_3_my_machine_screen.py b/resources/tester/test_821_3_my_machine_screen.py index 10039bce0..eb6f50add 100644 --- a/resources/tester/test_821_3_my_machine_screen.py +++ b/resources/tester/test_821_3_my_machine_screen.py @@ -111,7 +111,7 @@ def _stack_doc(key, no): def test_stack_line_copy(world): """⑤ 줄 쌓기로 고른 내 기계 — 미리 셈 됨 · 저장 때 값 사본에 박힘 → - 고쳐도 금액 그대로 + 단가새값 → [단가 갱신] 바뀐 자원 1 → 지워도 마지막 값 + 지운키.""" + 고치면 자동 따라옴(836-2 · 갱신 할 것 없음) → 지워도 저장한 사본 값 + 지운키.""" from M02_MasterTemplete import M02_Doc_Sk_Path as path from M02_MasterTemplete import M02_Labor_Ref as refs @@ -131,10 +131,10 @@ def test_stack_line_copy(world): copy = _at(me, lambda: store.read("sangeun", sk)["문서"]["수식"]) assert key in [r["키"] for r in copy["단가"]] # 값 사본에 박힘 _at(me, mym.update, key, {**made["줄"], "값": {**made["줄"]["값"], "가격": 3000}}) - now = _at(me, path.read, sk) - assert now["셈"]["계"] == was and now["사본"]["단가새값"] is True + now = _at(me, path.read, sk) # 836-2 프로젝트 밖 SK = 자동 따라옴(새 값 배지 · 갱신 할 것 없음) + assert now["셈"]["계"] > was and now["사본"]["자동"] and not now["사본"]["단가새값"] job = _at(me, path.preview, sk, {"할일": "단가갱신"}) - assert [x["키"] for x in job["바뀐자원"]] == [key] and job["후"]["계"] > was + assert job["바뀐자원"] == [] and job["후"]["계"] == now["셈"]["계"] _at(me, mym.delete, key) gone = _at(me, path.read, sk) assert gone["셈"]["계"] == was and gone["사본"]["지운키"] == [key] diff --git a/resources/tester/test_840_2_machine_sheet.py b/resources/tester/test_840_2_machine_sheet.py new file mode 100644 index 000000000..f9ae5bf88 --- /dev/null +++ b/resources/tester/test_840_2_machine_sheet.py @@ -0,0 +1,200 @@ +"""840-2 기계 보기 지킴 시험 — 값 박음 · 덮음 ↔ 기계 줄 · M01 같은 이름 막음 · 쓰기 없음. + +짝 = `M02_MasterTemplete/M02_MyMachine_Sheet.py` `bind` ↔ `common_util/common_util_machine_sheet.ts` `bindDoc` +(+ 덮음 ↔ 줄 `coverFrom` · `applyCover` — 화면 몫 · Node 로 돌림). +① M01 기계 655 대 전수 — 식 고름 → 값 박은 식 셈 = 기계 표 요소로 읽는 셈(노 · 재 · 경 · 계 같음) · 식 없음 = 까닭. +② 655 대 전수(TS) — 고침 없음 = 덮음 없음 · 줄 JSON 같음(열고 닫아도 M01 기계 파일 그대로) · 박음 거울. +③ 굴착기 1.0㎥ — 가격 · 조종원(2 인 · 직종 · 뺌) · 잡품 율 덮음 → 줄 칸 · 덮음 얹고 박은 셈 = 줄 고친 셈. +④ 내 기계 꼴 — 바로(상각비계수 → 손료계수) · 직접(시간당) · 처음 값으로 되돌리면 줄 같음. +⑤ M01 과 같은 이름 · 규격 = 409 · 다른 이름 = 통과(검사만 · 쓰기 없음). +""" + +import copy +import importlib +import json +import os +import subprocess +from decimal import Decimal as D + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 # scripts 길을 둠 +from M01_MasterData import M01_MasterData_Doc as m01doc +from M02_MasterTemplete import M02_Machine_Hourly as mh +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_MyMachine as mym +from M02_MasterTemplete import M02_MyMachine_Sheet as ms + +mc = importlib.import_module("master_doc_calc") +ml = importlib.import_module("master_doc_link") +mcv = importlib.import_module("master_doc_cover") + +HERE = os.path.dirname(os.path.abspath(__file__)) +COSTS = ("노무비", "재료비", "경비", "계") +DIG = "EQ000022" # 굴착기(무한궤도) 1.0㎥ 0201-0100 · GC000267 + + +def _plain(v): + return json.loads(json.dumps(v, ensure_ascii=False, default=ms._plain)) + + +@pytest.fixture(scope="module") +def world(): + """M01 기계 줄 · 식 고름 · 식 문서 — 한 번만(마스터 층 · 쓰기 없음).""" + with store.at(store.FOLDER): + rows = m01.loaded()[0]["기계.json"]["줄"] + docs = m01doc.docs() + links = ml.Links(m01.loaded()[1], docs) + picks = {} + for r in rows: + got = mh.pick({**r, "값": m01._dec(r.get("값") or {})}) + picks[r["키"]] = str(got["키"]) if got and str(got["키"]) in docs else None + yield {"rows": rows, "docs": docs, "links": links, "picks": picks} + + +def _node(payload): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_840_2_machine_sheet.cjs")], + input=json.dumps(_plain(payload), ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(proc.stdout) + + +def _money(a): + return {k: a.get(k) for k in COSTS} + + +# ── ① 655 대 금액 ────────────────────────────────────────────────────── +def test_655대_박은_식_셈_같음(world): + links, docs = world["links"], world["docs"] + same = none = 0 + for r in world["rows"]: + key = world["picks"][r["키"]] + if key is None: + none += 1 + continue + given = {"기계": str(r.get("원문번호"))} + before = mc.calc(docs[key], links, given) + after = mc.calc(ms.bind(docs[key], m01._dec(r.get("값") or {})), links, given) + assert _money(after) == _money(before), (r["키"], key) + same += before["셈됨"] + assert len(world["rows"]) == 655 and same >= 600 and none <= 44 + + +def test_식_없는_기계_까닭(world): + with store.at(store.FOLDER): + for r in world["rows"]: + if world["picks"][r["키"]] is None: + got = ms.sheet(_plain(r)) + assert got["키"] is None and got["문서"] is None and got["까닭"] + return + pytest.skip("식 없는 기계 없음") + + +# ── ② 655 대 덮음 ↔ 줄(TS) ────────────────────────────────────────────── +def test_655대_고침_없음_줄_같음_박음_거울(world): + docs, targets, want = {}, [], [] + for r in world["rows"]: + key = world["picks"][r["키"]] + if key is None: + continue + docs[key] = docs.get(key) or _plain(world["docs"][key]) + bind = r["키"] in (DIG, "EQ000001", world["rows"][-1]["키"]) + targets.append({"식": key, "처음": _plain(r), "개인": False, + **({"박음": _plain(r["값"])} if bind else {})}) # fmt: skip + want.append(_plain(ms.bind(world["docs"][key], r["값"])) if bind else None) + got = _node({"문서들": docs, "대상": targets}) + for t, g, w in zip(targets, got, want): + assert g["덮음"] == {}, t["처음"]["키"] + assert json.dumps(g["줄"], ensure_ascii=False) == json.dumps(t["처음"], ensure_ascii=False) + if w is not None: + assert g["박음"] == w + + +# ── ③ 굴착기 덮음 → 줄 · 셈 ──────────────────────────────────────────── +def test_굴착기_덮음_줄_칸_셈(world): + row = _plain(next(r for r in world["rows"] if r["키"] == DIG)) + doc = world["docs"]["GC000267"] + given = {"기계": row["원문번호"]} + jobs = ms._jobs() + lb = next(k for k, v in jobs.items() if v == "1002") + cases = [ + ({"값": {"가격": 150000}}, {"가격": 150000}), + ({"조종원": [{"키": "LB:1048", "인": 2}]}, {"조종원": 2}), + ({"조종원": [{"키": lb, "인": 1}]}, {"조종원직종": "1002"}), + ({"조종원": []}, {"조종원": 0}), + ({"잡품": [{"줄": "주연료", "율": 30}]}, {"잡재료율": 30}), + ] + targets = [ + {"식": "GC000267", "처음": row, "덮음": c, "개인": False, "직종": jobs} for c, _ in cases + ] + got = _node({"문서들": {"GC000267": _plain(doc)}, "대상": targets}) + for (cover, change), g in zip(cases, got): + want = copy.deepcopy(row) + want["값"].update(change) + assert g["줄"] == want, cover + shaped = ms.bind( + mcv.cover_doc(doc, given, {row["원문번호"]: m01._dec(cover)}), m01._dec(row["값"]) + ) + edited = ms.bind(doc, m01._dec(g["줄"]["값"])) + a, b = mc.calc(shaped, world["links"], given), mc.calc(edited, world["links"], given) + assert a["셈됨"] and _money(a) == _money(b), cover + # 지금 줄(초안) → 덮음 — 처음 줄 대비 고친 칸만 + cur = copy.deepcopy(row) + cur["값"].update({"가격": 150000, "조종원": 2, "잡재료율": 30}) + made = _node({"문서들": {"GC000267": _plain(doc)}, + "대상": [{"식": "GC000267", "처음": row, "지금": cur, "개인": False}]})[0] # fmt: skip + assert made["덮음"] == { + "값": {"가격": 150000}, + "조종원": [{"키": "LB:1048", "인": 2}], + "잡품": [{"줄": "주연료", "율": 30}], + } + assert made["줄"] == cur + + +# ── ④ 내 기계 꼴 ─────────────────────────────────────────────────────── +def test_내기계_바로_직접(world): + easy = {"이름": "t", "규격": "", "방식": "바로", + "값": {"가격": 1000, "손료계수": 2000, "주연료": 5, "주연료종류": "경유"}} # fmt: skip + hour = {"이름": "t", "규격": "", "방식": "직접", "값": {}, + "시간당": {"노무비": 1, "재료비": 2, "경비": 3}} # fmt: skip + with store.at(store.FOLDER): + a, b = ms.sheet(easy, mine=True), ms.sheet(hour, mine=True) + assert a["키"] == "GC000272" and b["키"] == "GC001199" + assert a["값"]["상각비계수"] == 2000 and a["값"]["정비비계수"] == 0 + docs = {a["키"]: a["문서"], b["키"]: b["문서"]} + targets = [ + {"식": a["키"], "처음": easy, "개인": True}, + {"식": a["키"], "처음": easy, "개인": True, "덮음": {"값": {"상각비계수": 2500}}}, + {"식": b["키"], "처음": hour, "개인": True}, + {"식": b["키"], "처음": hour, "개인": True, "덮음": {"값": {"경비": 10}}}, + ] + got = _node({"문서들": docs, "대상": targets}) + assert got[0]["덮음"] == {} and got[0]["줄"] == easy + assert got[1]["줄"]["값"] == {**easy["값"], "손료계수": 2500} + assert got[2]["덮음"] == {} and got[2]["줄"] == hour + assert got[3]["줄"]["시간당"] == {"노무비": 1, "재료비": 2, "경비": 10} + + +# ── ⑤ M01 같은 이름 막음 ─────────────────────────────────────────────── +def test_M01_같은_이름_막음(world): + dig = next(r for r in world["rows"] if r["키"] == DIG) + with store.at(store.FOLDER): + with pytest.raises(store.StoreError) as e: + mym.not_m01({"이름": dig["이름"], "규격": dig["규격"]}) + assert e.value.status == 409 + mym.not_m01({"이름": "sub3임시 없는 기계", "규격": "1"}) + + +def test_서버_덮음_몸_틀림_400(): + with store.at(store.FOLDER), pytest.raises(m01.StoreError) as e: + ms.sheet({"원문번호": "0201-0100", "값": {}}, None, {"값": {"가격": -1}}) + assert e.value.status == 400 + + +def test_수_꼴(): + assert ms._plain(D("1.50")) == 1.5 and ms._plain(D("3")) == 3 diff --git a/ui_template/ui_template_locale_machine.ts b/ui_template/ui_template_locale_machine.ts index 3650252ae..3ac2833a4 100644 --- a/ui_template/ui_template_locale_machine.ts +++ b/ui_template/ui_template_locale_machine.ts @@ -1,6 +1,6 @@ /* ============================================================================= * ui_template_locale_machine.ts - * 기계 양식 부품(`ui_template_machine_form.ts`) · M01 기계 목록 · 상세 사전(PLAN 821-2) — 신규 문구는 최하단에. + * 기계 보기(`A00_Common/line_stack_machine_sheet.ts` · 840-2 · 옛 기계 양식 걷음) · M01 기계 목록 · 상세 사전(PLAN 821-2) — 신규 문구는 최하단에. * ========================================================================== */ export const ui_locales_machine = { @@ -78,4 +78,17 @@ export const ui_locales_machine = { M01_MachineErrors: ["고칠 칸이 있음", "Fix the marked fields"], M01_MachineDetailGroup: ["상세구분", "Sub-group"], M01_MachineSourceNo: ["원문번호", "Source no."], + // 기계 보기(840-2 · `A00_Common/line_stack_machine_sheet`) + MS_Base: ["기본 정보 · 손료 근거", "Basics · ownership basis"], + MS_Mode: ["손료 방식", "Ownership method"], + MS_Out: ["시간당 경비 산출근거", "Hourly cost breakdown"], + MS_NoFormula: ["시간당 경비 식 없음", "No hourly formula"], + MS_Loading: ["식 불러오는 중", "Loading formula"], + MS_Locked: ["품셈 방식 — 손료 근거 비율로 셈", "By standard — computed from the ratios"], + MS_Coefs: ["계수(비율로 셈)", "Coefficients (from ratios)"], + MS_M01Same: [ + "M01 에 있는 기계 — 값은 단가산출 안에서 고침 · 내 기계는 M01 에 없는 기계만", + "Already in M01 — change its values inside a unit-cost sheet · my machines are for machines not in M01", + ], + MS_Range: ["{name} 보통 범위 {value} 밖", "{name} outside usual range {value}"], } as const; diff --git a/ui_template/ui_template_machine_form.css b/ui_template/ui_template_machine_form.css deleted file mode 100644 index 7d8681fa8..000000000 --- a/ui_template/ui_template_machine_form.css +++ /dev/null @@ -1,112 +0,0 @@ -/* 기계 양식(821-2) — 왼쪽 칸 · 오른쪽 시간당 계산식 · 좁은 폭(720 아래)은 한 줄로 */ -.ui-machine-form { - display: grid; - grid-template-columns: minmax(0, 1.4fr) minmax(280px, 1fr); - gap: var(--spacing-16); - align-items: start; -} - -.ui-machine-form__fields { - display: flex; - flex-direction: column; - gap: var(--spacing-12); - min-width: 0; -} - -.ui-machine-form__row { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); - gap: var(--spacing-8) var(--spacing-12); -} - -.ui-machine-form__group { - display: flex; - flex-direction: column; - gap: var(--spacing-8); -} - -.ui-machine-form__head { - display: flex; - align-items: center; - gap: var(--spacing-12); -} - -.ui-machine-form__coefs { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); - gap: var(--spacing-8); -} - -.ui-machine-form__coefs .ui-input { - background: var(--color-surface-sunken); - color: var(--color-text-secondary); -} - -.ui-machine-form .is-changed .ui-input, -.ui-machine-form .is-changed .ui-select { - background: color-mix(in srgb, var(--color-warning) 28%, var(--color-canvas)); -} - -.ui-machine-form__warn { - min-height: 0; - color: var(--color-warning); - font-size: var(--text-caption); -} - -.ui-machine-form__warn:empty { - display: none; -} - -.ui-machine-form__notice:empty { - display: none; -} - -.ui-machine-form__similar { - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - padding: var(--spacing-8) var(--spacing-12); -} - -.ui-machine-form__similar > summary { - cursor: pointer; - color: var(--color-text-secondary); - font-size: var(--text-body-sm); -} - -.ui-machine-form__similar .ui-find__list { - max-height: 240px; - overflow: auto; -} - -.ui-machine-form__preview { - display: flex; - flex-direction: column; - gap: var(--spacing-4); - padding: var(--spacing-12); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - background: var(--color-surface); - position: sticky; - top: 0; -} - -.ui-machine-form__preview .ui-calc-row--formula { - grid-template-columns: 64px minmax(0, 1fr) 96px; -} - -.ui-machine-form__rule { - border: 0; - border-top: 1px solid var(--color-border); - margin: var(--spacing-4) 0; - width: 100%; -} - -@media (max-width: 720px) { - .ui-machine-form { - grid-template-columns: minmax(0, 1fr); - } - - .ui-machine-form__preview { - position: static; - } -} diff --git a/ui_template/ui_template_machine_form.ts b/ui_template/ui_template_machine_form.ts deleted file mode 100644 index 7060d5757..000000000 --- a/ui_template/ui_template_machine_form.ts +++ /dev/null @@ -1,520 +0,0 @@ -/* ============================================================================= - * ui_template_machine_form.ts - * 부품 「기계 양식」(PLAN 821-2 · 요구서 796 1 장) — M01 기계 상세 · M02 내 기계 · 불러오기가 같이 씀. - * - * 칸 = 이름 · 규격 / 가격(천원) / 손료계수(바로 넣기 · 품셈 방식) / 주연료(종류 고르기 + ℓ/hr) / - * 잡품 %(주연료의) / 조종원(직종 고르기 + 인) / 전력(kWh · 선택) · 「시간당 값 직접」 꼴(allowHourly) - * 옆 = 시간당 경비 계산식 즉시(`common_util_machine_coef` · 정본은 서버 식) - * 찾기 = 「비슷한 기계에서 시작」(findSimilar 를 주면) — 고르면 값 · 이름 · 규격 복사 - * 상태 = 숫자만(아니면 오류) · 범위 밖 경고 · 고친 칸 노랑 · 같은 이름 알림 자리(setNotice) · readOnly - * 자료 = 목록(연료 · 직종 · 단가) · 찾기 · 저장은 부르는 쪽이 줌 — 이 부품은 M01 · M02 에 안 묶임 - * 값 묶음 = 기계 줄 `값` 꼴 그대로(칸 이름 같음) · 안 보이는 칸(선원 등)은 손대지 않음 - * ========================================================================== */ - -import { - MACHINE_COEFS, - MACHINE_INPUTS, - machineCoefOf, - machineHourly, -} from "../common_util/common_util_machine_coef"; -import { createCalcFormula, createCalcFormulaRow } from "./ui_template_calc_row"; -import { el, createInputField, createSelectField } from "./ui_template_elements"; -import { createFindList } from "./ui_template_find_list"; -import { attachPickSearch } from "./ui_template_pick_search"; -import { t as L } from "./ui_template_locale"; -import { createSegment } from "./ui_template_segment"; -import { createText } from "./ui_template_text"; -import "./ui_template_machine_form.css"; - -export type MachineValues = Record; - -/** 고르기 목록 한 칸 — 연료(값 = 종류 · 값 단가 원/ℓ) · 직종(값 = 코드 · 단가 원/일) */ -export interface MachinePick { - value: string; - text: string; - price: number | null; -} - -export interface MachineSimilar { - key: string; - name: string; - spec: string; - /** 흐린 꼬리표(구분 · 내 기계 등) */ - tag?: string; - values: MachineValues; -} - -export interface MachineFormOptions { - name?: string; - spec?: string; - values?: MachineValues; - /** 손료계수 방식 기본 — 값에 품셈 입력 다섯이 있으면 늘 품셈 방식 */ - coefMode?: "direct" | "pumsem"; - /** 「시간당 값 직접」 꼴 허용(내 기계) */ - allowHourly?: boolean; - fuels: MachinePick[]; - jobs: MachinePick[]; - /** 전력 단가(원/kWh) · 시간환산(조종원 하루 → 한 시간) */ - power?: number; - hour?: number; - readOnly?: boolean; - findSimilar?: (q: string) => Promise; - onSimilar?: (item: MachineSimilar) => void; - onChange?: () => void; -} - -export interface MachineFormState { - name: string; - spec: string; - values: MachineValues; - /** 숫자 아닌 칸 등 — 있으면 저장 막음 */ - errors: string[]; - changed: boolean; - /** 손료계수 방식 · 「시간당 값 직접」 꼴인지(내 기계 저장 방식) */ - mode: "direct" | "pumsem"; - hourly: boolean; -} - -export interface MachineFormHandle { - root: HTMLElement; - state: () => MachineFormState; - /** 같은 이름 · 규격 알림 등(빈 글 = 지움) */ - setNotice: (text: string) => void; -} - -/** 시간당 값 직접 칸 · 비목 */ -export const HOURLY_KEY = "시간당"; -const HOURLY_COSTS = ["노무비", "재료비", "경비"] as const; -const HOURLY_LABEL = { 노무비: "MF_Labor", 재료비: "MF_Material", 경비: "MF_Expense" } as const; - -interface NumSpec { - key: string; - label: string; - unit: string; - min: number; - max: number; -} -const n = (key: string, label: string, unit: string, min: number, max: number): NumSpec => ({ - key, - label, - unit, - min, - max, -}); -const PRICE = n("가격", "MF_Price", "천원", 0, 5_000_000); -const COEF = n("손료계수", "MF_Coef", "10⁻⁷/hr", 1, 100_000); -const PUMSEM = [ - n("내용시간", "MF_Life", "시간", 100, 100_000), - n("연간표준가동시간", "MF_Yearly", "시간", 100, 8_760), - n("상각비율", "MF_Dep", "", 0, 1), - n("정비비율", "MF_Rep", "", 0, 3), - n("연간관리비율", "MF_Mgmt", "", 0, 1), -]; -const FUEL = n("주연료", "MF_Fuel", "ℓ/hr", 0, 2_000); -const MISC = n("잡재료율", "MF_Misc", "%", 0, 100); -const CREW = n("조종원", "MF_Crew", "인", 0, 20); -const POWER = n("전력", "MF_Power", "kWh", 0, 10_000); -const COEF_LABEL: Record = { - 상각비계수: "MF_DepCoef", - 정비비계수: "MF_RepCoef", - 관리비계수: "MF_MgmtCoef", - 손료계수: "MF_Coef", -}; - -const tr = (key: string): string => L(key as "MF_Name"); -const fmt = (v: number): string => v.toLocaleString("ko-KR", { maximumFractionDigits: 4 }); -const shown = (v: unknown): string => (v === null || v === undefined ? "" : String(v)); -const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b); - -/** 글 → 수 · 빈 글 = null · 수 아님 = NaN */ -function parseNum(text: string): number | null { - const s = text.replace(/,/g, "").trim(); - if (!s) return null; - return /^-?\d+(\.\d+)?$/.test(s) ? Number(s) : Number.NaN; -} - -export function createMachineForm(opts: MachineFormOptions): MachineFormHandle { - const init: MachineValues = structuredClone(opts.values ?? {}); - let values: MachineValues = structuredClone(init); - let name = opts.name ?? ""; - let spec = opts.spec ?? ""; - const startName = name; - const startSpec = spec; - const hasPumsem = MACHINE_INPUTS.some((k) => typeof init[k] === "number"); - let mode: "direct" | "pumsem" = hasPumsem ? "pumsem" : (opts.coefMode ?? "direct"); - let hourlyMode = init[HOURLY_KEY] !== undefined && Boolean(opts.allowHourly); - const errors = new Map(); - - const root = el("div", { className: "ui-machine-form" }); - const fields = el("div", { className: "ui-machine-form__fields" }); - const side = el("aside", { className: "ui-machine-form__preview" }); - const notice = el("div", { className: "ui-machine-form__notice" }); - - /** 칸 값을 값 묶음에 — 빈 값은 원래 칸이 있었으면 null · 없었으면 칸을 뺌 */ - const put = (key: string, value: unknown): void => { - if (value === null || value === "") { - if (key in init) values[key] = null; - else delete values[key]; - } else values[key] = value; - }; - - const changed = (): boolean => name !== startName || spec !== startSpec || !same(values, init); - - /** 품셈 입력이 처음과 같으면 저장된 계수 그대로(원문 표 값) · 다르면 계수 짝으로 셈 */ - const syncCoefs = (): void => { - if (mode !== "pumsem") return; - const sameInputs = MACHINE_INPUTS.every((k) => same(values[k], init[k])); - const got = sameInputs ? null : machineCoefOf(values); - for (const k of MACHINE_COEFS) { - if (sameInputs) put(k, init[k] ?? null); - else put(k, got ? got[k] : null); - } - }; - - const fire = (): void => { - syncCoefs(); - paintPreview(); - paintCoefs(); - opts.onChange?.(); - }; - - /** 숫자 칸 — 숫자만 · 범위 밖 경고 · 고친 칸 노랑 */ - const numField = (spec_: NumSpec, read = false, hold?: MachineValues): HTMLElement => { - const bag = hold ?? values; - const field = createInputField({ - label: tr(spec_.label), - value: shown(bag[spec_.key]), - unit: spec_.unit || undefined, - size: 28, - }); - field.input.inputMode = "decimal"; - field.input.dataset.key = spec_.key; - const warn = el("span", { className: "ui-machine-form__warn" }); - field.root.append(warn); - const mark = (): void => { - const now = hold ? hold[spec_.key] : values[spec_.key]; - const was = hold - ? (init[HOURLY_KEY] as MachineValues | undefined)?.[spec_.key] - : init[spec_.key]; - field.root.classList.toggle("is-changed", !same(now ?? null, was ?? null)); - }; - if (read || opts.readOnly) field.input.readOnly = true; - field.input.addEventListener("input", () => { - const v = parseNum(field.input.value); - warn.textContent = ""; - if (Number.isNaN(v)) { - errors.set(spec_.key, `${tr(spec_.label)} · ${L("MF_NumberOnly")}`); - field.setError(L("MF_NumberOnly")); - } else { - errors.delete(spec_.key); - field.setError(); - if (v !== null && (v < spec_.min || v > spec_.max)) - warn.textContent = L("MF_Range").replace( - "{value}", - `${fmt(spec_.min)}~${fmt(spec_.max)}`, - ); - if (hold) { - if (v === null) delete hold[spec_.key]; - else hold[spec_.key] = v; - values[HOURLY_KEY] = { ...hold }; - } else put(spec_.key, v); - } - mark(); - fire(); - }); - mark(); - return field.root; - }; - - /** 고르기 칸 — 없음 + 목록 · 목록에 없는 지금 값도 한 칸(단가 없음) */ - const pickField = (key: string, label: string, list: MachinePick[]): HTMLElement => { - const now = shown(values[key]); - const options = [ - { value: "", text: L("MF_None") }, - ...list.map((p) => ({ value: p.value, text: p.text })), - ]; - if (now && !list.some((p) => p.value === now)) - options.push({ value: now, text: `${now} (${L("MF_NoPrice")})` }); - const field = createSelectField({ label: tr(label), options, value: now, size: 28 }); - field.select.dataset.key = key; - field.select.disabled = Boolean(opts.readOnly); - attachPickSearch(field.select, { placeholder: L("MF_PickFind") }); // 항목 많은 고르기(직종) = 찾기 판(801-2) - const mark = (): void => { - field.root.classList.toggle("is-changed", !same(values[key] ?? null, init[key] ?? null)); - }; - field.select.addEventListener("change", () => { - put(key, field.select.value || null); - mark(); - fire(); - }); - mark(); - return field.root; - }; - - const textField = ( - label: string, - get: () => string, - set: (v: string) => void, - was: string, - ): HTMLElement => { - const field = createInputField({ label: tr(label), value: get(), size: 28 }); - field.input.readOnly = Boolean(opts.readOnly); - field.input.addEventListener("input", () => { - set(field.input.value); - field.root.classList.toggle("is-changed", field.input.value !== was); - opts.onChange?.(); - }); - return field.root; - }; - - /* --- 손료계수 꼴 --- */ - const coefBox = el("div", { className: "ui-machine-form__group" }); - const coefOut = el("div", { className: "ui-machine-form__coefs" }); - const paintCoefs = (): void => { - if (mode !== "pumsem") return; - coefOut.replaceChildren( - ...MACHINE_COEFS.map((k) => { - const f = createInputField({ label: tr(COEF_LABEL[k]), value: shown(values[k]), size: 28 }); - f.input.readOnly = true; - f.input.tabIndex = -1; - f.root.classList.toggle("is-changed", !same(values[k] ?? null, init[k] ?? null)); - return f.root; - }), - ); - }; - const paintCoefBox = (): void => { - const pumsem = mode === "pumsem"; - coefBox.replaceChildren( - el("div", { - className: "ui-machine-form__row", - children: pumsem ? PUMSEM.map((s) => numField(s)) : [numField(COEF)], - }), - ...(pumsem ? [coefOut] : []), - ); - paintCoefs(); - }; - const coefSeg = createSegment({ - options: [ - { value: "direct", label: L("MF_CoefDirect") }, - { value: "pumsem", label: L("MF_CoefPumsem") }, - ], - value: mode, - size: 24, - ariaLabel: L("MF_Coef"), - onChange: (v) => { - mode = v as "direct" | "pumsem"; - if (mode === "direct") { - for (const k of [...MACHINE_INPUTS, ...MACHINE_COEFS.slice(0, 3)]) delete values[k]; - if (values["손료계수"] === undefined && init["손료계수"] !== undefined) - values["손료계수"] = init["손료계수"]; - } else for (const k of MACHINE_INPUTS) if (init[k] !== undefined) values[k] = init[k]; - paintCoefBox(); - fire(); - }, - }); - if (opts.readOnly) coefSeg.root.querySelectorAll("button").forEach((b) => (b.disabled = true)); - - /* --- 구성 / 시간당 값 직접 --- */ - const body = el("div", { className: "ui-machine-form__body" }); - const paintBody = (): void => { - if (hourlyMode) { - const hold = { ...((values[HOURLY_KEY] as MachineValues | undefined) ?? {}) }; - body.replaceChildren( - el("div", { - className: "ui-machine-form__row", - children: HOURLY_COSTS.map((k) => - numField(n(k, HOURLY_LABEL[k], "원/hr", 0, 10_000_000), false, hold), - ), - }), - ); - return; - } - paintCoefBox(); - body.replaceChildren( - numField(PRICE), - el("div", { - className: "ui-machine-form__group", - children: [ - el("div", { - className: "ui-machine-form__head", - children: [el("strong", { text: L("MF_Coef") }), coefSeg.root], - }), - coefBox, - ], - }), - el("div", { - className: "ui-machine-form__row", - children: [ - pickField("주연료종류", "MF_FuelKind", opts.fuels), - numField(FUEL), - numField(MISC), - ], - }), - el("div", { - className: "ui-machine-form__row", - children: [pickField("조종원직종", "MF_Job", opts.jobs), numField(CREW), numField(POWER)], - }), - ); - }; - const kindSeg = opts.allowHourly - ? createSegment({ - options: [ - { value: "build", label: L("MF_Build") }, - { value: "hourly", label: L("MF_HourlyDirect") }, - ], - value: hourlyMode ? "hourly" : "build", - size: 24, - onChange: (v) => { - hourlyMode = v === "hourly"; - if (hourlyMode) values[HOURLY_KEY] ??= {}; - else if (init[HOURLY_KEY] === undefined) delete values[HOURLY_KEY]; - paintBody(); - fire(); - }, - }) - : null; - - /* --- 비슷한 기계에서 시작 --- */ - const similarBox = (): HTMLElement[] => { - if (!opts.findSimilar || opts.readOnly) return []; - const find = createFindList({ placeholder: L("MF_SimilarFind") }); - const wrap = el("details", { - className: "ui-machine-form__similar", - children: [el("summary", { text: L("MF_Similar") }), find.root], - }); - let seq = 0; - let timer: number | undefined; - const look = async (): Promise => { - const mine = ++seq; - const got = await opts.findSimilar!(find.search.value.trim()).catch(() => []); - if (mine !== seq) return; - find.hint.textContent = got.length ? "" : L("MF_SimilarNone"); - find.setRows( - got.map((m) => ({ - key: m.key, - name: `${m.name} ${m.spec}`.trim(), - tag: m.tag, - sub: m.key, - onPick: () => { - values = { ...structuredClone(m.values) }; - name = m.name; - spec = m.spec; - mode = MACHINE_INPUTS.some((k) => typeof values[k] === "number") ? "pumsem" : mode; - coefSeg.set(mode); - hourlyMode = values[HOURLY_KEY] !== undefined && Boolean(opts.allowHourly); - kindSeg?.set(hourlyMode ? "hourly" : "build"); - wrap.open = false; - paintAll(); - setNotice(L("MF_SimilarCopied").replace("{value}", `${m.name} ${m.spec}`.trim())); - opts.onSimilar?.(m); - fire(); - }, - })), - ); - }; - find.search.addEventListener("input", () => { - window.clearTimeout(timer); - timer = window.setTimeout(() => void look(), 250); - }); - wrap.addEventListener("toggle", () => wrap.open && void look()); - return [wrap]; - }; - - /* --- 미리보기 --- */ - const paintPreview = (): void => { - const out = (v: number, key = false): HTMLElement => - el("span", { className: `ui-calc-row__out${key ? " is-key" : ""}`, text: fmt(v) }); - const line = (label: string, text: string, v: number, key = false): HTMLElement => - createCalcFormulaRow(label, text ? createCalcFormula(text) : null, out(v, key)); - const head = [ - el("strong", { text: L("MF_Preview") }), - createText(L("MF_PreviewNote"), "muted"), - ]; - if (hourlyMode) { - const h = (values[HOURLY_KEY] as Record | undefined) ?? {}; - const [a, b, c] = HOURLY_COSTS.map((k) => Number(h[k]) || 0); - side.replaceChildren( - ...head, - line(L("MF_Labor"), "", a), - line(L("MF_Material"), "", b), - line(L("MF_Expense"), "", c), - line(L("MF_Total"), "", a + b + c, true), - ); - return; - } - const fuel = opts.fuels.find((f) => f.value === values["주연료종류"])?.price ?? 0; - const wage = opts.jobs.find((j) => j.value === String(values["조종원직종"] ?? ""))?.price ?? 0; - const hour = opts.hour ?? 1 / 8; - const p = machineHourly(values, fuel, wage, opts.power ?? 0, hour); - const num = (k: string): string => fmt(Number(values[k]) || 0); - const rows = [ - line(L("MF_Loss"), `${num("가격")}천원 × 1,000 × ${num("손료계수")} × 10^-7^`, p.손료), - line(L("MF_Fuel"), `${num("주연료")}ℓ × ${fmt(fuel)}원`, p.주연료), - ...(values["전력"] - ? [line(L("MF_Power"), `${num("전력")}kWh × ${fmt(opts.power ?? 0)}원`, p.전력)] - : []), - line(L("MF_Misc"), `${fmt(p.주연료 + p.전력)} × ${num("잡재료율")}%`, p.잡품), - line(L("MF_Crew"), `${num("조종원")}인 × ${fmt(wage)}원 × ${fmt(hour)}`, p.조종원), - ]; - side.replaceChildren( - ...head, - ...rows, - el("hr", { className: "ui-machine-form__rule" }), - line(L("MF_Labor"), "", p.노무비), - line(L("MF_Material"), "", p.재료비), - line(L("MF_Expense"), "", p.경비), - line(L("MF_Total"), "", p.계, true), - ); - }; - - const setNotice = (text: string): void => { - notice.replaceChildren(...(text ? [createText(text, "warning")] : [])); - }; - - const top = el("div", { className: "ui-machine-form__row" }); - const paintAll = (): void => { - errors.clear(); - syncCoefs(); - top.replaceChildren( - textField( - "MF_Name", - () => name, - (v) => (name = v), - startName, - ), - textField( - "MF_Spec", - () => spec, - (v) => (spec = v), - startSpec, - ), - ); - paintBody(); - paintPreview(); - }; - fields.append(...similarBox(), notice, top, ...(kindSeg ? [kindSeg.root] : []), body); - root.append(fields, side); - root.classList.toggle("is-readonly", Boolean(opts.readOnly)); - paintAll(); - - return { - root, - state: () => ({ - name: name.trim(), - spec: spec.trim(), - // 칸 차례 = 처음 값 묶음 차례(방식을 바꿨다 되돌려도 같은 줄) · 새 칸은 뒤에 - values: structuredClone( - Object.fromEntries( - [...Object.keys(init), ...Object.keys(values)] - .filter((k, i, all) => all.indexOf(k) === i && k in values) - .map((k) => [k, values[k]]), - ), - ), - errors: [...errors.values()], - changed: changed(), - mode, - hourly: hourlyMode, - }), - setNotice, - }; -} diff --git a/ui_template/ui_template_ui_kit_parts.ts b/ui_template/ui_template_ui_kit_parts.ts index aeca68566..32643e158 100644 --- a/ui_template/ui_template_ui_kit_parts.ts +++ b/ui_template/ui_template_ui_kit_parts.ts @@ -54,7 +54,6 @@ import { createRangeBar } from "./ui_template_range_bar"; import { createStat, statGrid } from "./ui_template_stat"; import { toastNode } from "./ui_template_toast"; import { FRAME_DEMOS } from "./ui_template_ui_kit_frames"; -import { createMachineForm } from "./ui_template_machine_form"; /** 상태 줄 다섯 칸 */ export const KIT_STATES = ["default", "hover", "active", "disabled", "error"] as const; @@ -322,22 +321,6 @@ function tabsDemo(s: KitState): HTMLElement | null { return box; } -/** 기계 양식 견본 — 굴착기 1.0㎥ 값(821-2) */ -const kitMachine = (readOnly: boolean): HTMLElement => - createMachineForm({ - name: "굴착기(무한궤도)", - spec: "1.0㎥", - values: { - ...{ 가격: 142069, 내용시간: 10000, 연간표준가동시간: 1250, 상각비율: 0.9, 정비비율: 0.7 }, - ...{ 연간관리비율: 0.1, 상각비계수: 900, 정비비계수: 700, 관리비계수: 485, 손료계수: 2085 }, - ...{ 주연료: 19.5, 주연료종류: "경유", 잡재료율: 22, 조종원: 1, 조종원직종: "1048" }, - }, - fuels: [{ value: "경유", text: "경유", price: 1846.39 }], - jobs: [{ value: "1048", text: "건설기계운전사", price: 283297 }], - readOnly, - findSimilar: async () => [], - }).root; - // prettier-ignore export const KIT_PARTS: KitPart[] = [ // ── 552-2 기본 ───────────────────────────────────────────── @@ -403,8 +386,6 @@ export const KIT_PARTS: KitPart[] = [ demo: (s) => (s === "default" ? col(createKv([{ label: "연장", value: "1,080 m", big: true }, { label: "종단 경사", value: "12 %", ok: true }, { label: "최소 곡선반경", value: "12 m", ok: false }, { label: "비고", value: null }]), createKv([{ label: kitKeys("Ctrl", "Z"), value: "되돌리기" }, { label: kitKeys("휠 끌기"), value: "화면 옮기기" }, { label: kitKeys("Esc") }], { rule: true })) : null) }, { key: "calc-row", name: "계산 줄", group: "compound", kinds: "값 줄(이름 · 값 112 · 덧칸) · 식 줄(이름 · 식 · 값) · 시트 줄(이름 · 식 · 값 · 꼬리) · 위 · 아래첨자 · 중요 결과 굵게", file: "ui_template_calc_row.ts", demo: (s) => (s === "default" ? kitCalc() : null) }, - { key: "machine-form", name: "기계 양식", group: "compound", kinds: "이름 · 규격 · 가격 · 손료계수(바로 넣기 · 품셈 방식) · 주연료 · 잡품 · 조종원 · 전력 · 시간당 값 직접 · 옆 시간당 계산식 · 비슷한 기계에서 시작 · 고친 칸 · 숫자만", file: "ui_template_machine_form.ts", - demo: (s) => (s === "default" || s === "disabled" ? kitMachine(s === "disabled") : null) }, { key: "equation", name: "수식", group: "compound", kinds: "분수(가로 줄 안 세로 쌓기) · 위 · 아래첨자 · × ÷ · 괄호 · % · 조건 칩 · 하위 로직 블록(접힘) · 한 줄 = 결과명 = 이름 식 = 값 식 = 결과 + 변수 풀이", file: "ui_template_equation.ts", demo: (s) => (s === "default" ? kitEquation() : null) }, { key: "hopyo-grid", name: "일위대가 격자", group: "compound", kinds: "호표 머리(이름 · 규격 · 단위 · 1 단위 합계) · 구성 줄(품명 · 규격 · 수량 · 단위 · 비목 단가 / 금액 · 비고) · 줄 꼬리표 품셈 · 고정 · 합계 줄 빈 줄 붉게", file: "ui_template_hopyo_grid.ts", demo: (s) => (s === "default" ? createHopyoGrid([{ key: "D", no: 17, name: "돌기슭막이 H=2.0", unit: "m", total: { sum: 121748.6, labor: 94447.5, material: 0, expense: 27301.1 }, lines: [{ name: "돌쌓기 찰쌓기", spec: "뒷길이 35cm", qty: 2.09, unit: "㎡", prices: { sum: 52995, labor: 41250, expense: 11745 }, amounts: { sum: 110759.6, labor: 86212.5, expense: 24547.1 }, note: "산근 1호표", tag: "품셈" }, { name: "야면석 채집 운반", spec: "계곡 내", qty: 0.45, unit: "㎥", prices: { sum: 24420, labor: 18300, expense: 6120 }, amounts: { sum: 10989, labor: 8235, expense: 2754 }, tag: "고정" }, { name: "잡재료", spec: "노무비의", qty: null, unit: "%", prices: {}, amounts: {}, note: "수량 없음", tag: "품셈" }] }]).root : null) }, From bcc540df300cbb1032c5167c123168c9209b30d3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 15 Nov 2026 15:53:27 +0900 Subject: [PATCH 7/8] =?UTF-8?q?test(840-1):=20=EB=82=A1=EC=9D=80=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=20=EC=85=8B=20=E2=80=94=20=EC=A7=80=EA=B8=88?= =?UTF-8?q?=20=EA=BC=B4=EC=97=90=20=EB=A7=9E=EC=B6=A4(=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=ED=9D=A0=20=EC=97=86=EC=9D=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_717_4 = 811-1 부터 일위대가 안 SK 줄은 참조 한 행 그대로(본뜬 · 펼침 없음) — 옛 740-1 펼침 기대 고침 - test_721 736_2 = 머리 단추를 headBtns 로 조건부 전달(actions: headBtns)하는 꼴로 고침 - test_m02_structure_panel = 머리 단추 줄에 actions.undo 가 들어간 꼴(804-8)로 고침 Co-Authored-By: Claude Sonnet 5.5 --- resources/tester/test_717_4_sangeun_pick.py | 8 ++++---- resources/tester/test_721_find_pick.py | 3 ++- resources/tester/test_m02_structure_panel.py | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/resources/tester/test_717_4_sangeun_pick.py b/resources/tester/test_717_4_sangeun_pick.py index 445a1933c..8422912da 100644 --- a/resources/tester/test_717_4_sangeun_pick.py +++ b/resources/tester/test_717_4_sangeun_pick.py @@ -71,14 +71,14 @@ def test_list_amount_and_labor_line_holds_upper_sk(world): # noqa: F811 }, )["이름"] line = store.read("precombo", sc)["문서"]["담은로직"][0] - saved, inner = line["본뜬"], [line["수량"]] + saved, inner = line["참조"], [line["수량"]] outer = {"담은로직": [{"참조": {**MINE, "키": sc}, "수량": {"손": 1}}]} got = labor.calc(outer, {}) fed = labor.calc(outer, {f"{refs.ref_id({**MINE, '키': sc})}#0": 3}) direct = labor.calc({"담은로직": [{"참조": {**COMPANY, "키": sk}, "수량": {"손": 3}}]}, {}) - # 740-1 — 회사 SK = 로직 줄 + 패치로 펼쳐 담음 · 출처 = 본뜬 - assert {k: saved[k] for k in ("층", "주인", "키")} == {**COMPANY, "키": sk} - assert line["로직"] and "참조" not in line + # 811-1 — 일위대가 안 SK 줄 = 참조 한 행 그대로(펼치지 않음 · 본뜬 없음) + assert saved == {**COMPANY, "키": sk} + assert "로직" not in line and "본뜬" not in line # 742-1 — 일위대가 안 줄 수량 = 저장(사용자 입력) · 쓰는 자리 알약이 이어지면 그 값 assert inner == [{"손": 3}] assert got["반영합"]["계"] == pytest.approx(one * 3, abs=1) # 저장 수량 3 diff --git a/resources/tester/test_721_find_pick.py b/resources/tester/test_721_find_pick.py index bdec67e96..f0004d1df 100644 --- a/resources/tester/test_721_find_pick.py +++ b/resources/tester/test_721_find_pick.py @@ -128,7 +128,8 @@ def test_736_2_빠진_길_둘_신규_자재_기계_탭(): """736-2 — 개인 층 재료 고름 머리 [신규 자재] · 엑셀 연결 = 줄 갈래대로 탭(노임 인력 · 기계 기계 · 자재 재료).""" find = _text(M02 / "M02_MasterTemplete_Find_Pick.ts") assert 'opt.layer === "personal" && !!opt.material && ok("material")' in find - assert "openNewMaterial(opt.owner" in find and "actions: [newMat]" in find + assert "openNewMaterial(opt.owner" in find and "headBtns = canNew ? [newMat] : []" in find + assert "{ actions: headBtns }" in find # 머리 단추 있을 때만 모달 머리에 assert "machine?: (e: M01Element) => void;" in find assert 'opt.machine && ok("machine")' in find pick = _text(M02 / "M02_Material_Pick.ts") diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index 271ed7233..cf560b39b 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -220,7 +220,7 @@ def test_상세_화면_돌아가기는_집계표로_그_열_고른_채(): main = _text(MAIN) # M01 꼴 — 제목 앞(39-5) · 105-4 상세 이동 묶음(nav) · 49-7 [?] 칸(helpSlot)이 그 뒤 assert re.search( - r"children: \[\s*back,\s*title,\s*nav\.root,\s*helpSlot,\s*slot,\s*reload,\s*actions\.reset,\s*actions\.del,\s*save,", + r"children: \[\s*back,\s*title,\s*nav\.root,\s*helpSlot,\s*slot,\s*reload,\s*actions\.reset,\s*actions\.del,\s*actions\.undo,\s*save,", main, ) assert "!(sel.kind in NUMBER_FIELD)" in main # 구조물 도면 · 일위대가 · 면적산출만 From 1dff6e90643129c892e960780d3785587903f49d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 15 Nov 2026 15:59:47 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix(840-2):=20=EA=B8=B0=EA=B3=84=20?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=E2=80=94=20=EC=9D=BC=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=20=EC=86=90=EB=A3=8C=20=EA=B8=B0=EA=B3=84=20=EC=B9=B8=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=3D=20=EC=A4=84=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=EA=B8=80=20=C2=B7=20=EC=8B=9C=EA=B0=84=EB=8B=B9=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=EA=B2=BD=EA=B3=A0=20=EC=95=88=20=ED=95=A8=20=C2=B7?= =?UTF-8?q?=20=EC=8B=9D=20=EA=B7=B8=EB=8C=80=EB=A1=9C=EC=9D=BC=20=EB=95=8C?= =?UTF-8?q?=20=EC=84=9C=EB=B2=84=20=EA=B9=8C=EB=8B=AD=20=EA=B8=80=20?= =?UTF-8?q?=EC=83=88=EB=A1=9C=20=EB=B0=9B=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- A00_Common/line_stack_machine_sheet.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/A00_Common/line_stack_machine_sheet.ts b/A00_Common/line_stack_machine_sheet.ts index cb512c184..556c1165b 100644 --- a/A00_Common/line_stack_machine_sheet.ts +++ b/A00_Common/line_stack_machine_sheet.ts @@ -209,7 +209,10 @@ export function createMachineSheet(o: MachineSheetOptions) { info = got; rebuild(); drawAll(); - } else drawLive(); + } else { + info.까닭 = got.까닭; // 식은 그대로 — 까닭만 지금 줄로 + drawLive(); + } } catch (e) { if (mineSeq !== seq || (e as Error).name === "AbortError") return; info = { 키: null, 까닭: e instanceof Error ? e.message : "", 문서: null, 직종: jobs }; @@ -282,9 +285,15 @@ export function createMachineSheet(o: MachineSheetOptions) { ctlBox.replaceChildren(...(zone ? [zone] : [])); }; + /** 칸 단위 — 줄 `단위` 글(일 단위 손료 등) › 기본 */ + const unitOf = (k: string): string => { + const u = text((cur.단위 as MachineValues | undefined)?.[k]); + return !u || u === "10^-7/시간" ? (UNIT[k] ?? "") : u; + }; const numField = (k: string, read = false): HTMLElement => { const v = cur.값?.[k]; - const f = createInputField({ label: label(k), value: text(v), size: 28, ...(UNIT[k] ? { unit: UNIT[k] } : {}) }); // prettier-ignore + const unit = unitOf(k); + const f = createInputField({ label: label(k), value: text(v), size: 28, ...(unit ? { unit } : {}) }); // prettier-ignore f.input.inputMode = "decimal"; f.input.dataset.key = k; f.input.readOnly = ro || read; @@ -518,6 +527,7 @@ export function createMachineSheet(o: MachineSheetOptions) { }; const drawWarn = (): void => { const vals: MachineValues = { ...engineVals(cur, mine), ...(cur.값 ?? {}) }; + if (unitOf("손료계수") !== UNIT.손료계수) delete vals.손료계수; // 일 단위 손료(임업) = 시간당 범위 아님 warn.replaceChildren( ...rangeWarnings(vals).map(([k, r]) => createText(L("MS_Range").replace("{name}", label(k)).replace("{value}", r), "warning"),