feat(M02): 자재 추가 머리 [신규 자재] · 개인 자재 고르기 · 저장 검사 · 금액 · 사본 단가 굳힘 · 층 복사(148-3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3
This commit is contained in:
2026-10-09 19:14:50 +09:00
co-authored by Claude Opus 5.5
parent 3e9acce38d
commit 1763d124ca
9 changed files with 427 additions and 37 deletions
+4 -1
View File
@@ -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)}
+5
View File
@@ -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
+18 -2
View File
@@ -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:
@@ -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 = {
@@ -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)
@@ -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;
+57
View File
@@ -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)])
+181 -26
View File
@@ -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<void> => {
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<OwnRow>(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) });
+132
View File
@@ -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