Merge remote-tracking branch 'origin/dev' into sub_laptop_2

This commit is contained in:
2026-09-24 20:46:53 +09:00
14 changed files with 739 additions and 72 deletions
+7 -2
View File
@@ -27,6 +27,9 @@ class CalcBody(BaseModel):
inputs: dict[str, Any] = {}
row: dict[str, Any] | None = None # 저장 전 고친 로직 줄(없으면 저장된 파일로)
file: str | None = None # 새 로직이면 더할 로직 파일
고름: dict[
str, str
] = {} # {호표 줄 차례(0부터): 자재 키} — 그 줄만 이 품목으로 셈 · 저장 안 함
class Change(BaseModel):
@@ -244,13 +247,15 @@ def get_materials(
@router.post("/calc")
def post_calc(body: CalcBody) -> dict:
return _call(store.calc, body.key, body.inputs, body.row, body.file)
with store.cm.mf.mp.choosing(body.고름):
return _call(store.calc, body.key, body.inputs, body.row, body.file)
@router.post("/text")
def post_text(body: CalcBody) -> dict:
"""읽는 식 줄 — 몸은 `/calc` 와 같음."""
return _call(store.text, body.key, body.inputs, body.row, body.file)
with store.cm.mf.mp.choosing(body.고름):
return _call(store.text, body.key, body.inputs, body.row, body.file)
@router.post("/save")
@@ -168,6 +168,7 @@ export async function mountM01LogicLab(
savedKey: current.origKey,
file: current.file,
row: current.row as LogicRow,
prices: current.prices,
dirty: () => current.id in drafts,
values: current.values,
onText: (text) => {
@@ -175,3 +175,25 @@
.m01lab__edit {
margin-top: var(--spacing-12);
}
/* 시험 계산 — 재료 · 기계 고르기 표 모달 */
.m01-logic__pick--table {
width: min(1040px, 94vw);
}
.m01lab__pickrow {
cursor: pointer;
}
.m01lab__pickrow:hover,
.m01lab__pickrow.is-picked {
background: var(--color-paper);
}
.m01lab__pickrow.is-picked {
font-weight: 600;
}
.m01lab__warn {
color: var(--color-warning, #b45309);
}
@@ -88,6 +88,8 @@ export interface CalcLine {
비목: Record<string, number>;
/** 값 없는 요소를 다른 값으로 셈 — 「준용: <직종>」 · 「산정: …」 */
출처?: string;
/** 자재 단위가 줄 단위와 성격이 달라 환산 못 하고 셈 — 「줄 m · 자재 Ton」 */
단위경고?: string;
}
export type CalcAnswer =
| {
@@ -227,6 +229,8 @@ export const runCalc = (body: {
inputs: Record<string, unknown>;
row?: LogicRow;
file?: string;
/** 시험 계산에서 고른 재료 — 호표 줄 차례 → 자재품목 키 · 저장 안 함 */
고름?: Record<string, string>;
}): Promise<CalcAnswer> => request("/calc", body);
export const runText = (body: {
@@ -234,6 +238,8 @@ export const runText = (body: {
inputs: Record<string, unknown>;
row?: LogicRow;
file?: string;
/** 시험 계산에서 고른 재료 — 호표 줄 차례 → 자재품목 키 · 저장 안 함 */
고름?: Record<string, string>;
}): Promise<TextAnswer> => request("/text", body);
export const saveFiles = (
+76 -45
View File
@@ -12,9 +12,16 @@ import {
searchElements,
type CalcAnswer,
type CalcLine,
type ElementBrief,
type TextAnswer,
type LogicRow,
} from "./M01_MasterData_UI_Logic_Api";
import {
isMaterialKeyInput,
machineControl,
materialRows,
pickBody,
} from "./M01_MasterData_UI_Logic_CalcPick";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { formatMoney } from "./M01_MasterData_UI_Logic_Money";
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
@@ -47,6 +54,8 @@ export interface CalcContext {
/** 저장 전 새 초안이면 비움 — 서버가 임시 자리에 얹어 셈 */
file?: string;
row: LogicRow;
/** 잡힌 품목을 보이는 데 씀 — 재료 줄의 단가 요소 · 조건 글 → 요소 */
prices?: Record<string, ElementBrief | null>;
dirty: () => boolean;
/** 로직마다 넣은 값 — 다시 그려도 남음 */
values: Record<string, string>;
@@ -68,49 +77,56 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
// 지금 쓰는 LogicLab 화면은 그 칸에 안 걸려 폭이 이미 넓음(실측 730px) · 따로 안 풂
const optionText = (o: string | number): string =>
(typeof o === "string" && nameCache.get(o)) || String(o);
const fields = (ctx.row.입력 ?? []).map((spec) => {
const name = spec.이름;
let control: HTMLElement;
const picks = spec.고르기;
if (picks?.length) {
const pick = createSelectField({
options: ["", ...picks.map(String)].map((o) => ({ value: o, text: optionText(o) })),
value: ctx.values[name] ?? "",
compact: true,
onChange: (v) => {
ctx.values[name] = v;
},
});
control = pick.root;
const keys = picks.filter((o): o is string => typeof o === "string" && KEY_RE.test(o));
if (keys.length) {
void Promise.all(keys.map(keyName)).then(() => {
pick.setOptions(
["", ...picks.map(String)].map((o) => ({ value: o, text: optionText(o) })),
ctx.values[name] ?? "",
);
const again = (): void => host.querySelector<HTMLButtonElement>("[data-calc-run]")?.click();
const fields = (ctx.row.입력 ?? [])
.filter((spec) => !isMaterialKeyInput(spec)) // 재료 키 목록은 표 모달로 바뀌어 걷음
.map((spec) => {
const name = spec.이름;
let control: HTMLElement;
const picks = spec.고르기;
const machine = machineControl(spec, ctx.values, again);
if (machine) {
control = machine;
} else if (picks?.length) {
const pick = createSelectField({
options: ["", ...picks.map(String)].map((o) => ({ value: o, text: optionText(o) })),
value: ctx.values[name] ?? "",
compact: true,
onChange: (v) => {
ctx.values[name] = v;
},
});
control = pick.root;
const keys = picks.filter((o): o is string => typeof o === "string" && KEY_RE.test(o));
if (keys.length) {
void Promise.all(keys.map(keyName)).then(() => {
pick.setOptions(
["", ...picks.map(String)].map((o) => ({ value: o, text: optionText(o) })),
ctx.values[name] ?? "",
);
});
}
} else {
const box = el("input", {
className: "m01-logic__input",
attrs: { type: "text", inputmode: "decimal" },
});
if (spec.범위) box.placeholder = `${spec.범위[0]} ∼ ${spec.범위[1]}`;
box.value = ctx.values[name] ?? "";
box.addEventListener("input", () => {
ctx.values[name] = box.value;
});
control = box;
}
} else {
const box = el("input", {
className: "m01-logic__input",
attrs: { type: "text", inputmode: "decimal" },
const label = spec.단위 ? `${name} (${spec.단위})` : name;
// 긴 이름 줄만 한 칸 다 씀 — 고르기 줄은 짧으면 그대로 2열에 맞춰 나란히
const wide = label.length > 10;
return el("label", {
className: `m01-logic__field${wide ? " m01-logic__field--wide" : ""}`,
children: [el("span", { text: label }), control],
});
if (spec.범위) box.placeholder = `${spec.범위[0]} ∼ ${spec.범위[1]}`;
box.value = ctx.values[name] ?? "";
box.addEventListener("input", () => {
ctx.values[name] = box.value;
});
control = box;
}
const label = spec.단위 ? `${name} (${spec.단위})` : name;
// 긴 이름 줄만 한 칸 다 씀 — 고르기 줄은 짧으면 그대로 2열에 맞춰 나란히
const wide = label.length > 10;
return el("label", {
className: `m01-logic__field${wide ? " m01-logic__field--wide" : ""}`,
children: [el("span", { text: label }), control],
});
});
const materials = materialRows(ctx.row, ctx.prices ?? {}, ctx.values, again);
const out = el("div", { className: "m01-logic__calc-out" });
const mySeqAtOpen = ctx.onText ? previewSeq + 1 : previewSeq; // 이 그리기가 받을 순번
let manualRun = false; // [계산] 이 이미 답했으면 뒤늦게 오는 미리보기로 안 덮음
@@ -131,7 +147,7 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
if (!fields.length && !autoAsked.has(ctx.values)) {
autoAsked.add(ctx.values); // 입력이 없으면 채울 것이 없음 — 열 때 한 번 바로 계산
queueMicrotask(() => {
if (previewSeq === mySeqAtOpen) host.querySelector("button")?.click();
if (previewSeq === mySeqAtOpen) again();
});
} else if (ctx.savedKey !== null && !autoAsked.has(ctx.values)) {
autoAsked.add(ctx.values);
@@ -142,7 +158,7 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
if (!names.length || previewSeq !== mySeqAtOpen) return; // 값이 없거나 다른 로직이 이미 열림
for (const n of names) ctx.values[n] = String(auto[n]);
buildCalc(host, ctx); // 채운 값을 새로 그리고 바로 계산(결과 · 텍스트 수식까지)
host.querySelector("button")?.click();
again();
});
}
const run = async (): Promise<void> => {
@@ -161,6 +177,7 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
const body = {
key: ctx.savedKey ?? ctx.row.키,
inputs,
...pickBody(ctx.values),
...(draft ? { row: ctx.row, file: ctx.file } : {}),
};
const answer = await runCalc(body);
@@ -171,12 +188,14 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
}
};
const runButton = createButton({ label: tx("Calc_Run"), onClick: () => void run() });
runButton.setAttribute("data-calc-run", "");
host.replaceChildren(
el("h3", { text: tx("Calc_Title") }),
...(fields.length
? [el("div", { className: "m01-logic__head", children: fields })] // 요소 표가 쓰는 auto-fill 그리드 재사용 — 2열
...(fields.length || materials.length
? [el("div", { className: "m01-logic__head", children: [...fields, ...materials] })] // 요소 표가 쓰는 auto-fill 그리드 재사용 — 2열
: [el("p", { className: "m01-logic__muted", text: tx("Calc_NoInputs") })]),
createButton({ label: tx("Calc_Run"), onClick: () => void run() }),
runButton,
out,
);
}
@@ -201,7 +220,19 @@ function answerView(answer: CalcAnswer, draft: boolean, money: boolean): HTMLEle
const rows = answer.lines.map((line) =>
el("tr", {
children: [
el("td", { text: line.출처 ? `${line.이름} (${line.출처})` : line.이름 }),
el("td", {
children: [
el("span", { text: line.출처 ? `${line.이름} (${line.출처})` : line.이름 }),
...(line.단위경고
? [
el("span", {
className: "m01-logic__tag m01lab__warn",
text: `${tx("Calc_UnitWarn")} · ${line.단위경고}`,
}),
]
: []),
],
}),
el("td", { text: `${formatNumber(line.수량)} ${line.단위}` }),
el("td", { className: "m01-logic__money", text: formatMoney(line.금액) }),
],
@@ -0,0 +1,155 @@
/* =============================================================================
* M01_MasterData_UI_Logic_CalcPick.ts
* 시험 계산의 재료 · 기계 고르기 — 재료 줄마다 「잡힌 품목 + 품목 고르기」 · 기계 입력은 단추 하나
* 누르면 마스터 표 모달(`PickTable`) · 고른 재료는 `고름`(호표 줄 차례 → 키)에 실어 다시 계산 · 저장 안 함
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import { fetchRows, type Row } from "./M01_MasterData_Api_Fetch";
import type { ElementBrief, LogicInput, LogicRow } from "./M01_MasterData_UI_Logic_Api";
import { openPickTable } from "./M01_MasterData_UI_Logic_PickTable";
import { tx } from "./M01_MasterData_UI_Logic_Text";
import { condText, type PickCond } from "./M01_MasterData_UI_LogicLab_Modal";
interface Chosen {
ref: string;
label: string;
}
/** 로직마다 넣은 값(`values`)에 딸린 고른 재료 — 다시 그려도 남고 서버에는 계산 요청에만 실림 */
const chosen = new WeakMap<Record<string, string>, Map<number, Chosen>>();
const picksOf = (values: Record<string, string>): Map<number, Chosen> => {
let mine = chosen.get(values);
if (!mine) chosen.set(values, (mine = new Map()));
return mine;
};
/** 계산 요청 몸의 `고름` — 고른 것이 없으면 안 실음 */
export function pickBody(values: Record<string, string>): { 고름?: Record<string, string> } {
const mine = picksOf(values);
return mine.size
? { 고름: Object.fromEntries([...mine].map(([i, c]) => [String(i), c.ref])) }
: {};
}
const nameOf = (row: Row): string => `${row["이름"] ?? ""} ${row["규격"] ?? ""}`.trim();
const firstWord = (text: string): string => text.trim().split(/\s+/)[0] ?? "";
const MATERIAL_KEYS = /^MT\d{6}$/;
const MACHINE_CODE = /^\d{4}-\d{4}$/;
const MACHINE_KEY = /^EQ\d{6}$/;
/** 재료 키 목록 입력 — 표 모달로 바뀌어 걷음 */
export const isMaterialKeyInput = (spec: LogicInput): boolean =>
!!spec.고르기?.length && spec.고르기.every((o) => MATERIAL_KEYS.test(String(o)));
const isMachineInput = (spec: LogicInput): boolean =>
!!spec.고르기?.length &&
spec.고르기.every((o) => MACHINE_CODE.test(String(o)) || MACHINE_KEY.test(String(o)));
const pickButton = (label: string, onClick: () => void): HTMLButtonElement =>
createButton({ label, variant: "ghost", onClick });
const line = (label: string, current: HTMLElement, button: HTMLElement): HTMLElement =>
el("div", {
className: "m01-logic__field m01-logic__field--wide",
children: [el("span", { text: label }), el("div", { children: [current, button] })],
});
/** 재료 줄마다 한 칸 — 지금 잡힌 품목 + 품목 고르기 */
export function materialRows(
row: LogicRow,
prices: Record<string, ElementBrief | null>,
values: Record<string, string>,
again: () => void,
): HTMLElement[] {
const mine = picksOf(values);
return (row.호표 ?? [])
.map((item, i) => ({ item, i }))
.filter((x) => x.item.종류 === "재료")
.map(({ item, i }) => {
const cond =
typeof item.요소 === "object" && item.요소 !== null
? (item.요소 as unknown as PickCond)
: null;
const brief = (cond ? prices[condText(cond)] : prices[item.요소]) ?? null;
const label = item.이름 ?? (brief?.이름 as string | undefined) ?? "";
const held = mine.get(i);
const nowRef = held?.ref ?? brief?.ref;
const nowName =
held?.label ?? (brief ? `${brief.이름 ?? ""} ${brief.규격 ?? ""}`.trim() : "") ?? "";
const seeds = [cond?.["검색어"], cond?.이름, cond?.상세구분, cond?.구분, label]
.map((w) => firstWord(String(w ?? "")))
.filter((w, k, all) => w && all.indexOf(w) === k);
const current = el("span", {
className: "m01-logic__muted",
text: `${nowName || tx("Mat_NoPrice")} `,
});
const button = pickButton(tx("Pick_Item"), () =>
openPickTable({
kind: "재료",
title: `${tx("Pick_MatTitle")} · ${label}`,
seed: seeds[0] ?? "",
alt: seeds.slice(1),
isCurrent: (r) => String(r["키"]) === nowRef,
onPick: (r) => {
mine.set(i, { ref: String(r["키"]), label: nameOf(r) });
current.textContent = `${nameOf(r)} `;
again();
},
}),
);
return line(`${label}${item.규격 ? ` ${item.규격}` : ""}`, current, button);
});
}
/** 기계 입력 칸 — 표 모달로 고르면 입력값(원문 분류번호 · 키 목록이면 키) */
const machines = new Map<string, Row | null>();
async function machineRow(code: string): Promise<Row | null> {
if (!machines.has(code)) {
let found: Row | null = null;
try {
const got = await fetchRows("기계.json", 1, 5, code);
found = got.rows.find((r) => r["원문번호"] === code || r["키"] === code) ?? null;
} catch {
found = null;
}
machines.set(code, found);
}
return machines.get(code) ?? null;
}
export function machineControl(
spec: LogicInput,
values: Record<string, string>,
again: () => void,
): HTMLElement | null {
if (!isMachineInput(spec)) return null;
const current = el("span", { className: "m01-logic__muted" });
const paint = (): void => {
const code = values[spec.이름] ?? "";
const known = machines.get(code);
current.textContent = `${code ? `${code}${known ? ` · ${nameOf(known)}` : ""}` : tx("Mat_NoPrice")} `;
if (code && !machines.has(code)) void machineRow(code).then(paint);
};
paint();
const button = pickButton(tx("Pick_Machine"), () => {
const code = values[spec.이름] ?? "";
const known = machines.get(code);
openPickTable({
kind: "기계",
title: `${tx("Pick_Machine")} · ${spec.이름}`,
seed: String(known?.["이름"] ?? spec.이름),
isCurrent: (r) => !!code && (r["원문번호"] === code || r["키"] === code),
onPick: (r) => {
const useKey = MACHINE_KEY.test(String(spec.고르기?.[0]));
const value = String(useKey ? r["키"] : r["원문번호"]);
machines.set(value, r);
values[spec.이름] = value;
paint();
again();
},
});
});
return el("div", { children: [current, button] });
}
@@ -0,0 +1,142 @@
/* =============================================================================
* M01_MasterData_UI_Logic_PickTable.ts
* 시험 계산의 재료 · 기계 고르기 모달 — 마스터 컨테이너와 같은 표(`GET /rows`) · 쪽 나눔 · 칸 글
* 검색 칸을 채워 열고 · 잡힌 품목 칠함 · 한 줄 누르면 고름 · 저장 안 함(읽기만)
* ========================================================================== */
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
import { fetchRows, type Row } from "./M01_MasterData_Api_Fetch";
import { buildPager, flatten, show } from "./M01_MasterData_UI_Cells";
import { tx } from "./M01_MasterData_UI_Logic_Text";
export type PickTableKind = "재료" | "기계";
const FILES: Record<PickTableKind, string> = { 재료: "재료_자재품목.json", 기계: "기계.json" };
/** 표에 보일 칸 — 컨테이너 표의 칸 이름(묶음 값은 「값›가격」) */
const COLS: Record<PickTableKind, string[]> = {
재료: [
"키",
"구분",
"상세구분",
"이름",
"규격",
"단위",
"물가자료",
"유통물가",
"물가정보",
"거래가격",
],
기계: ["원문번호", "구분", "상세구분", "이름", "규격", "값›가격"],
};
const SIZE = 30;
export interface PickTableOptions {
kind: PickTableKind;
title: string;
/** 검색 칸을 채워 엶 — 결과가 없으면 첫 낱말만으로 다시 · 그래도 없으면 `alt` 를 차례로 */
seed: string;
alt?: string[];
/** 지금 잡힌 줄이면 true — 그 줄을 칠함 */
isCurrent: (row: Row) => boolean;
onPick: (row: Row) => void;
}
export function openPickTable(opt: PickTableOptions): void {
const close = (): void => back.remove();
const searchField = createInputField({
type: "search",
placeholder: tx("Pick_Search"),
value: opt.seed,
});
const search = searchField.input;
const host = el("div", { className: "m01-logic__pick-list" });
const dialog = el("div", {
className: "m01-logic__pick m01-logic__pick--table",
attrs: { role: "dialog", "aria-label": opt.title },
children: [
el("div", {
className: "m01-logic__section-head",
children: [
el("h3", { text: opt.title }),
createButton({ label: tx("Pick_Close"), variant: "ghost", onClick: close }),
],
}),
searchField.root,
host,
],
});
const back = el("div", { className: "m01-logic__backdrop", children: [dialog] });
back.addEventListener("click", (ev) => ev.target === back && close());
back.addEventListener("keydown", (ev) => ev.key === "Escape" && close());
let page = 1;
const tried = new Set([opt.seed]);
let seq = 0;
const load = async (fallback = false): Promise<void> => {
const mine = ++seq;
try {
const q = search.value.trim();
const got = await fetchRows(FILES[opt.kind], page, SIZE, q);
if (mine !== seq) return; // 최신 응답만
if (!got.total && fallback) {
const next = [...(q.includes(" ") ? [q.split(/\s+/)[0]] : []), ...(opt.alt ?? [])].find(
(w) => w && !tried.has(w),
);
if (next) {
tried.add(next);
search.value = next;
return load(true);
}
}
const cols = COLS[opt.kind];
const body = got.rows.map((row) => {
const flat = flatten(row);
const tr = el("tr", {
className: `m01lab__pickrow${opt.isCurrent(row) ? " is-picked" : ""}`,
children: cols.map((c) =>
el("td", { text: show(flat[c]), attrs: { title: show(flat[c]) } }),
),
});
tr.addEventListener("click", () => {
close();
opt.onPick(row);
});
return tr;
});
host.replaceChildren(
buildPager(got.total, SIZE, got.page, (next) => {
page = next;
void load();
}),
el("div", {
className: "m01-master__grid-wrap",
children: [
el("table", {
className: "m01-master__grid",
children: [
el("thead", {
children: [el("tr", { children: cols.map((c) => el("th", { text: c })) })],
}),
el("tbody", { children: body }),
],
}),
],
}),
);
host.querySelector(".is-picked")?.scrollIntoView({ block: "nearest" });
} catch (error) {
showToast(error instanceof Error ? error.message : tx("Load_Failed"), "error");
}
};
let timer = 0;
search.addEventListener("input", () => {
window.clearTimeout(timer);
timer = window.setTimeout(() => {
page = 1;
void load();
}, 300);
});
document.body.append(back);
search.focus();
void load(true);
}
@@ -67,6 +67,11 @@ const TEXT = {
Pick_Title: ["요소 찾기", "Find element"],
Pick_Search: ["이름·키·원문번호", "Name · key · source no."],
Pick_Total: ["{n} 개 중 앞 100 개", "first 100 of {n}"],
Pick_Item: ["품목 고르기", "Pick item"],
Pick_Machine: ["기계 고르기", "Pick machine"],
Pick_MatTitle: ["자재품목 고르기", "Pick a material item"],
Pick_Chosen: ["고름", "Picked"],
Calc_UnitWarn: ["단위 다름", "Unit differs"],
Pick_Close: ["닫기", "Close"],
Pick_All: ["(전체)", "(All)"],
Mat_Title: ["쓰이는 재료·기계", "Materials and machines"],
+11 -8
View File
@@ -118,7 +118,7 @@
- **원문번호 칸 없음** — 원문(ESTX)이 준 품번 `M0005250505` 는 남의 번호라 이름 몫을 못 함 · 대조는 `_키대장.json` 에만 둠(조달청에서 새로 더한 줄의 조달청 번호도 같음). 로직·연결은 키로 적음.
- 유류·전력은 뗌 — 유가·전기요금은 `재료_유가전력`(유류는 값이 비고 글뿐 · 중유는 받지 않음).
- `재료_유가전력` · `환율_한국은행환율` 도 열 한 벌 — 키 · 원문번호 · 구분 · 상세구분 · 이름 · 규격 · 단위 · 값 · 출처 · 비고. 유가전력 `구분` = 유류 · 전력 · `상세구분` = 유류(지역 이름) · 전력(계약종별) · `규격` = 기준일(유류 수집일 · 전력 시행일). 환율은 `규격` = 기준일 · 구분·상세구분 비움.
- 로직 호표가 자재품목 키(`MT…`)를 바로 가리키면 그 줄의 값 열 가운데 낮은 값 — **시험 계산용**.
- 재료 줄 단가 = 고른 품목(없으면 자동값 줄)의 값 열 가운데 낮은 값 — **시험 계산용**.
- `재료_품셈재료`(PLAN 7-4b 뒤 절반 — `old/` 로 옮김 · 살아 있는 마스터엔 안 읽힘 · 자료로만 둠) = 품셈이 부르는 재료 목록이던 것 — `{ 키, 원문번호, 이름, 규격, 단위, 요구절: [출처 모양], 연결, 고르기 }` · 값 칸 없음. 로직 호표·덧줄의 `MP…` 직접 참조는 다 걷어 자재품목 고르기 조건(7장)이나 대표 키로 바꿈 — 아래 두 줄은 그 옮기던 때의 자취.
- `연결` = 자재품목·유가전력 키 하나 · 후보 조건 `{ 이름, 규격, 지역: "입력" }`(지역이 갈리는 레미콘·골재·아스콘) · `{ 이름, 계약종별: "입력" }`(전력 — 로직 입력 `전력 계약종별`) · `null`(못 이음). 이 지역·계약종별 조건 묶음은 이제 7장의 고르기 조건(`PICK_KEYS` 의 `지역`·`계약종별`)에 그대로 들어가 호표에 바로 박힘 — 품셈재료 간접 없이 `master_material._region`·`_tariff` 가 품.
- 단가 = 연결된 줄의 값 열 다섯 가운데 낮은 값 · 호표 결과 줄에 `출처`(`<키>.<열>`) — **시험 계산용**. 후보 조건 = 품명 뿌리가 `이름` 으로 끝남 · 이름에 로직 입력 `자재지역` 글자 · 단위 같음 · 규격이 둘 이상 갈리면 멈춤 「후보 여럿」 · 그 지역 줄이 없으면 가까운 시군을 고르지 않고 멈춤 「지역 값 없음」.
@@ -209,7 +209,7 @@
```json
{
"종류": "재료",
"요소": { "구분": "관·수로", "상세구분": "흄관", "규격": "D300", "대표": "MT012345" },
"요소": { "구분": "관·수로", "상세구분": "흄관", "검색어": "흄관 D300" },
"이름": "흄관",
"단위": "m",
"수량": "1",
@@ -218,17 +218,20 @@
}
```
- 조건 칸 = `구분`(반드시 — `지역`·`계약종별` 조건은 예외) · `상세구분` · `이름` · `규격` · `대표` · `지역`("입력" 만) · `계약종별`("입력" 만) · 그 밖의 칸은 두지 않음. `종류` 는 「재료」만.
- 조건 칸 = `검색어` · `구분` · `상세구분` · `이름` · `규격` · `지역`("입력" 만) · `계약종별`("입력" 만) · 그 밖의 칸은 두지 않음. `대표` 칸은 두지 않음. `종류` 는 「재료」만.
- `지역`·`계약종별` 조건(PLAN 7-4b 뒤 절반) = 구분·상세구분 없이 `이름` 하나로 자재품목(지역)·유가전력(계약종별)을 넓게 찾음 — 옛 품셈재료 `연결` 묶음과 같은 모양·같은 매칭(`master_material._region`·`_tariff`) · 로직 입력 `자재지역`·`전력 계약종별` 이 계산 때 좁힘 · 값은 `null`(지역·전력 마스터에 안 굳힘).
- 구분·상세구분 = 자재품목 줄의 같은 이름 칸과 똑같이 적음(묶음 목록은 `ref/_묶음_자재품목.md`).
- 규격 조건 = 원문이 규격을 밝힌 것만 좁힘 — 낱말이 **모두** 든 줄이 후보(「D300」 · 「D300 B종」) · 대소문자·빈칸·`φ` 는 안 가림. 원문이 규격을 안 밝히면 칸을 두지 않음.
- 이름 조건 = 구분·상세구분·규격이 같아도 품명이 갈리는 자재품목을 가를 때만 둠(예: 파형강관 커플링밴드 vs 접속티 — 둘 다 관경만 규격에 있어 규격 조건으로는 안 갈림). 규격과 같은 방식(낱말이 **모두** 이름에 든 줄) — 대소문자·빈칸은 안 가림.
- 대표 줄 = 관리자가 고른 자재품목 키 — 시험 계산이 그 줄의 값을 씀. 조건 밖 키는 검사가 잡음.
- 후보는 **호표 줄 단위와 같은 자재품목 줄만** — 단위가 다르면 다른 물건(우드칩 ㎥ · 원목후로링 ㎡). 조건 안 줄이 모두 다른 단위면 `check_master 단위` 가 알림.
- **키를 못 박음** — 재료 줄은 늘 요소 묶음 + `검색어`(「합판(내수) 12t 서울」) · 줄에 자재품목 키를 바로 적지 않음 · 재료 입력에 키 목록(`고르기`)을 두지 않음.
- 찾기 = 검색어 낱말(띄어쓰기로 나눔)이 **모두** 이름 또는 규격에 든 자재품목 줄 · 요소에 `구분` · `상세구분` 이 있으면 그 안에서만 · 단위는 같은 성격까지(아래 환산).
- 자동값 차례 = 값 있는 줄 → 줄 단위와 똑같은 단위 → 검색어 안 지역 낱말(서울 등)과 맞는 줄 → 키 차례 · 그 첫째.
- 고른 품목 = 계산 요청에만 실음(`_화면_계약.md` 10장 `고름`) · 로직 파일에 저장하지 않음 · 폐기 품목은 지움.
- 단위 환산 = 같은 성격만 — 무게 g · kg · ton(톤 · Ton) / 길이 mm · cm · m · km / 넓이 ㎠ · ㎡ / 부피 ㎤ · ℓ · ㎥ · 단가를 줄 단위로 바꿔 계산.
- 단위 경고 = 성격이 다르면 막지 않음 — 계산은 하되 `/calc` 줄에 `단위경고` · `check_master` 가 검색어 후보 0 · 단위 경고 줄을 셈.
- 조건 값에 `{입력}` 을 넣을 수 있음 — `"규격": "{관경}"` 은 계산 때 입력값으로 채워짐(검사에서는 그 칸을 뺌).
- 대표가 없으면 시험 계산은 **조건 안 값 있는 첫 줄** · 로직 입력 `자재지역` 이 있으면 이름에 그 지역이 든 줄부터(레미콘·골재처럼 지역이 갈리는 품목).
- 실제 줄 고르기는 프로젝트 몫 — 후보 목록은 `GET /api/m01/materials`(화면·테스트 컨테이너가 같이 씀).
- 옛 방식(자재품목 키를 호표에 바로 적은 `MT…`)도 그대로 돎 — 그 줄은 그 자재품목 줄 값을 바로 씀. 품셈재료 간접(`MP…`)은 PLAN 7-4b 뒤 절반에서 다 걷음(위 4장) — 남았던 22줄(색소·에폭시·버너·퓨즈 등)도 자재품목 새 줄이나 대표 키로 이음.
- 기계 = 입력값에 원문 분류번호(목록 밖 번호도 기계 마스터에 있으면 받음) · 기본값 = 원문 분류번호 목록 첫째 · 인력은 고르지 않음(지정값).
- 근거가 덜 선 줄은 `비고` 칸에 적음 — 「환산 근거 미확인」.
- 입력 칸 = `이름`(반드시) · `설명` · `단위` · `고르기`·`범위`(둘 중 하나까지) · `출처`(원문 절) · `비고` · `시험입력`은 줄 머리에 따로.
@@ -241,7 +244,7 @@
## 8. 변수 · 식
- 요소 — 키 `LB000123` · 값 묶음은 `.칸` — `EQ000045.주연료`.
- 입력·중간 값으로 요소를 고르면 `ID:원문번호` 에 `{이름}` 을 끼움 — `EQ:{기계}.가격` · `MO:경유:{지역}` · `LB:{조종원직종}`. 끼운 값이 키여도 됨 — `MT:{철선}` 의 고르기는 키 목록. 번호가 겹치는 표·로직은 `ID:원문번호` 로 못 부름 — 키로 적음.
- 입력·중간 값으로 요소를 고르면 `ID:원문번호` 에 `{이름}` 을 끼움 — `EQ:{기계}.가격` · `MO:경유:{지역}` · `LB:{조종원직종}`. 재료는 `MT:{…}` 로 고르지 않음 — 7장 검색어. 번호가 겹치는 표·로직은 `ID:원문번호` 로 못 부름 — 키로 적음.
- 표 찾기 — `찾기(QF000041, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공`
- 맞는 줄이 여럿이면 찾는 값 칸이 있는 줄만 봄. 값 칸·조건 이름에 괄호·빈칸이 있으면 작은따옴표 — `.'시공량(m)'` · `'규준틀 높이'=높이`
- 찾기·로직 인자의 글자 값은 작은따옴표 — `구분='V(다짐속도,km/hr)'` · 따옴표 없으면 이름(입력·중간).
+17 -2
View File
@@ -47,9 +47,9 @@
## 3. 시험 계산
`POST /calc` 받음 `{key, inputs: {이름: 값}, row?, file?}` — 새 로직은 `key` ""
`POST /calc` 받음 `{key, inputs: {이름: 값}, row?, file?, 고름?}` — 새 로직은 `key` "" · `고름` 은 10장
- 됨 — `{ok: true, lines: [{이름, 단위, 수량, 단가, 금액, 비목: {비목: 금액}, 출처?}], sums: {노무비, 재료비, 경비, 계}, middle: {이름: 값}}` · `출처` = 품셈재료 줄의 단가 칸
- 됨 — `{ok: true, lines: [{이름, 단위, 수량, 단가, 금액, 비목: {비목: 금액}, 출처?, 단위경고?}], sums: {노무비, 재료비, 경비, 계}, middle: {이름: 값}}` · `출처` = 재료 줄의 단가 칸 · `단위경고` 는 10장
- 돈 아닌 로직 — `{ok: true, result, middle}`
- 멈춤 — `{ok: false, reason}` (200). 까닭 = 엔진 글 그대로(「입력 「돌」 없음」 · 「값 없음 — 관리자가 채울 값」 …).
- `row` 없음 = 저장된 파일로 계산 · `row` 있음 = 그 로직만 고친 줄로 바꿔(없던 로직이면 `file` 에 더해) 메모리에서 계산 — 파일에 안 씀. 요소 값은 늘 저장된 파일.
@@ -192,3 +192,18 @@
- `줄` = `{차례, 로직, 메모, 이름, 원문번호, 결과단위, 구분, 상세구분, 출처, ok, 갈래, 노무비, 재료비, 경비, 계}` · 돈이 아닌 로직은 `갈래` 「결과」 와 `결과` 수 하나(비목이 없어 합계에 안 얹음).
- 못 도는 줄은 통째로 멈추지 않고 그 줄만 `ok` false · `까닭` · `멈춤` 에 한 줄 — 나머지 줄의 합계는 그대로 냄.
- 답 — 200 · 400(틀린 몸 · 이름 없음 · 모르는 소유) · 404(없는 키) · 409(낡은 판본) · 422 `{detail: {errors: […]}}`(검사 걸림 — 없는 로직 키 · 같은 로직 두 번 · 빈 조합 · 조합을 담음).
## 10. 재료 · 기계 고르기
재료 줄은 키 대신 기본 검색어로 품목을 찾음 — 파일 모양 · 찾기 규칙 · 자동값 차례 · 단위 환산은 `_틀.md` 7장.
| 무엇 | 모양 |
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| 고른 품목 | `/calc` · `/text` 몸에 `"고름": {"<호표 줄 차례>": "MT000019"}` · 저장 안 함 · 기계는 그 입력값에 원문 분류번호(목록 밖 번호도 기계 마스터에 있으면 받음) |
| 단위 경고 | 성격이 다르면 계산은 하되 `/calc` 줄에 `"단위경고": "줄 m · 자재 Ton"` · 화면은 그 줄에 딱지 |
| 모달 검색 | 재료 = 자재품목 표 · 검색 칸 = 검색어 첫 낱말(예 「합판」) · 잡힌 품목 칠함 / 기계 = 기계 표 · 검색 칸 = 기계 이름(예 「크레인(타이어)」) |
- `고름` 이 없거나 그 줄에 없으면 자동값(`_틀.md` 7장 차례의 첫째).
- 고르면 다시 계산 · 고른 품목은 파일에 저장하지 않고 계산 요청에만 실음.
- 드롭다운 고르기는 걷음 — 재료 줄 · 기계 입력의 「품목 고르기」 가 모달을 검색 칸 채워 엶(재료 컨테이너 표 재사용).
- 인력은 고르지 않음(지정값).
@@ -267,6 +267,7 @@ class Master:
if number in slot:
self.twice.add((key[:2], number))
slot.setdefault(number, row)
mp.EQ.set(self.numbers.get("EQ", {})) # 기계 입력이 목록 밖 원문 분류번호도 받게
def get(self, ref: str) -> dict:
tid, _, number = ref.partition(":")
@@ -502,7 +503,7 @@ def _inputs(row: dict, given: dict) -> dict:
pass
if isinstance(value, (int, float)):
value = Decimal(str(value))
if "고르기" in spec and value not in spec["고르기"]:
if "고르기" in spec and value not in spec["고르기"] and not mp.machine(spec, value):
raise FormulaError(f"입력 「{name}」={value} 고르기 밖")
if "범위" in spec:
low, high = spec["범위"]
@@ -547,8 +548,8 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
return {"결과": cut(row, value, "결과"), "중간": _shown(env)}
sums = dict.fromkeys(COST_ITEMS, Decimal(0))
lines = []
for item in row.get("호표", []):
source = why = None
for n, item in enumerate(row.get("호표", [])):
source = why = warn = None
try:
qty = evaluate(parse(item["수량"]), env, master, depth)
except EmptyInput as e: # 비어 있는 입력을 쓰는 줄만 비움
@@ -579,18 +580,10 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
lines.append(_blank_line(item, str(e)))
continue
# 후보 0 · 값 빈 품목은 그 줄만 단가·금액을 비우고 까닭을 닮 — 로직은 계속 · 합에서 뺌
# (없는 키는 틀린 로직 — 지금처럼 멈춤)
# (없는 키는 틀린 로직 — 지금처럼 멈춤) · 검색어 · 고름 · 단위 환산은 `master_pick`
if isinstance(ref, str) and qty != 0:
master.get(ref)
try:
price, source = element(master, ref, env, item.get("단위"))
why = (
None
if price is not None
else f"「{master.label(ref)}」 값 없음 — 관리자가 채울 값"
)
except FormulaError as e:
price, why = None, str(e)
price, source, why, warn = mp.priced(master, item, ref, env, n if depth == 0 else None)
if price is None and qty == 0:
price, why = Decimal(0), None # 안 쓰는 재료 줄 — 값이 비어도 금액 0
split = {item["비목"]: None if price is None else qty * price}
@@ -606,6 +599,7 @@ def run(master: Master, ref: str, given: dict, depth: int = 0) -> dict:
"비목": split,
**({"출처": source} if source else {}),
**({"까닭": why} if why else {}),
**({"단위경고": warn} if warn else {}),
}
)
# 덧줄이 호표 줄 금액 하나를 빼고 더할 수 있게 — `줄.'호표 줄 이름'`(빈 줄은 뺌)
@@ -853,3 +847,4 @@ from master_material import ( # noqa: E402, F401
element,
pick,
)
import master_pick as mp # noqa: E402 — 검색어 · 고름 · 단위
@@ -120,7 +120,9 @@ def static(cond: dict) -> dict:
def cond_text(cond: dict) -> str:
"""고르기 조건 한 글자 열쇠 — 「구분|상세구분|규격|대표」 · 화면 단가 미리보기가 줄을 찾는 이름."""
return "|".join(str(cond.get(k) or "") for k in PICK_KEYS)
return "|".join(str(cond.get(k) or "") for k in PICK_KEYS) + (
f"|{cond['검색어']}" if cond.get("검색어") else ""
)
def by_class(master: mf.Master) -> dict[str, dict]:
@@ -140,7 +142,10 @@ def candidates(
) -> list[str]:
"""고르기 조건 안 자재품목 키 — 구분 · 상세구분 · 이름 · 규격 낱말이 모두 든 줄 · 파일 차례대로.
`unit` 을 주면 그 단위(호표 줄 단위)와 같은 줄만 — 단위가 다르면 다른 물건.
입력 `자재지역` 이 있으면 이름에 그 지역이 든 줄만(그런 줄이 없으면 조건 안 전부)."""
입력 `자재지역` 이 있으면 이름에 그 지역이 든 줄만(그런 줄이 없으면 조건 안 전부).
조건에 `검색어` 가 있으면 기본 검색어 찾기(`master_pick`)."""
if cond.get("검색어"):
return mp.search(master, cond, env, unit)
want = [plain_spec(w) for w in str(cond.get("규격") or "").split()]
want_name = [plain(w) for w in str(cond.get("이름") or "").split()]
detail = cond.get("상세구분")
@@ -160,6 +165,8 @@ def candidates(
def pick(master: mf.Master, cond: dict, env: dict, unit: str | None = None) -> str:
"""시험 계산이 쓸 줄 — 관리자가 정한 `대표`, 없으면 조건 안 값 있는 첫 줄(단위 같은 줄만)."""
if cond.get("검색어"):
return mp.pick(master, cond, env, unit)
if cond.get("대표"):
return str(cond["대표"])
keys = candidates(master, cond, env, unit)
@@ -173,6 +180,8 @@ def pick(master: mf.Master, cond: dict, env: dict, unit: str | None = None) -> s
def check_unit(master: mf.Master, where: str, cond: dict, unit: str | None) -> list[str]:
"""단위 경고 — 호표 줄·품셈재료 줄 단위와 맞는 후보가 없거나 대표 줄 단위가 다름.
틀린 데이터가 아니라 좁혀야 할 조건이라 `check_pick` 과 따로 봄(`check_master 단위`)."""
if cond.get("검색어"):
return mp.check_warn(master, where, cond, unit)
cond = static(cond)
rows = master.index.get(MARKET, {})
rep = str(cond.get("대표") or "")
@@ -194,6 +203,8 @@ def check_pick(master: mf.Master, where: str, cond: dict, unit: str | None = Non
값에 `{입력}` 이 든 칸(관경 등)은 계산 때 채워지므로 검사에서 뺌.
지역·계약종별 조건은 구분 없이 이름 하나로 자재품목·유가전력을 넓게 찾음(_region · _tariff)
— 여기서는 이름 칸만 있는지 봄(후보 0 은 계산 때 판단)."""
if cond.get("검색어") is not None and not set(cond) - {*PICK_KEYS, "검색어"}:
return mp.check_zero(master, where, cond)
if set(cond) - set(PICK_KEYS):
return [f"{where} · 재료 고르기 조건 모양 「{cond}」 — {' · '.join(PICK_KEYS)}"]
if ("지역" in cond and cond["지역"] != "입력") or (
@@ -257,3 +268,4 @@ def element(
import master_formula as mf # noqa: E402 — 서로 부름 · 이 모듈 이름이 다 선 뒤에 읽음
import master_pick as mp # noqa: E402 — 검색어 찾기
@@ -0,0 +1,161 @@
# -*- coding: utf-8 -*-
"""재료 줄의 기본 검색어 찾기 · 고른 품목 · 단위 환산 · 단위 경고 (PLAN 8-1 계약).
`master_formula.run` 이 재료 줄 단가를 풀 때 부름 — 요소 객체에 `검색어` 가 있으면 여기서 찾고,
없으면 옛 길(`master_material` 조건 · 대표). 서로 부르는 모듈이라 `master_formula` 는 끝에서 읽음.
"""
from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar
from decimal import Decimal
import master_material as mm
# 「같은 성격」 단위 — {성격: {정규 단위: 기준 단위 몇 배}} (키는 `plain_unit` 을 거친 꼴)
UNITS = {
"무게": {"g": 1, "kg": 1000, "톤": 1000000},
"길이": {"mm": 1, "cm": 10, "m": 1000, "km": 1000000},
"넓이": {"cm2": 1, "m2": 10000},
"부피": {"cm3": 1, "ℓ": 1000, "m3": 1000000},
}
_SCALE = {u: (kind, n) for kind, group in UNITS.items() for u, n in group.items()}
REGIONS = (
"전국평균 전국 서울 경기 강원 충북 충남 전북 전남 경북 경남 부산 대구 인천 대전 울산 세종 제주 광주"
" 전남광주"
).split()
CHOSEN: ContextVar[dict | None] = ContextVar("고름", default=None) # {호표 줄 차례(0부터): 자재 키}
EQ: ContextVar[dict | None] = ContextVar("기계번호", default=None) # 기계 마스터 {원문번호: 줄}
@contextmanager
def choosing(picks: dict | None):
"""`/calc` · `/text` 몸의 `고름` — 그 요청 동안만 · 저장 안 함."""
token = CHOSEN.set({str(k): str(v) for k, v in (picks or {}).items()})
try:
yield
finally:
CHOSEN.reset(token)
def machine(spec: dict, value) -> bool:
"""기계 입력 — 고르기 목록 밖 원문 분류번호도 기계 마스터에 있으면 받음."""
known = EQ.get() or {}
picks = spec.get("고르기") or []
return bool(picks) and str(value).strip() in known and all(str(o) in known for o in picks)
# ── 단위 ───────────────────────────────────────────────────────────────
def kind_of(unit) -> str | None:
got = _SCALE.get(mm.plain_unit(unit))
return got[0] if got else None
def same_kind(a, b) -> bool:
"""단위가 같거나 같은 성격(무게 · 길이 · 넓이 · 부피)."""
pa, pb = mm.plain_unit(a), mm.plain_unit(b)
return pa == pb or (kind_of(a) is not None and kind_of(a) == kind_of(b))
def convert(price, have, want):
"""`have` 단위 단가 → `want` 단위 단가 — 같은 성격이 아니면 그대로."""
if price is None or not want or mm.plain_unit(have) == mm.plain_unit(want):
return price
if not same_kind(have, want) or kind_of(have) is None:
return price
return price * Decimal(_SCALE[mm.plain_unit(want)][1]) / Decimal(_SCALE[mm.plain_unit(have)][1])
# ── 찾기 ───────────────────────────────────────────────────────────────
def _words(term) -> tuple[list[str], list[str]]:
"""(이름·규격에 든 낱말, 지역 낱말) — 지역 낱말은 고르는 차례에만 쓰임."""
every = [mm.plain_spec(w) for w in str(term or "").split()]
region = [w for w in every if w in REGIONS]
return [w for w in every if w not in REGIONS], region
def search(master, cond: dict, env: dict | None = None, unit: str | None = None) -> list[str]:
"""검색어 안 자재품목 키 — 낱말이 모두 이름 또는 규격에 든 줄(요소의 구분 · 상세구분 안에서) ·
단위는 같은 성격까지(하나도 없으면 다 — 경고가 붙음) · 고르는 차례:
값 있는 줄 → 줄 단위와 똑같은 단위 → 지역 낱말(입력 `자재지역` 포함)과 맞는 줄 → 키 차례."""
words, region = _words(cond.get("검색어"))
region += [mm.plain(env[mm.REGION])] if (env or {}).get(mm.REGION) else []
rows = master.index.get(mm.MARKET, {})
hits = [
key
for _, name, spec, _, key in mm._market_index(master)
if all(w in name.replace("×", "x") or w in spec for w in words)
and (not cond.get("구분") or rows[key].get("구분") == cond["구분"])
and (not cond.get("상세구분") or rows[key].get("상세구분") == cond["상세구분"])
]
if unit:
kin = [k for k in hits if same_kind(rows[k].get("단위"), unit)]
hits = kin or hits
want = mm.plain_unit(unit) if unit else None
def rank(key):
one = rows[key]
name = mm.plain(one.get("이름"))
return (
not mm._offers(master, key),
bool(want) and mm.plain_unit(one.get("단위")) != want,
not (region and all(r in name for r in region)),
)
return sorted(hits, key=rank) # 정렬은 안정 — 남는 차례는 파일 차례
def pick(master, cond: dict, env: dict, unit: str | None = None) -> str:
keys = search(master, cond, env, unit)
if not keys:
raise mm.mf.FormulaError(f"재료 검색어 후보 없음 — {cond.get('검색어')}")
return keys[0]
def priced(master, item: dict, ref, env: dict, index: int | None):
"""재료 줄 단가 — (단가, 출처, 까닭, 단위경고). 고른 품목(`고름`)이 있으면 그것 · 검색어가 있으면 찾기 ·
없으면 옛 길. 자재 단가는 줄 단위로 환산 · 성격이 다르면 그대로 두고 경고."""
unit = item.get("단위")
picked = (CHOSEN.get() or {}).get(str(index)) if index is not None else None
try:
if picked or (isinstance(ref, dict) and ref.get("검색어")):
ref = picked or pick(master, ref, env, unit)
have = master.get(ref).get("단위") if ref[:2] == mm.MARKET else None
price, source = mm.element(master, ref, env, None)
warn = None
if have and unit and not same_kind(have, unit):
warn = f"줄 {unit} · 자재 {have}"
price = convert(price, have, unit) if have and unit else price
else:
price, source = mm.element(master, ref, env, unit)
warn = None
except mm.mf.FormulaError as e:
return None, None, str(e), None
why = None if price is not None else f"「{master.label(ref)}」 값 없음 — 관리자가 채울 값"
return price, source, why, warn
# ── 검사 ───────────────────────────────────────────────────────────────
def check_zero(master, where: str, cond: dict) -> list[str]:
"""검색어 후보 0 — `{입력}` 이 든 검색어는 계산 때 채워져 여기서 못 봄."""
term = str(cond.get("검색어") or "")
if not term.strip() or "{" in term or any("{" in str(v) for v in cond.values()):
return [] if term.strip() else [f"{where} · 검색어 비어 있음"]
return [] if search(master, cond) else [f"{where} · 검색어 「{term}」 후보 0"]
def check_warn(master, where: str, cond: dict, unit: str | None) -> list[str]:
"""단위 경고 줄 — 검색어 후보가 있으나 줄 단위와 같은 성격이 하나도 없음."""
term = str(cond.get("검색어") or "")
if not unit or "{" in term or "{" in str(cond.get("구분") or ""):
return []
keys = search(master, cond)
rows = master.index.get(mm.MARKET, {})
if keys and not any(same_kind(rows[k].get("단위"), unit) for k in keys):
return [f"{where} · 검색어 「{term}」 후보 단위가 줄 단위 「{unit}」 과 성격이 다름"]
return []
import master_formula as mf # noqa: E402, F401 — 서로 부름 · 이 모듈 이름이 다 선 뒤에 읽음
+114
View File
@@ -0,0 +1,114 @@
"""M01 재료 기본 검색어 · 고름 · 단위 환산 · 단위 경고 — PLAN 8-1 · 8-2 계약.
검색어 든 견본 로직 줄을 시험 안에서 만들어(`POST /calc` 의 `row` — 저장 안 함) 돌림. 정본 폴더는 읽기만.
"""
from __future__ import annotations
from decimal import Decimal
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from M01_MasterData import M01_MasterData_Router as router_module
from M01_MasterData import M01_MasterData_Store as store
mf = store.cm.mf
mp = mf.mp
@pytest.fixture(scope="module")
def client() -> TestClient:
app = FastAPI()
app.include_router(router_module.router)
return TestClient(app)
def _sample(term: str, unit: str, **cond) -> dict:
"""재료 줄 하나짜리 견본 로직 — 검색어 줄 하나 · 수량 1."""
return {
"이름": "검색어 견본",
"구분": "자체",
"입력": [],
"호표": [
{
"종류": "재료",
"요소": {"검색어": term, **cond},
"이름": "견본 재료",
"단위": unit,
"수량": "1",
"비목": "재료비",
}
],
}
def _line(client: TestClient, row: dict, picks: dict | None = None, path: str = "calc") -> dict:
body = {"key": "GF999999", "inputs": {}, "row": row, **({"고름": picks} if picks else {})}
got = client.post(f"/api/m01/{path}", json=body).json()
assert got.get("ok", True) is True, got
return got["lines"][0] if path == "calc" else got
def test_검색어로_찾고_지역_낱말이_맞는_줄이_먼저(client) -> None:
seoul = _line(client, _sample("합판(내수) 12t 서울", "㎡"))
assert seoul["출처"].startswith("MT000018") and seoul["단가"] == 11354 # 값 열 가운데 낮은 값
busan = _line(client, _sample("합판(내수) 12t 부산", "㎡"))
assert busan["출처"].startswith("MT000020") and busan["단가"] == 11488
assert "단위경고" not in seoul
def test_고름은_그_줄만_그_품목으로_셈(client) -> None:
row = _sample("합판(내수) 12t 서울", "㎡")
got = _line(client, row, {"0": "MT000019"})
assert got["출처"].startswith("MT000019") and got["단가"] == 11556
assert _line(client, row)["단가"] == 11354 # 요청마다 — 저장 안 함
text = _line(client, row, {"0": "MT000019"}, "text")
assert text["ok"] is True # 읽는 식도 같은 몸으로 돎
def test_같은_성격_단위는_줄_단위로_환산(client) -> None:
got = _line(client, _sample("어닐링철선 서울 4.0mm", "톤")) # 자재 kg → 줄 톤
assert got["단가"] == 1610000 and "단위경고" not in got
got = _line(client, _sample("어닐링철선 서울 4.0mm", "g"))
assert got["단가"] == pytest.approx(1.61)
def test_성격이_다르면_계산은_하고_단위경고(client) -> None:
got = _line(client, _sample("어닐링철선 서울 4.0mm", "m"))
assert got["단가"] == 1610 and got["단위경고"] == "줄 m · 자재 kg"
assert got["금액"] == 1610
def test_검색어_후보가_없으면_그_줄만_비움(client) -> None:
got = client.post(
"/api/m01/calc", json={"key": "GF999999", "inputs": {}, "row": _sample("없는물건zzz", "개")}
).json()
assert got["ok"] is True and got["lines"][0]["금액"] is None
assert "후보 없음" in got["lines"][0]["까닭"]
def test_기계_입력은_목록_밖_원문번호도_받음(client) -> None:
key = "GC000267"
inputs = {**store.auto(key)["값"], "기계": "0101-0007"} # 고르기 목록 밖 · 기계 마스터엔 있음
got = client.post("/api/m01/calc", json={"key": key, "inputs": inputs}).json()
assert got["ok"] is True, got
bad = client.post("/api/m01/calc", json={"key": key, "inputs": {**inputs, "기계": "9999-9999"}})
assert bad.json()["ok"] is False # 기계 마스터에 없는 번호는 그대로 막힘
def test_단위_환산_표() -> None:
assert mp.convert(Decimal(1610), "kg", "톤") == 1610000
assert mp.convert(Decimal(5), "Ton", "kg") == Decimal("0.005")
assert mp.convert(Decimal(100), "㎡", "㎠") == Decimal("0.01")
assert mp.convert(Decimal(7), "kg", "m") == 7 # 성격이 다르면 그대로
assert mp.same_kind("ℓ", "㎥") and not mp.same_kind("kg", "m")
def test_check_master_검색어_후보0_단위경고(client) -> None:
whole = store.loaded()[1]
assert mf.check_pick(whole, "줄", {"검색어": "합판(내수) 12t 서울"}) == []
assert "후보 0" in mf.check_pick(whole, "줄", {"검색어": "없는물건zzz"})[0]
assert mf.check_unit(whole, "줄", {"검색어": "어닐링철선 서울 4.0mm"}, "kg") == []
assert "성격이 다름" in mf.check_unit(whole, "줄", {"검색어": "어닐링철선 서울 4.0mm"}, "m")[0]