Files
Aislo/M02_MasterTemplete/M02_Hopyo_Build.py
T
eomsangdonandClaude Opus 5.5 bf5d130e32 feat(M02): 338-3b 확인한 줄만 사전조합 — Build 새 몸 · 재검사 · 별칭 장부 씀 · 장부 길 · 옛 즉시 확정 길 없앰
- M02_Hopyo_Build — 몸 줄만(없으면 400) · 대상 여럿 같은 별칭 · 배 · 몫 · 통째 · 하위 호표 확인 전 409 · 본뜬 원본 · 확인 · 같은 본뜬 덮음 · confirmed(folder, source)
- M02_Labor_Router — hopyo-build 엄격 몸(extra forbid) · hopyo-ledger GET · PUT · hopyo-memory 없앰 · picks 확인 · 올린 뒤 Fit.ensure · 시동 Ours.warm(있을 때만) · ?to= 만들 층
- M02_Hopyo_Ledger — times_of 나눔
- 시험 test_m02_hopyo_build.py 새로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-15 23:21:09 +09:00

315 lines
15 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 · 338-3 · 설계 3-6 · 3-7).
[확인하고 사전조합 만들기] 몸 = 사용자가 체크한 줄만 —
`줄 [{차례, 대상 [{로직|자재|호표, 입력값, 배, 몫}] | 뺌}]` · 직접 줄 호표 = `통째 {대상}`.
몸이 비면 400(확인 없이 들어가는 길 없음).
서버 재검사(브라우저 값을 받아 적지 않음 · 금액 · 품 칸 없음) — 차례 = 그 호표 줄 · 대상 하나 =
돈 로직 · M01 자재품목 · 확인된 호표 중 하나 · 입력 이름 = 그 로직 입력 · 배 0 넘음 · 몫 (0, 1] ·
호표 = 만들 층에 확인된 사전조합(없으면 409 「제N호표 먼저 확인」).
호표 하나 = 사전조합 하나 — 이름 = 품명 + 규격 · 단위 = 엑셀 단위 · 구분 「실무자료」 ·
상세구분 = 파일 · 본뜬 = {층 「실무자료」, 키 「HP-###### 제N호표」, 파일, 원본 [층, 주인],
확인 {누가, 때}} · 만드는 층 = 부르는 층(일위대가 문서 층).
로직 = {로직, 별칭 = 엑셀 품명 40 자, 메모 「실무 0.14㎥ × 1 · 규격」, 입력값,
수량 {손 = 엑셀 수량 × 배}} · 한 줄 로직 여럿 = 같은 별칭 · 같은 로직 둘째부터 = 한 줄
사전조합으로 감싸 참조(같은 로직 두 번 금지) ·
자재 = {코드, 수량 {손}, 별도계상}(같은 자재 = 수량 더함) · 호표 = 확인된 사전조합 참조.
같은 본뜬 사전조합이 있으면 확인한 내용으로 덮음(`다시씀`) · 확인한 줄은 별칭 장부에 남음
(`M02_Hopyo_Ledger` · 호표 참조 줄 뺌).
빠진 줄(확인 안 함 · 뺌 · %) = 엑셀 금액과 함께 보고 · 만든 값 ↔ 엑셀 호표 단가(참고).
엔진 · 일위대가 계산은 그대로(`M02_Labor_Ref.Expand`).
"""
from __future__ import annotations
import json
from datetime import datetime
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_Ledger as ledger
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 = "실무자료"
LINES_MAX = 500
UNCHECKED = "확인 안 함"
PERCENT = "% 줄 — 우리 로직 덧줄이 셈"
WHOLE = "호표 통째로 대신"
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 _origins(folder: Path):
"""층 사전조합 가운데 실무자료 본뜬 → (키, 본뜬, 판)."""
for path in sorted((Path(folder) / "precombo").glob("SC-*.json")):
try:
raw = path.read_bytes()
doc = json.loads(raw)
except (OSError, ValueError):
continue
got = doc.get("본뜬") if isinstance(doc, dict) else None
if isinstance(got, dict) and got.get("층") == ORIGIN:
yield path.stem, got, raw
def _same_source(got: dict[str, Any], source: refs.Here | None) -> bool:
"""본뜬 원본 층이 같음 — 원본 없는 옛 것 · `source` None 은 가리지 않음."""
have = got.get("원본")
return source is None or not have or tuple(have) == tuple(source)
def confirmed(folder: str | Path, source: refs.Here | None = None) -> dict[str, str]:
"""층에 확인된 실무 사전조합 `{본뜬 키: SC 키}`(`source` = 파일 층 (층, 주인)으로 거름)."""
return {
got["키"]: key
for key, got, _ in _origins(Path(folder))
if got.get("확인") and _same_source(got, source)
}
def _save(doc: dict[str, Any]) -> tuple[dict[str, Any], bool]:
"""(참조, 다시씀) — 같은 본뜬(키 · 원본)이 있으면 그 키에 덮음 · 없으면 새 키."""
at = refs.here()
want = doc["본뜬"]
have = next(
(
(key, store.version_of(raw))
for key, got, raw in _origins(store.root())
if got.get("키") == want["키"] and _same_source(got, tuple(want["원본"]))
),
None,
)
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], one: dict[str, Any]) -> str:
qty = line.get("수량")
spec = str(line.get("규격") or "").strip()
head = f"실무 {qty if qty is not None else ''}{line.get('단위') or ''} × {one['배']}"
head += f"(몫 {one['몫']})" if one.get("몫") is not None else ""
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,
}
def _bad(text: str, status: int = 400) -> store.StoreError:
return store.StoreError(status, text)
class Builder:
"""한 번 만들기 — 파일 · 우리 쪽 목록 · 확인된 호표를 한 번 읽고 몸을 검사한 뒤 씀."""
def __init__(self, folder: Path, source: Path, key: str, no: int, who: Any) -> None:
self.folder = Path(folder)
self.doc = hopyo_store.read(source, key)
self.key, self.no, self.who = key, int(no), who
self.source = refs.where(source)
self.done = confirmed(folder, self.source)
self.known = ledger._known()
self.hopyo = next((h for h in self.doc.get("호표") or [] if int(h["번"]) == self.no), None)
if self.hopyo is None:
raise _bad(f"없는 호표 「제{no}호표」", 404)
# ── 재검사 ───────────────────────────────────────────────────────
def _target(self, raw: dict[str, Any], where: str) -> dict[str, Any]:
if raw.get("호표") is None:
return ledger.check_pair(
{k: v for k, v in raw.items() if k != "호표"}, where, self.known
)
if raw.get("로직") or raw.get("자재") or raw.get("입력값"):
raise _bad(f"호표 대상은 호표 · 배만 「{where}」")
child = int(raw["호표"])
if child == self.no:
raise _bad(f"호표가 자기를 담음 「{where}」")
if mark(self.key, child) not in self.done:
raise _bad(f"제{child}호표 먼저 확인", 409)
times, part = ledger.times_of(raw, where)
return {"호표": child, "배": times, "몫": part}
def check(self, lines: list[dict[str, Any]], whole: dict[str, Any] | None) -> tuple[dict, list]:
"""몸 재검사 → ({차례: 확인 줄}, 통째 대상) — 틀리면 400 · 하위 호표 409."""
if whole is not None and lines:
raise _bad("통째 · 줄 중 하나만")
if not lines and not (whole or {}).get("대상"):
raise _bad("확인한 줄이 없음")
if len(lines) > LINES_MAX:
raise _bad(f"줄은 {LINES_MAX} 개 안")
have = self.hopyo.get("줄") or []
out: dict[int, dict[str, Any]] = {}
for raw in lines:
at = int(raw.get("차례", -1))
if not 0 <= at < len(have) or at in out:
raise _bad(f"없는 줄 · 겹친 줄 「제{self.no}호표 {at}」")
where = f"제{self.no}호표 {at + 1}째 줄"
targets = raw.get("대상") or []
if bool(raw.get("뺌")) == bool(targets):
raise _bad(f"대상 · 뺌 중 하나만 「{where}」")
if raw.get("뺌") and raw["뺌"] not in ledger.KINDS[1:]:
raise _bad(f"뺌 까닭이 틀림 「{where}」")
if len(targets) > 20:
raise _bad(f"대상은 스물 이하 「{where}」")
out[at] = {"뺌": raw.get("뺌")} if raw.get("뺌") else {
"대상": [self._target(t, where) for t in targets]
} # fmt: skip
pairs = [self._target(t, f"제{self.no}호표 통째") for t in (whole or {}).get("대상") or []]
if any("호표" in p for p in pairs) or len(pairs) > 20:
raise _bad("통째 대상은 로직 · 자재만")
return out, pairs
# ── 쓰기 ─────────────────────────────────────────────────────────
def _origin(self, origin: str, stamp: str) -> dict[str, Any]:
sure = {"누가": self.who, "때": stamp}
file = self.doc.get("파일")
return {
"층": ORIGIN,
"주인": None,
"키": origin,
"파일": file,
"원본": list(self.source),
"확인": sure,
}
def build(self, checked: dict[int, dict[str, Any]], whole: list[dict[str, Any]]) -> dict:
stamp = datetime.now().isoformat(timespec="seconds")
hopyo, file = self.hopyo, self.doc.get("파일") or ""
lines, mats, missing, used = [], {}, [], set()
def put(line: dict[str, Any], one: dict[str, Any], alias: str | None) -> None:
qty = Decimal(str(line.get("수량") or 0)) * Decimal(str(one["배"]))
memo = _memo(line, one)
if "자재" in one:
apart = "별도계상" if line.get("갈래") == "별도계상" else "직접계산"
have = mats.get(one["자재"])
hand = qty + Decimal(str(have["수량"]["손"])) if have else qty
mats[one["자재"]] = {
"코드": one["자재"],
"수량": {"손": _num(hand)},
"별도계상": apart,
}
return
if "호표" in one:
ref = {
"층": refs.here()[0],
"주인": refs.here()[1],
"키": self.done[mark(self.key, one["호표"])],
}
lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}})
return
row = {"로직": one["로직"], "입력값": one["입력값"], "메모": memo}
if one["로직"] not in used:
used.add(one["로직"])
lines.append({**row, "별칭": alias, "수량": {"손": _num(qty)}})
return
at = int(line.get("차례", 0))
wrap = { # 같은 로직 둘째 — 한 줄 사전조합으로 감쌈
"이름": f"{hopyo['이름']} — {line.get('이름')}",
"단위": line.get("단위") or None,
"구분": ORIGIN,
"상세구분": file,
"담은로직": [{**row, "별칭": alias, "수량": {"손": 1}}],
"자재": [],
"본뜬": self._origin(f"{mark(self.key, self.no, at)} {one['로직']}", stamp),
}
ref, _ = _save(wrap)
lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}})
book: dict[str, dict[str, Any]] = {}
if whole:
for one in whole:
put({"수량": 1, "단위": hopyo.get("단위"), "이름": hopyo.get("이름")}, one, None)
book[ledger.hopyo_key(hopyo)] = self._row(hopyo, "짝", whole)
for at, line in enumerate(hopyo.get("줄") or []):
got = checked.get(at)
if line.get("갈래") == "%":
missing.append(_missing(line, PERCENT))
continue
if whole or got is None:
missing.append(_missing(line, WHOLE if whole else UNCHECKED))
continue
if got.get("뺌"):
missing.append(_missing(line, f"뺌 — {got['뺌']}"))
book[ledger.key(line)] = self._row(line, got["뺌"], [])
continue
alias = str(line.get("이름") or "")[: labor.ALIAS_MAX].strip() or None
for one in got["대상"]:
put(line, one, alias)
pairs = [p for p in got["대상"] if "호표" not in p]
if pairs:
book[ledger.key(line)] = self._row(line, "짝", pairs)
name = " ".join(x for x in (hopyo["이름"], hopyo.get("규격")) if x)
doc = {
"이름": name,
"단위": hopyo.get("단위") or None,
"구분": ORIGIN,
"상세구분": file,
"담은로직": lines,
"자재": list(mats.values()),
"본뜬": self._origin(mark(self.key, self.no), stamp),
}
ref, again = _save(doc)
wrote = ledger.write(self.folder, book, self.who, file or None)
return {"참조": ref, "다시씀": again, "빠진줄": missing, "장부": {"씀": wrote}}
def _row(self, line: dict[str, Any], kind: str, pairs: list[dict[str, Any]]) -> dict[str, Any]:
"""장부 한 줄 — 엑셀조항 = 줄 근거 출처(있으면)."""
origin = {"품명": line.get("이름"), "규격": line.get("규격"), "단위": line.get("단위")}
clauses = (line.get("근거") or {}).get("출처") or []
return {
"원래": {k: str(v or "") for k, v in origin.items()},
"갈래": kind,
"짝": pairs,
"엑셀조항": clauses if isinstance(clauses, list) else [],
}
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,
lines: list[dict[str, Any]] | None = None,
whole: dict[str, Any] | None = None,
who: Any = None,
) -> dict[str, Any]:
"""실무 호표(`source` 층 파일) + 확인한 줄 → 지금 층(`folder`) 사전조합 ·
별칭 장부 — `{참조, 이름, 다시씀, 빠진줄, 비교, 장부}`(부르는 쪽이 `store.at(folder)`)."""
maker = Builder(folder, source, key, no, who)
checked, pairs = maker.check(list(lines or []), whole)
made = maker.build(checked, pairs)
return {**made, "이름": maker.hopyo.get("이름"), "비교": compare(made["참조"], maker.hopyo)}