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
This commit is contained in:
2026-10-15 23:21:09 +09:00
co-authored by Claude Opus 5.5
parent ea21006352
commit bf5d130e32
4 changed files with 508 additions and 223 deletions
+219 -109
View File
@@ -1,39 +1,44 @@
"""M02 일위대가 실무자료 → 우리 일위대가 사전조합(PLAN 324-3).
"""M02 일위대가 실무자료 → 우리 일위대가 사전조합(PLAN 324-3 · 338-3 · 설계 3-6 · 3-7).
[확인하고 사전조합 만들기] 몸 = 사용자가 체크한 줄만 —
`줄 [{차례, 대상 [{로직|자재|호표, 입력값, 배, 몫}] | 뺌}]` · 직접 줄 호표 = `통째 {대상}`.
몸이 비면 400(확인 없이 들어가는 길 없음).
서버 재검사(브라우저 값을 받아 적지 않음 · 금액 · 품 칸 없음) — 차례 = 그 호표 줄 · 대상 하나 =
돈 로직 · M01 자재품목 · 확인된 호표 중 하나 · 입력 이름 = 그 로직 입력 · 배 0 넘음 · 몫 (0, 1] ·
호표 = 만들 층에 확인된 사전조합(없으면 409 「제N호표 먼저 확인」).
호표 하나 = 사전조합 하나 — 이름 = 품명 + 규격 · 단위 = 엑셀 단위 · 구분 「실무자료」 ·
상세구분 = 파일 · 본뜬 = {층 「실무자료」, 키 「HP-###### 제N호표」, 파일} ·
만드는 층 = 부르는 층(일위대가 문서 층). 들어가는 줄(`M02_Hopyo_Match` 의 `들어감`)만:
로직 = {로직, 별칭 = 엑셀 품명 40 자, 메모 = 「실무 0.14㎥ × 1 · 규격」, 입력값,
수량 {손 = 엑셀 수량 × 환산}} · 같은 로직 둘째부터 = 그 줄 하나짜리 사전조합으로 감싸 참조
(같은 로직 두 번 금지) · 자재 = {코드, 수량 {손}, 별도계상 = 엑셀 값 없음 「별도계상」 ·
아니면 「직접계산」}(같은 자재 = 수량 더함) · 일위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_Match as match
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 = "실무자료"
WHY = {
"%": "% 줄 — 우리 로직 덧줄이 셈",
"빼기": "뺌(확정)",
"후보": "못 맞춤 — 후보 확정 필요",
"없음": "못 맞춤",
}
LINES_MAX = 500
UNCHECKED = "확인 안 함"
PERCENT = "% 줄 — 우리 로직 덧줄이 셈"
WHOLE = "호표 통째로 대신"
def mark(key: str, no: int, at: int | None = None) -> str:
@@ -42,25 +47,46 @@ def mark(key: str, no: int, at: int | None = None) -> str:
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")):
def _origins(folder: Path):
"""층 사전조합 가운데 실무자료 본뜬 → (키, 본뜬, 판)."""
for path in sorted((Path(folder) / "precombo").glob("SC-*.json")):
try:
doc = json.loads(path.read_bytes())
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 and got.get("키") == origin:
return path.stem, store.version_of(path.read_bytes())
return None
if isinstance(got, dict) and got.get("층") == ORIGIN:
yield path.stem, got, raw
def _save(doc: dict[str, Any], fresh: bool) -> tuple[dict[str, Any], bool]:
"""(참조, 다시씀) — 있으면 그대로(`fresh` 면 그 키에 덮음) · 없으면 새 키."""
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()
have = _found(doc["본뜬"]["키"])
if have and not fresh:
return {"층": at[0], "주인": at[1], "키": have[0]}, True
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]
@@ -74,10 +100,11 @@ def _num(value: Decimal) -> int | float:
return int(value) if value == value.to_integral_value() else float(value)
def _memo(line: dict[str, Any], times: Any) -> str:
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 ''} × {times}"
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
@@ -90,96 +117,178 @@ def _missing(line: dict[str, Any], why: str) -> dict[str, Any]:
}
def _bad(text: str, status: int = 400) -> store.StoreError:
return store.StoreError(status, text)
class Builder:
"""한 번 만들기 — 파일 · 층 기억 · 우리 쪽 목록을 한 번 읽고 호표마다 만든 참조를 기억."""
"""한 번 만들기 — 파일 · 우리 쪽 목록 · 확인된 호표를 한 번 읽고 몸을 검사한 뒤 씀."""
def __init__(self, source: Path, folder: Path, key: str, fresh: bool) -> None:
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.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]]] = {}
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 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 _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 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["자재"]
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(code)
have = mats.get(one["자재"])
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))
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)}})
continue
one = {"로직": target["로직"], "입력값": got.get("입력값") or {}, "메모": memo}
if target["로직"] not in used:
used.add(target["로직"])
lines.append({**one, "별칭": alias, "수량": {"손": _num(qty)}})
continue
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('이름')}",
"단위": target.get("단위"),
"단위": line.get("단위") or None,
"구분": ORIGIN,
"상세구분": self.doc.get("파일") or "",
"담은로직": [{**one, "별칭": alias, "수량": {"손": 1}}],
"상세구분": file,
"담은로직": [{**row, "별칭": alias, "수량": {"손": 1}}],
"자재": [],
"본뜬": self._origin(mark(self.key, no, line["차례"])),
"본뜬": self._origin(f"{mark(self.key, self.no, at)} {one['로직']}", stamp),
}
ref, _ = _save(wrap, self.fresh)
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,
"상세구분": self.doc.get("파일") or "",
"상세구분": file,
"담은로직": lines,
"자재": list(mats.values()),
"본뜬": self._origin(mark(self.key, no)),
"본뜬": self._origin(mark(self.key, self.no), stamp),
}
ref, again = _save(doc, self.fresh)
self.made[no] = {**ref, "다시씀": again}
self.missing[no] = missing
return self.made[no]
ref, again = _save(doc)
wrote = ledger.write(self.folder, book, self.who, file or None)
return {"참조": ref, "다시씀": again, "빠진줄": missing, "장부": {"씀": wrote}}
def _origin(self, origin: str) -> dict[str, Any]:
return {"층": ORIGIN, "주인": None, "키": origin, "파일": self.doc.get("파일")}
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 단위 값 ↔ 엑셀 호표 단가."""
"""만든 사전조합 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
@@ -188,17 +297,18 @@ def compare(ref: dict[str, Any], hopyo: dict[str, Any]) -> dict[str, Any]:
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),
}
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)}