diff --git a/M02_MasterTemplete/M02_Item_Personal.py b/M02_MasterTemplete/M02_Item_Personal.py index 906dec16a..bf6c9cbd4 100644 --- a/M02_MasterTemplete/M02_Item_Personal.py +++ b/M02_MasterTemplete/M02_Item_Personal.py @@ -18,8 +18,9 @@ from common_util.common_util_json import atomic_write_json from M02_MasterTemplete import M02_MasterTemplete_Items as items_store from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_MasterTemplete_Tree as tree +from M02_MasterTemplete import M02_Material_Own as own from M02_MasterTemplete import M02_Template_Layers as layers -from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, read_json +from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json #: 복사본 파일 → 층 양식 종류 PART_KINDS = (("도면.json", "structure"), ("면적.json", "area")) @@ -94,6 +95,8 @@ def create_item( # ponytail: 참조(단가승격 · 사전조합)는 키 그대로 — 개인 층이 못 읽는 참조는 계산 때 # 까닭으로 멈춤(참조사본까지 옮기려면 `Labor_Ref.carry` 길) if isinstance(labor, dict) and labor.get("종류") == "일위대가": + # 개인 자재(148-3) — 사본에 굳힌 줄을 내 목록에 같은 키로(남의 개인 자재여도 `clean` 통과) + own.adopt(((read_json(copy / PRICE_FILE) or {}).get(own.BUNDLE) or {}).get("줄") or []) store.copy_from("labor", new_id, new_id, body=labor) ss = next(r for r in store.read_combos()["줄"] if r.get("열") == new_id) return {"key": ss["키"], "열": new_id, "이름": tree.name_of(heads)} diff --git a/M02_MasterTemplete/M02_Item_Source.py b/M02_MasterTemplete/M02_Item_Source.py index 5f219ca70..2688b5aa0 100644 --- a/M02_MasterTemplete/M02_Item_Source.py +++ b/M02_MasterTemplete/M02_Item_Source.py @@ -18,6 +18,7 @@ import json from pathlib import Path from typing import Any +from M02_MasterTemplete import M02_Material_Own as own from M02_MasterTemplete import M02_Template_Layers as layers TABLE_NAME = "구조물집계표" @@ -157,4 +158,8 @@ def build_source(item: dict[str, Any]) -> dict[str, Any]: ] wanted = logic_keys + [code for code in materials if code] files[PRICE_FILE] = prices.to_json(prices.collect(logic_keys, materials)) if wanted else {} + if mine := own.bundle( + own.codes(doc), root + ): # 개인 자재(148-3) — 원본 목록이 바뀌어도 사본 그대로 + files[PRICE_FILE][own.BUNDLE] = mine return files diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index 2495f7a72..cfcdac325 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -36,6 +36,7 @@ from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs from M02_MasterTemplete import M02_Labor_Ref as refs from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Material_Own as own mf = m01.cm.mf mm = mf.mp.mm @@ -145,9 +146,11 @@ def clean(doc: Any) -> dict[str, Any]: where = f"자재 {at}째" code = str(one.get("코드") or "") if isinstance(one, dict) else "" try: - row = whole.get(code) if code[:2] == mm.MARKET else None + row = whole.get(code) if code[:2] == mm.MARKET else own.find(code) except mf.FormulaError: row = None + if row is None and own.is_own(code): + raise _bad(f"{where} · 개인 자재 목록에 없는 코드(개인 층에서만) 「{code}」") if row is None: raise _bad(f"{where} · M01 자재품목에 없는 코드 「{code}」") if code in codes: @@ -395,7 +398,20 @@ def settle(result: dict, doc: dict, given: dict[str, Any]) -> dict[str, Any]: def material_price(master: Any, code: str, unit: str | None) -> dict[str, Any]: """자재 한 줄 기준 — `{기준단가, 가격: {값 열 다섯}, 기준칸, 까닭}` · 기준단가 = `mm.element` - (값 열 가운데 낮은 값) · 기준칸 = 그 값이 나온 열 이름(출처 `키.열`) · 원본 자료는 읽기만.""" + (값 열 가운데 낮은 값) · 기준칸 = 그 값이 나온 열 이름(출처 `키.열`) · 원본 자료는 읽기만. + 개인 자재(148-3 · `PM`) = 줄 「값」 하나(사본 `단가.json` 안 · 없으면 지금 층 목록 · 값 열 없음).""" + if own.is_own(code): + try: + row = master.get(code) + except mf.FormulaError: + row = own.find(code) or {} + price = row.get("값") + return { + "기준단가": price, + "가격": {slot: None for slot in mm.SLOTS}, + "기준칸": None, + "까닭": None if price is not None else "단가 없음", + } row = master.get(code) or {} prices = {slot: row.get(slot) for slot in mm.SLOTS} try: diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts index 2967ff477..a37a05411 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts @@ -508,14 +508,18 @@ export function mountLaborDetail( library, ), }); - const addMat = addMaterialButton(tc("Labor_AddMat"), (m) => { - const why = insideKeys((calc ?? last)?.줄).has(m.코드) - ? "Labor_InLogicMat" - : cur.자재.some((x) => x.코드 === m.코드) && "Labor_AlreadyMat"; - if (why) return showToast(tc(why).replace("{value}", `${m.코드} ${m.이름 ?? ""}`), "error"); - cur.자재.push({ 코드: m.코드, 이름: m.이름, 규격: m.규격, 단위: m.단위, 수량: null }); - changed(); - }); + const addMat = addMaterialButton( + tc("Labor_AddMat"), + (m) => { + const why = insideKeys((calc ?? last)?.줄).has(m.코드) + ? "Labor_InLogicMat" + : cur.자재.some((x) => x.코드 === m.코드) && "Labor_AlreadyMat"; + if (why) return showToast(tc(why).replace("{value}", `${m.코드} ${m.이름 ?? ""}`), "error"); + cur.자재.push({ 코드: m.코드, 이름: m.이름, 규격: m.규격, 단위: m.단위, 수량: null }); + changed(); + }, + opt.layer === "personal" ? { owner: library.owner } : undefined, // 개인 층만 신규 자재(148-3) + ); // 단위 = 표준 단위 목록에서 고르기(지금 값이 목록에 없어도 선택지로 둠) · 저장은 [저장] const unit = { diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index 5aa3aaf2e..b98e59a58 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -569,9 +569,11 @@ def _server(kind: str, doc: Any, source: Path | None = None) -> Any: if kind in ("labor", *layers.LIBRARY_KINDS): from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Labor_Ref as refs + from M02_MasterTemplete import M02_Material_Own as own if source is not None and isinstance(doc, dict): doc = refs.carry(doc, refs.where(source)) + own.carry(doc, source) # 개인 자재(148-3) — 받는 개인 층 목록에 같은 키로 if kind == "labor": return labor.server(doc) return refs.clean_doc(kind, doc) diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.css b/M02_MasterTemplete/M02_MasterTemplete_Structure.css index de09c0339..9d429ee3c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.css @@ -266,6 +266,22 @@ font-variant-numeric: tabular-nums; } +/* 개인 자재 표(148-3) · 신규 자재 입력 */ +.m02-pick__tag { + flex: none; + padding: 0 var(--spacing-4); + border-radius: var(--radius-sm, 4px); + background: var(--color-surface-sunken); + color: var(--color-text-secondary); + font-size: 0.85em; +} + +.m02-pick__new { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + /* 면적산출 · 재료산출 카드 미리보기 수정(31-3) — 시트는 카드 높이 · 아래 자재 조합 판은 카드 안에서 스크롤 */ .m02-struct__sheet.is-edit { display: flex; diff --git a/M02_MasterTemplete/M02_Material_Own.py b/M02_MasterTemplete/M02_Material_Own.py index aa07142d6..3eb6bab69 100644 --- a/M02_MasterTemplete/M02_Material_Own.py +++ b/M02_MasterTemplete/M02_Material_Own.py @@ -85,3 +85,60 @@ def add(folder: str | Path, body: dict[str, Any]) -> dict[str, Any]: doc["줄"].append(got) atomic_write_json(Path(folder) / FILE, doc) return got + + +# ── 148-3 — 저장 검사 · 셈 · 사본 · 층 사이 ───────────────────────────── +BUNDLE = "재료_개인자재.json" # 사본 `단가.json` 안 이름 + + +def is_own(code: str) -> bool: + return bool(KEY.match(code)) + + +def _personal(folder: Path) -> bool: + from M02_MasterTemplete import M02_Labor_Ref as refs + + return refs.where(folder)[0] == "personal" + + +def find(code: str, folder: str | Path | None = None) -> dict[str, Any] | None: + """개인 자재 한 줄 — `folder`(없으면 지금 층)가 개인 층일 때만.""" + folder = Path(folder) if folder is not None else store.root() + if not is_own(code) or not _personal(folder): + return None + return next((r for r in rows(folder) if r.get("키") == code), None) + + +def codes(doc: Any) -> list[str]: + """일위대가 문서 사용자 자재의 개인 자재 코드.""" + mats = doc.get("자재") if isinstance(doc, dict) else None + return [c for m in mats or [] if isinstance(m, dict) and is_own(c := str(m.get("코드") or ""))] + + +def bundle(wanted: list[str], folder: str | Path) -> dict[str, Any] | None: + """사본 `단가.json` 에 넣을 개인 자재 묶음(원본 층 목록에서 쓰는 줄만) — 없으면 None.""" + got = [r for r in rows(folder) if r.get("키") in set(wanted)] + return {**_blank(), "줄": got} if got else None + + +def adopt(found: list[dict[str, Any]], folder: str | Path | None = None) -> None: + """남의 줄을 같은 키로 내 목록에 — 이미 있는 키는 그대로 · 개인 층 아니면 400.""" + folder = Path(folder) if folder is not None else store.root() + if not found: + return + if not _personal(folder): + raise store.StoreError(400, "개인 자재는 개인 층에서만 — " + found[0].get("키", "")) + with store._LOCK: + doc = read(folder) + have = {r.get("키") for r in doc["줄"]} + new = [{k: r.get(k) for k in ("키", *COLS, "값")} for r in found if r.get("키") not in have] + if new: + doc["줄"] += new + atomic_write_json(folder / FILE, doc) + + +def carry(doc: Any, source: str | Path) -> None: + """층 사이 복사(`Store._server`) — 원본 개인 층 자재를 받는 층(지금 층) 목록에 같은 키로.""" + wanted = codes(doc) + if wanted and Path(source) != store.root(): + adopt([r for r in rows(source) if r.get("키") in set(wanted)]) diff --git a/M02_MasterTemplete/M02_Material_Pick.ts b/M02_MasterTemplete/M02_Material_Pick.ts index fa5e9f1bf..54579ce78 100644 --- a/M02_MasterTemplete/M02_Material_Pick.ts +++ b/M02_MasterTemplete/M02_Material_Pick.ts @@ -2,14 +2,18 @@ * M02_Material_Pick.ts * M01 자재 하나 고르기 창 — 이름 · 규격 · 코드 낱말로 찾아 하나 고름(일위대가 자재목록 · 자재 추가가 씀). * · 일위대가 [자재 추가] 단추(`addMaterialButton` · 148 에서 `Labor_Detail` 이 덜어 냄) + * · 개인 층(148-3) = 개인 자재(「개인」 표 · 앞) + M01 · 머리 [신규 자재] → 품명 · 단위(필수) · 규격 · 단가 → + * 서버가 품번(`PM` + 6자리)을 주고 그 줄로 고름(`M02_Material_Own`) * 모양(`m02-pick*`)은 `M02_MasterTemplete_Structure.css`. * ========================================================================== */ import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import { searchMaterials } from "../M01_MasterData/M01_MasterData_Api_Fetch"; +import { call } from "./M02_MasterTemplete_Api_Fetch"; +import { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick"; -/** 고른 자재 한 줄 — M01 자재(키 `MT`) */ +/** 고른 자재 한 줄 — M01 자재(키 `MT`) · 개인 자재(키 `PM`) */ export interface MaterialItem { 코드: string; 이름: string | null; @@ -17,6 +21,20 @@ export interface MaterialItem { 단위: string | null; } +/** 개인 자재 한 줄(서버 `개인자재.json` 꼴) */ +interface OwnRow { + 키: string; + 이름: string; + 규격: string; + 단위: string; + 값: number | null; +} + +/** 개인 층 주인(148-3 · 지금 층이면 null) — 넘기면 개인 자재 · [신규 자재] */ +export interface OwnLayer { + owner: string | null; +} + const TEXT = { pickTitle: "자재 추가", pickClose: "닫기", @@ -24,45 +42,92 @@ const TEXT = { searching: "찾는 중…", noMatch: "맞는 자재 없음", searchFailed: "자재 목록을 못 받음", + newMat: "신규 자재", + own: "개인", + noPrice: "단가 없음", + name: "품명", + spec: "규격", + unit: "단위", + price: "단가(원 · 비우면 단가 없음)", + key: "품번 — 저장 때 자동", + save: "저장", + back: "목록으로", + needName: "품명 · 단위는 꼭 적음", }; -/** 자재 고르기 창 — 고르면 `done` 부르고 닫음 */ -export function pickMaterial(done: (item: MaterialItem) => void): void { +const ownPath = (own: OwnLayer): string => + `/layers/personal/materials${own.owner ? `?owner=${encodeURIComponent(own.owner)}` : ""}`; +const toItem = (r: OwnRow): MaterialItem => ({ + 코드: r.키, + 이름: r.이름, + 규격: r.규격, + 단위: r.단위, +}); +/** 찾기 낱말이 모두 이름 · 규격 · 키에 듦(M01 찾기와 같은 규칙) */ +const matches = (r: OwnRow, words: string[]): boolean => { + const hay = `${r.이름} ${r.규격} ${r.키}`.toLowerCase(); + return words.every((w) => hay.includes(w)); +}; + +/** 자재 고르기 창 — 고르면 `done` 부르고 닫음 · `own` = 개인 층(개인 자재 · [신규 자재]) */ +export function pickMaterial(done: (item: MaterialItem) => void, own?: OwnLayer): void { + let mine: OwnRow[] = []; + const loaded = own + ? call<{ 줄: OwnRow[] }>(ownPath(own)) + .then((d) => void (mine = d.줄)) + .catch(() => undefined) + : Promise.resolve(); + let showNew = (): void => undefined; + const newButton = createButton({ + label: TEXT.newMat, + variant: "ghost", + onClick: () => showNew(), + }); + newButton.dataset.materialNew = ""; openModal({ title: TEXT.pickTitle, closeLabel: TEXT.pickClose, dialogClass: "m02-modal m02-pick", + ...(own ? { actions: [newButton] } : {}), mount: (body, close) => { const search = createInputField({ placeholder: TEXT.search, type: "search" }); const list = el("div", { className: "m02-pick__list" }); + const item = (m: MaterialItem, tags: string[] = []): HTMLElement => { + const row = el("button", { + className: "m02-pick__item", + attrs: { type: "button", "data-material": m.코드 }, + children: [ + el("span", { className: "m02-pick__key", text: m.코드 }), + `${m.이름} ${m.규격} ${m.단위}`.replace(/\s+/g, " "), + ...tags.map((t) => el("span", { className: "m02-pick__tag", text: t })), + ], + }); + row.addEventListener("click", () => { + done(m); + close(); + }); + return row; + }; let seq = 0; const paint = async (): Promise => { - const mine = ++seq; + const ticket = ++seq; list.replaceChildren(el("p", { className: "m02-struct__empty", text: TEXT.searching })); try { - const hits = await searchMaterials(search.input.value.trim()); - if (mine !== seq) return; + const q = search.input.value.trim(); + const [hits] = await Promise.all([searchMaterials(q), loaded]); + if (ticket !== seq) return; + const words = q.toLowerCase().split(/\s+/).filter(Boolean); + const ownHits = mine + .filter((r) => matches(r, words)) + .map((r) => item(toItem(r), r.값 === null ? [TEXT.own, TEXT.noPrice] : [TEXT.own])); + const all = [...ownHits, ...hits.map((m) => item(m))]; list.replaceChildren( - ...(hits.length - ? hits.map((m) => { - const row = el("button", { - className: "m02-pick__item", - attrs: { type: "button", "data-material": m.코드 }, - children: [ - el("span", { className: "m02-pick__key", text: m.코드 }), - `${m.이름} ${m.규격} ${m.단위}`.replace(/\s+/g, " "), - ], - }); - row.addEventListener("click", () => { - done(m); - close(); - }); - return row; - }) + ...(all.length + ? all : [el("p", { className: "m02-struct__empty", text: TEXT.noMatch })]), ); } catch { - if (mine === seq) showToast(TEXT.searchFailed, "error"); + if (ticket === seq) showToast(TEXT.searchFailed, "error"); } }; let timer = 0; @@ -70,13 +135,103 @@ export function pickMaterial(done: (item: MaterialItem) => void): void { window.clearTimeout(timer); timer = window.setTimeout(() => void paint(), 250); }); - body.append(search.root, list); + const picking = [search.root, list]; + if (own) + showNew = () => { + newButton.hidden = true; + body.replaceChildren( + newForm( + own, + search.input.value.trim(), + (row) => { + done(toItem(row)); + close(); + }, + () => { + newButton.hidden = false; + body.replaceChildren(...picking); + }, + ), + ); + }; + body.append(...picking); void paint(); search.input.focus(); }, }); } +/** 신규 자재 입력(148-3) — [저장] = 서버 추가 → 그 줄로 `saved` · 실패는 서버 까닭 그대로 */ +function newForm( + own: OwnLayer, + seed: string, + saved: (row: OwnRow) => void, + back: () => void, +): HTMLElement { + const name = createInputField({ label: TEXT.name, value: seed, required: true }); + const spec = createInputField({ label: TEXT.spec }); + const price = createInputField({ label: TEXT.price, type: "number", min: 0 }); + let unit = ""; + const unitHost = el("span"); + const drawUnit = (): void => + unitHost.replaceChildren( + createUnitPick({ + value: unit || NO_UNIT, + ariaLabel: TEXT.unit, + onPick: (u) => { + unit = u === NO_UNIT ? "" : u; + drawUnit(); + }, + }), + ); + drawUnit(); + const save = createButton({ + label: TEXT.save, + variant: "filled", + onClick: async () => { + const 이름 = name.input.value.trim(); + if (!이름 || !unit) return showToast(TEXT.needName, "error"); + const raw = price.input.value.trim(); + try { + saved( + await call(ownPath(own), { + method: "POST", + body: JSON.stringify({ + 이름, + 규격: spec.input.value.trim(), + 단위: unit, + 값: raw === "" ? null : Number(raw), + }), + }), + ); + } catch (e) { + showToast(e instanceof Error ? e.message : String(e), "error"); + } + }, + }); + return el("div", { + className: "m02-pick__new", + children: [ + name.root, + spec.root, + el("div", { + className: "ui-field", + children: [el("label", { className: "ui-field__label", text: TEXT.unit }), unitHost], + }), + price.root, + el("p", { className: "m02-struct__empty", text: TEXT.key }), + el("div", { + className: "ui-btn-group", + children: [createButton({ label: TEXT.back, variant: "ghost", onClick: back }), save], + }), + ], + }); +} + /** 일위대가 [자재 추가] 단추 — 누르면 고르기 창 · 고른 줄로 `done`(막을지는 부르는 쪽) */ -export const addMaterialButton = (label: string, done: (item: MaterialItem) => void): HTMLElement => - createButton({ label, variant: "filled", onClick: () => pickMaterial(done) }); +export const addMaterialButton = ( + label: string, + done: (item: MaterialItem) => void, + own?: OwnLayer, +): HTMLElement => + createButton({ label, variant: "filled", onClick: () => pickMaterial(done, own) }); diff --git a/resources/tester/test_m02_material_own.py b/resources/tester/test_m02_material_own.py index a0e8210c0..6c33074c0 100644 --- a/resources/tester/test_m02_material_own.py +++ b/resources/tester/test_m02_material_own.py @@ -9,6 +9,7 @@ from __future__ import annotations +import json from typing import Any import pytest @@ -97,3 +98,134 @@ def test_회사_층은_빈_목록_추가_400_남의_개인_403(world): other = _as(world, 43) assert other.get(URL.format("personal"), params={"owner": "7/42"}).status_code == 403 assert other.post(URL.format("personal") + "?owner=7/42", json=BOLT).status_code == 403 + + +# ── 148-3 — 저장 검사 · 셈 · 사본 · 층 사이 · 개인에 저장 ────────────── + + +def _personal(company: int, user: int, column: str = "a"): + return _layer(layers.personal_dir(company, user), column) + + +def _layer(folder, column): + """층 + 구조물 열 하나(빈 일위대가).""" + from M02_MasterTemplete import M02_MasterTemplete_Items as items_store + + items_store.ensure(folder) + with store.at(folder): + got = store.read("table", store.STRUCTURE_TABLE) + got["문서"]["열"] = [{"id": column, "머리": ["돌", "가"], "꼴": "수"}] + store.write("table", store.STRUCTURE_TABLE, got["판"], got["문서"]) + return folder + + +def _put_labor(folder, column, mats): + with store.at(folder): + have = store.read("labor", column) + store.write("labor", column, have["판"], {**have["문서"], "자재": mats}) + return store.read("labor", column)["문서"] + + +def test_개인_층_일위대가가_개인_자재를_받고_셈은_값(world): + from M02_MasterTemplete import M02_Labor_Doc as labor + + a = _personal(7, 42) + row = own.add(a, BOLT) + bare = own.add(a, {**BOLT, "규격": "M20", "값": None}) + doc = _put_labor(a, "a", [{"코드": row["키"], "수량": {"손": 2}}, {"코드": bare["키"]}]) + assert [(m["코드"], m["이름"], m["규격"]) for m in doc["자재"]] == [ + ("PM000001", "앵커볼트", "M16"), + ("PM000002", "앵커볼트", "M20"), + ] + with store.at(a): + got = labor.calc(doc, {})["자재"] + assert got[0]["기준단가"] == 1200 and got[0]["반영"] == 2400 + assert got[1]["기준단가"] is None and got[1]["까닭"] == "단가 없음" + + +def test_회사_층_문서엔_개인_자재_400(world): + from M02_MasterTemplete import M02_Labor_Doc as labor + + a = _personal(7, 42) + key = own.add(a, BOLT)["키"] + with store.at(layers.company_dir(7)), pytest.raises(store.StoreError) as e: + labor.clean({"열": "a", "담은로직": [], "자재": [{"코드": key}]}) + assert e.value.status == 400 and "개인 층에서만" in str(e.value.detail) + with pytest.raises(store.StoreError): # 기본(마스터) 층도 + labor.clean({"열": "a", "담은로직": [], "자재": [{"코드": key}]}) + + +def test_사본_단가에_개인_줄_굳힘_사본_셈은_층_셈(world): + from M02_MasterTemplete import M02_Item_Prices as prices + from M02_MasterTemplete import M02_Item_Source as source + from M02_MasterTemplete import M02_Labor_Doc as labor + + a = _personal(7, 42) + key = own.add(a, BOLT)["키"] + doc = _put_labor(a, "a", [{"코드": key, "수량": {"손": 3}}]) + with store.at(a): + ss = next(r["키"] for r in store.read_combos()["줄"] if r.get("열") == "a") + layer_calc = labor.calc(doc, {}) + files = source.build_source({"layer": "personal", "owner": "7/42", "key": ss}) + assert files["단가.json"][own.BUNDLE]["줄"] == [{"키": key, **{k: BOLT[k] for k in BOLT}}] + whole = prices.load_prices(files["단가.json"]) + copy_calc = labor.calc(doc, {}, whole=whole, read=lambda ref: None, pills=[]) + assert copy_calc["자재"][0]["기준단가"] == layer_calc["자재"][0]["기준단가"] == 1200 + # 원본 목록이 바뀌어도 사본 그대로 — 사본 단가는 굳힌 것 + (a / own.FILE).write_text(json.dumps(own._blank()), encoding="utf-8") + assert ( + labor.calc(doc, {}, whole=whole, read=lambda ref: None, pills=[])["자재"][0]["기준단가"] + == 1200 + ) + + +def test_층_사이_복사_받는_개인_층에_같은_키_회사로는_400(world): + a = _personal(7, 42) + b = _personal(7, 43, "b") + key = own.add(a, BOLT)["키"] + _put_labor(a, "a", [{"코드": key}]) + with store.at(b): + store.copy_from("labor", "b", "a", source_root=a) + assert store.read("labor", "b")["문서"]["자재"][0]["코드"] == key + assert [r["키"] for r in own.rows(b)] == [key] + company = _layer(layers.company_dir(7), "c") + with store.at(company): + with pytest.raises(store.StoreError) as e: + store.copy_from("labor", "c", "a", source_root=a) + assert e.value.status == 400 + + +def test_개인에_저장은_사본_개인_줄을_내_목록에_같은_키(world, tmp_path): + from M02_MasterTemplete import M02_Item_Personal as personal + from M02_MasterTemplete import M02_Item_Source as source + + a = _personal(7, 42) + key = own.add(a, BOLT)["키"] + _put_labor(a, "a", [{"코드": key}]) + with store.at(a): + ss = next(r["키"] for r in store.read_combos()["줄"] if r.get("열") == "a") + files = source.build_source({"layer": "personal", "owner": "7/42", "key": ss}) + copy = tmp_path / "copy" + copy.mkdir() + for name, body in files.items(): + (copy / name).write_text(json.dumps(body, ensure_ascii=False), encoding="utf-8") + b = _personal(7, 43, "b") + made = personal.create_item(b, ["돌", "나"], "볼트", "개", copy) + with store.at(b): + doc = store.read("labor", made["열"])["문서"] + assert doc["자재"][0]["코드"] == key + assert own.rows(b) == own.rows(a) + + +def test_화면_신규_자재는_머리_단추_개인_층만(): + """148-3 — [신규 자재] = 공용 모달 `actions`(개인 층만) · 품번은 안 보냄 · 상세는 개인 층에만 넘김.""" + import os + + here = os.path.join(os.path.dirname(__file__), "..", "..", "M02_MasterTemplete") + pick = open(os.path.join(here, "M02_Material_Pick.ts"), encoding="utf-8").read() + assert "...(own ? { actions: [newButton] } : {})" in pick + assert ( + "`/layers/personal/materials" in pick and "키:" not in pick.split("JSON.stringify")[1][:200] + ) + detail = open(os.path.join(here, "M02_MasterTemplete_Labor_Detail.ts"), encoding="utf-8").read() + assert 'opt.layer === "personal" ? { owner: library.owner } : undefined' in detail