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:
@@ -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)
|
||||
|
||||
@@ -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` 에 더함) · 돌려받는 것 = 계산할 키."""
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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()
|
||||
+2799
-241
File diff suppressed because it is too large
Load Diff
+30593
-30593
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
@@ -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": [],
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user