feat(777-1): 호표가 다른 호표를 부를 때 = 일위대가 둘 — 하위 함께 만들기 · 같은 SC 재사용 · 결과 글 · 납작 길 막음

- 부모 저장 때 확인 안 된 하위 호표(깊이 끝까지)를 하위 먼저 그 체크대로 함께 만듦(409 「먼저 확인」 걷음)
  · 서버 = 체크 제안 몸(M02_Hopyo_Kids.proposal ↔ 화면 bodyFor 짝) · 화면 = 확인 창 캐시 고름(Hopyo_Kids.ts)
  · 하위 실패 = 409 「하위 제N호표 실패 — …」 · 하위 · 하위남은줄 → 결과 표 [고르기]가 그 하위를 엶
  · [모두 고정] 연쇄 실패 까닭 따로 · 미리 보기는 하위 호표 줄을 이음으로 셈
- 같은 엑셀 다시 불러오기 = 같은 이름 · 같은 내용(수량까지) SC 재사용 — 본뜬.또(SK 꼴) · 끝내기는 그 몫만 뺌
- 결과 표 글 「SC-12 · 하위 제4호표 SC-11(사본 담음)」 · 확인 창 호표 줄 글 「하위 일위대가 SC-11 사본」
- [구조물 조합으로] 도 하위 먼저(못 만들면 지금처럼 고정 줄) · 붙여넣기 · B09 통째 = 이미 하위 먼저(안 고침)
- 엑셀 대조(CUTS · _excel_round · excel_given · compare) → 새 M02_Hopyo_Compare.py(Build 608 → 540)
- M02_Labor_Router 병행 흔적 가드 걷음(_later · inspect · 몸 칸 새로)
- STmate [엑셀 보기] 단가산출근거로 감(형식별 블록 시트 이름)
- 낡은 시험 고침: 「제46호표 먼저 확인」 409 · 글 → 하위 함께(777-1 정함) · 718-1 실패 = 남은줄 또는 하위 ·
  화면 시험 StaleError 안내 → unmadeKids · leftAt · 새 지킴 시험 test_777_1_hopyo_kids

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 09:24:48 +09:00
co-authored by Claude Opus 5.5
parent 4e0ea28aec
commit eaa8064f1a
17 changed files with 740 additions and 171 deletions
+45 -110
View File
@@ -5,7 +5,7 @@
몸이 비면 = 모두 고정(705-3 · [모두 고정으로 들여오기]).
서버 재검사(브라우저 값을 받아 적지 않음 · 금액 · 품 칸 없음) — 차례 = 그 호표 줄 · 대상 하나 =
돈 로직 · M01 자재품목 · 확인된 호표 중 하나 · 입력 이름 = 그 로직 입력 · 배 0 넘음 · 몫 (0, 1] ·
호표 = 만들 층에 확인된 사전조합(없으면 409 「제N호표 먼저 확인」).
호표 = 만들 층에 확인된 사전조합(확인 안 된 하위 = 저장 때 하위 먼저 함께 만듦 · `M02_Hopyo_Kids.first` · 777-1).
호표 하나 = 사전조합 하나 — 이름 = 품명 + 규격 · 단위 = 엑셀 단위 · 구분 「실무자료」 ·
상세구분 = 파일 · 본뜬 = {층 「실무자료」, 키 「HP-###### 제N호표」, 파일, 원본 [층, 주인],
확인 {누가, 때}} · 만드는 층 = 부르는 층(일위대가 문서 층).
@@ -18,7 +18,8 @@
「전국」 자재(361-1 · 키 · 단가 없음 · 서버가 줄에서 다시 셈) = 일위대가 자재 칸이 M01 키만
받아 `본뜬.전국자재 [{이름, 규격, 단위, 수량, 세 칸}]` 에 적고 빠진 줄에 까닭(금액 없음 ·
지역은 프로젝트에서) · 장부엔 안 남김.
같은 본뜬 사전조합이 있으면 확인한 내용으로 덮음(`다시씀`) · 확인한 줄은 별칭 장부에 남음
같은 본뜬 사전조합이 있으면 확인한 내용으로 덮음(`다시씀`) · 없으면 같은 내용 SC 재사용(`재사용` · `M02_Hopyo_Kids.save`) ·
`하위` = 부르는 하위 호표 `[{번, 키, 새로}]`(부모 줄 = 하위 사본) · 확인한 줄은 별칭 장부에 남음
(`M02_Hopyo_Ledger` · 호표 참조 줄 뺌).
빠짐 0(705-3) — 확인 안 한 줄 · 노임 · % · 「우리에 없음」 줄 = 고정 줄(`M02_Hopyo_Fixed` · 엑셀 숫자
그대로) · 확인 안 한 호표 줄 = 확인된 하위 사전조합 참조(단위 같음 · 아니면 고정) · 빠진 줄 = 「안 씀」 ·
@@ -31,13 +32,12 @@
`{비율: 기준}` = 고정 줄 + 그 이음 · 남은 줄(`Fixed.linked` 아님 · 「우리에 없음」 고른 줄 뺌) 있으면 409
`{까닭, 남은줄}`(저장 안 함 · `save` 거짓 = 막지 않고 `남은줄` 만) · 미리 보기 = `unlinked`(쓰기 없음).
수량(742-1 · 요구 584-1) = 엑셀 수량을 줄 · 자재 저장 수량(`{손}`)으로 · 같은 값 `본뜬.실무수량 {줄: {차례: 수},
자재: {코드: 수}}`(출처 기록 · 엑셀 대조 `compare` — 하위 호표 것까지 `excel_given`).
자재: {코드: 수}}`(출처 기록 · 엑셀 대조 `M02_Hopyo_Compare` — 하위 호표 것까지 `excel_given`).
"""
from __future__ import annotations
import json
from decimal import ROUND_FLOOR, ROUND_HALF_UP, Decimal
from decimal import Decimal
from pathlib import Path
from typing import Any
@@ -45,6 +45,7 @@ from common_util.common_util_time import utc_now_text
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Hopyo_Finger as finger
from M02_MasterTemplete import M02_Hopyo_Fixed as hfix
from M02_MasterTemplete import M02_Hopyo_Kids as hkids
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
@@ -52,7 +53,12 @@ from M02_MasterTemplete import M02_Hopyo_SkLink as sklink
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_Library_Same as lsame
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete.M02_Hopyo_Compare import ( # noqa: F401 — 부르는 쪽 이름 그대로
compare,
excel_given,
)
ORIGIN = "실무자료"
LINES_MAX = 500
@@ -69,23 +75,8 @@ def mark(key: str, no: int, at: int | None = None) -> str:
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)
_origins = hkids.entries # B09 가 빌려 씀(또 칸까지)
_same_source = hkids.same_source
def confirmed(folder: str | Path, source: refs.Here | None = None) -> dict[str, str]:
@@ -97,31 +88,6 @@ def confirmed(folder: str | Path, source: refs.Here | None = None) -> dict[str,
}
def _save(doc: dict[str, Any]) -> tuple[dict[str, Any], bool]:
"""(참조, 다시씀) — 같은 본뜬(키 · 원본)이 있으면 그 키에 덮음 · 없으면 새 키 · 엑셀 수량 = 저장(742-1)."""
hands = {
"줄": {str(i): x["수량"]["손"] for i, x in enumerate(doc["담은로직"]) if x.get("수량")},
"자재": {m["코드"]: m["수량"]["손"] for m in doc.get("자재") or [] if m.get("수량")},
}
doc = {**doc, "본뜬": {**doc["본뜬"], "실무수량": hands}}
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)
@@ -163,10 +129,12 @@ class Builder:
self.done = confirmed(folder, self.source)
self.known = ledger._known()
self.mat_names = {x["자재"]: x["이름"] for x in hopyo_match.targets()["자재"]}
self.units = {int(h["번"]) for h in self.doc.get("호표") or []}
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)
self.src, self.dry, self._sk, self._maker = Path(source), False, None, None
self.same, self.reused = lsame.Same(), False # 같은 내용 SC 찾기(한 요청 메모)
self.strict = True # 남은 줄 있으면 409(붙여넣기 `build_all` = 거짓)
self.doc_id = ledger.doc_id(source, key) # 739-1 이 불러오기에서 이은 짝
@@ -185,8 +153,10 @@ class Builder:
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)
if mark(self.key, child) not in self.done and not self.dry:
raise _bad(
f"제{child}호표 먼저 확인", 409
) # 저장 길은 하위 먼저 만든 뒤(`hkids.first`)
times, part = ledger.times_of(raw, where)
return {"호표": child, "배": times, "몫": part}
@@ -311,7 +281,7 @@ class Builder:
ref = {
"층": refs.here()[0],
"주인": refs.here()[1],
"키": self.done[mark(self.key, one["호표"])],
"키": self.done.get(mark(self.key, one["호표"]), "SC-미리"),
}
lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}})
return
@@ -336,7 +306,9 @@ class Builder:
"자재": [],
"본뜬": self._origin(f"{mark(self.key, self.no, at)} {one['로직']}", stamp),
}
ref = {"키": "SC-미리"} if self.dry else _save(wrap)[0] # 미리 보기 = 쓰기 없음
ref = (
{"키": "SC-미리"} if self.dry else hkids.save(wrap, self.same)[0]
) # 미리 보기 = 쓰기 없음
lines.append({"참조": ref, "별칭": alias, "메모": memo, "수량": {"손": _num(qty)}})
book: dict[str, dict[str, Any]] = {}
@@ -395,13 +367,15 @@ class Builder:
count = {"품셈": len(poomsem), "고정": fixed_n, "호표": kid_n}
count |= {"빠짐": sum(1 for x in missing if x["까닭"] != TWICE), "이음": links}
count["단가산출"] = sum(1 for x in lines if _is_sk(x))
sub = [{"번": n, "키": self.done.get(mark(self.key, n))} for n in sorted(kids)]
if not save:
ledger.write(self.folder, book, self.who, file or None, self.doc_id)
head = {"문서": doc, "빠진줄": missing, "남은줄": left}
head = {"문서": doc, "빠진줄": missing, "남은줄": left, "하위": sub}
return {**head, "줄수": {**count, "이음후보": linker.near}}
ref, again = _save(doc)
ref, again, self.reused = hkids.save(doc, self.same)
wrote = ledger.write(self.folder, book, self.who, file or None, self.doc_id)
out = {"참조": ref, "다시씀": again, "빠진줄": missing, "장부": {"씀": wrote}}
out = {"참조": ref, "다시씀": again, "재사용": self.reused, "빠진줄": missing, "하위": sub}
out["장부"] = {"씀": wrote}
return {**out, "줄수": {**count, "이음후보": linker.near}}
def unlinked(self, checked: dict[int, dict[str, Any]], whole: list[dict[str, Any]]) -> list:
@@ -418,7 +392,9 @@ class Builder:
if finger.kind_of(line) != "호표" and ref.get("종류") != "일위":
return None
no = int(ref.get("번") or 0)
made = self.done.get(mark(self.key, no))
made = self.done.get(mark(self.key, no)) or (
"SC-미리" if self.dry and no in self.units else None
)
unit = next((h.get("단위") for h in self.doc["호표"] if int(h["번"]) == no), None)
times = hopyo_match.factor(line.get("단위"), unit, line.get("규격"))["배"]
if not made or no in kids or no == self.no or times != 1:
@@ -457,59 +433,6 @@ class Builder:
}
# 엑셀 끝수 꼴(705-3) — (줄 금액 0.1 · 합 원 자름, 계 = 줄 계 합에서) · EST = 비목 합 내림 ·
# STmate = 줄 반올림 · 계 = 줄 계 합 내림(비목 머리 값과 따로)
CUTS = ((ROUND_FLOOR, False), (ROUND_HALF_UP, True), (ROUND_HALF_UP, False))
def _excel_round(got: dict[str, Any], how: str, by_line: bool) -> Decimal | None:
"""엑셀 끝수(705-3) — 줄 금액 비목마다 0.1(`how`) · 하위 호표 단가 = 원 · 비율 줄 = 기준 × 율 ÷ 100 ·
합 = 비목마다 원(`how`) 더함 · `by_line` = 줄 계 합 원 내림 · 못 셈 줄 있으면 None."""
parts = dict.fromkeys(labor.COSTS, Decimal(0))
for one in got.get("안") or []:
if one.get("기준") is None:
return None
cut = 100 if (one.get("고정") or {}).get("비율") else 1
for k in labor.COSTS:
unit = Decimal(one["기준"][k])
unit = unit.to_integral_value(how) if "참조" in one else unit
parts[k] += (unit * one["수량"] / cut).quantize(Decimal("0.1"), how)
if by_line:
return sum(parts.values()).to_integral_value(ROUND_FLOOR)
return sum(v.to_integral_value(how) for v in parts.values())
def excel_given(ref: dict[str, Any], depth: int = 1) -> dict[str, Any]:
"""`본뜬.실무수량` → 안 줄 알약 값(723-1 · 하위 사전조합까지) — 엑셀 대조 전용."""
doc = refs.read_ref(ref) if depth <= refs.DEPTH else None
if not isinstance(doc, dict):
return {}
rid, hands = refs.ref_id(ref), (doc.get("본뜬") or {}).get("실무수량") or {}
out = {f"{rid}#{i}": v for i, v in (hands.get("줄") or {}).items()}
out |= {f"{rid}#자재|{code}": v for code, v in (hands.get("자재") or {}).items()}
for one in refs.refs_in(doc):
out |= excel_given(one, depth + 1)
return out
def compare(ref: dict[str, Any], hopyo: dict[str, Any]) -> dict[str, Any]:
"""만든 사전조합 1 단위 값 ↔ 엑셀 호표 단가(참고 · 판정 아님) — 우리 = 엑셀 끝수 규칙(내림 ·
반올림 중 엑셀과 같은 쪽 · `_excel_round`) · 수량 = 엑셀 수량(`excel_given` · 723-1)."""
head = {k: ref[k] for k in ("층", "주인", "키")}
got = refs.Expand(m01.loaded()[1], given=excel_given(head))(head)
base = got.get("기준") or {}
excel = (hopyo.get("금액") or {}).get("계")
cuts = [_excel_round(got, *one) for one in CUTS] if base else []
cuts = [c for c in cuts if c is not None]
cut = next(
(c for c in cuts if abs(c - Decimal(str(excel or 0))) < 1), cuts[0] if cuts else None
)
ours = float(cut if cut is not None else base["계"]) if "계" in base else None
gap = round(ours - excel, 1) if ours is not None and excel is not None else None
same = ours is not None and abs(ours - (excel or 0)) < 1 # 705-3 — 엑셀 금액 없음 = 0
return {"엑셀": excel, "우리": ours, "차이": gap, "같음": same, "까닭": got.get("까닭")}
def unlinked(
folder: Path,
source: Path,
@@ -520,6 +443,7 @@ def unlinked(
) -> dict[str, Any]:
"""안 이은 줄 미리 보기(729-3) — 몸 = 만들기 몸 → `{남은줄, 수}`(저장 없음)."""
maker = Builder(folder, source, key, no, None)
maker.dry = True # 하위 확인 전 호표 대상 = 저장 때 함께 만듦(409 아님)
checked, pairs = maker.check(list(lines or []), whole)
left = maker.unlinked(checked, pairs)
return {"남은줄": left, "수": len(left)}
@@ -535,10 +459,16 @@ def build(
who: Any = None,
) -> dict[str, Any]:
"""실무 호표(`source` 층 파일) + 확인한 줄 → 지금 층(`folder`) 사전조합 ·
별칭 장부 — `{참조, 이름, 다시씀, 빠진줄, 비교, 장부}`(부르는 쪽이 `store.at(folder)`)."""
별칭 장부 — `{참조, 이름, 다시씀, 재사용, 빠진줄, 하위, 비교, 장부}`(부르는 쪽이 `store.at(folder)`) ·
확인 안 된 하위 호표 = 먼저 서버 체크 제안대로 함께 만듦(777-1 · 실패 = 409 「하위 제N호표 실패」)."""
kids = (
hkids.first(folder, source, key, no, who, lines) if whole is None else []
) # 통째 = 하위 줄 안 씀
new = {x["번"] for x in kids}
maker = Builder(folder, source, key, no, who)
checked, pairs = maker.check(list(lines or []), whole)
made = maker.build(checked, pairs)
made["하위"] = [{**x, "새로": x["번"] in new} for x in made["하위"]]
return {**made, "이름": maker.hopyo.get("이름"), "비교": compare(made["참조"], maker.hopyo)}
@@ -581,12 +511,16 @@ def build_all(
"실패": [],
"만듦": [],
}
shared, failed = lsame.Same(), set()
for no in _order(units):
out["줄"] += len(units[no].get("줄") or [])
bad = next((k for k in hkids.kids_of(units[no]) if k in failed), None)
try:
maker = Builder(folder, source, key, no, who)
maker.strict = strict
maker.strict, maker.same = strict, shared
have = maker.done.get(mark(key, no))
if bad is not None and not have: # 777-1 — 하위 실패 연쇄 까닭 따로
raise store.StoreError(409, {"까닭": f"하위 제{bad}호표 실패", "하위": bad})
if have:
out["그대로"] += 1
ref = {"층": refs.here()[0], "주인": refs.here()[1], "키": have}
@@ -600,6 +534,7 @@ def build_all(
except store.StoreError as e:
why = e.detail if isinstance(e.detail, dict) else {"까닭": str(e.detail)}
out["실패"].append({"번": no, **why})
failed.add(no)
continue
if got["같음"]:
out["같음"] += 1