diff --git a/M02_MasterTemplete/M02_Labor_SkRow.py b/M02_MasterTemplete/M02_Labor_SkRow.py new file mode 100644 index 000000000..0b91dea82 --- /dev/null +++ b/M02_MasterTemplete/M02_Labor_SkRow.py @@ -0,0 +1,289 @@ +"""일위대가 단가산출 줄 = SK 참조 한 행(PLAN 811-1 · 요구 707-1 · 681 · 698). + +일위대가(SC `precombo`) · 구조물 조합 일위대가(`labor`)의 단가산출 줄 = `{참조: {층, 주인, SK}, 별칭, 메모, 수량}` 한 행 · +저장 때 펼치지 않음(`M02_Library_Flat.flat_doc` SC = `sk=False`) · 셈 = 그 SK 1 단위 × 수량(`M02_Labor_Expand`) · +줄 누름 = 그 SK 「수식」 모달(화면 `Labor_SkEdit`). +M01 로직 줄(옛 저장본 · 740-1 로 펼쳐 담은 줄) → 그 층 SK 문서(복사해 내 것 · 681): + 몸 = `M02_Labor_Patch.to_sangeun`(바탕 로직 · 입력값 · 고름 · 자재표시 · 켜쌓기율 · 패치 덮개 · 뺀줄 · 더한줄) · + 이름 = 별칭 › 로직 이름 · 단위 = 바탕 기준단위(앞 수 뗌 · 원/10㎡ → ㎡ = 일위대가 로직 줄 ÷ 배와 같은 값) · + 자재표시 빈 재료 = 일위대가 기본(별도계상)을 적어 둠(SK 빈 칸 = 직접계산이라 금액이 바뀜) · + 사본 · 수식 = 그 일위대가 것을 실어 보냄(같은 값 묶음 · M01 이 바뀌었어도 금액 그대로) +옮기기(`migrate` · 한 번) = 층(마스터 · 회사 · 개인) SC · 구조물 조합 일위대가의 로직 줄 → SK + 참조 한 행 · +같은 몸 · 같은 사본 = 한 층 안 SK 하나 공유 · 면적산출 칸 잇기(수량 빈 줄) = 옛 알약 키 → 참조 알약 키 · +전후 금액(원 단위 · 줄마다) 같을 때만 씀 · 다르면 공유 없이 한 번 더 · 그래도 다르면 안 옮기고 목록 · +프로젝트(B09 사본)는 손대지 않음 · 보기 = 임시 storage 사본에서 실제로 옮겨 봄(공유 storage 안 씀). + `python -m M02_MasterTemplete.M02_Labor_SkRow [--apply] [--backup <폴더>] [--xlsx <파일>]` +""" + +from __future__ import annotations + +import json +import shutil +from contextlib import contextmanager +from decimal import Decimal +from pathlib import Path +from typing import Any + +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 + +KINDS = ("precombo", "labor") +CARRY = ("사본", "수식") # 일위대가 값 묶음 → 새 SK(같은 금액) + + +def _whole() -> Any: + from M01_MasterData import M01_MasterData_Store as m01 + + return m01.loaded()[1] + + +def logic_doc(one: dict, doc: dict, whole: Any) -> dict[str, Any]: + """로직 줄 `one`(일위대가 `doc` 안) → SK 문서 몸(키 없음 · 저장은 부르는 쪽).""" + 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 + + code = str(one["로직"]) + row = whole.logic(code) + shown = dict(one.get("자재표시") or {}) + for n, item in enumerate(row.get("호표") or []): + if item.get("종류") == "재료": + mine = shown.get(str(n)) + shown[str(n)] = {**labor.marks(mine), "별도계상": labor.mark_of(mine, "별도계상")} + unit = calc.base_unit(row) + name = str(one.get("별칭") or row.get("이름") or code) + sk = patch.to_sangeun({**one, "자재표시": shown}, name, unit[1] if unit else "식", whole) + return {**sk, **{k: doc[k] for k in CARRY if doc.get(k)}} + + +def from_logic(code: Any, new: bool = False, use: str | None = None) -> dict[str, Any]: + """[+ 줄] M01 기초 고름 — 지금 층에 SK 사본(같은 내용 = 409 물음 · 733-2) → `{이름: SK 키, 참조}`.""" + from M02_MasterTemplete import M02_Library_Ask as ask + + whole = _whole() + try: + whole.logic(str(code)) + except Exception as e: # noqa: BLE001 — 없는 로직 + raise store.StoreError(400, f"없는 기초 「{code}」") from e + sk = logic_doc({"로직": str(code), "입력값": {}}, {}, whole) + key = ask.ask("sangeun", sk, new, use) or refs.create("sangeun", sk)["이름"] + at = refs.here() + return {"이름": key, "참조": {"층": at[0], "주인": at[1], "키": key}} + + +def ref_line(one: dict, ref: refs.Ref) -> dict[str, Any]: + """옛 로직 줄 → 참조 한 행(별칭 · 메모 · 수량 그대로).""" + return {"참조": ref, **{k: one.get(k) for k in ("별칭", "메모", "수량")}} + + +def _finger(sk: dict) -> str: + """한 층 안 공유 열쇠 — 몸 + 사본 판(값 묶음이 같아야 같은 금액).""" + copy = sk.get("사본") or {} + body = {k: v for k, v in sk.items() if k not in CARRY} + tag = (copy.get("판"), copy.get("단가판")) if isinstance(copy, dict) else None + return json.dumps([body, tag], ensure_ascii=False, sort_keys=True, default=str) + + +def convert( + doc: dict, made: dict[str, str] | None, whole: Any +) -> tuple[dict, dict[str, str], list]: + """지금 층(`store.at` 안) 문서의 로직 줄 → SK(만들거나 `made` 에서 공유) + 참조 한 행 · + 답 = (새 문서, {옛 알약 키: 새 알약 키}(수량 빈 줄), [만든 SK 키]).""" + from M01_MasterData import M01_MasterData_Store_Combo as combo_store + + lines = [x for x in doc.get("담은로직") or [] if isinstance(x, dict)] + pills = combo_store.combo_pills({"담은로직": lines})["물량"] + by_logic = {code: p["키"] for p in pills for code in p.get("로직") or []} + at, out, moved, new = refs.here(), [], {}, [] + for one in doc.get("담은로직") or []: + if not (isinstance(one, dict) and one.get("로직")): + out.append(one) + continue + sk = logic_doc(one, doc, whole) + key = (made or {}).get(_finger(sk)) + if key is None: + key = refs.create("sangeun", sk)["이름"] + new.append(key) + if made is not None: + made[_finger(sk)] = key + ref = {"층": at[0], "주인": at[1], "키": key} + out.append(ref_line(one, ref)) + if one.get("수량") is None and by_logic.get(one["로직"]): + moved.setdefault(by_logic[one["로직"]], []).append(refs.ref_id(ref)) + return {**doc, "담은로직": out}, moved, new + + +def relink(area: dict, moved: dict[str, list[str]]) -> dict: + """면적산출 칸 잇기 — 옛 로직 알약 키 칸을 새 참조 알약 키에도(같은 칸 · 옛 잇기는 뗌).""" + sheets = [] + for sheet in area.get("시트") or []: + links = [] + for link in sheet.get("연결") or []: + keys = moved.get(str(link.get("키"))) if isinstance(link, dict) else None + links += [{**link, "키": k} for k in keys] if keys else [link] + sheets.append({**sheet, "연결": links} if "연결" in sheet else sheet) + return {**area, "시트": sheets} + + +# ── 금액(전후 대조) ──────────────────────────────────────────────────── +def _won(v: Any) -> Any: + return None if v is None else int(Decimal(str(v))) + + +def _unit(line: dict) -> Any: + """줄 1 단위 값(기준 ÷ 배 · 0.01 원).""" + got = (line.get("기준") or {}).get("계") + times = Decimal(str(line.get("배") or 1)) + return None if got is None else str((Decimal(str(got)) / times).quantize(Decimal("0.01"))) + + +def money(kind: str, key: str, doc: dict) -> dict[str, Any]: + """지금 층 문서 금액 — 조합 = 줄마다 반영 · 합(면적산출 칸 값) · SC = 1 단위(안 줄 저장 수량 · + 빈 수량 = 1).""" + from M02_MasterTemplete import M02_Labor_Doc as labor + from M02_MasterTemplete.M02_Labor_Expand import Expand, inner_pills + + if kind == "labor": # 기준합 = 줄마다 단위가 달라(로직 줄 결과단위 · 참조 줄 1 단위) 안 견줌 + got = labor.calc(doc) + rows = [[_won((x.get("반영") or {}).get("계")), _unit(x)] for x in got.get("줄") or []] + return {"줄": rows, "합": _won((got.get("반영합") or {}).get("계"))} + at = refs.here() + ref = {"층": at[0], "주인": at[1], "키": key} + given = {p["키"]: 1 for p in inner_pills(ref)} + out = {} + for name, use in (("저장", {}), ("하나", given)): + base = Expand(_whole(), refs.read_ref, use)(ref).get("기준") or {} + out[name] = _won(base.get("계")) + return out + + +def _path(kind: str, key: str) -> Path: + return store.root() / kind / f"{key}.json" + + +def _write(path: Path, doc: dict) -> None: + path.write_text(json.dumps(doc, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _one(kind: str, path: Path, made: dict[str, str] | None, whole: Any) -> dict[str, Any]: + """문서 하나 옮김(지금 층에 씀) — 전후 금액 · 다르면 되돌림(만든 SK 지움 · 파일 · 면적 원래대로).""" + raw = path.read_bytes() + doc = json.loads(raw) + key = path.stem + before = money(kind, key, doc) + area_path = _path("area", str(doc.get("열") or key)) if kind == "labor" else None + area_raw = area_path.read_bytes() if area_path and area_path.is_file() else None + got, moved, new = convert(doc, made, whole) + path.write_text(json.dumps(got, ensure_ascii=False, indent=2), encoding="utf-8") + if area_raw is not None and moved: + _write(area_path, relink(json.loads(area_raw), moved)) + after = money(kind, key, got) + same = before == after + if not same: # 되돌림 — 이번에 만든 SK 지움(공유 기억도) + path.write_bytes(raw) + if area_raw is not None: + area_path.write_bytes(area_raw) + for sk in new: + _path("sangeun", sk).unlink(missing_ok=True) + if made is not None: + for f in [f for f, k in made.items() if k in new]: + made.pop(f) + sks = {r["키"] for r in refs.refs_in(got) if str(r.get("키"))[:2] == "SK"} if same else set() + return { + "줄": sum(1 for x in doc.get("담은로직") or [] if isinstance(x, dict) and x.get("로직")), + "SK": sorted(sks), + "새SK": new if same else [], + "알약": sum(len(v) for v in moved.values()) if area_path else 0, + "전": before, + "후": after, + "같음": same, + } + + +def run_here(whole: Any = None) -> list[dict[str, Any]]: + """지금 설정 storage 의 층 문서 전부 옮김(쓰기) — 줄마다 결과.""" + whole, out = whole or _whole(), [] + shared: dict[tuple, dict[str, str]] = {} + for kind in KINDS: + for where, path in layers.layer_files(kind): + try: + doc = json.loads(path.read_bytes()) + except (OSError, ValueError): + continue + if not any(isinstance(x, dict) and x.get("로직") for x in doc.get("담은로직") or []): + continue + folder = path.parent.parent + made = shared.setdefault(refs.where(folder), {}) + with store.at(folder): + row = _one(kind, path, made, whole) + if not row["같음"]: # 공유 없이 한 번 더 + row = {**_one(kind, path, None, whole), "공유없음": True} + out.append({"어디": where, "종류": kind, "키": path.stem, **row}) + return out + + +@contextmanager +def _sandbox(root: Path): + """층 폴더(마스터 · 회사 · 개인 templates)만 임시 storage 로 복사 → 설정을 그리로.""" + from config import config_system + + old = (config_system.STORAGE_BASE_DIR, layers.SYSTEM_ROOT, store.FOLDER) + src = layers.storage_root() + shutil.copytree(layers.system_dir(), root / "system") + for tpl in [*src.glob("*/templates"), *src.glob("*/*/templates")]: + shutil.copytree(tpl, root / "storage" / tpl.relative_to(src)) + config_system.STORAGE_BASE_DIR = str(root / "storage") + layers.SYSTEM_ROOT = store.FOLDER = root / "system" + try: + yield + finally: + config_system.STORAGE_BASE_DIR, layers.SYSTEM_ROOT, store.FOLDER = old + + +def migrate(apply: bool = False, backup: Path | None = None, work: Path | None = None) -> list: + """보기(`apply` False) = 임시 storage 에서 옮겨 봄 · 씀 = `backup` 에 층 폴더 사본을 떠 둔 뒤 실제 층.""" + if not apply: + import tempfile + + with tempfile.TemporaryDirectory(dir=work) as tmp, _sandbox(Path(tmp)): + return run_here() + if backup is None: + raise SystemExit("--apply 는 --backup <폴더> 와 함께") + src = layers.storage_root() + shutil.copytree(layers.system_dir(), backup / "system") + for tpl in [*src.glob("*/templates"), *src.glob("*/*/templates")]: + shutil.copytree(tpl, backup / "storage" / tpl.relative_to(src)) + return run_here() + + +def to_xlsx(rows: list[dict], path: Path) -> None: + from openpyxl import Workbook + + wb = Workbook() + ws = wb.active + ws.title = "옮기기" + ws.append(["어디", "종류", "키", "로직 줄", "참조 SK", "새 SK", "알약 잇기", "전", "후", "같음", "공유없음"]) # fmt: skip + for r in rows: + ws.append( + [r["어디"], r["종류"], r["키"], r["줄"], " ".join(r["SK"]), len(r["새SK"]), r["알약"]] + + [json.dumps(r[k], ensure_ascii=False) for k in ("전", "후")] + + ["같음" if r["같음"] else "다름", "예" if r.get("공유없음") else ""] + ) + path.parent.mkdir(parents=True, exist_ok=True) + wb.save(path) + + +if __name__ == "__main__": # 브레인 — 보기 먼저 · `--apply --backup <폴더>` 는 승인 뒤 + import sys + + argv = sys.argv[1:] + opt = {a: argv[i + 1] for i, a in enumerate(argv[:-1]) if a in ("--backup", "--xlsx")} + got = migrate("--apply" in argv, Path(opt["--backup"]) if "--backup" in opt else None) + for one in got: + print(json.dumps(one, ensure_ascii=False, default=str)) + lines, same = sum(r["줄"] for r in got), sum(r["줄"] for r in got if r["같음"]) + sks = len({(r["어디"], k) for r in got for k in r["새SK"]}) + print(f"문서 {len(got)} · 로직 줄 {lines} · 옮김 {same} · 새 SK {sks} · 다름 {sum(not r['같음'] for r in got)}") # fmt: skip + if "--xlsx" in opt: + to_xlsx(got, Path(opt["--xlsx"])) diff --git a/M02_MasterTemplete/M02_Library_Flat.py b/M02_MasterTemplete/M02_Library_Flat.py index 0451d58fe..ff7716651 100644 --- a/M02_MasterTemplete/M02_Library_Flat.py +++ b/M02_MasterTemplete/M02_Library_Flat.py @@ -8,7 +8,8 @@ 수량 = 넣은 수 ÷ 배(728 환산) · 바탕 없는 SK = 그 더한줄 × 넣은 수 SK 에 SC SC 줄마다 더한줄(로직 줄 = 로직 한 줄 + 패치 몸 · 고정 · 자재 = 재료) · 수량 = 넣은 수 × 줄 수량(저장 `수량.손` › 실무수량 › 1 · 742-1) ÷ 배(물량 알약 배) - SC 에 SK 로직 줄 하나 + 패치(= SK 몸) · 별칭 = SK 이름 · 같은 자리(안 줄 알약 키 그대로) · + SC 에 SK 811-1 — 층 문서(저장 · 읽기 · 복사)는 참조 한 행 그대로(펼치지 않음 · 셈 = SK × 수량) · + SK 에 SC 를 넣을 때 그 SC 안 SK 줄만 아래처럼 로직 줄 하나 + 패치(= SK 몸) · 별칭 = SK 이름 · 바탕 없는 SK · 단위 환산 SK = 못 넣음(값 단위가 달라짐) SC 에 SC 사본 블록 — 안쪽 SC 몸 통째(M01 키 줄 + 덮개 · 실무수량)를 그 줄 참조 꼴 `{층: 사본, 주인: 몸 지문, 키: 본 SC, 몸}` 에 담음(`M02_Labor_Ref.copy_ref` · 원본 @@ -390,14 +391,18 @@ def _twice(doc: dict) -> str | None: return None -def flat_sc(doc: dict, read: Read, whole: Any, trail: tuple = (), strict: bool = True) -> dict: +def flat_sc( + doc: dict, read: Read, whole: Any, trail: tuple = (), strict: bool = True, sk: bool = True +) -> dict: """SC 문서 → M01 키만 — SK 참조 = 로직 줄 + 패치(같은 자리) · SC 참조 = 사본 블록(같은 자리) · - `strict` False = 못 펼친 줄 그대로(겹침 · 바탕 없음 · 환산 · 없는 참조).""" + `strict` False = 못 펼친 줄 그대로(겹침 · 바탕 없음 · 환산 · 없는 참조) · + `sk` False = SK 참조 줄은 참조 한 행 그대로(811-1 · 층 문서 저장 · 읽기 · 복사).""" 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): + keep = kind == "sangeun" and not sk + if kind not in ("sangeun", "precombo") or refs.is_copy(ref) or keep: lines.append(one) continue try: @@ -432,7 +437,7 @@ def flat_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) + return flat_sc(doc, read, whole, trail, strict, sk=False) # 811-1 SK 줄 = 참조 한 행 def flat_lines(kind: str, ref: Any, qty: Any = 1, doc: Any = None) -> dict[str, Any]: diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Combo.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Combo.ts index 3aeb99b34..0d661fd49 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Combo.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Combo.ts @@ -29,7 +29,10 @@ import { buildLaborDiagram, diagramTitle, renderComboDiagram, + type DiagramLogic, + type DiagramOut, } from "./M02_MasterTemplete_Labor_Diagram"; +import { lineId } from "./M02_MasterTemplete_Labor_Calc"; import { openQtyModal } from "./M02_MasterTemplete_Labor_QtyModal"; import { qtyShort } from "./M02_MasterTemplete_Labor_Rows"; import { tc } from "./M02_MasterTemplete_Labor_Text"; @@ -155,6 +158,26 @@ export function createCombo(opt: ComboOptions): Combo { }; } +/** 참조 줄 카드(811-1 · 단가산출 = SK 참조 한 행) — 이름 · 값 = 셈 답 · 넣을 값 없음 · 누름 = 그 줄 모달 */ +function refCards(doc: LaborDoc, calc: LaborCalc | null): DiagramLogic[] { + return doc.담은로직.flatMap((r) => { + if (!r.참조) return []; + const line = calc?.줄.find((l) => lineId(l) === lineId(r)); + const money = line?.반영 ?? line?.기준; + const out: DiagramOut = line?.까닭 ? { 멈춤: line.까닭 } : money ? { ...money } : null; + return [ + { + key: r.참조.키, // 같은 참조 두 번 금지 = 줄 하나 + name: r.별칭 || line?.이름 || r.참조.키, + unit: line?.분모 ?? "", + inputs: [], + out, + ...(money ? { basis: line?.반영 ? "반영" : "기준" } : {}), + }, + ]; + }); +} + /** 다이어그램 상자(열 상세) — `draw` 를 표가 다시 그릴 때마다 부름 */ export function diagramBox(): { root: HTMLElement; @@ -179,18 +202,22 @@ export function diagramBox(): { again = () => { if (!root.open) return; const data = buildLaborDiagram({ - rows: doc.담은로직.filter((r) => !r.참조), // ponytail: 참조 줄은 다이어그램에 안 그림 — 그릴 때 카드 꼴 더함 + rows: doc.담은로직.filter((r) => !r.참조), logics: [...logics.values()], calc, mats: doc.자재.length, }); + const at = data.logics.findIndex((l) => l.material); + data.logics.splice(at < 0 ? data.logics.length : at, 0, ...refCards(doc, calc)); renderComboDiagram(host, data, { focusInput: (key) => { const i = doc.담은로직.findIndex((r) => r.로직 === key); if (i >= 0) focus(i); }, - // 로직 카드 → 단가산출 로직 화면(떠날 때 물음은 UI_Main 의 leave guard) + // 로직 카드 → 단가산출 로직 화면(떠날 때 물음은 UI_Main 의 leave guard) · 참조 카드(811-1) = 그 줄 모달 openLogic: (key) => { + const ref = doc.담은로직.findIndex((r) => r.참조?.키 === key); + if (ref >= 0) return focus(ref); setPendingLogicKey(key); navigateTo(ROUTES.M01_MASTER_DATA); }, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_SkPick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_SkPick.ts new file mode 100644 index 000000000..83434c33f --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_SkPick.ts @@ -0,0 +1,48 @@ +/* ============================================================================= + * M02_MasterTemplete_Labor_SkPick.ts + * 일위대가 [+ 줄] 단가산출 = 그 층 SK 참조 한 행(PLAN 811-1 · 요구 707-1 · 681) — 일위대가 표(`Labor_Table`)가 부름. + * · M01 기초 고름 = 그 층에 SK 사본을 만듦(`POST …/sangeun/from-logic` · 같은 내용 SK 있으면 물음 = 그것을 씀 · 그래도 새로) + * · M02 단가산출 고름 = 같은 층 · 같은 주인 = 그대로 참조 · 다른 층(위 층 = 읽기 전용) = 이 층에 사본(`newLibrary` 본뜬) 뒤 참조 + * · 답 = 넣을 참조 {층, 주인, 키} · 취소 = null(일위대가 문서는 화면 [저장] 때 바뀜) + * ========================================================================== */ + +import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; +import { call, newLibrary, type Layer } from "./M02_MasterTemplete_Api_Fetch"; +import type { LaborRef } from "./M02_MasterTemplete_Labor_Calc"; +import { withSameAsk } from "./M02_MasterTemplete_Library_SameAsk"; + +export interface SkPickScope { + layer: Layer; + owner: string | null; +} + +const q = (s: SkPickScope): string => + s.owner && s.layer !== "system" ? `?owner=${encodeURIComponent(s.owner)}` : ""; + +/** M01 기초 → 이 층 SK 사본 참조 */ +export const skFromLogic = async (s: SkPickScope, code: string): Promise => + ( + await withSameAsk((extra) => + call<{ 이름: string; 참조: LaborRef }>(`/layers/${s.layer}/sangeun/from-logic${q(s)}`, { + method: "POST", + body: JSON.stringify({ 로직: code, ...extra }), + }), + ) + )?.참조 ?? null; + +/** 이 층 주인 — 주인 null = 로그인 사용자 자기 층(회사 「C」 · 개인 「C/U」 · 서버 참조 꼴) */ +async function ownerOf(s: SkPickScope): Promise { + if (s.layer === "system" || s.owner) return s.layer === "system" ? null : s.owner; + const me = await fetchSessionUser(); + if (!me) throw new Error("로그인이 끊김"); + return s.layer === "company" ? String(me.company_id) : `${me.company_id}/${me.id}`; +} + +/** 고른 SK — 이 층 것이면 그대로 · 다른 층이면 이 층 사본 */ +export async function ownSk(s: SkPickScope, ref: LaborRef): Promise { + const owner = await ownerOf(s); + if (ref.층 === s.layer && (ref.주인 ?? null) === owner) return ref; + const 본뜬 = { ...ref, 층: ref.층 as Layer }; + const got = await newLibrary(s.layer, "sangeun", { 본뜬 }, s.owner); + return { 층: s.layer, 주인: owner, 키: got.이름 }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Table.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Table.ts index 608962d85..49dbe6d25 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Table.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Table.ts @@ -14,6 +14,8 @@ * 구조물 조합 일위대가(카드 · 열 상세 · PLAN 806-1)도 이 한 부품 — `mode` 없음 = 조합 갈래: 정보 칸(이름 · 구분) * 없음 · 수량 칸 = 수량산출 모달(`Labor_Combo` — 면적산출 잇기) · 규격 열 줄(`Labor_Spec`) · 다이어그램(열 상세) · * 새 줄 수량 = 면적을 따름(빈 값). 단가산출(SK) 참조 줄 누름 = 그 단가산출 문서 모달(`Labor_SkEdit` — 수식 탭 · 저장 = 그 SK). + * 811-1(요구 707-1) — 단가산출 줄 = SK 참조 한 행(저장 때 안 펼침) · [로직 추가] M01 기초 = 이 층 SK 사본 · 다른 층 SK = + * 이 층 사본 뒤 참조(`Labor_SkPick`) · 프로젝트 · 셈 주입 = 옛 로직 줄. * ========================================================================== */ import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; @@ -41,6 +43,7 @@ import { tipBorn } from "./M02_MasterTemplete_Labor_Born"; import { nationalRows } from "./M02_MasterTemplete_Labor_National"; import { createCombo, diagramBox } from "./M02_MasterTemplete_Labor_Combo"; import { isSkRef, openSkEdit } from "./M02_MasterTemplete_Labor_SkEdit"; +import { ownSk, skFromLogic, type SkPickScope } from "./M02_MasterTemplete_Labor_SkPick"; import { specBar, specCells } from "./M02_MasterTemplete_Labor_Spec"; import { unlinkedBar } from "./M02_MasterTemplete_Labor_Unlinked"; import { pasteInto } from "./M02_MasterTemplete_Labor_Paste"; @@ -403,6 +406,16 @@ export function mountLaborTable( changed(); } + function addRef(ref: LaborRef, name: string, 별칭: string | null): void { + const id = lineId({ 참조: ref }); + if (cur.담은로직.some((r) => lineId(r) === id)) + return showToast(tc("Labor_AlreadyRef").replace("{value}", name), "error"); + cur.담은로직.push({ 로직: "", 참조: ref, 별칭, 메모: null, 입력값: {}, 수량: fresh() }); + changed(); + } + const failed = (e: unknown): void => + showToast(e instanceof Error ? e.message : tc("Load_Failed"), "error"); + /** 서고 고르기 — 참조 줄 · 고정 줄(721-2 새 고르기 창 · 인력 · 재료) */ const library = { layer: opt.layer, @@ -412,13 +425,10 @@ export function mountLaborTable( : opt.layer === getActiveLayer() ? getActiveOwner() : null, - pick: (ref: LaborRef, name: string, 별칭: string | null = null) => { - const id = lineId({ 참조: ref }); - if (cur.담은로직.some((r) => lineId(r) === id)) - return showToast(tc("Labor_AlreadyRef").replace("{value}", name), "error"); - cur.담은로직.push({ 로직: "", 참조: ref, 별칭, 메모: null, 입력값: {}, 수량: fresh() }); - changed(); - }, + pick: (ref: LaborRef, name: string, 별칭: string | null = null) => + own && isSkRef(ref) // 811-1 — 다른 층 SK = 이 층 사본 뒤 참조 + ? void ownSk(own, ref).then((r) => alive && addRef(r, name, 별칭), failed) + : addRef(ref, name, 별칭), fixed: (fx: LaborFixed, name: string) => { const id = lineId({ 고정: fx }); if (cur.담은로직.some((r) => lineId(r) === id)) @@ -448,11 +458,20 @@ export function mountLaborTable( changed(); }, }; + /** SK 사본 길(811-1) — M02 층 일위대가 · 조합만(프로젝트 · 셈 주입 = 옛 로직 줄) */ + const own: SkPickScope | null = + opt.layer !== "project" && !opt.calc ? { layer: opt.layer, owner: library.owner } : null; const addLogic = createButton({ label: tc("AddLogic"), variant: "filled", onClick: () => openLogicPicker((x) => { + if (own) + // 811-1 — M01 기초 = 이 층 SK 사본 참조 한 행(펼치지 않음) + return void skFromLogic(own, x.키).then( + (r) => r && alive && addRef(r, x.이름 || x.키, null), + failed, + ); if (cur.담은로직.some((r) => r.로직 === x.키)) return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error"); cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량: fresh() }); diff --git a/M02_MasterTemplete/M02_Sangeun_Router.py b/M02_MasterTemplete/M02_Sangeun_Router.py index 103631a31..c4f8a862d 100644 --- a/M02_MasterTemplete/M02_Sangeun_Router.py +++ b/M02_MasterTemplete/M02_Sangeun_Router.py @@ -1,5 +1,6 @@ """M02 단가산출(SK) 셈 길(PLAN 715-1b) — `POST /api/m02/layers/{층}/sangeun/calc` · [단가산출로 저장](715-3) `POST /api/m02/layers/{층}/sangeun/from-line`(층 쓰기 권한) · +일위대가 [+ 줄] M01 기초 = 그 층 SK 사본(811-1 · `M02_Labor_SkRow`) `…/sangeun/from-logic`(층 쓰기) · 식 칸 검사(792-1) `POST /api/m02/layers/{층}/sangeun/formula-check`(셈 없음 · 층 읽기 권한) · 가져온 식(793-2 · `M02_Sangeun_Imported`) `…/sangeun/imported`(만들기 · 층 쓰기) · `/price` · `/link`(화면 캐시 문서 고침 · 저장 없음 · 층 읽기 · 기초로 바꾸기 없음 · 809-1) · @@ -78,6 +79,26 @@ def sangeun_from_line( return _call(folder, calc.from_line, body.줄, body.이름, body.단위, body.새로, body.씀) +class FromLogicBody(BaseModel): + 로직: str + 새로: bool = False + 씀: str | None = None + + +@router.post("/layers/{layer}/sangeun/from-logic") +def sangeun_from_logic( + layer: Layer, + body: FromLogicBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """일위대가 [+ 줄] M01 기초(811-1) — 그 층 SK 사본 · 답 = 새 키 + 참조(일위대가 문서는 화면 [저장] 때).""" + from M02_MasterTemplete import M02_Labor_SkRow as skrow + + folder = _root(session, layer, owner, write=True) + return _call(folder, skrow.from_logic, body.로직, body.새로, body.씀) + + @router.post("/layers/{layer}/sangeun/calc") def calc_sangeun( layer: Layer, diff --git a/docs/raw/요구_1104/_목록_811-1_일위대가참조.xlsx b/docs/raw/요구_1104/_목록_811-1_일위대가참조.xlsx new file mode 100644 index 000000000..822572ec7 Binary files /dev/null and b/docs/raw/요구_1104/_목록_811-1_일위대가참조.xlsx differ diff --git a/resources/tester/test_723_labor_table.py b/resources/tester/test_723_labor_table.py index c7b25e8f0..5032a6d25 100644 --- a/resources/tester/test_723_labor_table.py +++ b/resources/tester/test_723_labor_table.py @@ -40,7 +40,9 @@ def test_화면_서고_상세는_일위대가표만(): _src("M02_MasterTemplete_Labor_Detail.ts"), ) assert "return mountLaborTable(host, doc, opt);" in detail # 806-1 — 조합도 같은 부품 - assert "calc = settle(calc, cur, combo?.given() ?? {});" in table # 짝 셈 바로(742-1 수량 = 문서) + assert ( + "calc = settle(calc, cur, combo?.given() ?? {});" in table + ) # 짝 셈 바로(742-1 수량 = 문서) assert 'const out: Record = { ...cur, 종류: "사전조합" };' in table for label in ('tc("Up")', 'tc("Down")', 't("Alias")', 'tc("Remove")'): assert label in table # 우클릭 메뉴 @@ -55,5 +57,5 @@ def test_화면_줄_모달은_SK_상세_줄_모드_적용은_덮개와_옵션(): detail = _src("M02_MasterTemplete_Sangeun_Detail.ts") assert "mountSangeunDetail(box, doc," in sk and "getValues: () => ({ ...values })" in detail assert "if (got.patch) row.패치 = got.patch;" in modal and "setValues(got.values);" in modal - assert 'createCheckbox(t("AlsoSk"), { checked: false })' in modal # 기본 꺼짐 + assert "AlsoSk" not in modal # 808-1 [단가산출로도 저장] 걷음 · 811-1 M01 기초 = SK 사본 참조 assert "openRowSangeun" not in modal # 717-3 두 보기 갈음 diff --git a/resources/tester/test_740_1_library_flat.py b/resources/tester/test_740_1_library_flat.py index 06ecaacec..5d570a8ec 100644 --- a/resources/tester/test_740_1_library_flat.py +++ b/resources/tester/test_740_1_library_flat.py @@ -138,7 +138,7 @@ def test_SC_에_SK_패치_겹침_E1_원본_고쳐도_그대로(world): 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]["참조"] == {**MINE, "키": sk} # 811-1 SK 줄 = 참조 한 행(안 펼침) assert doc["담은로직"][0]["패치"]["덮개"] == {"labor:LB000036#0": {"qty": 0.5}} assert doc["담은로직"][1]["참조"]["층"] == "사본" # 하위 호표(SC 안 SC) = 사본 블록 assert doc["담은로직"][1]["본뜬"]["키"] == inner @@ -148,8 +148,8 @@ def test_SC_에_SK_패치_겹침_E1_원본_고쳐도_그대로(world): 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) # 펼친 것 = 그대로 + assert _sc_value(me, old) > before * 3 # 참조 = 따라감(129,073 → 481,406 꼴) + assert _sc_value(me, host) == pytest.approx(_sc_value(me, old), rel=1e-9) # 811-1 = SK 따라감 def test_SC_에_SC_사본_블록_실무수량_겹침_원본_지워도(world): @@ -322,18 +322,19 @@ def test_옮기기_함수_전후_같음_사본_블록(world): 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)) + assert all(look[k]["같음"] and not look[k]["씀"] for k in (sk, sc, dup)) + assert look[sc]["남은참조"] == [y] # 811-1 SC 안 SK 줄 = 참조 한 행 그대로 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): # 하위 호표 = 사본 블록만 남음(원본 연결 없음) + for key, keep in ((sc, [("personal", y)]), (dup, [])): # 하위 호표 = 사본 블록 left = refs.refs_in(json.loads((me / "precombo" / f"{key}.json").read_bytes())) - assert [(r["층"], r["키"]) for r in left] == [("사본", inner)] + assert [(r["층"], r["키"]) for r in left] == [*keep, ("사본", 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) + assert all(not r["씀"] for r in again) def test_733_지문_옛_참조_펼친_것_같음(world): @@ -341,7 +342,7 @@ def test_733_지문_옛_참조_펼친_것_같음(world): 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)) + assert refs.refs_in(_read(me, "precombo", new)) == [{**MINE, "키": y}] # 811-1 참조 한 행 fingers = {k: same.finger("precombo", _read(me, "precombo", k)) for k in (old, new)} assert fingers[old] == fingers[new] @@ -387,7 +388,5 @@ def test_747_SK_재료_직접계산_일위대가에_넣어도_같은_금액(worl before = _sc_value(me, old) host = _make(me, "precombo", 이름="새", 담은로직=lines) line = _read(me, "precombo", host)["담은로직"][0] - assert line["자재표시"] and all( - v["별도계상"] == "직접계산" for v in line["자재표시"].values() - ) + assert line["참조"]["키"] == sk # 811-1 참조 한 행 = SK 재료 직접계산 그대로 assert _sc_value(me, host) == pytest.approx(before, rel=1e-9) diff --git a/resources/tester/test_811_1_labor_ref.py b/resources/tester/test_811_1_labor_ref.py new file mode 100644 index 000000000..a47daaf93 --- /dev/null +++ b/resources/tester/test_811_1_labor_ref.py @@ -0,0 +1,206 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""811-1 일위대가 단가산출 줄 = SK 참조 한 행(요구 707-1 · 681 · 698) — 임시 storage · M01 읽기만. + +- 저장 · 읽기 = 안 펼침(SK 참조 그대로) · 셈 = SK 1 단위 × 수량 · 모달 저장 = 그 SK 문서 → 일위대가 금액 따라감 +- [+ 줄] M01 기초 = 그 층 SK 사본(`from-logic` · 같은 내용 = 409 물음 · 씀 · 새로) · 일반 사용자 회사 층 403 +- 다른 층 SK = 이 층 사본 뒤 참조(화면 `Labor_SkPick` · 서버 서고 복사 길) +- 옮기기 = 로직 줄 → SK + 참조 한 행 · 면적 칸 잇기 옮김 · 전후 금액 같음 · 보기 = 임시 사본 · 실제 저장본 전수 +""" + +import json +import re +from decimal import Decimal +from pathlib import Path + +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_Combo as combo_store +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Labor_Ref as refs +from M02_MasterTemplete import M02_Labor_SkRow as skrow +from M02_MasterTemplete import M02_Library_Flat as flat +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 M02_MasterTemplete import M02_Sangeun_Router as sk_router +from M02_MasterTemplete import M02_Template_Layers as layers +from M02_MasterTemplete.M02_Labor_Expand import Expand +from resources.tester.test_740_1_library_flat import USER, _make, _read, _whole +from resources.tester.test_m02_labor_ref import MINE, STONE, TEN, world # noqa: F401 + +ROOT = Path(__file__).resolve().parents[2] +M02 = ROOT / "M02_MasterTemplete" +FORM = "GC000211" # 합판거푸집 — 재료 줄 있음(일위대가 기본 별도계상) + + +def _src(name: str) -> str: + return (M02 / name).read_text("utf-8") + + +def _value(folder, key, given=None): + with store.at(folder): + got = Expand(_whole(), refs.read_ref, given or {})({**MINE, "키": key}) + return got["기준"] + + +def test_참조_한_행_저장_읽기_안_펼침_셈은_SK_곱_수량(world): + me = world["personal"] + sk = _make(me, "sangeun", 이름="sub1임시 SK", 바탕={"로직": TEN}) + sc = _make(me, "precombo", 이름="sub1임시 SC", 담은로직=[{"참조": {**MINE, "키": sk}, "수량": {"손": 3}}]) # fmt: skip + doc = _read(me, "precombo", sc) + assert doc["담은로직"][0]["참조"] == {**MINE, "키": sk} and "로직" not in doc["담은로직"][0] + assert flat.flat_doc("precombo", doc)["담은로직"][0]["참조"]["키"] == sk # 읽기 길도 그대로 + one, got = _value(me, sk), _value(me, sc) + want = labor.mcut.sums([labor.mcut.row({k: v * 3 for k, v in one.items()})]) + assert got["계"] == want["계"] and got["계"] > 0 + + +def test_모달_저장_SK_문서_뒤_일위대가_따라감(world): + me = world["personal"] + sk = _make(me, "sangeun", 이름="sub1임시 SK", 바탕={"로직": TEN}) + sc = _make(me, "precombo", 이름="sub1임시 SC", 담은로직=[{"참조": {**MINE, "키": sk}, "수량": {"손": 1}}]) # fmt: skip + before = _value(me, sc)["계"] + with store.at(me): # 모달 [저장] = 그 SK 문서 쓰기(`saveTemplate` sangeun) + now = store.read("sangeun", sk) + cid = sd.line_ids(_whole().logic(TEN))[0] + doc = {**now["문서"], "덮개": {cid: {"배율": 2}}} + store.write("sangeun", sk, now["판"], calc.clean_doc(doc, refs.here())) + assert _value(me, sc)["계"] != before # 일위대가 금액이 SK 를 따라감 + assert _read(me, "precombo", sc)["담은로직"][0]["참조"]["키"] == sk # 일위대가 문서는 그대로 + + +def test_from_logic_층_SK_사본_같은것_물음_권한(world): + app = FastAPI() + app.include_router(sk_router.router) + app.dependency_overrides[verify_session] = lambda: USER + client = TestClient(app) + url = "/api/m02/layers/{}/sangeun/from-logic" + res = client.post(url.format("personal"), json={"로직": TEN}) + assert res.status_code == 200, res.text + key, ref = res.json()["이름"], res.json()["참조"] + assert ref == {**MINE, "키": key} + doc = _read(world["personal"], "sangeun", key) + assert doc["바탕"] == {"로직": TEN} and doc["단위"] == "㎡" # 원/10㎡ → ㎡(줄 ÷ 배와 같은 값) + again = client.post(url.format("personal"), json={"로직": TEN}) + assert again.status_code == 409 # 같은 내용 = 물음(733-2) + use = client.post(url.format("personal"), json={"로직": TEN, "씀": key}) + assert use.json()["이름"] == key + new = client.post(url.format("personal"), json={"로직": TEN, "새로": True}) + assert new.json()["이름"] != key + assert client.post(url.format("personal"), json={"로직": "GF999999"}).status_code == 400 + assert client.post(url.format("company"), json={"로직": TEN}).status_code == 403 + + +def test_M01_기초_SK_값_로직_줄과_같음_재료_별도계상(world): + """새 SK 1 단위 = 옛 로직 줄 ÷ 배 · 재료 빈 칸 = 일위대가 기본(별도계상)을 적어 둠.""" + me = world["personal"] + for code in (TEN, STONE, FORM): + with store.at(me): + got = skrow.from_logic(code, new=True) + line = labor.clean({"담은로직": [{"로직": code, "수량": {"손": 2}}]}) + ref = labor.clean({"담은로직": [{"참조": got["참조"], "수량": {"손": 2}}]}) + a, b = labor.calc(line, {}), labor.calc(ref, {}) + assert a["반영합"] == b["반영합"], code + doc = _read(me, "sangeun", got["이름"]) + assert doc["자재표시"] and all(v["별도계상"] == "별도계상" for v in doc["자재표시"].values()) + + +def _labor_world(me): + """개인 층 조합 일위대가(수량 빈 줄 = 면적 칸) + 면적 문서 + SC(로직 줄 · 손 수량).""" + lines = [{"로직": TEN, "수량": None}, {"로직": STONE, "수량": {"손": 2}}] + pill = combo_store.combo_pills({"담은로직": lines})["물량"][0]["키"] + with store.at(me): + doc = labor.clean({"담은로직": lines}) + (me / "labor").mkdir(parents=True, exist_ok=True) + (me / "area").mkdir(parents=True, exist_ok=True) + (me / "labor" / "c811.json").write_text(json.dumps({**doc, "열": "c811", "단위": "㎡"}, ensure_ascii=False), "utf-8") # fmt: skip + area = {"시트": [{"id": "s1", "칸": {"A1": {"값": "4"}}, "연결": [{"키": pill, "칸": "A1"}]}]} + (me / "area" / "c811.json").write_text(json.dumps(area, ensure_ascii=False), "utf-8") + sc = _make(me, "precombo", 이름="sub1임시 SC", 담은로직=[{"로직": TEN, "수량": {"손": 3}}]) + return pill, sc + + +def test_옮기기_로직_줄_SK_참조_면적_잇기_전후_같음(world): + me = world["personal"] + pill, sc = _labor_world(me) + with store.at(me): + before = labor.calc(json.loads((me / "labor" / "c811.json").read_bytes()))["반영합"] + sc_before = _value(me, sc) + rows = {r["키"]: r for r in skrow.run_here()} + assert rows["c811"]["같음"] and rows[sc]["같음"] and rows["c811"]["알약"] == 1 + doc = json.loads((me / "labor" / "c811.json").read_bytes()) + assert all("참조" in x and "로직" not in x for x in doc["담은로직"]) + assert [x["수량"] for x in doc["담은로직"]] == [None, {"손": 2}] # 수량 · 별칭 그대로 + area = json.loads((me / "area" / "c811.json").read_bytes()) + keys = [x["키"] for x in area["시트"][0]["연결"]] + assert keys == [refs.ref_id(doc["담은로직"][0]["참조"])] and pill not in keys + with store.at(me): + assert labor.calc(doc)["반영합"] == before # 원 단위 같음(면적 칸 4) + assert _value(me, sc) == sc_before + assert refs.refs_in(_read(me, "precombo", sc))[0]["키"].startswith("SK-") + made = {k for r in rows.values() for k in r["새SK"]} + assert len(made) == 3 # SC TEN · 조합 TEN · STONE — 사본(값 묶음)이 달라 공유 안 함 + assert skrow.run_here() == [] # 두 번째 = 옮길 로직 줄 없음 + + +def test_옮기기_다르면_되돌림(world, monkeypatch): + me = world["personal"] + _labor_world(me) + raw = (me / "labor" / "c811.json").read_bytes() + real = skrow.money + monkeypatch.setattr( + skrow, "money", lambda k, key, doc: {"다름": id(doc)} if k == "labor" else real(k, key, doc) + ) + rows = {r["키"]: r for r in skrow.run_here()} + assert not rows["c811"]["같음"] and rows["c811"]["공유없음"] + assert (me / "labor" / "c811.json").read_bytes() == raw # 원래대로 + left = {p.stem for p in (me / "sangeun").glob("SK-*.json")} + sc_made = set(rows[next(k for k in rows if k != "c811")]["새SK"]) + assert left == sc_made # 되돌린 문서가 만든 SK 는 지움 + + +def test_보기_임시_사본_공유_storage_안_씀(world, tmp_path): + me = world["personal"] + _labor_world(me) + raw = (me / "labor" / "c811.json").read_bytes() + rows = skrow.migrate(False, work=tmp_path) + assert rows and all(r["같음"] for r in rows) + assert (me / "labor" / "c811.json").read_bytes() == raw # 보기 = 진짜 층 안 고침 + assert not (me / "sangeun").exists() or not list((me / "sangeun").glob("SK-*.json")) + skrow.to_xlsx(rows, tmp_path / "목록.xlsx") + assert (tmp_path / "목록.xlsx").stat().st_size > 0 + + +@pytest.mark.skipif( + not any(layers.storage_root().glob("*/*/templates/labor/*.json")), reason="저장본 없음" +) +def test_실제_저장본_전수_보기_같음(tmp_path): + """공유 storage 층 문서 전부 — 임시 사본에서 옮겨 봄(쓰지 않음) · 줄마다 금액 같음.""" + rows = skrow.migrate(False, work=tmp_path) + assert rows and all(r["같음"] for r in rows), [r["키"] for r in rows if not r["같음"]] + + +def test_화면_줄_넣기_SK_사본_참조_부품(): + table = _src("M02_MasterTemplete_Labor_Table.ts") + pick = _src("M02_MasterTemplete_Labor_SkPick.ts") + assert 'opt.layer !== "project" && !opt.calc ? { layer: opt.layer' in table # 프로젝트 = 옛 길 + assert "return void skFromLogic(own, x.키).then(" in table # M01 기초 = SK 사본 + assert "own && isSkRef(ref)" in table and "void ownSk(own, ref)" in table # 다른 층 = 사본 + assert "if (isSkRef(row.참조) && !opt.calc)" in table # 줄 누름 = SK 수식 모달 + assert "/sangeun/from-logic" in pick and "{ 본뜬 }" in pick and "withSameAsk(" in pick + assert ( + "fetchSessionUser()" in pick + ) # 주인 null = 자기 층 → 서버 참조 꼴(같은 층 SK 를 사본으로 안 만듦) + combo = _src("M02_MasterTemplete_Labor_Combo.ts") + assert "...refCards(doc, calc)" in combo and "key: r.참조.키," in combo # 다이어그램 참조 카드 + for text in (table, pick): + assert len(text.splitlines()) <= 700 + assert not re.search(r"""\bel\(\s*["'](button|input|select)["']""", text) + assert len((M02 / "M02_Labor_SkRow.py").read_text("utf-8").splitlines()) <= 700 + + +def test_값_한_벌_소수(): + assert skrow._unit({"기준": {"계": Decimal("1289832")}, "배": 100}) == "12898.32"