Files
Aislo/M02_MasterTemplete/M02_Hopyo_Build.py
T
eomsangdonandClaude Opus 5.5 736aa405cf feat(일위대가실무): 324-3 실무 호표 → 우리 일위대가 사전조합 만들기 · 사전조합 줄 별칭 살림
- 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
2026-10-14 22:15:59 +09:00

205 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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),
}