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:
@@ -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;
|
||||
|
||||
@@ -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 } : {}) });
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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. 시험 계산
|
||||
|
||||
Reference in New Issue
Block a user