- M02_Hopyo_Build: 호표 = 사전조합 하나(이름 = 품명 + 규격 · 엑셀 단위 · 구분 실무자료 · 상세구분 파일 · 본뜬 실무자료 표시) · 로직 줄 {별칭 = 엑셀 품명 40 자 · 메모 「실무 0.14㎥ × 1 · 규격」 · 입력값 · 수량 손 = 엑셀 × 환산} · 자재 줄(값 없음 = 별도계상 · 아니면 직접계산 · 같은 자재 수량 더함) · 같은 로직 둘째 = 한 줄 사전조합으로 감쌈 · 일위N = 먼저 만들어 참조 · 순환 = 빠진 줄 · 같은 층 · 파일 · 호표 = 다시 씀 · 새로 = 그 키에 덮음 · 빠진 줄 + 엑셀 금액 · 만든 값 ↔ 엑셀 단가
- M02_Labor_Ref._combo_lines: 별칭 있으면 남김(한 줄)
- 길 POST /layers/{층}/hopyo-build(이 층 쓰기 · 파일 층 읽기)
- 시험 test_m02_hopyo_build 5 통과 · 곁 시험 84 통과
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
205 lines
9.4 KiB
Python
205 lines
9.4 KiB
Python
"""M02 일위대가 실무자료 → 우리 일위대가 사전조합(PLAN 324-3).
|
||
|
||
호표 하나 = 사전조합 하나 — 이름 = 품명 + 규격 · 단위 = 엑셀 단위 · 구분 「실무자료」 ·
|
||
상세구분 = 파일 · 본뜬 = {층 「실무자료」, 키 「HP-###### 제N호표」, 파일} ·
|
||
만드는 층 = 부르는 층(일위대가 문서 층). 들어가는 줄(`M02_Hopyo_Match` 의 `들어감`)만:
|
||
로직 = {로직, 별칭 = 엑셀 품명 40 자, 메모 = 「실무 0.14㎥ × 1 · 규격」, 입력값,
|
||
수량 {손 = 엑셀 수량 × 환산}} · 같은 로직 둘째부터 = 그 줄 하나짜리 사전조합으로 감싸 참조
|
||
(같은 로직 두 번 금지) · 자재 = {코드, 수량 {손}, 별도계상 = 엑셀 값 없음 「별도계상」 ·
|
||
아니면 「직접계산」}(같은 자재 = 수량 더함) · 일위N = 그 호표 사전조합을 먼저 만들어 참조
|
||
(순환 = 빠진 줄).
|
||
같은 층 · 같은 파일 · 같은 호표 = 있던 것을 다시 씀 · `새로` = 그 키에 다시 만들어 덮음(감싼 것도).
|
||
빠진 줄(% · 못 맞춤 · 빼기 · 환산 입력 · 추정) = 엑셀 금액과 함께 보고 · 만든 값 ↔ 엑셀 호표 단가.
|
||
엔진 · 일위대가 계산은 그대로(`M02_Labor_Ref.Expand`).
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from decimal import Decimal
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from M01_MasterData import M01_MasterData_Store as m01
|
||
from M02_MasterTemplete import M02_Hopyo_Match as match
|
||
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
|
||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||
|
||
ORIGIN = "실무자료"
|
||
WHY = {
|
||
"%": "% 줄 — 우리 로직 덧줄이 셈",
|
||
"빼기": "뺌(확정)",
|
||
"후보": "못 맞춤 — 후보 확정 필요",
|
||
"없음": "못 맞춤",
|
||
}
|
||
|
||
|
||
def mark(key: str, no: int, at: int | None = None) -> str:
|
||
"""본뜬 키 — 호표 「HP-000001 제3호표」 · 감싼 줄 「… 2째 줄」."""
|
||
head = f"{key} 제{no}호표"
|
||
return head if at is None else f"{head} {at + 1}째 줄"
|
||
|
||
|
||
def _found(origin: str) -> tuple[str, str] | None:
|
||
"""지금 층 사전조합 가운데 본뜬 = 이 실무 표시 → (키, 판)."""
|
||
for path in sorted((store.root() / "precombo").glob("SC-*.json")):
|
||
try:
|
||
doc = json.loads(path.read_bytes())
|
||
except (OSError, ValueError):
|
||
continue
|
||
got = doc.get("본뜬") if isinstance(doc, dict) else None
|
||
if isinstance(got, dict) and got.get("층") == ORIGIN and got.get("키") == origin:
|
||
return path.stem, store.version_of(path.read_bytes())
|
||
return None
|
||
|
||
|
||
def _save(doc: dict[str, Any], fresh: bool) -> tuple[dict[str, Any], bool]:
|
||
"""(참조, 다시씀) — 있으면 그대로(`fresh` 면 그 키에 덮음) · 없으면 새 키."""
|
||
at = refs.here()
|
||
have = _found(doc["본뜬"]["키"])
|
||
if have and not fresh:
|
||
return {"층": at[0], "주인": at[1], "키": have[0]}, True
|
||
if have:
|
||
store.write("precombo", have[0], have[1], doc)
|
||
key = have[0]
|
||
else:
|
||
key = refs.create("precombo", doc)["이름"]
|
||
return {"층": at[0], "주인": at[1], "키": key}, bool(have)
|
||
|
||
|
||
def _num(value: Decimal) -> int | float:
|
||
value = value.normalize()
|
||
return int(value) if value == value.to_integral_value() else float(value)
|
||
|
||
|
||
def _memo(line: dict[str, Any], times: Any) -> str:
|
||
qty = line.get("수량")
|
||
spec = str(line.get("규격") or "").strip()
|
||
head = f"실무 {qty if qty is not None else ''}{line.get('단위') or ''} × {times}"
|
||
return f"{head} · {spec}" if spec else head
|
||
|
||
|
||
def _missing(line: dict[str, Any], why: str) -> dict[str, Any]:
|
||
keep = ("차례", "이름", "규격", "수량", "단위")
|
||
return {
|
||
**{k: line.get(k) for k in keep},
|
||
"금액": (line.get("금액") or {}).get("계"),
|
||
"까닭": why,
|
||
}
|
||
|
||
|
||
class Builder:
|
||
"""한 번 만들기 — 파일 · 층 기억 · 우리 쪽 목록을 한 번 읽고 호표마다 만든 참조를 기억."""
|
||
|
||
def __init__(self, source: Path, folder: Path, key: str, fresh: bool) -> None:
|
||
self.doc = hopyo_store.read(source, key)
|
||
self.key, self.fresh = key, fresh
|
||
self.memo, self.units = match.memories(folder), match.units_of(self.doc)
|
||
self.got = match.targets()
|
||
self.made: dict[int, dict[str, Any]] = {}
|
||
self.missing: dict[int, list[dict[str, Any]]] = {}
|
||
|
||
def hopyo(self, no: int) -> dict[str, Any]:
|
||
found = next((h for h in self.doc.get("호표") or [] if int(h["번"]) == int(no)), None)
|
||
if found is None:
|
||
raise store.StoreError(404, f"없는 호표 「제{no}호표」")
|
||
return found
|
||
|
||
def build(self, no: int, trail: tuple[int, ...] = ()) -> dict[str, Any]:
|
||
"""호표 → 사전조합 참조 — 일위N 은 먼저(순환은 빠진 줄)."""
|
||
if no in self.made:
|
||
return self.made[no]
|
||
hopyo = match.match_hopyo(self.hopyo(no), self.memo, self.units, True, self.got)
|
||
lines, mats, missing = [], {}, []
|
||
used: set[str] = set()
|
||
for line in hopyo["줄"]:
|
||
got = line["맞춤"]
|
||
if not got["들어감"]:
|
||
conv = got.get("환산") or {}
|
||
why = WHY.get(got["상태"]) or (
|
||
"추정 환산 — 확정 필요" if conv.get("갈래") == "추정" else "환산 입력 필요"
|
||
)
|
||
missing.append(_missing(line, why))
|
||
continue
|
||
times = got["환산"]["배"]
|
||
qty = Decimal(str(line.get("수량") or 0)) * Decimal(str(times))
|
||
target = got["대상"]
|
||
alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None
|
||
memo = _memo(line, times)
|
||
if "자재" in target:
|
||
code = target["자재"]
|
||
apart = "별도계상" if line.get("갈래") == "별도계상" else "직접계산"
|
||
have = mats.get(code)
|
||
hand = qty + Decimal(str(have["수량"]["손"])) if have else qty
|
||
mats[code] = {"코드": code, "수량": {"손": _num(hand)}, "별도계상": apart}
|
||
continue
|
||
if "호표" in target:
|
||
child = int(target["호표"])
|
||
if child in (*trail, no):
|
||
missing.append(_missing(line, "순환 — 호표가 서로 담음"))
|
||
continue
|
||
ref = self.build(child, (*trail, no))
|
||
lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}})
|
||
continue
|
||
one = {"로직": target["로직"], "입력값": got.get("입력값") or {}, "메모": memo}
|
||
if target["로직"] not in used:
|
||
used.add(target["로직"])
|
||
lines.append({**one, "별칭": alias, "수량": {"손": _num(qty)}})
|
||
continue
|
||
wrap = { # 같은 로직 둘째 — 한 줄 사전조합으로 감쌈
|
||
"이름": f"{hopyo['이름']} — {line.get('이름')}",
|
||
"단위": target.get("단위"),
|
||
"구분": ORIGIN,
|
||
"상세구분": self.doc.get("파일") or "",
|
||
"담은로직": [{**one, "별칭": alias, "수량": {"손": 1}}],
|
||
"자재": [],
|
||
"본뜬": self._origin(mark(self.key, no, line["차례"])),
|
||
}
|
||
ref, _ = _save(wrap, self.fresh)
|
||
lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}})
|
||
name = " ".join(x for x in (hopyo["이름"], hopyo.get("규격")) if x)
|
||
doc = {
|
||
"이름": name,
|
||
"단위": hopyo.get("단위") or None,
|
||
"구분": ORIGIN,
|
||
"상세구분": self.doc.get("파일") or "",
|
||
"담은로직": lines,
|
||
"자재": list(mats.values()),
|
||
"본뜬": self._origin(mark(self.key, no)),
|
||
}
|
||
ref, again = _save(doc, self.fresh)
|
||
self.made[no] = {**ref, "다시씀": again}
|
||
self.missing[no] = missing
|
||
return self.made[no]
|
||
|
||
def _origin(self, origin: str) -> dict[str, Any]:
|
||
return {"층": ORIGIN, "주인": None, "키": origin, "파일": self.doc.get("파일")}
|
||
|
||
|
||
def compare(ref: dict[str, Any], hopyo: dict[str, Any]) -> dict[str, Any]:
|
||
"""만든 사전조합 1 단위 값 ↔ 엑셀 호표 단가."""
|
||
got = refs.Expand(m01.loaded()[1])({k: ref[k] for k in ("층", "주인", "키")})
|
||
base = got.get("기준") or {}
|
||
ours = float(base["계"]) if "계" in base else None
|
||
excel = (hopyo.get("금액") or {}).get("계")
|
||
gap = round(ours - excel, 1) if ours is not None and excel is not None else None
|
||
return {"엑셀": excel, "우리": ours, "차이": gap, "까닭": got.get("까닭")}
|
||
|
||
|
||
def build(folder: Path, source: Path, key: str, no: int, fresh: bool = False) -> dict[str, Any]:
|
||
"""실무 호표(`source` 층 파일) → 지금 층(`folder`) 사전조합 —
|
||
`{참조, 이름, 다시씀, 빠진줄, 비교}`(부르는 쪽이 `store.at(folder)`)."""
|
||
maker = Builder(source, folder, key, fresh)
|
||
made = maker.build(int(no))
|
||
hopyo = maker.hopyo(int(no))
|
||
ref = {k: made[k] for k in ("층", "주인", "키")}
|
||
return {
|
||
"참조": ref,
|
||
"이름": hopyo.get("이름"),
|
||
"다시씀": made["다시씀"],
|
||
"빠진줄": maker.missing.get(int(no), []),
|
||
"비교": compare(ref, hopyo),
|
||
}
|