diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index 3658548f3..c0d63c847 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -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) => { diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css index 4e2d49a38..7be58350e 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Style.css @@ -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); +} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts index a41ee3caa..809d64f6a 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts @@ -88,6 +88,8 @@ export interface CalcLine { 비목: Record; /** 값 없는 요소를 다른 값으로 셈 — 「준용: <직종>」 · 「산정: …」 */ 출처?: string; + /** 자재 단위가 줄 단위와 성격이 달라 환산 못 하고 셈 — 「줄 m · 자재 Ton」 */ + 단위경고?: string; } export type CalcAnswer = | { @@ -227,6 +229,8 @@ export const runCalc = (body: { inputs: Record; row?: LogicRow; file?: string; + /** 시험 계산에서 고른 재료 — 호표 줄 차례 → 자재품목 키 · 저장 안 함 */ + 고름?: Record; }): Promise => request("/calc", body); export const runText = (body: { @@ -234,6 +238,8 @@ export const runText = (body: { inputs: Record; row?: LogicRow; file?: string; + /** 시험 계산에서 고른 재료 — 호표 줄 차례 → 자재품목 키 · 저장 안 함 */ + 고름?: Record; }): Promise => request("/text", body); export const saveFiles = ( diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts index 8f6258cfc..0f1fdd8f7 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Calc.ts @@ -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; dirty: () => boolean; /** 로직마다 넣은 값 — 다시 그려도 남음 */ values: Record; @@ -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("[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 => { @@ -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.금액) }), ], diff --git a/M01_MasterData/M01_MasterData_UI_Logic_CalcPick.ts b/M01_MasterData/M01_MasterData_UI_Logic_CalcPick.ts new file mode 100644 index 000000000..b24842fe5 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Logic_CalcPick.ts @@ -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, Map>(); +const picksOf = (values: Record): Map => { + let mine = chosen.get(values); + if (!mine) chosen.set(values, (mine = new Map())); + return mine; +}; + +/** 계산 요청 몸의 `고름` — 고른 것이 없으면 안 실음 */ +export function pickBody(values: Record): { 고름?: Record } { + 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, + values: Record, + 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(); +async function machineRow(code: string): Promise { + 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, + 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] }); +} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_PickTable.ts b/M01_MasterData/M01_MasterData_UI_Logic_PickTable.ts new file mode 100644 index 000000000..d093bd119 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_Logic_PickTable.ts @@ -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 = { 재료: "재료_자재품목.json", 기계: "기계.json" }; +/** 표에 보일 칸 — 컨테이너 표의 칸 이름(묶음 값은 「값›가격」) */ +const COLS: Record = { + 재료: [ + "키", + "구분", + "상세구분", + "이름", + "규격", + "단위", + "물가자료", + "유통물가", + "물가정보", + "거래가격", + ], + 기계: ["원문번호", "구분", "상세구분", "이름", "규격", "값›가격"], +}; +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 => { + 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); +} diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Text.ts b/M01_MasterData/M01_MasterData_UI_Logic_Text.ts index d7ebfe4e7..5ee23361d 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Text.ts @@ -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"], diff --git a/resources/master_data/_틀.md b/resources/master_data/_틀.md index c91af7307..b8ed909bc 100644 --- a/resources/master_data/_틀.md +++ b/resources/master_data/_틀.md @@ -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)'` · 따옴표 없으면 이름(입력·중간). diff --git a/resources/master_data/_화면_계약.md b/resources/master_data/_화면_계약.md index 1c40e2160..a6bf8e638 100644 --- a/resources/master_data/_화면_계약.md +++ b/resources/master_data/_화면_계약.md @@ -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장 차례의 첫째). +- 고르면 다시 계산 · 고른 품목은 파일에 저장하지 않고 계산 요청에만 실음. +- 드롭다운 고르기는 걷음 — 재료 줄 · 기계 입력의 「품목 고르기」 가 모달을 검색 칸 채워 엶(재료 컨테이너 표 재사용). +- 인력은 고르지 않음(지정값).