diff --git a/B09_Estimation/B09_Estimation_Labor_Source.py b/B09_Estimation/B09_Estimation_Labor_Source.py index 389544545..ffec0daf9 100644 --- a/B09_Estimation/B09_Estimation_Labor_Source.py +++ b/B09_Estimation/B09_Estimation_Labor_Source.py @@ -308,6 +308,16 @@ class Builder: bid = self.precombo(whole, ref, doc, copies, depth + 1, use) return {"kind": "daega", "key": bid, "name": doc.get("이름") or name, "unit": doc.get("단위") or "", "qty": each} # fmt: skip + fz = one.get("굳힌") + if isinstance(fz, dict) and fz.get("키"): # 덮개 줄(740-1) = 굳힌 줄 단가산출 대가 + bid = self.sangeun( + whole, {"층": "project", "키": fz["키"]}, _sk_of(one), copies, depth + 1 + ) + if bid is None: + return {"kind": "blank", "name": name, "qty": each, "why": "덮개 줄을 못 셈"} + block = self.blocks[bid] # 블록 = 결과단위(배) 몫 · 수량 = 수량 ÷ 배 + return {"kind": "daega", "key": bid, "name": block["name"], "unit": block["unit"], + "qty": each} # fmt: skip code = str(one.get("로직") or "") if one.get("승격") is not True: # 단가산출 줄 = 산근 if not sg.is_money(whole, code): @@ -453,6 +463,36 @@ class Builder: return self.precombo(whole, ref, doc, copies, 1) +def _sk_of(one: dict) -> dict[str, Any]: + """굳힌 덮개 줄(740-1) → 굳힌 SK 꼴(`Builder.sangeun` 이 읽음) — 고름 · 자재표시 = 굳힌 줄 cid · + `원본.덮개` = 그 줄 패치 덮개(원값 = M02 에서 고친 줄의 품셈 값).""" + from M02_MasterTemplete import M02_Item_Sangeun as item_sk + from M02_MasterTemplete import M02_Sangeun_Doc as sd + + fz = one["굳힌"] + ids = sd.line_ids(fz["줄"]) + keyed = { + k: {ids[int(n)]: v for n, v in (fz.get(k) or {}).items() if int(n) < len(ids)} + for k in ("고름", "자재표시") + } + return { + "종류": "단가산출", + "키": fz["키"], + "이름": one.get("별칭") or fz["줄"].get("이름") or "", + "단위": "", + "바탕": {"로직": fz["키"]}, + "입력값": fz.get("입력값") or {}, + **({"켜쌓기율": fz["켜쌓기율"]} if fz.get("켜쌓기율") is not None else {}), + **keyed, + "덮개": {}, + "더한줄": [], + "뺀줄": [], + item_sk.ROW: fz["줄"], + "원본": {"덮개": (one.get("패치") or {}).get("덮개") or {}}, + "정체": list(fz.get("정체") or []), + } + + def _Loose(whole: Any, read: Any) -> Any: """B09 SK 속 펼침(723-4) — 사전조합 안 줄 수량 없음 = 0 값묶음(엔진 차례 · 수량만 씀 · 그 줄 값 = B09 사전조합 블록 · 이 공사에서 입력) · 그 밖 까닭은 그대로.""" diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts index f76aa17f8..4229f4f45 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Ho.ts @@ -449,17 +449,22 @@ function formulaCell(e: Entry, ctx: HoContext, text = ""): HTMLElement { const tagOf = (x: object): string | undefined => (x as { 꼬리표?: string }).꼬리표 || undefined; const sourceTag = (text: string): HTMLElement => { - const tag = createBadge(text, "neutral"); + const [shown, ...tip] = text.split("\n"); // 첫 줄 = 보임 · 나머지 = 풍선(없으면 같은 글) — 740-2 + const tag = createBadge(shown, "neutral"); tag.classList.add("m01lab__src"); - tag.title = text; + tag.title = tip.length ? tip.join(" ") : shown; return tag; }; function nameCell(e: Entry, label: string, tag: string): HTMLElement { return el("div", { children: [ - el("div", { text: label }), - ...(e.source ? [sourceTag(e.source)] : []), + e.source + ? el("div", { + className: "m01lab__namerow", // 이름 옆에 꼬리표 — 줄 높이 안 바뀜(740-2) + children: [el("span", { text: label }), sourceTag(e.source)], + }) + : el("div", { text: label }), ...(e.share && label === e.name ? [createBadge(`${e.share} ${tl("Share")}`, "accent")] : []), ...(e.spec ? [el("div", { className: "ui-text ui-text--muted", text: e.spec })] : []), ...(e.warn ? [createBadge(`${tx("Calc_UnitWarn")} · ${e.warn}`, "warning")] : []), diff --git a/M02_MasterTemplete/M02_Hopyo_Fit.py b/M02_MasterTemplete/M02_Hopyo_Fit.py index b4a3c16a4..a5b58c2cf 100644 --- a/M02_MasterTemplete/M02_Hopyo_Fit.py +++ b/M02_MasterTemplete/M02_Hopyo_Fit.py @@ -377,13 +377,22 @@ def run_job(folder: Path, key: str) -> dict[str, Any]: if unit not in out["블록"]: out["블록"][unit] = compute(block, line, targets) out["셈"] = [at, len(todo)] - if at % EVERY == 0: - atomic_write_json(path, out) + if at % EVERY == 0 and not _put(folder, key, path, out): + return out out["끝"] = True - atomic_write_json(path, out) + _put(folder, key, path, out) return out +def _put(folder: Path, key: str, path: Path, out: dict[str, Any]) -> bool: + """캐시 씀 — 셈 도중 HP 를 끝냈으면(743-1) 안 쓰고 지움 → 거짓.""" + if not _hp_path(folder, key).is_file(): + path.unlink(missing_ok=True) + return False + atomic_write_json(path, out) + return True + + def _work() -> None: while True: folder, key = _JOBS.get() diff --git a/M02_MasterTemplete/M02_Hopyo_Forget.py b/M02_MasterTemplete/M02_Hopyo_Forget.py new file mode 100644 index 000000000..adfca6def --- /dev/null +++ b/M02_MasterTemplete/M02_Hopyo_Forget.py @@ -0,0 +1,205 @@ +"""M02 일위대가 불러오기 끝냄(PLAN 743-1 · 요구 582-3 · 584-2) — 올린 엑셀은 그 불러오기 동안만. + +끝냄 한 길 `finish` = [끝내기] · 화면 떠남 · 하루 지남(`Store.EXPIRE`) · 붙여넣기 만듦 뒤 · +엑셀 보기 [삭제]: + HP · 격자 · 맞춤셈 캐시 지움 · 그 HP 로 만든 이 층 일위대가(SC) · 단가산출(SK) 엑셀 사본 걷음 + (`본뜬` → 출처 `{층 「실무자료」, 파일, 때, 누가}` + 전국자재 · 복사원본 · + SC 줄 메모 「실무 …」 걷음 · `본뜬.실무수량` = 모든 값이 그 줄 저장 `수량.손` 과 같을 때만 + 걷음(742-1 · 다르면 남김 = 금액 그대로)) · SK `본뜬.또` = 그 HP 몫만 뺌 · + 문서 몸(줄 · 덮개 · 더한줄 · 별칭 · 구분)과 별칭 장부는 그대로 · +끝낸 기록 없음(같은 엑셀 다시 올림 됨 · 키 번은 `불러옴.json` 번으로 다시 안 씀). +옛 파일 정리 `tidy`(한 번 · 층 폴더마다 · 미리 보기 = 표만) — 옛 HP(지문 없음) · 하루 지난 HP 끝냄 · + 끝낸 기록 지문 비움 · HP 가 없는 SC · SK 엑셀 사본 걷음. + `python -m M02_MasterTemplete.M02_Hopyo_Forget <층 폴더>… [--apply]`(없으면 미리 보기). +""" + +from __future__ import annotations + +import json +import re +import sys +from collections.abc import Callable +from datetime import datetime +from pathlib import Path +from typing import Any + +from common_util.common_util_json import atomic_write_json +from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store +from M02_MasterTemplete import M02_Labor_Ref as refs + +ORIGIN = "실무자료" +KEEP = ("층", "파일", "전국자재", "복사원본") +_MEMO = re.compile(r"^실무 ") +_HP = re.compile(r"^(HP-\d{6}) ") + + +# ── 사본 걷음(순수) ─────────────────────────────────────────────────── +def _slim(got: dict[str, Any]) -> dict[str, Any]: + """출처만 — 파일 · 때 · 누가(+ 전국자재 · 복사원본).""" + sure = got.get("확인") if isinstance(got.get("확인"), dict) else {} + out = {k: got[k] for k in KEEP if got.get(k) is not None} + return {**out, "층": ORIGIN, "때": sure.get("때"), "누가": sure.get("누가")} + + +def _hand(one: Any) -> Any: + qty = one.get("수량") if isinstance(one, dict) else None + return qty.get("손") if isinstance(qty, dict) else None + + +def _carried(doc: dict[str, Any], hands: dict[str, Any]) -> bool: + """실무수량 값이 모두 그 줄 · 자재 저장 수량과 같음(742-1 저장 뒤).""" + lines = doc.get("담은로직") or [] + mats = {m.get("코드"): m for m in doc.get("자재") or [] if isinstance(m, dict)} + for at, value in (hands.get("줄") or {}).items(): + i = int(at) if str(at).isdigit() else -1 + if not 0 <= i < len(lines) or _hand(lines[i]) != value: + return False + return all(_hand(mats.get(code)) == value for code, value in (hands.get("자재") or {}).items()) + + +def strip_sc(doc: dict[str, Any]) -> dict[str, Any]: + """일위대가(SC) 엑셀 사본 걷음 — 본뜬 출처만 · 줄 메모 「실무 …」 뺌.""" + got = doc.get("본뜬") or {} + lines = [ + {k: v for k, v in one.items() if k != "메모"} + if isinstance(one, dict) and _MEMO.match(str(one.get("메모") or "")) + else one + for one in doc.get("담은로직") or [] + ] + out = {**doc, "담은로직": lines, "본뜬": _slim(got)} + hands = got.get("실무수량") + if isinstance(hands, dict) and hands and not _carried(out, hands): + out["본뜬"]["실무수량"] = hands # 742 전 저장본 — 셈이 기대므로 남김 + return out + + +def strip_sk(doc: dict[str, Any], gone: Callable[[dict], bool]) -> dict[str, Any]: + """단가산출(SK) — 끝낸 출처(`gone(본뜬 · 또 한 칸)`) 몫만 걷음 · 다른 불러오기 몫은 그대로.""" + got = doc.get("본뜬") or {} + also = [x for x in got.get("또") or [] if isinstance(x, dict) and not gone(x)] + own = _slim(got) if gone(got) else {k: v for k, v in got.items() if k != "또"} + return {**doc, "본뜬": {**own, "또": also} if also else own} + + +# ── 끝냄 ───────────────────────────────────────────────────────────── +def _docs(folder: Path, kind: str): + prefix = "SC" if kind == "precombo" else "SK" + for path in sorted((folder / kind).glob(f"{prefix}-*.json")): + try: + doc = json.loads(path.read_bytes()) + except (OSError, ValueError): + continue + got = doc.get("본뜬") if isinstance(doc, dict) else None + if isinstance(got, dict) and got.get("층") == ORIGIN: + yield path, doc + + +def _here(got: dict[str, Any], here: refs.Here) -> bool: + """출처 원본이 이 층(원본 없는 옛 것 = 이 층).""" + return not got.get("원본") or tuple(got["원본"]) == tuple(here) + + +def forget(folder: str | Path, gone: Callable[[str], bool], apply: bool = True) -> list[str]: + """`gone(본뜬 키)` 인 이 층 SC · SK 엑셀 사본 걷음 → 걷은 문서 키(`apply` 거짓 = 셈만).""" + folder, out = Path(folder), [] + here = refs.where(folder) + + def hit(one: dict[str, Any]) -> bool: + mark = str(one.get("키") or "") + return bool(mark) and _here(one, here) and gone(mark) + + for kind in ("precombo", "sangeun"): + for path, doc in _docs(folder, kind): + 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) + if new != doc: + out.append(path.stem) + if apply: + atomic_write_json(path, new) + return out + + +def _caches(folder: Path, key: str) -> list[Path]: + from M02_MasterTemplete import M02_Hopyo_Fit as fit + + try: + got = fit.cache_path(folder, key) + except Exception: # noqa: BLE001 — 캐시 자리 못 찾음 = 지울 것 없음 + return [] + return list(got.parent.glob(f"{'_'.join(got.name.split('_')[:2])}_*.json")) + + +def finish(folder: str | Path, key: str) -> dict[str, Any]: + """불러오기 끝냄 — 만든 문서 사본 걷음 · HP · 격자 · 맞춤셈 캐시 지움 → `{지움, 만든}`.""" + folder = Path(folder) + hopyo_store.read(folder, key) # 없으면 404 + head = f"{key} " + made = forget(folder, lambda mark: mark.startswith(head)) + caches = _caches(folder, key) # HP 지우기 전에 자리 셈(HP 지문) + hopyo_store.delete(folder, key) # 먼저 지움 — 뒤 셈은 HP 없으면 캐시 안 씀(`Fit._put`) + # ponytail: 뒤 셈이 HP 확인 직후 · 쓰기 직전이면 한 벌 남을 수 있음(ms 틈) — tmp 캐시라 둠 + for old in caches: + old.unlink(missing_ok=True) + return {"지움": key, "만든": len(made)} + + +def expired(folder: str | Path, now: datetime | None = None) -> list[str]: + """하루 지난 작업 중 HP 키(`M02_Hopyo_Store.stale`).""" + out = [] + for path in sorted((Path(folder) / hopyo_store.DIRNAME).glob("HP-*.json")): + doc = hopyo_store._load(path) + if isinstance(doc, dict) and doc.get("지문") and hopyo_store.stale(doc, now): + out.append(path.stem) + return out + + +def sweep(folder: str | Path, now: datetime | None = None) -> list[str]: + """하루 지난 HP 끝냄 → 끝낸 키.""" + for key in (got := expired(folder, now)): + finish(folder, key) + return got + + +# ── 옛 파일 정리(한 번) ─────────────────────────────────────────────── +def tidy(folder: str | Path, apply: bool = False) -> list[dict[str, str]]: + """옛 실무자료 정리 표 `[{할일, 대상}]` — `apply` 거짓 = 미리 보기(쓰기 없음).""" + folder = Path(folder) + home = folder / hopyo_store.DIRNAME + rows: list[dict[str, str]] = [] + every = sorted(p.stem for p in home.glob("HP-*.json")) + old = [k for k in every if not (hopyo_store._load(home / f"{k}.json") or {}).get("지문")] + ended = [*old, *expired(folder)] + live = set(every) - set(ended) + for key in ended: + rows.append({"할일": "HP 끝냄(옛 · 하루 지남)", "대상": f"{hopyo_store.DIRNAME}/{key}"}) + if apply: + finish(folder, key) + log = hopyo_store.done(folder) + if log["지문"]: + rows.append({"할일": f"끝낸 기록 지문 {len(log['지문'])} 비움", "대상": "불러옴.json"}) + if apply: + hopyo_store._put_done(folder, {"번": log["번"]}) + + def gone(mark: str) -> bool: + m = _HP.match(mark) + return bool(m) and m.group(1) not in live + + for key in forget(folder, gone, apply): + rows.append({"할일": "엑셀 사본 걷음(출처만)", "대상": key}) + return rows + + +def main(argv: list[str]) -> int: + apply = "--apply" in argv + for one in [a for a in argv if a != "--apply"]: + rows = tidy(one, apply) + print(f"# {one} — {'고침' if apply else '미리 보기'} {len(rows)}") + for row in rows: + print(f"{row['할일']}\t{row['대상']}") + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/M02_MasterTemplete/M02_Hopyo_Line.py b/M02_MasterTemplete/M02_Hopyo_Line.py index f2b98770a..3ebb01590 100644 --- a/M02_MasterTemplete/M02_Hopyo_Line.py +++ b/M02_MasterTemplete/M02_Hopyo_Line.py @@ -309,7 +309,12 @@ def _grouped(got: dict, line: dict, ctx: Ctx) -> dict: return {**c, "뜻겹침": hm.near(mine, one["words"]), "출처같음": same, "책": book} named = [mark(c) for c in hm.name_cands(line, ctx.got)] - return {**got, "후보": [mark(c) for c in got.get("후보") or []], "이름후보": named} + cands = [mark(c) for c in got.get("후보") or []] + # 745-1 미리 고름 점 — 「다른 점」 1위 + 출처 같음 · 누르기는 사용자 + top = cands[0] if cands else {} + if top.get("판정") == "다른 점" and top.get("출처같음"): + top["미리"] = True + return {**got, "후보": cands, "이름후보": named} def _ledger_off(entry: dict | None, alias: dict) -> bool: diff --git a/M02_MasterTemplete/M02_Hopyo_Paste.py b/M02_MasterTemplete/M02_Hopyo_Paste.py index 4bfc4c117..5cc8d666d 100644 --- a/M02_MasterTemplete/M02_Hopyo_Paste.py +++ b/M02_MasterTemplete/M02_Hopyo_Paste.py @@ -24,6 +24,7 @@ from typing import Any from M02_MasterTemplete import M02_Hopyo_Build as hb from M02_MasterTemplete import M02_Hopyo_Excel as hx from M02_MasterTemplete import M02_Hopyo_Fixed as hfix +from M02_MasterTemplete import M02_Hopyo_Forget as forget from M02_MasterTemplete import M02_Hopyo_Store as hstore from M02_MasterTemplete import M02_MasterTemplete_Store as store @@ -296,7 +297,8 @@ def paste( folder: Any, text: str, cols: Any, who: Any, lines_only: bool = False, taken: Any = () ) -> dict[str, Any]: """[Ctrl+V] — `갈래` 「물음」(열 짝 짐작 + 보기 · 안 씀) · 「줄」(호표 머리 없음 · `lines_only`) · - 「만듦」(호표 머리 있음 = 실무자료 「붙여넣기 …」 + 호표마다 사전조합 · 705-3 `build_all` 그대로).""" + 「만듦」(호표 머리 있음 = 실무자료 「붙여넣기 …」 + 호표마다 사전조합 · 705-3 `build_all` 그대로 + · 만든 뒤 실무자료 끝냄 743-1).""" try: parsed = read_text(text, cols) except PasteError as error: @@ -310,4 +312,6 @@ def paste( return {**head, "갈래": "줄", **fixed_lines(parsed, taken)} keep = {k: parsed[k] for k in ("꼴", "시트", "호표", "단산", "검산")} row = hstore.save(folder, f"{SHEET} {datetime.now():%m-%d %H:%M}", keep, who) - return {**head, "갈래": "만듦", "키": row["키"], **hb.build_all(folder, folder, row["키"], who)} + made = hb.build_all(folder, folder, row["키"], who) + forget.finish(folder, row["키"]) # 743-1 — 붙인 글 실무자료는 만든 뒤 지움(엑셀 사본 걷음) + return {**head, "갈래": "만듦", "키": row["키"], **made} diff --git a/M02_MasterTemplete/M02_Hopyo_Store.py b/M02_MasterTemplete/M02_Hopyo_Store.py index 8fd3e0add..53ef5ead8 100644 --- a/M02_MasterTemplete/M02_Hopyo_Store.py +++ b/M02_MasterTemplete/M02_Hopyo_Store.py @@ -4,9 +4,9 @@ 파일 = 읽은 결과만(원본 엑셀 안 둠) `{키, 파일, 올린때, 올린이, 시트, 호표, 검산}` · 키 `HP-######` (층마다 다음 번호) · 맞춤 기억 = `{줄: {줄 열쇠: {로직?|자재?|빼기?, 입력값, 환산, 확정}}}`. 층 종류(`layers.KINDS`) 밖 폴더 — 양식 목록 · 복사 · 프로젝트 씨앗에 안 섞임 · 권한은 길 몫. -한시 불러오기(718-1) — 파일에 `지문`(올린 엑셀 sha256) · 목록 = 지문 있는 것(작업 중)만 · 옛 HP 는 -파일 그대로 목록에서만 숨김 · 끝낸 기록 `실무자료/불러옴.json` -`{번, 지문 {지문: {파일, 키, 끝낸때, 만든}}}` · 번 = 낸 키 가장 큰 번(지운 뒤에도 키를 다시 안 씀). +한시 불러오기(718-1 · 743-1) — 파일에 `지문`(올린 엑셀 sha256) · 목록 = 지문 있고 하루 안 지난 것 +(작업 중)만 · 옛 HP · 하루 지난 HP 는 목록에서 숨김(지움 = `M02_Hopyo_Forget`) · +`실무자료/불러옴.json` `{번}` = 낸 키 가장 큰 번(지운 뒤에도 키를 다시 안 씀 · 옛 `지문` 안 읽음). """ from __future__ import annotations @@ -15,6 +15,7 @@ import hashlib import json import re import threading +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any @@ -27,6 +28,7 @@ MEMORY = "맞춤" GRID = "격자" DONE = "불러옴" NO_GRID = "격자 없음 — 다시 올리면 보임" +EXPIRE = timedelta(days=1) # 작업 중 HP 보관 — 올린 뒤 하루 _KEY = re.compile(r"^HP-(\d{6})$") # ponytail: 프로세스 안 잠금 — 서버를 여럿 띄우면 파일 잠금으로 _LOCK = threading.Lock() @@ -68,18 +70,27 @@ def fingerprint(data: bytes) -> str: return hashlib.sha256(data).hexdigest() +def stale(doc: dict[str, Any], now: datetime | None = None) -> bool: + """하루 지난 HP — 올린때 기준 · 못 읽는 때 = 지난 것.""" + try: + when = datetime.fromisoformat(str(doc.get("올린때") or "").replace("Z", "+00:00")) + except ValueError: + return True + return (now or datetime.now(timezone.utc)) - when > EXPIRE + + def rows(folder: str | Path) -> list[dict[str, Any]]: - """층 실무자료 목록(키 차례) — 작업 중(지문 있음)만 · 옛 HP 는 숨김.""" + """층 실무자료 목록(키 차례) — 작업 중(지문 있음 · 하루 안)만 · 옛 HP · 지난 HP 는 숨김.""" out = [] for path in sorted(_dir(folder).glob("HP-*.json")): doc = _load(path) - if isinstance(doc, dict) and _KEY.match(path.stem) and doc.get("지문"): + if isinstance(doc, dict) and _KEY.match(path.stem) and doc.get("지문") and not stale(doc): out.append({**brief(doc), "지문": doc["지문"]}) return out def done(folder: str | Path) -> dict[str, Any]: - """끝낸 기록 `{번, 지문 {지문: {파일, 키, 끝낸때, 만든}}}`.""" + """키 번 기록 `{번, 지문}`(지문 = 743 전 끝낸 기록 · 안 읽음 · 정리 `Forget.tidy`).""" doc = _load(_dir(folder) / f"{DONE}.json") doc = doc if isinstance(doc, dict) else {} marks = doc.get("지문") @@ -137,22 +148,12 @@ def grid(folder: str | Path, key: str) -> dict[str, Any]: return doc -def delete(folder: str | Path, key: str, made: list[str] | None = None) -> None: - """HP · 격자 지움 · 만든 것(`made` SC 키)이 있으면 지문을 끝낸 기록에 남김(다시 올림 막음).""" +def delete(folder: str | Path, key: str) -> None: + """HP · 격자 지움(끝낸 기록 없음 · 같은 엑셀 다시 올림 됨 · 743-1).""" path = _path(folder, key) with _LOCK: - doc = _load(path) - if not isinstance(doc, dict): + if not isinstance(_load(path), dict): raise store.StoreError(404, f"없는 실무자료 「{key}」") - if made and doc.get("지문"): - log = done(folder) - log["지문"][doc["지문"]] = { - "파일": doc.get("파일"), - "키": key, - "끝낸때": utc_now_text(), - "만든": made, - } - _put_done(folder, log) path.unlink() _grid_path(folder, key).unlink(missing_ok=True) diff --git a/M02_MasterTemplete/M02_Item_Sangeun.py b/M02_MasterTemplete/M02_Item_Sangeun.py index 487125d62..9f4b9df23 100644 --- a/M02_MasterTemplete/M02_Item_Sangeun.py +++ b/M02_MasterTemplete/M02_Item_Sangeun.py @@ -50,7 +50,7 @@ def freeze(copies: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]: from M02_MasterTemplete import M02_Sangeun_Calc as calc sks = sorted(rid for rid, doc in copies.items() if is_sangeun(doc)) - if not sks: + if not sks and not any(_patched(doc) for doc in copies.values()): return copies whole = m01.loaded()[1] @@ -86,19 +86,77 @@ def freeze(copies: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]: if got["켜쌓기율"] is not None: frozen["켜쌓기율"] = prices.to_json(got["켜쌓기율"]) out[rid] = frozen + for rid in sorted(rid for rid, doc in copies.items() if _patched(doc)): + out[rid] = _freeze_lines(rid, copies[rid], whole, read, taken) return out +def _patched(doc: Any) -> list[int]: + """일위대가(SC) 사본의 덮개 줄(로직 줄 + `패치` · 740-1 SK 넣기 꼴) 차례.""" + if not isinstance(doc, dict) or doc.get("종류") != refs.TITLE["precombo"]: + return [] + lines = doc.get("담은로직") or [] + return [ + n for n, x in enumerate(lines) if isinstance(x, dict) and x.get("로직") and x.get("패치") + ] + + +def _freeze_lines(rid: str, doc: dict, whole: Any, read: Any, taken: set[str]) -> dict: + """SC 사본 덮개 줄마다 고친 줄을 풀어 굳힘(740-1 · B09 대가 · 사본 셈이 덮개를 읽게) — + 줄은 그대로 두고 덧칸 `굳힌 {키, 줄, 입력값, 켜쌓기율, 고름, 자재표시, 정체}`(고름 · + 자재표시 = 굳힌 줄 차례) · 못 푼 줄 = 그대로(사본 셈이 옛 길).""" + from M02_MasterTemplete import M02_Item_Prices as prices + from M02_MasterTemplete import M02_Labor_Patch as patch + from M02_MasterTemplete import M02_Sangeun_Calc as calc + from M02_MasterTemplete.M02_Labor_Expand import Expand + + lines = list(doc.get("담은로직") or []) + for n in _patched(doc): + one = lines[n] + try: + doc_ = sd.clean(patch.as_doc(one, whole), None, whole) + got = calc.resolve(doc_, whole, read, (rid,), line=True) # 일위대가 기본 별도계상 + row = {**got["줄"], "호표": [dict(x) for x in got["줄"]["호표"]]} + calc._bundles(row, whole, Expand(whole, read), (rid,)) # 고정 더한줄 = 값묶음 + except sd.DocError: + continue + key = frozen_key(f"{rid}#{n}", taken) + fz = {"키": key, "줄": prices.to_json({**row, "키": key}), "정체": list(got["정체"])} + fz |= { + "입력값": prices.to_json(got["입력값"]), + "고름": got["고름"], + "자재표시": got["자재표시"], + } + if got["켜쌓기율"] is not None: + fz["켜쌓기율"] = prices.to_json(got["켜쌓기율"]) + lines[n] = {**one, FROZEN: fz} + return {**doc, "담은로직": lines} + + +FROZEN = "굳힌" + + def rows(copies: dict[str, Any]) -> list[dict[str, Any]]: - """굳힌 SK 줄 — 사본 `단가.json` `FILE` 에 넣음(이름표 차례).""" - return [doc[ROW] for _, doc in sorted(copies.items()) if is_sangeun(doc) and ROW in doc] + """굳힌 SK 줄 + SC 덮개 줄 굳힌 줄(740-1) — 사본 `단가.json` `FILE` 에 넣음(이름표 차례).""" + out = [doc[ROW] for _, doc in sorted(copies.items()) if is_sangeun(doc) and ROW in doc] + for _, doc in sorted(copies.items()): + out += [ + x[FROZEN]["줄"] + for x in doc.get("담은로직") or [] + if isinstance(x, dict) and x.get(FROZEN) + ] + return out def materials(copies: dict[str, Any]) -> list[str]: """굳힌 SK 고른 자재 키 — 단가 묶음에 담음.""" - return [ + out = [ str(mt) for doc in copies.values() if is_sangeun(doc) for mt in (doc.get("고름") or {}).values() ] + for doc in copies.values(): + lines = [x for x in doc.get("담은로직") or [] if isinstance(x, dict) and x.get(FROZEN)] + out += [str(mt) for x in lines for mt in (x[FROZEN].get("고름") or {}).values()] + return out diff --git a/M02_MasterTemplete/M02_Labor_Expand.py b/M02_MasterTemplete/M02_Labor_Expand.py index 0b58f0e75..a0180cf07 100644 --- a/M02_MasterTemplete/M02_Labor_Expand.py +++ b/M02_MasterTemplete/M02_Labor_Expand.py @@ -109,8 +109,13 @@ class Expand: """사전조합 안 로직 줄 — 일위대가 로직 줄과 같은 `run_line` · 배는 물량 알약 것.""" from M02_MasterTemplete import M02_Labor_Doc as labor - pill = pill or {} - if one.get(patch.FIELD): # 줄 덮개(715-3) = 이름 없는 단가산출 · 별도계상까지 + pill, fz = pill or {}, one.get("굳힌") + if isinstance(fz, dict) and fz.get("키"): # 사본 굳힌 덮개 줄(740-1 · `Item_Sangeun`) + got = labor.run_line( + self.whole, fz["키"], fz.get("입력값"), fz.get("켜쌓기율"), fz.get("고름") or None + ) + got = labor.drop_apart(got, fz.get("자재표시")) + elif one.get(patch.FIELD): # 줄 덮개(715-3) = 이름 없는 단가산출 · 별도계상까지 got = patch.run(self.whole, one, self.read) else: got = labor.run_line( diff --git a/M02_MasterTemplete/M02_Labor_Patch.py b/M02_MasterTemplete/M02_Labor_Patch.py index aa0b3db09..f5fdf899f 100644 --- a/M02_MasterTemplete/M02_Labor_Patch.py +++ b/M02_MasterTemplete/M02_Labor_Patch.py @@ -15,7 +15,8 @@ from typing import Any from M02_MasterTemplete import M02_Sangeun_Doc as sd FIELD = "패치" -KEEP = ("덮개", "뺀줄", "더한줄") +CALC = ("덮개", "뺀줄", "더한줄") # 셈에 드는 칸(`as_doc` · 화면 짝 꼴) +KEEP = (*CALC, "뺀더한줄") # 뺀더한줄 = [되살림] 기억(744-1) TEMP = "SK-000000" # 이름 없는 줄 단가산출 — 저장 안 함 @@ -41,7 +42,9 @@ def clean(one: dict, where: str, whole: Any, row: dict | None, opened: list | No ) except sd.DocError as e: raise _bad(f"{where} · {e}") from e - out = {k: got[k] for k in KEEP if got[k]} + out = {k: got[k] for k in KEEP if got.get(k)} + for add in out.get("더한줄") or []: # 처음수량(744-1) = 넣을 때 수량 · 없으면 지금 수량 + add.setdefault("처음수량", add["수량"]) return {FIELD: out} if out else {} @@ -71,7 +74,7 @@ def _keyed(value: Any, ids: list[str]) -> dict: def as_doc(one: dict, whole: Any, name: str = "줄", unit: str = "식") -> dict[str, Any]: """일위대가 줄 → SK 문서(바탕 = 그 로직 · 그 SK) — 고름 · 자재표시는 cid 로.""" - body = {k: (one.get(FIELD) or {}).get(k) or ([] if k != "덮개" else {}) for k in KEEP} + body = {k: (one.get(FIELD) or {}).get(k) or ([] if k != "덮개" else {}) for k in CALC} head = {"키": TEMP, "이름": name, "단위": unit, **body} if "참조" in one: return {**head, "바탕": {"참조": one["참조"]}} @@ -97,7 +100,7 @@ def run(whole: Any, one: dict, read: Any = None) -> dict[str, Any]: try: doc = sd.clean(as_doc(one, whole), None, whole) - return calc.run(doc, whole, None, read or refs.read_ref) + return calc.run(doc, whole, None, read or refs.read_ref, line=True) except sd.DocError as e: return {"기준": None, "까닭": str(e)} diff --git a/M02_MasterTemplete/M02_Labor_Ref.py b/M02_MasterTemplete/M02_Labor_Ref.py index 9afa8b5b0..169bf4588 100644 --- a/M02_MasterTemplete/M02_Labor_Ref.py +++ b/M02_MasterTemplete/M02_Labor_Ref.py @@ -42,6 +42,7 @@ _OWNER = {"company": re.compile(r"^\d+$"), "personal": re.compile(r"^\d+/\d+$")} Ref = dict[str, Any] Here = tuple[str, str | None] +COPY = "사본" # 740-1 하위 호표 사본 블록 — 참조 꼴 {층: 사본, 주인: 몸 지문, 키: 본 SC, 몸} def _bad(text: str, status: int = 400) -> store.StoreError: @@ -103,7 +104,28 @@ def ref_path(ref: Ref) -> Path: return folder_of(ref["층"], ref.get("주인")) / kind_of(ref["키"]) / f"{ref['키']}.json" +def is_copy(ref: Any) -> bool: + """사본 블록(740-1) — 일위대가 안 일위대가 몸을 줄 안에 담음(원본 연결 없음 · 키 = 출처).""" + return isinstance(ref, dict) and ref.get("층") == COPY and isinstance(ref.get("몸"), dict) + + +def copy_ref(body: dict[str, Any], key: str) -> Ref: + """사본 블록 참조 — 몸(종류 · 이름 · 단위 · 담은로직 · 자재 · 본뜬) · 주인 = 몸 지문.""" + import hashlib + + keep = {k: body[k] for k in ("종류", "이름", "단위", "담은로직", "자재", "본뜬") if body.get(k)} + raw = json.dumps(keep, ensure_ascii=False, sort_keys=True, default=str) + return { + "층": COPY, + "주인": hashlib.sha256(raw.encode()).hexdigest()[:12], + "키": key, + "몸": keep, + } + + def read_ref(ref: Ref) -> dict[str, Any] | None: + if is_copy(ref): + return ref["몸"] try: got = json.loads(ref_path(ref).read_bytes()) except (OSError, ValueError): @@ -114,6 +136,8 @@ def read_ref(ref: Ref) -> dict[str, Any] | None: def readable(at: Here, ref: Ref) -> bool: """`at` 층이 `ref` 를 가리켜도 되나 — 위 층만(프로젝트는 아무것도 · 굳힌 사본만 씀).""" layer, owner = at + if ref.get("층") == COPY: # 사본 블록 = 줄 안 몸(어느 층도 읽음) + return True if layer == "project": return False if ref["층"] == "system": @@ -127,6 +151,8 @@ def check_ref(raw: Any, at: str, here_: Here) -> Ref: """참조 꼴 · 위 층만 · 있는 문서 — 아니면 400.""" if not isinstance(raw, dict): raise _bad(f"{at} · 참조는 {{층, 주인, 키}}") + if raw.get("층") == COPY: + return _check_copy(raw, at) layer, owner, key = raw.get("층"), raw.get("주인"), str(raw.get("키") or "") if layer == "system": owner = None @@ -142,6 +168,20 @@ def check_ref(raw: Any, at: str, here_: Here) -> Ref: return ref +def _check_copy(raw: Ref, at: str) -> Ref: + """사본 블록 검사 — 키 = SC · 몸 줄 · 자재 = 일위대가 검사 그대로 · 지문 다시 셈.""" + from M02_MasterTemplete import M02_Labor_Doc as labor + + body, key = raw.get("몸"), str(raw.get("키") or "") + if not isinstance(body, dict) or not _KEY.match(key) or kind_of(key) != "precombo": + raise _bad(f"{at} · 사본 블록은 {{층: 사본, 키: SC-…, 몸}}") + try: + labor.clean({"담은로직": body.get("담은로직") or [], "자재": body.get("자재") or []}) + except store.StoreError as e: + raise _bad(f"{at} · 사본 블록 · {e.detail}") from e + return copy_ref(body, key) + + # ── 서고 문서 정리 ───────────────────────────────────────────────────── def check_key(kind: str, name: str) -> None: found = _KEY.match(name) @@ -155,6 +195,10 @@ def clean_doc(kind: str, doc: Any) -> dict[str, Any]: raise _bad("서고 문서가 묶음 아님") key = str(doc.get("키") or "") check_key(kind, key) + if here()[0] != "project": # 740-1 — M02 참조 줄 = M01 키 줄로 펼침(못 펼친 줄은 그대로) + from M02_MasterTemplete import M02_Library_Flat as flat + + doc = flat.flat_doc(kind, doc, me={"층": here()[0], "주인": here()[1], "키": key}) name, unit = str(doc.get("이름") or "").strip(), str(doc.get("단위") or "").strip() if kind == "sangeun": from M02_MasterTemplete import M02_Sangeun_Calc as calc @@ -245,13 +289,14 @@ def _combo_lines(doc: dict, key: str) -> list[dict[str, Any]]: 일위대가와 같은 길(`labor.clean` · 같은 로직 · 같은 참조 두 번 · 입력값) · 수량 기본 {손: 1}.""" from M02_MasterTemplete import M02_Labor_Doc as labor - lines = [] - for one in labor.clean({"담은로직": doc.get("담은로직") or []})["담은로직"]: + lines, raw = [], doc.get("담은로직") or [] + for one, sent in zip(labor.clean({"담은로직": raw})["담은로직"], raw): head = {k: one[k] for k in ("참조", "고정", "패치") if k in one} # 패치 = 줄 덮개(715-3) if "로직" in one: head.update(로직=one["로직"], 입력값=one["입력값"]) head.update({k: one[k] for k in ("켜쌓기율", "고름", "자재표시") if k in one}) head.update({"별칭": one["별칭"]} if one["별칭"] else {}) # 실무 품명(324-3) + head.update(_mark(sent)) # 넣은 줄 출처(740-1 · 셈에 안 씀) lines.append({**head, "수량": one["수량"], "메모": one["메모"]}) at_ = here() me = {"층": at_[0], "주인": at_[1], "키": key} @@ -269,13 +314,20 @@ def _combo_mats(doc: dict, lines: list[dict[str, Any]]) -> list[dict[str, Any]]: 로직 자재와 같은 품목 400(`labor.no_twice`).""" from M02_MasterTemplete import M02_Labor_Doc as labor - mats = labor.clean({"자재": doc.get("자재") or []})["자재"] + raw = doc.get("자재") or [] + mats = [{**m, **_mark(s)} for m, s in zip(labor.clean({"자재": raw})["자재"], raw)] if mats: got = {"담은로직": lines, "자재": mats} labor.no_twice(got, labor.calc(got, {})) return mats +def _mark(sent: Any) -> dict[str, Any]: + """줄 `본뜬`(740-1 · 펼쳐 넣은 출처 {층, 주인, 키, 이름, 때}) — 묶음만 · 셈에 안 씀.""" + got = sent.get("본뜬") if isinstance(sent, dict) else None + return {"본뜬": got} if isinstance(got, dict) else {} + + def _no_loop(ref: Ref, trail: list[Ref]) -> None: """저장 때 순환 · 깊이 — 아래로 따라가 처음 문서를 다시 만나면 422 「A → B → A」.""" if len(trail) > DEPTH: @@ -412,8 +464,12 @@ def _reused(kind: str, doc: dict[str, Any], ref: Ref) -> str | None: def carry(doc: dict[str, Any], source: Here) -> dict[str, Any]: """복사(원본 층 `source` → 지금 층) — 지금 층이 못 읽는 참조는 그 문서를 지금 층에 새 키로 같이 복사(`본뜬` = 원래 참조) · 참조를 새 키로 바꿈 · 같은 원본 같은 내용 사본이 이미 있으면 - 그 키(733-2). 원본 층 읽기 권한은 부르는 쪽 몫.""" + 그 키(733-2). 원본 층 읽기 권한은 부르는 쪽 몫 · 단가산출 · 일위대가 = 먼저 펼침(740-1).""" target, done = here(), {} + if doc.get("종류") in (TITLE["sangeun"], TITLE["precombo"]): + from M02_MasterTemplete import M02_Library_Flat as flat + + doc = flat.flat_doc("sangeun" if doc["종류"] == TITLE["sangeun"] else "precombo", doc) def move(ref: Ref, depth: int) -> Ref: if readable(target, ref): diff --git a/M02_MasterTemplete/M02_Labor_Router.py b/M02_MasterTemplete/M02_Labor_Router.py index dc113d411..8705b2a01 100644 --- a/M02_MasterTemplete/M02_Labor_Router.py +++ b/M02_MasterTemplete/M02_Labor_Router.py @@ -8,8 +8,8 @@ 일위대가 실무자료(324 · `M02_Hopyo_*` · 올리기 · 지우기 = 층 쓰기 · 목록 · 읽기 = 층 읽기) · 338 = 확인한 줄만 만들기(몸 없음 400 · 옛 즉시 확정 길 없앰) · 별칭 장부 보기 · 고치기 · 올린 뒤 맞춤 뒤 셈(`M02_Hopyo_Fit.ensure`) · 시동 때 우리 지문 데움(`M02_Hopyo_Ours.warm`) · -718-1 한시 불러오기 = 올림 지문(끝낸 같은 엑셀 409 · 작업 중 같은 엑셀 = 그 파일 `이어서`) · -지우기 = [끝내기](HP · 격자 · 맞춤셈 캐시 지움 · 만든 일위대가가 있으면 지문 기록 · 별칭 장부 남음). +718-1 한시 불러오기 = 올림 지문(작업 중 같은 엑셀 = 그 파일 `이어서` · 끝낸 것 = 새로 · 743-1) · +지우기 = [끝내기](`M02_Hopyo_Forget.finish` · 올림 때 하루 지난 HP 도 끝냄). 층 권한 = `Router_Items._root`(`M02_Layer_Access` 한 벌). ⚠ 등록은 `main.py`(로그인만). """ @@ -27,6 +27,7 @@ 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_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 @@ -229,14 +230,7 @@ def _who(session: Session) -> dict[str, Any]: def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]: mark = hopyo_store.fingerprint(data) - if old := hopyo_store.done(store.root())["지문"].get(mark): - made = len(old.get("만든") or []) - when = str(old.get("끝낸때") or "")[:10] - raise store.StoreError( - 409, - f"이미 불러온 엑셀 「{old.get('파일')}」({when} · 만든 일위대가 · 단가산출 {made})" - " — 다시 만들지 않음", - ) + hopyo_forget.sweep(store.root()) # 하루 지난 작업 중 HP 끝냄(743-1) if same := next((r for r in hopyo_store.rows(store.root()) if r.get("지문") == mark), None): return {**same, "이어서": True} try: @@ -502,29 +496,10 @@ def hopyo_ledger_write( def hopyo_delete( layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) ) -> dict: - """실무자료 지우기 = 불러오기 [끝내기](718-1) — HP · 격자 · 맞춤셈 캐시 지움 · 만든 일위대가 - (이 층 확인된 본뜬 「키 제N호표」)는 그대로 · 있으면 지문 기록(같은 엑셀 다시 올림 막음) · - 별칭 장부 그대로 → `{지움, 만든}`.""" + """실무자료 지우기 = [끝내기](718-1 · 743-1 · 화면 떠남도) — HP · 격자 · 맞춤셈 캐시 지움 · + 만든 일위대가 · 단가산출은 엑셀 사본만 걷고 남음 · 별칭 장부 그대로 → `{지움, 만든}`.""" folder = _root(session, layer, owner, write=True) - return _call(folder, _finish, folder, key) - - -def _finish(folder: Path, key: str) -> dict[str, Any]: - head = f"{key} 제" - made = sorted( - {sc for mark, sc in hopyo_build.confirmed(folder).items() if mark.startswith(head)} - ) - made += sorted(hopyo_sangeun.made(folder, key).values()) # 단가산출(SK) - if (cache_path := _later("M02_Hopyo_Fit", "cache_path")) is not None: - try: - got = cache_path(folder, key) - except (OSError, store.StoreError): - got = None - # ponytail: 뒤 셈이 도는 중이면 끝낸 뒤 캐시를 다시 쓸 수 있음 — tmp 캐시라 둠 - for old in got.parent.glob(f"{'_'.join(got.name.split('_')[:2])}_*.json") if got else []: - old.unlink(missing_ok=True) - hopyo_store.delete(folder, key, made) - return {"지움": key, "만든": len(made)} + return _call(folder, hopyo_forget.finish, folder, key) class SangeunBody(_Strict): diff --git a/M02_MasterTemplete/M02_Library_Flat.py b/M02_MasterTemplete/M02_Library_Flat.py new file mode 100644 index 000000000..69ccc2b45 --- /dev/null +++ b/M02_MasterTemplete/M02_Library_Flat.py @@ -0,0 +1,573 @@ +"""M02 단가산출(SK) · 일위대가(SC) 펼침 · 초기화 · 옮기기(PLAN 740-1 · 요구 581 · 분석 579-2). + +서로 독립 — 연결 고리 = M01 키만. M02 문서를 넣으면(찾아 넣기 `-flat` · 저장 · 읽기 · 층 사이 +복사) 그 문서 몸을 M01 키 줄 + 그 문서 덮개 사본으로 펼쳐 담음 → 원본이 나중에 바뀌어도 +넣은 쪽 금액 그대로. 줄 `본뜬 {층, 주인, 키, 이름, 때}` = 출처 정보(셈에 안 씀). + SK 에 SK 더한줄 로직 한 줄 = 원본 몸 통째(바탕 기초 · 입력값 · 덮개 · 뺀줄 · 고름 · 자재표시 · + 켜쌓기율 · 더한줄 — `M02_Sangeun_Doc.BODY` · % 덧줄까지 같은 금액) · + 수량 = 넣은 수 ÷ 배(728 환산) · 바탕 없는 SK = 그 더한줄 × 넣은 수 + SK 에 SC SC 줄마다 더한줄(로직 줄 = 로직 한 줄 + 패치 몸 · 고정 · 자재 = 재료) · + 수량 = 넣은 수 × 줄 수량(저장 `수량.손` › 실무수량 › 1 · 742-1) ÷ 배(물량 알약 배) + SC 에 SK 로직 줄 하나 + 패치(= SK 몸) · 별칭 = SK 이름 · 같은 자리(안 줄 알약 키 그대로) · + 바탕 없는 SK · 단위 환산 SK = 못 넣음(값 단위가 달라짐) + SC 에 SC 사본 블록 — 안쪽 SC 몸 통째(M01 키 줄 + 덮개 · 실무수량)를 그 줄 참조 꼴 + `{층: 사본, 주인: 몸 지문, 키: 본 SC, 몸}` 에 담음(`M02_Labor_Ref.copy_ref` · 원본 + 연결 없음 · 하위 호표 끝수 단계 · B09 하위 대가 블록 그대로 · 같은 기초 겹침도 + 블록 안) + 바탕 SK 참조 · 패치 있는 SK 참조 줄 = 위 덮개를 아래 덮개에 겹쳐 한 벌(`compose`) +못 펼친 줄 = 저장 · 읽기 · 옮기기에선 옛 참조 그대로(값 그대로) · 넣기 길(`strict`)에선 400 까닭. +초기화(`reset`) = 덮개 · 뺀줄 · 더한줄 단가 걷음(안쪽 넣은 몸 · 사본 블록까지) · 이음 기계 고정 줄 +단가 = M01 기계 한 시간 값 다시 받음(736-3) · 항목 · 수량 · 입력값 · 고름 · 자재표시 · 켜쌓기율 · +기본정보 남김 → 값 = 연결된 M01 키 값. +옮기기(`migrate`) = 층 문서 펼쳐 다시 씀 — 전후 1 단위 금액 같을 때만(안 줄 수량 모두 1 로 대조) · +공유 storage 는 브레인이 돌림. +""" + +from __future__ import annotations + +import json +import re +import time +from decimal import Decimal +from typing import Any + +from M02_MasterTemplete import M02_Labor_Fixed as fixed +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_Sangeun_Doc as sd + +ADD = re.compile(r"^add:#(\d+)$") +FAIL = (ArithmeticError, KeyError, TypeError, ValueError) # 못 펼침(DocError · FormulaError 포함) +EMPTY = (None, {}, [], "") +Read = Any + + +def _whole() -> Any: + from M01_MasterData import M01_MasterData_Store as m01 + + return m01.loaded()[1] + + +def _d(value: Any) -> Decimal: + return Decimal(str(value)) + + +def mark(ref: refs.Ref, doc: dict) -> dict[str, Any]: + """줄 출처 — 펼친 원본 {층, 주인, 키, 이름, 때(UTC)}.""" + when = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + return { + **{k: ref.get(k) for k in ("층", "주인", "키")}, + "이름": doc.get("이름") or "", + "때": when, + } + + +def _read(ref: refs.Ref, read: Read, trail: tuple) -> tuple[dict, tuple]: + rid = refs.ref_id(ref) + if rid in trail: + raise sd.DocError("순환 — " + " → ".join(t.rsplit("|", 1)[-1] for t in (*trail, rid)), 422) + if len(trail) >= refs.DEPTH: + raise sd.DocError(f"참조가 {refs.DEPTH}단을 넘음", 422) + got = read(ref) + if not isinstance(got, dict): + raise sd.DocError(f"없는 참조 「{ref.get('키')}」") + return got, (*trail, rid) + + +# ── SK ──────────────────────────────────────────────────────────────── +def flat_sk(doc: dict, read: Read, whole: Any, trail: tuple = (), strict: bool = True) -> dict: + """SK 문서 → M01 키만(더한줄 참조 펼침 · 바탕 참조 겹침) · `strict` False = 못 펼친 줄 + 그대로.""" + adds = [] + for one in doc.get("더한줄") or []: + ref = one.get("참조") if isinstance(one, dict) else None + if not isinstance(ref, dict): + adds.append(one) + continue + try: + adds += into_sk(ref, one.get("수량", 1), read, whole, trail) + except FAIL as e: + if strict: + raise sd.DocError(f"「{ref.get('키')}」 · {e}") from e + adds.append(one) + out = {**doc, "더한줄": adds} + base = doc.get("바탕") or {} + if isinstance(base.get("참조"), dict): + try: + under, path = _read(base["참조"], read, trail) + under = flat_sk(sd.clean(under, None, whole), read, whole, path) + out = compose(under, out, whole) + except FAIL: + if strict: + raise + return out + + +def into_sk(ref: refs.Ref, qty: Any, read: Read, whole: Any, trail: tuple = ()) -> list[dict]: + """SK 에 넣을 더한줄 — 넣는 문서(SK · SC)를 M01 키 줄로(`qty` = 그 문서 단위 수).""" + src, path = _read(ref, read, trail) + kind, q, tag = refs.kind_of(ref.get("키")), _d(qty), mark(ref, src) + src = sd.clean(src, None, whole) if kind == "sangeun" else src + if kind == "sangeun": + return sk_adds(flat_sk(src, read, whole, path), q, whole, tag) + if kind == "precombo": + return sc_adds(flat_sc(src, read, whole, path), q, whole, tag) + raise sd.DocError("옛 단가승격은 펼치지 않음") + + +def filled_adds(doc: dict, whole: Any) -> list[dict]: + """더한줄 로직 빈 입력 = 그 SK 입력값(셈 `_fill` 의 공통 옵션 몫) — 옮겨도 같은 옵션.""" + common, out = doc.get("입력값") or {}, [] + for one in doc.get("더한줄") or []: + if one.get("종류") == "로직": + try: + names = [str(s.get("이름")) for s in whole.logic(one["로직"]).get("입력") or []] + except Exception: # noqa: BLE001 — 없는 로직 = 검사 · 셈이 까닭 + names = [] + mine = dict(one.get("입력값") or {}) + for n in names: + if mine.get(n) in EMPTY and common.get(n) not in EMPTY: + mine[n] = common[n] + one = {**one, "입력값": mine} + out.append(one) + return out + + +def _scaled(one: dict, q: Decimal, tag: dict) -> dict: + return {**one, "수량": sd.plain(_d(one.get("수량") or 0) * q), "본뜬": tag} + + +def sk_adds(y: dict, q: Decimal, whole: Any, tag: dict) -> list[dict]: + """펼친 SK → 더한줄 — 바탕 기초 있으면 한 줄(몸 통째) · 없으면 그 더한줄 × q.""" + from M02_MasterTemplete import M02_Sangeun_Calc as calc + + adds = filled_adds(y, whole) + code = (y.get("바탕") or {}).get("로직") + if not code: + return [_scaled(one, q, tag) for one in adds] + row = whole.logic(code) + names = {str(s.get("이름")) for s in row.get("입력") or []} + unit = calc.base_unit(row) + entry: dict[str, Any] = { + "종류": "로직", + "로직": code, + "이름": y.get("이름") or code, + "단위": unit[0] if unit else "", + "수량": sd.plain(q / calc.ratio_of(y, row, whole)), + "입력값": {k: v for k, v in (y.get("입력값") or {}).items() if k in names}, + } + entry |= { + k: y[k] for k in ("덮개", "뺀줄", "고름", "자재표시", "켜쌓기율") if y.get(k) not in EMPTY + } + return [{**entry, **({"더한줄": adds} if adds else {}), "본뜬": tag}] + + +def _hand(one: dict, kept: Any) -> Decimal: + """SC 안 줄 수량 — 저장 `수량.손`(742-1 사용자 입력) › 실무수량 › 1.""" + raw = one.get("수량") + got = raw.get("손") if isinstance(raw, dict) else None + return _d(got if got not in EMPTY else kept if kept not in EMPTY else 1) + + +def sc_adds(z: dict, q: Decimal, whole: Any, tag: dict) -> list[dict]: + """펼친 SC → SK 더한줄 — 줄 수량 = q × `_hand` · 로직 줄 ÷ 물량 알약 배.""" + from M02_MasterTemplete import M02_Labor_Patch as patch + from M02_MasterTemplete import M02_Sangeun_Calc as calc + + hands = (z.get("본뜬") or {}).get("실무수량") or {} + lines, mats = hands.get("줄") or {}, hands.get("자재") or {} + out = [] + for at, one in enumerate(z.get("담은로직") or []): + n = q * _hand(one, lines.get(str(at))) + if one.get("로직"): + row = whole.logic(one["로직"]) + body = patch.as_doc(one, whole) + entry = { + "종류": "로직", + "로직": one["로직"], + "이름": one.get("별칭") or row.get("이름") or "", + } + entry |= {"수량": sd.plain(n / calc.times_of(row)), "입력값": body["입력값"]} + keep = ("덮개", "뺀줄", "고름", "자재표시", "켜쌓기율", "더한줄") + entry |= {k: body[k] for k in keep if body.get(k) not in EMPTY} + elif fixed.is_fixed(one) and not refs.is_rate(one): + entry = { + "고정": one["고정"], + "이름": one["고정"].get("이름") or "", + "수량": sd.plain(n), + } + elif refs.is_copy(one.get("참조")): # 하위 호표 사본 블록 = 그 줄들도 더한줄로 + out += sc_adds(one["참조"]["몸"], n, whole, tag) + continue + else: + raise sd.DocError("비율 고정 줄 · 참조 줄은 단가산출에 못 담음") + out.append({**entry, "본뜬": tag}) + for mat in z.get("자재") or []: + code = str(mat.get("코드") or "") + if not code.startswith("MT"): + raise sd.DocError(f"개인 자재 「{code}」 는 단가산출에 못 담음") + n = q * _hand(mat, mats.get(code)) + one = { + "종류": "재료", + "요소": code, + "이름": mat.get("이름") or "", + "단위": mat.get("단위") or "", + } + apart = {"별도계상": True} if mat.get("별도계상") == "별도계상" else {} + out.append({**one, "수량": sd.plain(n), **apart, "본뜬": tag}) + return out + + +# ── 겹침(바탕 SK · 패치 SK 참조) ───────────────────────────────────────── +def _merge(a: dict | None, b: dict) -> dict: + """아래 덮개 a 위에 위 덮개 b — 품 qty 바꿈 · 배율 곱 · 단가 위 것.""" + if not a: + return dict(b) + out = {k: v for x in (a, b) for k, v in x.items() if k in sd.PARTS} + if "qty" in b: + out["qty"] = b["qty"] + elif "배율" in b and "qty" in a: + out["qty"] = sd.plain(_d(a["qty"]) * _d(b["배율"])) + elif "배율" in b: + out["배율"] = sd.plain(_d(a.get("배율", 1)) * _d(b["배율"])) + else: + out |= {k: a[k] for k in ("qty", "배율") if k in a} + return out | ({"원값": a["원값"]} if "원값" in a else {}) + + +def _cover_add(add: dict, cover: dict) -> dict: + """위 덮개를 아래 더한줄 항목에 — 수량 · 그 비목 단가만(그 밖 = 못 겹침).""" + out = dict(add) + if "qty" in cover: + out["수량"] = cover["qty"] + elif "배율" in cover: + out["수량"] = sd.plain(_d(add.get("수량") or 0) * _d(cover["배율"])) + prices = {sd.PARTS[k]: cover[k] for k in sd.PARTS if k in cover} + if prices and (add.get("종류") not in sd.ADD_PART or set(prices) != {add.get("비목")}): + raise sd.DocError("더한줄 단가 덮개를 못 겹침") + if prices: + out["단가"] = prices[add["비목"]] + return out + + +def compose(under: dict, over: dict, whole: Any) -> dict: + """펼친 바탕 SK `under` 위에 `over`(덮개 · 뺀줄 · 고름 · 자재표시 · 더한줄 · 입력값) → 한 벌 — + 위 줄 정체를 아래 바탕 정체로 옮김(`patched` 의 `정체` 짝) · 짝 잃은 위 고침은 버림(값 없음).""" + base = under.get("바탕") + row = whole.logic(base["로직"]) if (base or {}).get("로직") else sd.empty_row(under) + got = sd.patched(row, under) + where = dict(zip(sd.line_ids(got["줄"]), got["정체"])) + covers = {c: dict(v) for c, v in (under.get("덮개") or {}).items()} + drop, picks = list(under.get("뺀줄") or []), dict(under.get("고름") or {}) + marks, adds, gone = ( + dict(under.get("자재표시") or {}), + [dict(x) for x in under.get("더한줄") or []], + set(), + ) + + def to(cid: str) -> tuple[str, Any] | None: + o = where.get(cid) + hit = ADD.match(o) if o else None + return None if o is None else ("add", int(hit[1])) if hit else ("base", o) + + for cid, cover in (over.get("덮개") or {}).items(): + if t := to(cid): + if t[0] == "base": + covers[t[1]] = _merge(covers.get(t[1]), cover) + else: + adds[t[1]] = _cover_add(adds[t[1]], cover) + for cid in over.get("뺀줄") or []: + if (t := to(cid)) and t[0] == "add": + gone.add(t[1]) + elif t and t[1] not in drop: + drop.append(t[1]) + for name, mine, into in ( + ("고름", over.get("고름"), picks), + ("자재표시", over.get("자재표시"), marks), + ): + for cid, value in (mine or {}).items(): + if (t := to(cid)) and t[0] == "add": + raise sd.DocError(f"더한줄 {name}를 못 겹침") + if t: + into[t[1]] = value + rate = over.get("켜쌓기율", under.get("켜쌓기율")) + out = {**over, "바탕": base, "덮개": covers, "뺀줄": drop, "고름": picks, "자재표시": marks} + out["더한줄"] = [a for k, a in enumerate(adds) if k not in gone] + list( + over.get("더한줄") or [] + ) + out["입력값"] = {**(under.get("입력값") or {}), **(over.get("입력값") or {})} + return out | ({"켜쌓기율": rate} if rate is not None else {}) + + +# ── SC ──────────────────────────────────────────────────────────────── +def sk_line(y: dict, one: dict, whole: Any, tag: dict) -> dict: + """펼친 SK → 일위대가 로직 줄 하나(+ 패치) — 옛 참조 줄 `one` 의 별칭 · 메모 · 수량 · 패치 + 이어받음.""" + from M02_MasterTemplete import M02_Labor_Patch as patch + from M02_MasterTemplete import M02_Sangeun_Calc as calc + + if one.get(patch.FIELD): + y = compose(y, {"입력값": {}, **one[patch.FIELD]}, whole) + code = (y.get("바탕") or {}).get("로직") + if not code: + raise sd.DocError("바탕 기초 없는 단가산출은 일위대가 줄로 못 담음 — 그 안 줄을 따로 넣음") + row = whole.logic(code) + if calc.ratio_of(y, row, whole) != calc.times_of(row): + raise sd.DocError("단위 환산 단가산출은 일위대가 줄로 못 담음(값 단위가 바뀜)") + ids = sd.line_ids(row) + names = {str(s.get("이름")) for s in row.get("입력") or []} + at = {c: str(n) for n, c in enumerate(ids)} + body = {k: y[k] for k in ("덮개", "뺀줄") if y.get(k) not in EMPTY} + body |= {"더한줄": adds} if (adds := filled_adds(y, whole)) else {} + line: dict[str, Any] = { + "로직": code, + "입력값": {k: v for k, v in (y.get("입력값") or {}).items() if k in names}, + "별칭": one.get("별칭") or y.get("이름"), + "메모": one.get("메모"), + "수량": one.get("수량"), + } + for name in ("고름", "자재표시"): + if got := {at[c]: v for c, v in (y.get(name) or {}).items() if c in at}: + line[name] = got + if y.get("켜쌓기율") is not None: + line["켜쌓기율"] = y["켜쌓기율"] + return {**line, **({patch.FIELD: body} if body else {}), "본뜬": tag} + + +def _ids(doc: dict) -> list[Any]: + from M02_MasterTemplete import M02_Labor_Doc as labor + + lines = [labor.line_id(x) for x in doc.get("담은로직") or [] if isinstance(x, dict)] + return lines + [f"자재|{m.get('코드')}" for m in doc.get("자재") or [] if isinstance(m, dict)] + + +def _twice(doc: dict) -> str | None: + seen: set[str] = set() + for one in _ids(doc): + key = json.dumps(one, ensure_ascii=False, sort_keys=True, default=str) + if key in seen: + return str(one) + seen.add(key) + return None + + +def flat_sc(doc: dict, read: Read, whole: Any, trail: tuple = (), strict: bool = True) -> dict: + """SC 문서 → M01 키만 — SK 참조 = 로직 줄 + 패치(같은 자리) · SC 참조 = 사본 블록(같은 자리) · + `strict` False = 못 펼친 줄 그대로(겹침 · 바탕 없음 · 환산 · 없는 참조).""" + lines = [] + for at, one in enumerate(doc.get("담은로직") or []): + ref = one.get("참조") if isinstance(one, dict) else None + kind = refs.kind_of(ref.get("키")) if isinstance(ref, dict) else None + if kind not in ("sangeun", "precombo") or refs.is_copy(ref): + lines.append(one) + continue + try: + src, path = _read(ref, read, trail) + tag = mark(ref, src) + if kind == "sangeun": + y = flat_sk(sd.clean(src, None, whole), read, whole, path) + put = sk_line(y, one, whole, tag) + else: + body = flat_sc(src, read, whole, path) + put = {**one, "참조": refs.copy_ref(body, ref["키"]), "본뜬": tag} + rest = (doc.get("담은로직") or [])[at + 1 :] + trial = {"담은로직": [*lines, put, *rest], "자재": doc.get("자재") or []} + if twice := _twice(trial): + raise sd.DocError(f"같은 줄이 겹침 「{twice}」 — 펼쳐 넣지 못함") + except FAIL as e: + if strict: + raise sd.DocError(f"「{ref.get('키')}」 · {e}") from e + put = one + lines.append(put) + return {**doc, "담은로직": lines} + + +# ── 문 ──────────────────────────────────────────────────────────────── +def flat_doc( + kind: str, doc: Any, read: Read = refs.read_ref, strict: bool = False, me: Any = None +) -> Any: + """층 문서 하나 펼침(저장 · 읽기 · 복사 · 옮기기) — 참조 없으면 받은 그대로 · `me` = 이 문서 + 참조(자기 자신을 담은 줄 = 안 펼침 → 저장 검사가 422).""" + if not isinstance(doc, dict) or kind not in refs.PREFIX or not refs.refs_in(doc): + return doc + whole, trail = _whole(), (refs.ref_id(me),) if isinstance(me, dict) else () + if kind == "sangeun": + return flat_sk(doc, read, whole, trail, strict) + return flat_sc(doc, read, whole, trail, strict) + + +def flat_lines(kind: str, ref: Any, qty: Any = 1, doc: Any = None) -> dict[str, Any]: + """찾아 넣기(`-flat` 길) — `kind` 문서에 `ref` 를 넣을 줄 · 못 펼침 = StoreError 400 · + SC = 로직 줄(SK) · 사본 블록 줄(SC) 하나 · 지금 화면 문서 `doc` 과 겹치면 400.""" + from M02_MasterTemplete import M02_MasterTemplete_Store as store + + at = refs.here() + try: + ref = refs.check_ref(ref, "넣을 문서", at) + whole = _whole() + if kind == "sangeun": + return {"더한줄": into_sk(ref, qty if qty not in EMPTY else 1, refs.read_ref, whole)} + got = flat_sc({"담은로직": [{"참조": ref, "수량": None}]}, refs.read_ref, whole) + host = doc if isinstance(doc, dict) else {} + trial = {"담은로직": [*(host.get("담은로직") or []), *got["담은로직"]]} + if twice := _twice({**trial, "자재": host.get("자재") or []}): + raise sd.DocError(f"이미 담은 줄과 겹침 「{twice}」") + return {"담은로직": got["담은로직"], "자재": []} + except FAIL as e: + raise store.StoreError(getattr(e, "status", 400), str(e)) from e + + +# ── 초기화 ──────────────────────────────────────────────────────────── +def _reprice(fx: dict) -> tuple[dict, int]: + """이음 기계 고정 줄(736-3) 단가 = M01 기계 한 시간 값 다시 받음 — (줄, 바뀐 수).""" + from M02_MasterTemplete import M02_Machine_Hourly as machine + + key = (fx.get("이음") or {}).get("기계") + got = machine.hourly(str(key)) if key else {} + if not got.get("ok"): + return fx, 0 + price = {k: got[k] for k in sd.PARTS.values()} + return {**fx, "단가": price}, int(price != fx.get("단가")) + + +def _reset_adds(adds: list) -> tuple[list, int]: + out, n = [], 0 + for one in adds or []: + one = dict(one) if isinstance(one, dict) else one + if not isinstance(one, dict): + out.append(one) + continue + if "단가" in one: + one.pop("단가") + n += 1 + n += len(one.pop("덮개", None) or {}) + len(one.pop("뺀줄", None) or []) + one.pop("바탕판", None) + if one.get("더한줄"): + one["더한줄"], k = _reset_adds(one["더한줄"]) + n += k + if isinstance(one.get("고정"), dict): + one["고정"], k = _reprice(one["고정"]) + n += k + out.append(one) + return out, n + + +def _reset_sc(doc: dict) -> tuple[dict, int]: + """일위대가 초기화 — 줄 패치 · 사본 블록 몸 · 이음 기계 고정 줄.""" + from M02_MasterTemplete import M02_Labor_Patch as patch + + lines, n = [], 0 + for one in doc.get("담은로직") or []: + if not isinstance(one, dict): + lines.append(one) + continue + ref, body = one.get("참조"), one.get(patch.FIELD) + if refs.is_copy(ref): + inner, k = _reset_sc(ref["몸"]) + n += k + one = {**one, "참조": refs.copy_ref(inner, ref["키"])} + elif isinstance(one.get("고정"), dict): + fx, k = _reprice(one["고정"]) + n += k + one = {**one, "고정": fx} + elif body: + adds, k = _reset_adds(body.get("더한줄") or []) + n += k + len(body.get("덮개") or {}) + len(body.get("뺀줄") or []) + keep = {x: v for x, v in body.items() if x not in ("덮개", "뺀줄", "더한줄")} + rest = {**keep, **({"더한줄": adds} if adds else {})} + one = {x: v for x, v in one.items() if x != patch.FIELD} + one = {**one, **({patch.FIELD: rest} if rest else {})} + lines.append(one) + return {**doc, "담은로직": lines}, n + + +def reset(kind: str, doc: dict) -> tuple[dict, int]: + """[초기화] — (걷은 문서, 걷힌 고침 수) · 먼저 펼침(옛 참조 줄도 M01 키로).""" + doc = flat_doc(kind, doc) + if kind == "sangeun": + adds, n = _reset_adds(doc.get("더한줄") or []) + n += len(doc.get("덮개") or {}) + len(doc.get("뺀줄") or []) + return {**doc, "덮개": {}, "뺀줄": [], "더한줄": adds}, n + return _reset_sc(doc) + + +def reset_doc(kind: str, key: str | None, doc: Any, look: bool, version: str | None) -> dict: + """`-reset` 길 — 미리 보기 = 걷힐 수 · 아니면 지금 층에 씀(판이 오면 그 판일 때만).""" + from M02_MasterTemplete import M02_MasterTemplete_Store as store + + if doc is None: + if not key: + raise store.StoreError(400, "문서 또는 키") + found = store.read(kind, key) + doc, version = found["문서"], version or found["판"] + if not isinstance(doc, dict): + raise store.StoreError(400, "문서가 묶음 아님") + got, n = reset(kind, doc) + if look: + return {"걷힘": n, "문서": got} + name = str(got.get("키") or key or "") + if not version: + version = store.read(kind, name)["판"] + store.write(kind, name, version, got) + return {"걷힘": n, "문서": store.read(kind, name)["문서"]} + + +# ── 옮기기 ──────────────────────────────────────────────────────────── +def _value(ref: refs.Ref, read: Read, whole: Any) -> Any: + from M02_MasterTemplete.M02_Labor_Expand import Expand, inner_pills + + given = {p["키"]: 1 for p in inner_pills(ref, read)} + got = Expand(whole, read, given)(ref).get("기준") + return None if got is None else {k: round(float(v), 6) for k, v in got.items()} + + +def migrate(apply: bool = False) -> list[dict[str, Any]]: + """저장소 층(마스터 · 회사 · 개인) SK · SC 의 M02 참조 줄을 펼쳐 다시 씀 — 줄마다 결과 + {어디, 키, 펼침, 같음, 씀, 까닭} · `apply` False = 보기만.""" + from M02_MasterTemplete import M02_MasterTemplete_Store as store + from M02_MasterTemplete import M02_Template_Layers as layers + + whole, out = _whole(), [] + for kind in ("sangeun", "precombo"): + for where_, path in layers.layer_files(kind): + try: + doc = json.loads(path.read_bytes()) + except (OSError, ValueError): + continue + if not refs.refs_in(doc): + continue + layer, owner = refs.where(path.parent.parent) + ref = {"층": layer, "주인": owner, "키": path.stem} + got = flat_doc(kind, doc) + left = [ + r.get("키") + for r in refs.refs_in(got) + if refs.kind_of(r.get("키")) != "promote" and not refs.is_copy(r) + ] + row = { + "어디": where_, + "키": path.stem, + "펼침": left == [], + "남은참조": left, + "씀": False, + } + + def mine(r: refs.Ref, _got: dict = got, _rid: str = refs.ref_id(ref)) -> dict | None: + return _got if refs.ref_id(r) == _rid else refs.read_ref(r) + + row["전"], row["후"] = _value(ref, refs.read_ref, whole), _value(ref, mine, whole) + row["같음"] = row["전"] == row["후"] + if not row["같음"]: + row["까닭"] = "전후 금액 다름 — 안 옮김" + elif apply and got != doc: + with store.at(path.parent.parent): + store.write(kind, path.stem, store.read(kind, path.stem)["판"], got) + row["씀"] = True + out.append(row) + return out + + +if __name__ == "__main__": # 브레인 — `python -m M02_MasterTemplete.M02_Library_Flat [--apply]` + import sys + + for one in migrate("--apply" in sys.argv): + print(json.dumps(one, ensure_ascii=False, default=str)) diff --git a/M02_MasterTemplete/M02_Library_Same.py b/M02_MasterTemplete/M02_Library_Same.py index ba449ee4a..d89913e30 100644 --- a/M02_MasterTemplete/M02_Library_Same.py +++ b/M02_MasterTemplete/M02_Library_Same.py @@ -24,7 +24,7 @@ from common_util.common_util_derived_store import derived_key from M02_MasterTemplete import M02_Labor_Ref as refs DROP = {"키", "이름", "구분", "상세구분", "본뜬", "메모", "별칭", "바탕판", "원값", "계산값", "열"} -DROP |= {"일위대가번", "종류", "수정일", "출처"} +DROP |= {"일위대가번", "종류", "수정일", "출처", "처음수량", "뺀더한줄"} # 744-1 되돌림 기억 UNIT = {"m3": "㎥", "m2": "㎡", "m³": "㎥", "m²": "㎡"} EMPTY = (None, "", [], {}) @@ -80,6 +80,10 @@ class Same: def finger(self, kind: str, doc: Any, trail: tuple = ()) -> str | None: if not isinstance(doc, dict): return None + if not trail: # 740-1 — 옛 참조 줄 = 펼친 꼴로 대조(펼쳐 담은 문서와 같은 지문) + from M02_MasterTemplete import M02_Library_Flat as flat + + doc = flat.flat_doc(kind, doc) body = self._sk(doc, trail) if kind == "sangeun" else self._sc(doc, trail) if kind == "sangeun" and not body.get("바탕") and not body.get("더한줄"): return None diff --git a/M02_MasterTemplete/M02_Library_Same_Router.py b/M02_MasterTemplete/M02_Library_Same_Router.py index e6ed4575f..390350545 100644 --- a/M02_MasterTemplete/M02_Library_Same_Router.py +++ b/M02_MasterTemplete/M02_Library_Same_Router.py @@ -11,6 +11,10 @@ POST …/undo {키} — `_합친것/` 에서 되돌림 · 옮긴 참조도 되 `M02_Library_Delete.undo`(문서 · 자리 · 고친 문서 앞판) POST /layers/{층}/{kind}-tree/delete-check {대상} — 나무 지우기 미리 보기(735-1) · 지울 문서 · 쓰는 줄 +POST /layers/{층}/{kind}-flat {참조, 수량?, 문서?} — 넣을 줄(740-1 · M02 문서 → M01 키 줄 + 덮개 + 사본 · 본뜬 · `M02_Library_Flat.flat_lines`) · 층 읽기 권한 · 못 펼침 400 +POST /layers/{층}/{kind}-reset {키 | 문서, 미리보기, 판?} — [초기화](740-1) · 걷힐 수 · 문서 · + 미리보기 false = 그 층에 씀(쓰기 권한) 지움 = 이 층 문서만 · 남김 = 이 층 또는 위 층(위 층과 같은 아래 층 문서 = 남김 = 위 층 키) · 판 = 이 층 서고 나무 판(`{종류}_폴더.json`) · 권한 = 그 층 쓰기 · 프로젝트 사본 = 굳힌 값이라 안 건드림 · 지문 = `M02_Library_Same`(733-1) 읽기만. @@ -31,6 +35,7 @@ from common_util.common_util_auth import verify_session from common_util.common_util_json import atomic_write_json from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_Library_Delete as deleting +from M02_MasterTemplete import M02_Library_Flat as flat from M02_MasterTemplete import M02_Library_Same as same from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_Precombo_Folders as pre_folders @@ -380,3 +385,51 @@ def library_same_undo( ) -> dict: """되돌림 — `_합친것/<키>` 문서 · 나무 자리 · 옮긴 참조.""" return _run(undo, session, _root(session, layer, owner, write=True), kind, body.키) + + +class FlatBody(BaseModel): + """`참조` = 넣을 문서 {층, 주인, 키} · `수량` = 그 문서 단위 수(SK 만 · 기본 1) · `문서` = 지금 + 화면 문서(SC 겹침 검사).""" + + 참조: dict[str, Any] + 수량: float | None = None + 문서: dict[str, Any] | None = None + + +class ResetBody(BaseModel): + 키: str | None = None + 문서: dict[str, Any] | None = None + 미리보기: bool = True + 판: str | None = None + + +def _in(folder: Path, fn, *args): + with store.at(folder): + return fn(*args) + + +@router.post("/layers/{layer}/{kind}-flat") +def library_flat( + layer: Layer, + kind: Kind, + body: FlatBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """찾아 넣기(740-1) — 넣을 줄(SK `더한줄` · SC `담은로직` + `자재`) · 저장 안 함.""" + folder = _root(session, layer, owner) + return _run(_in, folder, flat.flat_lines, kind, body.참조, body.수량, body.문서) + + +@router.post("/layers/{layer}/{kind}-reset") +def library_reset( + layer: Layer, + kind: Kind, + body: ResetBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """[초기화](740-1) — 덮개 · 뺀줄 · 더한줄 단가 걷음 → M01 키 값 · `{걷힘, 문서}`.""" + folder = _root(session, layer, owner, write=not body.미리보기) + args = (kind, body.키, body.문서, body.미리보기, body.판) + return _run(_in, folder, flat.reset_doc, *args) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 9d1194a9b..9a21c40fe 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -216,6 +216,17 @@ export const checkLibraryDelete = ( method: "POST", body: JSON.stringify({ 대상: picks }), }); +/** [초기화](740-2) — 미리 보기(`미리보기` true) = 걷힐 고침 수 · 아니면 그 층 문서에 씀(`판` 이 다르면 409) */ +export const resetLibrary = ( + kind: LibraryKind, + layer: Layer, + owner: string | null, + body: { 키: string; 미리보기: boolean; 판?: string }, +): Promise<{ 걷힘: number; 문서: unknown }> => + call(owned(layer, `/layers/${layer}/${kind}-reset`, owner), { + method: "POST", + body: JSON.stringify(body), + }); /** 복사해 둔 문서 · 폴더 → 이 층 `dest` 아래 새 키(폴더는 안의 것까지 · 같은 폴더 이름 「(1)」) */ export const pasteLibraryTree = ( kind: LibraryKind, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts index eb3b487db..187f3c533 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api.ts @@ -110,6 +110,8 @@ export interface HopyoTarget { /** 묶음 표시(358-1 · 로직 후보만) — 품명 뜻 낱말 겹침 · 엑셀 출처 조항 같음(≥ 0.9) · 책(산림품셈 · 건설품셈) */ 뜻겹침?: boolean; 출처같음?: boolean; + /** 745-1 미리 고름 점 — 「다른 점」 1위 + 출처 같음(서버) · 자동 확정 아님 · 만들기는 사용자가 누름 */ + 미리?: boolean; 책?: string; } @@ -469,8 +471,9 @@ export const saveLedger = async ( }) ).줄; -export const deleteHopyo = (scope: HopyoScope, key: string): Promise => - call(at(scope, `/hopyo/${enc(key)}`), { method: "DELETE" }); +/** [끝내기] · 화면 떠남(743-1) — `keepalive` = 페이지를 닫아도 요청은 감 */ +export const deleteHopyo = (scope: HopyoScope, key: string, keepalive = false): Promise => + call(at(scope, `/hopyo/${enc(key)}`), { method: "DELETE", keepalive }); /** [확인하고 사전조합 만들기] — 확인한 줄만 · 직접 줄 호표 = `통째` → `target` 층 사전조합(파일 층 = `from`) */ export const buildHopyo = ( diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts index 0b299e0c1..a9db164a4 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Choose.ts @@ -10,6 +10,7 @@ * 저장 안 함 — 고른 것은 부르는 쪽 캐시(확인 창)로만 감. * ========================================================================== */ +import { createBadge } from "@ui/ui_template_badge"; import { createButton, el } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import type { @@ -30,6 +31,7 @@ const FOLD_TEXT: Record = { "지역 변형": "하나 고르면 그 지역 · 「전국」으로 되돌림은 위", 숫자: "이름 · 출처 안 닮음 · 숫자 지문만", }; +const PRE_TIP = "미리 고름 — 엑셀 인용 조항 = 1위 기초 조항 · 누르기는 사용자"; const note = (text: string): HTMLElement => el("span", { className: "ui-text ui-text--muted", text, attrs: { "data-text": "14" } }); const num = (v: number): string => String(Math.round(v * 10000) / 10000); @@ -270,8 +272,12 @@ export function openHopyoChoose(opt: ChooseOptions): void { const tag = basisTag(c.근거); return el("div", { className: `m02-hopyo__cand${weakCand(c) ? " is-weak" : ""}`, - attrs: { "data-cand": c.로직 ?? c.자재 ?? (c.전국 ? "전국" : "") }, + attrs: { + "data-cand": c.로직 ?? c.자재 ?? (c.전국 ? "전국" : ""), + ...(c.미리 ? { "data-pre": "" } : {}), + }, children: [ + ...(c.미리 ? [createBadge(PRE_TIP, "accent", { kind: "dot" })] : []), box, createButton({ label: candText(c, twins), @@ -287,6 +293,7 @@ export function openHopyoChoose(opt: ChooseOptions): void { }), ] : []), + ...(c.미리 ? [note("미리 고름")] : []), ...(c.글 ? [note(c.글)] : []), ...(weakCand(c) ? [note("이름 뜻 안 겹침")] : []), ...(tag && c.근거 diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts index cd670718c..fe4456135 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Side.ts @@ -1,7 +1,7 @@ /* ============================================================================= * M02_MasterTemplete_Hopyo_Side.ts * 좌측 「일위대가 불러오기」 컨테이너(PLAN 324-4 · 718-1 한시) — [불러오기](구조물 조합 층 단추 꼴 `m02-layers__btn`) → - * 본 칸 마법사(`M02_MasterTemplete_Hopyo_Wizard` · 결과 층 고름 · 작업 중 이어서 · [끝내기] 때 올린 파일 지움) · + * 본 칸 마법사(`M02_MasterTemplete_Hopyo_Wizard` · 결과 층 고름 · 작업 중 이어서 · [끝내기] · 화면 떠남 때 올린 파일 지움) · * 옛 파일 목록 없음(옛 HP 는 서버 목록에서 숨김) · * 아래 [별칭 장부](338-3) → 본 칸 장부 화면(`M02_MasterTemplete_Hopyo_Ledger` · 층 = 구조물 조합 층). * ========================================================================== */ @@ -40,7 +40,8 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { onClick: () => void showView(WIZARD), }); start.classList.add("m02-layers__btn"); - start.title = "다른 프로그램 일위대가 엑셀 → 우리 일위대가(한시 · 끝내면 올린 파일 지움)"; + start.title = + "다른 프로그램 일위대가 엑셀 → 우리 일위대가(한시 · 끝내거나 떠나면 올린 파일 지움)"; start.dataset.hopyoWizardOpen = ""; const ledger = el("button", { className: "m02-side__item ui-sidebar-row", @@ -52,7 +53,7 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { className: "m02-hopyo-side", children: [el("div", { className: "m02-side__row", children: [start] }), ledger], }); - let open: { key: string; view: HTMLElement } | null = null; + let open: { key: string; view: HTMLElement; leave?: () => void } | null = null; const paintList = (): void => { ledger.classList.toggle("is-active", open?.key === LEDGER_TITLE); @@ -61,18 +62,18 @@ export function buildHopyoSection(opt: HopyoSectionOptions): HopyoSection { async function showView(key: string): Promise { if (open?.key === key && open.view.isConnected) return; if (!(await opt.confirmLeave())) return; - const view = - key === WIZARD - ? buildHopyoWizard() - : buildHopyoLedger({ scope: pageScope(), canWrite: opt.canWrite() }); + open?.leave?.(); // 마법사 떠남 = 올린 파일 지움(743-1) + const wiz = key === WIZARD ? buildHopyoWizard() : null; + const view = wiz?.root ?? buildHopyoLedger({ scope: pageScope(), canWrite: opt.canWrite() }); opt.show(key === WIZARD ? title : `${title} — ${LEDGER_TITLE}`, view); opt.onOpen(); - open = { key, view }; + open = { key, view, ...(wiz ? { leave: wiz.leave } : {}) }; paintList(); } const paint = (): void => { if (open && !open.view.isConnected) { + open.leave?.(); // 다른 화면으로 = 마법사 떠남 open = null; paintList(); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts index d93bc1087..019a69dac 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Wizard.ts @@ -2,14 +2,15 @@ * M02_MasterTemplete_Hopyo_Wizard.ts * 일위대가 불러오기 = 한시 마법사(PLAN 718-1 · 요구서 내역12 4장 22) — 본 칸 한 화면 · 걸음 셋: * ① 결과 층(쓸 수 있는 층만 · 기본 개인) · 결과(일위대가 늘 · 단가산출 체크) · 엑셀 고르기 → 올림 - * (그 층에 둠 · 같은 엑셀 작업 중 = 그 파일 이어서 · 끝낸 같은 엑셀 = 서버 409 안내) · 작업 중 파일 이어서 + * (그 층에 둠 · 같은 엑셀 작업 중 = 그 파일 이어서 · 끝낸 같은 엑셀 = 새로 올림) · 작업 중 파일 이어서 * ② 보기 · 확인 · 만들기 = 엑셀 보기 창(`Hopyo_Sheet` — [모두 고정으로 들여오기] · 호표 [수정] → * 확인 창 만들기 · [구조물 조합으로] 그대로) · 만들 층 = 고른 층 · * 단가산출 체크면 단가산출 칸(`Hopyo_WizardSk` — 기초 · SK · 고름 대기 기초 고름 · 729-3) · * 안 이은 줄 띠(`Hopyo_Left` — 안 만든 호표의 남은 줄 · [고르기] = 줄마다 이을 것 · 729-3) * ③ [끝내기] = 남은 줄 다시 셈 → 있으면 막음(띠 · 남은 수 · 표) · 없으면 물음 → 서버가 이번 HP · 격자 · 맞춤셈 캐시 지움 · - * 만든 일위대가 · 단가산출 · 별칭 장부 남음 · 지문 기록 - * 작업 중 = 서버에 보관(떠났다 와도 ① 목록에서 이어서) · 옛 HP(지문 없음)는 서버 목록에서 숨김. + * 만든 일위대가 · 단가산출(엑셀 사본 걷고 출처만) · 별칭 장부 남음 + * 작업 중 = 이 화면 동안만(743-1) — 화면 떠남(`leave` · 페이지 닫음) = 이 화면에서 올리거나 이은 파일 지움 · + * 하루 지난 것은 서버가 지움 · 엑셀 자료는 보관하지 않음(필요하면 다시 올림). * ========================================================================== */ import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; @@ -61,7 +62,13 @@ export async function writableLayers(): Promise { return LAYERS.map((x) => x.layer).filter((layer) => accessFor(layer, null, user).canWrite); } -export function buildHopyoWizard(): HTMLElement { +export interface HopyoWizard { + root: HTMLElement; + /** 화면 떠남 — 이 화면에서 올리거나 이은 파일 지움(743-1) */ + leave: () => void; +} + +export function buildHopyoWizard(): HopyoWizard { const steps = createSegment({ options: STEPS.map((s) => ({ ...s, disabled: true })), value: "1", @@ -73,7 +80,7 @@ export function buildHopyoWizard(): HTMLElement { attrs: { "data-hopyo-wizard": "" }, children: [ createText( - "한시 — 다른 프로그램 일위대가 엑셀을 우리 일위대가로 옮김 · 끝내면 올린 파일 지움 · 별칭 장부는 남음", + "한시 — 다른 프로그램 일위대가 엑셀을 우리 일위대가로 옮김 · 끝내거나 화면을 떠나면 올린 파일 지움 · 별칭 장부는 남음", "hint", ), el("div", { className: "ui-toolbar", children: [steps.root] }), @@ -84,6 +91,16 @@ export function buildHopyoWizard(): HTMLElement { let pick: ManageLayer = "personal"; let wantSangeun = false; // 결과 「단가산출」 체크 const scopeOf = (layer: ManageLayer): HopyoScope => ({ layer, owner: null }); + const held = new Map(); // 이 화면이 쥔 파일 「층|키」 → 층 + const hold = (layer: ManageLayer, key: string): void => + void held.set(`${layer}|${key}`, scopeOf(layer)); + const drop = (layer: ManageLayer, key: string): void => void held.delete(`${layer}|${key}`); + const leave = (): void => { + window.removeEventListener("pagehide", leave); + for (const [id, scope] of held) void deleteHopyo(scope, id.split("|")[1], true).catch(() => {}); + held.clear(); + }; + window.addEventListener("pagehide", leave); async function first(): Promise { steps.set("1"); @@ -166,6 +183,7 @@ export function buildHopyoWizard(): HTMLElement { function second(layer: ManageLayer, file: HopyoFile): void { steps.set("2"); + hold(layer, file.키); const scope = scopeOf(layer); const view = createButton({ label: "엑셀 보기 · 확인 · 만들기", @@ -179,7 +197,10 @@ export function buildHopyoWizard(): HTMLElement { 파일: file.파일, target: scope, canWrite: true, - onDeleted: () => void first(), + onDeleted: () => { + drop(layer, file.키); + void first(); + }, }), ) .catch((error: unknown) => showToast(why(error), "error")), @@ -265,13 +286,14 @@ export function buildHopyoWizard(): HTMLElement { (x) => !x.one.호표.만든?.확인 && hasDraft(draftKey(scope, file.키, x.one.호표.번)), ).length; const ok = await showConfirmDialog( - `「${file.파일}」 불러오기를 끝냄 — 올린 파일(엑셀 결과 · 격자 · 맞춤 캐시)을 지움 · 만든 일위대가 · 단가산출과 별칭 장부는 남음 · 하나라도 만들었으면 같은 엑셀은 다시 올리지 못함` + + `「${file.파일}」 불러오기를 끝냄 — 올린 파일(엑셀 결과 · 격자 · 맞춤 캐시)을 지움 · 만든 일위대가 · 단가산출(출처만 남김)과 별칭 장부는 남음 · 같은 엑셀은 필요하면 다시 올림` + (loose ? ` · ${L("M02_LeftLoose").replace("{n}", String(loose))}` : ""), "끝내기", ); if (!ok) return steps.set("2"); try { const got = (await deleteHopyo(scope, file.키)) as { 만든?: number }; + drop(layer, file.키); showToast(`끝냄 — 만든 일위대가 · 단가산출 ${got.만든 ?? 0} · 올린 파일 지움`, "success"); } catch (error) { showToast(why(error), "error"); @@ -282,5 +304,5 @@ export function buildHopyoWizard(): HTMLElement { } void first(); - return root; + return { root, leave }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index 4dd499c3d..08eafe004 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -35,6 +35,13 @@ white-space: nowrap; } +/* 740-2 넣어 펼친 줄 출처 꼬리표 — 이름 글 끝에 이어 붙음(줄 높이 그대로 · 넘치면 말줄임) */ +.m02-labrow__born { + color: var(--color-text-secondary); + font-size: var(--text-caption); + font-weight: var(--font-weight-regular, 400); +} + .m02-labrow__key, .m02-labrow__note { color: var(--color-text-secondary); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts index 6a2519765..02350b538 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Api.ts @@ -111,6 +111,8 @@ export interface LaborRow { 고정?: LaborFixed; /** 그 줄 덮개(715-3 · 717-3 단가산출 모달) — 로직 줄 · SK 참조 줄 · 서버가 검사 */ 패치?: LinePatch; + /** 넣어 펼친 줄 출처(740-1 · 740-2 꼬리표) — {층, 주인, 키, 이름, 때} · 셈에 안 씀 */ + 본뜬?: Record; } /** 자재 세 칸(148-4 · 서버 `M02_Labor_Doc.MARKS` 와 같음) — 빈 칸 = 읽을 때 기본값 · 금액 무관 */ export const MAT_MARKS = { @@ -127,6 +129,9 @@ export type NationalMat = MatMarks & { 단위: string; 수량: { 손: number }; }; +/** 본뜬 곳 글 — 「층 키」 · 끝낸 실무자료 = 「실무자료 파일」 · 없으면 null */ +export const originText = (o: LaborDoc["본뜬"]): string | null => + o?.키 || o?.파일 ? `${o.층} ${o.키 ?? o.파일}` : null; /** 읽을 때 기본값(162-1 · 서버 `MARK_DEFAULT` 와 같음) — 저장은 비움 */ export const MAT_MARK_DEFAULT: Required = { 자재구분: "사급", @@ -151,6 +156,8 @@ function cleanShown(v: unknown): { 자재표시?: Record } { return Object.keys(out).length ? { 자재표시: out } : {}; } export interface LaborMat extends MatMarks { + /** 넣어 펼친 자재 출처(740-2) */ + 본뜬?: Record; 코드: string; 이름: string | null; 규격: string | null; @@ -163,7 +170,14 @@ export interface LaborDoc { 열: string; 일위대가번?: string; 단위: string | null; - 본뜬?: { 층: string; 주인: string | null; 키: string; 전국자재?: NationalMat[] } | null; + /** 실무자료 끝낸 뒤(743-1) = 키 없이 `파일 · 때 · 누가` 출처만 */ + 본뜬?: { + 층: string; + 주인?: string | null; + 키?: string; + 파일?: string; + 전국자재?: NationalMat[]; + } | null; 담은로직: LaborRow[]; 자재: LaborMat[]; 계산값?: unknown; @@ -192,6 +206,7 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc { ...(r.참조 || r.고정 ? {} : cleanShown(r.자재표시)), ...(r.고정 ? { 고정: r.고정 } : {}), // 705-1 — 읽을 때 떨구면 [저장]에서 줄이 사라짐 ...(r.패치 ? { 패치: r.패치 } : {}), // 717-3 — 떨구면 [저장]에서 줄 덮개가 사라짐 + ...(r.본뜬 ? { 본뜬: r.본뜬 } : {}), // 740-2 — 출처 꼬리표 })), 자재: (d.자재 ?? []).map((m) => ({ 코드: m.코드, @@ -199,6 +214,7 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc { 규격: m.규격 ?? null, 단위: m.단위 ?? null, 수량: m.수량 ?? null, + ...(m.본뜬 ? { 본뜬: m.본뜬 } : {}), // 740-2 ...cleanMarks(m), })), }; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Born.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Born.ts new file mode 100644 index 000000000..660024a55 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Born.ts @@ -0,0 +1,37 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_Born.ts + * 일위대가 상세 출처 꼬리표(740-2) — 넣어 펼친 줄 · 자재의 `본뜬` 을 짧은 글로(풍선 = 키 · 이름 · 층 · 때). + * 일위대가표 격자 = 비고 칸 글 `bornNote` + 풍선 `tipBorn`(칸 폭 · 줄 높이 안 바뀜) + * 줄 표(`Labor_Rows` · `Labor_MatTable`) = 이름 글 끝 `bornSpan`(같은 줄 · 넘치면 말줄임) + * 색 단추 아님 · 셈에 안 씀. + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import { bornText } from "./logic/M02_Born_Tag"; + +const SPLIT = "\n"; // 글 첫 줄 = 보임 · 나머지 = 풍선 + +/** 일위대가표 격자 비고 칸 풍선 — `at` = 격자 줄 → 일위대가 줄 번호(담은 로직 뒤 자재) */ +export function tipBorn( + root: HTMLElement, + at: (row: number) => number | null, + born: (n: number) => unknown, +): void { + root.querySelectorAll("tr[data-i]").forEach((tr) => { + const n = at(Number(tr.dataset.i)); + const [, ...tip] = n === null ? [] : bornText(born(n)).split(SPLIT); + if (tip.length) + tr.querySelector("td:nth-last-child(2)")?.setAttribute("title", tip.join(" ")); + }); +} + +/** 줄 표 이름 끝 꼬리표 — `본뜬` 이 없으면 null */ +export function bornSpan(born: unknown): HTMLElement | null { + const [shown, ...tip] = bornText(born).split(SPLIT); + if (!shown) return null; + return el("span", { + className: "m02-labrow__born", + text: ` ${shown}`, + attrs: { title: tip.join(" "), "data-born": "" }, + }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index a0315acaa..bfb4b5a62 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -44,6 +44,7 @@ import { type LaborQty, type LaborRow, type MatMarkKey, + originText, } from "./M02_MasterTemplete_Labor_Api"; import { buildLaborDiagram, @@ -567,7 +568,7 @@ export function mountLaborDetail( ); drawUnit(); const origin = createInputField({ type: "text", label: tc("Labor_Origin") }); - origin.input.value = cur.본뜬?.키 ? `${cur.본뜬.층} ${cur.본뜬.키}` : tc("Origin_Self"); + origin.input.value = originText(cur.본뜬) ?? tc("Origin_Self"); origin.input.readOnly = true; const section = (title: string, button: HTMLElement | null, body: HTMLElement): HTMLElement => diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts index 78070cdfa..260c6ad08 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts @@ -19,6 +19,7 @@ import { } from "@ui/ui_template_hopyo_grid"; import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch"; import type { LaborCalc, LaborCost, LaborDoc } from "./M02_MasterTemplete_Labor_Api"; +import { bornNote } from "./logic/M02_Born_Tag"; import { lineId } from "./M02_MasterTemplete_Labor_Calc"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import { valueLine } from "./M02_Labor_Values_Model"; @@ -79,7 +80,8 @@ export function laborBlock( amounts: money(line?.반영), note: line?.까닭 ?? - (calc && qty === null ? tc("Grid_NoQty") : row.참조 ? layerWord(row.참조.층) : row.로직), + (bornNote(row.본뜬) || // 740-2 넣은 줄 = 출처 글 + (calc && qty === null ? tc("Grid_NoQty") : row.참조 ? layerWord(row.참조.층) : row.로직)), tag: HOPYO_TAG.pum, }; }); @@ -101,7 +103,7 @@ export function laborBlock( ? tc("Grid_NoQty") : mat.별도계상 !== "직접계산" ? tc("Grid_Apart") // 짝 `settle` 의 aside 와 같은 규칙 - : mat.코드), + : bornNote(mat.본뜬) || mat.코드), }; }); const lines = [...logicLines, ...matLines]; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_MatTable.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_MatTable.ts index e4f785c45..7c6be0e7d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_MatTable.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_MatTable.ts @@ -13,6 +13,7 @@ import type { LaborCalcMat, LaborMat, } from "./M02_MasterTemplete_Labor_Api"; +import { bornSpan } from "./M02_MasterTemplete_Labor_Born"; import { markCells, markHeads } from "./M02_MasterTemplete_Labor_MatMarks"; import { moneyCell, @@ -66,7 +67,10 @@ export function matTable(data: MatTableData, on?: Omit): HT el("td", { className: "m02-labrow__name", children: [ - el("strong", { text: mat.이름 ?? "" }), + el("strong", { + text: mat.이름 ?? "", + children: [bornSpan(mat.본뜬)].filter((x): x is HTMLElement => !!x), + }), ...(line?.까닭 ? [el("small", { className: "ui-text ui-text--error", text: line.까닭 })] : []), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index eb0cceb42..ac137bf38 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -24,6 +24,7 @@ import type { LaborRow, MatMarkKey, } from "./M02_MasterTemplete_Labor_Api"; +import { bornSpan } from "./M02_MasterTemplete_Labor_Born"; import { lineId } from "./M02_MasterTemplete_Labor_Calc"; import { attachRowDrag } from "./M02_MasterTemplete_Labor_RowDrag"; import { tc } from "./M02_MasterTemplete_Labor_Text"; @@ -414,6 +415,7 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement }), el("strong", { text: fx?.이름 ?? (row.참조 ? line?.이름 : logic?.이름) ?? row.참조?.키 ?? row.로직, + children: [bornSpan(row.본뜬)].filter((x): x is HTMLElement => !!x), }), ...(values.empty ? [ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Table.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Table.ts index 6b035a2d8..0193d557a 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Table.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Table.ts @@ -31,9 +31,11 @@ import { type LaborCalc, type LaborDoc, type LaborRow, + originText, } from "./M02_MasterTemplete_Labor_Api"; import type { LaborDetailHandle, LaborDetailOptions } from "./M02_MasterTemplete_Labor_Detail"; import { laborBlock } from "./M02_MasterTemplete_Labor_Grid"; +import { tipBorn } from "./M02_MasterTemplete_Labor_Born"; import { unlinkedBar } from "./M02_MasterTemplete_Labor_Unlinked"; import { pasteInto } from "./M02_MasterTemplete_Labor_Paste"; import { openLogicPicker, withHopyo } from "./M02_MasterTemplete_Labor_Pick"; @@ -176,6 +178,7 @@ export function mountLaborTable( // 칸 초점이 표를 밀면(scroll) 메뉴가 바로 닫힘 — 밀림이 끝난 다음 그림에 엶 requestAnimationFrame(() => requestAnimationFrame(() => openTreeMenu(ev, menuAt(n)))); }); + tipBorn(grid.root, at, (n) => (cur.담은로직[n] ?? cur.자재[n - cur.담은로직.length])?.본뜬); // 740-2 const bar = unlinkedBar(cur, edit && ((qtyOnly) => changed(qtyOnly))); // 729-3 안 이은 줄 띠 gridHost.replaceChildren(...(bar ? [bar] : []), grid.root); showSource(); @@ -412,7 +415,7 @@ export function mountLaborTable( ); drawUnit(); const origin = createInputField({ type: "text", label: tc("Labor_Origin") }); - origin.input.value = cur.본뜬?.키 ? `${cur.본뜬.층} ${cur.본뜬.키}` : tc("Origin_Self"); + origin.input.value = originText(cur.본뜬) ?? tc("Origin_Self"); origin.input.readOnly = true; host.replaceChildren( diff --git a/M02_MasterTemplete/M02_MasterTemplete_Main_Actions.ts b/M02_MasterTemplete/M02_MasterTemplete_Main_Actions.ts new file mode 100644 index 000000000..4f02b668f --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Main_Actions.ts @@ -0,0 +1,156 @@ +/* ============================================================================= + * M02_MasterTemplete_Main_Actions.ts + * 단가산출 · 일위대가 상세 머리 단추 [초기화] [삭제](740-2 · 738-2 · 요구 579-3 · 581) — 차례 … [초기화] [삭제] [저장]. + * [삭제] = 735 경고 창(쓰는 곳 표 · 키) → 나무 지우기 → 목록으로 · 쓰는 곳 없으면 한 줄 물음. + * [초기화] = 서버 `-reset` 미리 보기(걷힐 고침 수) → 물음 → 그 층에 씀 → 다시 읽음. + * 덮개 · 뺀줄 · 더한줄 단가를 M01 키 값으로 되돌림 · 옵션(입력값) · 기본정보는 그대로(서버 740-1). + * 둘 다 쓰기 권한이 있는 층에서만 보임(`update`) · 상세에 저장 안 한 고침이 있으면 먼저 물음. + * ========================================================================== */ + +import { createButton, showToast } from "@ui/ui_template_elements"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { askConfirm } from "@ui/ui_template_modal"; +import { + editPrecomboTree, + fetchPrecomboTree, + resetLibrary, + type LibraryKind, +} from "./M02_MasterTemplete_Api_Fetch"; +import { askLibraryDelete } from "./M02_MasterTemplete_Library_Delete"; +import { precomboList } from "./M02_MasterTemplete_UI_Side_Precombo"; +import { sangeunList } from "./M02_MasterTemplete_UI_Side_Sangeun"; +import type { Selection } from "./M02_MasterTemplete_UI_Main"; + +const TEXT = { + Reset: ["초기화", "Reset"], + ResetTip: [ + "고친 값을 연결된 M01 키 값으로 되돌림 — 옵션 · 기본정보는 그대로", + "Revert edited values to their linked M01 key values — options and basic info stay", + ], + Delete: ["삭제", "Delete"], + DeleteTip: [ + "이 항목을 지움 — 쓰는 곳이 있으면 먼저 알림", + "Delete this item — warns first if used", + ], + ResetAsk: [ + "고친 값 {n} 개를 M01 값으로 되돌림{k} — 옵션 · 기본정보는 그대로", + "Revert {n} edited value(s) to M01 values{k} — options and basic info stay", + ], + Restored: [" (빼 둔 줄 {k} 개 되살림)", " ({k} removed line(s) restored)"], + Nothing: ["되돌릴 고침이 없음", "Nothing to revert"], + Done: ["M01 값으로 되돌렸음", "Reverted to M01 values"], + DeleteAsk: ["「{name}」 을(를) 지울까?", "Delete 「{name}」?"], + Deleted: ["지웠음", "Deleted"], + Failed: ["못 했음 — {why}", "Failed — {why}"], +} as const; +const tx = (key: keyof typeof TEXT): string => + String(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]); +const fill = (text: string, vars: Record): string => + Object.entries(vars).reduce((s, [k, v]) => s.replace(`{${k}}`, String(v)), text); + +export interface MainActionsCtx { + sel: () => Selection | null; + /** 지금 읽은 문서의 판 */ + version: () => string; + /** 지금 읽은 문서(저장본) — 뺀 줄 수를 셈 */ + loaded: () => unknown; + confirmLeave: () => Promise; + open: (sel: Selection | null) => Promise; + /** 좌측 목록을 다시 읽음 */ + changed: () => void; +} + +/** 문서 안 모든 `뺀줄` 길이 합(단가산출 · 일위대가 줄 안 덮개까지) */ +function removedCount(node: unknown): number { + if (Array.isArray(node)) return node.reduce((n, x) => n + removedCount(x), 0); + if (!node || typeof node !== "object") return 0; + return Object.entries(node as Record).reduce( + (n, [k, v]) => n + (k === "뺀줄" && Array.isArray(v) ? v.length : removedCount(v)), + 0, + ); +} +const why = (error: unknown): string => (error instanceof Error ? error.message : ""); + +export function createMainActions(ctx: MainActionsCtx): { + reset: HTMLButtonElement; + del: HTMLButtonElement; + update: (writable: boolean) => void; +} { + const reset = createButton({ label: tx("Reset"), variant: "ghost" }); + const del = createButton({ label: tx("Delete"), variant: "danger-ghost" }); + reset.title = tx("ResetTip"); + del.title = tx("DeleteTip"); + reset.dataset.mainReset = ""; + del.dataset.mainDelete = ""; + + const target = (): { at: Selection; kind: LibraryKind } | null => { + const at = ctx.sel(); + const kind = at?.kind; + return at && at.name && (kind === "sangeun" || kind === "precombo") ? { at, kind } : null; + }; + + reset.addEventListener("click", async () => { + const got = target(); + if (!got || !(await ctx.confirmLeave())) return; + const { at, kind } = got; + try { + const look = await resetLibrary(kind, at.layer, at.owner ?? null, { + 키: at.name, + 미리보기: true, + }); + if (!look.걷힘) return void showToast(tx("Nothing"), "success"); + const k = removedCount(ctx.loaded()); + const message = fill(tx("ResetAsk"), { + n: look.걷힘, + k: k ? fill(tx("Restored"), { k }) : "", + }); + if (!(await askConfirm({ message, okLabel: tx("Reset"), danger: true }))) return; + await resetLibrary(kind, at.layer, at.owner ?? null, { + 키: at.name, + 미리보기: false, + 판: ctx.version(), + }); + await ctx.open(at); // 다시 그림 + ctx.changed(); + showToast(tx("Done"), "success"); + } catch (error) { + showToast(fill(tx("Failed"), { why: why(error) }), "error"); + } + }); + + del.addEventListener("click", async () => { + const got = target(); + if (!got || !(await ctx.confirmLeave())) return; + const { at, kind } = got; + const owner = at.owner ?? null; + try { + const tree = await fetchPrecomboTree(at.layer, owner, kind); + const pick = { 경로: tree.자리?.[at.name] ?? [], 키: at.name }; + const ask = fill(tx("DeleteAsk"), { name: at.label ?? at.name }); + const ans = await askLibraryDelete(kind, at.layer, owner, [pick], ask, tx("Delete")); + if (!ans.ok) return; + await editPrecomboTree( + at.layer, + { op: "delete", 대상: [pick], ...(ans.sure ? { 확인: true } : {}) }, + tree.판, + owner, + kind, + ); + const scope = { layer: at.layer, owner, canWrite: true } as Parameters[0]; + await ctx.open(kind === "sangeun" ? sangeunList(scope) : precomboList(scope)); // 목록으로 + ctx.changed(); + showToast(tx("Deleted"), "success"); + } catch (error) { + showToast(fill(tx("Failed"), { why: why(error) }), "error"); + } + }); + + return { + reset, + del, + update: (writable) => { + const on = writable && !!target(); + reset.hidden = del.hidden = !on; + }, + }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py index 03633c2ac..4c1df9130 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py @@ -27,6 +27,7 @@ from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool, run_with_connection from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_Layer_Access as access +from M02_MasterTemplete import M02_Library_Flat as flat from M02_MasterTemplete import M02_MasterTemplete_Items as items_store from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router from M02_MasterTemplete import M02_MasterTemplete_Store as store @@ -313,6 +314,7 @@ async def read_layer_template( found["문서"] = refs.filled(kind, found["문서"]) # 옛 서고 문서 구분 · 상세구분(51-12) if layer != "project": # 옛 단가승격 참조 줄 = 새 꼴(134-2 · 프로젝트 사본은 그대로) found["문서"] = await asyncio.to_thread(refs.unpromote, kind, found["문서"]) + found["문서"] = await asyncio.to_thread(flat.flat_doc, kind, found["문서"]) # 740-1 return found diff --git a/M02_MasterTemplete/M02_MasterTemplete_Sangeun.css b/M02_MasterTemplete/M02_MasterTemplete_Sangeun.css index 8b20d7adc..88d5bf2f3 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Sangeun.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Sangeun.css @@ -52,10 +52,26 @@ font-weight: var(--font-weight-bold); } +/* 740-2 이름 + 출처 꼬리표 한 줄 — 이름이 말줄임으로 줄고 꼬리표는 그대로 */ +.m02-sk .m01lab__namerow { + display: flex; + align-items: center; + gap: var(--spacing-4); + min-width: 0; +} + +.m02-sk .m01lab__namerow > span:first-child { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + /* 727-2 기초 출처 꼬리표 — 칸 폭 고정 · 넘치면 말줄임(풍선 = 온 글) · 이름 칸 폭을 안 바꿈 */ .m02-sk .m01lab__src { display: inline-block; - max-width: 6em; + flex: none; + max-width: 9em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index d68699dba..cde030267 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -39,6 +39,7 @@ import { isPrecombo } from "./M02_MasterTemplete_UI_Side_Precombo"; import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet"; import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet_links"; import { createEmpty } from "@ui/ui_template_empty"; +import { createMainActions } from "./M02_MasterTemplete_Main_Actions"; export interface Selection { layer: Layer; projectId: string | null; @@ -141,6 +142,15 @@ export function createMain( const title = el("h2", { className: "m02-main__title", text: L("M02_PickTemplate") }); const reload = createButton({ label: L("M02_Reload"), variant: "ghost" }); const save = createButton({ label: L("M02_Save"), variant: "filled" }); + // 단가산출 · 일위대가 상세 [초기화] [삭제](740-2 · 738-2) — 차례 … [초기화] [삭제] [저장] + const actions = createMainActions({ + sel: () => sel, + version: () => version, + loaded: () => loaded, + confirmLeave: () => confirmLeave(), + open: (at) => open(at), + changed: () => onSaved?.(), + }); // 구조물 도면 · 일위대가 · 면적산출 상세 화면 → 구조물집계표(그 열 고른 채) · 일반 단추(43-5 · 알약 · ghost 아님) const back = createButton({ label: L("M02_Back") }); const helpSlot = el("span", { className: "m02-main__help" }); // 상세 [?](49-7) — 제목 바로 옆 @@ -171,6 +181,8 @@ export function createMain( helpSlot, slot, reload, + actions.reset, + actions.del, save, ...(extras.newItem ? [extras.newItem] : []), ], @@ -256,6 +268,7 @@ export function createMain( const bar = (): void => { reload.hidden = !sel; save.hidden = !sel || !writable(sel); + actions.update(!!sel && writable(sel)); back.hidden = !sel || !(sel.kind in NUMBER_FIELD); nav.update(back.hidden ? null : sel); // 보임 조건 = 뒤로 단추와 같음 const kind = sel && sel.kind in HELP_TITLE ? (sel.kind as keyof typeof HELP_TITLE) : null; diff --git a/M02_MasterTemplete/M02_Sangeun_Calc.py b/M02_MasterTemplete/M02_Sangeun_Calc.py index 307d5205f..63de29f2d 100644 --- a/M02_MasterTemplete/M02_Sangeun_Calc.py +++ b/M02_MasterTemplete/M02_Sangeun_Calc.py @@ -15,6 +15,8 @@ SK 단위 환산(728-2) = 배 = 바탕 기준단위(「100본」) → SK 단위( 「식」 · 빈 단위 = 앞 수 뗀 바탕 단위 = 옛 셈) · 다른 성격 = 문서 `환산` {계수 | 단위중량 {종별, 형상}} · 없으면 셈 막음(까닭) · 화면 셈 길 줄 · 계 = SK 1 단위 몫(`per_unit`). +바탕 재료(747-1) = 별도계상 빈 칸 = 직접계산(M01 기초처럼 재료비에 듦 · 「별도계상」 고른 줄만 밖) · +일위대가 덮개 줄(`line` · `M02_Labor_Patch.run`)은 일위대가 기본(222-1 · 162-1) 그대로. """ from __future__ import annotations @@ -126,7 +128,12 @@ def _base_of(doc: dict, whole: Any, read: Any, trail: tuple[str, ...]) -> dict[s def resolve( - doc: dict, whole: Any, read: Any = refs.read_ref, trail: tuple = (), given: dict | None = None + doc: dict, + whole: Any, + read: Any = refs.read_ref, + trail: tuple = (), + given: dict | None = None, + line: bool = False, ) -> dict[str, Any]: """SK 문서 → 고친 줄 `{줄, 고름, 자재표시, 정체, 짝잃음, 입력값, 켜쌓기율, 배, 바탕판}` · `trail` = 지나온 참조 이름표(순환 · 깊이 422) · `given` = 공통 옵션(더한줄 로직 입력).""" @@ -135,6 +142,8 @@ def resolve( doc, lack = _fill(doc, whole, common) got = sd.patched(base["줄"], doc, base["고름"], base["자재표시"]) items = got["줄"]["호표"] + if not line: + _direct(got["자재표시"], items) for at, cid in enumerate(got["정체"][: len(items)]): hit = re.match(r"^add:#(\d+)$", cid) if hit and int(hit[1]) in lack: @@ -155,6 +164,13 @@ def resolve( } +def _direct(marks: dict, items: list) -> None: + """SK 재료 줄 별도계상 빈 칸 = 직접계산(747-1 · 제자리) — 다른 두 칸은 그대로.""" + for n, item in enumerate(items): + if item.get("종류") == "재료": + marks[str(n)] = {"별도계상": "직접계산", **labor.marks(marks.get(str(n)))} + + # ── 못 셈 줄 = 그 줄만 빠짐(725-1 · 725-2) ───────────────────────────────── NO_QTY = "수량 없음" # `M02_Labor_Expand.NO_QTY` 와 같은 글(가져오기 둘레 피함) NO_QTY_WHY = "수량 없음 — 쓰는 자리에서 연결" @@ -216,6 +232,14 @@ def sub_run(one: dict, whole: Any, patched: bool = True) -> dict[str, Any]: row = whole.logic(code) except Exception as e: # noqa: BLE001 — 엔진 FormulaError return {"기준": None, "까닭": str(e), "정체": [], "짝잃음": [], "바탕판": ""} + if any(one.get(k) not in (None, {}, []) for k in sd.BODY): # 넣은 SK 몸(740-1) = 이름 없는 SK + keep = ("입력값", *sd.BODY, *(("덮개", "뺀줄") if patched else ())) + doc = {"키": "SK-000000", "이름": "줄", "단위": "식", "바탕": {"로직": code}} + doc |= {"덮개": {}, "뺀줄": [], "더한줄": [], **{k: one[k] for k in keep if k in one}} + try: + return {**run(doc, whole), "바탕판": fingerprint(row)} + except sd.DocError as e: + return {"기준": None, "까닭": str(e), "정체": [], "짝잃음": [], "바탕판": ""} got = sd.patched(row, one if patched else {}) over = _Over(whole, code, {**got["줄"], "키": code}) out = labor.run_line(over, code, one.get("입력값"), None, got["고름"] or None) @@ -241,7 +265,8 @@ def tag_of(whole: Any, code: str) -> str: def _detail(one: dict, at: int, whole: Any) -> dict[str, Any]: - """둘째 기초 세부 줄(화면) — 수량 · 금액 · 비목 = 세부 × 더한줄 수량 · cid `add:#k/세부 cid` · + """둘째 기초 세부 줄(화면) — 수량 · 금액 · 비목 = 세부 × 더한줄 수량(상자 합 몫) · `원문` = + 그 기초 1 기준단위 몫(M01 원문 품 · 744-1 화면 품 칸) · cid `add:#k/세부 cid` · `바탕값` = 덮기 전 1 기준단위 품 · 단가 · `뺀세부` = 뺀 줄 이름.""" sub = sub_run(one, whole) plain = sub_run(one, whole, False) if sd.sub_patched(one) else sub @@ -264,6 +289,7 @@ def _detail(one: dict, at: int, whole: Any) -> dict[str, Any]: **scaled, "비목": parts, "cid": f"add:#{at}/{cid}", + "원문": {k: line.get(k) for k in ("수량", "금액", "비목")}, "바탕값": _was(was[cid]) if cid in was else None, **({"덮개": [k for k in sd.COVER_KEYS if k in mine]} if mine else {}), **({"원값": mine["원값"]} if "원값" in mine else {}), @@ -301,13 +327,14 @@ def run( expand: Any = None, trail: tuple = (), detail: bool = False, + line: bool = False, ) -> dict[str, Any]: """SK 셈(저장 안 함) — `run_line` 답 + `{1단위, 배, 정체, 짝잃음, 바탕판}` · 줄마다 `cid` · 고친 칸 `덮개`(칸 이름) · `원값`. 틀린 문서 · 순환 = `sd.DocError` · - `detail` = 둘째 기초 세부 펼침(`_detail` · 화면 셈 길만).""" + `detail` = 둘째 기초 세부 펼침(`_detail` · 화면 셈 길만) · `line` = 일위대가 덮개 줄.""" from M02_MasterTemplete.M02_Labor_Expand import Expand - got = resolve(doc, whole, read, trail, given) + got = resolve(doc, whole, read, trail, given, line) row = {**got["줄"], "호표": [dict(x) for x in got["줄"]["호표"]]} _bundles(row, whole, expand or Expand(whole, read), trail) head = {k: got[k] for k in ("정체", "짝잃음", "바탕판")} | {"배": got["배"]} @@ -431,6 +458,8 @@ def clean_doc(doc: Any, here_: refs.Here) -> dict[str, Any]: base = _base_of(rough, whole, refs.read_ref, ()) out = sd.clean(doc, base["줄"], whole) out["바탕"], out["더한줄"] = rough["바탕"], rough["더한줄"] + for one in out["더한줄"]: # 처음수량(744-1) = 넣을 때 수량 · 없으면 지금 수량 + one.setdefault("처음수량", one["수량"]) if out["덮개"]: bare = run({**out, "덮개": {}}, whole) # 덮기 전 값 = 원값(그때 M01) was = {x["cid"]: x for x in bare.get("줄") or []} diff --git a/M02_MasterTemplete/M02_Sangeun_Doc.py b/M02_MasterTemplete/M02_Sangeun_Doc.py index 477d07e35..890daff6b 100644 --- a/M02_MasterTemplete/M02_Sangeun_Doc.py +++ b/M02_MasterTemplete/M02_Sangeun_Doc.py @@ -8,7 +8,8 @@ 덮개 {cid: {qty | 배율, labor, material, expense, 원값}}, 더한줄 [{종류: 인력|재료|기계, 요소, 이름, 단위, 수량, 비목?, 단가?, 별도계상?(재료 · 없음 = 계 안)} - | {종류: 로직, 로직, 입력값, 수량, 덮개?, 뺀줄?, 바탕판?} + | {종류: 로직, 로직, 입력값, 수량, 덮개?, 뺀줄?, 바탕판?, + 고름? · 자재표시? · 켜쌓기율? · 더한줄?(넣은 SK 몸 · 740-1) · 본뜬?(출처)} | {참조 | 고정, 이름?, 단위?, 수량}], 뺀줄 [cid], 바탕판, 환산?} SK 단위 환산(728-2) = `환산` {계수: 양수}(성격 다름 · 바탕 앞 수 뗀 1 단위 = SK 몇 단위) | @@ -178,7 +179,7 @@ def _added(raw: Any, where: str, whole: Any) -> dict: if row is not None: head["입력값"] = {k: v for k, v in given.items() if k in names} name = name or str(row.get("이름") or code) - head |= _sub_patch(raw, where) + head |= _sub_patch(raw, where) | _sub_body(raw, where, whole) elif raw.get("종류") in ADD_PART: key = str(raw.get("요소") or "") got = _element(whole, key, where) @@ -194,7 +195,24 @@ def _added(raw: Any, where: str, whole: Any) -> dict: unit = unit or str((got or {}).get("단위") or "") else: raise DocError(f"{where} · 종류는 인력 · 재료 · 기계 · 로직 · 참조 · 고정") - return {**head, "이름": name, "단위": unit, "수량": qty} + mark = {"본뜬": raw["본뜬"]} if isinstance(raw.get("본뜬"), dict) else {} # 출처(740-1) + if raw.get("처음수량") is not None: # 넣을 때 수량(744-1 [되돌림] 값) + mark["처음수량"] = plain(_num(raw["처음수량"], f"{where} 처음수량")) + return {**head, "이름": name, "단위": unit, "수량": qty, **mark} + + +def _gone_adds(raw: Any, whole: Any) -> list[dict]: + """뺀 더한줄 기억(744-1 · [되살림]) `[{차례, 줄}]` — 셈 안 함 · 못 읽는 줄은 버림.""" + out = [] + for one in raw if isinstance(raw, list) else []: + at = one.get("차례") if isinstance(one, dict) else None + if not isinstance(at, int) or isinstance(at, bool) or at < 0: + continue + try: + out.append({"차례": at, "줄": _added(one.get("줄"), "뺀 더한줄", whole)}) + except DocError: + continue + return out def _sub_patch(raw: dict, where: str) -> dict: @@ -210,9 +228,29 @@ def _sub_patch(raw: dict, where: str) -> dict: return {**({"덮개": covers} if covers else {}), **({"뺀줄": drop} if drop else {})} +BODY = ("고름", "자재표시", "켜쌓기율", "더한줄") # 넣은 SK 몸(740-1 · `M02_Library_Flat`) + + +def _sub_body(raw: dict, where: str, whole: Any) -> dict: + """넣은 SK 한 줄(740-1) — 그 SK 의 고름 · 자재표시 · 켜쌓기율 · 더한줄(M01 키만) · `본뜬`(출처 + 정보 · 셈에 안 씀) · 빈 칸은 안 둠.""" + out: dict[str, Any] = {} + if isinstance(raw.get("본뜬"), dict): + out["본뜬"] = raw["본뜬"] + if not any(raw.get(k) not in (None, {}, []) for k in BODY): + return out + body = {k: raw.get(k) for k in BODY if raw.get(k) not in (None, {}, [])} + doc = {"키": "SK-000000", "이름": "줄", "단위": "식", "바탕": {"로직": raw.get("로직")}, **body} + try: + got = clean(doc, None, whole) + except DocError as e: + raise DocError(f"{where} · {e}") from e + return out | {k: got[k] for k in BODY if got.get(k) not in (None, {}, [])} + + def sub_patched(one: dict) -> bool: - """고친 둘째 기초 — 더한줄 로직에 세부 덮개 · 뺀줄.""" - return one.get("종류") == "로직" and bool(one.get("덮개") or one.get("뺀줄")) + """고친 둘째 기초 — 더한줄 로직에 세부 덮개 · 뺀줄 · 넣은 SK 몸(740-1).""" + return one.get("종류") == "로직" and any(one.get(k) for k in ("덮개", "뺀줄", *BODY)) def _added_names(whole: Any, adds: Any) -> set[str]: @@ -305,6 +343,8 @@ def clean(doc: Any, base_row: dict | None = None, whole: Any = None) -> dict[str if len(set(drop)) != len(drop): raise DocError("뺀줄에 같은 줄 두 번") out["뺀줄"] = drop + if gone := _gone_adds(doc.get("뺀더한줄"), whole): + out["뺀더한줄"] = gone return out diff --git a/M02_MasterTemplete/logic/M02_Born_Tag.ts b/M02_MasterTemplete/logic/M02_Born_Tag.ts new file mode 100644 index 000000000..fb6021aae --- /dev/null +++ b/M02_MasterTemplete/logic/M02_Born_Tag.ts @@ -0,0 +1,53 @@ +/* ============================================================================= + * M02_Born_Tag.ts + * 넣은 줄 출처 꼬리표(740-2 · 요구 581) — 서버 `본뜬 {층, 주인, 키, 이름, 때}`(740-1) → 화면 글. + * 순수 계산 — 화면 · fetch 없음. 첫 줄 = 줄 옆에 보이는 짧은 글 · 둘째 줄 = 풍선(키 · 이름 · 층 · 때). + * 공용 상세 꼬리표 칸(`LogicLab_Ho` sourceTag)이 「\n」 앞만 보이고 전체를 풍선으로 씀. + * ========================================================================== */ + +export interface Born { + 층?: string | null; + 주인?: string | null; + 키?: string | null; + 이름?: string | null; + 때?: string | null; +} + +const LAYER: Record = { + system: "마스터", + company: "회사", + personal: "개인", + project: "프로젝트", +}; + +/** 서버 때(UTC) → 이 컴퓨터 시각 「2026-11-02 20:13」 · 읽을 수 없으면 "" */ +function localTime(at: unknown): string { + const d = new Date(String(at ?? "")); + if (Number.isNaN(d.getTime())) return ""; + const z = (n: number): string => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${z(d.getMonth() + 1)}-${z(d.getDate())} ${z(d.getHours())}:${z(d.getMinutes())}`; +} + +/** `본뜬` 한 벌 → 꼬리표 글(없으면 ""). `before` = 이미 있는 꼬리표(기초 원문번호) — 앞에 둠 */ +export function bornText(born: unknown, before = ""): string { + const b = born && typeof born === "object" ? (born as Born) : null; + if (!b || (!b.키 && !b.이름)) return before; + const shown = `${before ? `${before} · ` : ""}본뜬 ${b.키 ?? b.이름}`; + const layer = LAYER[String(b.층 ?? "")] ?? String(b.층 ?? ""); + const tip = [`${b.키 ?? ""} ${b.이름 ?? ""}`.trim(), layer, localTime(b.때)] + .filter(Boolean) + .join(" · "); + return `${shown}\n${tip}`; +} + +/** 단가산출 줄 열쇠(`add:#k` · `add:#k/…`) → 그 더한줄의 꼬리표 글 */ +export function bornOfCid(cid: string, adds: readonly object[] | undefined, before = ""): string { + const hit = /^add:#(\d+)/.exec(cid); + return bornText( + hit ? (adds?.[Number(hit[1])] as { 본뜬?: unknown } | undefined)?.본뜬 : undefined, + before, + ); +} + +/** 격자 비고 칸 글 — 「본뜬 SK-000012」 · 없으면 "" (첫 줄만) */ +export const bornNote = (born: unknown): string => bornText(born).split("\n")[0]; diff --git a/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts b/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts index 9a871b294..de6ae19bd 100644 --- a/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts +++ b/M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts @@ -13,6 +13,7 @@ * 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장). * ========================================================================== */ +import { bornOfCid } from "./M02_Born_Tag"; import type { SheetGroup } from "../../A00_Common/calc_sheet"; export type CoverPart = "labor" | "material" | "expense"; @@ -470,7 +471,8 @@ export function hoView( const l = lineBy.get(cid); const name = l?.이름 ?? cid; const at = subOf(cid); - const tag = tags.has(cid) ? { 꼬리표: tags.get(cid) } : {}; + const born = bornOfCid(cid, doc.더한줄, tags.get(cid) ?? ""); // 넣은 줄 출처(740-2) — 기초 번호 뒤에 붙임 + const tag = born ? { 꼬리표: born } : {}; if ((at?.cid ?? cid).startsWith("pct:")) { extra.push([cid, { ...((known.get(cid) as HoExtra) ?? { 이름: name, 식: "" }), ...tag }]); continue; diff --git a/docs/raw/요구_1102/_분석_585_항목별맞춤.md b/docs/raw/요구_1102/_분석_585_항목별맞춤.md new file mode 100644 index 000000000..3187baf54 --- /dev/null +++ b/docs/raw/요구_1102/_분석_585_항목별맞춤.md @@ -0,0 +1,192 @@ +# 분석 585 — 일위대가 불러오기 맞춤을 「항목 하나하나 ↔ M01 요소 · 기초」로 (조사 · 읽기만) + +조사 = 코드 따라감 + 여덟 파일 산근 블록(일위대가가 부르는 첫 줄 · 206) · 골든 정답 177 줄을 지금 함수 그대로 불러 셈(`tmp/brain_585/probe585.py` → `out585.json` · 모음 `agg585.py` · 우리 지문 캐시 = 사본 `tmp/brain_585/cache`). 코드 · storage · git 안 고침. + +## 0. 요약 + +1. 일위대가(호표)는 지금도 **줄마다** 맞춤 — 자재 → MT · 노임 → LB · 기계 → EQ · 호표 → 하위 · 단가산출 → 그 산근 블록. 「일위대가 = 한 로직 그룹」은 아님. +2. 덩어리로 다루는 자리 = 산근 블록 **판정**(블록 통째 0.5 % · 3 % 문턱) · 후보 블록(줄 견줌 없음 · 고름 대기 89) · 못옮김(요소 후보 없음) · 직접 줄 호표 통째 제안. +3. 블록 찾기는 이미 요소 벡터(직종 = LB 이름 · 장비 = EQ 갈래 · 자재 낱말)를 가장 무겁게 씀 · 절마다 조합 · 드문 요소 색인까지 있음. 다른 점 블록은 이미 줄 단위 차이 SK(729-1). +4. 요소로 기초를 거꾸로 찾으면 **나빠짐** — 골든 1위 163 → 107(요소만) · 36(색인) · 144(거름) — 같은 요소 집합 기초가 중앙 10 개 · 가르는 것은 이름 · 조항. +5. 후보 블록에 1위 기초와 줄 견줌을 넓히면 **좋아짐** — 고름 대기 89 중 72 블록(부르는 줄 318 중 272)에 항목별 비교가 생김 · 골든 1위 맞음 42 / 47(89 %) · 꼴 = 같은 항목 · 다른 품 25 · 엑셀이 더함 18 · 우리만 14 · 더함 + 뺌 12. +6. 「같은 줄 비율」은 판정 문턱으로 못 씀(SK · 후보 모두 중앙 0 — 품이 다 다름) · 블록 줄 → 요소 키는 인력 95 % · 장비 32 % · 자재 10 %(후보만 77). +7. 자동 이어짐 50.5 % · 골든 1위 · 판정 수는 그대로(582-2 · 584-3 · 743 · 739 · 740 과 맞음) — 얻는 것 = 사용자 고르는 걸음. +8. 권함 = 일감 셋(고름 대기 줄 비교 서버 · 그 화면 · 못옮김 · 기계 요소 후보) · 찾기 · 판정 문턱은 안 바꿈 · 물을 갈래 하나(D-4). + +--- + +## A. 지금 맞춤이 무엇을 단위로 견주나 + +### A-1. 단계 + +| # | 단계 | 무엇 ↔ 무엇 | 신호 | 결과 갈래 | 근거 | +| --- | --- | --- | --- | --- | --- | +| 1 | 일위대가 줄 가름 | **일위대가 줄 하나하나** → 갈래(% · 호표 · 노임 · 자재 · 별도계상 · 단가산출 · 기계) | 비고 표지(단산N · 일위N) · 단위 · 직종 이름 | 줄마다 다른 길 | `M02_Hopyo_Line.py:244-283` · `M02_Hopyo_Finger.py:303-309` | +| 2 | 자재 · 별도계상 줄 | 줄 하나 ↔ **M01 자재(MT)** | 이름 · 규격 같음 → 바탕 이름 · 규격 수 같은 한 품목 | 맞음(체크) · 후보 · 없음 | `Line.py:207-232` · `M02_Hopyo_Material.py:206` | +| 3 | 노임 줄 | 줄 하나 ↔ **M01 인력(LB)** | 이름(옛이름 · 괄호 풂) 같은 LB 하나 | 이음(만들기 때) · 못 이음 = 남은 줄 | `M02_Hopyo_Fixed.py:91-98` | +| 4 | 기계 줄 | 줄 하나 ↔ **M01 기계(EQ)** + 기계경비 기초 | 이름 · 규격 글 같음 › 갈래 + 규격 수 · 블록 없는 기계 지문(1 시간) | EQ 이음 · 판정 | `Fixed.py:105-116` · `Finger.py:145-155` | +| 5 | 호표 줄 | 줄 하나 ↔ 확인된 하위 일위대가 | 번 · 단위 환산 | 참조 | `Line.py:251-260` | +| 6 | 단가산출 줄 | 줄 하나 → 그 **산근 블록 통째** ↔ **기초 하나**(+ 절 조합) | 블록 지문 = 직종 품(LB 이름) · 장비 갈래 · 규격 · 시간(EQ 갈래 낱말) · 자재 낱말 · 율 · 조항 · 조건 · 이름 · 단위 | 순위 다섯 | `Fit.py:303-326`(셈 단위 = 블록 + 줄) · `Finger.py:158-235` · `Fit.py:190-224` | +| 6-1 | 순위 점수 | 블록 요소 벡터 ↔ 기초 요소 벡터(변형마다) | 인력 3 · 장비 2 · 조항 1.5 · 자재 1 · 이름 0.6 · 조건 0.6 · 단위 0.5 · 율 0.3 + 이름 · 근거 차례 덤 | 위 다섯 · 같음 1위 고정 | `Fit.py:35-44` · `:255-264` · `Verdict.py:111-120` | +| 6-2 | 절 조합 | 블록 **절(1. 2. 가. 나.)마다** 노임 · 장비 벡터 ↔ 기초 | 1위가 못 덮는 절(< 0.6) = 절 1위 · 몫 · 장비만 든 절 = 기계 시간당 기계경비 · **드문 직종 · 장비 색인**(요소 → 기초) | 조합(덧 로직) | `Combine.py:178-184` · `:240-300` | +| 6-3 | 판정 | 1위 기초 옵션 거꾸로 찾은 품 ↔ 엑셀 품(**블록 통째 문턱**) | 수준 ≤ 1(0.5 % + 반올림) 맞음 · 수준 2(3 %) 또는 직종 같음 + 조항 같음 = 다른 점 · 그 밖 후보 · 숫자 하나뿐 = 후보 | 맞음 · 다른 점 · 후보 | `Verdict.py:80-88` · `Fit.py:267-272` | +| 6-4 | 체크 제안 | 판정 | 맞음 + 환산 배 확실 | 체크 · 안 함 | `Line.py:139-150` | +| 7 | 단가산출 만들기 | 블록(부르는 **첫** 줄의 맞춤셈) | 판정 맞음 = 기초 · 다른 점 = 1위 기초 + **줄 단위 차이**(직종 · 장비 · 자재마다 덮개 · 더한줄 · 뺀줄 · 못옮김) · 후보 = 고름 대기(SK 안 만듦) | 기초 · SK · 고름 대기 | `Sangeun.py:85-95` · `:203-214` · `SkPatch.py:343-406` | +| 7-1 | 줄 단위 차이 | 엑셀 블록 직종 · 장비 · 자재 줄 ↔ 기초 셈 줄(cid) | 직종 이름 = LB 키 · 엑셀만 장비 = 기계경비 로직 + EQ(옵션 거꾸로) · 엑셀만 자재 = **못옮김(MT 찾기 없음 · 후보 [])** | 덮개 · 더한줄 · 뺀줄 · 못옮김 | `SkPatch.py:184-215` · `:248-263` · `:265-282` | +| 8 | 직접 줄 호표 | **호표 통째**(노임 · 기계 줄 수량 합) ↔ 기초 하나 | 같은 지문 · 판정 | 통째 제안(줄 맞춤과 따로) | `Finger.py:279-300` · `Line.py:373-389` | +| 9 | 일위대가 만들기 | 확인 안 한 단가산출 줄 → 그 블록 SK 참조 · 노임 → LB · 기계 → EQ | 남은 줄 = 409 | 이음 · 남은 줄 | `SkLink.py:61-94` · `Fixed.py:71-140` | + +### A-2. 사용자 걱정 「일위대가를 하나의 로직 그룹으로」 — 맞는 곳 · 안 맞는 곳 + +| 자리 | 지금 | 걱정과 | +| --- | --- | --- | +| 일위대가(호표) 한 장 | 줄마다 따로 맞춤(1~5 · 9) — 호표 통째를 기초 하나에 견주지 않음 | **안 맞음** — 이미 항목별 | +| 일위대가 자재 · 노임 · 기계 줄 | 줄 하나 ↔ M01 MT · LB · EQ 직접(2 · 3 · 4) | **안 맞음** — 사용자 안 (1) 이 이미 있음 | +| 산근(단가산출) 블록 · 찾기 | 블록을 요소 벡터(직종 = LB 이름 · 장비 = EQ 갈래 · 자재 낱말)로 풀어 기초 요소 벡터와 견줌 · 절마다 조합 · 드문 요소 색인 | 반쯤 — 「통째 덩어리」가 아니라 요소 벡터 · 다만 줄을 **M01 키**로 잇지는 않음(이름 · 낱말) | +| 산근 블록 · 판정 | **블록 통째 문턱** — 한 직종 품이 3 % 넘게 달라도 블록 전체가 다른 점 · 후보 | **맞음** — 이 자리가 「그룹」 | +| 후보(고름 대기) 블록 | 줄 단위 견줌을 아예 안 함(SK 안 만듦 · 「맞는 기초 없음」) | **맞음** — 같은 줄 몇 개인지 안 보임 | +| 다른 점 블록 | 1위 기초 + 줄 단위 차이 SK(729-1) | 안 맞음 — 이미 항목별 차이 | +| 못옮김(엑셀만 자재 · 못 찾은 장비 · 우리에 없는 직종) | 후보 [] — M01 요소를 찾아 주지 않음 | **맞음** — 줄 → 요소 잇기 빠짐 | +| 직접 줄 호표 | 호표 통째 ↔ 기초 하나 제안(줄 맞춤과 함께) | 맞음 — 다만 줄마다 LB · EQ 이음도 같이 돎 | +| 블록을 부르는 둘째 줄부터 | 만들기는 **첫 줄** 맞춤셈만 씀 | 작음 | + +→ 「일위대가 = 한 덩어리」는 아님. 덩어리로 다루는 자리 = **산근 블록 판정 문턱 · 후보 블록 · 못옮김 · 직접 줄 호표 통째** 넷. + +--- + +## B. 사용자 안 구체화 — 항목(줄) ↔ M01 요소 · 기초 + +### B-1. 세 꼴 + +| # | 꼴 | 지금 있나 | 새로 할 것 | +| --- | --- | --- | --- | +| (1) | 일위대가 줄 → M01 요소 직접(노임 이름 · 단가 · 자재 이름 · 규격 · 기계) | **있음**(A-1 2 · 3 · 4 · 729-2) | 없음(실측 C-5 로 남은 몫만 확인) | +| (2) | 산근 블록 안 줄 → M01 요소 키(LB · EQ · MT) 먼저 → 요소 묶음 · 품 비율로 기초를 거꾸로 찾음 | 반쯤 — 지문이 곧 요소 벡터 · 절 색인(`Combine.pool_for`) · 키로 잇지는 않음 | 줄마다 키 잇기(EQ · MT) · 요소 색인 순위 | +| (3) | 블록 = 기초 + 더한 줄 → 차이를 줄 단위로 | **있음**(다른 점 → SK · 729-1) | 후보 블록에도 1위 기초와 줄 단위 견줌 · 못옮김에 MT 후보 | + +### B-2. 판정에서 바꿀 자리(안) + +| 자리 | 지금 | 안 | +| --- | --- | --- | +| `Verdict._state`(`:80-88`) | 블록 통째 수준 · 다른 점 = 수준 2 또는 직종 같음 + 조항 같음 | 그대로(찾기 · 맞음 문턱 안 건드림) | +| `SkPatch.plan` 후보 길(`:362-366`) | 후보 = `_wait`(줄 견줌 없음) | 후보도 1위(다섯)와 줄 단위 견줌 → 「같은 줄 n / 엑셀 줄 m」 · 차이 줄을 고름 대기 응답에 붙임(만들지 않음) | +| `SkPatch._Plan.materials` · `miss`(`:265-282` · `:152-158`) | 엑셀만 자재 = 못옮김 · 후보 [] | 못옮김 `후보` = M01 자재(MT) 찾기(`Material.sure` · `candidates`) | +| `SkPatch._add_machine`(`:248-263`) | 기계경비 로직 수준 ≤ 1 못 찾으면 못옮김 | 못옮김 `후보` = EQ 갈래 같은 것 | +| 고르기 창(고름 대기 · 다른 점) | 기초 후보 다섯 · 판정 글 | 후보마다 「같은 줄 · 다른 줄 · 엑셀만 · 우리만」 줄 표(같은 응답 칸) | + +--- + +## C. 실측 — 여덟 파일 · 골든 177 + +### C-1. 바탕(지금) — 729 기록과 같음 + +| 잼 | 수 | +| --- | --- | +| 산근 블록(일위대가가 부름) | 206 = 기초 80 · SK 37 · 고름 대기 89(729-4 기록 그대로) · 부르는 일위대가 줄 280 · 137 · 318 | +| 고름 대기 까닭 | 맞는 기초 없음 83 · 단위 성격 다름 5 · 숫자 없음 1 · 상태 = 후보 84 · 다른 점 5 | +| 못옮김 | SK 8 블록 9 줄(자재 8 · 기계 1) · 부르는 줄 15 | +| 골든 1위 | 163 / 177(92.1 %) · 다섯 안 173 · 상태별 1위 맞음 = 맞음 76/78 · 다른 점 42/45 · 후보 45/54 | +| 골든 갈래 | 기초 79 · SK 36 · 고름 대기 62 — **고름 대기 62 중 1위 맞음 53** | + +### C-2. 블록 안 줄 → M01 요소 키(B-1 (2) 앞 단계) + +| 갈래 | 줄 | 유일 | 여럿 · 후보만 | 없음 | 까닭 | +| --- | --- | --- | --- | --- | --- | +| 인력 → LB | 314 | 299(95 %) | — | 15 | 15 = 「작업반장」 LB 이름 둘(키 2) | +| 장비 → EQ(갈래 + 규격 수) | 183 | 59(32 %) | 여럿 114 · 갈래만 10 | — | 갈래 낱말이 타이어 · 무한궤도 · 습지를 못 가름 · EQ 630 중 361 이 같은 (갈래, 규격 수) 무리 | +| 자재 → MT | 89 | 9(10 %) | 후보만 77 | 3 | 블록 글 자재는 「이름 규격」이 한 칸 · 지역 · 관급 변형 | +| 합 | 586 | 367(63 %) | 201 | 18 | | + +- 일위대가 줄(사용자 안 (1) · 지금 길): 노임 105 → LB 105(100 %) · 기계 36 → EQ 4(11 %) · 자재 219 → 맞음 91 · 별도계상 135 → 맞음 56(지역 변형) · 나머지 = 후보 · 없음. +- 기계 줄 EQ 시험(이름 낱말 덤 · 규격 수 겹침): 4 → 18 / 36 — 「대형브레이카 + 굴착기(0.7㎥)」 처럼 한 줄 두 장비 · 「타이어형 ↔ 타이어」 낱말 다름이 남음(정밀도 정답 없음 · 시험만). + +### C-3. 요소로 기초를 거꾸로 찾기(B-1 (2)) — 골든 1위 + +| 찾기 | 1위 | 다섯 안 | 지금과 | +| --- | --- | --- | --- | +| 지금(요소 벡터 + 이름 · 조항 · 조건 · 단위) | **163** | 173 | — | +| 요소만(인력 · 장비 · 자재 채널 · 품 비율 · 무게 그대로) | 107 | 142 | 고침 2 · 깨짐 58 | +| 요소 색인(품 없이 직종 ∪ 장비 갈래 Jaccard) | 36 | 84 | — | +| 지금 + 드문 요소 겹침으로 거름 | 144 | — | −19 | + +- 까닭 = 요소 지문이 겹침 — 같은 요소 집합(직종 ∪ 장비 갈래)을 가진 기초 수 중앙 **10** · 10 넘는 줄 91 / 177 · 하나뿐 64 · 숫자 하나뿐 줄(보통인부 하나 등) 26. +- 요소가 같은 기초 사이를 가르는 것은 이름 · 인용 조항 · 조건 — 지금 점수가 이미 요소 벡터(인력 3 · 장비 2 · 자재 1)를 가장 무겁게 씀(`Fit.py:35-44`). + +### C-4. 줄 단위 견줌을 후보(고름 대기)로 넓힘(B-1 (3)) — 1위 기초에 `SkPatch.plan(pick = 1위)` + +| 고름 대기 89 블록 → 1위와 줄 견줌 | 블록 | 부르는 줄 | +| --- | --- | --- | +| 모두 같음(셋 다 엑셀 줄 하나 · 숫자 하나뿐 규칙으로 후보 — 흙깎기 · 잔토처리 · 구조물터파기) | 3 | 13 | +| 구성 같음 · 품만 다름(덮개만) | 25 | 75 | +| 엑셀이 줄 더함(더한줄 · 「기초 + 더한 항목」) | 18 | 89 | +| 우리만 줄 있음(뺀줄) | 14 | 49 | +| 더함 + 뺌 | 12 | 46 | +| 견줌 못함(단위 성격 다름 — 100개 ↔ ㎡ · 본 ↔ M · ㎡ ↔ 조 …) | 17 | 46 | + +- 골든 고름 대기 62 → 견줌 47 · **1위 맞음 42(89 %)** — 모두 같음 2/2 · 구성 같음 10/12 · 엑셀이 더함 14/15 · 우리만 11/12 · 더함 + 뺌 5/6 · 견줌 못함 15(1위 맞음 11). +- 견준 줄 「같은 줄 수」는 문턱으로 못 씀 — 고름 대기 61 · 지금 SK 도 중앙 0(직종 · 장비는 같고 품이 다 다름 = 품셈 판 · 설계자 품) · 같은 줄 ≥ 1 로 거르면 6 줄만 남음. +- 지금 SK 37 꼴 = 구성 같음 13 · 엑셀이 더함 13 · 더함 + 뺌 8 · 우리만 3 → 「기초 + 더한 항목」(사용자 그림)은 SK · 후보 모두에서 셋 중 하나쯤 · 가장 많은 꼴 = 「같은 항목 · 다른 품」. + +### C-5. 못옮김 → M01 요소(B-2 둘째 줄) + +- 못옮김 9 = 자재 8(MT 후보 있음 7 · 없음 1) · 기계 1 → 후보를 붙이면 7 이 한 번 고름 · 부르는 일위대가 줄 15. + +### C-6. 일위대가 남은 줄 · 수 변화 + +| 잼 | 지금 | 안(B-2 · 만들지 않음) | 까닭 | +| --- | --- | --- | --- | +| 자동 이어짐(일위대가 줄) | 50.5 % | **같음** | 582-2 「완벽히 고른 경우에만」 — 안은 보여주기 · 후보 · 미리 셈만 | +| 고름 대기 블록에 줄 비교가 보임 | 0 / 89 | 72 / 89 | 견줌 못함 17 = 단위 계수 고름(지금 길) | +| 그 블록을 부르는 일위대가 줄 | 318 줄 = 후보 다섯 중 고름 | 272 줄 = 1위 줄 비교 보고 한 번 누름 | 누르면 SK 참조로 이어짐(729-2 길) | +| 못옮김 후보 | 0 | 7 / 9 | | +| 골든 1위 | 163 | 163 | 찾기 안 바꿈 | +| 「다른 점 · 후보 · 고름 대기」 판정 수 | 45 · 54 · 62(골든) | 그대로(가) · 고름 대기 → 다른 점(줄) 47(나) | D-4 | + +### C-7. 좋아지는 곳 · 나빠지는 곳 + +| | 곳 | 까닭 | +| --- | --- | --- | +| 좋아짐 | 고름 대기 72 블록 · 318 줄 | 「맞는 기초 없음 — 후보 5」 대신 1위 기초와 항목마다 같음 · 품 다름 · 엑셀만 · 우리만 · 누르면 곧 SK(엑셀 품 보존) — 1위 맞음 89 % | +| 그대로 | 숫자 하나뿐 줄 3 블록 | 줄 단위로 모두 같으나 한 줄이라 우연 맞음 — 지금 막음(`Fit.single`) 그대로 둠 | +| 좋아짐 | 못옮김 7 · 기계 줄 EQ | 요소 후보 · 낱말 덤 | +| 나빠짐(안 함) | 찾기를 요소 지문으로 바꿈 | 골든 1위 163 → 107 · 거름만 넣어도 144 — 요소 같은 기초가 10 개쯤 | +| 나빠짐(안 함) | 「같은 줄 비율」로 판정 문턱 | 거의 모든 블록이 0 — 판정이 갈리지 않음 | +| 주의 | 1위 틀림 5 / 47 을 누르면 바탕이 틀린 SK | 값은 엑셀 품 그대로지만 740 [초기화] 는 바탕 M01 기초 값으로 돌아감 → 누름은 사용자 몫으로 둠 | +| 셈 시간 | 고름 대기 블록마다 엔진 견줌 한 번 더 | 측정 안 함(지금 북부청 맞춤 셈 35~52 초) | + +--- + +## D. 권함 + +### D-1. 넣을 것 · 안 넣을 것 + +- **넣음** — 판정 뒤 단계(만들기 앞 보여주기)에만: ① 고름 대기 블록에 1위 기초(고르면 그 후보)와 항목별 줄 비교 ② 못옮김 · 남은 기계 줄에 M01 요소 후보. +- **안 넣음** — 찾기(순위)를 요소 지문 · 색인으로 바꾸기 · 판정 문턱을 「같은 줄 비율」로 바꾸기 · 일위대가 줄 → 요소(이미 있음). +- 사용자 걱정에 대한 답 = 일위대가는 이미 항목마다 맞춤 · 덩어리로 보는 자리는 산근 블록 판정 하나 → 거기에 항목별 비교를 붙이는 것이 사용자 안의 실효 부분. + +### D-2. 일감 + +| 일감 | 파일 | 단계 | 크기 | 시험 | +| --- | --- | --- | --- | --- | +| ① 고름 대기 줄 비교(서버) | `M02_Hopyo_SkPatch.py` `_wait`(`:312-319`) — 후보 1위에 `plan(pick)` 요약 `줄비교 {같음, 품다름[{갈래, 이름, 엑셀, 우리}], 엑셀만, 우리만, 못옮김}` · 만들지 않음 · `M02_Hopyo_Sangeun.build` 응답 `고름대기[].후보[0].줄비교` | [단가산출 만들기] · 남은 줄 | 서버 40 줄 안 | `test_729_hopyo_sk_link` · `test_718_1_hopyo_import` + 새 1(북부청 고름 대기 한 블록 줄비교 = 엑셀 품) | +| ② 고름 대기 창 줄 표(화면) | `M02_MasterTemplete_Hopyo_WizardSk.ts` · 고르기 창 `_Choose.ts` — 후보 옆 줄 표 · [이 기초로] = 지금 `바탕[번].로직` 몸 그대로 | 마법사 ② | 화면 60 줄 안 | `test_m02_hopyo_screen` · ORCA 사진 | +| ③ 못옮김 · 기계 요소 후보 | `SkPatch._Plan.miss` 부르는 곳(`:246` · `:263` · `:282`) 후보 = `Material.candidates` · EQ 갈래 + 규격 수 + 이름 낱말 · `M02_Hopyo_Fixed.Linker._machine`(`:105-116`) 낱말 덤 | 만들기 · 남은 줄 | 서버 20 줄 안 | `test_705_3_hopyo_fixed` · `test_729_hopyo_sk_link` | + +- 차례 = ① → ② (sub1 → sub5 · 745 미리 고름과 같은 고르기 창이라 745 와 묶음) · ③ 은 따로 작음. +- 곁 발견(585 밖 · 한 줄): 단위 모듈이 「hr ↔ 시간」 · 「개 ↔ 개소」를 성격 다름으로 봄(`master_unit.swap`) — 맞춤 쪽 `Fit.unit_fit` 은 같다고 봄 · 고름 대기 1~3 블록이 이것 탓. + +### D-3. 맞물림 + +| 제약 | 안과 | +| --- | --- | +| 582-2 사용자가 완벽히 고른 경우에만 만듦 | 맞음 — 자동 만들기 · 체크 없음 · 보여주기 · 후보만 | +| 584-3 미리 고름 = 다른 점만 | 고름 대기는 점 없음(가) — 올릴지는 D-4 | +| 584-2 · 743 불러온 엑셀 보관 안 함 | 맞음 — 줄 비교는 불러오기 동안 임시 자리 블록 글로만 셈 · 만든 SK 덮개 = 엑셀 품(값) · 본뜬 엑셀 사본은 743 이 걷음 · 끝낸 뒤 다시 맞춤 안 함 = 다시 불러옴 | +| 739 별칭 중앙 | 짝 = 기초까지만(그대로) · 줄 비교 · 못옮김 이음은 장부에 안 넣음(블록 안 줄은 일위대가 줄 열쇠가 아님 · 9 줄뿐) | +| 740 연결 = M01 키 | 맞음 — 줄 비교 결과가 곧 SK 덮개 · 더한줄(LB · 기계경비 로직 + EQ · MT) | + +### D-4. 사용자에게 물을 갈래(하나) + +「맞는 기초 없음」 단가산출 72 블록에 1위 기초와 항목별 비교를 보여줄 때: +- (가) 비교만 보여줌 · 미리 고름 점 없음 · 사용자가 보고 누름 — **권함**(지금 「다른 점」 정확도 93 % 보다 낮은 89 %) +- (나) 「다른 점」으로 올려 미리 고름 점까지(584-3 대상이 47 줄 늘어남) diff --git a/docs/raw/요구_1102/사용자요구_1102.md b/docs/raw/요구_1102/사용자요구_1102.md index b365fd990..7f6fbf65c 100644 --- a/docs/raw/요구_1102/사용자요구_1102.md +++ b/docs/raw/요구_1102/사용자요구_1102.md @@ -113,3 +113,11 @@ 또하나 일위대가 가져오기의 매칭과 관련하여 아이디어 검토 - 내가 생각했을때의 경쟁사 프로그램으로 출력한 엑셀파일에서 우리가 일위대가를 단순히 하나의 로직 그룹으로 보고 접근하게 문제가 되지 않나 생각되서 말이야. 우리의 기초로직은 M01에서 요소들과 단가산출 기초로 된걸 일위대가의 항목 하나하나와 비교하면 더 찾기 쉬운게 아닌가라는 생각이 듬. + +## 요구 586 (11-02 · 사용자) + +m01 단가산출 기초에는 하나의 단가산출 기초에는 정상적으로 자재부분도 수량이 들어가 있는데 복사해온 M02의 단가산출에는 재료비 항목에 전부 수량이 없어. 내 개인 m02 단가산출의 테스트1 항목을 6-3-1 합판거푸집 설치 및 해제를 보면 알거야. 확인해봐. + +## 요구 587 (11-02 · 사용자 · 586 이어서) + +시험은 테스트 1로 진행해. diff --git a/resources/tester/test_572_library_same.py b/resources/tester/test_572_library_same.py index 1dcb59ee4..ab67d6105 100644 --- a/resources/tester/test_572_library_same.py +++ b/resources/tester/test_572_library_same.py @@ -106,8 +106,8 @@ def test_같은층_위층_찾기_참조_지문(client): {"층": "system", "주인": None, "키": "SK-000006"} ] assert same.find_same(me, "sangeun", {**SK, "바탕": None, "더한줄": []}) == [] - # 참조 = 가리킨 문서 지문 — 층 넘어 같은 내용 사본을 가리키면 같음 + # 참조 = 펼친 꼴(740-1) 지문 — 층 넘어 같은 내용 사본을 가리키면 같음 · 다른 내용이면 다름 a = {**SK, "바탕": {"참조": {"층": "company", "주인": "7", "키": "SK-000005"}}, "덮개": {}} b = {**a, "바탕": {"참조": {"층": "system", "주인": None, "키": "SK-000006"}}} - c = {**a, "바탕": {"참조": {"층": "personal", "주인": "7/42", "키": "SK-000003"}}} + c = {**a, "바탕": {"참조": {"층": "personal", "주인": "7/42", "키": "SK-000004"}}} assert same.finger("sangeun", a) == same.finger("sangeun", b) != same.finger("sangeun", c) diff --git a/resources/tester/test_704_3_hopyo_grid.py b/resources/tester/test_704_3_hopyo_grid.py index 96bc0d304..fc847ea37 100644 --- a/resources/tester/test_704_3_hopyo_grid.py +++ b/resources/tester/test_704_3_hopyo_grid.py @@ -96,6 +96,7 @@ CALC = {"줄": [{"로직": "GF1", "분모": "㎡", "배": 10, "수량": 2.09, def test_M02_문서_calc_를_격자_줄로(): files = [GRID, "M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts", "M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts", + "M02_MasterTemplete/logic/M02_Born_Tag.ts", "M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts"] # fmt: skip logics = [ ["GF1", {"이름": "돌쌓기 찰쌓기", "입력": [], "결과단위": "㎡"}], @@ -146,6 +147,7 @@ def test_세_곳_같은_부품(): def test_번호는_글에_안_냄_사람_글은_그대로(): files = [GRID, "M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts", "M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts", + "M02_MasterTemplete/logic/M02_Born_Tag.ts", "M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts"] # fmt: skip ref = {"로직": "", "별칭": None, "입력값": {}, "수량": {"손": 1}, "참조": {"층": "personal", "주인": "1/3", "키": "SC-000002"}} # fmt: skip diff --git a/resources/tester/test_704_4_spec.py b/resources/tester/test_704_4_spec.py index 7d88c72a0..27471fdc1 100644 --- a/resources/tester/test_704_4_spec.py +++ b/resources/tester/test_704_4_spec.py @@ -176,6 +176,7 @@ def test_격자_규격_열_수량_칸_열마다(): files = [g.GRID, "M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts", "M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts", + "M02_MasterTemplete/logic/M02_Born_Tag.ts", "M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts"] # fmt: skip doc = {**g.DOC, "규격": {"이름": "H", "열": ["1.5", "2.0"], "기본": "2.0", "수량": {"GF1": {"1.5": 1.5, "2.0": 2.09}, "MT1": {"1.5": 4}}}} # fmt: skip diff --git a/resources/tester/test_705_2_hopyo_paste.py b/resources/tester/test_705_2_hopyo_paste.py index 62cacc2b0..b567d7af9 100644 --- a/resources/tester/test_705_2_hopyo_paste.py +++ b/resources/tester/test_705_2_hopyo_paste.py @@ -94,10 +94,9 @@ def test_실무_xlsx_넷_붙여넣기_만듦_합계_같음(client, tag): assert got["같음"] == FILES[tag][1] and all(abs(x["차이"]) <= 1 for x in got["다름"]) assert len(got["만듦"]) == got["호표"] and got["고정"] + got["호표참조"] == got["줄"] assert master_hash() == before - # 718-1 — 붙여넣기 실무자료 = 지문 없음 → 불러오기 목록엔 숨김 · 파일은 남음 + # 743-1 — 붙여넣기 실무자료 = 만든 뒤 지움(엑셀 보관 안 함) assert client.get("/api/m02/layers/personal/hopyo").json()["줄"] == [] - doc = client.get(f"/api/m02/layers/personal/hopyo/{got['키']}").json() - assert doc["파일"].startswith("붙여넣기") and doc["시트"] == "붙여넣기" + assert client.get(f"/api/m02/layers/personal/hopyo/{got['키']}").status_code == 404 def _block(rows, no): diff --git a/resources/tester/test_707_7_whole_import.py b/resources/tester/test_707_7_whole_import.py index 5c6ee1442..e6a988a0c 100644 --- a/resources/tester/test_707_7_whole_import.py +++ b/resources/tester/test_707_7_whole_import.py @@ -181,8 +181,8 @@ def test_내_자료집에_저장_하위_호표_같이_한_번(world, monkeypatch saved = {p.stem: json.loads(p.read_text("utf-8")) for p in (folder / "precombo").glob("*.json")} for key in keys: for one in saved[key]["담은로직"]: - if "참조" in one: - assert one["참조"]["층"] == "personal" and one["참조"]["키"] in saved + if "참조" in one: # 740-1 하위 호표 = 사본 블록(본뜬 = 같이 만든 개인 SC) + assert one["참조"]["층"] == "사본" and one["본뜬"]["키"] in saved copied = [d["본뜬"]["복사원본"] for d in saved.values() if "복사원본" in (d.get("본뜬") or {})] assert len(copied) == len({json.dumps(x, sort_keys=True) for x in copied}) # 같은 하위 한 번 for p in (folder / "precombo").glob("*.json"): # 다음 시험에 층 쓰기 안 남김 diff --git a/resources/tester/test_715_sangeun_store.py b/resources/tester/test_715_sangeun_store.py index b39938bec..1f46efaf0 100644 --- a/resources/tester/test_715_sangeun_store.py +++ b/resources/tester/test_715_sangeun_store.py @@ -2,7 +2,7 @@ """M02 단가산출(SK) 서고 · 셈(PLAN 715-1b · 715-2) — 임시 storage. 고친 줄 값 = 손셈 · 덧줄 % 엔진이 다시 셈 · 뺀줄 뒤 고름 제자리 · 짝 잃음 남김 · 원값 서버 · -순환 422 · 쓰는 곳 지우기 409 · 사전조합이 SK 를 담음 · 층 복사 왕복 같은 값. +바탕 · 사전조합에 SK 넣기 = 펼쳐 담음(740-1 · 원본 지워도 그대로) · 층 복사 왕복 같은 값. """ from decimal import Decimal @@ -106,32 +106,28 @@ def test_lost_cover_kept_and_listed(world): assert got["기준"]["계"] == pytest.approx(float(_bare(PCT)["기준"]["계"])) -def test_chain_loop_422_and_delete_409(world): - """바탕 사슬 A ← B · B 를 A 의 바탕으로 = 422 · 쓰는 곳 있는 A 지우기 = 409(이름).""" +def test_chain_base_flattened_delete_free(world): + """바탕 SK 넣기(740-1) = 펼쳐 담음 — 바탕 = M01 기초 · 위 덮개 겹침 · 값 같음 · + 원본 지워도 그대로.""" a = _sk(world["personal"], 바탕={"로직": PCT}) b = _sk( world["personal"], 바탕={"참조": {**MINE, "키": a}}, 덮개={"labor:LB000002#0": {"qty": 0}} ) + assert _doc(world["personal"], b)["바탕"] == {"로직": PCT} got = _calc(world["personal"], b) bare = _bare(PCT) assert got["기준"]["노무비"] == pytest.approx(float(bare["줄"][0]["금액"])) with store.at(world["personal"]): - doc = store.read("sangeun", a) - with pytest.raises(store.StoreError) as e: - store.write( - "sangeun", a, doc["판"], {**doc["문서"], "바탕": {"참조": {**MINE, "키": b}}} - ) - assert e.value.status == 422 - with pytest.raises(store.StoreError) as e: - store.delete("sangeun", a) - assert e.value.status == 409 and "단가산출 개인 7/42" in str(e.value.detail) + store.delete("sangeun", a) # 쓰는 곳 없음(서로 독립) + assert _calc(world["personal"], b)["기준"] == got["기준"] def test_precombo_holds_sangeun(world): - """사전조합 참조 줄이 SK 를 담음 — 1 단위 값 × 수량 · SK 지우기 막음.""" + """사전조합에 SK 넣기(740-1) = 로직 줄 + 패치(SK 몸) · 1 단위 값 × 수량 · SK 지워도 그대로.""" from M02_MasterTemplete import M02_Labor_Doc as labor sk = _sk(world["personal"], 바탕={"로직": PCT}, 덮개={"labor:LB000036#0": {"qty": 0.5}}) + one = _calc(world["personal"], sk)["1단위"]["계"] with store.at(world["personal"]): sc = refs.create( "precombo", @@ -141,17 +137,17 @@ def test_precombo_holds_sangeun(world): "담은로직": [{"참조": {**MINE, "키": sk}, "수량": {"손": 2}}], }, )["이름"] + line = store.read("precombo", sc)["문서"]["담은로직"][0] + assert line["로직"] == PCT and line["패치"]["덮개"] and line["본뜬"]["키"] == sk + store.delete("sangeun", sk) pill = {f"{refs.ref_id({**MINE, '키': sc})}#0": 2} # 723-1 안 줄 수량 = 쓰는 쪽 알약 got = labor.calc({"담은로직": [{"참조": {**MINE, "키": sc}, "수량": {"손": 1}}]}, pill) - with pytest.raises(store.StoreError) as e: - store.delete("sangeun", sk) - one = _calc(world["personal"], sk)["1단위"]["계"] assert got["반영합"]["계"] == pytest.approx(one * 2, abs=1) - assert e.value.status == 409 and "일위대가 개인 7/42" in str(e.value.detail) def test_layer_copy_round_trip(world): - """개인 B(바탕 = 개인 A) → 회사로 복사 = A 도 같이(새 키 · 본뜬) → 개인으로 다시 = 같은 값.""" + """개인 B(바탕 = 개인 A) → 회사로 복사 = 펼친 몸(바탕 = 기초 · 본뜬) → 개인으로 다시 = + 같은 값.""" a = _sk(world["personal"], 바탕={"로직": PCT}, 덮개={"labor:LB000036#0": {"qty": 0.5}}) b = _sk(world["personal"], 바탕={"참조": {**MINE, "키": a}}, 뺀줄=["labor:LB000002#0"]) want = _calc(world["personal"], b)["기준"] @@ -160,15 +156,15 @@ def test_layer_copy_round_trip(world): made = refs.create( "sangeun", refs.carry(refs.origin_mark(src, {**MINE, "키": b}), ("personal", "7/42")) ) + assert not list((world["company"] / "sangeun").glob("*.json"))[1:] # A 따로 복사 안 함 copy = _doc(world["company"], made["이름"]) - assert copy["바탕"]["참조"]["층"] == "company" and copy["본뜬"]["키"] == b + assert copy["바탕"] == {"로직": PCT} and copy["본뜬"]["키"] == b assert _calc(world["company"], made["이름"])["기준"] == want with store.at(world["personal"]): back = refs.create( "sangeun", refs.carry(refs.origin_mark(copy, {**COMPANY, "키": made["이름"]}), ("company", "7")), ) - assert _doc(world["personal"], back["이름"])["바탕"]["참조"]["층"] == "company" assert _calc(world["personal"], back["이름"])["기준"] == want diff --git a/resources/tester/test_717_2a_sangeun_list.py b/resources/tester/test_717_2a_sangeun_list.py index ac19b209a..ca3db3416 100644 --- a/resources/tester/test_717_2a_sangeun_list.py +++ b/resources/tester/test_717_2a_sangeun_list.py @@ -32,15 +32,15 @@ def test_sangeun_row_base_edits_users(world): assert row["바탕"] == f"{PCT} {name}".strip() assert row["고친줄수"] == 2 and row["쓰는곳수"] == 0 assert row["제목"] == "목록 시험" and row["단위"] == "㎡" - # 바탕 참조 SK · 더한줄 하나 → 그 SK 쓰는 곳 1 + # 바탕 참조 SK(740-1) = 펼쳐 담음 → 바탕 = 기초 · 고친 줄 = 겹친 덮개 · 뺀줄 + 더한줄 · 쓰는 곳 0 b = _sk( mine, 바탕={"참조": {**MINE, "키": a}}, 더한줄=[{"종류": "인력", "요소": "LB000036", "이름": "보통인부", "단위": "인", "수량": 1}], ) row_b = _row(mine, "sangeun", b) - assert row_b["바탕"] == a and row_b["고친줄수"] == 1 - assert _row(mine, "sangeun", a)["쓰는곳수"] == 1 + assert row_b["바탕"] == f"{PCT} {name}".strip() and row_b["고친줄수"] == 3 + assert _row(mine, "sangeun", a)["쓰는곳수"] == 0 # 바탕 없음 c = _sk(mine) row_c = _row(mine, "sangeun", c) diff --git a/resources/tester/test_717_2b_sangeun_detail.py b/resources/tester/test_717_2b_sangeun_detail.py index cacc11c8f..c63c353a7 100644 --- a/resources/tester/test_717_2b_sangeun_detail.py +++ b/resources/tester/test_717_2b_sangeun_detail.py @@ -9,6 +9,7 @@ """ import json +import re import shutil import subprocess from decimal import Decimal @@ -32,7 +33,9 @@ def _text(p: Path) -> str: def _node(body: str) -> object: - src = _text(MODEL) + "\n" + body + born = _text(M02 / "logic" / "M02_Born_Tag.ts") # 740-2 — 모델이 쓰는 꼬리표 글(import 줄은 뺌) + model = re.sub(r"^import .*$", "", _text(MODEL), flags=re.M) + src = born + "\n" + model + "\n" + body out = subprocess.run( ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript", "-e", src], diff --git a/resources/tester/test_717_4_sangeun_pick.py b/resources/tester/test_717_4_sangeun_pick.py index bdc8cf249..b3f2631b2 100644 --- a/resources/tester/test_717_4_sangeun_pick.py +++ b/resources/tester/test_717_4_sangeun_pick.py @@ -72,12 +72,14 @@ def test_list_amount_and_labor_line_holds_upper_sk(world): # noqa: F811 }, )["이름"] line = store.read("precombo", sc)["문서"]["담은로직"][0] - saved, inner = line["참조"], [line["수량"]] + saved, inner = line["본뜬"], [line["수량"]] outer = {"담은로직": [{"참조": {**MINE, "키": sc}, "수량": {"손": 1}}]} got = labor.calc(outer, {}) fed = labor.calc(outer, {f"{refs.ref_id({**MINE, '키': sc})}#0": 3}) direct = labor.calc({"담은로직": [{"참조": {**COMPANY, "키": sk}, "수량": {"손": 3}}]}, {}) - assert saved == {**COMPANY, "키": sk} # 참조 = 회사 SK 그대로 + # 740-1 — 회사 SK = 로직 줄 + 패치로 펼쳐 담음 · 출처 = 본뜬 + assert {k: saved[k] for k in ("층", "주인", "키")} == {**COMPANY, "키": sk} + assert line["로직"] and "참조" not in line # 742-1 — 일위대가 안 줄 수량 = 저장(사용자 입력) · 쓰는 자리 알약이 이어지면 그 값 assert inner == [{"손": 3}] assert got["반영합"]["계"] == pytest.approx(one * 3, abs=1) # 저장 수량 3 diff --git a/resources/tester/test_718_1_hopyo_import.py b/resources/tester/test_718_1_hopyo_import.py index ed4b93a5a..044b1efe2 100644 --- a/resources/tester/test_718_1_hopyo_import.py +++ b/resources/tester/test_718_1_hopyo_import.py @@ -1,7 +1,7 @@ -"""일위대가 불러오기 한시(PLAN 718-1) — 올림 지문 · [끝내기] 지움 · 겹침 막음. +"""일위대가 불러오기 한시(PLAN 718-1 · 743-1) — 올림 지문 · [끝내기] 지움. 같은 엑셀 작업 중 다시 올림 = 그 파일 이어서(새 키 없음) · [끝내기] = HP · 격자 · 맞춤셈 캐시 0 · -만든 일위대가 · 별칭 장부 그대로 · 끝낸 같은 엑셀 다시 올림 409 · 만든 것 없이 지움 = 다시 올림 됨 · +만든 일위대가 · 별칭 장부 그대로 · 끝낸 같은 엑셀 다시 올림 = 새 키(743-1 · 409 걷음) · 옛 HP(지문 없음) = 파일 그대로 목록에서만 숨김 · 키 번 = 지운 뒤에도 다시 안 씀. 층 = 임시 폴더. """ @@ -44,9 +44,9 @@ def test_이어서_끝내기_지움_겹침_막음(client): assert hs.memory(folder) == {"장부 줄": {"갈래": "안 씀"}} assert sorted((folder / "precombo").glob("SC-*.json")) == sc_before # 만든 일위대가 그대로 - block = _post(client) # 끝낸 같은 엑셀 = 막음 · 안내 - assert block.status_code == 409 and "이미 불러온 엑셀" in block.json()["detail"] - assert f"단가산출 {n}" in block.json()["detail"] and _keys(client) == [] + again = _post(client) # 743-1 — 끝낸 같은 엑셀 = 다시 올림 됨(새 키) + assert again.status_code == 200 and again.json()["키"] == "HP-000002" + assert _keys(client) == ["HP-000002"] assert sorted((folder / "precombo").glob("SC-*.json")) == sc_before @@ -88,6 +88,6 @@ def test_결과_단가산출_산근_블록_기초_SK_고름대기_겹침_없음( again = client.post(url).json() # 다시 = 그대로(겹침 없음) assert again["만듦"] == [] and again["그대로"] == len(got["만듦"]) res = client.delete(URL.format("personal") + "/HP-000001").json() - assert res["만든"] == len(got["만듦"]) # 단가산출만 만들어도 지문 기록(블록마다) + assert res["만든"] == len(docs) # 같은 내용 SK 한 벌 — 사본 걷은 문서 수 assert len(list((folder / "sangeun").glob("SK-*.json"))) == len(docs) # 남음 - assert _post(client).status_code == 409 + assert _post(client).status_code == 200 # 743-1 — 다시 올림 됨 diff --git a/resources/tester/test_719_1_sk_freeze.py b/resources/tester/test_719_1_sk_freeze.py index 26cef1aff..7c742de0f 100644 --- a/resources/tester/test_719_1_sk_freeze.py +++ b/resources/tester/test_719_1_sk_freeze.py @@ -119,10 +119,10 @@ def test_B09_서고_사본_값_같음_원본판(world, tmp_path): assert library.source_version(built) == mark # 다시 모아도 같은 판 again = library.import_copy(root, "precombo", "personal", "7/42", sc) assert again == {"폴더": made["폴더"], "만듦": False} - with store.at(world["personal"]): # SK 고침 → 원본판 다름 + with store.at(world["personal"]): # 740-1 — SK 고쳐도 SC 는 펼친 사본이라 원본판 그대로 now = store.read("sangeun", ref["ref"]["키"]) store.write("sangeun", ref["ref"]["키"], now["판"], {**now["문서"], "덮개": {}}) - assert library.source_version(library._build("precombo", "personal", "7/42", sc)[0]) != mark + assert library.source_version(library._build("precombo", "personal", "7/42", sc)[0]) == mark def test_SK_없는_사본_그대로(): diff --git a/resources/tester/test_719_2_sk_project.py b/resources/tester/test_719_2_sk_project.py index 1d7c8ee02..c0f99cc86 100644 --- a/resources/tester/test_719_2_sk_project.py +++ b/resources/tester/test_719_2_sk_project.py @@ -122,23 +122,18 @@ def test_SK_단가_덮개_속_일위대가_펼침(world, tmp_path): if not _engine_ready(world): pytest.skip("715-1b 엔진 입력 덧칸(단가 · 값묶음) 뒤 — 지금은 M02 SK 셈도 못 함") key = _mine(world, full=True) - # 742-1 속 일위대가 안 줄 저장 수량(손 1) → M02 SK · B09 모두 빈 줄 없음 - assert _calc(world["personal"], key)["빠진줄"] == [] + # 740-1 — 속 일위대가 = 넣을 때 펼쳐 담음(SC 로직 줄 = 로직 더한줄 · 본뜬) → 빠진 줄 없음 + live = _calc(world["personal"], key) + added = _added(world, key) + assert live["빠진줄"] == [] and not any("참조" in x for x in added) + assert added[-1]["로직"] == PCT and added[-1]["본뜬"]["키"].startswith("SC-") root, folder = _lib(world, tmp_path, key) daega, done, _ = _b09(root) block = done[daega["libs"][folder]] lines = block["lines"] assert lines[1]["kind"] == "fixed" and lines[1]["prices"]["labor"] == pytest.approx(200000) - sc_line = next(ln for ln in lines if ln["kind"] == "daega") - sc = done[sc_line["key"]] - assert sc["kind"] == "사전조합" and sc_line["qty"] == 2 - assert sc["blank"] == 0 and sc["lines"][0]["qty"] == 1 and block["blank"] == 0 - # 이 공사에서 입력(702 덮개 qty 1) = 저장 1 과 같은 값 → M02 셈(알약 값 1) 과 같은 금액 - cover = {sc["cover"]: {sc["lines"][0]["cid"]: {"qty": 1}}} - block = _b09(root, cover)[1][daega["libs"][folder]] assert block["blank"] == 0 - sc_ref = next(x for x in _added(world, key) if "참조" in x)["참조"] - _same_money(block, _calc_given(world["personal"], key, {f"{refs.ref_id(sc_ref)}#0": 1})) + _same_money(block, live) def _added(world, key): @@ -146,24 +141,6 @@ def _added(world, key): return store.read("sangeun", key)["문서"]["더한줄"] -def _calc_given(folder, key, pills): - """M02 SK 셈 — 속 일위대가 안 줄 수량 = 알약 값(723-1 `Expand` given).""" - from M01_MasterData import M01_MasterData_Store as m01 - from M02_MasterTemplete import M02_Sangeun_Calc as calc - from M02_MasterTemplete import M02_Sangeun_Doc as sd - from M02_MasterTemplete.M02_Labor_Expand import Expand - - whole = m01.loaded()[1] - with store.at(folder): - clean = sd.clean(store.read("sangeun", key)["문서"], None, whole) - me = refs.ref_id({"층": refs.here()[0], "주인": refs.here()[1], "키": clean["키"]}) - got = calc.run( - clean, whole, None, refs.read_ref, Expand(whole, refs.read_ref, pills), (me,) - ) - assert not got.get("까닭") - return got - - def test_구조물_사본_SK_줄_업데이트_덮개_입력값_고른자재_굳힌키_그대로( world, tmp_path, monkeypatch ): diff --git a/resources/tester/test_725_1_sangeun_add_inputs.py b/resources/tester/test_725_1_sangeun_add_inputs.py index 32eec4ca1..f442d2767 100644 --- a/resources/tester/test_725_1_sangeun_add_inputs.py +++ b/resources/tester/test_725_1_sangeun_add_inputs.py @@ -76,8 +76,8 @@ def test_line_without_input_only_that_line_drops(world, monkeypatch): # noqa: F assert up["기준"] is None and "입력 「지역」 없음" in up["까닭"] -def test_precombo_without_qty_only_that_line_drops(world): # noqa: F811 - """수량 없는 일위대가 줄(723-1) = 그 줄만 금액 — · 까닭 · 나머지 계 같음.""" +def test_precombo_in_sk_flattened_no_gap(world): # noqa: F811 + """740-1 — SK 에 넣은 일위대가 = 줄마다 더한줄로 펼쳐 담음(수량 = 넣은 수 × 1) · 빠진 줄 없음.""" with store.at(world["personal"]): sc = refs.create( "precombo", {"이름": "sub7임시SC", "단위": "㎥", "담은로직": [{"로직": PCT}]} @@ -86,10 +86,9 @@ def test_precombo_without_qty_only_that_line_drops(world): # noqa: F811 doc = _sk(world["personal"], 더한줄=[line, PCT_LINE]) rest = _sk(world["personal"], 더한줄=[PCT_LINE]) got, want = _run(world["personal"], doc), _run(world["personal"], rest) - gap = _line(got, "add:#0") - assert gap["금액"] is None and gap["까닭"] == calc.NO_QTY_WHY and gap["이름"] == "sub7임시SC" - assert [x["cid"] for x in got["빠진줄"]] == ["add:#0"] - assert got["기준"]["계"] == pytest.approx(float(want["기준"]["계"]), rel=1e-9) + assert doc["더한줄"][0]["로직"] == PCT and doc["더한줄"][0]["본뜬"]["키"] == sc["이름"] + assert got["빠진줄"] == [] + assert got["기준"]["계"] == pytest.approx(3 * float(want["기준"]["계"]), rel=1e-9) def test_screen_shows_dropped_lines(): diff --git a/resources/tester/test_727_sk_base_add.py b/resources/tester/test_727_sk_base_add.py index 8b4fda491..ad29b9a54 100644 --- a/resources/tester/test_727_sk_base_add.py +++ b/resources/tester/test_727_sk_base_add.py @@ -206,7 +206,13 @@ def _model(ans, doc, script): f"\nconst ans = {json.dumps(ans, ensure_ascii=False)};" f"\nconst doc = {json.dumps(doc, ensure_ascii=False)};\n{script}" ) - return _node(["M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts"], body) + return _node( + [ + "M02_MasterTemplete/logic/M02_Born_Tag.ts", + "M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts", + ], + body, + ) def test_727_2_화면_꼴_세부_줄_고침(world): # noqa: F811 diff --git a/resources/tester/test_729_hopyo_sk_link.py b/resources/tester/test_729_hopyo_sk_link.py index 1c8e7d7d6..60873446b 100644 --- a/resources/tester/test_729_hopyo_sk_link.py +++ b/resources/tester/test_729_hopyo_sk_link.py @@ -126,8 +126,8 @@ def test_실무_단가산출_기초_SK_고름대기_일위대가_막음(client): ok = client.post(BUILD, json={**body, "줄": picks}) assert ok.status_code == 200, ok.text doc = json.loads((folder / "precombo" / f"{ok.json()['참조']['키']}.json").read_bytes()) - refs_sk = [x for x in doc["담은로직"] if str((x.get("참조") or {}).get("키")).startswith("SK")] - assert refs_sk # 단가산출 줄 = SK 참조(고정 줄 대신) + refs_sk = [x for x in doc["담은로직"] if str((x.get("본뜬") or {}).get("키")).startswith("SK")] + assert refs_sk # 단가산출 줄 = SK 펼쳐 담음(740-1 · 로직 줄 + 패치 · 본뜬 = 그 SK) assert client.post(LEFT, json={**body, "줄": picks}).json()["수"] == 0 assert master_hash() == before @@ -223,7 +223,8 @@ def test_별칭_체크_줄_못옮김_SK_남은줄_그대로_SK_남은못옮김(c ok = client.post(BUILD, json={**body, "줄": [check, *picks]}) assert ok.status_code == 200, ok.text doc = json.loads((folder / "precombo" / f"{ok.json()['참조']['키']}.json").read_bytes()) - assert any((x.get("참조") or {}).get("키") == sk["키"] for x in doc["담은로직"]) # 차이 SK 참조 + # 차이 SK = 펼쳐 담음(740-1 · 본뜬 = 그 SK) + assert any((x.get("본뜬") or {}).get("키") == sk["키"] for x in doc["담은로직"]) assert master_hash() == before diff --git a/resources/tester/test_733_2_library_ask.py b/resources/tester/test_733_2_library_ask.py index 1b891f2d0..4d6e3b03e 100644 --- a/resources/tester/test_733_2_library_ask.py +++ b/resources/tester/test_733_2_library_ask.py @@ -93,23 +93,23 @@ def test_공유_승인_회사_같은_내용_409_씀_새로(world): def test_복사_딸림_같은_원본_재사용(world): + """구조물 조합 복사 딸림(733-2) = 같은 원본 사본 재사용 · 740-1 — 일위대가 안 일위대가는 + 넣을 때 사본 블록(딸림 없음).""" client = _client(world) _as(world) - mine = layers.personal_dir(7, 42) url = LIB.format("personal", "precombo") inner = client.post(url, json=_doc("안")).json()["이름"] ref = {"층": "personal", "주인": "7/42", "키": inner} outer = {"문서": {"이름": "밖", "단위": "㎡", "담은로직": [{"참조": ref}]}} top = client.post(url, json=outer).json()["이름"] - with store.at(mine): - doc = store.read("precombo", top)["문서"] - src = {"층": "personal", "주인": "7/42", "키": top} + with store.at(layers.personal_dir(7, 42)): + assert store.read("precombo", top)["문서"]["담은로직"][0]["참조"]["층"] == "사본" company = layers.company_dir(7) + combo = {"종류": "일위대가", "열": "rv", "담은로직": [{"참조": ref}], "자재": []} with store.at(company): - one = refs.create("precombo", refs.carry(refs.origin_mark(doc, src), ("personal", "7/42"))) - assert _count(company) == 2 # 밖 + 딸린 안 - two = refs.create("precombo", refs.carry(refs.origin_mark(doc, src), ("personal", "7/42"))) - assert _count(company) == 3 # 다시 복사 = 밖만 새로 · 딸린 안 = 앞 사본 재사용 - with store.at(company): - got = [store.read("precombo", m["이름"])["문서"]["담은로직"][0]["참조"] for m in (one, two)] + one = refs.carry(combo, ("personal", "7/42")) + assert _count(company) == 1 # 딸린 안 = 회사 새 키 + two = refs.carry(combo, ("personal", "7/42")) + assert _count(company) == 1 # 다시 복사 = 앞 사본 재사용 + got = [x["담은로직"][0]["참조"] for x in (one, two)] assert got[0] == got[1] and got[0]["층"] == "company" diff --git a/resources/tester/test_733_3_library_same_merge.py b/resources/tester/test_733_3_library_same_merge.py index e6b20aa28..56edcabe6 100644 --- a/resources/tester/test_733_3_library_same_merge.py +++ b/resources/tester/test_733_3_library_same_merge.py @@ -3,6 +3,7 @@ 묶음 = 이 층 + 위 층 같은 지문 · 합치기 = 쓰는 곳 참조 → 남김(금액 같음) · 지운 문서 `_합친것/` · 되돌림 = 문서 · 참조 되살림 · 지문 · 판 다르면 409 · 권한 밖 · 같은 참조 두 번 = 그 문서 남김 · +쓰는 곳 = 구조물 조합(740-1 — 단가산출 · 일위대가끼리는 펼쳐 담겨 참조 없음) · 위 층과 같은 아래 층 문서 = 남김 = 위 층 키 · 일반 사용자 회사 층 = 403. """ @@ -55,6 +56,27 @@ def _top(ref_keys, at=MINE): ] +def _user(folder, ref_keys, at=MINE, col="x1"): + """쓰는 곳 = 구조물 조합(740-1 — 단가산출 · 일위대가끼리는 펼쳐 담겨 참조 없음).""" + lines = [{**x, "수량": {"손": x["수량"]}} for x in _top(ref_keys, at)] + with store.at(folder): + store.write("labor", col, "", {"열": col, "담은로직": lines, "자재": []}) + return col + + +def _lines(folder, col): + with store.at(folder): + return store.read("labor", col)["문서"]["담은로직"] + + +def _sum(folder, col): + from M02_MasterTemplete import M02_Labor_Doc as labor + + with store.at(folder): + doc = labor.clean({**store.read("labor", col)["문서"], "열": col}) + return float(labor.calc(doc, {})["반영합"]["계"]) + + def _merge(api, keep, gone, 판, layer="personal"): body = {"남김": keep, "지움": gone, "판": 판} return api.post(URL.format(layer) + "/merge", json=body) @@ -64,14 +86,14 @@ def test_권한_밖이면_안_지움(api, world, monkeypatch): me = world["personal"] a = _make(me, 이름="가", 바탕={"로직": PCT}) c = _make(me, 이름="다", 바탕={"로직": PCT}) - top = _make(me, 이름="위", 단위="식", 바탕=None, 더한줄=_top([c])) + top = _user(me, [c]) monkeypatch.setattr(same_router, "_can_write", lambda session, at: False) 판 = api.get(URL.format("personal")).json()["판"] res = _merge(api, {**MINE, "키": a}, [c], 판) left = res.json()["남김"] assert res.status_code == 200 and left[0]["키"] == c and "권한 밖" in left[0]["쓰는곳"][0] assert res.json()["합침"] == [] and (me / "sangeun" / f"{c}.json").is_file() - assert _read(me, top)["더한줄"][0]["참조"]["키"] == c + assert _lines(me, top)[0]["참조"]["키"] == c def test_합치기_참조_옮김_금액_같음_되돌림(api, world): @@ -79,8 +101,8 @@ def test_합치기_참조_옮김_금액_같음_되돌림(api, world): a = _make(me, 이름="밑", 바탕={"로직": PCT}) b = _make(me, 이름="다른 이름 같은 내용", 바탕={"로직": PCT}) _make(me, 이름="다른 내용", 바탕={"로직": PCT}, 더한줄=_top([])[-1:]) - top = _make(me, 이름="위", 단위="식", 바탕=None, 더한줄=_top([b])) - before = _total(me, top) + top = _user(me, [b]) + before = _sum(me, top) got = api.get(URL.format("personal")) assert got.status_code == 200, got.text view = got.json() @@ -96,16 +118,16 @@ def test_합치기_참조_옮김_금액_같음_되돌림(api, world): assert out["묶음"] == [] and [g["키"] for g in out["합친것"]] == [b] assert not (me / "sangeun" / f"{b}.json").exists() assert (me / same_router.GONE / f"{b}.json").is_file() - assert _read(me, top)["더한줄"][0]["참조"]["키"] == a - assert _total(me, top) == pytest.approx(before) + assert _lines(me, top)[0]["참조"]["키"] == a + assert _sum(me, top) == pytest.approx(before) back = api.post(URL.format("personal") + "/undo", json={"키": b}) assert back.status_code == 200, back.text assert back.json()["되돌린곳수"] == 1 and back.json()["합친것"] == [] assert (me / "sangeun" / f"{b}.json").is_file() assert not (me / same_router.GONE / f"{b}.json").exists() - assert _read(me, top)["더한줄"][0]["참조"]["키"] == b - assert _total(me, top) == pytest.approx(before) + assert _lines(me, top)[0]["참조"]["키"] == b + assert _sum(me, top) == pytest.approx(before) assert api.post(URL.format("personal") + "/undo", json={"키": b}).status_code == 404 @@ -130,24 +152,25 @@ def test_권한_밖_남김_둘다_담은_문서(api, world, monkeypatch): a = _make(me, 이름="가", 바탕={"로직": PCT}) b = _make(me, 이름="나", 바탕={"로직": PCT}) c = _make(me, 이름="다", 바탕={"로직": PCT}) - both = _make(me, 이름="둘 다", 단위="식", 바탕=None, 더한줄=_top([a, b])) - before = _total(me, both) + both = _user(me, [a, b]) + before = _sum(me, both) 판 = api.get(URL.format("personal")).json()["판"] - res = _merge(api, {**MINE, "키": a}, [b, c], 판) # 둘 다 담은 문서 = 두 줄 모두 a · 금액 같음 + # 둘 다 담은 구조물 조합 = 같은 참조 두 번이 되어 못 옮김 → b 남김 · c 만 합침 · 금액 같음 + res = _merge(api, {**MINE, "키": a}, [b, c], 판) assert res.status_code == 200, res.text - assert [r["키"] for r in res.json()["합침"]] == [b, c] - assert [x["참조"]["키"] for x in _read(me, both)["더한줄"][:2]] == [a, a] - assert _total(me, both) == pytest.approx(before) - for key in (b, c): - assert api.post(URL.format("personal") + "/undo", json={"키": key}).status_code == 200 + assert [r["키"] for r in res.json()["합침"]] == [c] + assert [r["키"] for r in res.json()["남김"]] == [b] + assert [x["참조"]["키"] for x in _lines(me, both)[:2]] == [a, b] + assert _sum(me, both) == pytest.approx(before) + assert api.post(URL.format("personal") + "/undo", json={"키": c}).status_code == 200 def test_위층과_같으면_남김_위층_키(api, world): me, firm = world["personal"], world["company"] up = _make(firm, 이름="회사 것", 바탕={"로직": PCT}) mine = _make(me, 이름="내 것", 바탕={"로직": PCT}) - top = _make(me, 이름="위", 단위="식", 바탕=None, 더한줄=_top([mine])) - before = _total(me, top) + top = _user(me, [mine]) + before = _sum(me, top) view = api.get(URL.format("personal")).json() rows = view["묶음"][0]["문서"] assert [(r["층"], r["키"], r["위층"]) for r in rows] == [ @@ -156,8 +179,8 @@ def test_위층과_같으면_남김_위층_키(api, world): ] res = _merge(api, {**COMPANY, "키": up}, [mine], view["판"]) assert res.status_code == 200, res.text - assert _read(me, top)["더한줄"][0]["참조"] == {**COMPANY, "키": up} - assert _total(me, top) == pytest.approx(before) + assert _lines(me, top)[0]["참조"] == {**COMPANY, "키": up} + assert _sum(me, top) == pytest.approx(before) assert (firm / "sangeun" / f"{up}.json").is_file() # 위 층 문서는 지울 수 없음 · 일반 사용자는 회사 층 합치기 403 assert _merge(api, {**MINE, "키": mine}, [up], view["판"]).status_code in (403, 404) diff --git a/resources/tester/test_735_1_library_delete.py b/resources/tester/test_735_1_library_delete.py index b18e95d9f..583d2ffcb 100644 --- a/resources/tester/test_735_1_library_delete.py +++ b/resources/tester/test_735_1_library_delete.py @@ -136,7 +136,9 @@ def test_폴더_통째_한_기록_되돌림_자리까지_그새_고친_문서는 a = _sc(me, "가", [FIX]) b = _sc(me, "나", [FIX]) _labor(me, [{"참조": {**MINE, "키": a}, "수량": {"손": 1}}, FIX]) - other = _sc(me, "다른", [{"참조": {**MINE, "키": b}}, FIX]) + with store.at(me): # 쓰는 곳 = 구조물 조합(740-1 — 일위대가 안 일위대가는 사본 블록) + other = [{"참조": {**MINE, "키": b}, "수량": {"손": 1}}, FIX] + store.write("labor", "x2", "", {"열": "x2", "담은로직": other, "자재": []}) assert _tree(api, op="add-folder", 경로=[], 이름="묶음").status_code == 200 for key in (a, b): assert _tree(api, op="move", 키=key, 새경로=["묶음"]).status_code == 200 @@ -147,9 +149,10 @@ def test_폴더_통째_한_기록_되돌림_자리까지_그새_고친_문서는 assert res.status_code == 200, res.text name = res.json()["알림"]["기록"] assert [g["키"] for g in api.get(SAME).json()["합친것"]] == [name] - with store.at(me): # 그새 「다른」 을 고침 → 그 문서는 앞판으로 안 돌림 - have = store.read("precombo", other) - store.write("precombo", other, have["판"], {**have["문서"], "이름": "다른 고침"}) + with store.at(me): # 그새 x2 를 고침 → 그 문서는 앞판으로 안 돌림 + have = store.read("labor", "x2") + lines = [{**x, "메모": "고침"} for x in have["문서"]["담은로직"]] + store.write("labor", "x2", have["판"], {**have["문서"], "담은로직": lines}) back = api.post(SAME + "/undo", json={"키": name}).json() assert back["되돌린곳수"] == 1 and "그새 고침" in back["못한곳"][0] assert (me / "precombo" / f"{a}.json").is_file() and (me / "precombo" / f"{b}.json").is_file() diff --git a/resources/tester/test_740_1_library_flat.py b/resources/tester/test_740_1_library_flat.py new file mode 100644 index 000000000..e7b9ceb9c --- /dev/null +++ b/resources/tester/test_740_1_library_flat.py @@ -0,0 +1,374 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""740-1 단가산출(SK) · 일위대가(SC) 서로 독립 · 연결 = M01 키 · 초기화(임시 storage · M01 읽기만). + +- 넣기 = 펼침(SK 에 SK = 몸 통째 한 줄 · SC 에 SK = 로직 줄 + 패치 · SK 에 SC = 줄마다 + 더한줄 · SC 에 SC = 넣기 길만 안 줄 펴 넣음 · 저장 · 읽기는 하위 호표 참조 그대로) · 줄 `본뜬` · + 펼치기 전후 금액 같음 · 원본 고쳐도 · 지워도 그대로 +- 분석 579-2 실측 E1(129,073 → 481,406) = 이제 안 일어남 +- 바탕 SK 참조 · 패치 SK 참조 = 덮개 겹침 · 못 펼침(겹침 · 바탕 없음 · 환산) = 넣기 400 · + 저장은 옛 참조 +- 초기화 = 덮개 · 뺀줄 · 더한줄 단가 걷음 → M01 값 · 옵션 · 항목 · 수량 남음 · 길(미리 보기 · 씀) +- 옮기기 함수 = 옛 참조 문서 펼쳐 씀(전후 같음 · 겹침 남김 · 두 번째 = 할 것 없음) · 733 지문 같음 +- B09 = SC 덮개 줄 굳힘 → 대가 블록 금액 = M02(옛 산근 길은 덮개를 안 읽었음) +""" + +import json + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import verify_session +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 +from M02_MasterTemplete import M02_Library_Flat as flat +from M02_MasterTemplete import M02_Library_Same as same +from M02_MasterTemplete import M02_Library_Same_Router as same_router +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_Calc as calc +from M02_MasterTemplete import M02_Sangeun_Doc as sd +from resources.tester.test_m02_labor_ref import MINE, world # noqa: F401 + +GF = "GF000099" # 측구터파기 토사 · 원/㎥ +PCT = "GC001109" # 원/㎡ · 지붕잇기공 · 보통인부 · 「노무비 * 3 / 100」 +ADD = { + "종류": "인력", + "요소": "LB000002", + "비목": "노무비", + "이름": "보통인부", + "단위": "인", + "수량": 0.5, +} +USER = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False, "name": "갑"} + + +def _whole(): + return m01.loaded()[1] + + +def _raw(folder, kind, **doc): + """옛 꼴 문서(참조 줄 그대로) — 저장 길을 거치지 않고 파일로.""" + (folder / kind).mkdir(parents=True, exist_ok=True) + key = f"{refs.PREFIX[kind]}-{len(list((folder / kind).glob('*.json'))) + 1:06d}" + head = {"종류": refs.TITLE[kind], "키": key, "단위": "㎡"} + body = {"입력값": {}, "덮개": {}, "뺀줄": [], "고름": {}, "자재표시": {}, "더한줄": []} + full = {**head, **(body if kind == "sangeun" else {"자재": []}), **doc} + (folder / kind / f"{key}.json").write_text(json.dumps(full, ensure_ascii=False), "utf-8") + return key + + +def _make(folder, kind, **doc): + with store.at(folder): + extra = {"더한줄": []} if kind == "sangeun" else {} + return refs.create(kind, {"단위": "㎡", **extra, **doc})["이름"] + + +def _read(folder, kind, key): + with store.at(folder): + return store.read(kind, key)["문서"] + + +def _sk_total(folder, doc): + with store.at(folder): + return float(calc.run(sd.clean(doc, None, _whole()), _whole())["1단위"]["계"]) + + +def _sc_value(folder, key, read=None): + with store.at(folder): + got = flat._value({**MINE, "키": key}, read or refs.read_ref, _whole()) + assert got is not None + return got["계"] + + +def _y(folder): + """원본 SK — 바탕 PCT(% 덧줄) · 품 덮개 · 인력 더한줄(덧줄 밑에 듦).""" + return _make( + folder, + "sangeun", + 이름="원본", + 바탕={"로직": PCT}, + 덮개={"labor:LB000036#0": {"qty": 0.036}}, + 더한줄=[ADD], + ) + + +def test_SK_에_SK_몸_통째_금액_같음_원본_고쳐도_지워도_그대로(world): + me = world["personal"] + y = _y(me) + legacy = { + "이름": "밖", + "단위": "㎥", + "바탕": {"로직": GF}, + "더한줄": [{"참조": {**MINE, "키": y}, "수량": 2}], + } + before = _sk_total(me, {"키": "SK-000099", **legacy}) + x = _make(me, "sangeun", **legacy) + doc = _read(me, "sangeun", x) + entry = doc["더한줄"][0] + assert not refs.refs_in(doc) + assert entry["로직"] == PCT and entry["덮개"] and entry["더한줄"][0]["요소"] == "LB000002" + assert entry["본뜬"]["키"] == y and entry["본뜬"]["이름"] == "원본" and entry["본뜬"]["때"] + assert _sk_total(me, doc) == pytest.approx(before, rel=1e-9) + with store.at(me): # 원본 고침 · 지움 → 넣은 쪽 그대로 + now = store.read("sangeun", y) + store.write( + "sangeun", y, now["판"], {**now["문서"], "덮개": {"labor:LB000002#0": {"qty": 2}}} + ) + assert refs.users({**MINE, "키": y}) == [] + store.delete("sangeun", y) + assert _sk_total(me, _read(me, "sangeun", x)) == pytest.approx(before, rel=1e-9) + with store.at(me): # 화면 셈 길 = 넣은 줄 세부 펼침(안 더한줄 = add:#0/add:#0) + got = calc.calc_doc(_read(me, "sangeun", x)) + sub = next(r for r in got["줄"] if r["cid"] == "add:#0")["펼침"] + assert "add:#0/add:#0" in [r["cid"] for r in sub] + + +def test_SC_에_SK_패치_겹침_E1_원본_고쳐도_그대로(world): + """분석 579-2 E1 — 밖 SC(SK 참조 + 줄 패치 · 안 SC) 의 원본 SK 다른 줄을 고쳐도 밖 금액 + 그대로.""" + me = world["personal"] + sk = _make(me, "sangeun", 이름="밑", 바탕={"로직": PCT}, 더한줄=[]) + inner = _make(me, "precombo", 이름="안", 담은로직=[{"로직": "GC001111"}]) + lines = [ + {"참조": {**MINE, "키": sk}, "패치": {"덮개": {"labor:LB000036#0": {"qty": 0.5}}}}, + {"참조": {**MINE, "키": inner}}, + ] + old = _raw(me, "precombo", 이름="옛 밖", 담은로직=lines) + before = _sc_value(me, old) + host = _make(me, "precombo", 이름="밖", 담은로직=lines) + doc = _read(me, "precombo", host) + assert doc["담은로직"][0]["로직"] == PCT and doc["담은로직"][0]["본뜬"]["키"] == sk + assert doc["담은로직"][0]["패치"]["덮개"] == {"labor:LB000036#0": {"qty": 0.5}} + assert doc["담은로직"][1]["참조"]["층"] == "사본" # 하위 호표(SC 안 SC) = 사본 블록 + assert doc["담은로직"][1]["본뜬"]["키"] == inner + assert _sc_value(me, host) == pytest.approx(before, rel=1e-9) + with store.at(me): # E1 — 원본 SK 의 덮지 않은 줄 품 2 + now = store.read("sangeun", sk) + store.write( + "sangeun", sk, now["판"], {**now["문서"], "덮개": {"labor:LB000002#0": {"qty": 2}}} + ) + assert _sc_value(me, old) > before * 3 # 옛 참조 = 따라감(129,073 → 481,406 꼴) + assert _sc_value(me, host) == pytest.approx(before, rel=1e-9) # 펼친 것 = 그대로 + + +def test_SC_에_SC_사본_블록_실무수량_겹침_원본_지워도(world): + """하위 호표(SC 안 SC) = 사본 블록 — 원본 연결 없음 · 금액 같음 · 같은 기초 겹침도 블록 안.""" + me = world["personal"] + hands = {"층": "실무자료", "실무수량": {"줄": {"0": 3}, "자재": {}}} + inner = _make(me, "precombo", 이름="안", 담은로직=[{"로직": PCT}], 본뜬=hands) + lines = [{"로직": PCT}, {"참조": {**MINE, "키": inner}, "별칭": "안쪽"}] # 같은 기초 겹침 + old = _raw(me, "precombo", 이름="옛", 담은로직=lines) + before = _sc_value(me, old) + host = _make(me, "precombo", 이름="밖", 담은로직=lines) + line = _read(me, "precombo", host)["담은로직"][1] + block = line["참조"] + assert block["층"] == "사본" and block["키"] == inner and line["본뜬"]["키"] == inner + assert block["몸"]["담은로직"][0]["로직"] == PCT and line["별칭"] == "안쪽" + assert block["몸"]["본뜬"]["실무수량"]["줄"] == {"0": 3} # 엑셀 대조 · 하위 호표 끝수 + assert _sc_value(me, host) == pytest.approx(before, rel=1e-9) + with store.at(me): + got = flat.flat_lines("precombo", {**MINE, "키": old}, None, {"담은로직": [{"로직": GF}]}) + (me / "precombo" / f"{old}.json").unlink() # 옛 꼴 문서(참조) 치움 + assert refs.users({**MINE, "키": inner}) == [] + store.delete("precombo", inner) + assert _sc_value(me, host) == pytest.approx(before, rel=1e-9) # 원본 지워도 그대로 + assert got["담은로직"][0]["참조"]["층"] == "사본" # 넣기 길도 블록(겹침 없음) + + +def test_SK_에_SC_줄마다_더한줄(world): + me = world["personal"] + sc = _make(me, "precombo", 이름="조합", 담은로직=[{"로직": GF}]) + with store.at(me): + got = flat.flat_lines("sangeun", {**MINE, "키": sc}, 2)["더한줄"] + assert len(got) == 1 and got[0]["로직"] == GF and got[0]["수량"] == 2 # 실무수량 없음 = 1 × 2 + assert got[0]["본뜬"]["키"] == sc + # 742-1 저장 수량(수량.손)이 있으면 그 수 · 안 SC 펴기 = 바깥 × 안 + from decimal import Decimal + + hand = flat.sc_adds({"담은로직": [{"로직": GF, "수량": {"손": 3}}]}, Decimal(2), _whole(), {}) + assert hand[0]["수량"] == 6 + block = {"참조": refs.copy_ref({"담은로직": [{"로직": GF, "수량": {"손": 3}}]}, "SC-000001")} + deep = flat.sc_adds({"담은로직": [{**block, "수량": {"손": 2}}]}, Decimal(1), _whole(), {}) + assert deep[0]["로직"] == GF and deep[0]["수량"] == 6 # 사본 블록 = 바깥 × 안 + doc = {"키": "SK-000099", "이름": "x", "단위": "㎥", "바탕": None, "더한줄": got} + one = float(labor.run_line(_whole(), GF, {})["기준"]["계"]) + assert _sk_total(me, doc) == pytest.approx(2 * one, rel=1e-9) + + +def test_바탕_SK_겹침_못_펼침_까닭(world): + me = world["personal"] + under = _make( + me, "sangeun", 이름="밑", 바탕={"로직": PCT}, 덮개={"labor:LB000036#0": {"qty": 0.1}} + ) + over = {"이름": "위", "단위": "㎡", "바탕": {"참조": {**MINE, "키": under}}, "더한줄": [ADD]} + over |= {"덮개": {"labor:LB000036#0": {"배율": 2}}, "뺀줄": ["labor:LB000002#0"]} + before = _sk_total(me, {"키": "SK-000099", **over}) + got = flat.flat_doc("sangeun", {"키": "SK-000099", **over}) + assert got["바탕"] == {"로직": PCT} and got["덮개"]["labor:LB000036#0"]["qty"] == 0.2 + assert got["뺀줄"] == ["labor:LB000002#0"] + assert _sk_total(me, got) == pytest.approx(before, rel=1e-9) + # SC 에 바탕 없는 SK · 단위 환산 SK = 못 넣음 + bare = _make(me, "sangeun", 이름="바탕 없음", 바탕=None, 더한줄=[ADD]) + with store.at(me): + for key, why in ((bare, "바탕 기초 없는"),): + with pytest.raises(store.StoreError) as e: + flat.flat_lines("precombo", {**MINE, "키": key}) + assert e.value.status == 400 and why in str(e.value.detail) + + +def test_초기화_SK_SC_M01_값_옵션_남음(world): + me = world["personal"] + rock = {"보정작업": "암석작업(연암・보통암・경암)"} + entry = { + "종류": "로직", + "로직": PCT, + "입력값": {}, + "수량": 1, + "덮개": {"labor:LB000036#0": {"qty": 1}}, + } + priced = {**ADD, "단가": 1} + x = _make( + me, + "sangeun", + 이름="고친 것", + 단위="㎥", + 바탕={"로직": GF}, + 입력값=rock, + 덮개={"labor:LB000002#0": {"qty": 1}}, + 뺀줄=["logic:GC000267#0"], + 더한줄=[entry, priced], + ) + with store.at(me): + look = flat.reset_doc("sangeun", x, None, True, None) + assert look["걷힘"] == 4 and _read(me, "sangeun", x)["덮개"] # 미리 보기 = 안 씀 + done = flat.reset_doc("sangeun", x, None, False, None) + doc = _read(me, "sangeun", x) + assert doc["덮개"] == {} and doc["뺀줄"] == [] and doc["입력값"] == rock + assert [x.get("요소") or x.get("로직") for x in doc["더한줄"]] == [PCT, "LB000002"] + assert "덮개" not in doc["더한줄"][0] and "단가" not in doc["더한줄"][1] + assert done["걷힘"] == 4 + plain = {"키": "SK-000099", "이름": "x", "단위": "㎥", "바탕": {"로직": GF}, "입력값": rock} + plain["더한줄"] = [{**entry, "덮개": {}}, ADD] + assert _sk_total(me, doc) == pytest.approx(_sk_total(me, plain), rel=1e-9) + sc = _make( + me, + "precombo", + 이름="줄 덮개", + 담은로직=[ + {"로직": PCT, "패치": {"덮개": {"labor:LB000036#0": {"qty": 1}}, "더한줄": [priced]}} + ], + ) + with store.at(me): + got = flat.reset_doc("precombo", sc, None, False, None) + line = _read(me, "precombo", sc)["담은로직"][0] + assert got["걷힘"] == 2 and line["패치"] == {"더한줄": [{**ADD, "수량": 0.5, "처음수량": 0.5}]} + # 이음 기계 고정 줄(736-3) = M01 기계 한 시간 값 다시 받음 + from M02_MasterTemplete import M02_Machine_Hourly as machine + + hour = machine.hourly("EQ000019") + fx = {"이름": "굴착기", "단위": "시간", "단가": {"노무비": 1, "재료비": 1, "경비": 1}} + fx["이음"] = {"기계": "EQ000019"} + mc = _make(me, "precombo", 이름="기계", 담은로직=[{"고정": fx}]) + with store.at(me): + assert flat.reset_doc("precombo", mc, None, False, None)["걷힘"] == 1 + price = _read(me, "precombo", mc)["담은로직"][0]["고정"]["단가"] + assert price["노무비"] == pytest.approx(hour["노무비"]) and price["경비"] == pytest.approx( + hour["경비"] + ) + + +def test_길_넣기_초기화_권한(world): + app = FastAPI() + app.include_router(same_router.router) + app.dependency_overrides[verify_session] = lambda: USER + client = TestClient(app) + me = world["personal"] + y = _y(me) + url = "/api/m02/layers/personal/{}-{}" + res = client.post(url.format("sangeun", "flat"), json={"참조": {**MINE, "키": y}, "수량": 1}) + assert res.status_code == 200, res.text + assert res.json()["더한줄"][0]["본뜬"]["키"] == y + bad = client.post(url.format("precombo", "flat"), json={"참조": {**MINE, "키": "SK-000777"}}) + assert bad.status_code == 400 + look = client.post(url.format("sangeun", "reset"), json={"키": y}) + assert look.status_code == 200 and look.json()["걷힘"] == 1 + firm = client.post("/api/m02/layers/company/sangeun-reset", json={"키": y, "미리보기": False}) + assert firm.status_code == 403 # 일반 사용자 회사 층 쓰기 막힘 + + +def test_옮기기_함수_전후_같음_사본_블록(world): + me = world["personal"] + y = _y(me) + inner = _make(me, "precombo", 이름="안", 담은로직=[{"로직": GF}]) + sk = _raw( + me, + "sangeun", + 이름="옛 SK", + 단위="㎥", + 바탕={"로직": GF}, + 더한줄=[{"참조": {**MINE, "키": y}, "수량": 1}], + ) + sc = _raw( + me, + "precombo", + 이름="옛 SC", + 담은로직=[{"참조": {**MINE, "키": y}}, {"참조": {**MINE, "키": inner}}], + ) + dup = _raw( + me, "precombo", 이름="겹침", 담은로직=[{"로직": GF}, {"참조": {**MINE, "키": inner}}] + ) + before = {k: _sc_value(me, k) for k in (sc, dup)} | { + sk: _sk_total(me, _read(me, "sangeun", sk)) + } + look = {r["키"]: r for r in flat.migrate(False)} + assert all(look[k]["같음"] and look[k]["펼침"] and not look[k]["씀"] for k in (sk, sc, dup)) + rows = {r["키"]: r for r in flat.migrate(True)} + assert all(rows[k]["씀"] for k in (sk, sc, dup)) + assert not refs.refs_in(json.loads((me / "sangeun" / f"{sk}.json").read_bytes())) + for key in (sc, dup): # 하위 호표 = 사본 블록만 남음(원본 연결 없음) + left = refs.refs_in(json.loads((me / "precombo" / f"{key}.json").read_bytes())) + assert [(r["층"], r["키"]) for r in left] == [("사본", inner)] + assert _sk_total(me, _read(me, "sangeun", sk)) == pytest.approx(before[sk], rel=1e-9) + assert _sc_value(me, sc) == pytest.approx(before[sc], rel=1e-9) + assert _sc_value(me, dup) == pytest.approx(before[dup], rel=1e-9) + again = flat.migrate(True) # 두 번째 = 바꿀 것 없음 + assert all(r["펼침"] and not r["씀"] for r in again) + + +def test_733_지문_옛_참조_펼친_것_같음(world): + me = world["personal"] + y = _y(me) + old = _raw(me, "precombo", 이름="옛", 담은로직=[{"참조": {**MINE, "키": y}}, {"로직": GF}]) + new = _make(me, "precombo", 이름="새", 담은로직=[{"참조": {**MINE, "키": y}}, {"로직": GF}]) + assert not refs.refs_in(_read(me, "precombo", new)) + fingers = {k: same.finger("precombo", _read(me, "precombo", k)) for k in (old, new)} + assert fingers[old] == fingers[new] + + +def test_B09_SC_덮개_줄_대가_금액_M02_같음(world, tmp_path): + """SC 덮개 줄(SK 넣기 꼴) — 옛 B09 = 산근(덮개 안 읽음) · 이제 굳힌 줄 대가 = M02 값.""" + from B09_Estimation import B09_Estimation_Bill_Library as library + from resources.tester.test_719_2_sk_project import _b09, _project + + me = world["personal"] + cover = {"labor:LB000036#0": {"qty": 0.5}} + sc = _make( + me, + "precombo", + 이름="덮은 줄", + 담은로직=[{"로직": PCT, "패치": {"덮개": cover, "더한줄": [ADD]}}], + ) + m02 = _sc_value(me, sc) + bare = float(labor.run_line(_whole(), PCT, {})["기준"]["계"]) + assert m02 > bare * 2 # 덮개 · 더한줄이 값에 듦(옛 B09 산근 = bare) + root = _project(tmp_path / "proj", []) + made = library.import_copy(root, "precombo", "personal", "7/42", sc) + daega, done, _ = _b09(root) + block = done[daega["libs"][made["폴더"]]] + line = block["lines"][0] + assert line["kind"] == "daega" and block["blank"] == 1 # 안 줄 수량 = 이 공사에서 입력(723-4) + cover_qty = {block["cover"]: {line["cid"]: {"qty": 1}}} + block = _b09(root, cover_qty)[1][daega["libs"][made["폴더"]]] + assert block["blank"] == 0 + assert block["sum"] == pytest.approx(m02, abs=5) # 끝수(내림)만 다름 diff --git a/resources/tester/test_740_2_reset_delete_born.py b/resources/tester/test_740_2_reset_delete_born.py new file mode 100644 index 000000000..eca187d26 --- /dev/null +++ b/resources/tester/test_740_2_reset_delete_born.py @@ -0,0 +1,93 @@ +"""740-2 · 738-2 상세 머리 [초기화] [삭제] · 출처 꼬리표 지킴 시험(서버 · 화면 없음).""" + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +M02 = ROOT / "M02_MasterTemplete" +MAIN = (M02 / "M02_MasterTemplete_UI_Main.ts").read_text(encoding="utf-8") +ACTIONS = (M02 / "M02_MasterTemplete_Main_Actions.ts").read_text(encoding="utf-8") +HO = (ROOT / "M01_MasterData" / "M01_MasterData_UI_LogicLab_Ho.ts").read_text(encoding="utf-8") +COVER = (M02 / "logic" / "M02_Sangeun_Cover_Model.ts").read_text(encoding="utf-8") + + +def _born(expr: str): + """순수 TS(`logic/M02_Born_Tag.ts`)를 Node 로 돌려 JSON 답을 받음.""" + uri = (M02 / "logic" / "M02_Born_Tag.ts").as_uri() + code = ( + f"import {{ bornText, bornOfCid, bornNote }} from '{uri}';" + f"console.log(JSON.stringify({expr}));" + ) + out = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module", "-e", code], + capture_output=True, + text=True, + check=True, + cwd=ROOT, + ) + return json.loads(out.stdout) + + +BORN = { + "층": "personal", + "주인": "u1", + "키": "SK-000012", + "이름": "강관비계", + "때": "2026-11-02T06:30:12Z", +} + + +def test_꼬리표_글_첫줄_짧게_둘째줄_풍선(): + shown, tip = _born(f"bornText({json.dumps(BORN, ensure_ascii=False)})").split("\n") + assert shown == "본뜬 SK-000012" + assert "SK-000012 강관비계" in tip and "개인" in tip + assert re.search(r"\d{4}-\d\d-\d\d \d\d:\d\d", tip) # 이 컴퓨터 시각 · 시간대 따라 값이 다름 + + +def test_꼬리표_없으면_빈칸_기초번호_뒤에_붙음(): + assert _born("bornText(null)") == "" + assert _born("bornText(undefined, 'A1')") == "A1" + got = _born(f"bornText({json.dumps(BORN, ensure_ascii=False)}, 'A1')") + assert got.split("\n")[0] == "A1 · 본뜬 SK-000012" + + +def test_줄_열쇠에서_그_더한줄_출처를_찾음(): + adds = json.dumps([{}, {"본뜬": BORN}], ensure_ascii=False) + assert _born(f"bornOfCid('add:#1', {adds})").startswith("본뜬 SK-000012") + assert _born(f"bornOfCid('add:#1/add:#0', {adds})").startswith("본뜬 SK-000012") # 펼침 세부 + assert _born(f"bornOfCid('add:#0', {adds})") == "" + assert _born(f"bornOfCid('hopyo:X#3', {adds})") == "" + + +def test_머리_단추_차례_초기화_삭제_저장(): + kids = MAIN[MAIN.index('className: "m02-main__head"') :] + kids = kids[: kids.index("]")] + order = [kids.index(w) for w in ("reload,", "actions.reset,", "actions.del,", "save,")] + assert order == sorted(order) + assert "actions.update(" in MAIN # 쓰기 권한 없는 층 = 안 보임 + + +def test_삭제는_735_경고_창을_그대로_씀(): + assert "askLibraryDelete(" in ACTIONS and '"delete"' in ACTIONS + assert "sure ? { 확인: true }" in ACTIONS.replace( + "ans.sure", "sure" + ) # 확인 몸 = 쓰는 곳 줄까지 + assert "sangeunList(" in ACTIONS and "precomboList(" in ACTIONS # 지운 뒤 목록으로 + + +def test_초기화는_미리보기_뒤_물음_뒤_씀(): + assert ACTIONS.index("미리보기: true") < ACTIONS.index("미리보기: false") + assert "ctx.confirmLeave()" in ACTIONS # 저장 안 한 고침 먼저 물음 + assert "옵션 · 기본정보는 그대로" in ACTIONS + + +def test_공용_꼬리표_칸은_첫줄만_보이고_풍선에_나머지(): + assert 'text.split("\\n")' in HO + assert "bornOfCid(" in COVER + + +def test_격자_비고_칸은_첫줄만(): + assert _born(f"bornNote({json.dumps(BORN, ensure_ascii=False)})") == "본뜬 SK-000012" + assert _born("bornNote(null)") == "" diff --git a/resources/tester/test_743_1_hopyo_forget.py b/resources/tester/test_743_1_hopyo_forget.py new file mode 100644 index 000000000..a8b6c63a0 --- /dev/null +++ b/resources/tester/test_743_1_hopyo_forget.py @@ -0,0 +1,157 @@ +"""불러오기 엑셀 보관 안 함(PLAN 743-1 · 요구 582-3 · 584-2 · `M02_Hopyo_Forget`). + +[끝내기] = 남는 파일 0 · 만든 일위대가 · 단가산출 = 출처(파일 · 때 · 누가)만 · 금액 그대로 · +엑셀 사본(본뜬 키 · 원본 · 확인 · 품셈 · 줄 메모 「실무 …」) 걷음 · +실무수량 = 저장 수량과 같을 때만 걷음 · SK 또 = 그 HP 몫만 · +하루 지난 HP = 목록 숨김 → 올림 때 지움 · 옛 파일 정리 미리 보기(쓰기 0) → 고침. +층 = 임시 폴더. +""" + +import json +from datetime import datetime, timedelta, timezone + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Hopyo_Fit as fit +from M02_MasterTemplete import M02_Hopyo_Forget as forget +from M02_MasterTemplete import M02_Hopyo_Store as hs +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_hopyo_excel import URL, _post, client # noqa: F401 + +ALL = "/api/m02/layers/personal/hopyo-build-all" +SK_URL = URL.format("personal") + "/HP-000001/sangeun" + + +def _files(folder): + return sorted(p.name for p in (folder / hs.DIRNAME).rglob("HP-*.json")) + + +def _sums(folder): + """문서마다 1 단위 기준 계 — 끝내기 전후 같아야 함.""" + whole = refs.Expand(m01.loaded()[1]) + out = {} + with store.at(folder): + for kind in ("precombo", "sangeun"): + for p in sorted((folder / kind).glob("*.json")): + out[p.stem] = refs.summary(folder, kind, p.stem, whole).get("기준금액") + return out + + +def _docs(folder, kind): + return {p.stem: json.loads(p.read_bytes()) for p in sorted((folder / kind).glob("*.json"))} + + +# ── 순수 ────────────────────────────────────────────────────────────── +SURE = {"누가": {"id": 42, "이름": "갑"}, "때": "2026-11-02T00:00:00Z"} +HEAD = {"층": "실무자료", "주인": None, "키": "HP-000003 제2호표", "파일": "가.xls"} + + +def test_SC_사본_걷음_출처만_실무수량은_저장_수량과_같을_때만(): + lines = [ + {"로직": "L1", "메모": "실무 0.3㎥ × 1 · 규격", "수량": {"손": 0.3}, "별칭": "엑셀 품"}, + {"로직": "L2", "메모": "사용자 메모", "수량": {"손": 2}}, + ] + doc = { + "이름": "가", + "담은로직": lines, + "자재": [{"코드": "MT1", "수량": {"손": 5}}], + "본뜬": {**HEAD, "원본": ["personal", "7/42"], "확인": SURE, "품셈": [0], + "전국자재": [{"이름": "모래"}], "실무수량": {"줄": {"0": 0.3, "1": 2}, + "자재": {"MT1": 5}}}, + } # fmt: skip + got = forget.strip_sc(doc) + assert got["본뜬"] == { + "층": "실무자료", + "파일": "가.xls", + "전국자재": [{"이름": "모래"}], + "때": SURE["때"], + "누가": SURE["누가"], + } + assert "메모" not in got["담은로직"][0] and got["담은로직"][1]["메모"] == "사용자 메모" + assert got["담은로직"][0]["별칭"] == "엑셀 품" and got["담은로직"][0]["수량"] == {"손": 0.3} + assert got["자재"] == doc["자재"] + # 742 전 저장본(수량 null) = 실무수량이 셈 근거 → 남김 + old = {**doc, "담은로직": [{**x, "수량": None} for x in lines]} + assert forget.strip_sc(old)["본뜬"]["실무수량"] == doc["본뜬"]["실무수량"] + + +def test_SK_또_그_HP_몫만_걷음(): + other = {"키": "HP-000004 단산1", "원본": None, "갈래": "SK", "엑셀품": {}, "이름": "나"} + doc = {"본뜬": {**HEAD, "키": "HP-000003 단산1", "확인": SURE, "갈래": "기초", "엑셀품": {}, + "못옮김": [], "또": [other]}} # fmt: skip + got = forget.strip_sk(doc, lambda x: str(x.get("키")).startswith("HP-000003 ")) + assert got["본뜬"] == {**{"층": "실무자료", "파일": "가.xls"}, **SURE, "또": [other]} + both = forget.strip_sk(doc, lambda x: True) + assert "또" not in both["본뜬"] and "키" not in both["본뜬"] + + +# ── 길 ─────────────────────────────────────────────────────────────── +def test_끝내기_남는_파일_0_금액_그대로_출처만_다시_올림(client): + folder = layers.personal_dir(7, 42) + assert _post(client).json()["키"] == "HP-000001" + fit.run_job(folder, "HP-000001") + assert client.post(SK_URL).status_code == 200 + made = client.post(ALL, json={"층": "personal", "키": "HP-000001"}).json() + assert made["만듦"] + before = _sums(folder) # 모두 고정 = 메모 없음(메모 걷음은 순수 시험) + cache = fit.cache_path(folder, "HP-000001") + assert cache.is_file() + + res = client.delete(URL.format("personal") + "/HP-000001") + assert res.status_code == 200 + assert _files(folder) == [] and not cache.exists() # HP · 격자 · 맞춤셈 캐시 0 + assert _sums(folder) == before # 금액 그대로 + for kind in ("precombo", "sangeun"): + for key, doc in _docs(folder, kind).items(): + got = doc["본뜬"] + assert got["층"] == "실무자료" and got["파일"] and got["때"], key + assert not {"키", "원본", "확인", "품셈", "갈래", "엑셀품", "못옮김", "또"} & set(got) + lines = doc.get("담은로직") or [] + assert not any(str(x.get("메모", "")).startswith("실무 ") for x in lines) + assert hs.done(folder)["지문"] == {} # 끝낸 기록 없음 + again = _post(client) # 같은 엑셀 다시 올림 = 새 키(409 없음) + assert again.status_code == 200 and again.json()["키"] == "HP-000002" + + +def test_하루_지난_HP_목록_숨김_올림_때_지움(client): + folder = layers.personal_dir(7, 42) + assert _post(client).json()["키"] == "HP-000001" + path = folder / hs.DIRNAME / "HP-000001.json" + doc = json.loads(path.read_bytes()) + old = datetime.now(timezone.utc) - timedelta(days=1, minutes=1) + doc["올린때"] = old.replace(microsecond=0).isoformat().replace("+00:00", "Z") + path.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8") + assert client.get(URL.format("personal")).json()["줄"] == [] # 숨김 + assert forget.expired(folder) == ["HP-000001"] + got = _post(client).json() # 같은 엑셀이어도 지난 것 = 지우고 새로(이어서 아님) + assert got["키"] == "HP-000002" and not got.get("이어서") + assert _files(folder) == ["HP-000002.json", "HP-000002.json"] # 이번 것 + 격자 + + +def test_옛_파일_정리_미리_보기_쓰기_0_고침(client): + folder = layers.personal_dir(7, 42) + assert _post(client).json()["키"] == "HP-000001" # 작업 중 — 그대로 + home = folder / hs.DIRNAME + (home / "HP-000007.json").write_text(json.dumps({"키": "HP-000007", "호표": []}), "utf-8") + hs._put_done(folder, {"번": 7, "지문": {"ab": {"파일": "옛.xls", "만든": ["SC-000001"]}}}) + (folder / "precombo").mkdir(exist_ok=True) + gone = {"이름": "옛", "단위": "개", "담은로직": [{"로직": "L", "메모": "실무 1개 × 1"}], + "본뜬": {**HEAD, "키": "HP-000005 제1호표", "확인": SURE}} # fmt: skip + live = {**gone, "본뜬": {**HEAD, "키": "HP-000001 제1호표", "확인": SURE}} + for key, doc in (("SC-000001", gone), ("SC-000002", live)): + text = json.dumps(doc, ensure_ascii=False) + (folder / "precombo" / f"{key}.json").write_text(text, "utf-8") + snap = {p: p.read_bytes() for p in folder.rglob("*.json")} + + rows = forget.tidy(folder) + assert [r["대상"] for r in rows] == ["실무자료/HP-000007", "불러옴.json", "SC-000001"] + assert {p: p.read_bytes() for p in folder.rglob("*.json")} == snap # 미리 보기 = 쓰기 0 + + assert forget.tidy(folder, apply=True) == rows + assert _files(folder) == ["HP-000001.json", "HP-000001.json"] + assert hs.done(folder) == {"번": 7, "지문": {}} + docs = _docs(folder, "precombo") + assert "키" not in docs["SC-000001"]["본뜬"] and "메모" not in docs["SC-000001"]["담은로직"][0] + assert docs["SC-000002"] == live # 작업 중 HP 것 그대로 + assert forget.tidy(folder) == [] # 두 번째 = 할 일 없음 diff --git a/resources/tester/test_744_1_sk_detail_undo.py b/resources/tester/test_744_1_sk_detail_undo.py new file mode 100644 index 000000000..9da898dbf --- /dev/null +++ b/resources/tester/test_744_1_sk_detail_undo.py @@ -0,0 +1,89 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""744-1 서버 — 둘째 기초 세부 품 원문 · 되돌리기 기억(임시 storage · M01 읽기만). + +- 둘째 기초 `펼침` 줄 `원문` = 그 기초 1 기준단위 몫(GF000316 품 그대로) · 줄 수량 · 금액 = 머리 + 수량 곱한 몫(상자 합) 그대로 · 머리 수량 = 더한줄 수량 +- 더한줄 `처음수량` = 넣을 때 수량(없으면 저장 때 지금 수량) · 셈 · 지문 안 듦 +- `뺀더한줄 [{차례, 줄}]` = [기초 빼기] · 더한 항목 빼기 기억 · 셈 · 지문 안 듦 · 못 읽는 줄 버림 · + 일위대가 줄 패치도 같음 +""" + +import pytest + +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 +from M02_MasterTemplete import M02_Library_Same as same +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Sangeun_Calc as calc +from resources.tester.test_m02_labor_ref import world # noqa: F401 + +GF = "GF000099" # 원/㎥ +BIG = "GF000316" # 기준단위 100㎥ — ㎥ 바탕에서 처음 수량 0.01 +PCT = "GC001109" +MAN = {"종류": "인력", "요소": "LB000002", "수량": 1} + + +def _sk(folder, **doc): + with store.at(folder): + made = refs.create("sangeun", {"이름": "744", "단위": "㎥", "더한줄": [], **doc}) + return store.read("sangeun", made["이름"])["문서"] + + +def _calc(folder, doc): + with store.at(folder): + return calc.calc_doc(doc) + + +def test_둘째_기초_세부_원문_품_머리_수량(world): + doc = _sk( + world["personal"], 바탕={"로직": GF}, 더한줄=[{"종류": "로직", "로직": BIG, "수량": 0.01}] + ) + got = _calc(world["personal"], doc) + head = next(x for x in got["줄"] if x["cid"] == "add:#0") + m01_lines = labor.run_line(m01.loaded()[1], BIG, {})["줄"] + assert head["수량"] == pytest.approx(0.01) + for row, line in zip(head["펼침"], m01_lines): + assert row["원문"]["수량"] == pytest.approx(float(line["수량"]), rel=1e-9) # 원문 그대로 + assert row["수량"] == pytest.approx(float(line["수량"]) * 0.01, rel=1e-9) # 상자 몫 + assert sum(x["금액"] for x in head["펼침"]) == pytest.approx(head["금액"], rel=1e-9) + assert sum(x["원문"]["금액"] for x in head["펼침"]) * 0.01 == pytest.approx(head["금액"]) + + +def test_처음수량_넣을때_값_셈_지문_안_듦(world): + me = world["personal"] + plain = _sk(me, 바탕={"로직": PCT}, 단위="㎡", 더한줄=[{**MAN, "수량": 3}]) + assert plain["더한줄"][0]["처음수량"] == 3 # 없으면 저장 때 지금 수량 + kept = _sk(me, 바탕={"로직": PCT}, 단위="㎡", 더한줄=[{**MAN, "수량": 3, "처음수량": 1}]) + assert kept["더한줄"][0]["처음수량"] == 1 + assert _calc(me, kept)["기준"]["계"] == _calc(me, plain)["기준"]["계"] + assert same.finger("sangeun", kept) == same.finger("sangeun", plain) + + +def test_뺀더한줄_기억_셈_지문_안_듦_못읽는_줄_버림(world): + me = world["personal"] + gone = [ + {"차례": 0, "줄": {"종류": "로직", "로직": BIG, "수량": 0.01, "덮개": {}}}, + {"차례": 1, "줄": MAN}, + {"차례": -1, "줄": MAN}, # 차례 틀림 + {"차례": 2, "줄": {"종류": "로직", "로직": "GX999999", "수량": 1}}, # 없는 기초 + {"차례": 3}, # 줄 없음 + ] + doc = _sk(me, 바탕={"로직": GF}, 뺀더한줄=gone) + assert [x["차례"] for x in doc["뺀더한줄"]] == [0, 1] + assert doc["뺀더한줄"][1]["줄"]["요소"] == "LB000002" + bare = _sk(me, 바탕={"로직": GF}) + assert _calc(me, doc)["기준"]["계"] == _calc(me, bare)["기준"]["계"] + assert same.finger("sangeun", doc) == same.finger("sangeun", bare) + + +def test_일위대가_줄_패치_기억_처음수량(world): + me = world["personal"] + patch = {"더한줄": [{**MAN, "수량": 2}], "뺀더한줄": [{"차례": 0, "줄": MAN}]} + with store.at(me): + sc = refs.create( + "precombo", {"이름": "744", "단위": "㎡", "담은로직": [{"로직": PCT, "패치": patch}]} + ) + line = store.read("precombo", sc["이름"])["문서"]["담은로직"][0] + assert line["패치"]["더한줄"][0]["처음수량"] == 2 + assert line["패치"]["뺀더한줄"][0]["줄"]["요소"] == "LB000002" diff --git a/resources/tester/test_747_1_sk_material.py b/resources/tester/test_747_1_sk_material.py new file mode 100644 index 000000000..5657796ff --- /dev/null +++ b/resources/tester/test_747_1_sk_material.py @@ -0,0 +1,50 @@ +"""SK 바탕 재료 = M01 기초 그대로 셈(PLAN 747-1) — 별도계상 빈 칸 = 직접계산 · 고른 줄만 밖 · +일위대가 덮개 줄(`M02_Labor_Patch.run`)은 일위대가 기본(별도계상) 그대로.""" + +import pytest + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Patch as patch +from M02_MasterTemplete import M02_Sangeun_Calc as calc +from M02_MasterTemplete import M02_Sangeun_Doc as sd + +FORM = "GC000211" # 6-3-1 합판거푸집 — 합판 1.03 · 각재 0.038 · 폼타이 0 · 소모자재 = 재료비 % +PLY = "material:합판(내수 12㎜)#0" + + +def _whole(): + return m01.loaded()[1] + + +def _run(**doc): + whole = _whole() + clean = sd.clean({"키": "SK-000001", "이름": "시험", "단위": "㎡", **doc}, None, whole) + return labor._json(calc.run(clean, whole)) + + +def test_base_materials_count_like_m01(): + m01_ = labor._json(labor.run_line(_whole(), FORM, None)) + got = _run(바탕={"로직": FORM}) + assert got["기준"]["재료비"] == pytest.approx(m01_["기준"]["재료비"]) + assert got["기준"]["계"] == pytest.approx(m01_["기준"]["계"]) + qty = {x["cid"]: x["수량"] for x in got["줄"]} + assert qty[PLY] == pytest.approx(1.03) + assert qty["material:폼타이 12.7㎜#0"] == 0 # M01 수량 0 = 0 그대로 + + +def test_cover_and_explicit_apart(): + base = _run(바탕={"로직": FORM}) + more = _run(바탕={"로직": FORM}, 덮개={PLY: {"qty": 2}}) + ply = next(x for x in base["줄"] if x["cid"] == PLY) + assert more["기준"]["재료비"] > base["기준"]["재료비"] + ply["단가"] * 0.9 + out = _run(바탕={"로직": FORM}, 자재표시={PLY: {"별도계상": "별도계상"}}) + assert all(x["cid"] != PLY for x in out["줄"]) + assert out["기준"]["재료비"] < base["기준"]["재료비"] - ply["금액"] + 1 + + +def test_hopyo_line_keeps_line_default(): + whole = _whole() + line = {"로직": FORM, "입력값": {}, "패치": {"덮개": {PLY: {"qty": 2}}}} + got = labor._json(patch.run(whole, line)) + assert all(x["cid"] != PLY for x in got["줄"]) # 일위대가 기본 = 별도계상(222-1) diff --git a/resources/tester/test_m02_hopyo_build.py b/resources/tester/test_m02_hopyo_build.py index d2103a934..c37f6dc43 100644 --- a/resources/tester/test_m02_hopyo_build.py +++ b/resources/tester/test_m02_hopyo_build.py @@ -253,7 +253,9 @@ def test_하위_호표_먼저_확인_퍼센트_빠짐_통째(client): mine = layers.personal_dir(7, 42) made = _doc(mine, got["참조"]["키"]) line = made["담은로직"][0] - assert line["참조"] == child["참조"] and line["별칭"] == "고화매트재료비" + # 740-1 하위 호표 = 사본 블록(원본 연결 없음 · 키 = 출처) + assert line["참조"]["층"] == "사본" and line["참조"]["키"] == child["참조"]["키"] + assert line["본뜬"]["키"] == child["참조"]["키"] and line["별칭"] == "고화매트재료비" assert line["수량"] == {"손": 2.3} and made["본뜬"]["실무수량"]["줄"]["0"] == 2.3 # 742-1 assert "고화매트재료비||m2" not in _book(mine) # 호표 참조 줄 = 장부 안 씀 whole = _build(client, 3, 통째={"대상": [{"로직": "GF000101", "배": 1}]}) diff --git a/resources/tester/test_m02_hopyo_match.py b/resources/tester/test_m02_hopyo_match.py index dfc80ec41..e2cc9988c 100644 --- a/resources/tester/test_m02_hopyo_match.py +++ b/resources/tester/test_m02_hopyo_match.py @@ -433,3 +433,15 @@ def test_목록_열_때_뒤_셈_예약(client, monkeypatch): monkeypatch.setattr(fit, "ensure", lambda folder, key: asked.append(key) or {"끝": False}) rows = client.get(URL.format("personal")).json()["줄"] assert [r["키"] for r in rows] == asked == ["HP-000001"] + + +def test_미리_고름_점은_다른_점_1위_출처같음만(client): # 745-1 · 584-3 + _ready(client) + got = _hopyo(client, 3) + for name in ("구조물터파기(인력100%)", "구조물되메우기(인력100%)"): # 다른 점 + 출처 같음 + cands = got[name]["후보"] + assert got[name]["상태"] == "다른 점" and cands[0]["미리"] is True + assert not any("미리" in c for c in cands[1:]) # 1위만 + assert all("미리" not in c for c in got["잔토처리(인력100%)"]["후보"]) # 후보 줄 = 점 없음 + ok = _hopyo(client, 1)["레미콘타설(장비사용)"] # 맞음 줄도 점 없음(이미 체크) + assert ok["상태"] == "맞음" and all("미리" not in c for c in ok["후보"]) diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index 429ecb39a..a6710daec 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -538,8 +538,12 @@ def test_명사형_단추와_별칭_장부_풀이_359(): def test_372_2_저장은_개인_층에_삭제는_화면_층_권한(): - """718-1 — 만들 층 = 마법사에서 고른 층(올린 층과 같음) · [삭제] = 끝내기 → 처음 걸음.""" + """718-1 — 만들 층 = 마법사에서 고른 층(올린 층과 같음) · [삭제] = 끝내기 → 처음 걸음 · + 743-1 — 지운 파일은 떠남 지움 목록에서 뺌 · 떠남(화면 바뀜 · 페이지 닫음) = 쥔 파일 지움.""" side = _text("M02_MasterTemplete_Hopyo_Wizard.ts") open_ = side.split("m.openHopyoSheet({")[1].split("}),")[0] assert "target: scope," in open_ and "canWrite: true," in open_ - assert "onDeleted: () => void first()," in open_ + assert "drop(layer, file.키);" in open_ and "void first();" in open_ + assert 'addEventListener("pagehide", leave)' in side and "return { root, leave };" in side + panel = _text("M02_MasterTemplete_Hopyo_Side.ts") + assert panel.count("open?.leave?.()") == 1 and panel.count("open.leave?.()") == 1 diff --git a/resources/tester/test_m02_labor_ref.py b/resources/tester/test_m02_labor_ref.py index 6907dd7d6..cce49275a 100644 --- a/resources/tester/test_m02_labor_ref.py +++ b/resources/tester/test_m02_labor_ref.py @@ -179,18 +179,18 @@ def test_사전조합_틀린_줄_400_순환_422(world): with pytest.raises(store.StoreError) as e: _combo(world["system"], lines) assert e.value.status == 400 + # 740-1 — 사전조합에 사전조합 = 사본 블록(원본 연결 없음) · 되돌려 담아도 순환 없음 a = _combo(world["system"], [one], "가") b = _combo(world["system"], [{"참조": {**SYS, "키": a}}], "나") with store.at(world["system"]): + line = store.read("precombo", b)["문서"]["담은로직"][0] + assert line["참조"]["층"] == "사본" and line["본뜬"]["키"] == a + assert line["참조"]["몸"]["담은로직"][0]["참조"] == one["참조"] have = store.read("precombo", a) - with pytest.raises(store.StoreError) as e: - store.write( - "precombo", - a, - have["판"], - {**have["문서"], "담은로직": [{"참조": {**SYS, "키": b}}]}, - ) - assert e.value.status == 422 and f"{a} → {b} → {a}" in e.value.detail + store.write( + "precombo", a, have["판"], {**have["문서"], "담은로직": [{"참조": {**SYS, "키": b}}]} + ) + assert store.read("precombo", a)["문서"]["담은로직"][0]["참조"]["층"] == "사본" def test_층_규칙은_위_층만(world): @@ -331,11 +331,12 @@ def test_복사는_못_읽는_참조를_같이_복사_본뜬(world): ) got = _read(world["company"], "precombo", made["이름"]) assert got["본뜬"] == {**MINE, "키": sc} - assert [one["참조"] for one in got["담은로직"]] == [ - {**COMPANY, "키": "SC-000001"}, # 회사가 못 읽는 개인 사전조합 → 회사 층 새 키 - {**SYS, "키": sys_sp}, # 마스터는 그대로(옛 승격도) - ] - assert _read(world["company"], "precombo", "SC-000001")["본뜬"] == {**MINE, "키": inner} + # 740-1 — 개인 사전조합은 넣을 때 사본 블록(본뜬) → 회사로 따로 복사 안 함 · + # 마스터 옛 승격 그대로 + block = got["담은로직"][0] + assert block["참조"]["층"] == "사본" and block["본뜬"]["키"] == inner + assert got["담은로직"][1]["참조"] == {**SYS, "키": sys_sp} + assert made["이름"] == "SC-000001" # 딸림 복사 없음 # 못 읽는 옛 개인 승격은 따라 복사 안 함(134-2 · 승격 서고 없음) mine_sp = _promote(world["personal"]) with store.at(world["company"]), pytest.raises(store.StoreError) as e: diff --git a/resources/tester/test_m02_precombo_scope.py b/resources/tester/test_m02_precombo_scope.py index f3233f657..bd96219af 100644 --- a/resources/tester/test_m02_precombo_scope.py +++ b/resources/tester/test_m02_precombo_scope.py @@ -119,7 +119,9 @@ def test_분류_고정_폴더_지움_자유_폴더_메뉴_끌어_옮기기(): assert 'item(L("M02_Delete"), () => void remove([target]))' in menu assert 'opt.scope().canWrite ? undefined : tc("ReadOnly")' in menu # 읽기만 층 = 흐림 remove = side.split("const remove = async (")[1].split("\n };")[0] - assert "showConfirmDialog(ask" in remove and 'op: "delete", 대상:' in remove # 안에 든 것째 + assert ( + "askLibraryDelete(kind," in remove and 'op: "delete", 대상:' in remove + ) # 735 쓰는 곳 경고 뒤 그 줄째 assert 'newHere: tc("Lib_NewHere")' in pre_side assert '({ 이름: name, 구분: "", 상세구분: "", 단위: null, 담은로직: [] })' in pre_side make = side.split("const make = (path: string[]): void => {")[1].split("\n };")[0]