From 062fb07668244468239b58dd97b96b292d463247 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 18:12:34 +0900 Subject: [PATCH] =?UTF-8?q?feat(822-2):=20=EC=8B=9D=20=EA=B3=A0=EB=A6=84?= =?UTF-8?q?=20=ED=95=9C=20=EA=B3=B3(=EB=AA=A9=EB=A1=9D=20=E2=80=BA=20?= =?UTF-8?q?=EA=BC=B4)=20=C2=B7=20=EA=B9=8C=EB=8B=AD=20=EA=B8=80=20=C2=B7?= =?UTF-8?q?=20=EC=83=88=20=EA=B8=B0=EA=B3=84=20=EC=8B=9D=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D=20=C2=B7=20=EC=93=B0=EB=8A=94=20=EA=B3=84=EC=82=B0?= =?UTF-8?q?=EC=8B=9D=20=C2=B7=20=EC=A7=81=EC=A0=91=20=EA=B0=92=20=C2=B7=20?= =?UTF-8?q?=EC=A0=84=EB=A0=A5=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M02_Machine_Hourly.pick = 목록 › 그 기계 식 › 꼴(내 기계와 같은 규칙) · why = 값 모자란 까닭(가격 없음 · 범위 · 일 단위 손료 …) - 데이터 흐름 · 사본 모으기 · M01 · 불러오기 · 줄 쌓기 모두 pick 을 씀(데이터 흐름 옛 짝 고름 지움) - 내 기계 꼴: 전력 · 전력 + 조종원 · 직종 입력 · 손료계수 한 칸 · 직접(GC001199 — 줄 쌓기 회색 풀림) · 연료 + 전력 = 400 - M01 [새 기계] 저장 = 꼴 식 「기계」 목록에 등록 · M01 에서 만든 기계 지우면 목록에서 뺌 · 상세 「쓰는 계산식」(POST /machine/formula) · 목록 시간당 「—」 대신 까닭 글 - 미리보기 짝: 잡품 = (주연료 + 전력) × %(전력 기종 = 전력비의 %) - 시험 test_822_2 새로 · test_804_2 갈래에 전력 줄 더함(손료만 식과 전력 식은 다른 갈래 — 모르타르 믹서) Co-Authored-By: Claude Opus 5.5 --- M01_MasterData/M01_MasterData_Machine_Api.ts | 20 ++ M01_MasterData/M01_MasterData_Router.py | 6 + M01_MasterData/M01_MasterData_Store_Make.py | 1 + M01_MasterData/M01_MasterData_UI_Machine.ts | 3 +- .../M01_MasterData_UI_MachineDetail.ts | 28 +- M02_MasterTemplete/DataFlow/M02_DF_Values.py | 45 +--- M02_MasterTemplete/M02_Item_Prices.py | 9 +- M02_MasterTemplete/M02_Machine_Hourly.py | 87 +++++- .../M02_MasterTemplete_Find_Pick.ts | 4 +- M02_MasterTemplete/M02_MyMachine.py | 56 +++- M02_MasterTemplete/M02_MyMachine_Text.ts | 2 +- common_util/common_util_machine_coef.ts | 4 +- .../scripts/master_machine_coef.py | 5 +- resources/tester/test_804_2_line_stack.py | 4 +- .../tester/test_822_2_machine_hourly_all.py | 252 ++++++++++++++++++ 15 files changed, 458 insertions(+), 68 deletions(-) create mode 100644 resources/tester/test_822_2_machine_hourly_all.py diff --git a/M01_MasterData/M01_MasterData_Machine_Api.ts b/M01_MasterData/M01_MasterData_Machine_Api.ts index 6598fec05..8270f5854 100644 --- a/M01_MasterData/M01_MasterData_Machine_Api.ts +++ b/M01_MasterData/M01_MasterData_Machine_Api.ts @@ -53,5 +53,25 @@ export const fetchMachineUses = async (key: string): Promise => export const findMachines = async (q: string): Promise => (await get<{ rows: Row[] }>("/find", { q })).rows; +/** 쓰는 계산식(822-2) — 방법 = 목록 · 꼴 · 그 기계 식 · 까닭 = 값이 모자란 칸 */ +export interface MachineFormula { + 키?: string; + 이름?: string; + 방법: "목록" | "꼴" | "그 기계 식"; + 까닭: string; +} + +export async function fetchMachineFormula(row: Row): Promise { + const res = await fetch(`${API_BASE_URL}/m01/machine/formula`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(row), + }); + const body = (await res.json()) as MachineFormula & { detail?: unknown }; + if (!res.ok) throw new Error(String(body.detail ?? `HTTP ${res.status}`)); + return body; +} + export const fetchMachineRow = (key: string): Promise<{ version: string; row: Row }> => get("/row", { key }); diff --git a/M01_MasterData/M01_MasterData_Router.py b/M01_MasterData/M01_MasterData_Router.py index c9398209d..cc47eac9a 100644 --- a/M01_MasterData/M01_MasterData_Router.py +++ b/M01_MasterData/M01_MasterData_Router.py @@ -288,6 +288,12 @@ def get_machine_row(key: str) -> dict: return _call(machine.row, key) +@router.post("/machine/formula") +def post_machine_formula(row: dict[str, Any]) -> dict: + """기계 줄(저장 전 고친 값도) → 쓰는 계산식 {키 · 이름 · 방법 · 까닭}(822-2).""" + return _call(machine.formula, row) + + @router.post("/calc") def post_calc(body: CalcBody) -> dict: with store.cm.mf.mp.choosing(body.고름): diff --git a/M01_MasterData/M01_MasterData_Store_Make.py b/M01_MasterData/M01_MasterData_Store_Make.py index 291b7b78c..944658852 100644 --- a/M01_MasterData/M01_MasterData_Store_Make.py +++ b/M01_MasterData/M01_MasterData_Store_Make.py @@ -294,6 +294,7 @@ def save(batch: list[dict], confirmed: bool = False) -> dict: row.update(별칭=text) if text else row.pop("별칭") machine.fix(part) # 기계 줄 = 계수 서버 재계산 · 단위 · 출처 채움(821-2) _keep_same(part) + machine.register(batch) # [새 기계] = 꼴 식 목록 등록(822-2 · 개정확인 밖 — 자동 고침) fresh = False for part in batch: if part.get("file") != OWN: diff --git a/M01_MasterData/M01_MasterData_UI_Machine.ts b/M01_MasterData/M01_MasterData_UI_Machine.ts index 5e0e3f6b8..14c218259 100644 --- a/M01_MasterData/M01_MasterData_UI_Machine.ts +++ b/M01_MasterData/M01_MasterData_UI_Machine.ts @@ -132,7 +132,8 @@ export function renderMachines( if (!want.length) return; try { const got = await fetchMachineHourly(want); - for (const [k, h] of Object.entries(got)) hourly.set(k, h.계 === undefined ? "—" : num(h.계)); + for (const [k, h] of Object.entries(got)) + hourly.set(k, h.계 === undefined ? (h.reason ?? "—") : num(h.계)); } catch { for (const k of want) hourly.set(k, "—"); } diff --git a/M01_MasterData/M01_MasterData_UI_MachineDetail.ts b/M01_MasterData/M01_MasterData_UI_MachineDetail.ts index d1cad8a5f..2ab3d096d 100644 --- a/M01_MasterData/M01_MasterData_UI_MachineDetail.ts +++ b/M01_MasterData/M01_MasterData_UI_MachineDetail.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M01_MasterData_UI_MachineDetail.ts * M01 기계 상세 페이지(PLAN 821-2 · 요구 790-2 · 791) — 기계 양식 부품 + 원문번호 · 원문 · 구분 · - * 상세구분 · 별칭 · 비고 · 쓰는 곳 · 지우기(쓰는 곳 보이고 확인). + * 상세구분 · 별칭 · 비고 · 쓰는 계산식(822-2 · 서버 식 고름) · 쓰는 곳 · 지우기(쓰는 곳 보이고 확인). * 고침은 초안(sessionStorage)에 바로 쌓임 → 페이지 [저장] 한 번에 서버로(자동저장 없음) · * 서버가 계수를 계수 짝으로 다시 셈 · 단위 · 출처 칸을 채움(`M01_MasterData_Machine.fix`). * 대상 = 키(EQ…) · 저장 전 새 줄 `add:<차례>` · 새 기계 `new`(비슷한 기계에서 시작이 펼쳐짐). @@ -22,6 +22,7 @@ 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"; import { + fetchMachineFormula, fetchMachineLists, fetchMachineRow, fetchMachineUses, @@ -154,6 +155,29 @@ async function build(host: HTMLElement, opts: MachineDetailOptions, alive: () => } cur = ordered; checkSame(s.name, s.spec); + paintFormula(); + }; + + /* --- 쓰는 계산식(서버 식 고름 · 고친 값으로) --- */ + const formulaBox = el("section", { className: "m01-machine-detail__uses" }); + let formulaTimer: number | undefined; + const paintFormula = (): void => { + window.clearTimeout(formulaTimer); + formulaTimer = window.setTimeout(async () => { + const got = await fetchMachineFormula(cur).catch(() => null); + if (!got || !alive()) return; + const how = + got.방법 === "목록" + ? L("M01_MachineFormulaListed") + : got.방법 === "그 기계 식" + ? L("M01_MachineFormulaOwn") + : L("M01_MachineFormulaShape"); + formulaBox.replaceChildren( + el("strong", { text: L("M01_MachineFormula") }), + createText(got.키 ? `${got.키} ${got.이름 ?? ""} · ${how}` : L("M01_MachineFormulaNone")), + ...(got.까닭 ? [createText(got.까닭, "warning")] : []), + ); + }, 300); }; /* --- 같은 이름 · 규격 알림 --- */ @@ -275,10 +299,12 @@ async function build(host: HTMLElement, opts: MachineDetailOptions, alive: () => notice, metaBox, form.root, + formulaBox, usesBox, ], }), ); + paintFormula(); if (opts.target === "new") host.querySelector("details")?.setAttribute("open", ""); } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Values.py b/M02_MasterTemplete/DataFlow/M02_DF_Values.py index 060bad891..2d0224d59 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_Values.py +++ b/M02_MasterTemplete/DataFlow/M02_DF_Values.py @@ -258,59 +258,32 @@ def _user(uid: str, local: list[dict]) -> dict: MACHINE_SEP = "#" # 입력 판(sub3) 기계 요소 ref 「EQ키#칸」 HOURLY = "시간당 경비" # 기계 칸 이름 — 기계 시간당 경비 로직으로 셈(기종 → 로직 짝) -_MACHINE_PLAIN = {"기계", "지역", "보정작업", "조종원직종"} # 이 밖의 입력 = 부착 장비 전용 - - -def _machine_family() -> list[dict]: - """기계 시간당 경비 로직 — 「건설기계」 장 파일 안 · 입력 「기계」 고르기가 있는 돈 로직.""" - files = store.loaded()[0] - return [ - row - for name, data in files.items() - if data.get("그룹") == "로직" and "건설기계" in name - for row in data.get("줄") or [] - if str(row.get("결과단위") or "").startswith("원") - and any(i.get("이름") == "기계" and i.get("고르기") for i in row.get("입력") or []) - ] - - -def _machine_rank(row: dict) -> tuple: - """짝 차례(옛 EasyCalc `pickMachine` 와 같음) — 부착 전용 · 손료만 · 보정 · 기종 수 · 키.""" - names = [i.get("이름") for i in row.get("입력") or []] - codes = next(i.get("고르기") for i in row["입력"] if i.get("이름") == "기계") - attach = any(n not in _MACHINE_PLAIN for n in names) - loss = "지역" not in names - return (attach, loss, loss and "보정작업" in names, len(codes), str(row.get("키"))) def _machine_hourly(key: str, inputs: dict) -> dict: - """기계 한 시간 값 — 기종을 받는 기계경비 로직 가운데 짝 차례 첫째 · 빈 입력은 첫 고르기.""" + """기계 한 시간 값 — 식 고름 = `M02_Machine_Hourly.pick`(목록 › 꼴 · 822-2) · 빈 입력은 첫 고르기.""" + from M02_MasterTemplete import M02_Machine_Hourly as mh + row, why = _brief(key, "기계") if row is None: return {"ok": False, "reason": why} code = str(row.get("원문번호") or "") - fit = [r for r in _machine_family() if code in [str(c) for c in _codes(r)]] - if not fit: - return {"ok": False, "reason": f"「{row.get('이름')}」 은 시간당 경비를 셀 자료가 없음"} - logic = min(fit, key=_machine_rank) + logic = mh.pick(row) + if lack := mh.why(row, logic): + return {"ok": False, "reason": f"「{row.get('이름')}」 — {lack}"} given = {} for spec in logic.get("입력") or []: name = spec.get("이름") if name != "기계": given[name] = inputs.get(name, (spec.get("고르기") or [None])[0]) - args = "".join(f", {n}='{v}'" for n, v in given.items()) - ref = f"로직({logic['키']}, 기계='{code}'{args})" + args = "".join(f", {n if n.isidentifier() else repr(n)}='{v}'" for n, v in given.items()) + mine = f", 기계='{code}'" if mh.takes_machine(logic) else "" + ref = f"로직({logic['키']}{mine}{args})" line = {"종류": "로직", "요소": ref, "이름": "기계", "단위": "시간", "수량": "1"} got = _money(_calc({}, _row(line, ())), "계") return {**got, "name": row.get("이름"), "unit": "원/시간"} if got["ok"] else got -def _codes(row: dict) -> list: - return next( - (i.get("고르기") or [] for i in row.get("입력") or [] if i.get("이름") == "기계"), [] - ) - - def one_value(item: dict, local: list[dict] | None = None) -> dict: """물음 한 줄 `CfQuery {id, group, ref? · table? · unit? · field? · inputs?}` → `CfAnswer`. diff --git a/M02_MasterTemplete/M02_Item_Prices.py b/M02_MasterTemplete/M02_Item_Prices.py index 287b0e2fc..1c3c33176 100644 --- a/M02_MasterTemplete/M02_Item_Prices.py +++ b/M02_MasterTemplete/M02_Item_Prices.py @@ -186,10 +186,11 @@ class _Collector: return self.add(base) if field == dv.HOURLY: - code = str((self.whole.index.get(base[:2], {}).get(base) or {}).get("원문번호") or "") - for fit in dv._machine_family(): - if code in [str(c) for c in dv._codes(fit)]: - self.logic(str(fit.get("키"))) + from M02_MasterTemplete import M02_Machine_Hourly as mh + + row = self.whole.index.get(base[:2], {}).get(base) or {} + if got := mh.pick(row): # 식 고름 한 곳(822-2) + self.logic(str(got.get("키"))) def logic(self, key: str) -> None: if key in self.logics: diff --git a/M02_MasterTemplete/M02_Machine_Hourly.py b/M02_MasterTemplete/M02_Machine_Hourly.py index 03aa70c0b..0e4fb4952 100644 --- a/M02_MasterTemplete/M02_Machine_Hourly.py +++ b/M02_MasterTemplete/M02_Machine_Hourly.py @@ -7,17 +7,30 @@ 마스터 파일은 읽기만 · 로그인 사용자 모두. 기계표 = M01 + 지금 층 내 기계(821-1 · `M02_MyMachine` — 식은 채운 칸으로 고른 같은 GC 식 · 직접 = 그 값) · 층을 모르는 길(`/api/m02/logic-lib/machine-hourly`)은 M01 만. +식 고름 한 곳(822-2 · `pick`) = ㉠ 원문번호가 식 「기계」 목록에 있으면 그 식(짝 차례) ㉡ 그 기계만의 식 +(`_own`) ㉢ 없으면 꼴(채운 칸 · `M02_MyMachine.logic_key`) — M01 [새 기계] · 불러오기 · 데이터 흐름 · +줄 쌓기 · 단가산출 기초가 다 이것을 씀 · 못 고름 · 값 모자람 = 까닭 글(`why`). """ from __future__ import annotations +import re from typing import Any from M01_MasterData import M01_MasterData_Store as store from M02_MasterTemplete import M02_MyMachine as mym cm = store.cm -_PLAIN = {"기계", "지역", "보정작업", "조종원직종"} # 이 밖의 입력 = 부착 장비 전용 +_PLAIN = { + "기계", + "지역", + "보정작업", + "조종원직종", + "전력 계약종별", +} # 이 밖의 입력 = 부착 장비 전용 +_TEXT = {"주연료종류", "조종원직종"} # 글 칸 — 나머지 기계 줄 값 칸은 수 +_FIELD = re.compile(r"EQ:\{기계\}\.([^\s*/+()\-,]+)") +HOUR_UNIT = "10^-7/시간" def _family() -> list[dict]: @@ -54,12 +67,27 @@ def _operated(code: str) -> bool: def logic_of(code: str) -> dict | None: - """기계 원문번호 → 그 기계 시간당 경비 로직(짝 차례 첫째) · 없으면 None(813-2 M01 화면 · - 불러오기 `M02_Doc_Import.hourly_link` 도 씀). 기계표 조종원 > 0 = 조종원 호표 있는 식 먼저 - (「조종원 없는 기종」 목록에 겹친 기계 — 진동롤러(핸드가이드식) · 고소작업차 …).""" - fit = [r for r in _family() if code in [str(c) for c in _codes(r)]] + """기계 원문번호 → 그 기계 시간당 경비 로직 · 없으면 None(813-2 M01 화면 · 불러오기 + `M02_Doc_Import.hourly_link` 도 씀) — `pick` 과 같음(그 원문번호 기계 줄로).""" + index = mym.whole() + row = (index.numbers.get("EQ") or {}).get(code) or {"원문번호": code} + return pick(row) + + +def listed(code: str) -> bool: + """원문번호가 어느 기계경비식 「기계」 목록에 있는지(M01 [새 기계] 식 등록 · 상세 「쓰는 계산식」).""" + return bool(code) and any(code in [str(c) for c in _codes(r)] for r in _family()) + + +def pick(row: dict) -> dict | None: + """기계 줄 → 시간당 경비 식 — ㉠ 목록 ㉡ 그 기계만의 식 ㉢ 꼴 · 못 고름 = None(까닭 = `why`).""" + code = str(row.get("원문번호") or "") + fit = [r for r in _family() if code and code in [str(c) for c in _codes(r)]] if not fit: - return _own(code) or mym.logic_for(code) + if got := _own(code): + return got + key = None if _day_loss(row) else mym.logic_key(row) + return mym.whole().logic(key) if key else None op = _operated(code) def order(row: dict) -> tuple: @@ -84,6 +112,47 @@ def _own(code: str) -> dict | None: return None +def _day_loss(row: dict) -> str: + """손료계수 단위가 시간당(10⁻⁷)이 아님(산림품셈 「1일 1대당」) — 그 단위 글 · 아니면 빈 글.""" + unit = str((row.get("단위") or {}).get("손료계수") or HOUR_UNIT) + return "" if unit == HOUR_UNIT or "손료계수" not in (row.get("값") or {}) else unit + + +def _is_num(v: Any) -> bool: + try: + float(v) + except (TypeError, ValueError): + return False + return not isinstance(v, bool) + + +def why(row: dict, logic: dict | None) -> str: + """시간당 경비를 못 내는 까닭 한 줄(값이 정말 모자람) · 셀 수 있으면 빈 글.""" + vals = row.get("값") or {} + if logic is None: + if unit := _day_loss(row): + return f"손료계수 단위 「{unit}」 — 시간당 식 없음(일 단위 손료)" + if not _is_num(vals.get("주연료")) and vals.get("주연료") not in (None, ""): + return f"주연료 「{vals['주연료']}」 — 수 아님" + if float(vals.get("조종원") or 0) > 0 and not vals.get("조종원직종"): + return "조종원직종 없음" + if vals.get("주연료") and vals.get("전력"): + return "주연료 · 전력 둘 다 — 식 없음" + return "손료 계수 셋(상각 · 정비 · 관리) 없음" + text = " ".join(str(x.get(k)) for k in ("식", "수량") for p in ("중간", "호표", "덧줄") + for x in logic.get(p) or []) # fmt: skip + lost, odd = [], [] + for name in dict.fromkeys(_FIELD.findall(text)): + v = vals.get(name) + if v in (None, ""): + lost.append(name) + elif name not in _TEXT and not _is_num(v): + odd.append(f"{name} 「{v}」 — {'범위' if '∼' in str(v) else '수 아님'}") + elif name == "주연료종류" and f"{v}:전국평균" not in (mym.whole().numbers.get("MO") or {}): + odd.append(f"주연료종류 「{v}」 — 유가 표에 없음") + return " · ".join([*([f"{' · '.join(lost)} 없음"] if lost else []), *odd]) + + def takes_machine(logic: dict) -> bool: """식이 「기계」 입력(원문번호 고르기)을 받는지 — 안 받는 식(GF000145)엔 기계='…' 를 안 넘김.""" return any(i.get("이름") == "기계" for i in logic.get("입력") or []) @@ -100,9 +169,9 @@ def hourly(key: str) -> dict[str, Any]: if mym.mine(key): return mym.hourly(row, whole) code = str(row.get("원문번호") or "") - logic = logic_of(code) - if not logic: - return {"ok": False, "reason": f"「{row.get('이름')}」 은 시간당 경비를 셀 자료가 없음"} + logic = pick(row) + if lack := why(row, logic): + return {"ok": False, "reason": lack, **({"로직": logic["키"]} if logic else {})} args = "".join( # 빈칸 든 입력 이름(「전력 계약종별」 · 820-1 파워트로웰)은 따옴표로 f", {n if (n := i['이름']).isidentifier() else repr(n)}='{(i.get('고르기') or [''])[0]}'" for i in logic.get("입력") or [] diff --git a/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts index b1d684bd6..191987444 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts @@ -18,7 +18,7 @@ * · (785-3) `oil` = M01 유가전력(MO · `/logic-lib/elements?group=재료` 의 MO 줄) 고름 · 고정 줄 꼴 = 이음 유가 MO · * 재료비 = 지금 값(셈은 M01 값) · 「M01 전체」 탭 재료 아래 * · (821-3) `mine` = 개인 층 내 기계 묶음(품셈 기계 위 · 구분 「내 기계」 · 출처 「개인 키」 · 금액 = 서버 시간당) · - * 머리 [새 기계 만들기](양식 창 → 저장하면 그 기계로 고름) · 「시간당 값 직접」 내 기계 = 줄 쌓기 식 없음 → 회색 + * 머리 [새 기계 만들기](양식 창 → 저장하면 그 기계로 고름) · 「시간당 값 직접」 내 기계 = 직접 값 식 GC001199(822-2) · 식 없는 내 기계 = 회색 * ========================================================================== */ import { createDataTable } from "@ui/ui_template_data_table"; @@ -288,7 +288,7 @@ export function openFindPicker(opt: FindPickOptions): void { : take(() => put(e)), }; }); - /** 821-3 내 기계 줄 — 금액 = 서버 시간당 · 직접 = 회색(줄 쌓기 식 없음) */ + /** 821-3 내 기계 줄 — 금액 = 서버 시간당 · 식 없음(서버 로직 없음) = 회색 · 직접 = GC001199(822-2) */ const mineLines = (q: string): Line[] => { const words = q.toLowerCase().split(/\s+/).filter(Boolean); return mines diff --git a/M02_MasterTemplete/M02_MyMachine.py b/M02_MasterTemplete/M02_MyMachine.py index d7ec59cac..af0326b21 100644 --- a/M02_MasterTemplete/M02_MyMachine.py +++ b/M02_MasterTemplete/M02_MyMachine.py @@ -10,8 +10,9 @@ 내 기계 없는 층 = M01 그대로. 단가산출 사본 · [단가 갱신] · 「단가새값」(`M02_Sangeun_Copy`) · 기계 찾기(`M02_Machine_Find`) · 시간당(`M02_Machine_Hourly`) · 사본 모으기(`M02_Item_Prices.collect`)가 이 색인을 읽음 — 층을 모르는 길(`/api/m02/logic-lib/machine-hourly` 등)은 M01 만. -식 고름(채운 칸) = 연료 · 조종원 둘 다 GC000268 · 연료만 GC000272 · 조종원만 GC000276 · 없음 GC000273. -전력 칸은 담기만(M01 기계경비 식에 전력 줄 없음). +식 고름(채운 칸 · `logic_key` — M01 기계 목록 밖 줄도 같은 규칙 · 822-2) = 연료 + 조종원 GC000268(직종 없음 +GC000275) · 연료만 GC000272(손료계수 한 칸 GC001198) · 조종원만 GC000276 · 전력 GC001196(+ 조종원 GC001197) · +없음 GC000273 · 직접 GC001199(시간당 값 = 셈 꼴 값 칸 노무비 · 재료비 · 경비). 권한(본인 개인 층 · 맡은 회사 관리자)은 길(`M02_MyMachine_Router`) 몫. """ @@ -43,6 +44,8 @@ UNITS = { } # fmt: skip LOGIC = {(True, True): "GC000268", (True, False): "GC000272", (False, True): "GC000276", (False, False): "GC000273"} # fmt: skip +JOB_IN, ONE_FUEL = "GC000275", "GC001198" # 직종 입력 · 손료계수 한 칸 연료 +POWER, POWER_CREW, DIRECT_KEY = "GC001196", "GC001197", "GC001199" def mine(key: Any) -> bool: @@ -80,6 +83,8 @@ def rows(folder: Path | None = None) -> list[dict]: def engine(row: dict) -> dict: """셈 꼴 — 바로 = 상각비계수 ← 손료계수 · 정비 · 관리 0 · 빈 연료 · 조종원 칸 = 0 · 수 = Decimal.""" vals = dict(row.get("값") or {}) + if row.get("방식") == DIRECT: # 직접 식(GC001199)이 읽는 칸 + vals = {k: (row.get("시간당") or {}).get(k) or 0 for k in COSTS} if row.get("방식") == EASY: vals |= {"상각비계수": vals.get("손료계수") or 0, "정비비계수": 0, "관리비계수": 0} if row.get("방식") != DIRECT: @@ -123,18 +128,39 @@ def keys_in(doc: Any) -> list[str]: return sorted(set(FIND.findall(json.dumps(doc, ensure_ascii=False, default=str)))) -def logic_key(row: dict) -> str: +def _pos(v: Any) -> bool: + try: + return not isinstance(v, bool) and float(v) > 0 + except (TypeError, ValueError): + return False + + +def logic_key(row: dict) -> str | None: + """꼴(채운 칸) → 시간당 경비 식 키 · 못 고름(직종 없는 조종원만 · 연료 + 전력 · 수 아닌 연료 글) = None · + 계수 셋(상각 · 정비 · 관리) 없음 = 손료계수 한 칸 식(연료만) · 그 밖은 그 식 + 모자란 칸 까닭.""" + if row.get("방식") == DIRECT: + return DIRECT_KEY vals = row.get("값") or {} - return LOGIC[(float(vals.get("주연료") or 0) > 0, float(vals.get("조종원") or 0) > 0)] + fuel, crew, power = (_pos(vals.get(k)) for k in ("주연료", "조종원", "전력")) + job = bool(vals.get("조종원직종")) + three = all(vals.get(k) is not None for k in COEFS) + if vals.get("주연료") not in (None, "", 0) and not fuel or fuel and power: + return None + if power: + return POWER_CREW if crew and job else None if crew else POWER + if fuel and not crew: + return LOGIC[(True, False)] if three else ONE_FUEL + if crew: + return LOGIC[(fuel, True)] if job else JOB_IN if fuel else None + return LOGIC[(False, False)] def logic_for(code: str) -> dict | None: """내 기계 원문번호 → 시간당 경비 식(채운 칸으로 고름) · 내 기계 아님 = None.""" index = whole() row = (index.numbers.get("EQ") or {}).get(code) - if row is None or not mine(row.get("키")) or row.get("방식") == DIRECT: - return None - return index.logic(logic_key(row)) + key = logic_key(row) if row is not None and mine(row.get("키")) else None + return index.logic(key) if key else None def hourly(row: dict, index: Any) -> dict[str, Any]: @@ -142,11 +168,21 @@ def hourly(row: dict, index: Any) -> dict[str, Any]: head = {"이름": row.get("이름"), "규격": row.get("규격"), "원문번호": row.get("원문번호")} if row.get("방식") == DIRECT: sums = {k: float((row.get("시간당") or {}).get(k) or 0) for k in COSTS} - return {"ok": True, **head, "로직": None, "직접": True, **sums, "계": sum(sums.values())} + return { + "ok": True, + **head, + "로직": DIRECT_KEY, + "직접": True, + **sums, + "계": sum(sums.values()), + } import master_cut as mcut mf = m01.cm.mf - logic = index.logic(logic_key(row)) + key = logic_key(row) + if key is None: + return {"ok": False, **head, "reason": "식 없음 — 연료 · 전력은 하나만"} + logic = index.logic(key) given = {i["이름"]: (i.get("고르기") or [""])[0] for i in logic.get("입력") or []} given["기계"] = str(row.get("원문번호")) try: @@ -203,6 +239,8 @@ def clean(raw: Any) -> dict[str, Any]: need = ("가격", "손료계수") if how == EASY else ("가격", *RATIOS) if lost := [k for k in need if not vals.get(k)]: raise _bad(f"빈 칸 — {' · '.join(lost)}") + if vals.get("주연료") and vals.get("전력"): + raise _bad("주연료 · 전력은 하나만(원문 운전경비 표 한 칸)") drop = (*RATIOS, *COEFS) if how == EASY else () vals = {k: v for k, v in vals.items() if k not in drop} if how == BOOK: diff --git a/M02_MasterTemplete/M02_MyMachine_Text.ts b/M02_MasterTemplete/M02_MyMachine_Text.ts index 19b1e1732..20522733e 100644 --- a/M02_MasterTemplete/M02_MyMachine_Text.ts +++ b/M02_MasterTemplete/M02_MyMachine_Text.ts @@ -50,7 +50,7 @@ const TEXT = { // 기계 고르기 창 · 줄 쌓기 Pick_Group: "내 기계", Pick_New: "새 기계 만들기", - Pick_Direct: "시간당 값 직접 기계 — 줄 쌓기 식이 없어 못 넣음", + Pick_Direct: "시간당 경비 식이 없는 기계 — 줄 쌓기에 못 넣음", To_Mine: "복사해 내 기계로", // 내 품목 Items_Note: diff --git a/common_util/common_util_machine_coef.ts b/common_util/common_util_machine_coef.ts index b5a66f2d8..83316e0ee 100644 --- a/common_util/common_util_machine_coef.ts +++ b/common_util/common_util_machine_coef.ts @@ -5,7 +5,7 @@ * ⚠ 짝: `resources/master_data/scripts/master_machine_coef.py` — 같은 셈 · 같은 차례 * (거울 시험 `resources/tester/test_821_2_machine_coef.py`). 한쪽만 고치면 화면과 저장이 갈림. * 계수 = 건설품셈 공통 8-1-5(계수마다 정수 반올림 · 손료계수 = 합) · 미리보기 끝수 = 서버 - * 기계경비식(줄 0.1 원 아래 버림 · 잡품 = 버린 주연료 × % · 비목 합 원 미만 버림) · 정본은 서버 식. + * 기계경비식(줄 0.1 원 아래 버림 · 잡품 = 버린 (주연료 + 전력) × % · 비목 합 원 미만 버림) · 정본은 서버 식. * ========================================================================== */ export const MACHINE_INPUTS = [ @@ -73,8 +73,8 @@ export function machineHourly( ): MachineHourly { const loss = Math.floor((num(values["가격"]) * 1000 * num(values["손료계수"])) / 1e7 + E); const oil = tenth(num(values["주연료"]) * num(fuel)); - const misc = tenth((oil * num(values["잡재료율"])) / 100); const elec = tenth(num(values["전력"]) * num(power)); + const misc = tenth(((oil + elec) * num(values["잡재료율"])) / 100); const crew = tenth(num(values["조종원"]) * hour * num(wage)); const labor = Math.floor(crew + E); const material = Math.floor(oil + misc + elec + E); diff --git a/resources/master_data/scripts/master_machine_coef.py b/resources/master_data/scripts/master_machine_coef.py index 61ff5dc53..e9ae5f8c6 100644 --- a/resources/master_data/scripts/master_machine_coef.py +++ b/resources/master_data/scripts/master_machine_coef.py @@ -10,7 +10,8 @@ 계수마다 정수로 반올림 · 손료계수 = 셋의 합. 미리보기 = 서버 기계경비식(GC000267 ~)을 기본 옵션(보정 없음)으로 부른 값과 같은 끝수 — 손료 = 가격(천원) × 1000 × 손료계수 ÷ 10^7 원 미만 버림 · 줄 금액은 0.1 원 아래 버림 · - 잡품 = 버린 주연료 금액 × 잡재료율 % · 비목 합은 원 미만 버림(B09 끝수 `master_cut.engine`). + 잡품 = 버린 (주연료 + 전력) 금액 × 잡재료율 %(전력 기종 = 전력비의 % · 822-2 GC001196 · 7) · + 비목 합은 원 미만 버림(B09 끝수 `master_cut.engine`). 정본은 서버 식(`M02_Machine_Hourly`) — 이것은 화면 즉시 셈용. """ @@ -61,8 +62,8 @@ def preview( """값 묶음 + 유가(원/ℓ) · 노임(원/일) · 전력(원/kWh) · 시간환산 → 시간당 줄 · 비목 · 계.""" loss = math.floor(_num(values.get("가격")) * 1000 * _num(values.get("손료계수")) / 1e7 + _E) oil = _tenth(_num(values.get("주연료")) * _num(fuel)) - misc = _tenth(oil * _num(values.get("잡재료율")) / 100) elec = _tenth(_num(values.get("전력")) * _num(power)) + misc = _tenth((oil + elec) * _num(values.get("잡재료율")) / 100) crew = _tenth(_num(values.get("조종원")) * hour * _num(wage)) labor, material = math.floor(crew + _E), math.floor(oil + misc + elec + _E) return { diff --git a/resources/tester/test_804_2_line_stack.py b/resources/tester/test_804_2_line_stack.py index f0d9f6832..dedd53bee 100644 --- a/resources/tester/test_804_2_line_stack.py +++ b/resources/tester/test_804_2_line_stack.py @@ -116,7 +116,9 @@ def _hourly_inputs(logic_key: str) -> dict: def _kind(row: dict) -> tuple: - return (*mh._rank(row)[:3], any(x.get("이름") == "조종원" for x in row.get("호표") or [])) + """갈래 = 부착 · 손료만 · 보정 · 조종원 줄 · 전력 줄(822-2 전력 기종 식 — 손료만 식과 갈림).""" + lines = {x.get("이름") for x in row.get("호표") or []} + return (*mh._rank(row)[:3], "조종원" in lines, "전력" in lines) # 기계표 조종원 1 인 · 「조종원 없는 기종」 GC000272 목록에도 겹친 기계(815-3) — 조종원 든 식 diff --git a/resources/tester/test_822_2_machine_hourly_all.py b/resources/tester/test_822_2_machine_hourly_all.py new file mode 100644 index 000000000..3a2359d0c --- /dev/null +++ b/resources/tester/test_822_2_machine_hourly_all.py @@ -0,0 +1,252 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""822-2 기계 시간당 경비 식 완성 — 지킴 시험(정본 고침은 임시 사본 폴더 · 임시 storage 만). + +① 모든 기계 = 식 있음 또는 까닭 글(「셀 자료가 없음」 0) ② 식 고름 = 목록 › 꼴(채운 칸) 한 함수 +③ M01 [새 기계] 저장 = 꼴 식 목록 등록 · 지우면 목록에서 뺌 · 로직 파일 글 그대로(임시 마스터) +④ 직접 값 식(GC001199) — 내 기계 「직접」 시간당 · 줄 쌓기 식 ⑤ 전력 줄 — 점보드릴 135㎾ × 전력 단가 + +잡재료 6 % + 손료 + 조종원 · 계약종별 바꿈 · 내 기계 전력 ⑥ 옮긴 기계 견본(아스팔트 믹싱플랜트 · 타워크레인 · +직종 입력 = 아스팔트 스프레어) ⑦ 미리보기 = 서버 값 ⑧ 안 옮긴 기계 금액 그대로 ⑨ POWER TROWEL 이름. +""" + +from __future__ import annotations + +import json +import shutil +import sys +from decimal import Decimal +from pathlib import Path + +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from common_util import common_util_auth as auth +from M01_MasterData import M01_MasterData_Router as router_module +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Labor_Ref as refs +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_Sangeun_Calc as calc +from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT / "resources" / "master_data" / "scripts")) +import master_machine_coef as mc # noqa: E402 + +MACHINE, LOGIC = "기계.json", "로직_건설품셈_08장_건설기계.json" +POWER, POWER_CREW, ONE_FUEL, DIRECT = "GC001196", "GC001197", "GC001198", "GC001199" +NAME = "sub1임시기계822" +# 안 옮긴 기계 — 822-2 전 금액(전국평균 · 기본 옵션) +KEEP = {"EQ000001": 68550, "EQ000022": 108958} + + +def _rows() -> dict[str, dict]: + return {r["키"]: r for r in m01.read(MACHINE)[0]["줄"]} + + +def _wage(job: str) -> Decimal: + return Decimal(str(m01.loaded()[1].get(f"LB:{job}")["값"])) + + +def _codes(key: str) -> list[str]: + row = next(r for r in m01.read(LOGIC)[0]["줄"] if r["키"] == key) + return [str(c) for c in next(i for i in row["입력"] if i["이름"] == "기계").get("고르기", [])] + + +# ① · ② ─────────────────────────────────────────────────────────────── +def test_every_machine_has_formula_or_reason(): + """모든 기계 — 식을 고르고 값이 다 있음 · 아니면 까닭 글(빈 답 0).""" + silent = [] + for key, row in _rows().items(): + row = m01.loaded()[1].get(key) + logic = mh.pick(row) + if not logic and not mh.why(row, logic): + silent.append(key) + assert silent == [] + assert mh.hourly("EQ000343")["reason"] == "가격 없음" # TBM 3.0m + assert "일 단위" in mh.hourly("EQ000621")["reason"] # 산림품셈 예취기(1일 1대당) + assert mh.hourly("EQ000202")["reason"] == "주연료 「73.7+」 — 수 아님" # 표층재생기 · 안 풂 + assert "범위" in mh.hourly("EQ000552")["reason"] # 펌프 준설선 잡재료 13∼18 + assert ( + "중유" in mh.hourly("EQ000187")["reason"] + ) # 아스팔트 믹싱플랜트 40t — 유가 표에 중유 없음 + + +def test_pick_listed_then_shape(): + """목록에 있으면 그 식(짝 차례) · 없으면 꼴 — 내 기계와 같은 규칙.""" + assert mh.logic_of("0101-0007")["키"] in {"GC000268", "GC000269"} # 목록 + shape = mym.logic_key + full = {"가격": 1, "손료계수": 3, "상각비계수": 1, "정비비계수": 1, "관리비계수": 1} + assert shape({"값": {**full, "주연료": 1, "조종원": 1, "조종원직종": "1048"}}) == "GC000268" + assert shape({"값": {**full, "주연료": 1, "조종원": 1}}) == "GC000275" + assert shape({"값": {**full, "주연료": 1}}) == "GC000272" + assert shape({"값": {"가격": 1, "손료계수": 3, "주연료": 1}}) == ONE_FUEL + assert shape({"값": {**full, "조종원": 1, "조종원직종": "1048"}}) == "GC000276" + assert shape({"값": {**full, "전력": 5}}) == POWER + assert shape({"값": {**full, "전력": 5, "조종원": 1, "조종원직종": "1048"}}) == POWER_CREW + assert shape({"값": full}) == "GC000273" + assert shape({"방식": "직접", "값": {}}) == DIRECT + assert shape({"값": {**full, "주연료": 1, "전력": 5}}) is None + assert shape({"값": {**full, "조종원": 1}}) is None # 직종 없는 조종원만 — 까닭 + assert shape({"값": {**full, "주연료": "73.7+"}}) is None + + +# ③ M01 [새 기계] 식 등록 ────────────────────────────────────────────── +@pytest.fixture +def copy(tmp_path: Path, monkeypatch) -> Path: + for path in m01.FOLDER.glob("*.json"): + if not path.name.startswith("_") or path.name == m01.mk.BOOK.name: + shutil.copy(path, tmp_path / path.name) + monkeypatch.setattr(m01, "FOLDER", tmp_path) + return tmp_path + + +def _app() -> TestClient: + app = FastAPI() + app.dependency_overrides[auth.verify_session] = lambda: { + "user_id": 1, "name": "시험", "role": "SYSTEM_ADMIN", "company_id": 1, + } # fmt: skip + deps = [Depends(auth.verify_session), Depends(auth.require_system_admin)] + app.include_router(router_module.router, dependencies=deps) + return TestClient(app) + + +def _save(client: TestClient, changes: list[dict]) -> None: + version = client.get("/api/m01/machine/row", params={"key": "EQ000022"}).json()["version"] + body = {"files": [{"file": MACHINE, "version": version, "changes": changes}]} + res = client.post("/api/m01/save", json=body) + assert res.status_code == 200, res.text + + +def test_new_machine_registers_and_unregisters(copy: Path): + client = _app() + logic_text = (copy / LOGIC).read_text(encoding="utf-8") + seed = json.loads(json.dumps(_rows()["EQ000022"], default=float)) + row = {**seed, "키": "", "원문번호": "9999-0822", "이름": NAME, "출처": {}, "단위": {}} + del row["별칭"] + draft = client.post("/api/m01/machine/formula", json=row).json() + assert draft["키"] == "GC000268" and draft["방법"] == "꼴" and not draft["까닭"] + _save(client, [{"op": "add", "row": row}]) + assert "9999-0822" in _codes("GC000268") + key = next(k for k, r in _rows().items() if r["원문번호"] == "9999-0822") + hour = client.get("/api/m01/machine/hourly", params={"keys": key}).json()[key] + assert hour["로직"] == "GC000268" and hour["계"] == mh.hourly("EQ000022")["계"] + saved = json.loads(json.dumps(_rows()[key], default=float)) + got = client.post("/api/m01/machine/formula", json=saved).json() + assert got["방법"] == "목록" and got["키"] == "GC000268" + _save(client, [{"op": "delete", "key": key}]) + assert "9999-0822" not in _codes("GC000268") + assert (copy / LOGIC).read_text(encoding="utf-8") == logic_text + + +# ④ 직접 값 식 ───────────────────────────────────────────────────────── +def _at(folder, fn, *args): + with store.at(folder): + return fn(*args) + + +def test_direct_formula_my_machine(world): + me = world["personal"] + body = { + "이름": NAME, + "규격": "직접", + "방식": "직접", + "시간당": {"노무비": 10, "재료비": 20, "경비": 30}, + } + made = _at(me, mym.create, body) + assert made["시간당"]["로직"] == DIRECT and made["시간당"]["계"] == 60 + no = made["줄"]["원문번호"] + line = {"종류": "로직", "로직": DIRECT, "입력값": {"기계": no}, "수량": "2"} + name = _at( + me, lambda: refs.create("sangeun", {"이름": NAME, "단위": "시간", "더한줄": [line]})["이름"] + ) + doc = _at(me, lambda: store.read("sangeun", name)["문서"]) + got = _at(me, calc.calc_doc, doc, None, False)["기준"] + assert (got["노무비"], got["재료비"], got["경비"]) == (20, 40, 60) + + +# ⑤ 전력 줄 ──────────────────────────────────────────────────────────── +def _preview(key: str) -> dict: + v = json.loads(json.dumps(_rows()[key]["값"], default=float)) + fuel = m01.loaded()[1].numbers["MO"].get(f"{v.get('주연료종류')}:전국평균") + wage = float(_wage(v["조종원직종"])) if v.get("조종원직종") else 0 + return mc.preview(v, float((fuel or {}).get("값") or 0), wage, 114.4) + + +def test_power_line_jumbo_drill(): + h = mh.hourly("EQ000347") # 점보드릴 2붐 135㎾ · 잡재료 6 % · 조종원 1(건설기계운전사) + assert h["로직"] == POWER_CREW + loss = 605472 * 1000 * 2515 // 10**7 + elec = Decimal("135") * Decimal("114.4") + misc = int(elec * 6) / Decimal(100) + assert h["경비"] == loss and h["재료비"] == int(elec + misc) + assert h["노무비"] == int(_wage("1048") / 8) + p = _preview("EQ000347") + assert (p["노무비"], p["재료비"], p["경비"], p["계"]) == ( + h["노무비"], + h["재료비"], + h["경비"], + h["계"], + ) + home = m01.calc(POWER_CREW, {"기계": "5805-0002", "전력 계약종별": "주택용 저압"}) + assert home["ok"] and float(home["sums"]["재료비"]) > h["재료비"] # 120 원/kWh > 114.4 + + +def test_power_my_machine(world): + me = world["personal"] + body = { + "이름": NAME, + "규격": "전력", + "방식": "바로", + "값": {"가격": 1000, "손료계수": 2000, "전력": 10, "잡재료율": 5}, + } + hour = _at(me, mym.create, body)["시간당"] + assert hour["로직"] == POWER + want = mc.preview({"가격": 1000, "손료계수": 2000, "전력": 10, "잡재료율": 5}, 0, 0, 114.4) + assert (hour["재료비"], hour["경비"]) == (want["재료비"], want["경비"]) == (1201, 200) + with pytest.raises(store.StoreError): + _at(me, mym.create, {**body, "값": {**body["값"], "주연료": 1, "주연료종류": "경유"}}) + + +# ⑥ · ⑦ 옮긴 기계 견본 ───────────────────────────────────────────────── +def test_moved_samples(): + plant = mh.hourly("EQ000188") # 아스팔트 믹싱플랜트 60t — 연료 + 조종원 2 인 + assert plant["로직"] == "GC000268" and plant["노무비"] == int(_wage("1048") * 2 / 8) + assert plant["재료비"] > 0 + tower = mh.hourly("EQ000165") # 타워크레인 — 건설기계운전사 조종원 줄(요구 799) + assert tower["로직"] == "GC000276" and tower["노무비"] == int(_wage("1048") / 8) + spray = m01.loaded()[1].get("EQ000198") # 아스팔트 스프레어 — 직종 빈 칸 = 직종 입력 식 + assert mh.pick(spray)["키"] == "GC000275" and "조종원직종" in json.dumps( + mh.pick(spray)["입력"], ensure_ascii=False + ) + assert "가격" in mh.hourly("EQ000198")["reason"] + assert "3450-0642" not in _codes("GC000273") # 손료만 식에서 뺌 + for key, logic in ( + ("EQ000529", POWER), + ("EQ000534", ONE_FUEL), + ("EQ000532", POWER), + ("EQ000426", POWER), + ): + h = mh.hourly(key) + p = _preview(key) + assert h["로직"] == logic and (p["재료비"], p["경비"], p["계"]) == ( + h["재료비"], + h["경비"], + h["계"], + ), key + for key in ("EQ000188", "EQ000165"): + h, p = mh.hourly(key), _preview(key) + assert (p["노무비"], p["재료비"], p["경비"]) == (h["노무비"], h["재료비"], h["경비"]), key + + +# ⑧ · ⑨ ──────────────────────────────────────────────────────────────── +def test_unmoved_same_and_name(): + for key, total in KEEP.items(): + assert mh.hourly(key)["계"] == total + row = _rows()["EQ000534"] + assert (row["이름"], row["옛이름"], row["별칭"]) == ( + "POWER TROWEL", + "POWER TLOWEL", + "파워트로웰", + )