feat(M01): 테스트 컨테이너 고르기 부품 공용화 — 구분 → 상세구분 → 후보 거름 · B·C 에도 재료 바꿔 보기

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WRFJmmhPi4exAzHgPZHMT
This commit is contained in:
2026-09-20 22:14:22 +09:00
co-authored by Claude Sonnet 5
parent 35588d9aa5
commit 03e6b6511e
8 changed files with 216 additions and 84 deletions
@@ -165,6 +165,9 @@ export const searchElements = (
): Promise<{ total: number; items: ElementBrief[] }> =>
request(`/elements?${query({ group, q, limit: "100" })}`);
export const searchPrice = (q: string): Promise<{ total: number; items: ElementBrief[] }> =>
request(`/pick?${query({ kind: "price", q, limit: "100" })}`);
export const fetchMaterials = (cond: {
sub: string;
detail?: string;
+5 -80
View File
@@ -7,23 +7,19 @@
import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements";
import {
fetchLogic,
fetchMaterials,
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 { hasPickable, pickPanel, swappedRow } from "./M01_MasterData_UI_Test_Pick";
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)
@@ -35,15 +31,13 @@ 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] : []),
...(hasPickable(logic) ? [{ kind: "pick" } as Step] : []),
{ kind: "result" },
];
const picker = pickPanel(one, swaps, () => undefined);
let at = 0;
const go = (next: number): void => {
@@ -127,67 +121,7 @@ function wizard(host: HTMLElement, one: LogicOne): void {
};
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);
},
});
// 재료 고르기 조건({구분, 상세구분, 규격}) 이면 새 API 로 조건 안 후보 · 아니면 이름 찾기
const cond = line. as unknown;
const found_ =
cond && typeof cond === "object"
? fetchMaterials({
sub: String((cond as Record<string, unknown>)["구분"] ?? ""),
detail: String((cond as Record<string, unknown>)["상세구분"] ?? ""),
spec: String((cond as Record<string, unknown>)["규격"] ?? ""),
region: "울진",
})
: searchElements(line., line. ?? "");
void found_
.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,
],
});
frame(tt("Mat_Title"), tt("Mat_Note"), [picker], true);
};
const drawResult = async (): Promise<void> => {
@@ -199,16 +133,7 @@ function wizard(host: HTMLElement, one: LogicOne): void {
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;
const row = swappedRow(logic, swaps); // 바꿔 본 것이 있으면 복사본으로만 셈
let answer: CalcAnswer;
try {
answer = await runCalc({ key: logic.키, inputs, ...(row ? { row, file: one.file } : {}) });
+32 -2
View File
@@ -26,6 +26,8 @@ import {
type NamedFormula,
} from "./M01_MasterData_UI_Logic_Api";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { hasPickable, pickPanel, swappedRow, type Swaps } from "./M01_MasterData_UI_Test_Pick";
import { tt } from "./M01_MasterData_UI_Test_Text";
/* ── 원문 표(소요량·계수) 미리보기 — /elements 로 파일을 찾고 /table 로 통째 읽음 ── */
@@ -35,6 +37,7 @@ interface TableRow {
이름?: string;
원문번호?: string;
출처?: string;
?: { 공종?: string; 대상?: string[]; 로직키?: string[] };
조건?: Record<string, string>;
값칸?: Record<string, string>;
줄?: Record<string, unknown>[];
@@ -218,13 +221,24 @@ interface State {
answer: CalcAnswer | null;
selected: number;
calcSeq: number;
swaps: Swaps;
picker: HTMLElement;
}
export function render(host: HTMLElement, logicKey: string): void {
host.replaceChildren(el("p", { className: "m01-logic__muted", text: "불러오는 중…" }));
void fetchLogic(logicKey)
.then((one) => {
const state: State = { one, values: {}, answer: null, selected: 0, calcSeq: 0 };
const swaps: Swaps = new Map();
const state: State = {
one,
values: {},
answer: null,
selected: 0,
calcSeq: 0,
swaps,
picker: pickPanel(one, swaps, () => void recalc(host, state)),
};
paint(host, state);
void recalc(host, state);
})
@@ -249,7 +263,7 @@ async function recalc(host: HTMLElement, state: State): Promise<void> {
const answer = await runCalc({
key: state.one.logic.키,
inputs,
row: state.one.logic,
row: swappedRow(state.one.logic, state.swaps) ?? state.one.logic,
file: state.one.file,
});
if (seq !== state.calcSeq) return; // 최신 응답만 채택
@@ -309,6 +323,14 @@ function paint(host: HTMLElement, state: State): void {
main.append(
el("h3", { text: `${row.} ${row.}` }),
inputWrap,
...(hasPickable(state.one.logic)
? [
el("details", {
attrs: { open: "" },
children: [el("summary", { text: tt("Mat_Title") }), state.picker],
}),
]
: []),
buildHoTable(host, state),
);
if (state.answer && !state.answer.ok) {
@@ -434,6 +456,14 @@ async function buildCard(side: HTMLElement, state: State): Promise<void> {
className: "m01-logic__muted",
text: `${r.source} · ${table. ?? table.} ${table. ?? ""} (${table. ?? ""})`,
}),
...(table.
? [
el("p", {
className: "m01-logic__muted",
text: `${tt("Use_Of")}${[table.., (table.. ?? []).join("·")].filter(Boolean).join(" · ")}`,
}),
]
: []),
el("table", {
className: "m01-logic__grid",
children: [
+14 -1
View File
@@ -23,6 +23,8 @@ import {
type FlowColumn,
type FlowKind,
} from "./M01_MasterData_UI_Test_C_Model";
import { hasPickable, pickPanel, swappedRow, type Swaps } from "./M01_MasterData_UI_Test_Pick";
import { tt } from "./M01_MasterData_UI_Test_Text";
import "./M01_MasterData_UI_Test_C_Style.css";
const TEXT = {
@@ -69,6 +71,8 @@ function mount(host: HTMLElement, one: LogicOne): void {
for (const spec of row. ?? []) {
if (spec.?.length) values[spec.] = String(spec.[0]);
}
const swaps: Swaps = new Map();
const picker = pickPanel(one, swaps, () => run());
const open = new Set<string>();
const subs = new Map<string, LogicRow>();
let answer: CalcAnswer | null = null;
@@ -178,7 +182,8 @@ function mount(host: HTMLElement, one: LogicOne): void {
inputs[spec.] =
option !== undefined ? option : Number.isNaN(Number(raw)) ? raw : Number(raw);
}
void runCalc({ key: row.키, inputs })
const swapped = swappedRow(row, swaps);
void runCalc({ key: row.키, inputs, ...(swapped ? { row: swapped, file: one.file } : {}) })
.then((got) => {
answer = got;
redraw();
@@ -208,6 +213,14 @@ function mount(host: HTMLElement, one: LogicOne): void {
? [el("p", { className: "m01c__bad", text: one.reasons.join(" · ") })]
: []),
stopped,
...(hasPickable(row)
? [
el("details", {
attrs: { open: "" },
children: [el("summary", { text: tt("Mat_Title") }), picker],
}),
]
: []),
el("div", {
className: "m01c__flow",
children: [inputColumn(row, values, later), rest],
@@ -0,0 +1,146 @@
/* =============================================================================
* M01_MasterData_UI_Test_Pick.ts
* 재료·기계 바꿔 고르기 부품 — 방식 A·B·C 가 같이 씀 · 구분 → 상세구분 → 후보 순으로 좁힘
* 바꾼 것은 시험 계산용 복사본에만 — 정본 로직은 그대로
* ========================================================================== */
import { createSelectField, el } from "@ui/ui_template_elements";
import {
fetchMaterials,
searchElements,
searchPrice,
type ElementBrief,
type HoLine,
type LogicOne,
type LogicRow,
} from "./M01_MasterData_UI_Logic_Api";
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
import { tt } from "./M01_MasterData_UI_Test_Text";
export type Swaps = Map<number, ElementBrief>;
const PICKABLE = ["재료", "기계"];
/** 고를 수 있는 줄이 있는지 */
export const hasPickable = (logic: LogicRow): boolean =>
(logic. ?? []).some((l) => PICKABLE.includes(l.));
/** 바꿔 본 줄이 있으면 고친 복사본 — 없으면 undefined(정본 그대로 셈) */
export function swappedRow(logic: LogicRow, swaps: Swaps): LogicRow | undefined {
if (!swaps.size) return undefined;
return {
...logic,
: (logic. ?? []).map((l, i) => {
const s = swaps.get(i);
return s ? { ...l, 요소: s.ref, 이름: s.이름 ?? l., 규격: s.규격 ?? l. } : l;
}),
};
}
type Brief = ElementBrief & { 구분?: string; 상세구분?: string };
const distinct = (items: Brief[], key: "구분" | "상세구분"): string[] => [
...new Set(items.map((i) => i[key]).filter((v): v is string => !!v)),
];
const label = (it: Brief): string =>
[`${it. ?? ""} ${it. ?? ""}`.trim(), formatNumber(it.)].filter(Boolean).join(" · ");
function pickRow(one: LogicOne, line: HoLine, i: number, swaps: Swaps, onChange: () => void) {
const cond =
typeof line. === "object" ? (line. as unknown as Record<string, unknown>) : null;
const price = cond ? undefined : one.prices[line.]?.;
let all: Brief[] = [];
let sub = "";
let detail = String(cond?.["상세구분"] ?? "");
const subBox = createSelectField({
options: [],
value: "",
compact: true,
onChange: (v) => ((sub = v), (detail = ""), fill()),
});
const detailBox = createSelectField({
options: [],
value: "",
compact: true,
onChange: (v) => ((detail = v), fill()),
});
const itemBox = createSelectField({
options: [{ value: "", text: tt("Mat_Default") }],
value: "",
onChange: (ref) => {
const item = all.find((it) => it.ref === ref);
if (item) swaps.set(i, item);
else swaps.delete(i);
onChange();
},
});
const subRow = el("div", {
className: "m01-test__filters",
children: [subBox.root, detailBox.root],
});
// 구분 → 상세구분 → 후보 — 앞 단을 고르면 뒷 단 목록이 좁혀짐(구분 칸이 없는 후보면 그 단은 숨김)
const fill = (): void => {
const subs = distinct(all, "구분");
const inSub = all.filter((it) => !sub || it. === sub);
const details = distinct(inSub, "상세구분");
const inDetail = inSub.filter((it) => !detail || it. === detail);
const all_ = { value: "", text: tt("Pick_All") };
subBox.root.hidden = subs.length < 1;
detailBox.root.hidden = details.length < 1;
subBox.setOptions([all_, ...subs.map((v) => ({ value: v, text: v }))], sub);
detailBox.setOptions([all_, ...details.map((v) => ({ value: v, text: v }))], detail);
itemBox.setOptions(
[
{ value: "", text: tt("Mat_Default") },
...inDetail.map((it) => ({ value: it.ref, text: label(it) })),
],
swaps.get(i)?.ref ?? "",
);
};
// 고르기 조건({구분, 상세구분, 규격}) 줄 = 조건 안 후보 · 옛 줄 = 이름으로 찾은 후보
const found = cond
? fetchMaterials({
sub: String(cond["구분"] ?? ""),
spec: String(cond["규격"] ?? ""),
region: "울진",
})
: line. === "재료"
? searchPrice((line. ?? "").split(/[\s(·]/)[0]) // 옛 줄 — 이름 첫 낱말로 자재품목(구분 붙음)
: searchElements(line., line. ?? "");
void found
.then((r) => {
all = r.items as Brief[];
if (cond) sub = String(cond["구분"] ?? "");
fill();
})
.catch(() => undefined);
const spec = line. ? ` ${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. ?? String(line.)}${spec}` }),
el("span", { className: "m01-test__muted", text: `${line. ?? ""}${cost}` }),
subRow,
itemBox.root,
],
});
}
/** 재료·기계 줄마다 한 칸씩 — 한 번 만들어 두고 다시 그릴 때 같은 노드를 씀(고른 것이 안 날아감) */
export function pickPanel(one: LogicOne, swaps: Swaps, onChange: () => void): HTMLElement {
const rows = (one.logic. ?? [])
.map((line, i) => ({ line, i }))
.filter((x) => PICKABLE.includes(x.line.))
.map((x) => pickRow(one, x.line, x.i, swaps, onChange));
return el("div", {
className: "m01-test__picks",
children: rows.length ? rows : [el("p", { text: tt("Mat_None") })],
});
}
@@ -103,3 +103,15 @@
background: var(--color-surface);
color: var(--color-danger, #c0392b);
}
.m01-test__picks {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
}
.m01-test__filters {
display: flex;
gap: var(--spacing-8);
flex-wrap: wrap;
}
@@ -34,6 +34,8 @@ const TEXT = {
],
Mat_None: ["이 공종은 재료·기계를 따로 고르지 않습니다", "No materials or machines to pick"],
Mat_Default: ["기본 (원문 그대로)", "Default (as in source)"],
Pick_All: ["(전체)", "(All)"],
Use_Of: ["이 표의 용도", "Used for"],
Mat_Price: ["단가", "Unit price"],
Result_Title: ["결과", "Result"],
Result_Stopped: ["계산이 멈춤", "Calculation stopped"],
+2 -1
View File
@@ -34,7 +34,8 @@
- `blocked` = 로직 검사(`check_master.py 로직`)에 걸림 · `reasons` = 걸린 까닭 글.
- `prices` = 호표 `요소``{ref, 이름, 규격, 단위, 값}`(단가 자동 칸) · 없는 요소는 `null` · `{이름}` 이 낀 원문번호·하위 로직 줄은 빠짐(계산 때 정해짐). 품셈재료는 `값` = 연결을 따라간 낮은 값(시험 계산용) · `출처` = 그 값의 열(`<자재품목 키>.<열>`) · 자재지역 후보는 `값` null(계산 때 정해짐).
- `elements` = 요소 찾기 창 — 그룹 전체에서 `q` 찾기 · `ref` = 식에 넣는 키 · 표형 그룹은 `값칸` 이 옴.
- `materials` = 재료 고르기 단계 — 로직 재료 줄 `요소` 가 조건 `{구분, 상세구분, 규격}` 이면 이 API 로 후보(방식 A). `값` 이 비어 있어도 후보로 둠.
- `materials` = 재료 고르기 단계 — 로직 재료 줄 `요소` 가 조건 `{구분, 상세구분, 규격}` 이면 이 API 로 후보(방식 A·B·C 가 부품 `M01_MasterData_UI_Test_Pick.ts` 하나를 같이 씀 · 구분 → 상세구분 → 후보 순으로 좁힘). `값` 이 비어 있어도 후보로 둠. 옛 재료 줄(품셈재료 키)은 이름 첫 낱말로 `pick?kind=price` 후보.
- 소요량·계수 표의 `용도`({공종, 대상, 로직키}) = 방식 B 원문 표 미리보기 머리에 안내 · 칸이 없으면 안 보임.
- `pick` = 고르기 창 — `price` = `재료_자재품목.json`(값 = 값 열 다섯 가운데 낮은 값 · `관급` = 관급 값 있음) ref 키 · `job` = `인력.json` 값 있는 공표 직종(구분 「미확보」 빼고) ref 키.
## 3. 시험 계산