Merge remote-tracking branches 'origin/main_desktop_1', 'origin/sub_desktop_1', 'origin/sub_laptop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_3' and 'origin/sub_laptop_4' into main_laptop_1

This commit is contained in:
27 changed files with 35982 additions and 30861 deletions
+8
View File
@@ -124,6 +124,14 @@ def get_pick(kind: str, q: str = "", limit: int = 50) -> dict:
return _call(store.pick, kind, q, limit)
@router.get("/materials")
def get_materials(
sub: str, detail: str = "", spec: str = "", region: str = "", limit: int = 50
) -> dict:
"""재료 고르기 조건 안 후보 — sub = 구분 · detail = 상세구분 · spec = 규격 · region = 자재지역."""
return _call(store.materials, sub, detail, spec, region, limit)
@router.post("/calc")
def post_calc(body: CalcBody) -> dict:
return _call(store.calc, body.key, body.inputs, body.row, body.file)
+23
View File
@@ -412,6 +412,29 @@ def pick(kind: str, q: str, limit: int) -> dict:
return {"total": len(hits), "items": hits[: min(max(limit, 1), 200)]}
def _market_brief(row: dict) -> dict:
return {
"ref": str(row.get("")),
**{k: row.get(k) for k in ("구분", "상세구분", "이름", "규격", "단위")},
"": _lowest(row),
"관급": row.get("관급") is not None,
}
def materials(sub: str, detail: str, spec: str, region: str, limit: int) -> dict:
"""재료 고르기 조건 안 후보 목록 — 화면·테스트 컨테이너가 같이 씀.
`기본` = 시험 계산이 쓸 줄(대표 줄이 없을 때의 첫 줄)."""
whole = cm.master(folder=FOLDER)
cond = {"구분": sub, "상세구분": detail or None, "규격": spec or None}
env = {cm.mf.REGION: region} if region else {}
keys = cm.mf.candidates(whole, cond, env)
return {
"total": len(keys),
"기본": cm.mf.pick(whole, cond, env) if keys else None,
"items": [_market_brief(whole.get(k)) for k in keys[: min(max(limit, 1), 200)]],
}
# ── 시험 계산 ──────────────────────────────────────────────────────────
def _swap(files: dict, key: str, row: dict, file: str | None) -> str:
"""저장 전 시험 — 메모리 사본에서 그 로직을 고친 줄로 바꿈(없으면 `file` 에 더함) · 돌려받는 것 = 계산할 키."""
+36 -16
View File
@@ -1,6 +1,6 @@
/* =============================================================================
* M01_MasterData_UI_Page.ts
* 마스터 요소 화면 — 좌측 도킹 패널(컨테이너 여덟: 인력·재료·기계·소요량·계수·환율·요율·일위대가 로직)
* 마스터 요소 화면 — 좌측 도킹 패널(컨테이너 아홉: 인력·재료·기계·소요량·계수·환율·요율·일위대가 로직·로직 테스트)
* / 우측 요소 표 · 표 목록 · 로직 편집
*
* 시스템 관리자만. 고친 것은 초안(sessionStorage)에 쌓이고 [저장] 한 번에 서버로 —
@@ -45,6 +45,7 @@ function buildPage(): HTMLElement {
let query = "";
let dispose = (): void => {};
let logicMounted = false;
let testMounted = false;
/* --- 우측: 머리(제목·찾기·저장) + 알림 + 본문 --- */
const title = el("h2", { className: "m01-master__title", text: L("M01_PickFile") });
@@ -60,6 +61,7 @@ function buildPage(): HTMLElement {
children: [title, searchField.root, summary, drop, save],
});
const logicHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } });
const testHost = el("div", { className: "m01-master__logic", attrs: { hidden: "" } });
const elementView = el("div", {
className: "m01-master__elements",
children: [head, notice, body],
@@ -77,14 +79,15 @@ function buildPage(): HTMLElement {
onDraftChange(refreshBar);
refreshBar();
const showMode = (logic: boolean): void => {
logicHost.hidden = !logic;
elementView.hidden = logic;
const showMode = (mode: "elements" | "logic" | "test"): void => {
logicHost.hidden = mode !== "logic";
testHost.hidden = mode !== "test";
elementView.hidden = mode !== "elements";
};
const openFile = (pick: Pick): void => {
dispose();
showMode(false);
showMode("elements");
if (pick.view) {
query = pick.view.q;
search.value = query;
@@ -162,21 +165,38 @@ function buildPage(): HTMLElement {
return [L("M01_Stale").replace("{value}", stale.map(fileLabel).join(", ")), again];
};
/* --- 좌측: 컨테이너 여덟 --- */
const side = buildSide(openFile, () => {
dispose();
dispose = (): void => {};
current = null;
showMode(true);
if (logicMounted) return;
logicMounted = true;
void import("./M01_MasterData_UI_Logic_Page").then((m) => m.mountM01Logic(logicHost, side));
});
/* --- 좌측: 컨테이너 아홉 --- */
const side = buildSide(
openFile,
() => {
dispose();
dispose = (): void => {};
current = null;
showMode("logic");
if (logicMounted) return;
logicMounted = true;
void import("./M01_MasterData_UI_Logic_Page").then((m) => m.mountM01Logic(logicHost, side));
},
() => {
dispose();
dispose = (): void => {};
current = null;
showMode("test");
if (testMounted) return;
testMounted = true;
void import("./M01_MasterData_UI_Test").then((m) => m.mountM01Test(testHost));
},
);
const layout = el("div", { className: "ui-workflow-layout m01-master" });
const main = el("main", {
className: "ui-workflow-layout__main",
children: [el("div", { className: "m01-master__panel", children: [elementView, logicHost] })],
children: [
el("div", {
className: "m01-master__panel",
children: [elementView, logicHost, testHost],
}),
],
});
const overlays = createWorkflowOverlays({
title: L("M01_Title"),
+16 -2
View File
@@ -104,8 +104,13 @@ const LEAF: Group[] = ["환율", "요율"];
/** 거름이 있는 컨테이너 — 펼치면 「전체」(또는 닫기 전 거름) 이 바로 열림 */
const FILTERED: Group[] = ["인력", "기계", "소요량", "계수"];
const LOGIC_ID = "로직|";
const TEST_ID = "테스트|";
export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): SideHandle {
export function buildSide(
onOpen: (pick: Pick) => void,
onLogic: () => void,
onTest: () => void,
): SideHandle {
const lists = new Map<Group, FileInfo[]>();
const bodies = new Map<Group, HTMLElement>();
const buttons = new Map<string, HTMLElement>();
@@ -375,7 +380,16 @@ export function buildSide(onOpen: (pick: Pick) => void, onLogic: () => void): Si
onLogic();
});
const root = el("div", { className: "m01-side", children: [...groups, logic] });
// 테스트 컨테이너 — 눌러 펼치는 목록이 없는 한 줄(환율·요율과 같은 모양)
const test = section(L("M01_GroupTest"), null);
test.classList.add("m01-side__leaf");
buttons.set(TEST_ID, test);
test.querySelector(".ui-collapsible__title")?.addEventListener("click", () => {
setActive(TEST_ID);
onTest();
});
const root = el("div", { className: "m01-side", children: [...groups, logic, test] });
attachCollapsible(root);
void Promise.all(GROUPS.map(refresh)).catch((error) =>
showToast(error instanceof Error ? error.message : L("M01_LoadFailed"), "error"),
+98
View File
@@ -0,0 +1,98 @@
/* =============================================================================
* M01_MasterData_UI_Test.ts
* 로직 테스트 컨테이너 틀 — 공종(로직) 10 개 고르기 + 방식 탭 A·B·C
* 읽기와 시험 계산만 — 저장 없음 · 정본 로직은 안 고침 · 계산은 로직 화면과 같은 `/calc`
* 접속 계약 — 방식 파일마다 `render(host, logicKey)` 하나
* ========================================================================== */
import { createSelectField, el, showToast } from "@ui/ui_template_elements";
import { fetchLogics } from "./M01_MasterData_UI_Logic_Api";
import { render as renderA } from "./M01_MasterData_UI_Test_A";
import { tt } from "./M01_MasterData_UI_Test_Text";
import "./M01_MasterData_UI_Test_Style.css";
/** 시험 대상 산림품셈 로직 10 개 — 복잡도(입력·표 찾기·로직 부르기·덧줄·재료)로 잰 순서.
* 재는 법 `resources/master_data/scripts/로직_복잡도_재기.py` · 메쌓기·노체·트럭 운반은 끝까지 눌러 보는 세 개. */
export const TEST_KEYS: readonly string[] = [
"GF000181", // 12-16 맨홀
"GF000177", // 12-12 날개벽
"GF000173", // 12-11-1 VR관(소켓식) 부설
"GF000174", // 12-11-2-1 흄관 부설
"GF000160", // 12-4 합판거푸집
"GF000182", // 12-17-1 펌프카 타설
"GF000069", // 7-11 동력상하차기 집재
"GF000219", // 13-4-1 돌쌓기 메쌓기(인력)
"GF000119", // 9-16-1 노체포설
"GF000137", // 10-4 중기운반 트레일러
];
type Render = (host: HTMLElement, logicKey: string) => void;
/** 방식 파일이 오면 여기 한 줄씩 잇는다 — null 은 준비 중 */
const METHODS: { id: string; label: () => string; render: Render | null }[] = [
{ id: "A", label: () => tt("Tab_A"), render: renderA },
{ id: "B", label: () => tt("Tab_B"), render: null },
{ id: "C", label: () => tt("Tab_C"), render: null },
];
export function mountM01Test(host: HTMLElement): void {
let logicKey = TEST_KEYS[0];
let method = METHODS[0].id;
const body = el("div", { className: "m01-test__body" });
const tabs = el("div", { className: "m01-test__tabs" });
const select = createSelectField({
label: tt("Pick_Logic"),
options: TEST_KEYS.map((k) => ({ value: k, text: k })),
value: logicKey,
onChange: (v) => {
logicKey = v;
draw();
},
});
const draw = (): void => {
tabs.replaceChildren(
...METHODS.map((m) =>
el("button", {
className: `m01-test__tab${m.id === method ? " is-active" : ""}`,
text: m.label(),
attrs: { type: "button", "data-method": m.id },
}),
),
);
tabs.querySelectorAll("button").forEach((b) =>
b.addEventListener("click", () => {
method = b.dataset.method ?? "A";
draw();
}),
);
const one = METHODS.find((m) => m.id === method);
if (one?.render) one.render(body, logicKey);
else body.replaceChildren(el("p", { className: "m01-test__muted", text: tt("Soon") }));
};
host.replaceChildren(
el("div", {
className: "m01-test",
children: [
el("div", {
className: "m01-test__bar",
children: [el("h2", { text: tt("Title") }), select.root, tabs],
}),
el("p", { className: "m01-test__muted", text: tt("Read_Only") }),
body,
],
}),
);
// 목록에서 이름을 받아 「번호 이름」 으로 바꿈 — 못 받아도 키로 쓸 수 있음
fetchLogics()
.then((all) => {
const label = new Map(all.map((r) => [r., `${r.} ${r.}`]));
select.setOptions(
TEST_KEYS.map((k) => ({ value: k, text: label.get(k) ?? k })),
logicKey,
);
})
.catch((error) => showToast(error instanceof Error ? error.message : tt("Failed"), "error"));
draw();
}
+274
View File
@@ -0,0 +1,274 @@
/* =============================================================================
* M01_MasterData_UI_Test_A.ts
* 방식 A — 질문·답 마법사: 공종 → 조건 질문(한 번에 하나) → 재료·기계 고르기 → 결과 호표
* 식은 안 보임 · 시험 계산은 로직 화면과 같은 `/calc` · 저장 없음
* ========================================================================== */
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
import {
fetchLogic,
runCalc,
searchElements,
type CalcAnswer,
type ElementBrief,
type HoLine,
type LogicInput,
type LogicOne,
} from "./M01_MasterData_UI_Logic_Api";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { tt } from "./M01_MasterData_UI_Test_Text";
type Step =
{ kind: "job" } | { kind: "ask"; spec: LogicInput } | { kind: "pick" } | { kind: "result" };
const PICKABLE = ["재료", "기계"];
export function render(host: HTMLElement, logicKey: string): void {
host.replaceChildren(el("p", { className: "m01-test__muted", text: tt("Loading") }));
fetchLogic(logicKey)
.then((one) => wizard(host, one))
.catch((error) => showToast(error instanceof Error ? error.message : tt("Failed"), "error"));
}
function wizard(host: HTMLElement, one: LogicOne): void {
const { logic } = one;
const values: Record<string, string> = {};
const swaps = new Map<number, ElementBrief>();
const lines = (logic. ?? [])
.map((line, i) => ({ line, i }))
.filter((x) => PICKABLE.includes(x.line.));
const steps: Step[] = [
{ kind: "job" },
...(logic. ?? []).map((spec): Step => ({ kind: "ask", spec })),
...(lines.length ? [{ kind: "pick" } as Step] : []),
{ kind: "result" },
];
let at = 0;
const go = (next: number): void => {
at = next;
draw();
};
const frame = (
title: string,
note: string,
content: HTMLElement[],
nextOk: boolean,
): HTMLButtonElement => {
const last = steps[at].kind === "result";
const back = createButton({ label: tt("Back"), variant: "ghost", onClick: () => go(at - 1) });
back.disabled = at === 0;
const next = createButton({ label: tt("Next"), onClick: () => go(at + 1) });
next.disabled = !nextOk;
const again = createButton({ label: tt("Again"), variant: "ghost", onClick: () => go(0) });
host.replaceChildren(
el("div", {
className: "m01-test__card",
children: [
el("p", {
className: "m01-test__step",
text: tt("Step", { n: at + 1, total: steps.length }),
}),
el("h3", { text: title }),
...(note ? [el("p", { className: "m01-test__muted", text: note })] : []),
...content,
el("div", { className: "m01-test__nav", children: last ? [back, again] : [back, next] }),
],
}),
);
return next;
};
const draw = (): void => {
const step = steps[at];
if (step.kind === "job") {
frame(
`${logic.} ${logic.}`,
tt("Job_Note"),
[el("p", { text: `${tt("Job_Title")} · ${logic.}` })],
true,
);
} else if (step.kind === "ask") drawAsk(step.spec);
else if (step.kind === "pick") drawPick();
else void drawResult();
};
const drawAsk = (spec: LogicInput): void => {
const name = spec.;
const ready = (): boolean => (values[name] ?? "").trim() !== "";
let next: HTMLButtonElement | null = null;
const changed = (v: string): void => {
values[name] = v;
if (next) next.disabled = !ready();
};
let control: HTMLElement;
if (spec.?.length) {
control = createSelectField({
options: ["", ...spec..map(String)].map((o) => ({ value: o, text: o })),
value: values[name] ?? "",
onChange: changed,
}).root;
} else {
const box = el("input", {
className: "m01-test__input",
attrs: { type: "text", inputmode: "decimal" },
});
box.value = values[name] ?? "";
box.addEventListener("input", () => changed(box.value));
control = box;
}
const question = spec.?.length
? tt("Ask_Pick", { name })
: tt("Ask_Number", { name, unit: spec.단위 ? ` (${spec.})` : "" });
const hint = spec. ? tt("Ask_Range", { min: spec.범위[0], max: spec.범위[1] }) : "";
next = frame(question, hint, [control], ready());
};
const drawPick = (): void => {
const rows = lines.map(({ line, i }) => pickRow(line, i));
frame(
tt("Mat_Title"),
tt("Mat_Note"),
rows.length ? rows : [el("p", { text: tt("Mat_None") })],
true,
);
};
const pickRow = (line: HoLine, i: number): HTMLElement => {
const price = one.prices[line.]?.;
const found = new Map<string, ElementBrief>();
const select = createSelectField({
options: [{ value: "", text: tt("Mat_Default") }],
value: "",
onChange: (ref) => {
const item = found.get(ref);
if (item) swaps.set(i, item);
else swaps.delete(i);
},
});
void searchElements(line., line. ?? "")
.then((r) => {
r.items.forEach((it) => found.set(it.ref, it));
select.setOptions(
[
{ value: "", text: tt("Mat_Default") },
...r.items.map((it) => ({
value: it.ref,
text: [`${it. ?? ""} ${it. ?? ""}`.trim(), formatNumber(it.)]
.filter(Boolean)
.join(" · "),
})),
],
swaps.get(i)?.ref ?? "",
);
})
.catch(() => undefined);
const spec = line. ? ` ${line.}` : "";
const unit = line. ?? "";
const cost =
price !== undefined && price !== null ? ` · ${tt("Mat_Price")} ${formatNumber(price)}` : "";
return el("div", {
className: "m01-test__pick",
children: [
el("strong", { text: `${line.} · ${line. ?? line.}${spec}` }),
el("span", { className: "m01-test__muted", text: `${unit}${cost}` }),
select.root,
],
});
};
const drawResult = async (): Promise<void> => {
const inputs: Record<string, unknown> = {};
for (const spec of logic. ?? []) {
const raw = (values[spec.] ?? "").trim();
if (raw === "") continue;
const option = spec.?.find((o) => String(o) === raw);
inputs[spec.] =
option !== undefined ? option : Number.isNaN(Number(raw)) ? raw : Number(raw);
}
// 바꿔 본 재료·기계가 있으면 복사본으로만 셈 — 정본 로직은 그대로
const row = swaps.size
? {
...logic,
: (logic. ?? []).map((l, i) => {
const s = swaps.get(i);
return s ? { ...l, 요소: s.ref, 이름: s.이름 ?? l., 규격: s.규격 ?? l. } : l;
}),
}
: undefined;
let answer: CalcAnswer;
try {
answer = await runCalc({ key: logic.키, inputs, ...(row ? { row, file: one.file } : {}) });
} catch (error) {
showToast(error instanceof Error ? error.message : tt("Failed"), "error");
return;
}
frame(tt("Result_Title"), "", resultView(answer), true);
};
draw();
}
function resultView(answer: CalcAnswer): HTMLElement[] {
if (!answer.ok) {
return [
el("div", {
className: "m01-test__reasons",
children: [
el("strong", { text: tt("Result_Stopped") }),
el("div", { text: answer.reason }),
],
}),
];
}
const out: HTMLElement[] = [];
if (answer.lines) {
out.push(
el("table", {
className: "m01-test__grid",
children: [
el("thead", {
children: [
el("tr", {
children: [tt("Col_Name"), tt("Col_Qty"), tt("Col_Amount")].map((h) =>
el("th", { text: h }),
),
}),
],
}),
el("tbody", {
children: answer.lines.map((l) =>
el("tr", {
children: [
el("td", { text: l.출처 ? `${l.} (${l.})` : l. }),
el("td", { text: `${formatNumber(l.)} ${l.}` }),
el("td", { className: "m01-test__money", text: formatNumber(l.) }),
],
}),
),
}),
],
}),
);
}
if (answer.sums) {
out.push(
el("p", {
className: "m01-test__total",
attrs: { "data-total": String(answer.sums["계"] ?? 0) },
text: `${tt("Sum_Total")} ${formatNumber(answer.sums["계"] ?? 0)}`,
}),
);
}
if (answer.result !== undefined) {
out.push(
el("p", {
className: "m01-test__total",
attrs: { "data-total": String(answer.result) },
text: `${tt("Sum_Result")} ${formatNumber(answer.result)}`,
}),
);
}
return out;
}
+271
View File
@@ -0,0 +1,271 @@
/* =============================================================================
* M01_MasterData_UI_Test_C.ts
* 로직 테스트 — 방식 C(흐름 그림). 설계 값 → 표 찾기 → 수량 → × 단가 → 할증·덧줄 → 비목 합계 → 계
*
* 읽기 + 시험 계산만(저장 없음 · 정본 로직은 건드리지 않음) — 로직 화면이 쓰는 같은 길
* (`fetchLogic` · `runCalc`)을 그대로 씀. 상자를 누르면 값과 출처 · 로직이 로직을 부르는
* 줄은 상자 안에서 펼침 · 설계 값을 바꾸면 흐름의 값이 바로 바뀜.
* 뼈대(어느 상자가 어디에) = `M01_MasterData_UI_Test_C_Model.ts`.
* ========================================================================== */
import { createSelectField, el, showToast } from "@ui/ui_template_elements";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import {
fetchLogic,
runCalc,
type CalcAnswer,
type LogicOne,
type LogicRow,
} from "./M01_MasterData_UI_Logic_Api";
import {
buildFlow,
type FlowBox,
type FlowColumn,
type FlowKind,
} from "./M01_MasterData_UI_Test_C_Model";
import "./M01_MasterData_UI_Test_C_Style.css";
const TEXT = {
Loading: ["불러오는 중", "Loading"],
Failed: ["불러오지 못함", "Load failed"],
Stopped: ["멈춤", "Stopped"],
NoInputs: ["받을 값 없음", "No inputs"],
Other: ["그 밖", "Other"],
Sub: ["이 로직이 부르는 로직", "Logic called here"],
SubLines: ["호표", "Unit-cost lines"],
Col_입력: ["설계 값", "Design values"],
Col_중간: ["표 찾기", "Table lookup"],
Col_수량: ["수량", "Qty"],
Col_단가: ["× 단가", "× Unit price"],
Col_덧줄: ["할증·덧줄", "Extra lines"],
Col_비목: ["비목 합계", "Cost items"],
Col_계: ["계", "Total"],
} as const satisfies Record<string, readonly [string, string]>;
const tc = (key: keyof typeof TEXT): string =>
TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0];
const title = (kind: FlowKind): string => tc(`Col_${kind}` as keyof typeof TEXT);
/** 흐름 그림 하나를 `host` 에 그림 — 로직 키 하나(어느 로직이 와도 돎) */
export function render(host: HTMLElement, logicKey: string): void {
host.replaceChildren(el("p", { className: "m01c__muted", text: tc("Loading") }));
void fetchLogic(logicKey)
.then((one) => mount(host, one))
.catch((error: unknown) => {
host.replaceChildren(
el("p", {
className: "m01c__bad",
text: error instanceof Error ? error.message : tc("Failed"),
}),
);
});
}
function mount(host: HTMLElement, one: LogicOne): void {
const row = one.logic;
const values: Record<string, string> = {};
// 고르기 칸은 첫 값으로 시작 — 설계자가 바로 흐름을 보게(수 칸은 비워 둠)
for (const spec of row. ?? []) {
if (spec.?.length) values[spec.] = String(spec.[0]);
}
const open = new Set<string>();
const subs = new Map<string, LogicRow>();
let answer: CalcAnswer | null = null;
let timer: number | undefined;
const rest = el("div", { className: "m01c__rest" });
const stopped = el("p", { className: "m01c__bad", attrs: { hidden: "" } });
const boxView = (box: FlowBox): HTMLElement => {
const node = el("details", {
className: `m01c__box${box.bad ? " m01c__box--bad" : ""}`,
children: [
el("summary", {
children: [
el("span", { className: "m01c__label", text: box.label }),
el("span", { className: "m01c__value", text: box.value }),
...(box.note ? [el("span", { className: "m01c__note", text: box.note })] : []),
],
}),
el("dl", {
className: "m01c__detail",
children: box.detail.flatMap(([k, v]) => [el("dt", { text: k }), el("dd", { text: v })]),
}),
],
});
if (open.has(box.id)) node.open = true;
node.addEventListener("toggle", () => (node.open ? open.add(box.id) : open.delete(box.id)));
if (box.logic) node.append(subView(box.logic));
return node;
};
/** 로직이 부르는 로직 — 펼칠 때 한 번 읽어 그 호표를 상자 안에 보임 */
const subView = (key: string): HTMLElement => {
const body = el("div", { className: "m01c__sub-body" });
const node = el("details", {
className: "m01c__sub",
children: [el("summary", { text: `${tc("Sub")} · ${key}` }), body],
});
const fill = (sub: LogicRow): void => {
body.replaceChildren(
el("p", { className: "m01c__muted", text: `${sub.} (${sub.}) · ${sub.}` }),
el("p", { className: "m01c__muted", text: tc("SubLines") }),
el("ul", {
children: (sub. ?? []).map((item) =>
el("li", { text: `${item. ?? item.} · ${item.}` }),
),
}),
);
};
node.addEventListener("toggle", () => {
if (!node.open || body.childElementCount) return;
const had = subs.get(key);
if (had) {
fill(had);
return;
}
body.replaceChildren(el("p", { className: "m01c__muted", text: tc("Loading") }));
void fetchLogic(key)
.then((deep) => {
subs.set(key, deep.logic);
fill(deep.logic);
})
.catch(() => body.replaceChildren(el("p", { className: "m01c__bad", text: tc("Failed") })));
});
return node;
};
const columnView = (column: FlowColumn): HTMLElement => {
const head = el("h4", { className: "m01c__col-head", text: title(column.kind) });
// 상자가 많으면 비목으로 묶어 접음 — 줄이 많은 로직도 한 화면에(비목 없는 줄은 「그 밖」)
const costOf = (box: FlowBox): string => box.cost ?? tc("Other");
const costs = [...new Set(column.boxes.map(costOf))];
const body =
column.boxes.length > 6 && costs.length > 1
? costs.map((cost) =>
el("details", {
className: "m01c__group",
attrs: { open: "" },
children: [
el("summary", { text: cost }),
...column.boxes.filter((b) => costOf(b) === cost).map(boxView),
],
}),
)
: column.boxes.map(boxView);
return el("section", { className: "m01c__col", children: [head, ...body] });
};
const redraw = (): void => {
const columns = buildFlow(row, answer, one.prices, values);
const nodes: HTMLElement[] = [];
for (const column of columns.slice(1)) {
nodes.push(el("span", { className: "m01c__arrow", text: "" }), columnView(column));
}
rest.replaceChildren(...nodes);
const reason = answer && !answer.ok ? answer.reason : "";
stopped.textContent = reason ? `${tc("Stopped")}${reason}` : "";
stopped.hidden = !reason;
};
const run = (): void => {
const inputs: Record<string, unknown> = {};
for (const spec of row. ?? []) {
const raw = (values[spec.] ?? "").trim();
if (raw === "") continue; // 빈 칸은 안 보냄 — 엔진이 「입력 없음」 으로 멈춤
const option = spec.?.find((o) => String(o) === raw);
inputs[spec.] =
option !== undefined ? option : Number.isNaN(Number(raw)) ? raw : Number(raw);
}
void runCalc({ key: row.키, inputs })
.then((got) => {
answer = got;
redraw();
})
.catch((error: unknown) => {
showToast(error instanceof Error ? error.message : tc("Failed"), "error");
});
};
const later = (): void => {
window.clearTimeout(timer);
timer = window.setTimeout(run, 250);
};
host.replaceChildren(
el("div", {
className: "m01c",
children: [
el("div", {
className: "m01c__head",
children: [
el("h3", { text: `${row.} ${row.}` }),
el("span", { className: "m01c__muted", text: `${row.} · ${row.}` }),
],
}),
...(one.reasons.length
? [el("p", { className: "m01c__bad", text: one.reasons.join(" · ") })]
: []),
stopped,
el("div", {
className: "m01c__flow",
children: [inputColumn(row, values, later), rest],
}),
],
}),
);
redraw();
run();
}
/** 설계 값 칸 — 한 번만 세움(다시 그려도 적던 값·글쇠 자리가 안 날아감) */
function inputColumn(
row: LogicRow,
values: Record<string, string>,
onChange: () => void,
): HTMLElement {
const fields = (row. ?? []).map((spec) => {
let control: HTMLElement;
if (spec.?.length) {
control = createSelectField({
options: spec.고르기.map((o) => ({ value: String(o), text: String(o) })),
value: values[spec.] ?? "",
compact: true,
onChange: (v) => {
values[spec.] = v;
onChange();
},
}).root;
} else {
const box = el("input", {
className: "m01c__input",
attrs: { type: "text", inputmode: "decimal" },
});
if (spec.) box.placeholder = `${spec.[0]} ${spec.[1]}`;
box.value = values[spec.] ?? "";
box.addEventListener("input", () => {
values[spec.] = box.value;
onChange();
});
control = box;
}
return el("label", {
className: "m01c__field",
children: [
el("span", {
className: "m01c__label",
text: spec.단위 ? `${spec.} (${spec.})` : spec.,
}),
control,
],
});
});
return el("section", {
className: "m01c__col m01c__col--input",
children: [
el("h4", { className: "m01c__col-head", text: title("입력") }),
...(fields.length ? fields : [el("p", { className: "m01c__muted", text: tc("NoInputs") })]),
],
});
}
@@ -0,0 +1,238 @@
/* =============================================================================
* M01_MasterData_UI_Test_C_Model.ts
* 방식 C(흐름 그림)의 뼈대 — 로직 한 줄 + 시험 계산 답 → 왼쪽에서 오른쪽 칸의 상자.
* 설계 값 → 표 찾기 → 수량 → × 단가 → 할증·덧줄 → 비목 합계 → 계
*
* 순수 — DOM·서버를 안 씀(시험이 이 파일만 떼어 Node 로 돌림).
* 값은 모두 시험 계산 답(`POST /calc`)에서 옴 — 여기서 새로 셈하지 않음.
* ========================================================================== */
import type {
CalcAnswer,
CalcLine,
ElementBrief,
HoLine,
LogicRow,
NamedFormula,
} from "./M01_MasterData_UI_Logic_Api";
export type FlowKind = "입력" | "중간" | "수량" | "단가" | "덧줄" | "비목" | "계";
/** 흐름 상자 하나 — 누르면 `detail`(값과 출처) */
export interface FlowBox {
/** 다시 그려도 같은 id — 펼친 상자를 그대로 둠 */
id: string;
kind: FlowKind;
label: string;
value: string;
note?: string;
detail: [string, string][];
/** 로직이 로직을 부르는 줄 — 그 로직 키(펼치기) */
logic?: string;
/** 비목 — 상자가 많은 로직에서 묶기·접기 */
cost?: string;
/** 값이 없어 막힌 상자 */
bad?: boolean;
}
export interface FlowColumn {
kind: FlowKind;
boxes: FlowBox[];
}
export const COSTS = ["노무비", "재료비", "경비"];
export function fmt(value: unknown): string {
if (typeof value === "number") return value.toLocaleString("ko-KR", { maximumFractionDigits: 4 });
if (value === null || value === undefined) return "";
return typeof value === "object" ? JSON.stringify(value) : String(value);
}
/** 로직 줄의 결과가 돈인지 — 돈이면 호표·비목·계, 아니면 결과 식 하나 */
export const isMoney = (row: LogicRow): boolean =>
!("결과" in row) && (row. ?? "").startsWith("원");
/** `로직(GC000268, 기계='…')` 에서 부르는 로직 키 */
export function logicRef(element: string): string | undefined {
return /로직\(\s*([A-Z]{1,2}\w+)/.exec(element)?.[1];
}
const keep = (pairs: [string, unknown][]): [string, string][] =>
pairs.filter(([, v]) => v !== undefined && v !== null && v !== "").map(([k, v]) => [k, fmt(v)]);
/** 파일에 있으나 화면 틀에 없는 칸(입력·호표의 출처·비고) */
const extra = (item: object, name: string): string | undefined =>
(item as Record<string, string | undefined>)[name];
export function buildFlow(
row: LogicRow,
answer: CalcAnswer | null,
prices: Record<string, ElementBrief | null>,
values: Record<string, string>,
): FlowColumn[] {
const ok = answer?.ok ? answer : null;
const lines = ok?.lines ?? [];
const middle = ok?.middle ?? {};
const columns: FlowColumn[] = [inputColumn(row, values)];
const mid = middleColumn(row, middle);
if (mid.boxes.length) columns.push(mid);
if (!isMoney(row)) {
columns.push(resultColumn(row, ok?.result));
return columns;
}
const ho = row. ?? [];
columns.push(qtyColumn(ho, lines), priceColumn(ho, lines, prices));
const plus = extraColumn(row. ?? [], lines, ho.length);
if (plus.boxes.length) columns.push(plus);
const sums = ok?.sums ?? null;
columns.push(costColumn(sums, lines), totalColumn(row, sums));
return columns;
}
function inputColumn(row: LogicRow, values: Record<string, string>): FlowColumn {
const boxes = (row. ?? []).map((spec, i) => ({
id: `입력:${i}`,
kind: "입력" as const,
label: spec.이름,
value: values[spec.] ?? "",
note: spec.단위,
detail: keep([
["단위", spec.],
["고르기", (spec. ?? []).join(" · ")],
["범위", spec. ? `${spec.[0]} ${spec.[1]}` : ""],
["출처", extra(spec, "출처")],
]),
}));
return { kind: "입력", boxes };
}
function middleColumn(row: LogicRow, middle: Record<string, unknown>): FlowColumn {
const boxes = (row. ?? []).map((step, i) => ({
id: `중간:${i}`,
kind: "중간" as const,
label: step.이름,
value: fmt(middle[step.]),
note: step.식.startsWith("찾기(") ? "표 찾기" : "식",
detail: keep([
["식", step.],
["출처", step.],
]),
}));
return { kind: "중간", boxes };
}
function qtyColumn(ho: HoLine[], lines: CalcLine[]): FlowColumn {
const boxes = ho.map((item, i) => ({
id: `수량:${i}`,
kind: "수량" as const,
label: item.이름 || item.,
value: lines[i] ? fmt(lines[i].) : "",
note: item.단위,
cost: item.비목,
detail: keep([
["식", item.],
["종류", item.],
["단위", item.],
["비고", extra(item, "비고")],
]),
}));
return { kind: "수량", boxes };
}
function priceColumn(
ho: HoLine[],
lines: CalcLine[],
prices: Record<string, ElementBrief | null>,
): FlowColumn {
const boxes = ho.map((item, i) => {
const line = lines[i];
const brief = prices[item.];
const price = line ? line.단가 : brief?.값;
return {
id: `단가:${i}`,
kind: "단가" as const,
label: item.이름 || item.,
value: line ? fmt(line.) : "",
note: price === undefined || price === null ? "단가 —" : `× ${fmt(price)}`,
cost: item.비목,
logic: logicRef(item.),
bad: !line && brief === null,
detail: keep([
["요소", item.],
["단가", price],
["금액", line?.],
["비목", item. ?? Object.keys(line?. ?? {}).join(" · ")],
["출처", line?. ?? [brief?., brief?.].filter(Boolean).join(" ")],
]),
};
});
return { kind: "단가", boxes };
}
function extraColumn(list: NamedFormula[], lines: CalcLine[], from: number): FlowColumn {
const boxes = list.map((item, i) => ({
id: `덧줄:${i}`,
kind: "덧줄" as const,
label: item.이름,
value: fmt(lines[from + i]?.),
note: item.비목,
cost: item.비목,
detail: keep([
["식", item.],
["비목", item.],
["출처", item.],
]),
}));
return { kind: "덧줄", boxes };
}
function costColumn(sums: Record<string, number> | null, lines: CalcLine[]): FlowColumn {
const boxes = COSTS.map((cost) => ({
id: `비목:${cost}`,
kind: "비목" as const,
label: cost,
value: fmt(sums?.[cost]),
cost,
detail: lines
.filter((line) => line.?.[cost] !== undefined)
.map((line) => [line., fmt(line.[cost])] as [string, string]),
}));
return { kind: "비목", boxes };
}
function totalColumn(row: LogicRow, sums: Record<string, number> | null): FlowColumn {
return {
kind: "계",
boxes: [
{
id: "계",
kind: "계",
label: row.결과단위 || "계",
value: fmt(sums?.),
detail: keep([
...COSTS.map((c) => [c, sums?.[c]] as [string, unknown]),
["끝수", row.],
["출처", row.],
]),
},
],
};
}
function resultColumn(row: LogicRow, result: unknown): FlowColumn {
return {
kind: "계",
boxes: [
{
id: "계",
kind: "계",
label: row.결과단위 || "결과",
value: fmt(result),
detail: keep([
["식", row.?.],
["출처", row.],
]),
},
],
};
}
@@ -0,0 +1,182 @@
/* M01 로직 테스트 — 방식 C(흐름 그림). 왼쪽에서 오른쪽으로 칸 · 칸마다 상자 */
.m01c [hidden] {
display: none !important;
}
.m01c {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12);
box-sizing: border-box;
color: var(--color-text-body);
font-size: var(--text-body-sm);
}
.m01c__head {
display: flex;
align-items: baseline;
gap: var(--spacing-8);
flex-wrap: wrap;
}
.m01c__head h3 {
margin: 0;
font-size: 16px;
color: var(--color-text);
}
.m01c__flow {
display: flex;
align-items: flex-start;
gap: var(--spacing-4);
overflow-x: auto;
padding-bottom: var(--spacing-8);
}
/* 값 칸은 다시 그려도 설계 값 칸은 그대로 — 그래서 한 겹 더 있음 */
.m01c__rest {
display: contents;
}
.m01c__col {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
flex: 0 0 auto;
width: 170px;
min-width: 0;
}
.m01c__col--input {
width: 200px;
}
.m01c__col-head {
margin: 0;
padding-bottom: 2px;
border-bottom: 1px solid var(--color-border);
color: var(--color-text-muted);
font-size: var(--text-caption);
font-weight: 600;
}
.m01c__arrow {
align-self: center;
padding-top: 28px;
color: var(--color-text-muted);
font-size: 18px;
}
.m01c__box,
.m01c__group {
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
}
.m01c__group {
border-style: dashed;
background: none;
}
.m01c__group > summary {
padding: 2px 6px;
color: var(--color-text-muted);
font-size: var(--text-caption);
cursor: pointer;
}
.m01c__group > .m01c__box {
margin: 2px;
}
.m01c__box > summary {
display: flex;
flex-direction: column;
gap: 1px;
padding: 4px 6px;
cursor: pointer;
}
.m01c__box--bad {
border-color: var(--color-danger);
}
.m01c__label {
color: var(--color-text);
overflow-wrap: anywhere;
}
.m01c__value {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.m01c__note,
.m01c__muted {
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.m01c__bad {
margin: 0;
color: var(--color-danger);
font-size: var(--text-caption);
}
.m01c__detail {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 1px var(--spacing-4);
margin: 0;
padding: 4px 6px;
border-top: 1px solid var(--color-border);
font-size: var(--text-caption);
}
.m01c__detail dt {
color: var(--color-text-muted);
}
.m01c__detail dd {
margin: 0;
overflow-wrap: anywhere;
}
.m01c__sub {
padding: 0 6px 4px;
font-size: var(--text-caption);
}
.m01c__sub > summary {
color: var(--color-text-muted);
cursor: pointer;
}
.m01c__sub-body ul {
margin: 2px 0;
padding-left: 14px;
}
.m01c__field {
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 6px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
}
.m01c__input {
box-sizing: border-box;
width: 100%;
min-width: 0;
padding: 3px 6px;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface);
color: var(--color-text);
font-size: var(--text-body-sm);
}
@@ -0,0 +1,105 @@
/* M01 로직 테스트 컨테이너 — 틀 + 방식 A (방식 B·C 는 각자 파일에서 같은 접두 m01-test__ 를 씀) */
.m01-test {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
height: 100%;
padding: var(--spacing-12);
box-sizing: border-box;
overflow: auto;
color: var(--color-text-body);
font-size: var(--text-body-sm);
}
.m01-test__bar,
.m01-test__tabs,
.m01-test__nav {
display: flex;
align-items: center;
gap: var(--spacing-8);
flex-wrap: wrap;
}
.m01-test__bar h2 {
margin: 0 auto 0 0;
font-size: 18px;
color: var(--color-text);
}
.m01-test__tab {
padding: var(--spacing-4) var(--spacing-12);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: transparent;
color: inherit;
cursor: pointer;
}
.m01-test__tab.is-active {
background: var(--color-surface);
font-weight: 600;
}
.m01-test__muted {
margin: 0;
color: var(--color-text-muted);
}
.m01-test__card {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
max-width: 640px;
padding: var(--spacing-12);
border-radius: var(--radius-cards);
background: var(--color-surface);
}
.m01-test__card h3,
.m01-test__step {
margin: 0;
}
.m01-test__pick {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.m01-test__input {
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: transparent;
color: inherit;
}
.m01-test__grid {
width: 100%;
border-collapse: collapse;
}
.m01-test__grid th,
.m01-test__grid td {
padding: var(--spacing-4);
border-bottom: 1px solid var(--color-border);
text-align: left;
}
.m01-test__money {
text-align: right !important;
font-variant-numeric: tabular-nums;
}
.m01-test__total {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.m01-test__reasons {
padding: var(--spacing-8);
border-radius: var(--radius-cards);
background: var(--color-surface);
color: var(--color-danger, #c0392b);
}
@@ -0,0 +1,53 @@
/* =============================================================================
* M01_MasterData_UI_Test_Text.ts
* 로직 테스트 컨테이너 글자 — [한국어, 영어] · 틀과 방식 파일(A·B·C)이 같이 씀
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
const TEXT = {
Title: ["일위대가 로직 테스트", "Logic test"],
Pick_Logic: ["공종 고르기", "Pick a job"],
Tab_A: ["A 질문·답", "A Wizard"],
Tab_B: ["B 호표 + 설명", "B Table + notes"],
Tab_C: ["C 흐름 그림", "C Flow"],
Soon: ["준비 중", "Coming soon"],
Loading: ["불러오는 중", "Loading"],
Failed: ["불러오지 못함", "Load failed"],
Read_Only: ["시험 계산만 — 저장하지 않음", "Trial only — nothing is saved"],
Step: ["{n} / {total}", "{n} / {total}"],
Back: ["이전", "Back"],
Next: ["다음", "Next"],
Again: ["처음부터", "Start over"],
Job_Title: ["이 공종의 단가를 셈합니다", "Price this job"],
Job_Note: [
"몇 가지만 물어봅니다 — 답을 마치면 결과 표가 나옵니다",
"A few questions, then the result table",
],
Ask_Pick: ["「{name}」 — 어느 것으로 할까요?", "「{name}」 — which one?"],
Ask_Number: ["「{name}」 값을 넣어 주세요{unit}", "Enter 「{name}」{unit}"],
Ask_Range: ["범위 {min} {max}", "Range {min} {max}"],
Mat_Title: ["쓰이는 재료·기계", "Materials and machines"],
Mat_Note: [
"기본은 원문 그대로 — 바꿔 보면 결과가 달라짐(저장 안 함)",
"Default is the source as is — swapping only changes this trial",
],
Mat_None: ["이 공종은 재료·기계를 따로 고르지 않습니다", "No materials or machines to pick"],
Mat_Default: ["기본 (원문 그대로)", "Default (as in source)"],
Mat_Price: ["단가", "Unit price"],
Result_Title: ["결과", "Result"],
Result_Stopped: ["계산이 멈춤", "Calculation stopped"],
Col_Name: ["이름", "Name"],
Col_Qty: ["수량", "Qty"],
Col_Amount: ["금액", "Amount"],
Sum_Total: ["합계", "Total"],
Sum_Result: ["결과 값", "Result"],
} as const satisfies Record<string, readonly [string, string]>;
export type TestTextKey = keyof typeof TEXT;
export function tt(key: TestTextKey, fill: Record<string, string | number> = {}): string {
const entry = TEXT[key];
const text: string = entry[currentLanguageIndex as 0 | 1] ?? entry[0];
return text.replace(/\{(\w+)\}/g, (m, k: string) => (k in fill ? String(fill[k]) : m));
}
+22
View File
@@ -192,6 +192,28 @@
- `비목` = 노무비 · 재료비 · 경비. `덧줄` 식에서 비목 합을 이름으로 씀.
- `결과단위` 가 돈이 아닌 로직(㎥/시간)은 `호표` 없이 `중간` + `결과` 식.
- `종류` 가 로직이고 `비목` 칸이 없는 호표 줄은 불러온 결과를 비목별로 나눠 얹음.
재료 고르기 — 호표 재료 줄의 `요소` 를 키 대신 조건 묶음으로 적음:
```json
{
"종류": "재료",
"요소": { "구분": "관·수로", "상세구분": "흄관", "규격": "D300", "대표": "MT012345" },
"이름": "흄관",
"단위": "m",
"수량": "1",
"비목": "재료비",
"출처": "산림품셈 13-6-1"
}
```
- 조건 칸 = `구분`(반드시) · `상세구분` · `규격` · `대표` · 그 밖의 칸은 두지 않음. `종류` 는 「재료」만.
- 구분·상세구분 = 자재품목 줄의 같은 이름 칸과 똑같이 적음(묶음 목록은 `ref/_묶음_자재품목.md`).
- 규격 조건 = 원문이 규격을 밝힌 것만 좁힘 — 낱말이 **모두** 든 줄이 후보(「D300」 · 「D300 B종」) · 대소문자·빈칸·`φ` 는 안 가림. 원문이 규격을 안 밝히면 칸을 두지 않음.
- 대표 줄 = 관리자가 고른 자재품목 키 — 시험 계산이 그 줄의 값을 씀. 조건 밖 키는 검사가 잡음.
- 대표가 없으면 시험 계산은 **조건 안 값 있는 첫 줄** · 로직 입력 `자재지역` 이 있으면 이름에 그 지역이 든 줄부터(레미콘·골재처럼 지역이 갈리는 품목).
- 실제 줄 고르기는 프로젝트 몫 — 후보 목록은 `GET /api/m01/materials`(화면·테스트 컨테이너가 같이 씀).
- 옛 방식(품셈재료 키 `MP…` · 자재품목 키 `MT…`)도 그대로 돎 — 로직 일괄 변환 전까지 함께 씀.
- 근거가 덜 선 줄은 `비고` 칸에 적음 — 「환산 근거 미확인」.
- 입력에 원문 범위가 있으면 `범위: [아래, 위]` · 기본값 두지 않음.
- `소유` = 공용 · 개인(뒤 단계 자리).
@@ -0,0 +1,107 @@
# 자재품목 묶음 (구분 · 상세구분)
`scripts/build_자재품목_분류.py` 가 만든 표 — 손으로 고치지 않음.
- 구분 23 · 상세구분 96 · 미분류 1063 줄 (전체 30592 줄)
## 규칙
- 품명에서 공백을 모두 뺀 글자에 정규식을 위에서부터 대어 첫 일치를 따름 — 순서가 곧 우선순위.
- 조달청 줄(출처 「자재품목 조달청 …」)은 조달청 품명 16 개 표를 따름.
- 못 가린 줄은 구분 「미분류」 · 상세구분 없음.
- 값 · 이름 · 키는 바꾸지 않음(두 칸만).
- 다시 돌리면 같은 결과.
## 구분별 상세구분 (줄 수)
### 관·이음관 (7044)
PE관 2906 · 이음쇠·부속 1003 · 주철관 828 · 강관 705 · PVC관 461 · 파형강관 417 · 유리섬유·FRP관 398 · 동관·스텐관 132 · 흄관 117 · 단열이중관 77
### 식생·종자 (5869)
수목 5433 · 수목지주·보호 181 · 종자 105 · 잔디·지피 97 · 비료·토양개량 25 · 농약·약제 21 · 비료 7
### 레미콘 (3203)
레미콘 3203
### 철근·강재 (1857)
형강 703 · 철근 602 · 강판 262 · 구조용강관 147 · 철선 92 · 철망·와이어메쉬 51
### 아스콘·포장 (1705)
아스콘 1479 · 포장블록 173 · 특수포장 30 · 아스팔트 13 · 투수포장재 10
### 안전시설 (1505)
표지판·지주 933 · 가드레일 427 · 낙석방지책 63 · 낙석방지·휀스 59 · 차선·방호 23
### 볼트·앵커·철물 (1001)
볼트·너트 525 · 와이어로프·클램프 208 · 앵커 189 · 못 41 · 철근커플러 38
### 전기·통신 (799)
케이블·전선 312 · 판넬·차단기 163 · 전선관·트레이 134 · 조명·전등 133 · 부속·기타 57
### 밸브·수도자재 (761)
밸브 597 · 수도계량기·유량계 79 · 스프링클러·호스 51 · 펌프 34
### 운송·용역 (675)
화물운임 552 · 시험·검사 62 · 폐기물 61
### 매트·토목섬유 (641)
돌망태 372 · 부직포·토목섬유 76 · 식생매트 64 · 돌망태·사방 63 · 매트 54 · 부직포 8 · 잔디보호매트 4
### 맨홀·수로·그레이팅 (599)
수로관 283 · 그레이팅 166 · 맨홀 150
### 석재 (525)
경계석 401 · 조경석·자연석 124
### 도료·방수·접착 (522)
도료 249 · 접착·혼화 138 · 방수·실링 135
### 콘크리트제품 (484)
말뚝·파일 223 · 경계석·블록 171 · PC구조물 90
### 시멘트·골재 (483)
골재 323 · 모르타르 101 · 시멘트 59
### 기계·설비 (462)
보일러·공조 172 · 기계류 118 · 놀이·운동·시설물 109 · 측정·시험기기 53 · 소방·가스 10
### 목재 (430)
각재·판재 167 · 목재덱 161 · 합판 78 · 판재 24
### 단열·보온 (282)
단열재 282
### 건축마감 (279)
문·창·유리 123 · 천장·바닥·마감재 75 · 타일·기와 66 · 위생기구 15
### 잡재료 (142)
소모품 92 · 용접·가스 28 · 고철·기타금속 11 · 화약 9 · 용수 2
### 교량·구조부속 (133)
신축이음·받침 75 · 보강재·정착 58
### 거푸집·가설재 (128)
동바리·비계 72 · 거푸집 48 · 가설건축물 8
@@ -0,0 +1,461 @@
"""재료_자재품목.json 의 구분 · 상세구분 두 칸 채움 — 품명 글자 규칙(위에서 아래로 첫 일치) · 다시 돌리면 같은 결과.
· 조달청 줄(출처 「자재품목 조달청 …」) = 조달청 품명 표(조달청_분류) 를 따름
· 못 가린 줄 = 구분 「미분류」 · 상세구분 null
· 값 · 이름 · 키는 건드리지 않음 (줄 텍스트의 두 칸만 바꿈 · 나머지 글자 그대로)
· 같이 씀: ref/_묶음_자재품목.md (구분별 상세구분 · 줄 수) 를 새로 씀
사용: python scripts/build_자재품목_분류.py (JSON · 묶음 md 갱신)
python scripts/build_자재품목_분류.py --미분류 (못 가린 품명 앞쪽 나열)
"""
import collections
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "재료_자재품목.json"
OUT_MD = ROOT / "ref" / "_묶음_자재품목.md"
# 조달청 품명 → (구분, 상세구분)
조달청_분류 = {
"조경용수목": ("식생·종자", "수목"),
"수목보호용지지대": ("식생·종자", "수목지주·보호"),
"부숙유기질비료": ("식생·종자", "비료"),
"일반용폴리에틸렌관": ("관·이음관", "PE관"),
"수도용폴리에틸렌관": ("관·이음관", "PE관"),
"도로안전표지판지주": ("안전시설", "표지판·지주"),
"철제가드레일": ("안전시설", "가드레일"),
"낙석방지책": ("안전시설", "낙석방지책"),
"금속돌망태": ("매트·토목섬유", "돌망태"),
"식생매트": ("매트·토목섬유", "식생매트"),
"잔디보호매트": ("매트·토목섬유", "잔디보호매트"),
"토목용부직포": ("매트·토목섬유", "부직포"),
"자연석경계석": ("석재", "경계석"),
"투수골재포장재": ("아스콘·포장", "투수포장재"),
"목재덱": ("목재", "목재덱"),
"목재판재": ("목재", "판재"),
}
# (구분, 상세구분, 정규식) — 이름에서 공백을 모두 뺀 글자에 대고 위에서부터 첫 일치 · 좁은 규칙이 위
R = [
# 지반조사 도구
("기계·설비", "기계류", r"드라이브파이프|코아리프터|샘플러|신월튜브|코아바렐|리밍쉘"),
# 운송 · 시험 · 폐기물
("운송·용역", "화물운임", r"구역화물|폐기물운반|항만하역|운반비|운송비"),
("운송·용역", "폐기물", r"폐기물처리"),
("운송·용역", "시험·검사", r"시험(?!기|편)|출장비"),
(
"기계·설비",
"측정·시험기기",
r"시험기|시험편|탐상|측정기|계측기|측량기|판독기|분석기|필림|세륜기|축중기|레벨기|트랜싯",
),
# 레미콘 · 아스콘
("레미콘", "레미콘", r"레미콘|레디믹스"),
("아스콘·포장", "아스콘", r"아스콘|순환아스"),
("도료·방수·접착", "방수·실링", r"아스팔트(조인트|실란트|프라이머|루핑|펠트|콤파운드|방수)"),
("아스콘·포장", "아스팔트", r"^(블론|개질유화|유화)?아스팔트(유제)?$|록크하드"),
(
"아스콘·포장",
"포장블록",
r"잔디블록|바닥벽돌|투수콘|투수블[록럭]|고압블록|규사블록|다색(저속|블록)|점자블록|인터로킹|보도블[록럭]|차도블록",
),
# 밸브 · 수도
(
"밸브·수도자재",
"밸브",
r"밸브|제수|체크|게이트|감압|스트레이너|수전|분수전|공기변|수문|콕크",
),
("밸브·수도자재", "수도계량기·유량계", r"수도미터|계량기|유량계|미터기|보호통"),
("밸브·수도자재", "스프링클러·호스", r"스프링클러|살수|급수호스|송수호스|호스|분사|헤드|점적"),
("밸브·수도자재", "펌프", r"펌프|양수기"),
("도료·방수·접착", "방수·실링", r"백업재"),
("관·이음관", "단열이중관", r"단열이중"),
("기계·설비", "보일러·공조", r"정화조"),
# 거푸집 (문양거푸집 등 — 단열 글자보다 먼저)
(
"거푸집·가설재",
"거푸집",
r"거푸집|유로폼|알폼|코팅합판|폼타이|플랫타이|분리제|박리제|폼핀|핀폴|패널(?!스위치)|내부코너",
),
# 단열 · 보온
(
"단열·보온",
"단열재",
r"단열(?!문)|보온|샌드위치|폴리스티렌|스티로폼|암면|글라스울|압출법|페놀폼|은박지",
),
# 동바리·비계 (철물·브라켓 글자보다 먼저)
(
"거푸집·가설재",
"동바리·비계",
r"동바리|비계|낙하물|가설|가시설|버팀|흙막이|스트럿|안전발판|건설발판|발판|사다리|가림막|가세틀|바퀴\(|조립식틀|시스템동바리|스카폴드",
),
# 볼트 · 앵커 · 철물 중 관·형강과 겹치는 것을 먼저
("볼트·앵커·철물", "철근커플러", r"철근커플러|커플러|정착구"),
("볼트·앵커·철물", "앵커", r"앵커|앙카|케미칼|카트리지|인서트"),
(
"볼트·앵커·철물",
"볼트·너트",
r"볼트|볼트너트|못트|너트|와셔|스크류|스크루|리벳|스터드|턴버클|고정핀|앵커핀|나사(?!식)|철나사|직결",
),
("볼트·앵커·철물", "", r"철못|타정|콘크리트못|STS못|벨가못|꺾쇠|표시못"),
(
"볼트·앵커·철물",
"와이어로프·클램프",
r"와이어로프|P\.P로프|클램프|훅클램프|행거|철물|지지금구|연결금구|브라켓|샤클|피뢰침",
),
("전기·통신", "전선관·트레이", r"전선관"),
# 도료 · 방수 · 접착 (좁은 것)
(
"도료·방수·접착",
"방수·실링",
r"방수|실런트|실란트|실링|코킹|지수재|씰$|백업재|충진|퍼티|벤토나이트(?!매트)|방식테이프|줄눈재|물끊기",
),
(
"도료·방수·접착",
"도료",
r"페인트(?!붓)|프라이머|신너|시너|희석제|도료|코팅제|래커|에나멜|방청|광명단|징크|안료|염료|표면처리제|표면강화제|피막양생|양생제|나노큐브|하도|상도",
),
(
"도료·방수·접착",
"접착·혼화",
r"접착|본드|(?<!합성)수지(?!\))|레진|혼화|감수제|AE제|지연제|경화제|바인더|라텍스|폴리탑|메도킹|고무패킹|패킹|가스켓",
),
# 식생 · 종자
("식생·종자", "종자", r"종자|산림종묘|배합종자"),
(
"식생·종자",
"비료·토양개량",
r"비료|생육|영양제|침식안정제|토양개량|피트모스|펄라이트|유기질|인공토양|상토|배양토|퇴비",
),
("식생·종자", "농약·약제", r"살충제|살균제|제초제|농약|훈증약제|방제약|기피제"),
(
"식생·종자",
"수목지주·보호",
r"수목지주|지주대|수목보호|수간주입|수목표찰|수목영양|나무보호|수목받침|수목고정",
),
(
"식생·종자",
"잔디·지피",
r"잔디|줄떼|평떼|지피|초화|비비추|맥문동|원추리|꽃양귀비|수레국화|패랭이|국화|억새|갈대|송엽국|들꽃|야생화|구근",
),
(
"식생·종자",
"수목",
r"나무(\(|,|$)|나무$|소나무|곰솔|해송|주목|금송|낙우송|메타세쿼이아|측백|향나무|철쭉|산홍|산수유|목련|단풍|벚|동백|무궁화|회양목|배롱|은행|느티|팽나무|회화|서어|물푸레|이팝|층층|칠엽수|계수|산딸|먼나무|모과|감나무|대추|마가목|복자기|산사|살구|사철|쥐똥|꽝꽝|남천|피라칸|조팝|개나리|장미|수국|라일락|수수꽃다리|박태기|낙상홍|화살|병꽃|불두화|히어리|야광|다정큼|당종려|종려|소철|대나무|조릿대|등나무|능소화|담쟁이|송악|마삭|인동|덩굴|아이비|칠자화|왕버들|수양|능수|진달래|백송|과꽃|옥매화|꽃양배추|화백|버들|유카|율마|허브|채송|봉선|일일초|안개초|꽃무릇|영춘화|양매자|식물재배화단|철쭉류|사스레|황매|명자|백량금|협죽도|만리화|말발도리|골담초|아로니아|오죽|올괴불|황근|풍년화|태산목|용버들|옥향|담팔수|무화과|나한송|구상나무|편백|눈향|눈주목|비자|가시|후박|녹나무|후피향|아왜|굴거리|팔손이|목서|치자|서향|광나무|보리수|버드|갯버들|포플러|사시|아까시|싸리|자귀|주엽|쉬나무|개오동|때죽|쪽동백|고로쇠|당단풍|산벚|왕벚|겹벚|자엽|해당화|꽃사과|꽃복숭|모감주|황칠|노각|밤|호두|가래|음나무|두릅|오동|참죽|옻|붉나무|말채|백당|덜꿩|병아리|고욤|헛개|꾸지|뽕|닥|미루|사과|배$|돌배|앵두|자두|석류|유자|귤|감귤|매실|살구|블루엔젤|에메랄드|쏠라데리|사피니아|라벤다|아스타|제라늄|버베나|빈카|범부채|아게라|익소라|에키네시아|카네이션|실유카|채진목|숙근|웨이브페츄니아|나리|매미꽃|금낭화|땅채송화|사초|붓꽃|할미꽃|돌나물|돌장미|초롱|프리뮬라|팬지|비올라|페튜니아|메리골드|샐비어|맨드라미|코스모스|해바라기|수선|튤립|히아신스|크로커스|백합|창포|부들|연꽃|수련|물칸나|칸나|달리아|글라디올러스|아이리스|맥문|옥잠|가는잎|수크령|갈풀|띠|톱풀|꿩의비름|세덤|기린초|돌단풍|둥굴레|은방울|모란|작약|우산|양지꽃|뚝갈|벌개미취|구절초|쑥부쟁이|층꽃|꽃댕강|댕강|황금|미선|서부|미국|서양|중국|무늬|은사초|그린톱|그라스|핑크뮬리|팜파스|수생|정수식물",
),
("식생·종자", "수목", r"^H\d{1,2}(\.\d+)?[×xX*,]\s*[RBWrbw]\d"),
# 시멘트 · 골재
(
"시멘트·골재",
"시멘트",
r"^(백|초속경|초조강|무수축|포틀랜드)?시멘트(?!벽돌|블록|라이닝|몰탈|모르타르|판|기와)",
),
("시멘트·골재", "모르타르", r"모르타르|몰탈|그라우트|무수축"),
(
"시멘트·골재",
"골재",
r"골재|쇄석|^모래|석분|자갈|잡석|부순돌|마사토|호박돌|성토재|사질토|규사",
),
# 안전시설 (관·형강보다 먼저 — 휀스 · 지주)
("안전시설", "가드레일", r"가드레일|방호울타리|방호책|충격흡수|완충시설|방호벽"),
(
"안전시설",
"표지판·지주",
r"표지판|표지병|안전표지|도로표지|안내판|표찰|반사경|반사지|시선유도|경계표주|알림표지|지주(?!대)",
),
(
"안전시설",
"낙석방지·휀스",
r"낙석방지|방책|방음|볼라드|휀스|펜스|난간|라바콘|안전화|안전모|안전대|안전망|추락방지|방지턱|안전띠|바리케이",
),
# 거푸집 · 가설재
# 목재
("목재", "합판", r"합판|MDF|OSB|파티클|섬유판|텍스|석고보드"),
(
"목재",
"각재·판재",
r"각재|판재|미송|외송|방부|원목|목재|우드칩|통나무|낙엽송|구조목|멀바우|데크(?!플레이트|패널|고정)|삼나무|편백(?!나무)|경계목|면목|침목",
),
# 매트 · 토목섬유
("매트·토목섬유", "돌망태·사방", r"돌망태|개비온|망태석|사석|녹생토|녹화|사방|이불형"),
(
"매트·토목섬유",
"부직포·토목섬유",
r"부직포|지오텍스|지오그리드|지오콤|지오셀|토목섬유|차수시트|드레인보드|배수판|투수시트|섬유지|네트|오탁방지|보호망|그물",
),
("매트·토목섬유", "매트", r"매트|멀칭|코이어|방초|고화매트|벤토나이트매트"),
# 석재
("석재", "경계석", r"경계석|도로경계|측석"),
(
"석재",
"조경석·자연석",
r"조경석|자연석|호박석|^산석|경관석|판석|사고석|야면석|막돌|다듬돌|석재|화강석|대리석|현무암|사암|점판암",
),
# 콘크리트제품
(
"콘크리트제품",
"말뚝·파일",
r"콘크리트말뚝|PC말뚝|PHC|콘크리트파일|복합말뚝|강관파일|강관말뚝|쉬트파일|시트파일|강널말뚝|팽이파일|측량말뚝|전기저항용접강관파일",
),
("콘크리트제품", "PC구조물", r"조립식PC|프리캐스트|PC맨홀|PC박스|PC개량|PC우수"),
(
"콘크리트제품",
"경계석·블록",
r"콘크리트경계|보차도경계|경계블록|시멘트벽돌|시멘트블록|보강토|GS|옹벽블록|호안블록|생태호안|식생호안|블록|벽돌",
),
# 맨홀 · 수로 · 그레이팅
("맨홀·수로·그레이팅", "그레이팅", r"그레이팅|트렌치|격자"),
(
"맨홀·수로·그레이팅",
"맨홀",
r"뚜껑|맨홀|인상링|우수받이|집수정|측구뚜껑|주철뚜껑|집수구|물받이(?!후)|침투측구|받이",
),
(
"맨홀·수로·그레이팅",
"수로관",
r"수로|플륨|암거|개거|배수로|측구|벤치플|[UL]형(측구|수로|플륨|배수)",
),
# 관 — 재질별
("잡재료", "소모품", r"PVC(용접봉|코팅|시트|바닥|선|천막)"),
("관·이음관", "흄관", r""),
("관·이음관", "파형강관", r"파형강관|파형관|커플링밴드|수밀벨트"),
(
"관·이음관",
"PVC관",
r"(?<![A-Za-z])(PVC|P\.V\.C|VR|VG|XL|PB|CPVC|PRC)|내충격|염화비닐|고강성|레진관",
),
(
"관·이음관",
"PE관",
r"^PE|폴리에틸렌|HDPE|(?<![A-Za-z])PE|HI-?(PER|GP|3R|3P|VP)|이중벽|다중벽|삼중벽|유공관|하수관|우수관|융착",
),
(
"관·이음관",
"주철관",
r"주철|(?<![A-Za-z])(KP|PM|HP)|덕타일|닥타일|타이튼|메카니칼|압륜|이탈방지|접합부속",
),
(
"관·이음관",
"유리섬유·FRP관",
r"FRP(파이프|덕트|관|이음|곡관)|GFRP(관|파이프)|유리섬유(곡관|복합관|관|파이프|덕트)|섬유복합관",
),
("관·이음관", "동관·스텐관", r"동관|스텐관|스테인리스관|STS관|SUS관|STS(파이프|배관)"),
(
"관·이음관",
"강관",
r"강관|배관용|압력배관|흑관|백관|가스관|피복강관|도복장|에폭시강관|쉬즈관|비굴착|관세척|나팔관|스틸파이프|STEELPIPE|파이프",
),
(
"관·이음관",
"이음쇠·부속",
r"STS(티|캡|엘보|레듀)|이경|티이|엘보|니플|레듀[샤셔]|유니온|소켓|플랜지|곡관|편락|엔드캡|엔드플러그|이형관|이음쇠|이음관|분기관|새들|플럭|마개|선단캡|노말밴드|˚밴드|연결구|주름관|플렉시블|플렉스",
),
# 철근 · 강재
("철근·강재", "철근", r"철근"),
("철근·강재", "철망·와이어메쉬", r"와이어메[쉬시]|용접철망|^철망|크림프철망|메쉬"),
("철근·강재", "철선", r"철선|결속선|어닐링|아연도철|PC강선|강선"),
(
"철근·강재",
"형강",
r"[Hh]형강|[ㄱㄷ]형강|^[ICT]형강|C형강|^(STS)?앵글$|평강|평철|각철|4각강|환봉|봉강|철평석|강봉|H빔|[Hh]-?빔",
),
(
"철근·강재",
"강판",
r"(?<!보)강판|후판|동판|열연|냉연|철판|스틸플레이트|데크플레이트|기초플레이트|복공판|탄소주강",
),
("철근·강재", "구조용강관", r"구조용|각관|반경철관|철관"),
# 전기 · 통신
("전기·통신", "케이블·전선", r"케이블|CABLE|^전선|전선$|HFIX|HIV|접지|절연|배선(?!용)"),
("전기·통신", "전선관·트레이", r"전선관|TRAY|트레이|풀박스|분전함|박스"),
(
"전기·통신",
"판넬·차단기",
r"차단기|접촉기|릴레이|분전반|제어기|제어반|스위치|콘센트|PLC|타이머|인버터|변압기|SK판넬|TS판넬|퓨즈|전원용",
),
(
"전기·통신",
"조명·전등",
r"LED|조명|램프|등기구|투광|보안등|안정기|경광등|경고등|유도등|신호등|가로등|등주",
),
# 기계 · 설비
(
"기계·설비",
"보일러·공조",
r"보일러|공조|에어컨|환풍|송풍|히터|화장로|버너|탱크|물탱크|저류조|열교환|팬틸트|정화조",
),
(
"잡재료",
"용접·가스",
r"프로판가스|탄산가스|프레온가스|아세틸렌|^(용접|산소|알곤|아르곤)|용접봉",
),
("기계·설비", "소방·가스", r"소방|소화(?!화)|소화전|가스믹서|가스버너|경보|감지기|방화"),
(
"기계·설비",
"기계류",
r"유압잭|잭써포트|모터|전동기|기어|감속기|베어링|해머|브레이커|톱|드릴|비트|블레이드|코아|롤러|다짐|믹서|용접기|절단기|발전기|콤프레샤|컴프레샤|비트|리밍쉘|스텝|공구",
),
(
"기계·설비",
"놀이·운동·시설물",
r"놀이|운동기구|의자|벤치|파고라|퍼걸라|쓰레기통|분리수거함|음수대|조형물|정자",
),
# 건축마감
("건축마감", "타일·기와", r"타일|테라조|기와|세라믹|도기"),
(
"건축마감",
"문·창·유리",
r"도어|(?<![가-힣])문|문(?=\(|,|$)|문틀|문비|창호|(?<!섬)유리|샤시|경첩|손잡이|자물쇠|힌지|셔터|블라인드",
),
(
"건축마감",
"천장·바닥·마감재",
r"천장|천정|흡음|석고|후레싱|후레임|칸막이|바닥재|카페트|마감|몰딩|걸레받이|조이너|루버|점검구|코너비드|논슬립|계단",
),
("건축마감", "위생기구", r"대변기|세면기|소변기|양변기|욕조|싱크"),
# 교량 · 구조 부속 · 특수포장 · 기타 좁은 규칙
(
"교량·구조부속",
"신축이음·받침",
r"신축이음|교량받침|익스펜션조인트|익스팬션조인트|EJ|내진탄성받침|교량배수|받침",
),
(
"교량·구조부속",
"보강재·정착",
r"PC강연선|GFRP보강근|DB정착바|강섬유|다웰바|스페이셔|SOC-?BAR|간격재|장선연결|콘크리트근가|SRR보강재",
),
("아스콘·포장", "특수포장", r"소일콘포장|탄성포장|황토포장|미끄럼방지포장|탑쏘일|포장재"),
("거푸집·가설재", "가설건축물", r"콘테이너|컨테이너"),
("매트·토목섬유", "부직포·토목섬유", r"그리드|구갑망|메탈라스|스텐리스망|식생토낭"),
(
"전기·통신",
"부속·기타",
r"슬리브|부스바|애자|수평도체|터미날|CONNECTOR|백열|형광등|보조등|철주|콘크리트주|보행등|접속관",
),
(
"안전시설",
"차선·방호",
r"차선규제|계선주|단부레일|라운드레일|DS연결대|DS차광망|핸드레일촉지판",
),
(
"관·이음관",
"이음쇠·부속",
r"부싱|STS직관|안장티|THP|(?<![A-Za-z])STS(플러그|캡)|SUS밴드|연결관|알루미늄관|후렉시블|유공도수관|다발관|조립식배수구|에어밴트|수도꼭지",
),
("볼트·앵커·철물", "와이어로프·클램프", r"로프|가마니|클립|타카핀|착지핀|칼블럭"),
(
"도료·방수·접착",
"도료",
r"락카|왁스|바탕조정제|주입용에폭시|주입재|씰링재|구체강화제|타르페이퍼|봉함재",
),
("잡재료", "화약", r"폭약|뇌관"),
("잡재료", "용수", r"^물\(상수도"),
("잡재료", "고철·기타금속", r"고철|봉땜납"),
("잡재료", "용접·가스", r"아세톤"),
("철근·강재", "형강", r"트러스|중도리"),
("건축마감", "천장·바닥·마감재", r"T-?BAR"),
("거푸집·가설재", "동바리·비계", r"수평재|수직재"),
("도료·방수·접착", "접착·혼화", r"기포제"),
("잡재료", "소모품", r"^풀\(|보양지|소창"),
# 잡재료
("잡재료", "용접·가스", r"\(날\)|알곤용접봉"),
(
"잡재료",
"소모품",
r"장갑|걸레|비닐|페인트붓|^붓$|사포|브러[시쉬]|톱날|연마|숫돌|기름|오일|윤활|그리스|목탄|비누|현수막|끈|밧줄|포대|마대|볏짚|양생포|필름|테이프|와이어",
),
]
RX = [(a, b, re.compile(c, re.I)) for a, b, c in R]
def 분류(이름: str, 출처: str):
n = re.sub(r"\s+", "", 이름 or "")
if 출처.startswith("자재품목 조달청"):
return 조달청_분류.get(n, ("미분류", None))
if not n:
return ("미분류", None)
for a, b, rx in RX:
if rx.search(n):
return (a, b)
return ("미분류", None)
LINE = re.compile(r'("구분": )(?:null|"[^"]*")(, "상세구분": )(?:null|"[^"]*")')
NAME = re.compile(r'"이름": "((?:[^"\\]|\\.)*)"')
SRC_ = re.compile(r'"출처": "((?:[^"\\]|\\.)*)"')
NOTE_OLD = "구분 · 상세구분 = 원문에 대·중분류 칸이 없어 비움(지어내지 않음)"
NOTE_NEW = "구분 · 상세구분 = 품명 글자 규칙으로 채움(scripts/build_자재품목_분류.py · 묶음 목록 ref/_묶음_자재품목.md) · 조달청 줄은 조달청 품명을 따름 · 못 가린 줄은 「미분류」"
def q(s):
return "null" if s is None else json.dumps(s, ensure_ascii=False)
def main():
text = SRC.read_text(encoding="utf-8")
rows = json.loads(text)[""]
out, res = [], []
for ln in text.split("\n"):
m = LINE.search(ln)
if m and '"": "MT' in ln:
nm = json.loads('"' + NAME.search(ln).group(1) + '"')
sc = json.loads('"' + SRC_.search(ln).group(1) + '"')
a, b = 분류(nm, sc)
ln = LINE.sub(lambda _: f'"구분": {q(a)}, "상세구분": {q(b)}', ln, count=1)
res.append((a, b, nm))
out.append(ln.replace(NOTE_OLD, NOTE_NEW))
assert len(res) == len(rows), (len(res), len(rows))
return "\n".join(out), res
def 묶음_md(res):
by = collections.defaultdict(collections.Counter)
for a, b, _ in res:
by[a][b or ""] += 1
ks = sorted((k for k in by if k != "미분류"), key=lambda k: -sum(by[k].values()))
ndet = sum(len(by[k]) for k in ks)
L = [
"# 자재품목 묶음 (구분 · 상세구분)",
"",
"`scripts/build_자재품목_분류.py` 가 만든 표 — 손으로 고치지 않음.",
"",
f"- 구분 {len(ks)} · 상세구분 {ndet} · 미분류 {sum(by['미분류'].values())} 줄 (전체 {len(res)} 줄)",
"",
"## 규칙",
"",
"- 품명에서 공백을 모두 뺀 글자에 정규식을 위에서부터 대어 첫 일치를 따름 — 순서가 곧 우선순위.",
"- 조달청 줄(출처 「자재품목 조달청 …」)은 조달청 품명 16 개 표를 따름.",
"- 못 가린 줄은 구분 「미분류」 · 상세구분 없음.",
"- 값 · 이름 · 키는 바꾸지 않음(두 칸만).",
"- 다시 돌리면 같은 결과.",
"",
"## 구분별 상세구분 (줄 수)",
"",
]
for k in ks:
L.append(f"### {k} ({sum(by[k].values())})")
L.append("")
L.append(" · ".join(f"{d} {c}" for d, c in by[k].most_common()))
L.append("")
return "\n".join(L)
if __name__ == "__main__":
new, res = main()
if "--미분류" in sys.argv:
c = collections.Counter(re.sub(r"\s+", "", n) for a, b, n in res if a == "미분류")
print(sum(c.values()), len(c))
print(
" ".join(
f"{k}:{v}"
for k, v in c.most_common(int(sys.argv[-1]) if sys.argv[-1].isdigit() else 300)
)
)
sys.exit()
SRC.write_text(new, encoding="utf-8", newline="\n")
OUT_MD.write_text(묶음_md(res), encoding="utf-8", newline="\n")
print("구분·상세구분 채움", len(res), "")
@@ -6,7 +6,7 @@
(1) 틀 — 파일 이름 · 머리 칸 · 줄 칸 · 출처 모양 · 키 모양·겹침 · 대장
(2) 본문 — 표형 요소의 출처 절을 본문 md 에서 찾아 수를 맞댐 · 결손(본문 표에 있고 요소에 없음)·허구(요소에 있고 본문 절에 없음)
(3) 로직 — 변수가 가리키는 요소·표·칸 · 찾기 조건 이름 · 로직 입력 · 돌고 도는 참조 · 끊긴 키
(3) 로직 — 변수가 가리키는 요소·표·칸 · 찾기 조건 이름 · 로직 입력 · 재료 고르기 조건 · 돌고 도는 참조 · 끊긴 키
(4) 계산 — 로직 키와 입력값으로 호표 줄별 수량·단가·금액 · 비목 합 (`master_formula.py`)
수 뽑기는 `resources/knowledge/original/_pipeline/pum_md_tool.py` 의 것을 씀.
"""
@@ -260,8 +260,10 @@ class Master:
raise FormulaError(f"없는 요소 「{ref}")
return row
def label(self, ref: str) -> str:
"""알림 글 — 키 옆에 이름."""
def label(self, ref) -> str:
"""알림 글 — 키 옆에 이름 · 고르기 조건은 조건 글."""
if isinstance(ref, dict):
return "재료 고르기 " + " · ".join(str(v) for v in ref.values() if v)
try:
row = self.get(ref)
except FormulaError:
@@ -476,7 +478,8 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
else {item["비목"]: qty * price}
)
else:
ref = fill(item["요소"], env)
# 고르기 조건 묶음은 그대로 넘김 — `element` 가 대표 줄·첫 줄로 풂
ref = item["요소"] if isinstance(item["요소"], dict) else fill(item["요소"], env)
try:
price, source = element(master, ref, env)
except FormulaError:
@@ -654,6 +657,11 @@ def check_logic(master: Master, row: dict) -> tuple[list[str], set]:
item.get("종류") != "로직" and "비목" not in item
):
out.append(f"{where} · 비목 「{item.get('비목')}")
if isinstance(item["요소"], dict): # 재료 고르기 조건
if item.get("종류") != "재료":
out.append(f"{where} · 고르기 조건은 종류 「재료」 만")
out += check_pick(master, where + " 요소", item["요소"])
continue
one(item["요소"], where + " 요소", known)
group = mk.GROUP_OF.get(mk.id_of(str(item["요소"])))
if item.get("종류") != "로직" and group != item.get("종류"):
@@ -687,4 +695,14 @@ def find_loops(graph: dict[str, set]) -> list[list[str]]:
# 품셈재료 단가 — 서로 부르는 모듈이라 끝에서 읽음
from master_material import LINKED, REGION, SLOTS, TARIFF, element # noqa: E402, F401
from master_material import ( # noqa: E402, F401
LINKED,
PICK_KEYS,
REGION,
SLOTS,
TARIFF,
candidates,
check_pick,
element,
pick,
)
@@ -16,6 +16,7 @@ LINKED = "MP" # 품셈재료 테이블ID
MARKET = "MT" # 자재품목 테이블ID
PRICED = "MO" # 유가전력 테이블ID — 값 한 칸
SLOTS = ("물가자료", "유통물가", "물가정보", "거래가격", "관급") # 자재품목 값 열
PICK_KEYS = ("구분", "상세구분", "규격", "대표") # 재료 고르기 조건 칸 (`_틀.md` 7장)
REGION = "자재지역" # 로직 입력 — 「지역」 은 유가 고르기가 씀
TARIFF = "전력 계약종별" # 로직 입력 — 품셈·계약예규에 계약종별 규정 없음
_UNITS = {"ea": "", "ton": "", "t": "", "l": "", "리터": "", "set": "", "세트": ""}
@@ -104,8 +105,58 @@ def _candidates(master: mf.Master, ref: str, row: dict, env: dict) -> list[str]:
return [key for _, key in hits]
def element(master: mf.Master, ref: str, env: dict) -> tuple[object, str | None]:
"""요소 값과 출처 — 자재품목은 제 줄, 품셈재료는 연결을 따라가 낮은 값 · 그 밖은 줄의 `값`."""
# ── 재료 고르기 ────────────────────────────────────────────────────────
def candidates(master: mf.Master, cond: dict, env: dict | None = None) -> list[str]:
"""고르기 조건 안 자재품목 키 — 구분 · 상세구분 · 규격 낱말이 모두 든 줄 · 파일 차례대로.
입력 `자재지역` 있으면 이름에 지역이 줄만(그런 줄이 없으면 조건 전부)."""
want = [plain_spec(w) for w in str(cond.get("규격") or "").split()]
detail = cond.get("상세구분")
hits = [
key
for key, row in master.index.get(MARKET, {}).items()
if row.get("구분") == cond.get("구분")
and (not detail or row.get("상세구분") == detail)
and all(w in plain_spec(row.get("규격")) for w in want)
]
region = plain((env or {}).get(REGION, ""))
near = [k for k in hits if region in plain(master.get(k).get("이름"))] if region else []
return near or hits
def pick(master: mf.Master, cond: dict, env: dict) -> str:
"""시험 계산이 쓸 줄 — 관리자가 정한 `대표`, 없으면 조건 안 값 있는 첫 줄."""
if cond.get("대표"):
return str(cond["대표"])
keys = candidates(master, cond, env)
keys = [k for k in keys if _offers(master, k)] or keys
if not keys:
raise mf.FormulaError(f"재료 고르기 후보 없음 — {cond}")
return keys[0]
def check_pick(master: mf.Master, where: str, cond: dict) -> list[str]:
"""고르기 조건 검사 — 칸 이름 · 구분·상세구분이 자재품목에 있는지 · 대표 줄 · 후보 0."""
if set(cond) - set(PICK_KEYS) or not cond.get("구분"):
return [f"{where} · 재료 고르기 조건 모양 「{cond}」 — {' · '.join(PICK_KEYS)}"]
out, rows = [], master.index.get(MARKET, {})
for slot in ("구분", "상세구분"):
if cond.get(slot) and not any(r.get(slot) == cond[slot] for r in rows.values()):
out.append(f"{where} · 자재품목에 없는 {slot}{cond[slot]}")
rep = cond.get("대표")
if rep and str(rep) not in rows:
out.append(f"{where} · 대표 줄 「{rep}」 이 자재품목에 없음")
elif rep and str(rep) not in candidates(master, cond):
out.append(f"{where} · 대표 줄 「{rep}」 이 고르기 조건 밖")
if not out and not candidates(master, cond):
out.append(f"{where} · 재료 고르기 후보 0")
return out
def element(master: mf.Master, ref, env: dict) -> tuple[object, str | None]:
"""요소 값과 출처 — 자재품목은 제 줄, 품셈재료는 연결을 따라가 낮은 값 · 그 밖은 줄의 `값`.
`ref` 고르기 조건 묶음이면 대표 (없으면 조건 ) ."""
if isinstance(ref, dict):
ref = pick(master, ref, env)
row = master.get(ref)
if ref[:2] == MARKET: # 자재품목을 바로 가리키는 호표 줄
return _lowest(master, ref)
@@ -0,0 +1,53 @@
"""산림품셈 로직 복잡도 재기 — 입력 수 · 표 찾기 수 · 로직 부르기 · 덧줄 · 재료 가짓수.
사용: python resources/master_data/scripts/로직_복잡도_재기.py [상위 N]
점수 = 입력 + 찾기 + 로직부르기*2 + 덧줄 + 재료 (읽기 전용 · 아무것도 )
"""
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
FIND = re.compile(r"찾기\(")
CALL = re.compile(r"로직\(")
def measure(row: dict) -> dict:
ho = row.get("호표") or []
text = " ".join(str(h.get("수량", "")) + str(h.get("요소", "")) for h in ho)
text += " ".join(str(m.get("", "")) for m in row.get("중간") or [])
text += " ".join(str(m.get("", "")) for m in row.get("덧줄") or [])
m = {
"입력": len(row.get("입력") or []),
"찾기": len(FIND.findall(text)),
"로직": len(CALL.findall(text)),
"덧줄": len(row.get("덧줄") or []),
"재료": sum(1 for h in ho if h.get("종류") == "재료"),
}
m["점수"] = m["입력"] + m["찾기"] + m["로직"] * 2 + m["덧줄"] + m["재료"]
return m
def main() -> None:
top = int(sys.argv[1]) if len(sys.argv) > 1 else 25
out = []
for f in sorted(ROOT.glob("로직_산림품셈_*.json")):
for row in json.loads(f.read_text(encoding="utf-8"))[""]:
out.append(
{"": row[""], "번호": row["원문번호"], "이름": row["이름"], **measure(row)}
)
out.sort(key=lambda r: -r["점수"])
for r in out[:top]:
print(
r[""],
r["번호"],
r["이름"],
{k: r[k] for k in ("입력", "찾기", "로직", "덧줄", "재료", "점수")},
)
print("", len(out))
if __name__ == "__main__":
main()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+141
View File
@@ -0,0 +1,141 @@
/* M01 C( ) TS Node .
* (프론트에 JS 테스트 러너가 없어 pytest가 스크립트를 부른다 test_m01_flow_c.py)
* 확장자가 `.cjs` 까닭은 helper_b05_patch_skirt.cjs 머리말 참고(루트가 ESM). */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const source = fs.readFileSync(
path.join(__dirname, "..", "..", "M01_MasterData", "M01_MasterData_UI_Test_C_Model.ts"),
"utf8",
);
const js = ts.transpileModule(source, {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const moduleBox = { exports: {} };
new Function("exports", "module", "require", js)(moduleBox.exports, moduleBox, require);
const { buildFlow, logicRef } = moduleBox.exports;
/** 산림품셈 13-4-1 메쌓기 모양 — 표 찾기 하나 · 인력 두 줄 · 덧줄 하나(할증) */
const 메쌓기 = {
: "GF000219",
원문번호: "13-4-1",
이름: "돌쌓기 메쌓기(인력)",
결과단위: "원/㎡",
출처: "산림품셈 13-4-1",
입력: [
{ 이름: "뒷길이", 단위: "㎝", 고르기: [25, 30] },
{ 이름: "높이", 단위: "m" },
],
중간: [{ 이름: "증가율", : "찾기(QF000422, 높이=높이).증가율", 출처: "산림품셈 13-4-1 주③" }],
호표: [
{
종류: "인력",
요소: "LB000033",
이름: "석공",
단위: "인",
수량: "찾기(QF000421, 뒷길이=뒷길이).석공",
비목: "노무비",
},
{
종류: "로직",
요소: "로직(GC000268, 기계='2702-0020')",
이름: "굴착기",
단위: "시간",
수량: "1 / N",
비목: "경비",
},
],
덧줄: [{ 이름: "잡재료", : "노무비 * 0.03", 비목: "재료비", 출처: "산림품셈 13-4-1 주①" }],
끝수: null,
};
const = {
ok: true,
lines: [
{ 이름: "석공", 단위: "인", 수량: 1.2, 단가: 300000, 금액: 360000, 비목: { 노무비: 360000 } },
{ 이름: "굴착기", 단위: "시간", 수량: 0.5, 단가: 80000, 금액: 40000, 비목: { 경비: 40000 } },
{ 이름: "잡재료", 단위: "식", 수량: 1, 단가: 10800, 금액: 10800, 비목: { 재료비: 10800 } },
],
sums: { 노무비: 360000, 재료비: 10800, 경비: 40000, : 410800 },
middle: { 증가율: 15 },
};
/** 돈이 아닌 로직 — 결과 식 하나 */
const 작업량 = {
: "GF000118",
원문번호: "9-16-1",
이름: "노체포설 시간당 작업량",
결과단위: "㎥/hr",
출처: "산림품셈 9-16-1",
입력: [{ 이름: "토질", 고르기: ["보통토", "암괴"] }],
중간: [{ 이름: "E", : "찾기(QF000300, 토질=토질).E" }],
결과: { : "3600 * q * E / Cm" },
};
const kinds = (columns) => columns.map((c) => c.kind);
const box = (columns, kind, i) => columns.find((c) => c.kind === kind).boxes[i];
const out = {
logicRef: [logicRef("로직(GC000268, 기계='2702-0020')"), logicRef("LB000033") ?? null],
: kinds(buildFlow(메쌓기, , {}, { 뒷길이: "25" })),
_계산전: kinds(buildFlow(메쌓기, null, {}, {})),
_돈아님: kinds(buildFlow(작업량, { ok: true, result: 42.5, middle: { E: 0.8 } }, {}, {})),
};
{
const columns = buildFlow(메쌓기, , {}, { 뒷길이: "25", 높이: "4" });
const 입력 = box(columns, "입력", 0);
const 중간 = box(columns, "중간", 0);
const 수량 = box(columns, "수량", 0);
const 단가1 = box(columns, "단가", 0);
const 단가2 = box(columns, "단가", 1);
const 덧줄 = box(columns, "덧줄", 0);
const = box(columns, "계", 0);
const 비목 = columns.find((c) => c.kind === "비목").boxes;
out. = {
입력값: 입력.value,
입력출처: 입력.detail,
중간값: 중간.value,
중간식: 중간.detail[0],
수량값: 수량.value,
수량식: 수량.detail[0],
단가노트: 단가1.note,
단가값: 단가1.value,
로직: 단가2.logic ?? null,
로직아님: 단가1.logic ?? null,
덧줄값: 덧줄.value,
덧줄식: 덧줄.detail[0],
비목: 비목.map((b) => [b.label, b.value]),
비목상세: 비목.map((b) => b.detail),
계값: .value,
묶음: columns.find((c) => c.kind === "수량").boxes.map((b) => b.cost),
id: [수량.id, 단가1.id, 덧줄.id, .id],
};
}
{
const columns = buildFlow(메쌓기, null, { LB000033: null }, {});
out.계산전 = {
수량값: box(columns, "수량", 0).value,
수량식: box(columns, "수량", 0).detail[0],
단가막힘: box(columns, "단가", 0).bad === true,
계값: box(columns, "계", 0).value,
};
}
{
const columns = buildFlow(메쌓기, { ok: false, reason: "입력 없음" }, {}, {});
out.멈춤 = { : kinds(columns), 계값: box(columns, "계", 0).value };
}
{
const columns = buildFlow(작업량, { ok: true, result: 42.5, middle: { E: 0.8 } }, {}, {});
out.돈아님 = {
중간값: box(columns, "중간", 0).value,
결과값: box(columns, "계", 0).value,
결과식: box(columns, "계", 0).detail[0],
};
}
process.stdout.write(JSON.stringify(out));
+29
View File
@@ -427,3 +427,32 @@ def test_유가전력_구분_상세구분_거름(client: TestClient) -> None:
detail="주택용 저압",
)
assert hit["total"] > 0 and {r["상세구분"] for r in hit["rows"]} == {"주택용 저압"}
def test_재료_고르기_후보_목록(client: TestClient) -> None:
"""구분·상세구분·규격으로 좁힌 후보 · `기본` = 시험 계산이 쓸 줄 · 자재지역."""
market = store.FOLDER / "재료_자재품목.json"
data = json.loads(market.read_text(encoding="utf-8"))
for row, sub, detail, name, spec in (
(data[""][0], "관·수로", "흄관", "흄관-경상북도(울진군)", "D300 B종"),
(data[""][1], "관·수로", "흄관", "흄관-경상북도(포항시)", "D300 B종"),
(data[""][2], "관·수로", "파형강관", "파형강관", "D300"),
):
row.update({"구분": sub, "상세구분": detail, "이름": name, "규격": spec})
market.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
assert client.get("/api/m01/materials").status_code == 422 # 구분은 반드시
whole = _get(client, "/api/m01/materials", sub="관·수로")
assert whole["total"] == 3 and whole["기본"] == whole["items"][0]["ref"]
pipes = _get(client, "/api/m01/materials", sub="관·수로", detail="흄관")
assert [i["ref"] for i in pipes["items"]] == [r[""] for r in data[""][:2]]
assert pipes["items"][0]["단위"] and pipes["items"][0][""] is not None
spec = _get(client, "/api/m01/materials", sub="관·수로", spec="D300 B종")
assert spec["total"] == 2
near = _get(client, "/api/m01/materials", sub="관·수로", detail="흄관", region="울진")
assert [i["ref"] for i in near["items"]] == [data[""][0][""]]
assert _get(client, "/api/m01/materials", sub="없는구분") == {
"total": 0,
"기본": None,
"items": [],
}
+82
View File
@@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
"""M01 로직 테스트 방식 C(흐름 그림) 뼈대 검증 — `M01_MasterData_UI_Test_C_Model.ts`.
프론트에 JS 테스트 러너가 없어 Node 헬퍼(helper_m01_flow_c.cjs) TS를 트랜스파일해
돌리고, pytest 결과(JSON) 본다. 뼈대만 금액은 엔진이 답을 그대로 옮기는지.
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
@pytest.fixture(scope="module")
def flow():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_m01_flow_c.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_칸_차례(flow):
"""설계 값 → 표 찾기 → 수량 → × 단가 → 할증·덧줄 → 비목 합계 → 계."""
assert flow[""] == ["입력", "중간", "수량", "단가", "덧줄", "비목", ""]
# 계산 전에도 같은 뼈대 — 값만 비어 있음
assert flow["칸_계산전"] == flow[""]
# 돈이 아닌 로직은 호표가 없음
assert flow["칸_돈아님"] == ["입력", "중간", ""]
def test_값은_엔진_답_그대로(flow):
v = flow[""]
assert v["중간값"] == "15"
assert v["수량값"] == "1.2"
assert (v["단가노트"], v["단가값"]) == ("× 300,000", "360,000")
assert v["덧줄값"] == "10,800"
assert v["비목"] == [["노무비", "360,000"], ["재료비", "10,800"], ["경비", "40,000"]]
assert v["계값"] == "410,800"
def test_상자를_누르면_값과_출처(flow):
v = flow[""]
assert v["중간식"] == ["", "찾기(QF000422, 높이=높이).증가율"]
assert v["수량식"] == ["", "찾기(QF000421, 뒷길이=뒷길이).석공"]
assert v["덧줄식"] == ["", "노무비 * 0.03"]
assert ["단위", ""] in v["입력출처"]
# 비목 상자 = 그 비목에 들어온 줄
assert v["비목상세"] == [[["석공", "360,000"]], [["잡재료", "10,800"]], [["굴착기", "40,000"]]]
def test_로직이_로직을_부르는_줄(flow):
assert flow["logicRef"] == ["GC000268", None]
assert flow[""]["로직"] == "GC000268" # 펼칠 상자
assert flow[""]["로직아님"] is None
def test_id_는_다시_그려도_같음(flow):
assert flow[""]["id"] == ["수량:0", "단가:0", "덧줄:0", ""]
assert flow[""]["묶음"] == ["노무비", "경비"] # 비목으로 묶어 접기
def test_계산_전과_멈춤(flow):
# 값이 없어도 식은 보임 · 값 없는 요소는 막힘 표시
assert flow["계산전"]["수량값"] == ""
assert flow["계산전"]["수량식"][1].startswith("찾기(")
assert flow["계산전"]["단가막힘"] is True
assert flow["계산전"]["계값"] == ""
assert flow["멈춤"][""] == flow[""] and flow["멈춤"]["계값"] == ""
def test_돈_아닌_로직(flow):
assert flow["돈아님"]["중간값"] == "0.8"
assert flow["돈아님"]["결과값"] == "42.5"
assert flow["돈아님"]["결과식"] == ["", "3600 * q * E / Cm"]
@@ -0,0 +1,50 @@
"""M01 로직 테스트 컨테이너 — 대상 10 개(틀 파일 TEST_KEYS)가 실제 산림품셈 로직이고 막히지 않았는지.
방식 A·B·C 같은 `/calc` 여기서는 10 개가 모두 선택값으로 셈이 끝까지 가는지만 .
"""
from __future__ import annotations
import re
from pathlib import Path
from M01_MasterData import M01_MasterData_Store as store
FRAME = Path(store.__file__).with_name("M01_MasterData_UI_Test.ts")
KEYS = re.findall(r'^\s+"(GF\d{6})",', FRAME.read_text(encoding="utf-8"), re.M)
def _first_inputs(logic: dict) -> dict:
"""고르기 = 첫 값 · 범위 = 아래 끝 · 나머지 = 10 (마법사가 흔히 넣는 값)."""
out = {}
for spec in logic.get("입력") or []:
if spec.get("고르기"):
out[spec["이름"]] = spec["고르기"][0]
elif spec.get("범위"):
out[spec["이름"]] = spec["범위"][0]
else:
out[spec["이름"]] = 10
return out
def test_ten_keys():
assert len(KEYS) == 10 and len(set(KEYS)) == 10
def test_keys_are_forest_logics_and_not_blocked():
for key in KEYS:
one = store.logic(key)
assert one["file"].startswith("로직_산림품셈_"), key
assert not one["blocked"], (key, one["reasons"])
def test_required_three_present():
# 메쌓기 13-4-1 · 노체 9-16-1 · 트럭 운반 10-4 — 끝까지 눌러 보는 셋
assert {"GF000219", "GF000119", "GF000137"} <= set(KEYS)
def test_calc_answers_for_each():
for key in KEYS:
one = store.logic(key)
answer = store.calc(key, _first_inputs(one["logic"]), None, None)
assert isinstance(answer, dict) and "ok" in answer, key
+143 -2
View File
@@ -710,8 +710,7 @@ def test_자재품목_열_한_벌():
book = mk.load_book()
fuel = [f"MT{i:06d}" for i in range(1, 8)] # 6·7 = 전력 — 재료_유가전력으로 감
assert all(k not in rows and k not in book[""] and k in book["폐기"] for k in fuel)
assert {r["구분"] for r in rows.values()} == {None}
assert {r["상세구분"] for r in rows.values()} == {None}
assert all(r["구분"] for r in rows.values()) # 채움 — build_자재품목_분류.py
def test_품셈재료_자재지역_후보(whole):
@@ -724,3 +723,145 @@ def test_품셈재료_자재지역_후보(whole):
mf.element(whole, "MP:레미콘", {"자재지역": "울진"})
value, source = mf.element(whole, "MP:모래", {"자재지역": "수원"})
assert source.startswith("MT") and value > 0
# ── 재료 고르기 조건 (`_틀.md` 7장) ────────────────────────────────────
def _pipes() -> mf.Master:
"""고르기 견본 — 자재품목 넉 줄 · 로직 한 줄(호표 요소가 고르기 조건)."""
rows = [
{
"": "MT900001",
"구분": "관·수로",
"상세구분": "흄관",
"이름": "흄관",
"규격": "D300 B종",
"단위": "m",
"물가자료": None,
"관급": None,
},
{
"": "MT900002",
"구분": "관·수로",
"상세구분": "흄관",
"이름": "흄관",
"규격": "D 300 B종",
"단위": "m",
"물가자료": Decimal(7000),
"관급": Decimal(6500),
},
{
"": "MT900003",
"구분": "관·수로",
"상세구분": "흄관",
"이름": "흄관",
"규격": "D600 B종",
"단위": "m",
"물가자료": Decimal(12000),
"관급": None,
},
{
"": "MT900004",
"구분": "관·수로",
"상세구분": "파형강관",
"이름": "파형강관",
"규격": "D300",
"단위": "m",
"물가자료": Decimal(9000),
"관급": None,
},
]
logic = {
"": "GX900001",
"이름": "흄관 부설",
"입력": [],
"호표": [
{
"종류": "재료",
"요소": {"구분": "관·수로", "상세구분": "흄관", "규격": "D300"},
"이름": "흄관",
"단위": "m",
"수량": "2",
"비목": "재료비",
}
],
}
return mf.Master(
{
"재료_자재품목.json": {"그룹": "재료", "원문": "자재품목", "": rows},
"로직_자체.json": {"그룹": "로직", "원문": "자체", "": [logic]},
}
)
def test_재료_고르기_조건이_후보를_좁힘():
"""구분 → 상세구분 → 규격 낱말(빈칸·대소문자 안 가림) 차례로 좁아짐."""
whole = _pipes()
assert len(mm.candidates(whole, {"구분": "관·수로"})) == 4
assert len(mm.candidates(whole, {"구분": "관·수로", "상세구분": "흄관"})) == 3
narrow = mm.candidates(whole, {"구분": "관·수로", "상세구분": "흄관", "규격": "D300 B종"})
assert narrow == ["MT900001", "MT900002"] # 「D 300 B종」 도 같은 줄로 봄
assert mm.candidates(whole, {"구분": "없는구분"}) == []
def test_재료_고르기_시험계산은_대표_줄_없으면_값_있는_첫_줄():
whole = _pipes()
cond = {"구분": "관·수로", "상세구분": "흄관", "규격": "D300"}
assert mm.pick(whole, cond, {}) == "MT900002" # MT900001 은 값이 없어 건너뜀
value, source = mf.element(whole, cond, {})
assert value == 6500 and source == "MT900002.관급" # 값 열 다섯 가운데 낮은 값
assert mm.pick(whole, {**cond, "대표": "MT900001"}, {}) == "MT900001"
with pytest.raises(mf.FormulaError, match="후보 없음"):
mm.pick(whole, {"구분": "없는구분"}, {})
def test_재료_고르기_호표_줄이_돎():
"""호표 `요소` 가 조건 묶음이어도 옛 방식과 같은 길로 금액이 섬."""
got = mf.run(_pipes(), "GX900001", {})
assert got["재료비"] == 13000 and got[""][0]["단가"] == 6500
def test_재료_고르기_조건_검사():
whole = _pipes()
ok = {"구분": "관·수로", "상세구분": "흄관"}
assert mm.check_pick(whole, "어디", ok) == []
assert "조건 모양" in mm.check_pick(whole, "어디", {**ok, "지역": "울진"})[0]
assert "조건 모양" in mm.check_pick(whole, "어디", {"상세구분": "흄관"})[0]
assert "없는 구분" in mm.check_pick(whole, "어디", {"구분": "없는구분"})[0]
assert "없는 상세구분" in mm.check_pick(whole, "어디", {**ok, "상세구분": "없음"})[0]
assert "조건 밖" in mm.check_pick(whole, "어디", {**ok, "대표": "MT900004"})[0]
assert "자재품목에 없음" in mm.check_pick(whole, "어디", {**ok, "대표": "MT000001"})[0]
# 호표 줄 검사에도 같이 실림 — 종류가 재료가 아니면 잡음
row = dict(whole.get("GX900001"))
row["호표"] = [{**row["호표"][0], "종류": "기계"}]
assert any("종류 「재료」" in x for x in mf.check_logic(whole, row)[0])
def test_재료_고르기_자재지역():
"""지역이 갈리는 품목 — 이름에 그 지역이 든 줄부터 · 그런 줄이 없으면 조건 안 전부."""
rows = [
{
"": "MT900011",
"구분": "레미콘",
"상세구분": None,
"이름": "레미콘-경상북도(포항시)",
"규격": "25-24-15",
"단위": "",
"물가자료": Decimal(80000),
"관급": None,
},
{
"": "MT900012",
"구분": "레미콘",
"상세구분": None,
"이름": "레미콘-경상북도(울진군)",
"규격": "25-24-15",
"단위": "",
"물가자료": Decimal(90000),
"관급": None,
},
]
whole = mf.Master({"재료_자재품목.json": {"그룹": "재료", "원문": "자재품목", "": rows}})
cond = {"구분": "레미콘"}
assert mm.candidates(whole, cond, {mm.REGION: "울진"}) == ["MT900012"]
assert len(mm.candidates(whole, cond, {mm.REGION: "제주"})) == 2 # 없으면 조건 안 전부
assert mf.element(whole, cond, {mm.REGION: "울진"})[0] == 90000
@@ -0,0 +1,121 @@
"""자재품목 구분 · 상세구분 — 다시 돌려도 같은 결과 · 품셈재료가 부르는 재료가 제 묶음에 듦."""
from __future__ import annotations
import collections
import json
import re
import sys
from pathlib import Path
MD = Path(__file__).resolve().parents[1] / "master_data"
sys.path.insert(0, str(MD / "scripts"))
import build_자재품목_분류 as B # noqa: E402
ROWS = json.loads((MD / "재료_자재품목.json").read_text(encoding="utf-8"))[""]
PM = json.loads((MD / "재료_품셈재료.json").read_text(encoding="utf-8"))[""]
# 품셈재료 이름(뿌리) → 자재품목에서 들어가야 할 구분
기대 = {
"시멘트": "시멘트·골재",
"모래": "시멘트·골재",
"규사": "시멘트·골재",
"모르타르": "시멘트·골재",
"이형철근": "철근·강재",
"철망": "철근·강재",
"철선": "철근·강재",
"결속선": "철근·강재",
"아연도철선": "철근·강재",
"폼타이(Form Tie)": "거푸집·가설재",
"문양거푸집(판넬)": "거푸집·가설재",
"철물(강관비계)": "볼트·앵커·철물",
"브라켓(낙하물방지망)": "볼트·앵커·철물",
"부직포": "매트·토목섬유",
"드레인 보드(연직배수재)": "매트·토목섬유",
"벤토나이트매트": "매트·토목섬유",
"VR관(소켓식)": "관·이음관",
"흄관": "관·이음관",
"파형강관": "관·이음관",
"커플링밴드(파형강관)": "관·이음관",
"고무링(VR관)": "관·이음관",
"아스팔트": "아스콘·포장",
"접착제(JOINT FILLER)": "도료·방수·접착",
"에폭시 접착제": "도료·방수·접착",
"프라이머": "도료·방수·접착",
"조합페인트": "도료·방수·접착",
"징크프라이머": "도료·방수·접착",
"벤토나이트": "도료·방수·접착",
"벤토나이트알갱이": "도료·방수·접착",
"종자(초류)": "식생·종자",
"비료": "식생·종자",
"우드칩(목재칩)": "목재",
"시멘트벽돌": "콘크리트제품",
"콘크리트못": "볼트·앵커·철물",
"와셔": "볼트·앵커·철물",
"카트리지": "볼트·앵커·철물",
"카페트": "건축마감",
"싱글코아바렐": "기계·설비",
"더블코아바렐": "기계·설비",
"드라이브파이프": "기계·설비",
"메탈크라운비트": "기계·설비",
"윙비트": "기계·설비",
"버튼비트": "기계·설비",
"폭약": "잡재료",
"아세틸렌": "잡재료",
"용접봉": "잡재료",
"": "잡재료",
"고철": "잡재료",
}
def _root(n: str) -> str:
return re.sub(r"\s+", "", re.split(r"[\(,\-]", n.strip())[0])
def test_규칙_다시_돌려도_같은_결과():
new, res = B.main()
assert new == (MD / "재료_자재품목.json").read_text(encoding="utf-8")
assert len(res) == len(ROWS)
def test_두_칸_채움_·_구분_서른_안쪽():
by = collections.defaultdict(set)
for x in ROWS:
assert x["구분"], x[""]
assert (x["상세구분"] is None) == (x["구분"] == "미분류"), x[""]
by[x["구분"]].add(x["상세구분"])
assert len(by) - 1 <= 30
assert sum(1 for x in ROWS if x["구분"] == "미분류") < len(ROWS) * 0.06
def test_조달청_줄은_조달청_품명_표를_따름():
for x in ROWS:
if x["출처"].startswith("자재품목 조달청"):
assert (x["구분"], x["상세구분"]) == B.조달청_분류[re.sub(r"\s+", "", x["이름"])]
def test_품셈재료_연결_줄이_제_묶음():
by = {x[""]: x for x in ROWS}
for p in PM:
if isinstance(p["연결"], str):
r = _root(p["이름"])
want = 기대.get(p["이름"]) or 기대.get(r)
assert want, p["이름"]
assert by[p["연결"]]["구분"] == want, (p["이름"], by[p["연결"]]["이름"])
def test_품셈재료_이름과_같은_품명_줄이_한_묶음():
idx = collections.defaultdict(list)
for x in ROWS:
idx[_root(x["이름"])].append(x)
for p in PM:
want = 기대.get(p["이름"])
if not want:
continue
hit = idx.get(_root(p["이름"]), [])
assert hit, p["이름"]
assert {x["구분"] for x in hit} == {want}, (
p["이름"],
collections.Counter(x["구분"] for x in hit),
)
+1
View File
@@ -67,4 +67,5 @@ export const ui_locales_m1 = {
M01_GroupExchange: ["환율", "Exchange rates"],
M01_GroupRate: ["요율", "Rates"],
M01_GroupLogic: ["일위대가 로직", "Unit-cost logic"],
M01_GroupTest: ["일위대가 로직 테스트", "Logic test"],
} as const;