diff --git a/M01_MasterData/M01_MasterData_UI_Combo.ts b/M01_MasterData/M01_MasterData_UI_Combo.ts index 93e428dc..791ffb66 100644 --- a/M01_MasterData/M01_MasterData_UI_Combo.ts +++ b/M01_MasterData/M01_MasterData_UI_Combo.ts @@ -12,12 +12,13 @@ import { showConfirmDialog, showToast, } from "@ui/ui_template_elements"; -import { fetchLogics, type LogicSummary } from "./M01_MasterData_UI_Logic_Api"; +import { ApiError, fetchLogics, type LogicSummary } from "./M01_MasterData_UI_Logic_Api"; import { deleteCombo, + editCombo, fetchCombo, fetchCombos, - saveCombo, + newCombo, type Combo, type ComboSummary, } from "./M01_MasterData_UI_Combo_Api"; @@ -32,13 +33,25 @@ const FILTER_ID = "조합|"; const blankCombo = (): Combo => ({ 키: "", 이름: "", - 구분: "", - 상세구분: "", - 단위: "", - 비고: "", - 로직: [], + 구분: null, + 상세구분: null, + 단위: null, + 담은로직: [], + 출처: "자체", + 소유: "현장", + 비고: null, }); +/** 서버가 준 까닭 — 422 는 검사 글 목록 · 409 는 낡은 판본 */ +function reason(error: unknown): string { + if (error instanceof ApiError && typeof error.detail === "object" && error.detail) { + const d = error.detail as { errors?: string[]; stale?: string[] }; + if (d.errors) return d.errors.join(" / "); + if (d.stale) return tc("Stale"); + } + return error instanceof Error ? error.message : tc("Load_Failed"); +} + export interface ComboHandle { /** 컨테이너를 펼칠 때 — 목록을 새로 받아 그림 */ show: () => Promise; @@ -71,13 +84,15 @@ export function mountM01Combo( /* --- 왼쪽 거름 --- */ const drawFilter = (): void => { - const subs = [...new Set(items.map((x) => x.구분).filter(Boolean))].map((name) => ({ - name, - book: null, - details: [...new Set(items.filter((x) => x.구분 === name).map((x) => x.상세구분))].filter( - Boolean, - ), - })); + const subs = [...new Set(items.map((x) => x.구분).filter((v): v is string => !!v))].map( + (name) => ({ + name, + book: null, + details: [...new Set(items.filter((x) => x.구분 === name).map((x) => x.상세구분))].filter( + (v): v is string => !!v, + ), + }), + ); filterHost.replaceChildren( side.filter({ id: FILTER_ID, @@ -99,7 +114,11 @@ export function mountM01Combo( el("div", { className: "m01-logic__section-head", children: [ - createButton({ label: tc("New"), variant: "filled", onClick: () => draw(blankCombo()) }), + createButton({ + label: tc("New"), + variant: "filled", + onClick: () => draw(blankCombo(), ""), + }), ...more, ], }); @@ -120,8 +139,8 @@ export function mountM01Combo( const tr = el("tr", { className: "m01-master__table-row", attrs: { "data-combo-key": x.키 }, - children: [x.키, x.이름, x.구분, x.상세구분, x.단위, String(x.로직수)].map((v) => - el("td", { text: v }), + children: [x.키, x.이름, x.구분 ?? "", x.상세구분 ?? "", x.단위 ?? "", String(x.count)].map( + (v) => el("td", { text: v }), ), }); tr.addEventListener("click", () => void open(x.키)); @@ -150,41 +169,46 @@ export function mountM01Combo( const open = async (key: string): Promise => { try { - draw(await fetchCombo(key)); + const one = await fetchCombo(key); + draw(one.combo, one.version); } catch (error) { showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); } }; /* --- 상세 --- */ - const draw = (combo: Combo): void => { + const draw = (combo: Combo, version: string): void => { const field = (label: string, name: "이름" | "구분" | "상세구분" | "단위" | "비고") => { const f = createInputField({ type: "text", label }); - f.input.value = combo[name]; + f.input.value = combo[name] ?? ""; f.input.setAttribute("data-combo-field", name); - f.input.addEventListener("input", () => (combo[name] = f.input.value)); + f.input.addEventListener("input", () => { + const v = f.input.value; + if (name === "이름") combo.이름 = v; + else combo[name] = v || null; + }); return f.root; }; - const preview = buildPreview(() => combo.로직); + const preview = buildPreview(() => combo); const table = el("div"); const drawTable = (): void => { const move = (i: number, d: number): void => { const j = i + d; - if (j < 0 || j >= combo.로직.length) return; - [combo.로직[i], combo.로직[j]] = [combo.로직[j], combo.로직[i]]; + if (j < 0 || j >= combo.담은로직.length) return; + [combo.담은로직[i], combo.담은로직[j]] = [combo.담은로직[j], combo.담은로직[i]]; drawTable(); }; - const trs = combo.로직.map((row, i) => { - const info = logics.get(row.키); + const trs = combo.담은로직.map((row, i) => { + const info = logics.get(row.로직); const memo = createInputField({ type: "text" }); - memo.input.value = row.메모; - memo.input.addEventListener("input", () => (row.메모 = memo.input.value)); + memo.input.value = row.메모 ?? ""; + memo.input.addEventListener("input", () => (row.메모 = memo.input.value || null)); const name = el("button", { className: "ui-btn ui-btn--ghost", - text: info?.이름 ?? row.키, + text: info?.이름 ?? row.로직, attrs: { type: "button", title: tc("Open") }, }); - name.addEventListener("click", () => onOpenLogic(row.키)); + name.addEventListener("click", () => onOpenLogic(row.로직)); const btns = el("div", { className: "m01-logic__chips", children: [ @@ -194,17 +218,17 @@ export function mountM01Combo( label: tc("Remove"), variant: "ghost", onClick: () => { - combo.로직.splice(i, 1); + combo.담은로직.splice(i, 1); drawTable(); }, }), ], }); return el("tr", { - attrs: { "data-combo-logic": row.키 }, + attrs: { "data-combo-logic": row.로직 }, children: [ el("td", { text: String(i + 1) }), - el("td", { text: row.키 }), + el("td", { text: row.로직 }), el("td", { children: [name] }), el("td", { text: info?.결과단위 ?? "" }), el("td", { children: [memo.root] }), @@ -241,7 +265,7 @@ export function mountM01Combo( onClick: () => openLogicPicker((x) => { if (!logics.has(x.키)) logics.set(x.키, x); - combo.로직.push({ 키: x.키, 메모: "", 입력: {} }); + combo.담은로직.push({ 로직: x.키, 메모: null }); drawTable(); }), }); @@ -251,12 +275,13 @@ export function mountM01Combo( onClick: async () => { if (!combo.이름.trim()) return showToast(tc("NeedName"), "error"); try { - const saved = await saveCombo(combo); + const key = combo.키 ? combo.키 : (await newCombo(combo)).key; + if (combo.키) await editCombo(combo.키, version, combo); showToast(tc("Saved"), "success"); await load(); - draw(saved); + await open(key); } catch (error) { - showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); + showToast(reason(error), "error"); } }, }); @@ -266,11 +291,11 @@ export function mountM01Combo( onClick: async () => { if (!combo.키 || !(await showConfirmDialog(tc("DeleteAsk")))) return; try { - await deleteCombo(combo.키); + await deleteCombo(combo.키, version); showToast(tc("Deleted"), "success"); await load(); } catch (error) { - showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); + showToast(reason(error), "error"); } }, }); diff --git a/M01_MasterData/M01_MasterData_UI_Combo_Api.ts b/M01_MasterData/M01_MasterData_UI_Combo_Api.ts index cb0518e5..c0ea8a02 100644 --- a/M01_MasterData/M01_MasterData_UI_Combo_Api.ts +++ b/M01_MasterData/M01_MasterData_UI_Combo_Api.ts @@ -1,34 +1,72 @@ /* ============================================================================= * M01_MasterData_UI_Combo_Api.ts * 일위대가 조합 화면이 부르는 서버 길 — 조합 마스터 `일위대가조합.json`(키 UA) - * 길 이름은 계약(PLAN 4-2)이 오면 이 파일만 맞춤 · 미리 보기는 로직마다 `/calc` 를 불러 화면에서 합침 + * 길 = `_화면_계약.md` 9장 * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { ApiError } from "./M01_MasterData_UI_Logic_Api"; -/** 조합에 담은 로직 한 줄 — 입력 = 그 로직을 셀 때 넣을 값 */ +/** 담은 로직 한 줄 — 로직 키와 메모뿐(수량·계산은 마스터에 두지 않음 · 계약 9장) */ export interface ComboLogic { - 키: string; - 메모: string; - 입력: Record; + 로직: string; + 메모: string | null; } +/** 조합 한 줄 통째 — `_틀.md` 10장 · 키·소유는 서버 것이 이김 */ export interface Combo { 키: string; 이름: string; - 구분: string; - 상세구분: string; - 단위: string; - 비고: string; - 로직: ComboLogic[]; + 구분: string | null; + 상세구분: string | null; + 단위: string | null; + 담은로직: ComboLogic[]; + 출처: string | null; + 소유: string | null; + 비고: string | null; } -export interface ComboSummary { - 키: string; +export interface ComboSummary extends Combo { + count: number; + blocked: boolean; + reasons: string[]; +} +/** `GET /combo` 의 `줄` — 담은 로직에 이름을 붙인 것 */ +export interface ComboLine { + 차례: number; + 로직: string; + 메모: string | null; + ok: boolean; + 까닭?: string; + 이름?: string; + 구분?: string; + 상세구분?: string; + 결과단위?: string; +} +export interface ComboOne { + file: string; + version: string; + combo: Combo; + 줄: ComboLine[]; +} +export interface PreviewLine { + 차례: number; + 로직: string; 이름: string; - 구분: string; - 상세구분: string; - 단위: string; - 로직수: number; + ok: boolean; + 까닭?: string; + 갈래?: string; + 결과?: number; + 노무비?: number; + 재료비?: number; + 경비?: number; + 계?: number; +} +export interface PreviewAnswer { + 줄: PreviewLine[]; + 노무비: number; + 재료비: number; + 경비: number; + 계: number; + 멈춤: string[]; } async function request(path: string, body?: unknown): Promise { @@ -46,11 +84,23 @@ async function request(path: string, body?: unknown): Promise { export const fetchCombos = (): Promise => request<{ combos: ComboSummary[] }>("/combos").then((d) => d.combos); -export const fetchCombo = (key: string): Promise => - request<{ combo: Combo }>(`/combo?key=${encodeURIComponent(key)}`).then((d) => d.combo); +export const fetchCombo = (key: string): Promise => + request(`/combo?key=${encodeURIComponent(key)}`); -/** 키가 비면 새 조합 — 서버가 다음 번호(UA…)를 줌 */ -export const saveCombo = (combo: Combo): Promise => - request<{ combo: Combo }>("/combo/save", { combo }).then((d) => d.combo); +export const newCombo = (combo: Combo): Promise<{ file: string; version: string; key: string }> => + request("/combo/new", { combo, owner: "현장" }); -export const deleteCombo = (key: string): Promise => request("/combo/delete", { key }); +export const editCombo = ( + key: string, + version: string, + combo: Combo, +): Promise<{ file: string; version: string }> => request("/combo/edit", { key, version, combo }); + +export const deleteCombo = (key: string, version: string): Promise => + request("/combo/delete", { key, version }); + +/** 보기만 — 수량(inputs)은 이 부름에만 실림 · 저장 안 함 */ +export const previewCombo = ( + combo: Combo | string, + inputs: Record>, +): Promise => request("/combo/preview", { combo, inputs }); diff --git a/M01_MasterData/M01_MasterData_UI_Combo_Preview.ts b/M01_MasterData/M01_MasterData_UI_Combo_Preview.ts index 99aa134f..becb0cf1 100644 --- a/M01_MasterData/M01_MasterData_UI_Combo_Preview.ts +++ b/M01_MasterData/M01_MasterData_UI_Combo_Preview.ts @@ -1,15 +1,15 @@ /* ============================================================================= * M01_MasterData_UI_Combo_Preview.ts - * 조합 미리 보기 — 담은 로직 저마다 입력값으로 `/calc` 를 불러 노무비·재료비·경비로 갈라 합침(보기만 · 저장 없음) + * 조합 미리 보기 — 담은 로직 저마다 받은 입력값을 서버 미리 보기(`POST /combo/preview`)에 실어 비목별 합계를 받음 + * 입력값(수량)은 이 화면 안에만 두고 조합에 저장하지 않음(계약 9장) * ========================================================================== */ import { createInputField, createSelectField, el } from "@ui/ui_template_elements"; -import { fetchLogic, runCalc, type LogicInput } from "./M01_MasterData_UI_Logic_Api"; -import type { ComboLogic } from "./M01_MasterData_UI_Combo_Api"; +import { fetchLogic, type LogicInput } from "./M01_MasterData_UI_Logic_Api"; +import { previewCombo, type Combo } from "./M01_MasterData_UI_Combo_Api"; import { tc, type ComboTextKey } from "./M01_MasterData_UI_Combo_Text"; const COSTS = ["노무비", "재료비", "경비"] as const; -type Costs = Record<(typeof COSTS)[number], number>; const LABEL: Record<(typeof COSTS)[number], ComboTextKey> = { 노무비: "Prev_Labor", 재료비: "Prev_Material", @@ -18,25 +18,28 @@ const LABEL: Record<(typeof COSTS)[number], ComboTextKey> = { const num = (v: string): number | string => (v.trim() !== "" && !isNaN(Number(v)) ? Number(v) : v); const fmt = (n: number): string => n.toLocaleString("ko-KR", { maximumFractionDigits: 4 }); -const total = (c: Costs): number => c.노무비 + c.재료비 + c.경비; -const blank = (): Costs => ({ 노무비: 0, 재료비: 0, 경비: 0 }); /** 로직 입력 정의 → 처음 값(고르기 첫째 · 범위 아래끝) */ const initial = (i: LogicInput): string => String(i.고르기?.[0] ?? i.범위?.[0] ?? ""); -/** 입력 칸 — 바꾸면 그 줄의 입력에 바로 적음(조합과 함께 저장됨) */ -function inputCell(input: LogicInput, row: ComboLogic): HTMLElement { - const value = row.입력[input.이름] ?? initial(input); - row.입력[input.이름] = value; +/** 입력 칸 — 바꾼 값은 `values`(이 화면 메모)에만 적음 */ +function inputCell( + input: LogicInput, + key: string, + values: Record>, +): HTMLElement { + const mine = (values[key] ??= {}); + const value = mine[input.이름] ?? initial(input); + mine[input.이름] = value; const choices = input.고르기?.map((v) => ({ value: String(v), text: String(v) })); const field = choices ? createSelectField({ options: choices, value, compact: true, label: input.이름 }) : createInputField({ type: "text", label: input.이름 }); const control = "select" in field ? field.select : field.input; control.value = value; - control.setAttribute("data-input", `${row.키}|${input.이름}`); - control.addEventListener("input", () => (row.입력[input.이름] = control.value)); - control.addEventListener("change", () => (row.입력[input.이름] = control.value)); + control.setAttribute("data-input", `${key}|${input.이름}`); + control.addEventListener("input", () => (mine[input.이름] = control.value)); + control.addEventListener("change", () => (mine[input.이름] = control.value)); return field.root; } @@ -46,8 +49,11 @@ export interface PreviewHandle { redraw: () => Promise; } -export function buildPreview(rows: () => ComboLogic[]): PreviewHandle { - const inputs = el("div", { className: "m01-logic__stack" }); +export function buildPreview(combo: () => Combo): PreviewHandle { + const values: Record> = {}; + /** 서버가 받을 꼴 — 원래 숫자·고르기 값으로 되돌림 */ + const inputs = new Map(); + const form = el("div", { className: "m01-logic__stack" }); const out = el("div", { attrs: { "data-combo": "preview" } }); const rowOf = (cells: string[], note = "", bold = false): HTMLElement => el("tr", { @@ -57,34 +63,43 @@ export function buildPreview(rows: () => ComboLogic[]): PreviewHandle { const run = async (): Promise => { out.replaceChildren(el("p", { className: "m01-logic__muted", text: "…" })); - const sum = blank(); - const trs: HTMLElement[] = []; - for (const row of rows()) { - const cost = blank(); - let name = row.키; - let note = ""; - try { - const one = await fetchLogic(row.키); - name = one.logic.이름; - const values: Record = {}; - for (const i of one.logic.입력) { - const raw = row.입력[i.이름] ?? initial(i); - if (raw.trim() === "") continue; // 빈 칸은 안 보냄(로직 화면과 같음) - values[i.이름] = i.고르기?.find((o) => String(o) === raw) ?? num(raw); - } - const got = await runCalc({ key: row.키, inputs: values }); - if (got.ok) { - for (const line of got.lines ?? []) for (const c of COSTS) cost[c] += line.비목?.[c] ?? 0; - } else note = `${tc("Prev_Fail")}: ${got.reason}`; - } catch (error) { - note = `${tc("Prev_Fail")}: ${error instanceof Error ? error.message : ""}`; + const send: Record> = {}; + for (const [key, defs] of inputs) { + send[key] = {}; + for (const i of defs) { + const raw = values[key]?.[i.이름] ?? initial(i); + if (raw.trim() === "") continue; // 빈 칸은 안 보냄(로직 화면과 같음) + send[key][i.이름] = i.고르기?.find((o) => String(o) === raw) ?? num(raw); } - for (const c of COSTS) sum[c] += cost[c]; - trs.push( - rowOf([`${row.키} ${name}`, ...COSTS.map((c) => fmt(cost[c])), fmt(total(cost))], note), - ); } - trs.push(rowOf([tc("Prev_Sum"), ...COSTS.map((c) => fmt(sum[c])), fmt(total(sum))], "", true)); + let got; + try { + got = await previewCombo(combo(), send); + } catch (error) { + out.replaceChildren( + el("p", { text: `${tc("Prev_Fail")}: ${error instanceof Error ? error.message : ""}` }), + ); + return; + } + const trs = got.줄.map((l) => + rowOf( + [ + `${l.로직} ${l.이름}`, + fmt(l.노무비 ?? 0), + fmt(l.재료비 ?? 0), + fmt(l.경비 ?? 0), + fmt(l.계 ?? 0), + ], + l.ok ? "" : `${tc("Prev_Fail")}: ${l.까닭 ?? ""}`, + ), + ); + trs.push( + rowOf( + [tc("Prev_Sum"), fmt(got.노무비), fmt(got.재료비), fmt(got.경비), fmt(got.계)], + "", + true, + ), + ); const heads = [tc("Prev_Row"), ...COSTS.map((c) => tc(LABEL[c])), tc("Prev_Sum")]; out.replaceChildren( el("table", { @@ -108,26 +123,28 @@ export function buildPreview(rows: () => ComboLogic[]): PreviewHandle { const redraw = async (): Promise => { const blocks: HTMLElement[] = []; - for (const row of rows()) { + inputs.clear(); + for (const row of combo().담은로직) { try { - const one = await fetchLogic(row.키); + const one = await fetchLogic(row.로직); + inputs.set(row.로직, one.logic.입력); blocks.push( el("div", { className: "m01lab__info", children: [ el("strong", { className: "m01lab__cell--wide", - text: `${row.키} ${one.logic.이름}`, + text: `${row.로직} ${one.logic.이름}`, }), - ...one.logic.입력.map((i) => inputCell(i, row)), + ...one.logic.입력.map((i) => inputCell(i, row.로직, values)), ], }), ); } catch { - /* 못 읽은 로직은 계산 때 까닭이 뜸 */ + /* 못 읽은 로직은 미리 보기에서 그 줄만 까닭이 뜸 */ } } - inputs.replaceChildren(...blocks); + form.replaceChildren(...blocks); out.replaceChildren(el("p", { className: "m01-logic__muted", text: tc("Prev_Wait") })); }; @@ -139,7 +156,7 @@ export function buildPreview(rows: () => ComboLogic[]): PreviewHandle { children: [el("h3", { text: tc("Prev_Title") })], }), el("h4", { text: tc("Prev_Inputs") }), - inputs, + form, button, out, ], diff --git a/M01_MasterData/M01_MasterData_UI_Combo_Text.ts b/M01_MasterData/M01_MasterData_UI_Combo_Text.ts index 4d6bfad1..60ca1532 100644 --- a/M01_MasterData/M01_MasterData_UI_Combo_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_Combo_Text.ts @@ -17,6 +17,7 @@ const TEXT = { DeleteAsk: ["이 조합을 지울까요?", "Delete this combination?"], Saved: ["저장함", "Saved"], Deleted: ["지움", "Deleted"], + Stale: ["그 사이 조합 파일이 바뀜 — 다시 열어 주세요", "The file changed — reopen it"], NeedName: ["이름을 적어야 저장됨", "A name is required"], Load_Failed: ["불러오지 못함", "Load failed"], Head_Key: ["키", "Key"], diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.css b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.css new file mode 100644 index 00000000..3a69e809 --- /dev/null +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.css @@ -0,0 +1,46 @@ +/* M01 로직 개선 시험 — 텍스트 수식 두 칸(왼쪽 이름 식 · 오른쪽 값 식) */ +.m01lab-fx { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + column-gap: var(--spacing-12); + min-width: 0; +} + +.m01lab-fx__head, +.m01lab-fx__row { + display: grid; + grid-template-columns: subgrid; + grid-column: 1 / -1; +} + +.m01lab-fx__head { + padding-bottom: var(--spacing-4); + border-bottom: 1px solid var(--color-border); +} + +.m01lab-fx__wide { + grid-column: 1 / -1; +} + +.m01lab-fx__row { + cursor: pointer; + border-radius: var(--radius-lg); +} + +.m01lab-fx__row.is-on, +.m01lab-fx__row.is-pin { + background: color-mix(in srgb, gold 35%, transparent); +} + +.m01lab-fx__cell { + min-width: 0; + padding: var(--spacing-4); +} + +@media (max-width: 600px) { + .m01lab-fx, + .m01lab-fx__head, + .m01lab-fx__row { + grid-template-columns: minmax(0, 1fr); + } +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts index 12b14727..c021fae0 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Formula.ts @@ -1,7 +1,8 @@ /* ============================================================================= * M01_MasterData_UI_LogicLab_Formula.ts * 「텍스트 수식」 컨테이너 — 서버(`POST /text`)가 준 줄을 그대로 찍음(화면에서 다시 계산하지 않음) - * 비목 묶음 제목 · 줄(`이름 : 글`) · 소계 · 끝수 한 마디 · 까닭 있는 줄은 옅게 + * 두 칸 — 왼쪽 = 변수 이름 식(`이름글`) · 오른쪽 = 값이 들어간 식(`글`) · 줄은 `짝` 번호로 같은 높이 + * 한쪽에 올리거나 누르면 같은 줄이 밝아짐 · 비목 제목 · 소계 · 끝수 한 마디 · 까닭 있는 줄은 옅게 * ========================================================================== */ import { el } from "@ui/ui_template_elements"; @@ -9,6 +10,7 @@ import type { TextAnswer } from "./M01_MasterData_UI_Logic_Api"; import { formatNumber } from "./M01_MasterData_UI_Logic_Edit"; import { tx } from "./M01_MasterData_UI_Logic_Text"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; +import "./M01_MasterData_UI_LogicLab_Formula.css"; export interface FormulaContext { text: TextAnswer | null; @@ -31,30 +33,57 @@ export function buildFormula(host: HTMLElement, ctx: FormulaContext): void { ); return; } + const heads = el("div", { + className: "m01lab-fx__head", + children: [ + el("strong", { text: tl("Formula_Left") }), + el("strong", { text: tl("Formula_Right") }), + ], + }); + const rows: HTMLElement[] = []; + const light = (pair: string, on: boolean): void => + rows.forEach((r) => r.dataset.pair === pair && r.classList.toggle("is-on", on)); const blocks = text.groups.flatMap((g) => [ - el("h4", { className: "m01lab__group", text: g.비목 }), - ...g.줄.map((line) => - el("div", { - className: `m01lab__text${line.까닭 ? " m01lab__text--off" : ""}`, - attrs: { "data-text-line": line.이름 }, + el("h4", { className: "m01lab__group m01lab-fx__wide", text: g.비목 }), + ...g.줄.map((line, i) => { + const pair = String(line.짝 ?? `${g.비목}${i}`); + const row = el("div", { + className: `m01lab-fx__row m01lab__text${line.까닭 ? " m01lab__text--off" : ""}`, + attrs: { "data-text-line": line.이름, "data-pair": pair }, children: [ - el("div", { text: `${line.이름} : ${line.글}` }), - ...(line.까닭 ? [el("div", { className: "m01-logic__muted", text: line.까닭 })] : []), + el("div", { className: "m01lab-fx__cell", text: line.이름글 ?? line.이름 }), + el("div", { + className: "m01lab-fx__cell", + children: [ + el("div", { text: `${line.이름} : ${line.글}` }), + ...(line.까닭 ? [el("div", { className: "m01-logic__muted", text: line.까닭 })] : []), + ], + }), ], - }), - ), + }); + row.addEventListener("mouseenter", () => light(pair, true)); + row.addEventListener("mouseleave", () => light(pair, false)); + row.addEventListener("click", () => { + const pinned = row.classList.toggle("is-pin"); + rows.forEach((r) => r !== row && r.classList.remove("is-pin")); + if (!pinned) row.classList.remove("is-pin"); + }); + rows.push(row); + return row; + }), el("p", { - className: "m01lab__subtotal-text", + className: "m01lab__subtotal-text m01lab-fx__wide", attrs: { "data-text-sub": String(g.소계) }, text: `${g.비목} ${tl("Subtotal")} ${formatNumber(g.소계)}${g.끝수 ? ` (${g.끝수})` : ""}`, }), ]); + blocks.unshift(heads); blocks.push( el("p", { - className: "m01lab__total", + className: "m01lab__total m01lab-fx__wide", attrs: { "data-text-sum": String(text.계) }, text: `${tl("Total")} ${formatNumber(text.계)}`, }), ); - host.replaceChildren(...blocks); + host.replaceChildren(el("div", { className: "m01lab-fx", children: blocks })); } diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts index 0fbfa52f..7fd3ca41 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Modal.ts @@ -16,6 +16,7 @@ import { type RelatedFind, type TableRow, } from "./M01_MasterData_UI_Logic_Note"; +import { jumpToMaster, type MasterJump } from "./M01_MasterData_UI_Side"; import { pillBar, type Pill } from "./M01_MasterData_UI_LogicLab_Pill"; import { tl } from "./M01_MasterData_UI_LogicLab_Text"; import "./M01_MasterData_UI_LogicLab_Pill.css"; @@ -72,6 +73,7 @@ function tableView( className: "m01-logic__muted", text: `${find.source} → ${find.find.col} · ${hit ? tl("Modal_Hit") : tl("Modal_NoHit")}`, }), + goButton({ kind: "table", key: table.키 }), el("div", { className: "m01-logic__scroll", children: [ @@ -93,6 +95,14 @@ function tableView( const groupOf = (ref: string): string => ref.startsWith("LB") ? "인력" : ref.startsWith("M") ? "재료" : ref.startsWith("EQ") ? "기계" : ""; +function goButton(target: MasterJump): HTMLElement { + return createButton({ + label: tl("Pill_Go"), + variant: "ghost", + onClick: () => jumpToMaster(target), + }); +} + function priceView(ref: string, brief: ElementBrief): HTMLElement { const cols = Object.entries(brief.값칸 ?? {}); return el("div", { @@ -114,6 +124,7 @@ function priceView(ref: string, brief: ElementBrief): HTMLElement { ...cols.flatMap(([k, v]) => [el("dt", { text: k }), el("dd", { text: String(v) })]), ], }), + goButton({ kind: "element", group: groupOf(ref), ref: brief.ref ?? ref, file: brief.file }), ], }); } @@ -153,7 +164,11 @@ export function openMaterialModal(target: ModalTarget): void { } else if (target.element && !logicRef) { pills.push({ label: `${groupOf(base)} ${target.title}`.trim(), - view: () => el("p", { text: target.element }), + view: () => + el("p", { + className: "m01-logic__muted", + text: `${target.element} — ${tl(target.element?.includes("{") ? "Pill_NoPick" : "Pill_NoValue")}`, + }), }); } if (logicRef) { diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts index 92387f43..91943b63 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab_Text.ts @@ -45,11 +45,20 @@ const TEXT = { ], Click_Hint: ["줄을 누르면 그 줄이 쓰는 자료가 뜸", "Click a line to see the data it uses"], Modal_Close: ["닫기", "Close"], + Formula_Left: ["변수 이름 식", "Formula with names"], + Formula_Right: ["값이 들어간 식", "Formula with values"], Modal_Sum: ["한 줄 풀이", "In one line"], Pill_Hint: ["쓰인 자료 — 누르면 펼침", "Data used — tap to open"], Modal_UnitPrice: ["단가", "Unit price"], Pill_Table: ["표", "Table"], Pill_Logic: ["로직", "Logic"], + Pill_Go: ["마스터 화면에서 보기", "Open in master"], + Pill_Back: ["← 로직 개선 시험으로 돌아가기", "← Back to logic lab"], + Pill_NoPick: [ + "고르기 조건에 맞는 마스터 줄이 없음 — 단가를 못 찾음", + "No master row matches the pick conditions", + ], + Pill_NoValue: ["관리자 값 미확보 — 마스터에 단가가 없음", "No price in master yet"], Modal_Qty: ["수량 식", "Quantity formula"], Modal_Price: ["단가 자료", "Price source"], Modal_Value: ["값", "Value"], diff --git a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts index b520087e..fc50d374 100644 --- a/M01_MasterData/M01_MasterData_UI_Logic_Api.ts +++ b/M01_MasterData/M01_MasterData_UI_Logic_Api.ts @@ -100,6 +100,10 @@ export type CalcAnswer = export interface TextLine { 이름: string; 글: string; + /** 같은 줄을 변수 이름으로 적은 식 — 왼쪽 칸 */ + 이름글?: string; + /** 줄 번호(답 전체에서 하나씩) — 두 칸 줄 맞춤 · 밝히기 */ + 짝?: number; 금액: number; 까닭?: string; } diff --git a/M01_MasterData/M01_MasterData_UI_Page.ts b/M01_MasterData/M01_MasterData_UI_Page.ts index ae12a8b3..08f2e216 100644 --- a/M01_MasterData/M01_MasterData_UI_Page.ts +++ b/M01_MasterData/M01_MasterData_UI_Page.ts @@ -20,7 +20,7 @@ import { createWorkflowOverlays } from "@ui/ui_template_overlay"; import { ROUTES } from "@config/config_frontend"; import { navigateTo } from "../A00_Common/router"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; -import { saveFiles } from "./M01_MasterData_Api_Fetch"; +import { saveFiles, type FileInfo } from "./M01_MasterData_Api_Fetch"; import { discard, isStale, onDraftChange, payload, totalCount } from "./M01_MasterData_Draft"; import { renderRows } from "./M01_MasterData_UI_Rows"; import { @@ -28,10 +28,14 @@ import { fileLabel, loadView, saveView, + setMasterJump, setPendingLogicKey, takePendingLogicKey, + type MasterJump, type Pick, } from "./M01_MasterData_UI_Side"; +import { loadTable } from "./M01_MasterData_UI_Logic_Note"; +import { tl } from "./M01_MasterData_UI_LogicLab_Text"; import { renderTables } from "./M01_MasterData_UI_Tables"; import type { LogicHandle } from "./M01_MasterData_UI_Logic_Page"; import type { ComboHandle } from "./M01_MasterData_UI_Combo"; @@ -227,6 +231,45 @@ function buildPage(): HTMLElement { }); }; + /** 알약 → 마스터 화면 — 찾기 글을 그 키로 두고 열고, 알림줄에 「시험으로 돌아가기」를 둠 */ + const jump = async (target: MasterJump): Promise => { + let group = target.kind === "element" ? target.group : ""; + let file: FileInfo | undefined; + let ref = ""; + if (target.kind === "table") { + const table = await loadTable(target.key); + group = + ["소요량", "계수"].find((g) => side.files(g).some((f) => f.file === table?.file)) ?? ""; + file = side.files(group).find((f) => f.file === table?.file); + ref = target.key; + } else { + const list = side.files(group); + file = list.find((f) => f.file === target.file) ?? list[0]; + ref = target.ref; + } + if (!file) return showToast(L("M01_LoadFailed"), "error"); + side.setActive(null); + openFile({ group, file, sub: "", detail: "", label: group, view: { q: ref, page: 1 } }); + const back = createButton({ label: tl("Pill_Back"), variant: "ghost" }); + back.addEventListener("click", () => { + showNotice([]); + openLabTab(); + }); + showNotice([back]); + if (target.kind === "table") { + const key = target.key; + const seen = new MutationObserver(() => { + const row = body.querySelector(`[data-key="${CSS.escape(key)}"]`); + if (!row) return; + seen.disconnect(); + row.click(); + }); + seen.observe(body, { childList: true, subtree: true }); + window.setTimeout(() => seen.disconnect(), 8000); + } + }; + setMasterJump((target) => void jump(target)); + /* --- 좌측: 컨테이너 --- */ const side = buildSide(openFile, () => openLogicTab(), openLabTab, openComboTab); diff --git a/M01_MasterData/M01_MasterData_UI_Side.ts b/M01_MasterData/M01_MasterData_UI_Side.ts index 1e30922d..4d586ae9 100644 --- a/M01_MasterData/M01_MasterData_UI_Side.ts +++ b/M01_MasterData/M01_MasterData_UI_Side.ts @@ -59,6 +59,16 @@ export function takePendingLogicKey(): string | null { } } +/** 「로직 개선 시험」 알약 → 마스터 화면 — 표(키) 또는 요소(그룹 · 키 · 파일) */ +export type MasterJump = + { kind: "table"; key: string } | { kind: "element"; group: string; ref: string; file?: string }; + +let masterJump: (target: MasterJump) => void = () => {}; +export const setMasterJump = (fn: (target: MasterJump) => void): void => { + masterJump = fn; +}; +export const jumpToMaster = (target: MasterJump): void => masterJump(target); + /** 요소 화면에서 열 것 — 파일 · 하위 거름(구분·세부분류) · 상세구분 · 제목 */ export interface Pick { group: string; @@ -82,6 +92,8 @@ export interface SideHandle { labHost: HTMLElement; /** 그룹의 파일 판본을 다시 받음(저장 뒤) — 돌려받는 것 = 새 목록 */ refresh: (group: string) => Promise; + /** 그룹의 파일 목록(이미 받은 것) */ + files: (group: string) => FileInfo[]; setActive: (id: string | null) => void; /** 좁은 화면에서 사용자가 거름을 다 골랐을 때 부를 것 — 패널을 접어 목록을 드러냄 */ setOnPicked: (fn: () => void) => void; @@ -456,5 +468,6 @@ export function buildSide( const setOnPicked = (fn: () => void): void => { onPicked = fn; }; - return { root, logicHost, comboHost, labHost, refresh, setActive, setOnPicked, filter }; + const files = (group: string): FileInfo[] => lists.get(group as Group) ?? []; + return { root, logicHost, comboHost, labHost, refresh, files, setActive, setOnPicked, filter }; } diff --git a/resources/master_data/ref/_검증_조합.md b/resources/master_data/ref/_검증_조합.md new file mode 100644 index 00000000..be73126d --- /dev/null +++ b/resources/master_data/ref/_검증_조합.md @@ -0,0 +1,20 @@ +# 검증 — 일위대가 조합 (일감 24) + +읽기 전용 · 시험용 복사본 폴더에서 서버 길(TestClient)로 만들고 지움 — 정본 `일위대가조합.json`(줄 0)·`_키대장.json` 은 안 건드림. + +## 어긋난 것 (2) + +1. **`메모` 칸으로 수량이 새 들어갈 수 있음** — 담은 로직 줄의 `메모` 에 글자가 아닌 값(숫자 `5` · 묶음 `{"수량": 3}`)을 보내도 200 으로 저장됨. 검사(`master_combo.check_row`)는 칸 이름(로직 · 메모)만 보고 `메모` 값의 모양은 안 봄 → 4-0(조합에 수량·계산 없음)의 뒷문. 재현: `POST /combo/new` 에 `담은로직: [{"로직": "GF000219", "메모": {"수량": 3}}]`. 고칠 곳: `check_row` 에서 `메모` 는 글자 또는 null 만. `check_master` 도 같은 함수를 써서 같이 잡힘. +2. **`이름` 이 글자가 아니어도 저장됨** — `이름: ["x"]` 가 200 으로 저장(`UA000005`) · `check_combo_form` 도 `str()` 로 감싸 통과. 고칠 곳: 이름은 글자만(서버 400 · 틀 검사). + +참고(계약대로라 어긋남 아님 · 확인만): `구분` · `단위` 를 안 주면 null 로 저장됨 — 미리 보기 `결과단위` 없는 조합이 생길 수 있음. + +## 통과한 것 + +- ① 만들기 → 읽기 → 고치기 → 지우기: 키는 서버가 `UA` 다음 번호로 줌(지운 번호는 다시 안 씀) · 열 한 벌(키·이름·구분·상세구분·단위·담은로직·출처·소유·비고)이 그대로 저장 · 고치기로 로직 빼기 · 지우면 줄 0. `키`·`소유` 를 몸에 실어 보내도 서버 것이 이김. +- 수량·계산 칸: 조합 위쪽 여분 칸(`수량` · `계산` · `비율`)은 200 이되 **저장되지 않고 버려짐** · 담은 로직 줄의 여분 칸(`수량` · `계산`)은 422(「모르는 칸」). +- ② 검사: 없는 로직 키(422 「없는 요소」) · 모양 틀린 키 · 같은 로직 두 번(줄 번호 표시) · 빈 조합 · `담은로직` 없음·null · 조합이 조합을 담기(`UA…`) · 로직 아닌 키(`EA…`) · 목록 아님 · 줄이 묶음 아님 · 이름 공백 400 · 모르는 소유 400 · 없는 키 404 — 모두 막힘. 거절된 요청은 파일·키 번호를 바꾸지 않음(같은 번호가 다시 나옴). +- 낡은 판본: 고치기·지우기 모두 409 `{stale: ["일위대가조합.json"]}` · 거절된 고치기 뒤 판본 그대로. +- 담은 로직이 나중에 사라지면: 조회는 200 + `blocked` true + 까닭 · 미리 보기는 그 줄만 멈추고 나머지 합계는 냄. +- ③ 미리 보기 합계: 로직 셋(GF000219 산림 찾기 · GC000996 덧줄 있음 · GC000994 로직 부르기 있음)을 담아 각 로직의 시험 계산과 비목별로 대조 — 줄마다 노무비·재료비·경비가 같고, 합계도 노무·재료·경비 비목별 합과 같음(계 729,595.598). 저장 전 조합 줄로 보내도 같은 합계 · 미리 보기는 저장하지 않음(줄 수 그대로). 입력에 없는 이름을 주면 그 줄만 「입력 … 없음」으로 멈춤. `/logic/combos` 거꾸로 찾기 · 목록의 `count` · 소유·찾기 거름도 맞음. +- ④ `check_master`: 조합 검사가 들어 있음(`조합` 모드 · `전부` 모드에 포함 · 틀은 `check_combo_form`) — 정본에서 틀 0 · 로직 0 · 조합 0건. 시험 `test_m01_combo.py` 17 통과. diff --git a/resources/master_data/ref/_목록.md b/resources/master_data/ref/_목록.md new file mode 100644 index 00000000..819aaf65 --- /dev/null +++ b/resources/master_data/ref/_목록.md @@ -0,0 +1,41 @@ +# ref/ 문서 목록 (2026-09-22) + +`resources/master_data/ref/` 검증·조사 문서 35개 — 파일마다 무엇을 잰 것 · 결론 한 마디 · 열림(더 볼 것 있음)/닫힘(기록만 남음). 새 문서를 더할 때 이 표에 한 줄 보탤 것. + +| 파일 | 잰 것 | 결론 | 상태 | +|---|---|---|---| +| `_검증_관급_가까운줄.md` | 관급 채우기 355→108→112, 값이 가까운 55줄이 같은 물건인지 | 51줄 확정 · 소형고압블록·KP접합부속 등 112줄은 확정 거리 | 열림 → `_확정거리_관급.md` | +| `_검증_기계_용도.md` | 기계.json 654줄·소요량·계수 표 로직키 전수 | 예외 1건(공종 칸에 코드 그대로) 뿐, 대체로 원문과 일치 | 닫힘 | +| `_검증_로직_변환.md` | 커밋 1bcc957e(품셈재료 고르기 일괄 변환) 앞뒤 대조 | 단위 섞인 후보 271줄 있으나 대표 줄만 써 당장 금액 오류 아님 | 닫힘(참고용) | +| `_검증_로직개선_사용자경로.md` | ORCA 로 로직 컨테이너 화면 사용자 경로 시험 | 텍스트 수식 소계 헷갈림 등 화면 문제 9건 적음 | 열림(화면 담당 몫) | +| `_검증_별칭_복사.md` | 「복사해서 만들기」 별칭 자동 붙이기 40개 표본 | 어긋남 1건(이름 겹침 검사 없음) 찾아 고침 · 재검증 0건 | 닫힘 | +| `_검증_시험입력.md` | 로직 65줄 「시험입력」 칸 값·계산전부 재현 | 원문 첫 값 아닌 것 몇 건 · sub1 보고(84)와 지금(82) 갈래 수가 다름 | 열림 | +| `_검증_식_해석기.md` | 3-3 식 해석기(`master_draft.py`) 견본 14개 | 옮기기·검사·저장 전부 뜻대로 동작, 어긋남 없음 | 닫힘 | +| `_검증_요소_화면.md` | 요소 화면(M01) 거름·클릭 이동 조작 | 깨진 곳 2건 찾아 고침 확인 · 나머지 이상 없음 | 닫힘 | +| `_검증_자재품목_분류.md` | 재료_자재품목.json 구분·상세구분 표본(97구분×15줄) | 오분류 4건 · 애매 항목은 개별 판단 필요 | 열림 | +| `_검증_재료_공공출처.md` | 안티그래비티 조사 12건 실제 대조 | 이름 글자만 겹치는 5종(에폭시 등) — 마스터엔 안 넣음 | 닫힘 | +| `_검증_테스트_컨테이너.md` | 로직 10개를 방식 A·B·C(옛 편집·복사·컨테이너)로 계산 대조 | 세 방식 다 같음(요약) · 입력 이름 설명 대기 목록 남음 | 열림 | +| `_검증_텍스트_수식.md` | `/text` 로직 1,291개 전수(비목 나눔·몫·수량0 등 4항목) | 어긋남 83줄(몫 나누기) 찾아 고침 → 재대조 1,291개 전부 0 | 닫힘 | +| `_검증_품셈재료_고르기.md` | 품셈재료 고르기 채워진 142줄 전수, 대표 줄 자재품목 대조 | 대표 줄이 다른 물건 1건·규격 표기 어긋남 등 여러 건 적음 | 열림 | +| `_검증_이름식.md` | `/calc`·`/text` 이름 식·값 식 짝 로직 27개(일감20) | 찾기() 별칭 이름 라벨 어긋남 2건 → 원인 고침 · 로직 1,291개 재검사 0건 | 닫힘 | +| `_관리자값_미확보.md` | 계산전부가 멈추는 「값 없음」 재료 45종 자동 목록 | 새로 채운 값 0(공공출처 재대조해도 못 찾음) · 후보만 있는 것 8건 | 열림 | +| `_남은_옛재료줄.md` | 로직이 품셈재료·자재품목을 바로 가리키는 옛 방식 줄 307→178 | 갈래별(짝없음139·지역17·변수22) 정리 · 129줄은 고르기 조건으로 바꿈 | 열림 → `_품셈재료_삭제조건.md` | +| `_단위_환산_원문.md` | 단위 환산 필요 18줄의 원문(건설·산림 표준품셈) 값 대조 | 본문에서 실제 본 값만 적음 · 확정·적용은 문서 밖 | 열림 | +| `_단위_환산_필요.md` | 단위 안 맞아 고르기 칸이 빈 품셈재료 | 18줄(환산 필요) + 흄관·VR관 별도 정리 | 열림 | +| `_묶음_기계.md` | 기계.json 654줄 구분·상세구분 집계 | 구분 11·상세구분 174 표(참고용, 손으로 안 고침) | 닫힘 | +| `_묶음_자재품목.md` | 재료_자재품목.json 30,592줄 구분·상세구분 집계 | 구분 24·상세구분 100·미분류 25 표(참고용) | 닫힘 | +| `_분석_시설자재가격_모래.md` | 시설자재가격 옛 스냅샷 대 2022하 자료 · 모래 단위중량 | 두 자료 식별번호 안 이어짐 · 품명 조건 조회 방식 임의 결정 | 닫힘 | +| `_비교_estx.md` | 다산 ESTX 2026-07 대 마스터 값 전수 비교 | 값 베낀 흔적 없음 · 미공표 직종 값도 ESTX 에 없음 | 닫힘 | +| `_설계_관급단가_갱신.md` | 조달청 API 로 관급 단가 갱신 설계·실행(355→108) | 분류명 글자로 받을 수 있음 확인 · 남은 108(→112) 갈래별 정리 | 열림 → `_확정거리_관급.md` | +| `_설계_로직_만들기.md` | 빈 화면에서 로직 새로 만드는 화면 설계 | 방식 C 기본 · [새로 만들기] 모달 방식 A 안 · 견본 시험 통과 | 열림(화면 구현은 딴 창) | +| `_설계_식_복사.md` | 「복사해서 만들기」 규칙·수치 근거(찾기·로직 1,354줄 전수) | 복사본은 원본을 안 따라감(스냅숏) 등 설계 확정 | 열림(화면 구현은 딴 창) | +| `_점검_나라장터.md` | 재료_나라장터자재 6,999줄 원본 대조 | 표본 전부 원본과 같음(스텐밴드 앞공백 표기 차만) | 닫힘 | +| `_점검_없는기계.md` | 로직 비고 「기계 요소 없음」 141건 | 기종 되살려 채움 끝 · 원문에 없는 줄은 그대로 둠 | 닫힘 | +| `_점검_자체.md` | 인력_자체 57줄·재료_자체 366줄 내부중복·원문대조 | 불일치 4건 기록(예: "이형철근" 원문은 "철근") | 닫힘 | +| `_점검_자체_짝짓기.md` | 재료_자체 → 재료_품셈재료 연결 짝짓기 | 짝 있는 것 표로 정리 · 없는 것 방대한 목록 | 닫힘(뒤이어 `_없는_재료.md`·`_관리자값_미확보.md` 로 이어짐) | +| `_조사_레미콘_규격.md` | 레미콘 규격(골재-강도-슬럼프) 무근·철근 흔한 값 조사 | 무근 25-18-080·철근 25-21-120 추천 · 로직에 선택 칸 신설 제안 | 열림(사용자 확정 거리) | +| `_지역_갈림_재료.md` | 지역·계약종별로 값 갈리는 재료(레미콘·아스콘·골재 등) | 지역 칸은 안 만들고 로직 입력(자재지역)으로 정리 끝 | 닫힘 | +| `_첫조합_없는로직.md` | 로직 1,354개를 화면 첫 조합으로 돌려 「맞는 줄 0개」 찾기 | 53→51(2건 고침) · 나머지는 화면 안내로 풀 것 | 열림(화면 담당 몫) | +| `_없는_재료.md` | 자재품목에 이름조차 없는 품셈재료 111줄 | 공공출처 재대조해도 후보 있는 5건 뺀 106건은 못 채움 | 열림 | +| `_품셈재료_삭제조건.md` | 품셈재료 테이블을 지울 수 있는 상태인지(옛 방식 178줄) | 갈래별(139·17·22) 「지우려면 무엇이 있어야」 정리 | 열림 | +| `_확정거리_관급.md` | 관급 남은 112줄의 사용자 확정 거리 한 곳에 모음(일감23) | 소형고압블록·KP접합부속·레미콘 오기·중온아스콘 갈래별 정리 | 열림 | diff --git a/resources/master_data/ref/_확정거리_관급.md b/resources/master_data/ref/_확정거리_관급.md new file mode 100644 index 00000000..fd6ec8e9 --- /dev/null +++ b/resources/master_data/ref/_확정거리_관급.md @@ -0,0 +1,52 @@ +# 관급 값 채우기 — 사용자 확정 거리 모음 (2026-09-22) + +관급 채우기 355 → 108 → (일감21 접속티 4 추가) → **남은 112줄**(`_검증_관급_가까운줄.md` 일감19~22 종합). 값은 이번에도 안 고침 — 갈래마다 「무엇을 정하면 몇 줄이 채워지나」 만 정리. + +| 갈래 | 남은 줄 | 결정하면 채워지는 줄(최대) | +|---|---|---| +| ① 소형고압블록 | 49 | 최대 11(근거 강도 차) — 아래 | +| ② KP 접합부속 | 17 | 최대 17(관경별 압륜 후보 있는 것만) — 아래 | +| ③ 레미콘 부산 25-3-150 | 1 | 1(단순 오기 확인이면) | +| ④ 중온 아스콘 지역 묶음 | 28 | 0(자료 자체가 없음 — 문구만 정정) | +| ④ 그 밖 13(레미콘 뺀 나머지) | 13 | 0~3(아래 「그 밖」 참고) | +| (별도) 충북 U형 8cm 회색 비고 없음 | 1 | 0(다산 값 자체가 없어 결정과 무관) | +| (별도) 접속티 PE내면·양면 4 | — | 이미 일감21에서 null+까닭 반영 · 결정 대상 아님 | + +## ① 소형고압블록 49줄 — 확정 거리 셋 + +1. **기준 — 지역가(다산 지역별 값) vs 전지역가(나대영 전지역 공급가)** 어느 쪽을 관급 기준으로 볼지. 이미 채운 회색 14줄이 나대영 최저가를 써서 다산 지역가와 **-10~-29%** 벌어짐(전북만 0%) — 기준을 정하면 이 14줄도 다시 봐야 함. +2. **나대영 U102·U104(색 표기 없음)를 적색으로 볼지.** 전북 적색 6cm·8cm 2줄(MT009980·82)이 U102·U104 값과 **정확히 같음** — 맞다고 보면 이 2줄은 바로 채워짐. +3. **청원콘크리트 11,110(경기 12개 시 한정)을 다산 「경기도」 전체에 쓸지.** 경기도 녹·백·청 6cm 3줄(MT009942~44)이 청원 컬러 값과 **정확히 같음** — 공급 범위(12개 시)가 다산 「경기도」 전체보다 좁아 확정 필요. + +숫자로 — ②+③ 그대로 인정하면 **5줄**(전북 2 + 경기 3) 정확히 채워짐. 거기에 ①까지 인정해 U102·U104 값을 ±5% 근사로 다른 지역에도 쓰면(대구경북 6cm·8cm 2 · 경기 8cm 별도값 없음 · 대전충남 6cm 1 · 광주전남 6cm 1 · 충북 8cm 1 = 근사 6줄) **최대 11줄**. 나머지 38줄(광주전남 컬러값 11~20% 차 · 조달청에 그 지역·색 자체가 없는 줄)은 셋 다 정해도 못 채움. + +## ② KP 접합부속 17줄 — 확정 거리 하나 + +**다산 「KP 접합부속」이 압륜 한 벌인지, 고무링 포함 세트인지.** 조달청엔 그 이름의 세트가 없고 「압륜」(동명주물 등)만 있는데, 값이 다산 세트값의 **1.7~2.5배**(D80 33,800 vs 17,330 · D500 220,000 vs 89,770) — 압륜 단독이라 확정해도 이 배수 차이의 까닭(세트 구성이 다른지 · 다산이 할인 단가인지)은 따로 규명해야 함. + +숫자로 — 확정해도 D80~D700 대는 압륜 후보가 있어 값을 옮길 수 있는 자리가 최대 17줄이지만, **D900 이상은 압륜 후보 자체가 적음**(정확한 관경별 개수는 이번에 안 셈) — 실제 채워지는 수는 17보다 적을 수 있음. + +## ③ 레미콘 부산 25-3-150(MT021211) 1줄 — 확정 거리 하나 + +다산 값 122,560 이 조달청 **25-33-150** 값과 정확히 같음(25-30-150 은 119,000 으로 다름) — **25-3-150 을 25-33-150 의 오기로 볼지** 확정하면 1줄 바로 채워짐. 강도 30·33·35 어느 것도 다산 글자만으로는 못 가려 오기가 아니라면 채울 수 없음. + +## ④ 중온 아스콘 지역 묶음 28줄 — 결정해도 0줄 + +세종·충남 4개 시(10줄) · 전북 전주권(6줄)은 그 지역을 묶은 중온 조달청 줄 자체가 없음 — 결정할 거리가 아니라 **자료 없음**. 전북 정읍권(6줄)·남원권(6줄)은 지역은 있으나 **3등급 그 규격(BB-3·4·WC-1·3·5·6) 줄이 없음**(1등급만 있거나 아예 없음) — 등급 다른 1등급으로 채우면 다른 물건이라 못 채움. **확정할 것은 값이 아니라 문구뿐** — 지금 까닭 「그 지역의 중온 아스콘 줄이 없음」을 전북 정읍·남원 12줄만 「그 지역에 그 등급(3등급) 중온 줄이 없음」으로 고치는 것(값은 그대로 못 채움). + +## ④ 그 밖 13줄(레미콘 제외) — 대부분 결정과 무관하게 못 채움 + +| 줄 | 결정하면 채워지나 | +|---|---| +| 아스콘 서울·인천·경기(강남·강동·송파 제외) 3등급 3줄 | 조달청 소묶음이 여러 개(89,590~107,350) 섞여 **어느 소묶음이 다산 묶음인지 글자로 못 가림** — 사람이 소묶음 하나를 고르면 3줄 채워지나, 최저값이 다산보다 크게 낮아 추천 안 함 | +| 아스콘 전북 전주권 중온 3줄 | 그 시군 묶음 자체가 없음 · 다른 전북 묶음은 +6~8%(±5% 밖) — 결정해도 못 채움 | +| 생태어소블록 1줄 · 생태옹벽블록 2줄 | 품명·치수 자체가 다름(호안·옹벽 규격 불일치) — 결정해도 못 채움 | +| 합성목재(본우드) 3줄 | 치수·형(중공/솔리드)·단위 자체가 다름 — 결정해도 못 채움 | +| 콘크리트보강섬유(슈퍼셀) 1줄 | 「콘크리트보강섬유」는 시트(m·㎡) 3줄뿐, 다산은 포(kg) — 물건 자체가 다름 — 결정해도 못 채움 | + +숫자로 — 이 13줄 가운데 결정으로 채울 수 있는 것은 **아스콘 서울·인천·경기 3줄**뿐(그마저 추천 안 함) · 나머지 10줄은 조달청에 같은 물건이 없어 어떤 결정도 못 채움. + +## 참고 — 별도 사안(관급 채우기 수에는 안 넣음) + +- 레미콘 규격 미지정 14건(원문이 무근·철근만 밝히고 강도·슬럼프를 안 밝힘) — `_조사_레미콘_규격.md` 참고. 관급값 채우기가 아니라 **로직에 강도·슬럼프 선택 칸을 새로 두는 설계** 확정 거리(무근 기본 18MPa · 철근 기본 21MPa 제안, 기본값도 사용자 협의). +- 충북 U형 T=8cm 회색(MT009987) — 다산 값 자체가 없어(비고도 빔) 어떤 결정으로도 못 채움 · 나대영 U103(8,000)이 후보이나 대조할 다산 값이 없음.