From 5e2ac2ae79aa95415cb4d1309776928401708d46 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 2 Nov 2026 18:44:12 +0900 Subject: [PATCH] =?UTF-8?q?feat(736-3):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=C2=B7=20=EC=9D=BC=EC=9C=84=EB=8C=80=EA=B0=80=20[?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=20=EC=B6=94=EA=B0=80]=20=EA=B8=B0=EA=B3=84(M?= =?UTF-8?q?01=20EQ)=20=EA=B3=A0=EB=A6=84=20=E2=80=94=20=EA=B3=A0=EC=A0=95?= =?UTF-8?q?=20=EC=A4=84(=EC=9D=B4=EC=9D=8C=20=EA=B8=B0=EA=B3=84=20=C2=B7?= =?UTF-8?q?=20=EB=8B=A8=EA=B0=80=20=3D=20=ED=95=9C=20=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EB=85=B8=20=C2=B7=20=EC=9E=AC=20=C2=B7=20=EA=B2=BD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 찾아 넣기 창 기계 줄 누름 = 서버 `/logic-lib/machine-hourly` 로 한 시간 값을 받아 고정 줄 단가로 - 일위대가 = 이음 기계 고정 줄 · 단가산출 = 더한줄 고정 줄(수량 1) — 같은 금액 - 서버 `M02_Machine_Hourly`(기계경비 로직 짝 차례 첫째 · 읽기만) · 시험 추가 Co-Authored-By: Claude Sonnet 5.5 --- M02_MasterTemplete/M02_Logic_Lib_Read.py | 7 ++ M02_MasterTemplete/M02_Machine_Hourly.py | 72 +++++++++++++++++++ .../M02_MasterTemplete_Find_Pick.ts | 36 +++++++++- .../M02_MasterTemplete_Labor_Pick.ts | 1 + .../M02_MasterTemplete_Sangeun_Pick.ts | 9 ++- resources/tester/test_721_find_pick.py | 41 ++++++++++- 6 files changed, 159 insertions(+), 7 deletions(-) create mode 100644 M02_MasterTemplete/M02_Machine_Hourly.py diff --git a/M02_MasterTemplete/M02_Logic_Lib_Read.py b/M02_MasterTemplete/M02_Logic_Lib_Read.py index ae6231d52..751134d50 100644 --- a/M02_MasterTemplete/M02_Logic_Lib_Read.py +++ b/M02_MasterTemplete/M02_Logic_Lib_Read.py @@ -11,6 +11,7 @@ from __future__ import annotations from fastapi import APIRouter, HTTPException from M01_MasterData import M01_MasterData_Router as m01 +from M02_MasterTemplete import M02_Machine_Hourly as machine router = APIRouter(prefix="/api/m02/logic-lib", tags=["M02 Logic Lib Read"]) @@ -71,3 +72,9 @@ def post_calc(body: m01.CalcBody) -> dict: @router.post("/text") def post_text(body: m01.CalcBody) -> dict: return m01.post_text(body) + + +@router.get("/machine-hourly") +def get_machine_hourly(key: str) -> dict: + """기계(EQ) 한 시간 값 노 · 재 · 경(736-3 · [항목 추가] 기계 고정 줄 단가).""" + return machine.hourly(key) diff --git a/M02_MasterTemplete/M02_Machine_Hourly.py b/M02_MasterTemplete/M02_Machine_Hourly.py new file mode 100644 index 000000000..71b6955b1 --- /dev/null +++ b/M02_MasterTemplete/M02_Machine_Hourly.py @@ -0,0 +1,72 @@ +"""M02 기계(M01 EQ) 한 시간 값 — [항목 추가] 창에서 기계를 고르면 고정 줄 단가(PLAN 736-3). + +기계 줄 = 일위대가 · 단가산출 더한줄의 고정 줄(이음 기계 EQ · 수량 1) — 단가 = 이 값(노 · 재 · 경). +셈 = M01 「건설기계」 장 기계경비 로직 가운데 그 기종을 받는 짝 차례 첫째 +(`로직(GC, 기계='원문번호')` · 옵션은 기본값). 짝 차례 · 셈은 +`DataFlow/M02_DF_Values._machine_hourly` 와 같은 규칙(그 폴더는 시험 길이라 따로 둠). +마스터 파일은 읽기만 · 로그인 사용자 모두. +""" + +from __future__ import annotations + +from typing import Any + +from M01_MasterData import M01_MasterData_Store as store + +cm = store.cm +_PLAIN = {"기계", "지역", "보정작업", "조종원직종"} # 이 밖의 입력 = 부착 장비 전용 + + +def _family() -> list[dict]: + """기계 시간당 경비 로직 — 「건설기계」 장 파일 안 · 입력 「기계」 고르기가 있는 돈 로직.""" + return [ + row + for name, data in store.loaded()[0].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 _codes(row: dict) -> list: + return next(i["고르기"] for i in row["입력"] if i.get("이름") == "기계") + + +def _rank(row: dict) -> tuple: + """부착 전용 · 손료만 · 보정 · 기종 수 · 키 — 작을수록 먼저.""" + names = [i.get("이름") for i in row.get("입력") or []] + attach = any(n not in _PLAIN for n in names) + loss = "지역" not in names + return (attach, loss, loss and "보정작업" in names, len(_codes(row)), str(row.get("키"))) + + +def hourly(key: str) -> dict[str, Any]: + """`{ok, 이름, 규격, 노무비, 재료비, 경비, 계}` · 못 셈 = `{ok: False, reason}`.""" + if not key.startswith("EQ"): + return {"ok": False, "reason": f"「{key}」 는 기계 아님"} + try: + row = store.loaded()[1].get(key) + except cm.mf.FormulaError as e: + return {"ok": False, "reason": str(e)} + code = str(row.get("원문번호") or "") + fit = [r for r in _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=_rank) + args = "".join( + f", {i['이름']}='{(i.get('고르기') or [''])[0]}'" + for i in logic.get("입력") or [] + if i.get("이름") != "기계" + ) + call = f"로직({logic['키']}, 기계='{code}'{args})" + line = {"종류": "로직", "요소": call, "이름": "기계", "단위": "시간", "수량": "1"} + holder = { + "키": "", "원문번호": "", "이름": "기계 시간", "결과단위": "원/식", "출처": "자체", + "입력": [], "중간": [], "호표": [line], "덧줄": [], "끝수": None, + } # fmt: skip + got = store.calc("", {}, holder, None) + if not got.get("ok") or "sums" not in got: + return {"ok": False, "reason": got.get("reason") or "셈 못 함"} + sums = {k: float(got["sums"][k]) for k in (*cm.mf.COST_ITEMS, "계")} + return {"ok": True, "이름": row.get("이름"), "규격": row.get("규격"), **sums} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts index 569c17244..21852349c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts @@ -49,6 +49,8 @@ export interface M01Element { 규격: string; 단위: string; 값: number | null; + /** 기계(736-3) — 한 시간 노 · 재 · 경(서버 `/logic-lib/machine-hourly` · 고르는 순간 받음) */ + 단가?: Record<"노무비" | "재료비" | "경비", number>; } /** 고를 수 있는 종류 — logic = M01 단가산출 · 기초 로직 · labor = 인력 · material = 재료 · machine = 기계 · sk = M02 단가산출 · pre = 일위대가 */ @@ -80,6 +82,16 @@ export const logicHeads = (rows: StandardRow[]): StandardRow[] => /** 일위대가 고정 줄(705-1 이음 꼴) — 인력 = 노임 LB · 재료 = 자재 MT · 단가 = 지금 값(셈은 단가 책 값) */ export function fixedOf(e: M01Element): LaborFixed { + if (e.종류 === "기계") { + // 736-3 기계 = 이음 기계 EQ · 단가 = 한 시간 노 · 재 · 경(셈은 줄 단가 그대로) + return { + 이름: e.이름, + 규격: e.규격, + 단위: e.단위, + 단가: e.단가 ?? { 노무비: 0, 재료비: 0, 경비: 0 }, + 이음: { 노임: null, 자재: null, 기계: e.코드 }, + }; + } const labor = e.종류 === "인력"; return { 이름: e.이름, @@ -171,6 +183,8 @@ export function openFindPicker(opt: FindPickOptions): void { rowClass: (i) => (shown[i]?.dim ? "is-dim" : null), onRowClick: (i) => shown[i]?.go(), }); + const fail = (error: unknown): void => + showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); const take = (f: () => void) => (): void => { close(); f(); @@ -199,6 +213,20 @@ export function openFindPicker(opt: FindPickOptions): void { }, ]; }); + /** 기계 고름 — 한 시간 값을 받아 단가로(못 셈 = 까닭 알림 · 창 그대로) */ + const hourly = async (e: M01Element): Promise => { + try { + const got = await call<{ ok: boolean; reason?: string } & Record>( + `/logic-lib/machine-hourly?key=${encodeURIComponent(e.코드)}`, + ); + if (!got.ok) throw new Error(got.reason ?? tc("Load_Failed")); + const { 노무비, 재료비, 경비, 계 } = got; + return { ...e, 값: 계, 단가: { 노무비, 재료비, 경비 } }; + } catch (error) { + fail(error); + return null; + } + }; const elementLines = (kind: M01Element["종류"], items: PickItem[]): Line[] => items.map((m) => { const e: M01Element = { @@ -223,7 +251,11 @@ export function openFindPicker(opt: FindPickOptions): void { 구분: join(kind, m.구분, put ? "" : L("M02_FindElementLater")), 출처: `M01 ${m.ref}`, dim: !put, - go: put ? take(() => put(e)) : () => showToast(L("M02_FindElementLater"), "warning"), + go: !put + ? () => showToast(L("M02_FindElementLater"), "warning") + : kind === "기계" + ? () => void hourly(e).then((x) => x && take(() => put(x))()) + : take(() => put(e)), }; }); const docLines = (rows: SangeunRow[], kind: string): Line[] => @@ -346,8 +378,6 @@ export function openFindPicker(opt: FindPickOptions): void { search.input.focus(); draw(); - const fail = (error: unknown): void => - showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); void Promise.all([fetchLibLogics(), fetchStandard().catch(() => null)]) .then(([l, s]) => { logics = l; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index f9c08a56e..98962e502 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -109,6 +109,7 @@ export function openLogicPicker( logic: done, ref: lib.pick, element: fixed && ((e) => fixed(fixedOf(e), e.이름)), + machine: fixed && ((e) => fixed(fixedOf(e), e.이름)), // 736-3 기계 = 이음 기계 고정 줄 material: lib.material, }); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts index c45c64732..776eb0c34 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun_Pick.ts @@ -9,8 +9,8 @@ * ========================================================================== */ import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch"; -import { openFindPicker } from "./M02_MasterTemplete_Find_Pick"; -import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc"; +import { fixedOf, openFindPicker } from "./M02_MasterTemplete_Find_Pick"; +import type { LaborFixed, LaborRef } from "./M02_MasterTemplete_Labor_Calc"; export const SANGEUN_KIND = "sangeun"; /** 층 차례 — 위에서 아래 */ @@ -73,7 +73,8 @@ export async function listLayerDocs( export type SangeunLine = | { 종류: "로직"; 로직: string; 입력값: Record; 수량: number } | { 참조: LaborRef; 수량: number } - | { 종류: "인력" | "재료"; 요소: string; 이름: string | null; 단위: string | null; 수량: number }; + | { 종류: "인력" | "재료"; 요소: string; 이름: string | null; 단위: string | null; 수량: number } + | { 고정: LaborFixed; 이름: string; 단위: string | null; 수량: number }; /** 단가산출 [항목 추가] — `scope` = 그 SK 문서 층(고를 수 있는 서고 = 위 층만) */ export function openSangeunLinePicker( @@ -87,5 +88,7 @@ export function openSangeunLinePicker( element: (e) => e.종류 !== "기계" && done({ 종류: e.종류, 요소: e.코드, 이름: e.이름, 단위: e.단위, 수량: 1 }), + // 736-3 기계 = 고정 줄(이음 기계 EQ · 단가 = 한 시간 노 · 재 · 경) · 수량 1 + machine: (e) => done({ 고정: fixedOf(e), 이름: e.이름, 단위: e.단위, 수량: 1 }), }); } diff --git a/resources/tester/test_721_find_pick.py b/resources/tester/test_721_find_pick.py index 4f8b5133a..b81649914 100644 --- a/resources/tester/test_721_find_pick.py +++ b/resources/tester/test_721_find_pick.py @@ -63,7 +63,7 @@ def test_721_2_부르는_곳_셋만_새_창(): def test_721_2_고정_줄_꼴(): find = _text(M02 / "M02_MasterTemplete_Find_Pick.ts") assert "이음: { 노임: labor ? e.코드 : null, 자재: labor ? null : e.코드, 기계: null }" in find - assert "go: put ? take(() => put(e))" in find + assert "take(() => put(e))" in find and "!put" in find assert "dim: !put," in find # 받는 쪽 없으면 회색 막힘 @@ -151,3 +151,42 @@ def test_736_2_빠진_길_둘_신규_자재_기계_탭(): assert '"machine"' in lost and "pickMachine" not in lost unlinked = _text(M02 / "M02_MasterTemplete_Labor_Unlinked.ts") assert 'machine: (e) => done("기계", e.코드)' in unlinked and "pickMachine" not in unlinked + + +def test_736_3_기계_고정_줄_금액(): + """736-3 — [항목 추가] 기계(EQ) = 고정 줄(이음 기계 · 단가 = 한 시간 노 · 재 · 경) — 단가산출 · 일위대가가 같은 금액.""" + from M02_MasterTemplete import M02_Machine_Hourly as machine + + EQ = "EQ000019" # 굴착기(무한궤도) 0.6㎥ + got = machine.hourly(EQ) + assert got["ok"] and got["계"] > 0 + assert got["계"] == pytest.approx(got["노무비"] + got["재료비"] + got["경비"]) + assert not machine.hourly(LB)["ok"] and not machine.hourly("EQ999999")["ok"] + fx = { + "이름": got["이름"], + "규격": got["규격"], + "단위": "시간", + "단가": {k: got[k] for k in ("노무비", "재료비", "경비")}, + "이음": {"노임": None, "자재": None, "기계": EQ}, + } + sk = calc.calc_doc( + { + "키": "SK-000001", + "이름": "시험", + "단위": "시간", + "바탕": None, + "더한줄": [{"고정": fx, "이름": got["이름"], "단위": "시간", "수량": 1}], + } + )["1단위"] + doc = labor.clean({"담은로직": [{"로직": "", "고정": fx, "수량": {"손": 1}}]}) + assert sk["계"] == pytest.approx(got["계"]) + assert labor.calc(doc, {})["반영합"]["계"] == pytest.approx(got["계"]) + find = _text(M02 / "M02_MasterTemplete_Find_Pick.ts") + assert ( + "/logic-lib/machine-hourly?key=" in find + 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" + )