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
+66
View File
@@ -0,0 +1,66 @@
"""M02 실무 호표 만든 일위대가 ↔ 엑셀 호표 단가 대조(PLAN 705-3 · 723-1 · 742-1 · 777-1 `M02_Hopyo_Build` 에서 떼어 냄).
참고 · 판정 아님 — 우리 = 엑셀 끝수 꼴(EST 비목 합 내림 · STmate 줄 반올림 · 계 = 줄 계 합 내림) 중 엑셀과 같은 쪽 ·
수량 = 엑셀 수량(`본뜬.실무수량` · 하위 사전조합까지 `excel_given`).
"""
from __future__ import annotations
from decimal import ROUND_FLOOR, ROUND_HALF_UP, Decimal
from typing import Any
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Labor_Ref as refs
# 엑셀 끝수 꼴(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("까닭")}
+6 -2
View File
@@ -5,7 +5,7 @@
HP · 격자 · 맞춤셈 캐시 지움 · 그 HP 로 만든 이 층 일위대가(SC) · 단가산출(SK) 엑셀 사본 걷음
(`본뜬` → 출처 `{층 「실무자료」, 파일, 때, 누가}` + 전국자재 · 복사원본 ·
SC 줄 메모 「실무 …」 걷음 · `본뜬.실무수량` = 모든 값이 그 줄 저장 `수량.손` 과 같을 때만
걷음(742-1 · 다르면 남김 = 금액 그대로)) · SK `본뜬.또` = 그 HP 몫만 뺌 ·
걷음(742-1 · 다르면 남김 = 금액 그대로)) · SK · SC `본뜬.또` = 그 HP 몫만 뺌 ·
문서 몸(줄 · 덮개 · 더한줄 · 별칭 · 구분)과 별칭 장부는 그대로 ·
끝낸 기록 없음(같은 엑셀 다시 올림 됨 · 키 번은 `불러옴.json` 번으로 다시 안 씀).
옛 파일 정리 `tidy`(한 번 · 층 폴더마다 · 미리 보기 = 표만) — 옛 HP(지문 없음) · 하루 지난 HP 끝냄 ·
@@ -113,7 +113,11 @@ def forget(folder: str | Path, gone: Callable[[str], bool], apply: bool = True)
got = doc["본뜬"]
if not any(hit(x) for x in [got, *(got.get("또") or [])] if isinstance(x, dict)):
continue
new = strip_sc(doc) if kind == "precombo" else strip_sk(doc, hit)
new = strip_sk(doc, hit) if kind == "sangeun" or not hit(got) else strip_sc(doc)
if kind == "precombo" and (
also := [x for x in got.get("또") or [] if isinstance(x, dict) and not hit(x)]
):
new["본뜬"]["또"] = also # 777-1 같은 내용 SC 를 나눠 쓴 다른 불러오기 몫은 그대로
if new != doc:
out.append(path.stem)
if apply:
+6
View File
@@ -3,6 +3,7 @@
[구조물 조합으로] = 확인 창 몸(체크한 줄 · 통째 · `M02_Hopyo_Build.check` 그대로)으로 줄을 만들어(`build(save=False)` ·
사전조합 SC 문서 안 씀 · 옛 [사전조합 생성] 길은 그대로) 이 층 구조물집계표에 새 항목(SU · `M02_Item_Personal.create_item`
· 경로 「실무자료」) + 그 일위대가 문서에 줄을 담음(수량 = 1 단위 몫 `{손}`).
확인 안 된 하위 호표 = 먼저 일위대가로 함께 만듦(777-1 `M02_Hopyo_Kids.first` · 줄 = 하위 사본 · 못 만들면 고정 줄).
규격 묶음 = 같은 파일에서 이름 · 단위 같고 줄 구성(줄 이름 · 단위 차례) 같은 · 규격만 다른 호표(5-A 「구성 같은 묶음」) →
한 항목의 규격 열(704-4) — 열 이름 = 규격 글에서 달라지는 칸(「H=1.5」 → 이름 H · 열 1.5) · 아니면 이름 「규격」 ·
열마다 같은 몸으로 만든 줄 수량(줄 차례 같음 · 다르면 400) · 기본 열 = 연 호표.
@@ -15,6 +16,7 @@ from pathlib import Path
from typing import Any
from M02_MasterTemplete import M02_Hopyo_Build as hb
from M02_MasterTemplete import M02_Hopyo_Kids as hkids
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_Item_Personal as personal
from M02_MasterTemplete import M02_Labor_Spec as spec
@@ -82,6 +84,10 @@ def siblings(source: Path, key: str, no: int) -> dict[str, Any]:
def _doc(folder: Path, source: Path, key: str, no: int, lines: list, whole: Any, who: Any) -> dict:
try: # 777-1 — 하위 호표 먼저(엑셀 단가 한 줄로 납작 안 함) · 못 만들면 지금처럼 고정 줄
hkids.first(folder, source, key, no, who, lines) if whole is None else None
except store.StoreError:
pass
maker = hb.Builder(folder, source, key, no, who)
checked, pairs = maker.check(list(lines or []), whole)
return {**maker.build(checked, pairs, save=False), "호표": maker.hopyo}
+247
View File
@@ -0,0 +1,247 @@
"""M02 실무 호표가 다른 호표를 부를 때 = 일위대가 둘(PLAN 777-1 · 요구 621-4 · 요구 581 「SK · SC 서로 독립」).
하위 함께 만들기 `first` — 부모 저장(`M02_Hopyo_Build.build` · [구조물 조합으로])에서 확인 안 된 하위 호표(호표 줄 ·
호표 대상 · 깊이 끝까지)를 하위 먼저 그 하위의 서버 체크 제안 몸(`proposal`)으로 만듦(409 「먼저 확인」 대신) ·
부모 줄 = 하위 사본(740-1 그대로) · 하위 실패 = 409 `{까닭 「하위 제N호표 실패 — …」, 하위 N, 하위남은줄}`.
화면 캐시 고름은 화면이 하위를 먼저 보냄(`M02_MasterTemplete_Hopyo_Kids.ts`).
`proposal` 짝 = 화면 `bodyFor` · `wholeFor`(`M02_MasterTemplete_Hopyo_Kids.ts` · 캐시 빈 것) — 체크 제안 줄 · 대상 꼴 같음.
같은 내용 일위대가 재사용 `save` — 같은 본뜬(키 · 원본)이면 그 SC(혼자 쓰면 덮음 · 나눠 쓰면 내용 같을 때만) ·
아니면 이 층 같은 이름 · 같은 내용 SC(`M02_Library_Same.find_same` + 수량까지) 의 `본뜬.또` 에 출처를 더해 씀
(SK 꼴 `M02_Hopyo_Sangeun.Maker.write`) · 없으면 새로 → 다시 불러와도 SC 가 안 늚.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from M02_MasterTemplete import M02_Hopyo_Finger as finger
from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_Library_Flat as lflat
from M02_MasterTemplete import M02_Library_Same as lsame
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete.M02_Hopyo_Sangeun import _same_mark
ORIGIN = "실무자료"
KIND = "precombo"
# ── 같은 내용 재사용 ─────────────────────────────────────────────────
def entries(folder: str | Path):
"""층 사전조합 실무자료 출처 → (키, 본뜬 · 또 한 칸, 판) — 또 = 같은 내용으로 이 SC 를 쓴 뒤 출처."""
for path in sorted((Path(folder) / KIND).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:
for one in [got, *(x for x in got.get("또") or [] if isinstance(x, dict))]:
yield path.stem, one, 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 _bare(value: Any) -> Any:
"""사본 블록 `주인`(몸 지문 · 메모까지 셈) 뺌 — 셈 내용은 `몸` 이 다 가짐."""
if isinstance(value, list):
return [_bare(x) for x in value]
if not isinstance(value, dict):
return value
return {k: _bare(x) for k, x in value.items() if not (k == "주인" and "몸" in value)}
def _rows(doc: dict[str, Any], part: str) -> list[str]:
return sorted(json.dumps(lsame.norm(_bare(x)), ensure_ascii=False, sort_keys=True) for x in doc.get(part) or []) # fmt: skip
def body(doc: dict[str, Any]) -> list:
"""셈 내용 그대로(수량까지 · 하위 = 펼친 사본 몸) — `find_same` 지문은 SC 수량을 안 봄."""
flat = lflat.flat_doc(KIND, doc)
head = [lsame.unit(flat.get("단위")), lsame.norm(flat.get("규격"))]
return [*head, _rows(flat, "담은로직"), _rows(flat, "자재")]
def _entry(doc: dict[str, Any]) -> dict[str, Any]:
return {**doc["본뜬"], "이름": doc["이름"]}
def _split(old: dict[str, Any], mine: dict[str, Any]) -> tuple[dict, list]:
got = old.get("본뜬") or {}
own = {k: v for k, v in got.items() if k != "또"}
rest = [x for x in got.get("또") or [] if isinstance(x, dict) and not _same_mark(x, mine)]
return own, rest
def _put(key: str, raw: bytes, old: dict, own: dict, also: list, doc: dict | None = None) -> None:
new = {**(doc or old), "본뜬": {**own, "또": also} if also else own}
if new != old:
store.write(KIND, key, store.version_of(raw), new)
def _empty(root: Path, doc: dict[str, Any]) -> list[str]:
"""빈 호표(줄 · 자재 없음 = 지문 없음) — 이름 · 단위 같은 실무자료 SC."""
if doc.get("담은로직") or doc.get("자재"):
return []
out = []
for path in sorted((root / KIND).glob("SC-*.json")):
try:
got = json.loads(path.read_bytes())
except (OSError, ValueError):
continue
same = (got.get("이름"), lsame.unit(got.get("단위"))) == (doc["이름"], lsame.unit(doc.get("단위"))) # fmt: skip
if same and (got.get("본뜬") or {}).get("층") == ORIGIN:
out.append(path.stem)
return out
def save(doc: dict[str, Any], same: lsame.Same | None = None) -> tuple[dict[str, Any], bool, bool]:
"""(참조, 다시씀, 재사용) — 엑셀 수량 = 저장(742-1) · `same` = 한 요청 안 지문 메모."""
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}}
same, root, mine = same or lsame.Same(), store.root(), doc["본뜬"]
here = refs.here()
ref = lambda key: {"층": here[0], "주인": here[1], "키": key} # noqa: E731
have = next(
(
(key, raw)
for key, got, raw in entries(root)
if got.get("키") == mine["키"] and same_source(got, tuple(mine["원본"]))
),
None,
)
if have:
key, raw = have
old = json.loads(raw)
own, rest = _split(old, mine)
owner, alike = _same_mark(own, mine), body(old) == body(doc)
if owner and (not rest or alike): # 혼자 · 내용 같음 = 제자리 덮음(나눈 출처 그대로)
_put(key, raw, old, mine, rest, doc)
same.add(root, KIND, key, doc)
return ref(key), True, False
if alike: # 남의 SC 를 같은 내용으로 씀 — 제 출처 칸만
_put(key, raw, old, own, [*rest, _entry(doc)])
return ref(key), True, False
head = {k: v for k, v in rest[0].items() if k != "이름"} if owner else own
_put(key, raw, old, head, rest[1:] if owner else rest) # 내용 달라짐 = 떼어 냄
for key in same.find_same(root, KIND, doc) or _empty(root, doc):
raw = (root / KIND / f"{key}.json").read_bytes()
old = json.loads(raw)
if old.get("이름") == doc["이름"] and body(old) == body(doc): # 이름까지 = 같은 호표
own, rest = _split(old, mine)
_put(key, raw, old, own, [*rest, _entry(doc)])
return ref(key), False, True
key = refs.create(KIND, doc)["이름"]
same.add(root, KIND, key, doc)
return ref(key), False, False
# ── 하위 함께 만들기 ─────────────────────────────────────────────────
def kids_of(hopyo: dict[str, Any]) -> list[int]:
"""호표 줄이 부르는 호표 번(줄 차례)."""
out = []
for line in hopyo.get("줄") or []:
ref = line.get("참조") or {}
if finger.kind_of(line) == "호표" or ref.get("종류") == "일위":
out.append(int(ref.get("번") or 0))
return out
def order(units: dict[int, dict[str, Any]], no: int, also: Any = ()) -> list[int]:
"""`no` 아래 하위 호표(+ `also` 와 그 아래) — 하위 먼저 · `no` 뺌 · 고리는 끊음."""
out: list[int] = []
seen = {int(no)}
def visit(at: int) -> None:
for kid in kids_of(units.get(at) or {}):
if kid not in seen and kid in units:
seen.add(kid)
visit(kid)
out.append(kid)
visit(int(no))
for kid in also:
if kid not in seen and kid in units:
seen.add(kid)
visit(kid)
out.append(kid)
return out
def _target(p: dict[str, Any]) -> dict[str, Any]:
"""대상 → 만들기 몸 대상(화면 `toTarget` 짝)."""
times = 1 if p.get("배") is None else p["배"]
if p.get("호표") is not None:
return {"호표": p["호표"], "배": times}
link = next((k for k in ("노임", "기계", "비율") if p.get(k)), None)
if link:
return {link: p[link], "배": 1}
head = (
{"로직": p["로직"], "입력값": p.get("입력값") or {}}
if p.get("로직")
else {"전국": True}
if p.get("전국")
else {"자재": p.get("자재")}
)
return {**head, "배": times, **({"몫": p["몫"]} if p.get("몫") is not None else {})}
def proposal(hopyo: dict[str, Any]) -> tuple[list[dict[str, Any]], dict[str, Any] | None]:
"""서버 체크 제안 몸 (줄, 통째) — 보기(`M02_Hopyo_Line.hopyo_view`) 체크 제안 줄만(화면 `bodyFor` 짝)."""
whole = hopyo.get("통째")
if whole and whole.get("체크"):
return [], {"대상": [_target(p) for p in whole.get("대상") or []]}
lines = []
for line in hopyo.get("줄") or []:
got = line.get("맞춤") or {}
parts = got.get("대상") if isinstance(got.get("대상"), list) else []
if got.get("상태") == "%":
ok = bool(parts and parts[0].get("비율"))
else:
ok = bool(parts) and all((p.get("배") or 0) > 0 for p in parts)
if got.get("체크") and ok:
lines.append({"차례": line["차례"], "대상": [_target(p) for p in parts]})
return lines, None
def first(
folder: Path, source: Path, key: str, no: int, who: Any, lines: Any = ()
) -> list[dict[str, Any]]:
"""확인 안 된 하위 호표(호표 줄 + 몸 `lines` 의 호표 대상)를 하위 먼저 서버 체크 제안대로 만듦 →
`[{번, 키}]`(새로 만든 것) ·
실패 = 409 `{까닭 「하위 제N호표 실패 — …」, 하위, 하위남은줄}`(만든 하위는 남음)."""
from M02_MasterTemplete import M02_Hopyo_Build as hb
from M02_MasterTemplete import M02_Hopyo_Match as hm
units = {int(h["번"]): h for h in hopyo_store.read(source, key).get("호표") or []}
also = [
t["호표"] for x in lines or [] for t in x.get("대상") or [] if t.get("호표") is not None
]
todo = order(units, no, also)
done = hb.confirmed(folder, refs.where(source)) if todo else {}
made: list[dict[str, Any]] = []
for kid in todo:
if hb.mark(key, kid) in done:
continue
try:
lines, whole = proposal(hm.view(source, key, kid, to=folder)["호표"])
maker = hb.Builder(folder, source, key, kid, who)
got = maker.build(*maker.check(lines, whole))
except store.StoreError as e:
why = e.detail if isinstance(e.detail, dict) else {"까닭": str(e.detail)}
left = why.get("남은줄") or []
detail = {"까닭": f"하위 제{kid}호표 실패 — {why.get('까닭')}", "하위": kid}
raise store.StoreError(409, {**detail, "하위남은줄": left}) from e
done[hb.mark(key, kid)] = got["참조"]["키"]
made.append({"번": kid, "키": got["참조"]["키"]})
return made
+1 -1
View File
@@ -256,7 +256,7 @@ def match_line(line: dict[str, Any], ctx: Ctx, full: bool = True) -> dict[str, A
target = {"호표": no, "이름": h.get("이름", ""), "단위": h.get("단위"), "입력값": {}}
target.update(못읽음=[], 출처={}, 환산=conv, 몫=None, 배=conv["배"])
ok = bool(made) and conv["배"] is not None and conv["갈래"] != "추정"
text = f"확인된 일위대가 {made}" if made else f"제{no}호표 먼저 확인"
text = f"하위 일위대가 {made} 사본" if made else f"제{no}호표 — 저장 때 하위로 함께 만듦"
return {**out, "상태": "호표", "체크": ok, "대상": [target], "글": text}
if kind == "노임":
return {**out, "상태": "노임", "글": "노임 · 그릇 없음"}
+1 -1
View File
@@ -29,7 +29,7 @@ WHY = {
"별도계상": "자재 후보 여럿 · 없음",
"노임": "같은 이름 노임 없음",
"기계": "같은 기계(EQ) 없음 · 여럿",
"호표": "하위 호표 확인 전 · 단위 다름",
"호표": "하위 호표 단위 다름 · 같은 하위 둘째",
"%": "밑수가 다른 줄 금액 합 아님",
}
+10 -25
View File
@@ -16,8 +16,6 @@
from __future__ import annotations
import importlib
import inspect
from pathlib import Path
from typing import Any, Literal
@@ -27,11 +25,13 @@ from pydantic import BaseModel, ConfigDict, Field
from common_util.common_util_auth import verify_session
from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build
from M02_MasterTemplete import M02_Hopyo_Excel as hopyo_excel
from M02_MasterTemplete import M02_Hopyo_Fit as hopyo_fit
from M02_MasterTemplete import M02_Hopyo_Forget as hopyo_forget
from M02_MasterTemplete import M02_Hopyo_Grid as hopyo_grid
from M02_MasterTemplete import M02_Hopyo_Item as hopyo_item
from M02_MasterTemplete import M02_Hopyo_Ledger as hopyo_ledger
from M02_MasterTemplete import M02_Hopyo_Match as hopyo_match
from M02_MasterTemplete import M02_Hopyo_Ours as hopyo_ours
from M02_MasterTemplete import M02_Hopyo_Paste as hopyo_paste
from M02_MasterTemplete import M02_Hopyo_Relink as hopyo_relink
from M02_MasterTemplete import M02_Hopyo_Sangeun as hopyo_sangeun
@@ -211,17 +211,7 @@ def new_material(
HOPYO_MAX_BYTES = 20 * 1024 * 1024
def _later(module: str, name: str):
"""맞춤 엔진(가 창) 함수 — 아직 없으면 None(올리기 · 시동은 그대로)."""
try:
got = importlib.import_module(f"M02_MasterTemplete.{module}")
except ImportError:
return None
return getattr(got, name, None)
if (_warm := _later("M02_Hopyo_Ours", "warm")) is not None:
_warm() # 시동 — 우리 지문 캐시(디스크) 읽어 둠
hopyo_ours.warm() # 시동 — 우리 지문 캐시(디스크) 읽어 둠
def _who(session: Session) -> dict[str, Any]:
@@ -242,8 +232,7 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]:
except Exception: # 격자는 보기용 덤 — 못 만들어도 올리기는 됨(보기 창 404)
grid = None
row = hopyo_store.save(store.root(), name, parsed, who, grid, mark)
if (ensure := _later("M02_Hopyo_Fit", "ensure")) is not None:
ensure(store.root(), row["키"]) # 맞춤 뒤 셈(일감 줄 · 화면 안 기다림)
hopyo_fit.ensure(store.root(), row["키"]) # 맞춤 뒤 셈(일감 줄 · 화면 안 기다림)
return row
@@ -254,9 +243,8 @@ def hopyo_list(
"""실무자료 목록 `{줄: [{키, 파일, 올린때, 올린이, 시트, 호표수, 어긋남}]}`."""
folder = _root(session, layer, owner)
rows = hopyo_store.rows(folder)
if (ensure := _later("M02_Hopyo_Fit", "ensure")) is not None:
for row in rows: # 지금 판 맞춤셈 없는 파일 = 목록 열 때 뒤 셈 예약(358-3 ③)
ensure(folder, row["키"])
for row in rows: # 지금 판 맞춤셈 없는 파일 = 목록 열 때 뒤 셈 예약(358-3 ③)
hopyo_fit.ensure(folder, row["키"])
return {"줄": rows}
@@ -328,11 +316,9 @@ def hopyo_one(
session: Session = Depends(verify_session),
) -> dict:
"""호표 하나 — 줄마다 맞춤(상태 · 체크 제안 · 대상 · 후보 5) · `to` = 만들 층(없으면 파일 층 ·
호표 참조 줄 체크 · 만든 · 맞춤 엔진이 `to` 를 받을 때만)."""
호표 참조 줄 체크 · 만든)."""
folder = _root(session, layer, owner)
if to is None or "to" not in inspect.signature(hopyo_match.view).parameters:
return _call(folder, hopyo_match.view, folder, key, no)
target = _root(session, to, to_owner)
target = _root(session, to, to_owner) if to is not None else None
return _call(folder, lambda: hopyo_match.view(folder, key, no, to=target))
@@ -395,13 +381,12 @@ class BuildWhole(_Strict):
class BuildBody(_Strict):
"""실무 호표 → 이 층 사전조합 — 파일 층 `층` · `주인` · `키` + 호표 `번` + 확인한 `줄` ·
직접 줄 호표 = `통째` · 같은 본뜬은 늘 덮음(`새로` = 옛 화면 호환 · 안 씀)."""
직접 줄 호표 = `통째` · 같은 본뜬은 늘 덮음."""
층: Layer
주인: str | None = None
키: str
번: int
새로: bool = False
줄: list[BuildLine] = Field(default_factory=list, max_length=500)
통째: BuildWhole | None = None
@@ -414,7 +399,7 @@ def hopyo_make(
session: Session = Depends(verify_session),
) -> dict:
"""[확인하고 사전조합 만들기] — 확인한 줄만(이 층 쓰기 · 파일 층 읽기) · 별칭 장부 씀 →
`{참조, 이름, 다시씀, 빠진줄, 비교, 장부}` · 몸 없음 400 · 하위 호표 확인 전 409."""
`{참조, 이름, 다시씀, 재사용, 빠진줄, 하위, 비교, 장부}` · 하위 호표 확인 전 = 함께 만듦(실패 409)."""
folder = _root(session, layer, owner, write=True)
source = _root(session, body.층, body.주인)
lines = [one.model_dump(exclude_none=True) for one in body.줄]
@@ -29,7 +29,6 @@ import { openModal } from "@ui/ui_template_modal";
import { t as L } from "@ui/ui_template_locale";
import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source";
import {
buildHopyo,
guessHopyo,
readHopyoOne,
type BuildLine,
@@ -41,6 +40,7 @@ import {
type HopyoPart,
type HopyoScope,
} from "./M02_MasterTemplete_Hopyo_Api";
import { buildWithKids as buildHopyo } from "./M02_MasterTemplete_Hopyo_Kids"; // 777-1 하위 함께
import { builtWord, openHopyoChoose, targetText } from "./M02_MasterTemplete_Hopyo_Choose";
import {
checkTables,
@@ -0,0 +1,130 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Kids.ts
* 일위대가 실무자료 만들기 몸 · 하위 호표 함께(PLAN 777-1 · 요구 621-4) — 호표가 다른 호표를 부르면 일위대가 둘
* (하위 일위대가 따로 + 부모 줄 = 하위 사본).
* · bodyFor · wholeFor = 확인 창 캐시 위 체크 제안(짝 = 서버 `M02_Hopyo_Kids.proposal` · 캐시 빈 것)
* · unmadeKids = 고른 호표 줄이 부르는 확인 안 된 하위 호표(깊이 끝까지 · 하위 먼저) — [저장]이 그 캐시대로 함께 만듦
* · buildWithKids = 확인 창 [생성] — 확인 안 된 하위를 그 캐시대로 먼저 → 부모(실패 = 「하위 제N호표 실패」)
* · 캐시 없는 하위도 서버가 부모 저장 때 제안대로 함께 만듦(`M02_Hopyo_Kids.first`) — 화면은 캐시 고름을 살림
* · kidsText = 결과 글 「· 하위 제4호표 SC-11(사본 담음)」 · leftAt = 하위 실패의 남은 줄 = 그 하위 [고르기]
* ========================================================================== */
import { t as L } from "@ui/ui_template_locale";
import { StaleError } from "./M02_MasterTemplete_Api_Fetch";
import {
buildHopyo,
readHopyoOne,
type HopyoBuilt,
type HopyoOne,
type HopyoScope,
} from "./M02_MasterTemplete_Hopyo_Api";
import { bodyOf, draftKey, loadDraft, partsOf, toTarget } from "./M02_MasterTemplete_Hopyo_Check";
type Body = Parameters<typeof buildHopyo>[2];
type Whole = Parameters<typeof buildHopyo>[3];
type From = Parameters<typeof buildHopyo>[1];
/** 호표 하나의 몸 — 확인 창 캐시 위 체크 제안 · 통째면 줄 없음 */
export function bodyFor(scope: HopyoScope, key: string, one: HopyoOne): Body {
const h = one.호표;
const d = loadDraft(draftKey(scope, key, h.번));
return h.통째 && (d.통째 ?? h.통째.체크) ? [] : bodyOf(h.줄, d);
}
export function wholeFor(scope: HopyoScope, key: string, one: HopyoOne): Whole {
const h = one.호표;
const d = loadDraft(draftKey(scope, key, h.번));
return h.통째 && (d.통째 ?? h.통째.체크) ? { 대상: h.통째.대상.map(toTarget) } : null;
}
/** (줄, 통째) 몸 한 벌 */
export const bodyPair = (scope: HopyoScope, key: string, one: HopyoOne): [Body, Whole] => [
bodyFor(scope, key, one),
wholeFor(scope, key, one),
];
/** 호표 줄이 부르는 호표 번(체크 안 한 호표 줄 · 캐시에서 고른 호표 대상까지) */
const kidNos = (scope: HopyoScope, key: string, one: HopyoOne): number[] => {
const d = loadDraft(draftKey(scope, key, one.호표.번));
return one.호표.줄.flatMap((l) =>
partsOf(l, d).flatMap((p) => (p.호표 !== undefined ? [p.호표] : [])),
);
};
/** [저장] 할 호표 — 함께 만드는 하위 · 체크 줄(통째) 있음 · 하위를 부름(호표 줄 = 하위 사본) */
export function readyFor(scope: HopyoScope, key: string, one: HopyoOne, picked: number[]): boolean {
const [lines, whole] = bodyPair(scope, key, one);
return (
!picked.includes(one.호표.번) || !!lines.length || !!whole || !!kidNos(scope, key, one).length
);
}
/** 확인 안 된 하위 호표 보기 — 하위 먼저 · `ones` 와 이미 만든 하위(그 아래도) 뺌 */
export async function unmadeKids(
scope: HopyoScope,
key: string,
target: HopyoScope,
ones: HopyoOne[],
): Promise<HopyoOne[]> {
const out: HopyoOne[] = [];
const seen = new Set(ones.map((one) => one.호표.번));
async function visit(one: HopyoOne): Promise<void> {
for (const no of kidNos(scope, key, one)) {
if (seen.has(no)) continue;
seen.add(no);
const kid = await readHopyoOne(scope, key, no, target);
if (kid.호표.만든) continue;
await visit(kid);
out.push(kid);
}
}
for (const one of ones) await visit(one);
return out;
}
/** 하위 실패 — 까닭 「하위 제N호표 실패 — …」 · 남은 줄은 그 하위 몫(`leftAt`) */
function kidFail(error: unknown, no: number): StaleError {
const left = (error as { detail?: { 남은줄?: unknown } } | null)?.detail?.남은줄;
const why = error instanceof Error ? error.message : String(error);
const text = `${L("M02_HopyoKidFail").replace("{n}", String(no))} — ${why}`;
return new StaleError(text, { 까닭: text, 하위: no, 하위남은줄: left ?? [] });
}
/** 확인 창 [생성] — 확인 안 된 하위를 그 캐시대로 먼저 만들고 부모 */
export async function buildWithKids(
target: HopyoScope,
from: From,
줄: Body,
통째?: Whole,
): Promise<HopyoBuilt> {
const scope = { layer: from.층, owner: from.주인 };
const me = await readHopyoOne(scope, from.키, from.번, target);
for (const kid of await unmadeKids(scope, from.키, target, [me])) {
const no = kid.호표.번;
const body = [bodyFor(scope, from.키, kid), wholeFor(scope, from.키, kid)] as const;
await buildHopyo(target, { ...from, 번: no }, ...body).catch((error: unknown) => {
throw kidFail(error, no);
});
}
return buildHopyo(target, from, 줄, 통째);
}
/** 하위 실패면 (그 하위 남은 줄 몸, 하위 번) · 아니면 그대로 — 결과 표 [고르기]가 그 하위를 엶 */
export function leftAt(error: unknown, no: number): [unknown, number] {
const d = (error as { detail?: { 하위?: number; 하위남은줄?: unknown } } | null)?.detail;
return d?.하위 !== undefined ? [{ detail: { 남은줄: d.하위남은줄 } }, d.하위] : [error, no];
}
/** 결과 글 꼬리 — 같은 내용 재사용 · 하위 일위대가(사본 담음 · 함께 만듦) */
export function kidsText(got: HopyoBuilt): string {
const more = got as HopyoBuilt & {
재사용?: boolean;
하위?: { 번: number; 키: string | null; 새로?: boolean }[];
};
const kids = (more.하위 ?? []).map((k) => {
const head = L("M02_HopyoKid")
.replace("{n}", String(k.번))
.replace("{key}", k.키 ?? "");
return ` · ${head}${k.새로 ? ` · ${L("M02_HopyoKidNew")}` : ""}`;
});
return (more.재사용 ? ` · ${L("M02_HopyoReused")}` : "") + kids.join("");
}
@@ -6,8 +6,8 @@
* 오른쪽 = 호표 목록(체크 · 제N호표 [수정] · 찾기 · 제안 n/m — 다 연결이면 녹색 · 뒤 셈 중이면 3 초마다 다시) + [저장].
* · 목록 누름 → 시트에서 그 호표 영역(B열 ~ 끝 열) 고름 · 칸 누름 → 그 행 호표를 목록에서 강조 · 영역 고름
* · [수정] = 그 호표 확인 창(`openHopyoCheck` · 줄 누름과 따로)
* · [저장] = 체크한 호표마다 체크 줄(확인 창 캐시 · 없으면 서버 제안)로 하위 호표부터 차례로 `/hopyo-build` —
* 물음 한 번(확인 창 안 연 호표 수) · 체크 줄 없는 호표 건너뜀 · 하위 확인 전(409)은 안내 ·
* · [저장] = 체크한 호표 + 확인 안 된 하위 호표(777-1 · `Hopyo_Kids`)마다 체크 줄(확인 창 캐시 · 없으면 서버 제안)로
* 하위 호표부터 차례로 `/hopyo-build` — 물음 한 번 · 체크 줄 없는 고른 호표 건너뜀 · 하위 실패 = 그 하위 [고르기] ·
* 결과 표(사전조합 키 · 빠진 줄 · 값 차이) · `onImport` 가 있으면 호표마다 알림([로직 추가] 참조 줄)
* · [모두 고정으로 들여오기](705-3) = 물음 한 번 → 서버 `/hopyo-build-all`(호표 모두 · 하위 먼저 · 엑셀 숫자 그대로 ·
* 이미 만든 호표 그대로) → 알림 「호표 n · 줄 m · 품셈 줄 a · 고정 줄 b · 빠짐 0 · 엑셀 합계 = 우리 합계 n/n」 + 다음 걸음
@@ -27,7 +27,6 @@ import { openModal } from "@ui/ui_template_modal";
import { t as L } from "@ui/ui_template_locale";
import type { Sheet } from "../A00_Common/spreadsheet/spreadsheet_types";
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
import { StaleError } from "./M02_MasterTemplete_Api_Fetch";
import {
buildHopyo,
buildHopyoAll,
@@ -45,7 +44,6 @@ import {
type HopyoView,
} from "./M02_MasterTemplete_Hopyo_Api";
import {
bodyOf,
draftKey,
hasDraft,
HOPYO_BUILT,
@@ -53,8 +51,8 @@ import {
openHopyoCheck,
partsOf,
POLL_MS,
toTarget,
} from "./M02_MasterTemplete_Hopyo_Check";
import { bodyPair, kidsText, leftAt, readyFor, unmadeKids } from "./M02_MasterTemplete_Hopyo_Kids";
import { builtWord } from "./M02_MasterTemplete_Hopyo_Choose";
import { openRelink, relinkKeys } from "./M02_MasterTemplete_Hopyo_Relink";
import { leftButton } from "./M02_MasterTemplete_Hopyo_Left";
@@ -83,7 +81,7 @@ export interface HopyoSheetOptions {
}
const ILWI = "일위대가";
const DANSAN = "단가산출";
const DANSAN = ["단가산출", "단가산출근거"]; // 형식별 블록 시트(EST · STmate)
const why = (error: unknown): string => (error instanceof Error ? error.message : String(error));
const won = (v: number | null | undefined): string =>
@@ -392,7 +390,8 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
poll(file);
if (grid instanceof Error) return void left.replaceChildren(note(grid.message));
await mountSheet(grid);
if (opt.단산행 !== undefined) sheet?.select(rowsText(opt.단산행), DANSAN);
const rows = opt.단산행 === undefined ? null : rowsText(opt.단산행);
if (rows) DANSAN.some((name) => sheet?.select(rows, name));
else if (current !== null) focus(current);
} catch (error) {
if (!closed) {
@@ -413,17 +412,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
],
});
/** 호표 하나의 몸 — 확인 창 캐시 위 체크 제안 · 통째면 줄 없음 */
function bodyFor(one: HopyoOne): Parameters<typeof buildHopyo>[2] {
const h = one.호표;
const d = loadDraft(draftKey(opt.scope, opt.key, h.번));
return h.통째 && (d.통째 ?? h.통째.체크) ? [] : bodyOf(h.줄, d);
}
function wholeFor(one: HopyoOne): Parameters<typeof buildHopyo>[3] {
const h = one.호표;
const d = loadDraft(draftKey(opt.scope, opt.key, h.번));
return h.통째 && (d.통째 ?? h.통째.체크) ? { 대상: h.통째.대상.map(toTarget) } : null;
}
const body = (one: HopyoOne) => bodyPair(opt.scope, opt.key, one);
const headCell = (no: number, name: string): HTMLElement =>
el("td", { text: `제${no}호표 ${name}`.trim() });
@@ -448,11 +437,12 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
skipped.push(el("tr", { children: [headCell(no, ""), el("td", { text: why(error) })] }));
}
}
ones.push(...(await unmadeKids(opt.scope, opt.key, target, ones))); // 777-1 하위 함께
} finally {
hideLoadingOverlay();
paintGo();
}
const ready = ones.filter((one) => bodyFor(one).length || wholeFor(one));
const ready = ones.filter((one) => readyFor(opt.scope, opt.key, one, nos)); // 하위 함께
for (const one of ones)
if (!ready.includes(one))
skipped.push(
@@ -488,7 +478,7 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
const cell = headCell(no, h.이름);
try {
const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: no };
const got = await buildHopyo(target, from, bodyFor(one), wholeFor(one));
const got = await buildHopyo(target, from, ...body(one));
ok += 1;
opt.onImport?.(got, h);
rows.push(
@@ -496,18 +486,16 @@ export function openHopyoSheet(opt: HopyoSheetOptions): void {
attrs: { "data-built": got.참조.키 },
children: [
cell,
el("td", { text: `${got.참조.키} · ${builtWord(got)}` }),
el("td", { text: `${got.참조.키} · ${builtWord(got)}${kidsText(got)}` }),
el("td", { text: String(got.빠진줄.length), className: "m02-hopyo__num" }),
el("td", { text: won(got.비교.차이), className: "m02-hopyo__num" }),
],
}),
);
} catch (error) {
const left = leftButton(error, { scope: opt.scope, target, key: opt.key, 파일: opt.파일, 번: no }); // prettier-ignore
const text =
left || !(error instanceof StaleError)
? `실패 — ${why(error)}`
: `안내 — ${why(error)} · 하위 호표를 함께 고르거나 먼저 저장`;
const [err, kid] = leftAt(error, no); // 하위 실패 = 그 하위 남은 줄
const left = leftButton(err, { scope: opt.scope, target, key: opt.key, 파일: opt.파일, 번: kid }); // prettier-ignore
const text = `실패 — ${why(error)}`;
rows.push(el("tr", { children: [cell, el("td", { text }), ...(left ? [el("td", { children: [left] })] : [])] })); // prettier-ignore
}
}
+5 -1
View File
@@ -28,7 +28,11 @@ def test_이어서_끝내기_지움_겹침_막음(client):
assert _keys(client) == ["HP-000001"]
made = client.post(ALL, json={"층": "personal", "키": "HP-000001"}).json()
n = len(made["만듦"]) # 729-2 — 남은 줄 있는 호표 = 실패(남은줄)
assert n and n + len(made["실패"]) == 68 and all(x["남은줄"] for x in made["실패"])
assert (
n
and n + len(made["실패"]) == 68
and all(x.get("남은줄") or x.get("하위") for x in made["실패"])
)
hs.remember(folder, {"장부 줄": {"갈래": "안 씀"}}) # 별칭 장부 = 끝내기 뒤 남음
cache = fit.cache_path(folder, "HP-000001")
cache.write_text("{}", encoding="utf-8")
+191
View File
@@ -0,0 +1,191 @@
"""777-1 호표가 다른 호표를 부를 때 = 일위대가 둘(요구 621-4 · `M02_Hopyo_Kids`).
- 하위 확인 전 부모 저장 = 409 없이 하위 · 부모 둘(하위 = 서버 체크 제안대로 · 부모 줄 = 하위 사본 · 답 `하위`)
- 하위가 남은 줄로 못 만들면 = 409 「하위 제N호표 실패」 · `하위` · `하위남은줄`(부모 · 하위 안 씀)
- 같은 엑셀 두 번([끝내기] 뒤 다시 올림) = 같은 내용 SC 재사용(`본뜬.또`) · SC 안 늚 · 끝내면 그 몫만 뺌
- 깊이 3 사슬 = 손자 → 자식 → 부모 차례로 · 부모 안 사본 안에 사본
- 서버 체크 제안 몸(`proposal`) = 화면 `bodyFor` 꼴(체크 줄만 · % = 밑수 고른 것만 · 통째)
층은 임시 폴더(공용 storage 안 씀).
"""
import json
import pytest
from M02_MasterTemplete import M02_Hopyo_Build as hb
from M02_MasterTemplete import M02_Hopyo_Fit as fit
from M02_MasterTemplete import M02_Hopyo_Forget as forget
from M02_MasterTemplete import M02_Hopyo_Kids as hk
from M02_MasterTemplete import M02_Hopyo_Store as hs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_Template_Layers as layers
# 올리기 길 시험의 `client` 마당을 같이 씀
# ruff: noqa: F811
from resources.tester.test_705_3_hopyo_fixed import DOCS, FILES
from resources.tester.test_m02_hopyo_excel import _post, client, master_hash # noqa: F401
BUILD = "/api/m02/layers/personal/hopyo-build"
LEFT = "/api/m02/layers/personal/hopyo-unlinked"
GEO = DOCS / FILES["거창"]
def _mine():
return layers.personal_dir(7, 42)
def _scs():
return sorted(p.stem for p in (_mine() / "precombo").glob("SC-*.json"))
def _doc(key):
return json.loads((_mine() / "precombo" / f"{key}.json").read_bytes())
def _decided(client, body):
left = client.post(LEFT, json=body).json()["남은줄"]
return {**body, "줄": [*body["줄"], *({"차례": x["차례"], "뺌": "우리에 없음"} for x in left)]}
def _geo(client, key="HP-000001"):
assert _post(client, data=GEO.read_bytes(), name=GEO.name).status_code == 200
fit.run_job(_mine(), key) # 숫자 판정 · 단가산출 맞춤셈(서버 체크 제안이 씀)
def test_하위_확인_전_부모_저장_둘_생김(client):
before = master_hash()
_geo(client)
body = _decided(client, {"층": "personal", "키": "HP-000001", "번": 3, "줄": []})
res = client.post(BUILD, json=body)
assert res.status_code == 200, res.text # 409 「제4호표 먼저 확인」 없음
got = res.json()
kid = hb.confirmed(_mine())["HP-000001 제4호표"]
assert got["하위"] == [{"번": 4, "키": kid, "새로": True}]
assert hb.confirmed(_mine())["HP-000001 제3호표"] == got["참조"]["키"] != kid
copy = next(x for x in _doc(got["참조"]["키"])["담은로직"] if "참조" in x)
assert copy["참조"]["층"] == "사본" and copy["참조"]["키"] == kid # 부모 줄 = 하위 사본(740-1)
again = client.post(BUILD, json=body).json() # 다시 저장 = 하위 그대로 · 부모 덮음
assert again["하위"] == [{"번": 4, "키": kid, "새로": False}] and again["다시씀"] is True
assert master_hash() == before
def test_하위_실패_까닭_따로_아무것도_안_씀(client):
assert _post(client).status_code == 200 # 북부청 임도 제45호표 → 제46호표(남은 줄 셋)
body = {"층": "personal", "키": "HP-000001", "번": 45, "줄": []}
res = client.post(BUILD, json=body)
assert res.status_code == 409, res.text
why = res.json()["detail"]
assert why["까닭"].startswith("하위 제46호표 실패 — ") and why["하위"] == 46
assert why["하위남은줄"] and "남은줄" not in why # 부모 [고르기]로 잘못 안 엶 — 하위 몫
assert hb.confirmed(_mine()) == {}
left = client.post(LEFT, json=body).json()["남은줄"] # 미리 보기 = 하위 호표 줄은 함께 만듦
assert all(x["갈래"] != "호표" for x in left)
def test_같은_엑셀_두_번_SC_안_늚(client):
assert _post(client).status_code == 200
folder = _mine()
with store.at(folder):
first = hb.build_all(folder, folder, "HP-000001")
made = _scs()
assert first["실패"] == [] and len(made) <= first["호표"] + 3
keys = {x["번"]: x["키"] for x in first["만듦"]}
forget.finish(folder, "HP-000001")
assert hb.confirmed(folder) == {} # 끝낸 뒤 = 출처만(키 뺌)
up = _post(client).json()
assert up["키"] == "HP-000002" and not up.get("이어서")
with store.at(folder):
second = hb.build_all(folder, folder, "HP-000002")
assert _scs() == made # 같은 내용 = 재사용 · 안 늚
assert {x["번"]: x["키"] for x in second["만듦"]} == keys
assert (second["같음"], second["다름"]) == (first["같음"], first["다름"])
shared = [k for k in made if any(x["키"].startswith("HP-000002 ") for x in _doc(k)["본뜬"].get("또", []))] # fmt: skip
assert shared and set(hb.confirmed(folder)) == {hb.mark("HP-000002", n) for n in keys}
forget.finish(folder, "HP-000002") # 그 몫만 뺌
assert _scs() == made and not any(_doc(k)["본뜬"].get("또") for k in made)
def test_깊이_3_사슬(client, monkeypatch):
_geo(client)
folder = _mine()
units = {int(h["번"]): h for h in hs.read(folder, "HP-000001")["호표"]}
deep = {
no: hk.order(units, no)
for no in units
if any(hk.kids_of(units[k]) for k in hk.kids_of(units[no]) if k in units)
}
top = min(deep)
plain = hk.proposal
def decided(hopyo): # 하위 남은 줄 = 「우리에 없음」(화면 [고르기] 몫을 시험이 대신)
lines, _ = plain({**hopyo, "통째": None}) # 줄로(통째면 하위 줄이 빠짐 — 사슬을 보려고)
body = {"층": "personal", "키": "HP-000001", "번": hopyo["번"], "줄": lines}
return _decided(client, body)["줄"], None
monkeypatch.setattr(hk, "proposal", decided)
body = _decided(client, {"층": "personal", "키": "HP-000001", "번": top, "줄": []})
res = client.post(BUILD, json=body)
assert res.status_code == 200, res.text
done = hb.confirmed(folder)
assert all(hb.mark("HP-000001", k) in done for k in deep[top]) # 손자까지 다 만듦
new = [x["번"] for x in res.json()["하위"] if x["새로"]]
assert new and set(new) <= set(deep[top])
def depth(doc):
inner = [
x["참조"]["몸"] for x in doc.get("담은로직") or [] if (x.get("참조") or {}).get("몸")
]
return 1 + max((depth(x) for x in inner), default=0)
assert depth(_doc(done[hb.mark("HP-000001", top)])) == 3 # 부모 안 사본 안에 사본
@pytest.mark.parametrize(
("view", "want"),
[
(
{
"줄": [
{
"차례": 0,
"맞춤": {
"체크": True,
"상태": "로직",
"대상": [{"로직": "GF1", "입력값": {"a": 1}, "배": 2, "몫": 0.5}],
},
}, # fmt: skip
{
"차례": 1,
"맞춤": {"체크": False, "상태": "로직", "대상": [{"로직": "GF2", "배": 1}]},
}, # fmt: skip
{"차례": 2, "맞춤": {"체크": True, "상태": "%", "대상": [{"비율": None}]}},
{
"차례": 3,
"맞춤": {"체크": True, "상태": "호표", "대상": [{"호표": 4, "배": None}]},
}, # fmt: skip
{
"차례": 4,
"맞춤": {"체크": True, "상태": "자재", "대상": [{"자재": "MT1", "배": 0}]},
}, # fmt: skip
{
"차례": 5,
"맞춤": {"체크": True, "상태": "자재", "대상": [{"전국": True, "배": 3}]},
}, # fmt: skip
]
},
(
[
{"차례": 0, "대상": [{"로직": "GF1", "입력값": {"a": 1}, "배": 2, "몫": 0.5}]},
{"차례": 5, "대상": [{"전국": True, "배": 3}]},
],
None,
),
),
(
{"줄": [], "통째": {"체크": True, "대상": [{"로직": "GF1", "배": 1}]}},
([], {"대상": [{"로직": "GF1", "입력값": {}, "배": 1}]}),
),
],
)
def test_서버_체크_제안_몸_화면_짝(view, want):
assert hk.proposal(view) == want
+5 -2
View File
@@ -1,6 +1,6 @@
"""일위대가 실무자료 → 사전조합 만들기(PLAN 324-3 · 338-3 · `M02_Hopyo_Build`) — 확인한 줄만 ·
몸 없음 400 · 빠진 줄 까닭 · 로직 여럿 같은 별칭 · 같은 로직 감쌈 · 배 · 몫 · 자재 · 통째 ·
재검사 400(없는 로직 · 모르는 입력 · 배 ≤ 0 · 금액 칸) · 하위 호표 409 → 확인 뒤 참조 · 장부 씀 ·
재검사 400(없는 로직 · 모르는 입력 · 배 ≤ 0 · 금액 칸) · 하위 실패 409(777-1) → 확인 뒤 참조 · 장부 씀 ·
`본뜬.확인` · 덮음 · 옛 즉시 확정 길 404 · 장부 길(보기 · 고치기 · 권한) · picks 확인 ·
master_data 원본 그대로. 층은 임시 폴더.
"""
@@ -244,7 +244,10 @@ def test_몸_없음_재검사_400(client):
def test_하위_호표_먼저_확인_퍼센트_빠짐_통째(client):
assert _post(client).status_code == 200
ref = [{"차례": 0, "대상": [{"호표": 46, "배": 1}]}]
assert "제46호표 먼저 확인" in _build(client, 45, ref, want=409)["detail"]
why = _build(client, 45, ref, want=409)[
"detail"
] # 777-1 — 하위 먼저 함께(제안대로) · 남은 줄로 실패
assert why["까닭"].startswith("하위 제46호표 실패") and why["하위"] == 46 and why["하위남은줄"]
child = _build(client, 46, [{"차례": 0, "뺌": "안 씀"}])
assert [x["까닭"] for x in child["빠진줄"]] == ["뺌 — 안 씀"] # % 줄 = 비율 고정 줄(705-3)
rate = _doc(layers.personal_dir(7, 42), child["참조"]["키"])["담은로직"][-1]["고정"]
+5 -1
View File
@@ -140,7 +140,11 @@ def test_새_상태_체크_제안(client):
assert dig["대상"][0]["로직"] == "GC000083" and len(dig["후보"]) == 5
assert "특별인부 엑셀 0.3542 · 우리 0.3704" in dig["꼬리표"]
ref = _hopyo(client, 45)["고화매트재료비"]
assert ref["상태"] == "호표" and ref["체크"] is False and ref["글"] == "제46호표 먼저 확인"
assert (
ref["상태"] == "호표"
and ref["체크"] is False
and ref["글"] == "제46호표 — 저장 때 하위로 함께 만듦"
) # 777-1
assert ref["대상"][0]["호표"] == 46 and ref["대상"][0]["단위"] == "㎡"
pct = _hopyo(client, 46)["잡재료비"]
assert pct["상태"] == "%" and pct["체크"] is False
+3 -1
View File
@@ -319,7 +319,9 @@ def test_옛_즉시_확정과_바로_만들기_없음():
save = sheet.split("async function save(")[1].split("async function makeAll(")[0]
assert 'showConfirmDialog(ask, "저장")' in save and "hasDraft(" in save
assert "건너뜀 — 체크 줄 없음" in save and "await makeAll(ready, skipped);" in save
assert "buildOrder(" in sheet and "error instanceof StaleError" in sheet # 하위 먼저 · 409 안내
assert (
"buildOrder(" in sheet and "unmadeKids(" in sheet and "leftAt(" in sheet
) # 하위 먼저 · 함께(777-1)
edit = sheet.split("const editButton =")[1].split("function paintList(")[0]
assert '"수정"' in edit and "e.stopPropagation();" in edit and "openHopyoCheck({" in edit
row = sheet.split("function paintList(")[1]
+4
View File
@@ -297,6 +297,10 @@ export const ui_locales_m2 = {
M02_LeftMakeSk: ["SK 만들기", "Make SK"],
M02_LeftSkNone: ["만든 단가산출 없음 — 고름 대기 그대로", "Nothing made — still waiting"],
M02_LeftChoose: ["고르기", "Pick"],
M02_HopyoKid: ["하위 제{n}호표 {key}(사본 담음)", "Sub sheet {n} {key} (copy inside)"],
M02_HopyoKidNew: ["함께 만듦", "made together"],
M02_HopyoKidFail: ["하위 제{n}호표 실패", "Sub sheet {n} failed"],
M02_HopyoReused: ["같은 내용 일위대가 다시 씀", "Same unit-price reused"],
M02_LeftFind: ["찾기", "Find"],
M02_LeftMachine: ["기계", "Machine"],
M02_LeftNoneTip: [