Merge remote-tracking branch 'origin/dev' into sub_laptop_1
This commit is contained in:
@@ -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<void>;
|
||||
@@ -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<void> => {
|
||||
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");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
로직: 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<T>(path: string, body?: unknown): Promise<T> {
|
||||
@@ -46,11 +84,23 @@ async function request<T>(path: string, body?: unknown): Promise<T> {
|
||||
export const fetchCombos = (): Promise<ComboSummary[]> =>
|
||||
request<{ combos: ComboSummary[] }>("/combos").then((d) => d.combos);
|
||||
|
||||
export const fetchCombo = (key: string): Promise<Combo> =>
|
||||
request<{ combo: Combo }>(`/combo?key=${encodeURIComponent(key)}`).then((d) => d.combo);
|
||||
export const fetchCombo = (key: string): Promise<ComboOne> =>
|
||||
request(`/combo?key=${encodeURIComponent(key)}`);
|
||||
|
||||
/** 키가 비면 새 조합 — 서버가 다음 번호(UA…)를 줌 */
|
||||
export const saveCombo = (combo: Combo): Promise<Combo> =>
|
||||
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<unknown> => 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<unknown> =>
|
||||
request("/combo/delete", { key, version });
|
||||
|
||||
/** 보기만 — 수량(inputs)은 이 부름에만 실림 · 저장 안 함 */
|
||||
export const previewCombo = (
|
||||
combo: Combo | string,
|
||||
inputs: Record<string, Record<string, unknown>>,
|
||||
): Promise<PreviewAnswer> => request("/combo/preview", { combo, inputs });
|
||||
|
||||
@@ -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<string, Record<string, string>>,
|
||||
): 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<void>;
|
||||
}
|
||||
|
||||
export function buildPreview(rows: () => ComboLogic[]): PreviewHandle {
|
||||
const inputs = el("div", { className: "m01-logic__stack" });
|
||||
export function buildPreview(combo: () => Combo): PreviewHandle {
|
||||
const values: Record<string, Record<string, string>> = {};
|
||||
/** 서버가 받을 꼴 — 원래 숫자·고르기 값으로 되돌림 */
|
||||
const inputs = new Map<string, LogicInput[]>();
|
||||
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<void> => {
|
||||
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<string, unknown> = {};
|
||||
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<string, Record<string, unknown>> = {};
|
||||
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<void> => {
|
||||
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,
|
||||
],
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -100,6 +100,10 @@ export type CalcAnswer =
|
||||
export interface TextLine {
|
||||
이름: string;
|
||||
글: string;
|
||||
/** 같은 줄을 변수 이름으로 적은 식 — 왼쪽 칸 */
|
||||
이름글?: string;
|
||||
/** 줄 번호(답 전체에서 하나씩) — 두 칸 줄 맞춤 · 밝히기 */
|
||||
짝?: number;
|
||||
금액: number;
|
||||
까닭?: string;
|
||||
}
|
||||
|
||||
@@ -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<void> => {
|
||||
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<HTMLElement>(`[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);
|
||||
|
||||
|
||||
@@ -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<FileInfo[]>;
|
||||
/** 그룹의 파일 목록(이미 받은 것) */
|
||||
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 };
|
||||
}
|
||||
|
||||
@@ -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 통과.
|
||||
@@ -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접합부속·레미콘 오기·중온아스콘 갈래별 정리 | 열림 |
|
||||
@@ -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)이 후보이나 대조할 다산 값이 없음.
|
||||
Reference in New Issue
Block a user