Files
Aislo/M02_MasterTemplete/M02_Hopyo_Build.py
T
eomsangdonandClaude Opus 5.5 a2c2ad92de fix(M02 실무자료): 361-1 지역만 다른 자재 = 전국 — 줄 글 지역 · (전국) 품목 · 없으면 키 없는 전국 자리표 · 고르기 창 지역 변형 접음 · 전국 되돌림 · 만들기 본뜬.전국자재
- 맞춤: 줄 글 지역 = 그 지역 변형(꼬리표 「지역 — 줄 글」) · 지역 없는 변형((전국) 품목 먼저) · 없으면(레미콘) 「전국」 자리표(바탕 이름(전국)-관급/사급 · 규격 · 단위 · 키 · 단가 없음 · 꼬리표 「전국 — 지역은 프로젝트에서」) · 옛 「못 가림 — N개 중 첫째」 없앰
- 후보 = 그 품목 변형 모두(`변형`) · 고르기 창 1위 / 전국 / 지역 변형(접힘) · 대상 꼴 `{전국: true}`
- 미리 셈 · 만들기: 전국 대상은 서버가 줄에서 다시 셈 · 일위대가 자재 칸이 M01 키만 받아 사전조합 `본뜬.전국자재` 에 적고 빠진 줄 까닭 · 장부엔 안 남김 · 만들기 몸 꼴에 `전국` 칸
- 시험: 전국 · 줄 글 지역 · (전국) 품목 · 고르기 지역 → 전국 되돌림 · 만들기 결과 · 화면 도우미

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

374 lines
19 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 · 규격」, 입력값,
수량 {손 = 엑셀 수량 × 배}} · 한 줄 로직 여럿 = 같은 별칭 · 같은 로직 둘째부터 = 한 줄
사전조합으로 감싸 참조(같은 로직 두 번 금지) ·
자재 = {코드, 수량 {손}, 세 칸}(같은 자재 = 수량 더함 · 세 칸 = `Material.marks`
별도계상 · 자재구분 · 운반비 · 358-2) · 로직 자재와 같은 품목 = 사용자 자재에서 빼고
세 칸만 그 로직 `자재표시` 로(빠진 줄에 까닭) · 호표 = 확인된 사전조합 참조 ·
「전국」 자재(361-1 · 키 · 단가 없음 · 서버가 줄에서 다시 셈) = 일위대가 자재 칸이 M01 키만
받아 `본뜬.전국자재 [{이름, 규격, 단위, 수량, 세 칸}]` 에 적고 빠진 줄에 까닭(금액 없음 ·
지역은 프로젝트에서) · 장부엔 안 남김.
같은 본뜬 사전조합이 있으면 확인한 내용으로 덮음(`다시씀`) · 확인한 줄은 별칭 장부에 남음
(`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_Match as hopyo_match
from M02_MasterTemplete import M02_Hopyo_Material as hmat
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 = "호표 통째로 대신"
TWICE = "로직 자재와 같은 품목 — 세 칸만 로직 자재표시로(수량 = 로직 셈)"
NATIONAL = "전국 — 지역은 프로젝트에서(자재 키 · 단가 없음 · 본뜬.전국자재)"
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.mat_names = {x["자재"]: x["이름"] for x in hopyo_match.targets()["자재"]}
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, line: dict | None = None) -> dict[str, Any]:
if raw.get("전국"):
return self._national(raw, where, line)
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 _national(self, raw: dict[str, Any], where: str, line: dict | None) -> dict[str, Any]:
"""「전국」 대상 — 줄에서 서버가 다시 셈(이름 · 규격 · 단위 = 서버 것)."""
if set(raw) - {"전국", "배", "몫", "입력값"} or raw.get("입력값"):
raise _bad(f"전국 대상은 전국 · 배 · 몫만 「{where}」")
one = hmat.national(line) if line is not None else None
if one is None:
raise _bad(f"전국 자재 없음 — 지역 변형만 있는 자재 줄만 「{where}」")
times, part = ledger.times_of(raw, where)
return {**{k: one[k] for k in ("전국", "이름", "규격", "단위")}, "배": 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, have[at]) 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, mat_lines = [], {}, [], set(), {}
nation: dict[tuple, dict[str, Any]] = {}
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:
at = (one["이름"], one["규격"], one["단위"])
have = nation.get(at)
hand = qty + Decimal(str(have["수량"]["손"])) if have else qty
head = {"이름": at[0], "규격": at[1], "단위": at[2], "수량": {"손": _num(hand)}}
nation[at] = {**head, **(hmat.marks(line, at[0]) if not have else {
k: have[k] for k in labor.MARKS if k in have
})} # fmt: skip
missing.append(_missing(line, NATIONAL))
return
if "자재" in one:
have = mats.get(one["자재"])
hand = qty + Decimal(str(have["수량"]["손"])) if have else qty
name = self.mat_names.get(one["자재"], "")
mats[one["자재"]] = {
"코드": one["자재"],
"수량": {"손": _num(hand)},
**(
have
and {k: have[k] for k in labor.MARKS if k in have}
or hmat.marks(line, name)
),
}
if not have:
mat_lines[one["자재"]] = line
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 and "전국" not in p]
if pairs:
book[ledger.key(line)] = self._row(line, "짝", pairs)
for code in self._inside(lines, mats): # 로직 자재와 같은 품목 = 세 칸만 로직 자재표시로
missing.append(_missing(mat_lines[code], TWICE))
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),
}
if nation:
doc["본뜬"]["전국자재"] = list(nation.values())
ref, again = _save(doc)
wrote = ledger.write(self.folder, book, self.who, file or None)
return {"참조": ref, "다시씀": again, "빠진줄": missing, "장부": {"씀": wrote}}
def _inside(self, lines: list[dict[str, Any]], mats: dict[str, dict[str, Any]]) -> list[str]:
"""로직 자재와 같은 품목(Labor_Doc `no_twice` 400 막음) — 사용자 자재에서 빼고
세 칸을 그 로직 줄 `자재표시 {재료 줄 차례: 세 칸}` 으로(수량 = 로직 셈) → 옮긴 코드."""
whole, moved = m01.loaded()[1], []
for row in (r for r in lines if r.get("로직")):
got = labor.run_line(whole, row["로직"], row.get("입력값"), None, None)
for m in got.get("재료") or []:
code = (m.get("품목") or {}).get("키")
if code in mats and code not in moved:
marks = {k: mats[code][k] for k in labor.MARKS if k in mats[code]}
row.setdefault("자재표시", {})[str(m["차례"])] = marks
moved.append(code)
for code in moved:
mats.pop(code)
return moved
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)}