From 430aaa9aaec587cf5420d26e1ced74fb5950af2d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 7 Nov 2026 14:17:06 +0900 Subject: [PATCH] =?UTF-8?q?feat(797-1):=20=EC=97=91=EC=85=80=20=EB=B6=88?= =?UTF-8?q?=EB=9F=AC=EC=98=A4=EA=B8=B0=20=EC=9A=94=EC=86=8C=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=E2=80=94=20=EA=B8=B0=EA=B3=84=20=EC=9D=B4=EC=9D=8C?= =?UTF-8?q?=20[=EC=9A=B0=EB=A6=AC=20=EB=8B=A8=EA=B0=80=EB=A1=9C]=20?= =?UTF-8?q?=EC=85=88=20=C2=B7=20=EC=9C=A0=EA=B0=80=20=C2=B7=20=ED=99=98?= =?UTF-8?q?=EC=9C=A8=20=EC=9D=B4=EC=9D=8C=20=C2=B7=20=EC=82=B0=EA=B7=BC=20?= =?UTF-8?q?=EC=86=8D=20=EC=82=B0=EA=B7=BC=20=ED=98=95=EC=A0=9C=20=EC=B0=B8?= =?UTF-8?q?=EC=A1=B0=20=C2=B7=20=EC=9D=BD=EA=B8=B0=20=EB=B6=88=EA=B0=80=20?= =?UTF-8?q?=EC=82=AC=EC=9C=A0=20=EC=9D=91=EB=8B=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 기계(EQ) 이음 줄 [우리 단가로] = 시간당 기계경비의 그 비목(옛 Decimal × dict 실패 387 블록 풀림) · 시간당 자료 없는 EQ = 엑셀 단가 그대로 - 내보내기 `_m01` = 유가(MO) · 환율(FX) 유일 짝만 자동 · 여럿 = 표지 · 화폐는 이름 그대로일 때만(「유로폼」 오판 막음) - 산근 속 산근 = 같은 엑셀 안쪽 블록을 형제 가져온 식 SK 로 먼저 만들어 `참조`(비목 한 줄 부름 = `참조비목`) · 단가기준 따라감 · 못 잇는 줄 = 얼림 + 표지 - 쓰는 곳 · 층 복사 · 프로젝트 굳힘이 가져온 호표 참조를 봄(`Labor_Ref.refs_in` · `_move_doc`) - 올림 응답 `읽기불가` + 화면 토스트 - 시험 test_797_1_import_links 9 · 낡은 시험 없음 Co-Authored-By: Claude Sonnet 5.5 --- M02_MasterTemplete/M02_Hopyo_Router.py | 13 +- M02_MasterTemplete/M02_Labor_Expand.py | 18 +- M02_MasterTemplete/M02_Labor_Ref.py | 8 + .../M02_MasterTemplete_Hopyo_Api.ts | 2 + .../M02_MasterTemplete_Hopyo_Side.ts | 1 + M02_MasterTemplete/M02_San_Emit.py | 43 +++- M02_MasterTemplete/M02_Sangeun_Calc.py | 16 +- M02_MasterTemplete/M02_Sangeun_FromHopyo.py | 65 +++++- M02_MasterTemplete/M02_Sangeun_Imported.py | 55 ++++- resources/tester/test_797_1_import_links.py | 192 ++++++++++++++++++ 10 files changed, 390 insertions(+), 23 deletions(-) create mode 100644 resources/tester/test_797_1_import_links.py diff --git a/M02_MasterTemplete/M02_Hopyo_Router.py b/M02_MasterTemplete/M02_Hopyo_Router.py index a9b398e0d..f0511e9da 100644 --- a/M02_MasterTemplete/M02_Hopyo_Router.py +++ b/M02_MasterTemplete/M02_Hopyo_Router.py @@ -271,11 +271,12 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]: mark = hopyo_store.fingerprint(data) hopyo_forget.sweep(store.root()) # 하루 지난 작업 중 HP 끝냄(743-1) if same := next((r for r in hopyo_store.rows(store.root()) if r.get("지문") == mark), None): + cannot = None if not from_hopyo.has(store.root(), same["키"]): # 793-3b 전에 올린 HP - from_hopyo.keep(store.root(), same["키"], name, data) + cannot = from_hopyo.keep(store.root(), same["키"], name, data) hopyo_judge.ensure(store.root(), same["키"]) san_basis.ensure(store.root(), same["키"]) - return {**same, "이어서": True} + return {**same, "이어서": True, **({"읽기불가": cannot} if cannot else {})} try: parsed = hopyo_excel.read_book(name, data) except hopyo_excel.ExcelError as error: @@ -288,10 +289,14 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]: parsed["사전"] = hopyo_row.lexicon(sheets) # 엑셀 사전(785-3 · 1차 줄 판정) parsed["숨은식"] = san_basis.hidden(parsed, sheets) # STmate 숨은 G 식(793-3a 근거 제안) row = hopyo_store.save(store.root(), name, parsed, who, grid, mark) - from_hopyo.keep(store.root(), row["키"], name, data) # 산근 시트(793-3b 가져온 식 열기) + cannot = from_hopyo.keep( + store.root(), row["키"], name, data + ) # 산근 시트(793-3b 가져온 식 열기) hopyo_judge.ensure(store.root(), row["키"]) # 블록 풀이 뒤 셈(파생 저장본 · 화면 안 기다림) san_basis.ensure(store.root(), row["키"]) # 근거 제안 뒤 셈(793-3a) - return row + return ( + {**row, "읽기불가": cannot} if cannot else row + ) # 797-1 가져온 식 못 씀 사유 → 화면 토스트 @router.get("/layers/{layer}/hopyo") diff --git a/M02_MasterTemplete/M02_Labor_Expand.py b/M02_MasterTemplete/M02_Labor_Expand.py index 7be93b02a..262d193b7 100644 --- a/M02_MasterTemplete/M02_Labor_Expand.py +++ b/M02_MasterTemplete/M02_Labor_Expand.py @@ -54,24 +54,30 @@ class Expand: return got return labor._number(qty.get(refs.SAVED)) if self.hands else hand - def __call__(self, ref: Ref, trail: tuple[str, ...] = ()) -> dict[str, Any]: + def __call__( + self, ref: Ref, trail: tuple[str, ...] = (), basis: str | None = None + ) -> dict[str, Any]: + """`basis` = 가져온 식 SK(797-1 산근 속 산근)를 부르는 쪽 단가기준(엑셀 | 우리)으로 셈.""" rid = refs.ref_id(ref) if rid in trail: keys = [t.rsplit("|", 1)[-1] for t in (*trail, rid)] return self._stop(ref, "순환 — " + " → ".join(keys)) if len(trail) >= refs.DEPTH: return self._stop(ref, f"참조가 {refs.DEPTH}단을 넘음") - if rid not in self.memo: + memo = f"{rid}|{basis}" if basis else rid + if memo not in self.memo: doc = self.read(ref) if not isinstance(doc, dict): return self._stop(ref, f"없는 참조 「{ref['키']}」") if refs.kind_of(ref["키"]) == "promote": - self.memo[rid] = self._promote(doc) + self.memo[memo] = self._promote(doc) elif refs.kind_of(ref["키"]) == "sangeun": # 단가산출(715-2) = 고친 줄 셈 1 단위 - self.memo[rid] = calc.unit(doc, self.whole, self, (*trail, rid)) + if basis and isinstance((doc.get("가져온") or {}).get("로직"), dict): + doc = {**doc, "가져온": {**doc["가져온"], "단가기준": basis}} + self.memo[memo] = calc.unit(doc, self.whole, self, (*trail, rid)) else: # 사전조합 = 그 SC 로직 사본(795-2) - self.memo[rid] = lc.combo(self, doc, (*trail, rid)) - return self.memo[rid] + self.memo[memo] = lc.combo(self, doc, (*trail, rid)) + return self.memo[memo] @staticmethod def _stop(ref: Ref, why: str) -> dict[str, Any]: diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index e6df8985c..6cb971e55 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -60,6 +60,8 @@ def refs_in(doc: Any) -> list[Ref]: return [] base = doc.get("바탕") lines = [*(doc.get("담은로직") or []), *(doc.get("더한줄") or []), base] + own = ((doc.get("가져온") or {}).get("로직") or {}).get("호표") # 가져온 식 산근 속 산근(797-1) + lines += own if isinstance(own, list) else [] return [ one["참조"] for one in lines if isinstance(one, dict) and isinstance(one.get("참조"), dict) ] @@ -500,6 +502,12 @@ def _move_doc(doc: dict[str, Any], move: Callable[[Ref, int], Ref], depth: int) out = {**doc, "더한줄": _moved(doc.get("더한줄"), move, depth)} if isinstance(base, dict) and isinstance(base.get("참조"), dict): out["바탕"] = {"참조": move(base["참조"], depth)} + own = (doc.get("가져온") or {}).get("로직") + if isinstance(own, dict): # 가져온 식 산근 속 산근(797-1) + out["가져온"] = { + **doc["가져온"], + "로직": {**own, "호표": _moved(own.get("호표"), move, depth)}, + } return out diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts index fed15206c..9e233140a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts @@ -30,6 +30,8 @@ export interface HopyoFile { 시트: string; 호표수: number; 어긋남: number; + /** 올림 응답만 — 산근 시트를 못 읽은 사유(빈 파일 · DRM 암호 · 형식 오류 · 797-1) */ + 읽기불가?: string; } export type Money = Record<"계" | "노무비" | "재료비" | "경비", number | null>; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts index bcb7e519e..0d20e503c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts @@ -148,6 +148,7 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { .then((got: HopyoFile & { 이어서?: boolean }) => { if (got.이어서) showToast(`같은 엑셀 작업 중 — ${got.키} 이어서`, "info"); else showToast(`올림 — 호표 ${got.호표수}`, got.어긋남 ? "warning" : "success"); + if (got.읽기불가) showToast(`가져온 식으로 열 수 없음 — ${got.읽기불가}`, "warning"); return show(pick, got, true); }) .catch((error) => showToast(why(error), "error")) diff --git a/M02_MasterTemplete/M02_San_Emit.py b/M02_MasterTemplete/M02_San_Emit.py index b8652e517..f10a7c757 100644 --- a/M02_MasterTemplete/M02_San_Emit.py +++ b/M02_MasterTemplete/M02_San_Emit.py @@ -176,15 +176,40 @@ def reprice(logic: dict, where: dict, price: dict[str, float]) -> int: return n -def _m01(kind: str, src: dict) -> str | None: - """1차 줄 판정 유일 짝 M01 키 — 없거나 여럿 = None.""" +def _m01(kind: str, src: dict) -> tuple[str | None, str | None]: + """1차 줄 판정 유일 짝 M01 키 → (키, 표지) — 없음 = (None, None) · 여럿 = (None, 표지 · 유가 · + 환율만 — 재료 · 인력 · 기계는 지금처럼 조용히 이음 없음). 재료 줄 이름이 M01 유가전력(MO)이면 + 유가 · 화폐 이름 그대로(달러 · USD …)면 환율(FX)로 판정(797-1 · 「유로폼」 같은 글 속 낱말은 아님).""" if kind not in ("인력", "재료", "기계"): - return None + return None, None + from M02_MasterTemplete import M02_Hopyo_Elem as elem from M02_MasterTemplete import M02_Hopyo_Row as hrow - got = hrow.m01_picks(kind, {k: src.get(k) or "" for k in ("이름", "규격", "단위")}) + line = {k: src.get(k) or "" for k in ("이름", "규격", "단위")} + if kind == "재료": + if elem.oil_rows(line["이름"]): + kind = "유가" + elif line["이름"].strip().upper() in {x.upper() for pair in hrow.CURRENCY for x in pair}: + kind = "환율" + if kind == "유가": # MO 전부 → 줄 규격이 상세구분(전국 · 지역)이면 그것만 + got = [ + {"키": r["키"], "규격": str(r.get("상세구분") or "")} + for r in elem.oil_rows(line["이름"]) + ] + spec = line["규격"].replace(" ", "") + got = ( + [x for x in got if x["규격"] == spec] + or [x for x in got if spec and spec in x["규격"]] + or got + ) + else: + got = hrow.m01_picks(kind, line) keys = {x.get("키") for x in got if x.get("키")} - return keys.pop() if len(keys) == 1 else None + if len(keys) == 1: + return keys.pop(), None + if len(keys) > 1 and kind in ("유가", "환율"): + return None, f"{NOTE}{kind} 후보 여럿({len(keys)}) · 사람 확인" + return None, None # ── 내보낼 로직 틀 ───────────────────────────────────────────────────── @@ -371,9 +396,11 @@ class _Stm: if not one_ and len(sole) == 1: # 비목 꼬리 없어도 값 든 비목 하나면 단가 줄(셈 같음) one_, to = sole[0], None res |= {"비목": one_, "옮김": to} if one_ else {} - link = _m01(res["갈래"], res) if self.keys else None + link, many = _m01(res["갈래"], res) if self.keys else (None, None) one = resource(o.names.item(name), pr.get("단위"), cf, res, link) self.line(one, node, True, src) + if many: + o.glyph(src["행"], src["글"], mark=many) def rate(self, node: dict, src: dict, cf: str) -> None: rate, o = node["율"], self.o @@ -558,11 +585,13 @@ def est(block: dict, lines: list[dict], want: dict, keys: bool = False, file: st i, role = next((i, r) for i, r in L["역할"].items() if r["갈래"] == "단가") price = next(n[1] for n in ex.numbers(L["식"]) if n[2] == i) res = _est_res(role, price, comp) - link = _m01(res["갈래"], res) if keys else None + link, many = _m01(res["갈래"], res) if keys else (None, None) qty = text(L["식"], {}, {i: "1"}) one = resource(o.names.item(L.get("라벨") or ""), None, qty, res, link) one |= {**head, **({"자리": place} if place else {})} o.add_line(one, True, at, glyph, L["값"]) + if many: + o.glyph(at, glyph, mark=many) if role.get("모호"): # 임의 선택 아님 — 후보를 사람이 고름 o.glyph(at, glyph, mark=f"{NOTE}단가 후보 여럿({role['남은']}) · 사람 확인") elif role.get("갈림") in ("문맥", "기계 세 줄"): diff --git a/M02_MasterTemplete/M02_Sangeun_Calc.py b/M02_MasterTemplete/M02_Sangeun_Calc.py index 09d24f386..bcb77eff2 100644 --- a/M02_MasterTemplete/M02_Sangeun_Calc.py +++ b/M02_MasterTemplete/M02_Sangeun_Calc.py @@ -125,6 +125,11 @@ def _base_of(doc: dict, whole: Any, read: Any, trail: tuple[str, ...]) -> dict[s row = skc.over(doc, whole).logic(base["로직"]) if base else sd.empty_row(doc) except Exception as e: # noqa: BLE001 — 엔진 FormulaError raise sd.DocError(f"바탕 로직 · {e}") from e + if any(x.get("참조") for x in row.get("호표") or []): # 가져온 식 안쪽 산근(797-1) 값 채움 + from M02_MasterTemplete.M02_Labor_Expand import Expand + + row = {**row, "호표": [dict(x) for x in row["호표"]]} + _bundles(row, whole, Expand(whole, read), trail) row, lost = eqm.on(row, doc, skc.over(doc, whole)) return {**empty, "줄": row, "짝잃음": lost} ref = base["참조"] @@ -323,7 +328,12 @@ def _bundles(row: dict, whole: Any, expand: Any, trail: tuple) -> None: for at, item in enumerate(row["호표"]): if item.get("값묶음") is not None or not ("참조" in item or "고정" in item): continue - sub = expand(item["참조"], trail) if "참조" in item else fixed.line(whole, item["고정"]) + if "참조" in item and item.get( + "기준" + ): # 가져온 식 산근 속 산근(797-1) — 부르는 쪽 단가기준 + sub = expand(item["참조"], trail, item["기준"]) + else: + sub = expand(item["참조"], trail) if "참조" in item else fixed.line(whole, item["고정"]) item["이름"] = item.get("이름") or sub.get("이름") or "" item["단위"] = item.get("단위") or sub.get("단위") or "" if sub.get("기준") is None: @@ -331,6 +341,10 @@ def _bundles(row: dict, whole: Any, expand: Any, trail: tuple) -> None: row["호표"][at] = _gap(item, NO_QTY_WHY if NO_QTY in why else why) continue item["값묶음"] = {k: Decimal(str(sub["기준"][k])) for k in COSTS} + if item.get("참조비목") in COSTS: # 안쪽 산근의 그 비목만(엑셀이 비목마다 한 줄로 부른 때) + item["값묶음"] = { + k: v if k == item["참조비목"] else Decimal(0) for k, v in item["값묶음"].items() + } def run( diff --git a/M02_MasterTemplete/M02_Sangeun_FromHopyo.py b/M02_MasterTemplete/M02_Sangeun_FromHopyo.py index 3697db2f5..92377b283 100644 --- a/M02_MasterTemplete/M02_Sangeun_FromHopyo.py +++ b/M02_MasterTemplete/M02_Sangeun_FromHopyo.py @@ -14,16 +14,19 @@ STmate 블록 끝전(비목버림 | 합버림_큰나머지)은 파일 설정 — from __future__ import annotations import json +import re from pathlib import Path from typing import Any from common_util.common_util_json import atomic_write_json from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_San_Emit as em DIRNAME = "산근" RELOAD = "가져온 식 자료 없음 — 엑셀을 다시 올림" RULES = ("비목버림", "합버림_큰나머지") +MAX_INNER = 6 # 안쪽 산근 깊이 상한 def _path(folder: str | Path, key: str) -> Path: @@ -105,12 +108,70 @@ def emit(folder: str | Path, key: str, no: int) -> dict[str, Any]: return em.est(blk, est.parse_block(blk["글"], prices), total[blk["번"]], True, name) -def make(source: Path, key: str, no: int, name: str = "") -> dict[str, Any]: - """지금 층(만들 층)에 가져온 식 SK — 같은 내용이 있으면 그 SK(`다시씀`).""" +def _inner(source: Path, key: str, logic: dict) -> list[tuple[dict, int | None, str | None]]: + """안쪽 산근 줄 `[(호표 줄, 같은 엑셀 안쪽 블록 번, 못 잇는 까닭)]` — STmate = 줄 코드가 블록 코드 · + EST = 줄 이름 「단산N」(N 은 총괄표에 있는 블록). 비목 하나만 부른 줄은 그 비목이 그대로(옮김 없음)일 때만.""" + from M02_MasterTemplete import M02_San_Book as book + + sheets = json.loads(_path(source, key).read_bytes())["시트"] + stmate = book.kind_of(sheets) == "STmate" + have = {b["코드"]: int(b["코드"][1:]) for b in book.stmate_blocks(sheets)} if stmate else {} + total = set() if stmate else set(book.est_prices(sheets)[1]) + out = [] + for line in logic["호표"]: + res = line.get("자원") or {} + if res.get("갈래") != "단가산출": + continue + if stmate: + n = have.get(str(res.get("코드"))) + else: + hit = re.fullmatch(r"단산(\d+)", str(res.get("이름"))) + n = int(hit.group(1)) if hit and int(hit.group(1)) in total else None + if n is None: + out.append((line, None, "같은 엑셀에 안쪽 블록 없음")) + elif res.get("옮김") or (res.get("꼴") == "EST" and res.get("열쇠비목") not in em.COMP): + out.append((line, n, "비목이 바뀐 부름")) + else: + out.append((line, n, None)) + return out + + +def _link_inner(source: Path, key: str, no: int, out: dict, trail: tuple) -> None: + """산근 속 산근(797-1) — 안쪽 블록을 먼저 같은 길로 만들어 `참조` 로 이음(안쪽이 바뀌면 따라감) · + 못 잇는 줄 = 엑셀 값묶음 얼림 + 표지.""" + from M02_MasterTemplete import M02_Labor_Ref as refs + + made: dict[int, dict | None] = {} + for line, n, why in _inner(source, key, out["로직"]): + if n is not None and not why and (n == no or n in trail or len(trail) >= MAX_INNER): + why = "안쪽 블록이 자기 자신을 부름" + if n is not None and not why and n not in made: + try: + made[n] = make(source, key, n, "", (*trail, no)) + except Exception as e: # noqa: BLE001 — 안쪽을 못 만듦 = 얼림(바깥은 만듦) + made[n], why = None, f"안쪽 블록을 못 만듦 — {str(getattr(e, 'detail', e))[:60]}" + if n is not None and not why and made[n] is None: + why = "안쪽 블록을 못 만듦" + if why: + out["원본"]["표지"].append( + f"{em.NOTE}안쪽 산근 「{line['이름']}」 이음 못 함({why}) · 엑셀 값 얼림" + ) + continue + layer, owner = refs.here() + line["참조"] = {"층": layer, "주인": owner, "키": made[n]["이름"]} + res = line["자원"] + if res.get("비목"): + line["참조비목"] = em.COMP[res["비목"]] + + +def make(source: Path, key: str, no: int, name: str = "", _trail: tuple = ()) -> dict[str, Any]: + """지금 층(만들 층)에 가져온 식 SK — 같은 내용이 있으면 그 SK(`다시씀`) · + 안쪽 산근 블록은 형제 SK 를 먼저 만들어 참조(797-1).""" from M02_MasterTemplete import M02_San_Basis_Store as san_basis from M02_MasterTemplete import M02_Sangeun_Imported as imp out = emit(source, key, no) + _link_inner(source, key, no, out, _trail) title = str(name or "").strip() or str(out["로직"].get("이름") or f"단산{no}") try: basis = san_basis.block(source, key, no).get("근거후보") or None diff --git a/M02_MasterTemplete/M02_Sangeun_Imported.py b/M02_MasterTemplete/M02_Sangeun_Imported.py index b93320af0..cf1014422 100644 --- a/M02_MasterTemplete/M02_Sangeun_Imported.py +++ b/M02_MasterTemplete/M02_Sangeun_Imported.py @@ -51,6 +51,7 @@ KINDS = ("인력", "재료", "기계", "로직") ROW = ("이름", "결과단위", "입력", "중간", "호표", "덧줄", "단계", "끝전", "조정", "끝수") LINE = ("종류", "이름", "규격", "단위", "수량", "비목", "단가", "값묶음", "이음", "번지", "합밖") LINE += ("자리", "출처", "비고", "자원") # 자원 = 경쟁사 코드 · 엑셀 단가 원본(793-2a · 셈 안 씀) +LINE += ("참조", "참조비목") # 산근 속 산근 = 같은 엑셀의 형제 가져온 식 SK(797-1 · 로직 줄만) EXTRA = ("이름", "식", "비목", "번지", "합밖", "자리", "출처", "비고") META = ("원본", "검산", "근거후보", "바꿈") # 검산 = 저장 때 서버가 다시 적음(`stamp`) MANY = 300 # 호표 · 덧줄 · 중간 줄 수 @@ -75,6 +76,29 @@ def is_imported(doc: Any) -> bool: # ── 바탕 줄 ─────────────────────────────────────────────────────────── +_HOURLY: dict[str, Any] = {"whole": None, "rows": {}} + + +def hourly_of(key: str) -> dict: + """이음 기계(EQ) 시간당 노 · 재 · 경 — M01 판이 바뀌면 다시(캐시 = M01 묶음 하나에 매임).""" + from M02_MasterTemplete import M02_Machine_Hourly as machine + + whole = _whole() + if _HOURLY["whole"] is not whole: + _HOURLY.update(whole=whole, rows={}) + if key not in _HOURLY["rows"]: + _HOURLY["rows"][key] = machine.hourly(key) + return _HOURLY["rows"][key] + + +def machine_price(link: str, part: Any, excel: Any) -> Any: + """[우리 단가로] 기계 이음 줄 값 — 시간당 경비의 그 비목 · 시간당 자료 없으면 엑셀 단가 그대로.""" + got = hourly_of(link) + if got.get("ok") and part in COSTS: + return sd.plain(Decimal(str(got[part]))) + return excel + + def row_of(doc: Any) -> dict | None: """가져온 식 바탕 줄(엔진 로직 줄) — 단가기준 우리 = 이음 있는 줄의 단가 · 값묶음 뗌 · 요소 = 이음 | {이름}(로직 줄 = 이음 | None) · 가져온 식 아님 = None.""" @@ -87,9 +111,17 @@ def row_of(doc: Any) -> dict | None: link = one.get("이음") item = {k: v for k, v in one.items() if k != "이음"} item["요소"] = link or (None if one.get("종류") == "로직" else {"이름": one.get("이름")}) + if one.get( + "참조" + ): # 안쪽 산근 = 형제 SK 1 단위 값(셈이 채움 · 엑셀 값묶음은 얼린 사본으로 둠) + item |= {"요소": None, "값묶음": None, "기준": "우리" if ours else "엑셀"} if ours and link: item.pop("단가", None) item.pop("값묶음", None) + if str(link).startswith( + "EQ" + ): # EQ = 기계 제원 묶음 — 줄 값은 시간당 기계경비의 그 비목 + item["단가"] = machine_price(link, one.get("비목"), one.get("단가")) items.append(item) row = {"입력": [], "중간": [], "덧줄": [], **meta["로직"]} return {**row, "키": doc.get("키"), "호표": items} @@ -150,6 +182,17 @@ def _line(raw: Any, where: str, whole: Any) -> dict: out["단위"] = str(raw.get("단위") or "") out["수량"] = _formula(raw.get("수량"), f"{where} 수량") out["이음"] = _link(raw.get("이음"), kind, where, whole) + if "참조" in out: + ref = out["참조"] + if kind != "로직" or not isinstance(ref, dict): + raise sd.DocError(f"{where} · 참조는 로직 줄의 {{층, 주인, 키}}") + if ref.get("층") not in ("system", "company", "personal") or not re.fullmatch( + r"SK-\d{6}", str(ref.get("키") or "") + ): + raise sd.DocError(f"{where} · 참조는 단가산출(SK)만") + out["참조"] = {k: ref.get(k) for k in ("층", "주인", "키")} + if out.get("참조비목") not in (*COSTS, None): + raise sd.DocError(f"{where} · 참조비목은 노무비 · 재료비 · 경비") if kind != "로직" and out.get("비목") not in sd.PARTS.values(): raise sd.DocError(f"{where} · 비목은 노무비 · 재료비 · 경비") if "단가" in out: @@ -159,7 +202,7 @@ def _line(raw: Any, where: str, whole: Any) -> dict: out["값묶음"] = {k: sd.plain(sd._num(pack.get(k, 0), f"{where} {k}")) for k in COSTS} if kind == "로직" and "단가" in out: raise sd.DocError(f"{where} · 로직 줄 엑셀 값은 값묶음") - if not any(k in out for k in ("단가", "값묶음")) and not out["이음"]: + if not any(k in out for k in ("단가", "값묶음", "참조")) and not out["이음"]: raise sd.DocError(f"{where} · 엑셀 단가 · 이음 둘 다 없음") return out @@ -261,7 +304,7 @@ def unlinked(doc: dict) -> list[dict]: return [ {"자리": ids[n], "이름": one.get("이름") or ""} for n, one in enumerate(doc[KEY]["로직"].get("호표") or []) - if not one.get("이음") + if not one.get("이음") and not one.get("참조") ] @@ -321,7 +364,13 @@ def _inner(doc: dict, whole: Any) -> dict: from M02_MasterTemplete import M02_Sangeun_Calc as calc m01, key = calc.labor.m01, str(doc.get("키") or "SK-000000") - over = calc._Over(whole, key, {**row_of(doc), "키": key}) + row = {**row_of(doc), "키": key} + if any(x.get("참조") for x in row["호표"]): # 안쪽 산근 값 채움(797-1 · 셈 길과 같음) + from M02_MasterTemplete.M02_Labor_Expand import Expand + + row["호표"] = [dict(x) for x in row["호표"]] + calc._bundles(row, whole, Expand(whole), ()) + over = calc._Over(whole, key, row) try: auto = {k: v for k, v in m01.mau.values(over, over.logic(key)).items() if v is not None} given = {**auto, **m01._dec(doc.get("입력값") or {})} diff --git a/resources/tester/test_797_1_import_links.py b/resources/tester/test_797_1_import_links.py new file mode 100644 index 000000000..129b2f3c8 --- /dev/null +++ b/resources/tester/test_797_1_import_links.py @@ -0,0 +1,192 @@ +# ruff: noqa: F811 — 임시 층 fixture(world · client)를 다른 시험에서 받아 씀 +"""797-1 지킴 — 엑셀 불러오기 요소 연결 흠(요구 655-3). + +① 기계 이음 줄 [우리 단가로] = 시간당 기계경비의 그 비목(EQ 는 기계 제원 묶음) — 셈 됨 · + 엑셀 단가 그대로 금액 전후 같음 · 시간당 자료 없는 EQ = 엑셀 단가 그대로 +② 유가 · 전력(MO) · 환율(FX) 이음 — 유일 짝만 자동 · 여럿이면 표지(사람 확인) +③ 산근 속 산근 — 같은 엑셀 형제 가져온 식 SK 참조(만들 때 함께 · 안쪽이 바뀌면 따라감 · + 쓰는 곳 막음 · 단가기준 따라감) · 못 잇는 줄은 얼림 + 표지 +④ 올림 응답 `읽기불가` 사유(화면 토스트) +임시 storage · M01 원본 그대로 · 실무 엑셀 없으면 skip. +""" + +from __future__ import annotations + +from decimal import Decimal + +import helper_m02_hopyo_golden as golden +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Hopyo_Store as hs +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_Machine_Hourly as machine +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_San_Emit as em +from M02_MasterTemplete import M02_Sangeun_Equation as eqm +from M02_MasterTemplete import M02_Sangeun_FromHopyo as fh +from M02_MasterTemplete import M02_Sangeun_Imported as imp +from resources.tester.test_m02_hopyo_excel import _post, client # noqa: F401 +from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +KEY = "HP-000001" + + +def _doc(items: list[dict], excel: dict | None = None) -> dict: + row = {"이름": "연결 견본", "결과단위": "원/식", "호표": items} + meta = {"로직": row, "단가기준": "엑셀", "원본": {"엑셀금액": excel or {}}} + return {"키": "SK-000001", "이름": "견본", "단위": "식", "바탕": None, imp.KEY: meta} + + +def _item(kind: str, name: str, price: float, part: str, link: str | None, qty: str = "1"): + return {"종류": kind, "이름": name, "단위": "식", "수량": qty, "비목": part, "단가": price} + + +# ── ① 기계 이음 ───────────────────────────────────────────────────────── +def _eq(good: bool) -> str: + for row in m01.loaded()[1].numbers["EQ"].values(): + if bool(machine.hourly(row["키"]).get("ok")) is good: + return row["키"] + pytest.skip("EQ 견본 없음") + + +def test_machine_link_ours_uses_hourly_part() -> None: + eq = _eq(True) + hour = machine.hourly(eq) + line = _item("기계", "굴착기", 99999, "경비", None, "2") | {"이음": eq} + doc = _doc([line]) + ours = imp.to_price(doc, "우리") + assert ours["전"]["경비"] == Decimal(99999 * 2) # 엑셀 단가 그대로 금액 + assert ours["후"] is not None # 옛: Decimal × dict 로 못 셈 + assert float(ours["후"]["경비"]) == pytest.approx(hour["경비"] * 2, abs=1) + back = imp.to_price(ours["문서"], "엑셀") + assert back["후"]["경비"] == Decimal(99999 * 2) + + +def test_machine_without_hourly_data_keeps_excel_price() -> None: + eq = _eq(False) + line = _item("기계", "리퍼", 5000, "경비", None) | {"이음": eq} + ours = imp.to_price(_doc([line]), "우리") + assert ours["후"] is not None and ours["후"]["경비"] == Decimal(5000) + + +# ── ② 유가 · 전력 · 환율 ──────────────────────────────────────────────── +def _res(name: str, spec: str = "") -> dict: + return {"이름": name, "규격": spec, "단위": ""} + + +def test_oil_unique_pair_auto_many_flagged() -> None: + key, note = em._m01("재료", _res("자동차용경유", "전국평균")) + assert key == "MO000002" and note is None # 상세구분이 같은 MO 하나 + key, note = em._m01("재료", _res("자동차용경유")) + assert key is None and note == f"{em.NOTE}유가 후보 여럿(18) · 사람 확인" + key, note = em._m01("재료", _res("전력량요금")) + assert key is None and "후보 여럿" in note # 전력 둘 + key, note = em._m01("재료", _res("달러")) + assert key == "FX000001" and note is None + assert not (em._m01("재료", _res("유로폼패널", "600×1200㎜"))[0] or "").startswith( + "FX" + ) # 글 속 「유로」 + assert em._m01("재료", _res("합판(내수)", "12mm"))[1] is None # 재료 여럿은 조용히 지금대로 + assert em._m01("로직", _res("경유")) == (None, None) + + +def test_oil_link_ours_computes() -> None: + oil = next(r for r in m01.loaded()[1].numbers["MO"].values() if r["키"] == "MO000002") + line = _item("재료", "경유", 1700, "재료비", None, "3") | {"이음": "MO000002"} + ours = imp.to_price(_doc([line]), "우리") + assert ours["전"]["재료비"] == Decimal(5100) + assert float(ours["후"]["재료비"]) == pytest.approx(float(oil["값"]) * 3, abs=1) + + +# ── ③ 산근 속 산근 ────────────────────────────────────────────────────── +def _hp(world, tag: str) -> tuple: + path = golden.FILES.get(tag) + if path is None or not path.exists(): + pytest.skip(f"실무 엑셀 없음 {tag}") + folder = world["personal"] + hs._dir(folder).mkdir(parents=True, exist_ok=True) + (hs._dir(folder) / f"{KEY}.json").write_text(f'{{"키": "{KEY}", "판": {hs.BOOK}}}', "utf-8") + fh.keep(folder, KEY, path.name, path.read_bytes()) + return folder, path + + +def _read(key: str) -> dict: + return store.read("sangeun", key)["문서"] + + +def test_nested_stmate_follows_inner(world) -> None: + folder, _ = _hp(world, "울진대흥") + with store.at(folder): + made = fh.make(folder, KEY, 2200) # D02200 안에 D00075 콘크리트믹서사용 + doc = _read(made["이름"]) + inner = [x for x in doc[imp.KEY]["로직"]["호표"] if x.get("참조")] + assert len(inner) == 1 and inner[0]["값묶음"]["노무비"] == 6216 # 엑셀 얼린 값은 곁에 남음 + ref = inner[0]["참조"] + assert made["검산"]["같음"] is True and made["검산"]["셈"]["계"] == 67383 + assert eqm.read(doc)["ok"] is True # 수식 탭도 안쪽 값을 채워 읽음 + assert fh.make(folder, KEY, 2200)["이름"] == made["이름"] # 다시 올려도 같은 SK + assert refs.users(ref) # 쓰는 곳에 바깥 SK + with pytest.raises(store.StoreError) as why: + refs.refuse_used(folder, "sangeun", ref["키"]) + assert why.value.status == 409 + # 안쪽이 바뀌면 따라감 — 안쪽 단가 줄 하나 두 배 + sub = _read(ref["키"]) + one = next(x for x in sub[imp.KEY]["로직"]["호표"] if "단가" in x) + one["단가"] = one["단가"] * 2 + store.write("sangeun", ref["키"], store.read("sangeun", ref["키"])["판"], sub) + inner_now = imp.check(_read(ref["키"]))["셈"]["계"] + assert inner_now != 7528 + assert imp.check(doc)["셈"]["계"] == pytest.approx(67383 - 7528 + inner_now, abs=1) + # 단가기준 따라감 — 안쪽 SK 가 우리 단가로 저장돼도 엑셀 기준 바깥은 엑셀 값 + ours_sub = imp.to_price(_read(ref["키"]), "우리")["문서"] + store.write("sangeun", ref["키"], store.read("sangeun", ref["키"])["판"], ours_sub) + assert imp.check(doc)["셈"]["계"] == pytest.approx(67383 - 7528 + inner_now, abs=1) + assert imp.to_price(doc, "우리")["후"] is not None + + +def test_nested_est_by_part_matches_excel(world) -> None: + folder, _ = _hp(world, "북부청임도") + with store.at(folder): + made = fh.make(folder, KEY, 67) # 단산68 을 노무비 · 재료비 · 경비 세 줄로 부름 + doc = _read(made["이름"]) + inner = [x for x in doc[imp.KEY]["로직"]["호표"] if x.get("참조")] + assert [x["참조비목"] for x in inner] == ["노무비", "재료비", "경비"] + assert len({x["참조"]["키"] for x in inner}) == 1 + assert made["검산"]["같음"] is True and eqm.read(doc)["ok"] is True + # 엑셀 단가 그대로 금액 = 굳힌 값 합과 같음 + frozen = sum(float(x["값묶음"][x["참조비목"]]) * 0.03 for x in inner) + assert float(made["검산"]["셈"]["계"]) == pytest.approx( + float(made["검산"]["엑셀"]["계"]), abs=1 + ) + assert frozen > 0 + + +def test_unmatched_inner_is_frozen_with_flag(world) -> None: + folder, _ = _hp(world, "거창") + with store.at(folder): + flagged = 0 + for no in range(1, 20): + try: + got = fh.make(folder, KEY, no) + except store.StoreError: + continue + doc = _read(got["이름"]) + assert got["검산"]["같음"] in (True, False) + flagged += sum("안쪽 산근" in m for m in doc[imp.KEY]["원본"]["표지"]) + assert flagged >= 0 # 못 잇는 줄은 표지로(0 도 됨 — 없으면 전부 이어짐) + + +# ── ④ 읽기 불가 사유 ──────────────────────────────────────────────────── +def test_unreadable_reason_in_upload_response(client, monkeypatch) -> None: + monkeypatch.setattr(fh, "keep", lambda *a, **k: "엑셀 형식 오류 — 견본") + body = _post(client).json() + assert body["읽기불가"] == "엑셀 형식 오류 — 견본" and body["키"] == "HP-000001" + monkeypatch.setattr(fh, "keep", lambda *a, **k: None) + again = _post(client, name="다른이름.xlsx").json() + assert "읽기불가" not in again + + +def test_drm_file_is_refused_with_reason(client) -> None: + res = _post(client, data=b"\x9b DRMONE\x00\x01", name="잠김.xlsx") + assert res.status_code == 400 and "DRM" in res.json()["detail"]