From 3c7a285f1f1489c8dc9a464374e7a4540f036c24 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 09:32:39 +0900 Subject: [PATCH 01/12] =?UTF-8?q?feat(m01-easycalc):=20=EC=89=AC=EC=9A=B4?= =?UTF-8?q?=20=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=20Api=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=C2=B7=20=ED=95=A8=EC=88=98=20=EB=A8=B8=EB=A6=AC=20?= =?UTF-8?q?=EC=98=AC=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EasyListItem · EasyOpened · EasySaved · EasyUse · EasyMachine · EasyMachineElement - listEasy · openEasy · calcLater · newEasy · copyEasy · editEasy · deleteEasy · usesOf · listMachines · machineElement · plainError 머리(몸은 다음 커밋) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL --- M01_MasterData/EasyCalc/M01_EasyCalc_Api.ts | 116 ++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 M01_MasterData/EasyCalc/M01_EasyCalc_Api.ts diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Api.ts b/M01_MasterData/EasyCalc/M01_EasyCalc_Api.ts new file mode 100644 index 000000000..5d8860a01 --- /dev/null +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Api.ts @@ -0,0 +1,116 @@ +/* ============================================================================= + * M01_EasyCalc_Api.ts + * 쉬운 단가산출 Api — 서버 길 감싸기(목록 · 열기 + 자동값 · 시험 계산 뒤에서 · 새로 · 본뜨기 · + * 고치기 · 지우기 · 쓰는 곳) · 403 · 409 · 422 를 쉬운 말로 · 기계 기종 → 부를 기계경비 로직 · 넘길 값 + * 모양은 `docs/raw/M01_쉬운단가/설계.md` 3절 그대로 · 쓰기는 `/logic/*` 만(`/save` 안 씀) + * ========================================================================== */ + +import type { CalcAnswer, LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api"; + +/** 목록 한 줄 */ +export interface EasyListItem { + key: string; + name: string; + /** 품셈 기본(읽기만) · false = 내가 만든 것 */ + isBase: boolean; + /** 화면 글 「품셈 기본」 · 「내가 만든 것」 */ + owner: string; + /** 결과단위 「원/㎡」 */ + resultUnit: string; + /** 쉬운 길로 열 수 있나 — 돈 아닌 로직(작업량 등)은 false */ + canOpen: boolean; + /** 못 여는 까닭 쉬운 말 · 열 수 있으면 null */ + whyNot: string | null; + /** 어디 것인지 — 「산림품셈 13장 구조물」 · 내가 만든 것은 장만 */ + where: string; +} + +/** 열기 답 — `trial` = 설계 때 정하는 값의 견본(자동값) */ +export interface EasyOpened { + row: LogicRow; + version: string; + isBase: boolean; + trial: Record; +} + +/** 새로 · 본뜨기 · 고치기 답 */ +export interface EasySaved { + key: string; + version: string; + row: LogicRow; +} + +/** 쓰는 곳 한 줄 — 일위대가 조합 · 이 로직을 부르는 다른 로직 */ +export interface EasyUse { + kind: "combo" | "logic"; + key: string; + name: string; +} + +/** 기계 고르기 목록 한 줄 */ +export interface EasyMachine { + 분류번호: string; + 이름: string; + 규격: string; + /** 줄 단위(「시간」) */ + 단위: string; +} + +/** 기종 → 호표 「요소」(기계경비 로직 부름 글) · 그 로직이 받는 설계 때 정하는 값 */ +export interface EasyMachineElement { + element: string; + needInputs: LogicInput[]; +} + +const notReady = (): never => { + throw new Error("준비 중"); +}; + +export async function listEasy(_q: string): Promise { + return notReady(); +} + +export async function openEasy(_key: string): Promise { + return notReady(); +} + +/** 0.3초 뒤 서버 시험 계산 · 최신 응답만 `onDone` · 멈춤 · 실패는 `plain` 쉬운 말 */ +export function calcLater( + _row: LogicRow, + _trial: Record, + _onDone: (ans: CalcAnswer | null, plain: string | null) => void, +): void { + notReady(); +} + +export async function newEasy(_row: LogicRow): Promise { + return notReady(); +} + +export async function copyEasy(_key: string): Promise { + return notReady(); +} + +export async function editEasy(_key: string, _version: string, _row: LogicRow): Promise { + return notReady(); +} + +export async function deleteEasy(_key: string, _version: string): Promise<{ version: string }> { + return notReady(); +} + +export async function usesOf(_key: string): Promise { + return notReady(); +} + +export async function listMachines(_q: string): Promise { + return notReady(); +} + +export async function machineElement(_기종: string): Promise { + return notReady(); +} + +export function plainError(_e: unknown): string { + return notReady(); +} From 6f1b569cd9bb71d80ea33007d1afcace63f56db7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 09:34:04 +0900 Subject: [PATCH 02/12] =?UTF-8?q?feat(M01-=EC=89=AC=EC=9A=B4=EB=8B=A8?= =?UTF-8?q?=EA=B0=80):=20=EC=8B=A4=EB=AC=B4=20=EC=9D=BC=EC=9C=84=EB=8C=80?= =?UTF-8?q?=EA=B0=80=ED=91=9C=20=C2=B7=20=EC=84=A4=EA=B3=84=20=EB=95=8C=20?= =?UTF-8?q?=EC=A0=95=ED=95=98=EB=8A=94=20=EA=B0=92=20=EB=9D=A0(Sheet)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 읽기만 하는 일위대가표(명칭·규격·수량·단위·합계·노무비·재료비·경비·비고·합계 줄)와 시험값 띠 추가. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- .../EasyCalc/M01_EasyCalc_Sheet.css | 112 ++++++++ M01_MasterData/EasyCalc/M01_EasyCalc_Sheet.ts | 246 ++++++++++++++++++ 2 files changed, 358 insertions(+) create mode 100644 M01_MasterData/EasyCalc/M01_EasyCalc_Sheet.css create mode 100644 M01_MasterData/EasyCalc/M01_EasyCalc_Sheet.ts diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Sheet.css b/M01_MasterData/EasyCalc/M01_EasyCalc_Sheet.css new file mode 100644 index 000000000..3a57c2b6f --- /dev/null +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Sheet.css @@ -0,0 +1,112 @@ +.m01-easysheet__title { + margin: 0 0 var(--spacing-8); + color: var(--color-text-body); + font-size: var(--text-body); + font-weight: 600; +} + +.m01-easysheet__status, +.m01-easysheet__muted { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +/* 좁은 폭은 표 안에서만 가로 스크롤 — 쪽 전체는 안 밀림 */ +.m01-easysheet__scroll { + max-width: 100%; + overflow-x: auto; +} + +.m01-easysheet__table { + width: 100%; + min-width: 920px; + border-collapse: collapse; + color: var(--color-text-body); + font-size: var(--text-body-sm); +} + +.m01-easysheet__table th, +.m01-easysheet__table td { + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid var(--color-border); + text-align: left; + vertical-align: middle; +} + +.m01-easysheet__table thead th { + background: var(--color-surface); + color: var(--color-text-secondary); + font-weight: 600; + white-space: nowrap; +} + +.m01-easysheet__table .m01-easysheet__group { + text-align: center; +} + +.m01-easysheet__table .m01-easysheet__num { + text-align: right; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.m01-easysheet__table .m01-easysheet__note { + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.m01-easysheet__table tfoot th, +.m01-easysheet__table tfoot td { + background: var(--color-surface); + font-weight: 700; +} + +.m01-easysheet__strip-title { + color: var(--color-text-body); +} + +.m01-easysheet__fields { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8) var(--spacing-16); + margin: var(--spacing-8) 0; +} + +.m01-easysheet__field { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + min-width: 0; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m01-easysheet__ctl { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.m01-easysheet__input { + box-sizing: border-box; + width: 160px; + max-width: 100%; + height: var(--control-height-sm); + padding: 3px 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-canvas); + color: var(--color-text-body); + font: inherit; + font-size: var(--text-body-sm); +} + +.m01-easysheet__input[aria-invalid] { + border-color: var(--color-danger); +} + +.m01-easysheet__warn { + color: var(--color-danger); + font-size: var(--text-caption); +} diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Sheet.ts b/M01_MasterData/EasyCalc/M01_EasyCalc_Sheet.ts new file mode 100644 index 000000000..ca04ab25e --- /dev/null +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Sheet.ts @@ -0,0 +1,246 @@ +/* ============================================================================= + * M01_EasyCalc_Sheet.ts + * 쉬운 단가산출 — 실무 일위대가표(읽기만) · 「설계 때 정하는 값」 띠(시험값만, 저장 안 됨) + * 계약 = docs/raw/M01_쉬운단가/설계.md 3절 Sheet. 이 폴더째 지워도 됨. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createSelectField, el } from "@ui/ui_template_elements"; +import { + searchElements, + type CalcAnswer, + type CalcLine, + type LogicInput, +} from "../M01_MasterData_UI_Logic_Api"; +import { formatMoney, formatPrice } from "../M01_MasterData_UI_Logic_Money"; +import { safeReason } from "../M01_MasterData_UI_Logic_Text"; +import "./M01_EasyCalc_Sheet.css"; + +const TEXT = { + Busy: ["계산 중", "Calculating"], + Stopped: ["계산하지 못했어요", "Could not calculate"], + Name: ["명칭", "Name"], + Spec: ["규격", "Size"], + Qty: ["수량", "Qty"], + Unit: ["단위", "Unit"], + Total: ["합계", "Total"], + Labor: ["노무비", "Labor"], + Material: ["재료비", "Material"], + Expense: ["경비", "Expense"], + Note: ["비고", "Note"], + Price: ["단가", "Price"], + Amount: ["금액", "Amount"], + Strip: ["설계 때 정하는 값", "Values set at design time"], + Trial: ["여기 값은 시험용 — 저장 안 됨", "Trial values only — not saved"], + Range: ["{a} ∼ {b} 사이", "between {a} and {b}"], + Pick: ["고르세요", "Choose"], + Bad: ["숫자를 넣어 주세요", "Enter a number"], + OutOfRange: ["범위 밖이에요", "Out of range"], +} as const; + +const ts = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex]; + +const COSTS = ["노무비", "재료비", "경비"] as const; +const COST_HEAD = { 노무비: "Labor", 재료비: "Material", 경비: "Expense" } as const; + +const qty = (n: number): string => n.toLocaleString("ko-KR", { maximumFractionDigits: 6 }); +const cell = (text: string, className = ""): HTMLElement => el("td", { className, text }); +const money = (text: string): HTMLElement => cell(text, "m01-easysheet__num"); + +/** 준용 · 산정 글에 붙은 마스터 번호는 뺌 */ +const note = (line: CalcLine): string => + [line.출처?.replace(/[A-Z]{2}\d{6}\./g, ""), line.단위경고].filter(Boolean).join(" · "); + +function lineRow(line: CalcLine): HTMLElement { + // 비목별 단가 = 줄 단가 × (그 비목 금액 ÷ 줄 금액) — 금액 반올림 오차가 단가에 안 섞임 + const per = (k: string): number => + line.금액 ? (line.단가 * (line.비목?.[k] ?? 0)) / line.금액 : 0; + return el("tr", { + children: [ + cell(line.이름), + cell(line.품목?.규격 ?? ""), + money(qty(line.수량)), + cell(line.단위), + money(formatPrice(line.단가)), + money(formatMoney(line.금액)), + ...COSTS.flatMap((k) => [ + money(formatPrice(per(k))), + money(formatMoney(line.비목?.[k] ?? 0)), + ]), + cell(note(line), "m01-easysheet__note"), + ], + }); +} + +/** 실무 일위대가표 — 읽기만 · 맨 아래 합계 줄 */ +export function renderSheet( + host: HTMLElement, + head: { name: string; resultUnit: string }, + ans: CalcAnswer | null, +): void { + const title = el("p", { + className: "m01-easysheet__title", + text: [head.name, head.resultUnit].filter(Boolean).join(" · "), + }); + if (!ans || !ans.ok) { + host.replaceChildren( + title, + el("p", { + className: "m01-easysheet__status", + text: ans ? `${ts("Stopped")} — ${safeReason(ans.reason)}` : ts("Busy"), + }), + ); + return; + } + const lines = ans.lines ?? []; + const sums = ans.sums ?? {}; + const group = (label: string): HTMLElement => + el("th", { className: "m01-easysheet__group", text: label, attrs: { colspan: "2" } }); + const one = (label: string): HTMLElement => el("th", { text: label, attrs: { rowspan: "2" } }); + const pair = (): HTMLElement[] => [ts("Price"), ts("Amount")].map((h) => el("th", { text: h })); + const table = el("table", { + className: "m01-easysheet__table", + children: [ + el("thead", { + children: [ + el("tr", { + children: [ + one(ts("Name")), + one(ts("Spec")), + one(ts("Qty")), + one(ts("Unit")), + group(ts("Total")), + ...COSTS.map((k) => group(ts(COST_HEAD[k]))), + one(ts("Note")), + ], + }), + el("tr", { children: [...pair(), ...pair(), ...pair(), ...pair()] }), + ], + }), + el("tbody", { children: lines.map(lineRow) }), + el("tfoot", { + children: [ + el("tr", { + children: [ + el("th", { text: ts("Total"), attrs: { colspan: "4" } }), + cell(""), + money(formatMoney(sums["계"] ?? 0)), + ...COSTS.flatMap((k) => [cell(""), money(formatMoney(sums[k] ?? 0))]), + cell(""), + ], + }), + ], + }), + ], + }); + host.replaceChildren(title, el("div", { className: "m01-easysheet__scroll", children: [table] })); +} + +/** 마스터 번호 꼴 고르기 값 → 이름(한 번 물은 것은 다시 안 물음) */ +const KEY_RE = /^[A-Z]{2}\d{6}$/; +const KEY_GROUP: Record = { MT: "재료", EQ: "기계", LB: "인력" }; +const nameCache = new Map(); + +async function keyName(key: string): Promise { + if (nameCache.has(key)) return nameCache.get(key)!; + const group = KEY_GROUP[key.slice(0, 2)]; + let name: string | null = null; + if (group) { + try { + const hit = (await searchElements(group, key)).items.find((it) => it.ref === key); + name = hit ? `${hit.이름 ?? ""}${hit.규격 ? ` ${hit.규격}` : ""}`.trim() || null : null; + } catch { + name = null; + } + } + nameCache.set(key, name); + return name; +} + +/** state = 띠가 들고 있는 시험값(고칠 때마다 같은 객체를 바꿈) · emit = 바뀐 값을 밖에 알림 */ +type Trial = Record; + +function numberBox(spec: LogicInput, state: Trial, emit: () => void): HTMLElement { + const [lo, hi] = spec.범위 ?? []; + const hint = spec.범위 ? ts("Range").replace("{a}", String(lo)).replace("{b}", String(hi)) : ""; + const box = el("input", { + className: "m01-easysheet__input", + attrs: { type: "text", inputmode: "decimal", placeholder: hint }, + }); + const warn = el("span", { className: "m01-easysheet__warn" }); + box.value = state[spec.이름] === undefined ? "" : String(state[spec.이름]); + box.addEventListener("change", () => { + const raw = box.value.trim(); + const num = Number(raw); + const bad = raw !== "" && Number.isNaN(num) ? ts("Bad") : ""; + const out = !bad && raw !== "" && spec.범위 && (num < lo! || num > hi!) ? ts("OutOfRange") : ""; + warn.textContent = bad || out; + box.toggleAttribute("aria-invalid", !!(bad || out)); + if (bad) return; + if (raw === "") delete state[spec.이름]; + else state[spec.이름] = num; + emit(); + }); + return el("span", { className: "m01-easysheet__ctl", children: [box, warn] }); +} + +function pickBox(spec: LogicInput, state: Trial, emit: () => void): HTMLElement { + const picks = spec.고르기 ?? []; + const optionText = (o: string | number): string => + (typeof o === "string" && nameCache.get(o)) || String(o); + const options = (): { value: string; text: string }[] => [ + { value: "", text: ts("Pick") }, + ...picks.map((o) => ({ value: String(o), text: optionText(o) })), + ]; + const cur = state[spec.이름] === undefined ? "" : String(state[spec.이름]); + const field = createSelectField({ + options: options(), + value: cur, + compact: true, + ariaLabel: spec.이름, + onChange: (v) => { + const hit = picks.find((o) => String(o) === v); // 원래 값(수) 그대로 보냄 + if (hit === undefined) delete state[spec.이름]; + else state[spec.이름] = hit; + emit(); + }, + }); + 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(() => field.setOptions(options(), cur)); + } + return field.root; +} + +/** 「설계 때 정하는 값」 띠 — 바꾸면 onChange(새 시험값) · 값이 없으면 띠를 숨김 */ +export function renderTrialStrip( + host: HTMLElement, + inputs: LogicInput[], + trial: Record, + onChange: (trial: Record) => void, +): void { + if (!inputs.length) { + host.replaceChildren(); + host.hidden = true; + return; + } + host.hidden = false; + const state = { ...trial }; + const emit = (): void => onChange({ ...state }); + host.replaceChildren( + el("strong", { className: "m01-easysheet__strip-title", text: ts("Strip") }), + el("div", { + className: "m01-easysheet__fields", + children: inputs.map((spec) => + el("label", { + className: "m01-easysheet__field", + children: [ + el("span", { text: spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름 }), + spec.고르기?.length ? pickBox(spec, state, emit) : numberBox(spec, state, emit), + ], + }), + ), + }), + el("p", { className: "m01-easysheet__muted", text: ts("Trial") }), + ); +} From fb991500fdb275532a62b74ba878d2190b960894 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 09:34:46 +0900 Subject: [PATCH 03/12] =?UTF-8?q?feat(m01-easycalc):=20=EC=89=AC=EC=9A=B4?= =?UTF-8?q?=20=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=20=ED=81=B0=20=EC=B0=BD?= =?UTF-8?q?=20=ED=8B=80=20=C2=B7=20=EC=8B=9C=EC=9E=91=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=C2=B7=20=EA=B3=A0=EC=B9=98=EB=8A=94=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=9C=84=20=EB=9D=A0(1=EA=B1=B8=EC=9D=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M01_MasterData/EasyCalc/M01_EasyCalc_Entry.ts | 395 +++++++++++++++++- .../EasyCalc/M01_EasyCalc_Style.css | 157 ++++++- 2 files changed, 536 insertions(+), 16 deletions(-) diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Entry.ts b/M01_MasterData/EasyCalc/M01_EasyCalc_Entry.ts index b930f56cb..3dabd97c8 100644 --- a/M01_MasterData/EasyCalc/M01_EasyCalc_Entry.ts +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Entry.ts @@ -1,33 +1,400 @@ /* ============================================================================= * M01_EasyCalc_Entry.ts - * 쉬운 단가산출 시험 길 — 들어가는 단추 + 빈 창(틀만). 이 폴더째 지워도 됨. + * 쉬운 단가산출 시험 길 — 들어가는 단추 · 큰 창 틀 · 시작 화면 · 고치는 화면 틀(위 띠 · 자리들) + * 모양은 `docs/raw/M01_쉬운단가/설계.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄). + * 1걸음 = 틀만 — 카드 · 설계 때 정하는 값 · 실무 일위대가표 · 저장 · 지우기 · 되돌리기는 2걸음에 이음. * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, el } from "@ui/ui_template_elements"; +import { + createButton, + createInputField, + createSelectField, + createTag, + el, + showConfirmDialog, + showToast, +} from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; +import { + fetchLogic, + fetchLogics, + type LogicRow, + type LogicSummary, +} from "../M01_MasterData_UI_Logic_Api"; import "./M01_EasyCalc_Style.css"; const TEXT = { Open: ["쉬운 단가산출(시험)", "Easy unit-price (trial)"], Title: ["쉬운 단가산출", "Easy unit-price"], - Ready: ["준비 중", "Coming soon"], + CloseWindow: ["창 닫기", "Close window"], Close: ["닫기", "Close"], + Loading: ["불러오는 중", "Loading"], + LoadFailed: ["목록을 못 불러옴 — {v}", "Could not load the list — {v}"], + Start_Ask: ["무엇과 비슷한가요?", "What is it similar to?"], + Start_Search: ["이름으로 찾기 — 예: 거푸집", "Search by name — e.g. formwork"], + Start_Blank: ["빈 종이로 새로 만들기", "Start from a blank sheet"], + Start_Mine: ["내가 만든 것", "Made by me"], + Start_MineNone: ["아직 없음", "None yet"], + Start_Found: ["찾은 것", "Found"], + Start_Count: ["{n} 개 중 {m} 개 보임 — 더 좁혀 찾기", "Showing {m} of {n} — narrow the search"], + Start_None: ["찾은 것 없음", "Nothing found"], + Base: ["품셈 기본", "Standard"], + Mine: ["내가 만든 것", "Made by me"], + CannotOpen: ["쉬운 길로 못 엶", "Not for the easy view"], + Per: ["{u} 당", "per {u}"], + Name: ["이름", "Name"], + NamePlaceholder: ["예: 거푸집 설치", "e.g. formwork setup"], + Spec: ["크기", "Size"], + Unit: ["단위", "Unit"], + Total: ["{u} 만드는 데 {v} 원", "{v} won to make {u}"], + Undo: ["되돌리기", "Undo"], + Save: ["저장", "Save"], + Delete: ["지우기", "Delete"], + Unsaved: ["저장 안 함", "Not saved"], + BaseNote: [ + "품셈 기본은 읽기만 — 고치려면 아래 단추로 내 것을 만듦", + "Standard items are read-only — use the button below to make your own copy", + ], + BaseCopy: ["이걸 바탕으로 고치기", "Edit a copy of this"], + Trial: ["설계 때 정하는 값", "Values set at design time"], + Cards: ["무엇이 들어가나", "What goes in"], + Sheet: ["실무 일위대가표로 보기", "View as a unit-cost sheet"], + NotYet: ["준비 중", "Coming soon"], + Leave_Ask: [ + "저장 안 한 것이 있음 — 저장하지 않고 닫을까요?", + "There are unsaved changes — close without saving?", + ], + Leave_Ok: ["저장 안 하고 닫기", "Close without saving"], } as const; -const te = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex]; +type TextKey = keyof typeof TEXT; -/** 「식으로 새로 만들기」 옆에 놓는 단추 — 누르면 빈 창 */ +const te = (key: TextKey, vars: Record = {}): string => + Object.entries(vars).reduce( + (text, [k, v]) => text.replace(`{${k}}`, String(v)), + (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string, + ); + +/** 목록에 한 번에 보이는 줄 수 */ +const SHOWN = 50; + +/** 고치는 화면이 쥔 것 — 2걸음에 Model 의 EasyDoc 으로 바꿈 */ +interface Paper { + key: string | null; + version: string | null; + isBase: boolean; + name: string; + spec: string; + resultUnit: string; + row: LogicRow | null; +} + +const isMine = (key: string): boolean => key.startsWith("GX"); +/** 돈이 아닌 결과(작업량 등) — 쉬운 길은 「원/…」 만 엶 */ +const canOpen = (item: LogicSummary): boolean => item.결과단위.startsWith("원/"); +/** 「원/㎡」 → 「㎡」 */ +const unitOf = (resultUnit: string): string => resultUnit.replace(/^원\//, ""); +/** 「㎡」 → 「1㎡」 · 「100m」 은 그대로 */ +const amountOf = (resultUnit: string): string => { + const unit = unitOf(resultUnit); + return /^\d/.test(unit) ? unit : `1${unit}`; +}; + +/** 「추가」 옆에 놓는 단추 — 누르면 큰 창 */ export function createEasyCalcButton(): HTMLButtonElement { - return createButton({ - label: te("Open"), - variant: "filled", - onClick: () => - openModal({ - title: te("Title"), - closeLabel: te("Close"), - mount: (body) => - body.append(el("p", { className: "m01-easycalc__ready", text: te("Ready") })), + return createButton({ label: te("Open"), variant: "filled", onClick: openEasyCalc }); +} + +function openEasyCalc(): void { + let body: HTMLElement; + let paper: Paper | null = null; + let dirty = false; + let logics: LogicSummary[] | null = null; + let query = ""; + + /** 저장 안 한 것이 있으면 한 번 물음 — 닫아도 되면 true */ + const mayLeave = async (): Promise => + !paper || !dirty || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); + + const notYet = (): void => showToast(te("NotYet"), "info"); + + /* ---------- 시작 화면 ---------- */ + + const listRow = (item: LogicSummary): HTMLElement => { + const ok = canOpen(item); + const row = el("button", { + className: "m01-easycalc__item", + attrs: { type: "button" }, + children: [ + el("span", { className: "m01-easycalc__item-name", text: item.이름 }), + createTag( + isMine(item.키) ? te("Mine") : te("Base"), + isMine(item.키) ? "accent" : "neutral", + ), + el("span", { + className: "m01-easycalc__item-unit", + text: ok ? te("Per", { u: amountOf(item.결과단위) }) : item.결과단위, + }), + ...(ok ? [] : [createTag(te("CannotOpen"), "warning")]), + ], + }); + row.disabled = !ok; + row.addEventListener("click", () => void openPaper(item)); + return row; + }; + + const showStart = async (): Promise => { + paper = null; + dirty = false; + const status = el("p", { className: "m01-easycalc__muted", text: te("Loading") }); + const mineHost = el("div", { className: "m01-easycalc__list" }); + const foundHost = el("div", { className: "m01-easycalc__list" }); + const search = createInputField({ + type: "search", + placeholder: te("Start_Search"), + value: query, + onInput: (v) => { + query = v; + drawFound(); + }, + }); + search.root.classList.add("m01-easycalc__search"); + + const drawFound = (): void => { + if (!logics) return; + const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean); + const hits = words.length + ? logics.filter((x) => words.every((w) => x.이름.toLowerCase().includes(w))) + : logics; + status.textContent = !hits.length + ? te("Start_None") + : hits.length > SHOWN + ? te("Start_Count", { n: hits.length.toLocaleString(), m: SHOWN }) + : ""; + foundHost.replaceChildren(...hits.slice(0, SHOWN).map(listRow)); + }; + + body.replaceChildren( + el("div", { + className: "m01-easycalc__start", + children: [ + el("div", { + className: "m01-easycalc__start-col", + children: [ + el("h3", { className: "m01-easycalc__ask", text: te("Start_Ask") }), + search.root, + el("h4", { text: te("Start_Found") }), + status, + foundHost, + ], + }), + el("div", { + className: "m01-easycalc__start-col m01-easycalc__start-side", + children: [ + createBigButton(te("Start_Blank"), () => openBlank()), + el("h4", { text: te("Start_Mine") }), + mineHost, + ], + }), + ], }), + ); + search.input.focus(); + + try { + logics ??= await fetchLogics(); + } catch (error) { + status.textContent = te("LoadFailed", { + v: error instanceof Error ? error.message : String(error), + }); + return; + } + const mine = logics.filter((x) => isMine(x.키)); + mineHost.replaceChildren( + ...(mine.length + ? mine.map(listRow) + : [el("p", { className: "m01-easycalc__muted", text: te("Start_MineNone") })]), + ); + drawFound(); + }; + + /* ---------- 고치는 화면 ---------- */ + + const openBlank = (): void => { + paper = { + key: null, + version: null, + isBase: false, + name: "", + spec: "", + resultUnit: unitChoices()[0] ?? "원/㎡", + row: null, + }; + showPaper(); + }; + + const openPaper = async (item: LogicSummary): Promise => { + body.replaceChildren(el("p", { className: "m01-easycalc__muted", text: te("Loading") })); + try { + const one = await fetchLogic(item.키); + paper = { + key: one.logic.키, + version: one.version, + isBase: !isMine(one.logic.키), + name: one.logic.이름, + spec: "", + resultUnit: one.logic.결과단위, + row: one.logic, + }; + showPaper(); + } catch (error) { + showToast(error instanceof Error ? error.message : String(error), "error"); + void showStart(); + } + }; + + /** 단위 고르기 후보 — 목록에 많이 쓰인 「원/…」 차례 */ + const unitChoices = (): string[] => { + const count = new Map(); + for (const x of logics ?? []) { + if (canOpen(x)) count.set(x.결과단위, (count.get(x.결과단위) ?? 0) + 1); + } + return [...count.entries()].sort((a, b) => b[1] - a[1]).map(([u]) => u); + }; + + const showPaper = (): void => { + const p = paper; + if (!p) return; + const readOnly = p.isBase; + const unsaved = createTag(te("Unsaved"), "warning"); + unsaved.hidden = true; + const total = el("div", { className: "m01-easycalc__total" }); + const drawTotal = (): void => { + total.textContent = te("Total", { u: amountOf(p.resultUnit), v: "—" }); + }; + const touched = (): void => { + dirty = true; + unsaved.hidden = false; + }; + + const name = createInputField({ + label: te("Name"), + placeholder: te("NamePlaceholder"), + value: p.name, + onInput: (v) => { + p.name = v; + touched(); + }, + }); + const spec = createInputField({ + label: te("Spec"), + value: p.spec, + onInput: (v) => { + p.spec = v; + touched(); + }, + }); + const units = unitChoices(); + if (!units.includes(p.resultUnit)) units.unshift(p.resultUnit); + const unit = createSelectField({ + label: te("Unit"), + options: units.map((u) => ({ value: u, text: unitOf(u) })), + value: p.resultUnit, + disabled: readOnly, + onChange: (v) => { + p.resultUnit = v; + drawTotal(); + touched(); + }, + }); + name.input.disabled = readOnly; + spec.input.disabled = readOnly; + name.root.classList.add("m01-easycalc__name"); + + const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: notYet }); + undo.disabled = true; + const close = createButton({ + label: te("Close"), + variant: "ghost", + onClick: () => + void (async () => { + if (await mayLeave()) void showStart(); + })(), + }); + const actions = readOnly + ? [close] + : [ + undo, + createButton({ label: te("Save"), onClick: notYet }), + ...(p.key && isMine(p.key) + ? [createButton({ label: te("Delete"), variant: "danger", onClick: notYet })] + : []), + close, + ]; + + const section = (title: string, extra = ""): HTMLElement => + el("section", { + className: `m01-easycalc__part ${extra}`.trim(), + children: [ + el("h4", { text: title }), + el("p", { className: "m01-easycalc__muted", text: te("NotYet") }), + ], + }); + const sheet = el("details", { + className: "m01-easycalc__part m01-easycalc__sheet", + children: [ + el("summary", { text: te("Sheet") }), + el("p", { className: "m01-easycalc__muted", text: te("NotYet") }), + ], + }); + + drawTotal(); + body.replaceChildren( + el("div", { + className: "m01-easycalc__strip", + children: [ + name.root, + spec.root, + unit.root, + total, + unsaved, + el("div", { className: "m01-easycalc__actions", children: actions }), + ], + }), + ...(readOnly + ? [ + el("div", { + className: "m01-easycalc__base", + children: [ + el("p", { text: te("BaseNote") }), + createBigButton(te("BaseCopy"), notYet), + ], + }), + ] + : []), + section(te("Trial"), "m01-easycalc__trial"), + section(te("Cards"), "m01-easycalc__cards"), + sheet, + ); + if (!readOnly) name.input.focus(); + }; + + // 화면 함수들을 다 만든 뒤에 엶 — mount 가 바로 showStart 를 부름 + openModal({ + title: te("Title"), + closeLabel: te("CloseWindow"), + dialogClass: "m01-easycalc", + beforeClose: mayLeave, + mount: (host) => { + body = host; + void showStart(); + }, }); } + +function createBigButton(label: string, onClick: () => void): HTMLButtonElement { + const button = createButton({ label, variant: "filled", onClick }); + button.classList.add("m01-easycalc__big"); + return button; +} diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Style.css b/M01_MasterData/EasyCalc/M01_EasyCalc_Style.css index 767d9150d..e23ed19f7 100644 --- a/M01_MasterData/EasyCalc/M01_EasyCalc_Style.css +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Style.css @@ -1,6 +1,159 @@ -.m01-easycalc__ready { +/* 쉬운 단가산출 — 큰 창 틀 · 시작 화면 · 위 띠 */ + +/* 공용 모달(위 고정)을 화면 대부분으로 — 높이를 고정해 화면이 바뀌어도 창이 안 움직임 */ +.ui-modal.m01-easycalc { + --modal-max: 90vh; + width: min(1280px, 96vw); + height: var(--modal-max); + font-size: var(--text-body); +} + +.m01-easycalc .ui-modal__body { + flex: 1; + min-height: 0; + gap: var(--spacing-16); +} + +.m01-easycalc__muted { margin: 0; - padding: var(--spacing-16) 0; color: var(--color-text-muted); font-size: var(--text-body-sm); } + +.m01-easycalc__big { + min-height: 56px; + padding: 0 var(--spacing-24); + font-size: var(--text-body); +} + +/* 시작 화면 — 왼쪽 찾기 · 오른쪽 새로 만들기 + 내가 만든 것 */ +.m01-easycalc__start { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); + gap: var(--spacing-24); + min-height: 0; +} + +.m01-easycalc__start-col { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; +} + +.m01-easycalc__start-col h4 { + margin: var(--spacing-8) 0 0; +} + +.m01-easycalc__ask { + margin: 0; + font-size: var(--text-subheading); +} + +.m01-easycalc__search .ui-input { + font-size: var(--text-body); +} + +.m01-easycalc__list { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.m01-easycalc__item { + display: flex; + align-items: center; + gap: var(--spacing-8); + width: 100%; + padding: var(--spacing-8) var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); + color: var(--color-text); + font: inherit; + text-align: left; + cursor: pointer; +} + +.m01-easycalc__item:hover:not(:disabled) { + border-color: var(--color-accent); +} + +.m01-easycalc__item:disabled { + cursor: default; + opacity: 0.6; +} + +.m01-easycalc__item-name { + flex: 1; + min-width: 0; +} + +.m01-easycalc__item-unit { + color: var(--color-text-muted); + font-size: var(--text-body-sm); + white-space: nowrap; +} + +/* 고치는 화면 — 위 띠 */ +.m01-easycalc__strip { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--spacing-12); + padding-bottom: var(--spacing-12); + border-bottom: 1px solid var(--color-border); +} + +.m01-easycalc__strip .ui-field { + min-width: 120px; +} + +.m01-easycalc__name { + flex: 1 1 240px; +} + +.m01-easycalc__total { + font-size: var(--text-subheading); + font-weight: 700; + white-space: nowrap; +} + +.m01-easycalc__actions { + display: flex; + gap: var(--spacing-8); + margin-left: auto; +} + +.m01-easycalc__base { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-16); +} + +.m01-easycalc__base p { + margin: 0; +} + +.m01-easycalc__part { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); +} + +.m01-easycalc__part h4 { + margin: 0; +} + +.m01-easycalc__cards { + flex: 1; +} + +.m01-easycalc__sheet summary { + cursor: pointer; + font-weight: 600; +} From de8b71fab6cd54667ab465972e5b3f74b211662a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 09:36:16 +0900 Subject: [PATCH 04/12] =?UTF-8?q?feat(m01-easycalc):=20=EC=8B=9C=EC=9E=91?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=C2=B7=20=EA=B3=A0=EC=B9=98=EB=8A=94=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=EC=9D=84=20Api=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EB=AA=A8=EC=96=91=20=C2=B7=20Model=20EasyDoc=20=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016PSXsvyDxjNmufteN5bqmt --- M01_MasterData/EasyCalc/M01_EasyCalc_Entry.ts | 149 +++++++++++------- 1 file changed, 90 insertions(+), 59 deletions(-) diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Entry.ts b/M01_MasterData/EasyCalc/M01_EasyCalc_Entry.ts index 3dabd97c8..5b052cb8f 100644 --- a/M01_MasterData/EasyCalc/M01_EasyCalc_Entry.ts +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Entry.ts @@ -16,12 +16,15 @@ import { showToast, } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; +import { fetchLogic, fetchLogics, type LogicRow } from "../M01_MasterData_UI_Logic_Api"; import { - fetchLogic, - fetchLogics, - type LogicRow, - type LogicSummary, -} from "../M01_MasterData_UI_Logic_Api"; + listEasy, + openEasy, + plainError, + type EasyListItem, + type EasyOpened, +} from "./M01_EasyCalc_Api"; +import type { EasyDoc } from "./M01_EasyCalc_Model"; import "./M01_EasyCalc_Style.css"; const TEXT = { @@ -79,20 +82,68 @@ const te = (key: TextKey, vars: Record = {}): string => /** 목록에 한 번에 보이는 줄 수 */ const SHOWN = 50; -/** 고치는 화면이 쥔 것 — 2걸음에 Model 의 EasyDoc 으로 바꿈 */ -interface Paper { - key: string | null; - version: string | null; - isBase: boolean; - name: string; - spec: string; - resultUnit: string; - row: LogicRow | null; -} +/* ponytail: Api(sub4) 속이 「준비 중」 인 동안만 Logic_Api 로 대신함 — Api 가 채워지면 아래 셋의 catch 를 지움 */ +const loadList = (): Promise => + listEasy("").catch(() => + fetchLogics().then((all) => + all.map((x) => { + const isBase = !x.키.startsWith("GX"); + const canOpen = x.결과단위.startsWith("원/"); + return { + key: x.키, + name: x.이름, + isBase, + owner: isBase ? te("Base") : te("Mine"), + resultUnit: x.결과단위, + canOpen, + whyNot: canOpen ? null : te("CannotOpen"), + where: `${x.구분} ${x.상세구분}`.trim(), + }; + }), + ), + ); +const loadOne = (key: string): Promise => + openEasy(key).catch(() => + fetchLogic(key).then((one) => ({ + row: one.logic, + version: one.version, + isBase: !one.logic.키.startsWith("GX"), + trial: {}, + })), + ); +const plain = (error: unknown): string => { + try { + return plainError(error); + } catch { + return error instanceof Error ? error.message : String(error); + } +}; -const isMine = (key: string): boolean => key.startsWith("GX"); -/** 돈이 아닌 결과(작업량 등) — 쉬운 길은 「원/…」 만 엶 */ -const canOpen = (item: LogicSummary): boolean => item.결과단위.startsWith("원/"); +/** 빈 종이 — 꼭 채울 칸만(검토 A 「꼭 채울 것」) · 구분 · 상세구분 · 소유 · 키는 서버가 채움 */ +const blankRow = (resultUnit: string): LogicRow => ({ + 키: "", + 원문번호: "", + 이름: "", + 결과단위: resultUnit, + 출처: "자체", + 입력: [], + 중간: [], + 호표: [], + 덧줄: [], + 끝수: null, +}); + +const docOf = (opened: EasyOpened, key: string | null): EasyDoc => ({ + key, + version: opened.version, + isBase: opened.isBase, + name: opened.row.이름, + spec: "", + resultUnit: opened.row.결과단위, + inputs: opened.row.입력 ?? [], + cards: [], + row: opened.row, +}); /** 「원/㎡」 → 「㎡」 */ const unitOf = (resultUnit: string): string => resultUnit.replace(/^원\//, ""); /** 「㎡」 → 「1㎡」 · 「100m」 은 그대로 */ @@ -108,9 +159,9 @@ export function createEasyCalcButton(): HTMLButtonElement { function openEasyCalc(): void { let body: HTMLElement; - let paper: Paper | null = null; + let paper: EasyDoc | null = null; let dirty = false; - let logics: LogicSummary[] | null = null; + let logics: EasyListItem[] | null = null; let query = ""; /** 저장 안 한 것이 있으면 한 번 물음 — 닫아도 되면 true */ @@ -121,26 +172,24 @@ function openEasyCalc(): void { /* ---------- 시작 화면 ---------- */ - const listRow = (item: LogicSummary): HTMLElement => { - const ok = canOpen(item); + const listRow = (item: EasyListItem): HTMLElement => { + const ok = item.canOpen; const row = el("button", { className: "m01-easycalc__item", attrs: { type: "button" }, children: [ - el("span", { className: "m01-easycalc__item-name", text: item.이름 }), - createTag( - isMine(item.키) ? te("Mine") : te("Base"), - isMine(item.키) ? "accent" : "neutral", - ), + el("span", { className: "m01-easycalc__item-name", text: item.name }), + createTag(item.owner, item.isBase ? "neutral" : "accent"), el("span", { className: "m01-easycalc__item-unit", - text: ok ? te("Per", { u: amountOf(item.결과단위) }) : item.결과단위, + text: ok ? te("Per", { u: amountOf(item.resultUnit) }) : item.resultUnit, }), - ...(ok ? [] : [createTag(te("CannotOpen"), "warning")]), + ...(ok ? [] : [createTag(item.whyNot ?? te("CannotOpen"), "warning")]), ], }); row.disabled = !ok; - row.addEventListener("click", () => void openPaper(item)); + row.title = item.where; + row.addEventListener("click", () => void openPaper(item.key)); return row; }; @@ -165,7 +214,7 @@ function openEasyCalc(): void { if (!logics) return; const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean); const hits = words.length - ? logics.filter((x) => words.every((w) => x.이름.toLowerCase().includes(w))) + ? logics.filter((x) => words.every((w) => x.name.toLowerCase().includes(w))) : logics; status.textContent = !hits.length ? te("Start_None") @@ -203,14 +252,12 @@ function openEasyCalc(): void { search.input.focus(); try { - logics ??= await fetchLogics(); + logics ??= await loadList(); } catch (error) { - status.textContent = te("LoadFailed", { - v: error instanceof Error ? error.message : String(error), - }); + status.textContent = te("LoadFailed", { v: plain(error) }); return; } - const mine = logics.filter((x) => isMine(x.키)); + const mine = logics.filter((x) => !x.isBase); mineHost.replaceChildren( ...(mine.length ? mine.map(listRow) @@ -222,34 +269,18 @@ function openEasyCalc(): void { /* ---------- 고치는 화면 ---------- */ const openBlank = (): void => { - paper = { - key: null, - version: null, - isBase: false, - name: "", - spec: "", - resultUnit: unitChoices()[0] ?? "원/㎡", - row: null, - }; + const row = blankRow(unitChoices()[0] ?? "원/㎡"); + paper = { ...docOf({ row, version: "", isBase: false, trial: {} }, null), version: null }; showPaper(); }; - const openPaper = async (item: LogicSummary): Promise => { + const openPaper = async (key: string): Promise => { body.replaceChildren(el("p", { className: "m01-easycalc__muted", text: te("Loading") })); try { - const one = await fetchLogic(item.키); - paper = { - key: one.logic.키, - version: one.version, - isBase: !isMine(one.logic.키), - name: one.logic.이름, - spec: "", - resultUnit: one.logic.결과단위, - row: one.logic, - }; + paper = docOf(await loadOne(key), key); showPaper(); } catch (error) { - showToast(error instanceof Error ? error.message : String(error), "error"); + showToast(plain(error), "error"); void showStart(); } }; @@ -258,7 +289,7 @@ function openEasyCalc(): void { const unitChoices = (): string[] => { const count = new Map(); for (const x of logics ?? []) { - if (canOpen(x)) count.set(x.결과단위, (count.get(x.결과단위) ?? 0) + 1); + if (x.canOpen) count.set(x.resultUnit, (count.get(x.resultUnit) ?? 0) + 1); } return [...count.entries()].sort((a, b) => b[1] - a[1]).map(([u]) => u); }; @@ -327,7 +358,7 @@ function openEasyCalc(): void { : [ undo, createButton({ label: te("Save"), onClick: notYet }), - ...(p.key && isMine(p.key) + ...(p.key && !p.isBase ? [createButton({ label: te("Delete"), variant: "danger", onClick: notYet })] : []), close, From 63235dc2039efce5e3369557076165f99b887d11 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 09:37:05 +0900 Subject: [PATCH 05/12] =?UTF-8?q?feat(m01-easycalc):=20=EC=89=AC=EC=9A=B4?= =?UTF-8?q?=20=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=20Model=20=EC=86=8D=20?= =?UTF-8?q?=EC=B1=84=EC=9B=80=20=C2=B7=20=EC=8B=9C=ED=97=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - toDoc · toRow: 호표 인력 · 재료 · 기계경비 로직(GC000267~276) · 그 밖 로직 카드 · 숫자 아닌 수량은 잠금 + 쉬운 말 까닭 - 덧줄 「바탕 * 숫자 / 100」(노무비 · 재료비 · 경비 · 사람 줄 노임 합) = 덧붙임 카드 · 그 밖 = 잠금 - 안 바뀐 줄은 원래 글자 그대로 — 견본 셋 · 마스터 로직 1,469개 왕복 같음 - addCard · addPercent · withInputs · updateCard · removeCard · moveCard(같은 묶음 안) · checkCard · checkName · EasyHistory - 시험 resources/tester/test_m01_easycalc_model.py 14개 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VBToTCGk5dqDKeXCEP3vhk --- M01_MasterData/EasyCalc/M01_EasyCalc_Model.ts | 517 +++++++++++++++++- resources/tester/test_m01_easycalc_model.py | 402 ++++++++++++++ 2 files changed, 894 insertions(+), 25 deletions(-) create mode 100644 resources/tester/test_m01_easycalc_model.py diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Model.ts b/M01_MasterData/EasyCalc/M01_EasyCalc_Model.ts index 6768e03d2..ba2f0782e 100644 --- a/M01_MasterData/EasyCalc/M01_EasyCalc_Model.ts +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Model.ts @@ -1,10 +1,16 @@ /* ============================================================================= * M01_EasyCalc_Model.ts * 쉬운 단가산출 Model — 로직 줄(LogicRow) ↔ 카드(EasyDoc · EasyCard) 바꾸기 · 카드 조작 · 검사 · 되돌리기 - * DOM 없음 · 모양은 `docs/raw/M01_쉬운단가/설계.md` 3절 그대로 + * DOM 없음 · 바깥 값은 타입만 가져옴(Node 시험이 이 파일을 바로 돌림) · 모양은 `설계.md` 3절 그대로 + * + * 호표 줄 → 카드: 인력 = person · 재료 = material · 기계경비 로직(GC000267~276) 부름 = machine · 그 밖 로직 = logic + * 수량이 숫자 글이면 qty · 아니면 qtyFormula + lockedWhy(쉬운 말) + * 덧줄 → 카드: 「바탕 * 숫자 / 100」 + 바탕 = 노무비 · 재료비 · 경비 또는 사람 줄 노임 합 = percent · 그 밖 = locked + * 카드 → 줄: 카드 차례대로 호표 · 덧줄을 다시 짬 · 안 바뀐 줄은 원래 글자 그대로(왕복 같음) · 안 다룬 칸은 raw 에서 살림 + * 글 [한, 영] — 화면이 `setEasyModelLanguage(currentLanguageIndex)` 로 맞춤 * ========================================================================== */ -import type { LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api"; +import type { HoLine, LogicInput, LogicRow, NamedFormula } from "../M01_MasterData_UI_Logic_Api"; export type CardKind = "person" | "material" | "machine" | "logic" | "percent" | "locked"; export type CostKind = "노무비" | "재료비" | "경비"; @@ -66,55 +72,516 @@ export interface PickResult { needInputs?: LogicInput[]; } -const notReady = (): never => { - throw new Error("준비 중"); +/* ── 글 ─────────────────────────────────────────────────────────────────── */ + +const TEXT = { + WhyTable: ["품셈 표에서 찾는 값", "Looked up from the standard table"], + WhyCond: ["조건에 따라 달라지는 값", "Changes with conditions"], + WhyInput: ["설계 때 정하는 값으로 정해짐", "Set by values chosen at design time"], + WhyCalc: ["계산으로 정해지는 값", "Worked out by a calculation"], + WhyKind: ["쉬운 길에서 못 고치는 줄", "This line can't be changed here"], + NoNumber: ["얼마인지 숫자로 넣어야 함", "Enter the amount as a number"], + Zero: ["0 은 안 됨 — 빼려면 지움", "0 is not allowed — remove the line instead"], + Negative: ["0 보다 큰 수만 됨", "Only numbers above 0"], + NoName: ["이름이 비어 있음", "The name is empty"], + SameLine: ["같은 줄이 이미 있음 — 한 줄에 합쳐 넣음", "Same line already exists — merge them"], + SameName: ["내가 만든 것에 같은 이름이 있음", "One of yours already has this name"], + /** {per} {qty} {unit} 을 채움 */ + TooBig: ["{per} 에 {qty}{unit} — 너무 큰 수 아닌지?", "{qty}{unit} for {per} — too large?"], +} as const; + +let lang = 0; + +/** 화면 말 고르기 — 0 한국어 · 1 영어 */ +export function setEasyModelLanguage(index: number): void { + lang = index === 1 ? 1 : 0; +} + +const tx = (key: keyof typeof TEXT): string => TEXT[key][lang]; + +/* ── 작은 도움 ──────────────────────────────────────────────────────────── */ + +const COSTS: readonly string[] = ["노무비", "재료비", "경비"]; +const isCost = (v: unknown): v is CostKind => typeof v === "string" && COSTS.includes(v); +/** 기계경비 로직 — `로직_건설품셈_08장_건설기계.json` GC000267~276 */ +const MACHINE_CALL = /^\s*로직\(\s*GC0002(6[7-9]|7[0-6])\s*[,)]/; +const NUMBER_TEXT = /^\s*-?\d+(\.\d+)?\s*$/; +const SIMPLE_TERM = /^[가-힣A-Za-z0-9_.]+$/; + +let seq = 0; +const newId = (): string => `c${++seq}`; + +const clone = (v: T): T => (v === undefined ? v : (JSON.parse(JSON.stringify(v)) as T)); +const isObj = (v: unknown): v is Record => + typeof v === "object" && v !== null && !Array.isArray(v); +const norm = (s: string): string => s.replace(/\s+/g, ""); + +/** 수를 글로 — 지수 꼴 없이 · 뒤 0 뗌 */ +function numText(n: number): string { + const s = String(Number(n.toPrecision(12))); + return s.includes("e") ? n.toFixed(12).replace(/\.?0+$/, "") : s; +} + +/** 바깥을 통째 감싼 괄호 하나 벗김 */ +function unwrap(s: string): string { + const t = s.trim(); + if (!t.startsWith("(") || !t.endsWith(")")) return t; + let depth = 0; + for (let i = 0; i < t.length; i++) { + if (t[i] === "(") depth++; + else if (t[i] === ")" && --depth === 0 && i < t.length - 1) return t; + } + return t.slice(1, -1).trim(); +} + +/** 괄호 밖의 + 로 나눔 */ +function splitPlus(s: string): string[] { + const out: string[] = []; + let depth = 0; + let cur = ""; + for (const ch of s) { + if (ch === "(") depth++; + if (ch === ")") depth--; + if (ch === "+" && depth === 0) { + out.push(cur.trim()); + cur = ""; + } else cur += ch; + } + out.push(cur.trim()); + return out; +} + +const hasName = (formula: string, name: string): boolean => { + const esc = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return new RegExp(`(^|[^가-힣A-Za-z0-9_.'])${esc}($|[^가-힣A-Za-z0-9_'])`).test(formula); }; +/** 잠금 까닭 — 표에서 찾음 > 조건 > 설계 값 > 계산 · 중간 값은 안까지 따라감 */ +function lockedWhy(formula: string, row: LogicRow): string { + const seen = new Set(); + const flags = { table: false, cond: false, input: false }; + const walk = (f: string, depth: number): void => { + if (/찾기\s*\(/.test(f)) flags.table = true; + if (/(만약|최소|최대|올림|버림|반올림)\s*\(|[<>]|==|!=/.test(f)) flags.cond = true; + for (const i of row.입력 ?? []) if (hasName(f, i.이름)) flags.input = true; + if (depth > 6) return; + for (const m of row.중간 ?? []) { + if (seen.has(m.이름) || !hasName(f, m.이름)) continue; + seen.add(m.이름); + walk(m.식, depth + 1); + } + }; + walk(formula, 0); + if (flags.table) return tx("WhyTable"); + if (flags.cond) return tx("WhyCond"); + if (flags.input) return tx("WhyInput"); + return tx("WhyCalc"); +} + +/* ── 덧붙임(%) 꼴 ───────────────────────────────────────────────────────── */ + +/** 사람 줄 하나의 노임 조각 — 「수량 * 요소」 */ +function personTerm(qtyText: string, element: unknown): string { + const q = SIMPLE_TERM.test(qtyText.trim()) ? qtyText.trim() : `(${qtyText.trim()})`; + return `${q} * ${String(element)}`; +} + +/** 「바탕 * 숫자 / 100」 → 바탕 글 · 율 · 아니면 null */ +function parsePercent(formula: string): { base: string; rate: number } | null { + const m = /^([\s\S]*)\*\s*(\d+(?:\.\d+)?)\s*\/\s*100\s*$/.exec(formula.trim()); + if (!m || !m[1].trim()) return null; + return { base: unwrap(m[1]), rate: Number(m[2]) }; +} + +/** 바탕이 사람 줄 노임 합인가 — 사람 줄 모두 · 차례대로 · 빠짐없이 */ +function isPersonSum(base: string, persons: { qtyText: string; element: unknown }[]): boolean { + if (persons.length === 0) return false; + const terms = splitPlus(base); + if (terms.length !== persons.length) return false; + return terms.every((t, i) => { + const p = persons[i]; + const want = personTerm(p.qtyText, p.element); + return norm(t) === norm(want) || norm(t) === norm(`${p.qtyText} * ${String(p.element)}`); + }); +} + +function baseOf( + base: string, + persons: { qtyText: string; element: unknown }[], +): EasyCard["percentBase"] { + if (isCost(base)) return base; + return isPersonSum(base, persons) ? "사람줄합" : null; +} + +/* ── 로직 줄 → 카드 ─────────────────────────────────────────────────────── */ + +const qtyTextOf = (line: HoLine): string => String(line.수량 ?? ""); + +function hoCard(line: HoLine, index: number, row: LogicRow): EasyCard { + const kind: CardKind = + line.종류 === "인력" + ? "person" + : line.종류 === "재료" + ? "material" + : line.종류 === "로직" + ? MACHINE_CALL.test(String(line.요소)) + ? "machine" + : "logic" + : "locked"; + const qtyText = qtyTextOf(line); + const numeric = NUMBER_TEXT.test(qtyText); + return { + id: newId(), + kind, + name: line.이름 ?? "", + spec: line.규격 ?? "", + unit: line.단위 ?? "", + qty: numeric ? Number(qtyText) : null, + qtyFormula: numeric ? null : qtyText, + cost: isCost(line.비목) ? line.비목 : null, + percentBase: null, + element: clone(line.요소), + lockedWhy: kind === "locked" ? tx("WhyKind") : numeric ? null : lockedWhy(qtyText, row), + source: { part: "호표", index }, + raw: clone(line), + }; +} + +function addCardOf( + line: NamedFormula, + index: number, + row: LogicRow, + persons: { qtyText: string; element: unknown }[], +): EasyCard { + const p = parsePercent(line.식); + const base = p ? baseOf(p.base, persons) : null; + const percent = p !== null && base !== null; + return { + id: newId(), + kind: percent ? "percent" : "locked", + name: line.이름 ?? "", + spec: "", + unit: percent ? "%" : "", + qty: percent ? p.rate : null, + qtyFormula: percent ? null : line.식, + cost: isCost(line.비목) ? line.비목 : null, + percentBase: percent ? base : null, + element: null, + lockedWhy: percent ? null : lockedWhy(line.식, row), + source: { part: "덧줄", index }, + raw: clone(line), + }; +} + export function toDoc( - _row: LogicRow, - _meta: { key: string | null; version: string | null; isBase: boolean }, + row: LogicRow, + meta: { key: string | null; version: string | null; isBase: boolean }, ): EasyDoc { - return notReady(); + const ho = row.호표 ?? []; + const persons = ho + .filter((h) => h.종류 === "인력") + .map((h) => ({ qtyText: qtyTextOf(h), element: h.요소 })); + return { + key: meta.key, + version: meta.version, + isBase: meta.isBase, + name: row.이름 ?? "", + spec: typeof row["규격"] === "string" ? (row["규격"] as string) : "", + resultUnit: row.결과단위 ?? "", + inputs: clone(row.입력 ?? []), + cards: [ + ...ho.map((h, i) => hoCard(h, i, row)), + ...(row.덧줄 ?? []).map((d, i) => addCardOf(d, i, row, persons)), + ], + row: clone(row), + }; } -export function toRow(_doc: EasyDoc): LogicRow { - return notReady(); +/** 새 로직의 최소 칸 줄 — 검토 A 4-1 · 구분 · 상세구분 · 소유 · 키는 서버가 채움 */ +export function emptyRow(name = "", per = "㎡"): LogicRow { + return { + 키: "", + 원문번호: "", + 이름: name, + 결과단위: `원/${per}`, + 출처: "자체", + 입력: [], + 중간: [], + 호표: [], + 덧줄: [], + 끝수: null, + }; } -export function addCard(_doc: EasyDoc, _pick: PickResult): EasyDoc { - return notReady(); +/** 빈 종이로 새로 */ +export function newDoc(name = "", per = "㎡"): EasyDoc { + return toDoc(emptyRow(name, per), { key: null, version: null, isBase: false }); } -export function updateCard(_doc: EasyDoc, _id: string, _patch: Partial): EasyDoc { - return notReady(); +/* ── 카드 → 로직 줄 ─────────────────────────────────────────────────────── */ + +const partOf = (c: EasyCard): "호표" | "덧줄" => + c.kind === "percent" ? "덧줄" : c.kind === "locked" ? (c.source?.part ?? "덧줄") : "호표"; + +const TYPE_OF: Record = { + person: "인력", + material: "재료", + machine: "로직", + logic: "로직", +}; + +/** 수량 글 — 식이면 식 · 숫자가 원래 글과 같으면 원래 글(「0.50」 보존) */ +function qtyTextFor(c: EasyCard, rawText: unknown): string { + if (c.qtyFormula !== null) return c.qtyFormula; + if (c.qty === null || !Number.isFinite(c.qty)) return ""; + const keep = + typeof rawText === "string" && NUMBER_TEXT.test(rawText) && Number(rawText) === c.qty; + return keep ? (rawText as string) : numText(c.qty); } -export function removeCard(_doc: EasyDoc, _id: string): EasyDoc { - return notReady(); +/** 칸 쓰기 — 원래 있었거나 값이 있을 때만(없던 빈 칸을 만들지 않음) */ +function setField(o: Record, k: string, v: string): void { + if (v !== "" || k in o) o[k] = v; } -export function moveCard(_doc: EasyDoc, _id: string, _step: -1 | 1): EasyDoc { - return notReady(); +function hoLineOf(c: EasyCard): HoLine { + const raw = isObj(c.raw) ? clone(c.raw) : {}; + const line: Record = raw; + if (c.kind !== "locked") line.종류 = TYPE_OF[c.kind]; + line.요소 = clone(c.element); + setField(line, "이름", c.name); + setField(line, "규격", c.spec); + setField(line, "단위", c.unit); + line.수량 = qtyTextFor(c, raw.수량); + if (c.cost !== null) line.비목 = c.cost; + else if (isCost(raw.비목)) delete line.비목; + return line as unknown as HoLine; } -export function checkCard(_card: EasyCard, _doc: EasyDoc): string | null { - return notReady(); +function percentText(c: EasyCard, persons: { qtyText: string; element: unknown }[]): string { + const rate = numText(c.qty ?? 0); + if (c.percentBase === "사람줄합") { + const terms = persons.map((p) => personTerm(p.qtyText, p.element)); + return `(${terms.length ? terms.join(" + ") : "0"}) * ${rate} / 100`; + } + return `${c.percentBase ?? "노무비"} * ${rate} / 100`; } +function addLineOf(c: EasyCard, persons: { qtyText: string; element: unknown }[]): NamedFormula { + const raw = isObj(c.raw) ? clone(c.raw) : {}; + const line: Record = raw; + line.이름 = c.name; + const rawText = typeof raw.식 === "string" ? raw.식 : ""; + if (c.kind === "percent") { + const p = parsePercent(rawText); + const same = p !== null && p.rate === c.qty && baseOf(p.base, persons) === c.percentBase; + line.식 = same ? rawText : percentText(c, persons); + } else { + line.식 = c.qtyFormula ?? (c.qty !== null ? numText(c.qty) : rawText); + } + if (c.cost !== null) line.비목 = c.cost; + else if (isCost(raw.비목)) delete line.비목; + return line as unknown as NamedFormula; +} + +export function toRow(doc: EasyDoc): LogicRow { + const row = clone(doc.row) as LogicRow & Record; + row.이름 = doc.name; + setField(row, "규격", doc.spec); + row.결과단위 = doc.resultUnit; + row.입력 = clone(doc.inputs); + const ho = doc.cards.filter((c) => partOf(c) === "호표").map(hoLineOf); + const persons = ho + .filter((h) => h.종류 === "인력") + .map((h) => ({ qtyText: qtyTextOf(h), element: h.요소 })); + const add = doc.cards.filter((c) => partOf(c) === "덧줄").map((c) => addLineOf(c, persons)); + if (ho.length || "호표" in row) row.호표 = ho; + if (add.length || "덧줄" in row) row.덧줄 = add; + return row; +} + +/* ── 카드 조작 (모두 새 문서를 돌려줌 · 받은 문서는 안 바꿈) ─────────────── */ + +const COST_OF: Record = { + person: "노무비", + material: "재료비", + machine: null, + logic: null, +}; + +/** 묶음 — 화면의 묶음과 같음(사람 · 재료 · 기계 · 다른 일위대가 · 덧붙임) */ +const groupOf = (c: EasyCard): string => (partOf(c) === "덧줄" ? "덧붙임" : c.kind); + +/** 같은 묶음 마지막 카드 뒤 · 없으면 같은 쪽(호표 · 덧붙임) 끝 · 그것도 없으면 호표는 맨 앞 · 덧붙임은 맨 뒤 */ +function insertAt(cards: EasyCard[], card: EasyCard): EasyCard[] { + const last = (hit: (c: EasyCard) => boolean): number => { + let at = -1; + cards.forEach((c, i) => { + if (hit(c)) at = i; + }); + return at; + }; + let at = last((c) => groupOf(c) === groupOf(card)); + if (at < 0) at = last((c) => partOf(c) === partOf(card)); + if (at < 0 && partOf(card) === "덧줄") at = cards.length - 1; + const out = cards.slice(); + out.splice(at + 1, 0, card); + return out; +} + +export function addCard(doc: EasyDoc, pick: PickResult): EasyDoc { + const card: EasyCard = { + id: newId(), + kind: pick.kind, + name: pick.name, + spec: pick.spec, + unit: pick.unit, + qty: pick.qty, + qtyFormula: null, + cost: COST_OF[pick.kind], + percentBase: null, + element: clone(pick.element), + lockedWhy: null, + source: null, + raw: null, + }; + return withInputs({ ...doc, cards: insertAt(doc.cards, card) }, pick.needInputs ?? []); +} + +/** 설계 때 정하는 값 더하기 — 없는 이름만(기계를 다시 골랐을 때도 씀) */ +export function withInputs(doc: EasyDoc, needs: LogicInput[]): EasyDoc { + const inputs = doc.inputs.slice(); + for (const need of needs) if (!inputs.some((i) => i.이름 === need.이름)) inputs.push(clone(need)); + return inputs.length === doc.inputs.length ? doc : { ...doc, inputs }; +} + +/** 덧붙임(%) 카드 더하기 — 넣을 자리 기본은 화면이 정함(설계.md 1절 9) */ +export function addPercent( + doc: EasyDoc, + name: string, + base: CostKind | "사람줄합", + rate: number | null, + cost: CostKind | null, +): EasyDoc { + const card: EasyCard = { + id: newId(), + kind: "percent", + name, + spec: "", + unit: "%", + qty: rate, + qtyFormula: null, + cost, + percentBase: base, + element: null, + lockedWhy: null, + source: null, + raw: null, + }; + return { ...doc, cards: insertAt(doc.cards, card) }; +} + +/** 고치기 — 숫자로 바꾸기 = `{ qty: 값, qtyFormula: null }` (잠금 까닭도 지움) */ +export function updateCard(doc: EasyDoc, id: string, patch: Partial): EasyDoc { + return { + ...doc, + cards: doc.cards.map((c) => { + if (c.id !== id) return c; + const next: EasyCard = { ...c, ...patch, id: c.id }; + if (next.qtyFormula === null && next.kind !== "locked") next.lockedWhy = null; + return next; + }), + }; +} + +export function removeCard(doc: EasyDoc, id: string): EasyDoc { + return { ...doc, cards: doc.cards.filter((c) => c.id !== id) }; +} + +/** ▲(-1) ▼(1) — 같은 묶음 안 이웃과 자리 바꿈 · 끝이면 그대로 */ +export function moveCard(doc: EasyDoc, id: string, step: -1 | 1): EasyDoc { + const from = doc.cards.findIndex((c) => c.id === id); + if (from < 0) return doc; + const g = groupOf(doc.cards[from]); + let to = from + step; + while (to >= 0 && to < doc.cards.length && groupOf(doc.cards[to]) !== g) to += step; + if (to < 0 || to >= doc.cards.length) return doc; + const cards = doc.cards.slice(); + [cards[from], cards[to]] = [cards[to], cards[from]]; + return { ...doc, cards }; +} + +/* ── 검사 (쉬운 말 한 줄 · 없으면 null) ──────────────────────────────────── */ + +/** 단위별 어림 위 끝 — 넘으면 물음 꼴 경고 */ +const TOO_BIG: Record = { 인: 10, 일: 10, 시간: 24, "%": 100 }; +const TOO_BIG_ELSE = 10000; + +export function checkCard(card: EasyCard, doc: EasyDoc): string | null { + if (!card.name.trim()) return tx("NoName"); + if (card.qtyFormula !== null) return null; + if (card.qty === null || !Number.isFinite(card.qty)) return tx("NoNumber"); + if (card.qty === 0) return tx("Zero"); + if (card.qty < 0) return tx("Negative"); + const same = doc.cards.some( + (c) => + c.id !== card.id && + c.kind === card.kind && + c.name === card.name && + c.spec === card.spec && + JSON.stringify(c.element) === JSON.stringify(card.element) && + (card.kind !== "percent" || c.percentBase === card.percentBase), + ); + if (same) return tx("SameLine"); + if (card.qty > (TOO_BIG[card.unit] ?? TOO_BIG_ELSE)) { + const per = `1${doc.resultUnit.replace(/^원\//, "") || ""}`; + return tx("TooBig") + .replace("{per}", per) + .replace("{qty}", numText(card.qty)) + .replace("{unit}", card.unit); + } + return null; +} + +/** 로직 이름 검사 — mine = 내가 만든 것 이름들(자기 이름은 빼고 넘김) */ +export function checkName(name: string, mine: string[]): string | null { + if (!name.trim()) return tx("NoName"); + return mine.some((m) => m.trim() === name.trim()) ? tx("SameName") : null; +} + +/* ── 되돌리기 ───────────────────────────────────────────────────────────── */ + +const HISTORY_MAX = 200; + +/** 문서 기록 — 처음 연 문서를 push 한 뒤 바뀔 때마다 push · undo 는 앞 문서 */ export class EasyHistory { - push(_doc: EasyDoc): void { - notReady(); + private stack: string[] = []; + private saved: string | null = null; + + push(doc: EasyDoc): void { + const s = JSON.stringify(doc); + if (this.stack[this.stack.length - 1] === s) return; + this.stack.push(s); + if (this.saved === null) this.saved = s; + if (this.stack.length > HISTORY_MAX) this.stack.shift(); } + undo(): EasyDoc | null { - return notReady(); + if (this.stack.length < 2) return null; + this.stack.pop(); + return JSON.parse(this.stack[this.stack.length - 1]) as EasyDoc; } + get canUndo(): boolean { - return notReady(); + return this.stack.length > 1; } + + /** 저장한 뒤(처음은 연 뒤) 바뀜 — 「저장 안 함」 표시 */ get dirty(): boolean { - return notReady(); + return this.stack.length > 0 && this.stack[this.stack.length - 1] !== this.saved; } + markSaved(): void { - notReady(); + this.saved = this.stack[this.stack.length - 1] ?? null; } } diff --git a/resources/tester/test_m01_easycalc_model.py b/resources/tester/test_m01_easycalc_model.py new file mode 100644 index 000000000..b6f6ac8ee --- /dev/null +++ b/resources/tester/test_m01_easycalc_model.py @@ -0,0 +1,402 @@ +"""M01 쉬운 단가산출 Model 시험(34-3b) — Node 로 `M01_EasyCalc_Model.ts` 를 바로 돌림 · 화면 조작은 ORCA. + +· 견본 로직 줄 셋(인력+재료 · 기계+% 덧줄 · 표 찾기 잠금)은 이 시험 안에 둠 — 진짜 마스터에 기대지 않음 +· 왕복(toDoc → toRow 가 원래 줄과 같음) · 카드 종류 · 잠금 까닭 · 더하기 · 지우기 · 차례 · 숫자로 바꾸기 · 되돌리기 · 검사 +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +MODEL = ROOT / "M01_MasterData" / "EasyCalc" / "M01_EasyCalc_Model.ts" + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") + +# ── 견본 줄 ─────────────────────────────────────────────────────────────── +HEAD = {"원문번호": "", "구분": "자체", "상세구분": "시험", "출처": "자체", "소유": "현장"} + +PERSON_MATERIAL = { + **HEAD, + "키": "GX900001", + "이름": "시험 찰쌓기", + "결과단위": "원/㎡", + "입력": [], + "중간": [], + "호표": [ + { + "종류": "인력", + "요소": "LB000005", + "이름": "석공", + "단위": "인", + "수량": "0.072", + "비목": "노무비", + }, + { + "종류": "재료", + "요소": {"구분": "시멘트", "검색어": "시멘트 40kg"}, + "이름": "시멘트", + "규격": "40kg", + "단위": "포", + "수량": "0.50", + "비목": "재료비", + "출처": "산림품셈 12-1-1", + }, + ], + "덧줄": [], + "끝수": None, + "비고": "견본", +} + +MACHINE_PERCENT = { + **HEAD, + "키": "GX900002", + "이름": "시험 깬돌 쌓기", + "결과단위": "원/㎡", + "입력": [{"이름": "지역", "고르기": ["전국평균", "서울"]}], + "중간": [], + "호표": [ + { + "종류": "인력", + "요소": "LB000002", + "이름": "보통인부", + "단위": "인", + "수량": "0.036", + "비목": "노무비", + }, + { + "종류": "로직", + "요소": "로직(GC000270, 기계='0201-0060', 집게='7206-0070', 지역=지역)", + "이름": "굴삭기+집게", + "규격": "0.6㎥", + "단위": "시간", + "수량": "0.27", + }, + { + "종류": "로직", + "요소": "로직(GX000777)", + "이름": "모르터바르기", + "단위": "㎥", + "수량": "0.009", + }, + ], + "덧줄": [ + {"이름": "공구손료", "식": "(0.036 * LB000002) * 2 / 100", "비목": "재료비"}, + {"이름": "잡재료비", "식": "노무비 * 3 / 100", "비목": "재료비", "출처": "산림품셈 1-2-6"}, + ], + "끝수": {"대상": "계", "자리": 0, "방법": "버림"}, +} + +TABLE_LOCKED = { + **HEAD, + "키": "GX900003", + "이름": "시험 메쌓기", + "결과단위": "원/㎡", + "입력": [{"이름": "뒷길이", "단위": "㎝"}, {"이름": "보정", "단위": "배"}], + "중간": [{"이름": "석공품", "식": "찾기(QF000041, 뒷길이=뒷길이).석공"}], + "호표": [ + { + "종류": "인력", + "요소": "LB000005", + "이름": "석공", + "단위": "인", + "수량": "석공품", + "비목": "노무비", + }, + { + "종류": "인력", + "요소": "LB000002", + "이름": "보통인부", + "단위": "인", + "수량": "만약(뒷길이 > 40, 0.4, 0.3)", + "비목": "노무비", + }, + { + "종류": "인력", + "요소": "LB000003", + "이름": "특별인부", + "단위": "인", + "수량": "보정 * 0.1", + "비목": "노무비", + }, + { + "종류": "인력", + "요소": "LB000001", + "이름": "작업반장", + "단위": "인", + "수량": "0.1 * 2", + "비목": "노무비", + }, + ], + "덧줄": [ + { + "이름": "공구손료", + "식": "(석공품 * LB000005 + (만약(뒷길이 > 40, 0.4, 0.3)) * LB000002" + " + (보정 * 0.1) * LB000003 + (0.1 * 2) * LB000001) * 3 / 100", + "비목": "경비", + }, + { + "이름": "잡재료", + "식": "주재료비 * 찾기(QC000589, 구분='잡재료비').값 / 100", + "비목": "재료비", + }, + ], + "끝수": None, +} + +SAMPLES = [PERSON_MATERIAL, MACHINE_PERCENT, TABLE_LOCKED] +META = {"key": "GX900001", "version": "v1", "isBase": False} + + +def _run(body: str, *args: object) -> object: + """견본을 받아 Model 을 돌리는 Node 글 — `M` = Model 모듈 · `A` = 넘긴 인자 · `out(x)` 로 답""" + script = ( + f"const M = await import({json.dumps(MODEL.as_uri())});\n" + "const A = JSON.parse(process.argv[1]);\n" + "const out = (x: unknown) => console.log(JSON.stringify(x));\n" + body + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script, json.dumps(list(args))], + capture_output=True, text=True, encoding="utf-8", timeout=60, cwd=ROOT, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _doc(row: dict) -> dict: + return _run("out(M.toDoc(A[0], A[1]));", row, META) + + +# ── 왕복 ─────────────────────────────────────────────────────────────────── +@pytest.mark.parametrize("row", SAMPLES, ids=["인력재료", "기계덧붙임", "표찾기잠금"]) +def test_왕복_같음(row: dict) -> None: + back = _run("out(M.toRow(M.toDoc(A[0], A[1])));", row, META) + assert back == row + + +def test_새_로직_최소칸() -> None: + row = _run("out(M.toRow(M.newDoc('새 일', 'm')));") + assert row == { + "키": "", + "원문번호": "", + "이름": "새 일", + "결과단위": "원/m", + "출처": "자체", + "입력": [], + "중간": [], + "호표": [], + "덧줄": [], + "끝수": None, + } + + +# ── 카드 바꾸기 ───────────────────────────────────────────────────────────── +def test_카드_종류와_숫자() -> None: + d = _doc(MACHINE_PERCENT) + assert [c["kind"] for c in d["cards"]] == ["person", "machine", "logic", "percent", "percent"] + person, machine, logic, tool, misc = d["cards"] + assert person["qty"] == 0.036 and person["cost"] == "노무비" and person["qtyFormula"] is None + assert machine["cost"] is None and machine["spec"] == "0.6㎥" + assert logic["cost"] is None + assert (tool["percentBase"], tool["qty"], tool["unit"], tool["cost"]) == ( + "사람줄합", + 2, + "%", + "재료비", + ) + assert misc["percentBase"] == "노무비" and misc["qty"] == 3 + assert d["inputs"] == MACHINE_PERCENT["입력"] and d["resultUnit"] == "원/㎡" + + +def test_잠금_까닭_쉬운말() -> None: + d = _doc(TABLE_LOCKED) + kinds = [c["kind"] for c in d["cards"]] + assert kinds == ["person"] * 4 + ["percent", "locked"] + why = [c["lockedWhy"] for c in d["cards"]] + assert why[:4] == [ + "품셈 표에서 찾는 값", + "조건에 따라 달라지는 값", + "설계 때 정하는 값으로 정해짐", + "계산으로 정해지는 값", + ] + assert d["cards"][0]["qtyFormula"] == "석공품" and d["cards"][0]["qty"] is None + assert d["cards"][4]["percentBase"] == "사람줄합" + assert why[5] == "품셈 표에서 찾는 값" + for w in filter(None, why): + for bad in ("키", "식", "호표", "덧줄", "비목", "정본", "자체"): + assert bad not in w + + +# ── 카드 조작 ─────────────────────────────────────────────────────────────── +def test_더하기_묶음_뒤_기계는_설계값_더함() -> None: + pick_person = { + "kind": "person", + "name": "석공", + "spec": "", + "unit": "인", + "element": "LB000005", + "qty": 0.5, + } + pick_machine = { + "kind": "machine", + "name": "굴삭기", + "spec": "0.7㎥", + "unit": "시간", + "element": "로직(GC000267, 기계='0201-0020', 지역=지역, 보정작업='해당 없음')", + "qty": 0.3, + "needInputs": [{"이름": "지역", "고르기": ["전국평균"]}], + } + got = _run( + "let d = M.toDoc(A[0], A[1]);\n" + "d = M.addCard(d, A[2]); d = M.addCard(d, A[3]); d = M.addCard(d, A[3]);\n" + "d = M.addPercent(d, '공구손료', '사람줄합', 2, '경비');\n" + "out(M.toRow(d));", + PERSON_MATERIAL, + META, + pick_person, + pick_machine, + ) + ho = got["호표"] + assert [h["이름"] for h in ho] == ["석공", "석공", "시멘트", "굴삭기", "굴삭기"] + assert ho[1] == { + "종류": "인력", + "요소": "LB000005", + "이름": "석공", + "단위": "인", + "수량": "0.5", + "비목": "노무비", + } + assert ho[3]["종류"] == "로직" and "비목" not in ho[3] and ho[3]["규격"] == "0.7㎥" + assert got["입력"] == [{"이름": "지역", "고르기": ["전국평균"]}] + assert got["덧줄"] == [ + {"이름": "공구손료", "식": "(0.072 * LB000005 + 0.5 * LB000005) * 2 / 100", "비목": "경비"} + ] + + +def test_지우기와_사람줄합_다시짬() -> None: + got = _run( + "let d = M.toDoc(A[0], A[1]);\n" + "const extra = M.addCard(d, {kind:'person', name:'석공', spec:'', unit:'인', element:'LB000005', qty:0.1});\n" + "const removed = M.removeCard(extra, d.cards[0].id);\n" + "out([M.toRow(extra), M.toRow(removed), M.toRow(M.removeCard(d, d.cards[0].id))]);", + MACHINE_PERCENT, + META, + ) + two, one, none = got + assert two["덧줄"][0]["식"] == "(0.036 * LB000002 + 0.1 * LB000005) * 2 / 100" + assert two["덧줄"][1] == MACHINE_PERCENT["덧줄"][1] + assert one["덧줄"][0]["식"] == "(0.1 * LB000005) * 2 / 100" + assert [h["이름"] for h in none["호표"]] == ["굴삭기+집게", "모르터바르기"] + assert none["덧줄"][0]["식"] == "(0) * 2 / 100" + + +def test_차례_같은_묶음_안에서만() -> None: + got = _run( + "const d = M.toDoc(A[0], A[1]);\n" + "const ids = d.cards.map((c: any) => c.id);\n" + "const names = (x: any) => x.cards.map((c: any) => c.name);\n" + "out([names(M.moveCard(d, ids[1], 1)), names(M.moveCard(d, ids[0], -1))," + " names(M.moveCard(d, ids[3], 1)), names(M.moveCard(d, ids[0], 1)) ]);", + TABLE_LOCKED, + META, + ) + down, top, add_down, end = got + assert down == ["석공", "특별인부", "보통인부", "작업반장", "공구손료", "잡재료"] + assert top == ["석공", "보통인부", "특별인부", "작업반장", "공구손료", "잡재료"] + assert add_down == top # 사람 묶음 끝 → 덧붙임으로 안 넘어감 + assert end == ["보통인부", "석공", "특별인부", "작업반장", "공구손료", "잡재료"] + + +def test_잠금을_숫자로_바꾸기() -> None: + got = _run( + "const d = M.toDoc(A[0], A[1]);\n" + "const n = M.updateCard(d, d.cards[0].id, { qty: 0.5, qtyFormula: null });\n" + "out([n.cards[0], M.toRow(n), d.cards[0].qtyFormula]);", + TABLE_LOCKED, + META, + ) + card, row, before = got + assert card["lockedWhy"] is None and card["qty"] == 0.5 + assert row["호표"][0]["수량"] == "0.5" + assert before == "석공품" # 받은 문서는 안 바뀜 + # 석공 수량이 바뀌어 사람 줄 합이 다시 짜임 + assert row["덧줄"][0]["식"].startswith("(0.5 * LB000005 + ") + + +def test_되돌리기와_저장안함() -> None: + got = _run( + "const h = new M.EasyHistory();\n" + "const d0 = M.toDoc(A[0], A[1]);\n" + "const log: unknown[] = [];\n" + "h.push(d0); log.push([h.canUndo, h.dirty]);\n" + "const d1 = M.updateCard(d0, d0.cards[0].id, { qty: 0.08 });\n" + "h.push(d1); h.push(d1); log.push([h.canUndo, h.dirty]);\n" + "h.markSaved(); log.push([h.dirty]);\n" + "const back = h.undo(); log.push([back.cards[0].qty, h.canUndo, h.dirty, h.undo()]);\n" + "out(log);", + PERSON_MATERIAL, + META, + ) + assert got == [[False, False], [True, True], [False], [0.072, False, True, None]] + + +# ── 검사 ─────────────────────────────────────────────────────────────────── +def test_검사_쉬운말_한줄() -> None: + got = _run( + "const d = M.toDoc(A[0], A[1]);\n" + "const c = d.cards[0];\n" + "const t = (p: any) => M.checkCard({ ...c, ...p }, d);\n" + "out([t({}), t({ qty: null }), t({ qty: Number.NaN }), t({ qty: 0 }), t({ qty: -1 })," + " t({ name: ' ' }), t({ qty: 12 }), t({ qty: null, qtyFormula: '석공품' })," + " M.checkCard(M.addCard(d, {kind:'person', name:'석공', spec:'', unit:'인', element:'LB000005'," + " qty:0.1}).cards[1], M.addCard(d, {kind:'person', name:'석공', spec:'', unit:'인'," + " element:'LB000005', qty:0.1}))," + " M.checkName('시험 찰쌓기', ['시험 찰쌓기']), M.checkName('', []), M.checkName('새것', ['헌것'])]);", + PERSON_MATERIAL, + META, + ) + assert got == [ + None, + "얼마인지 숫자로 넣어야 함", + "얼마인지 숫자로 넣어야 함", + "0 은 안 됨 — 빼려면 지움", + "0 보다 큰 수만 됨", + "이름이 비어 있음", + "1㎡ 에 12인 — 너무 큰 수 아닌지?", + None, + "같은 줄이 이미 있음 — 한 줄에 합쳐 넣음", + "내가 만든 것에 같은 이름이 있음", + "이름이 비어 있음", + None, + ] + + +def test_영어_글() -> None: + got = _run( + "M.setEasyModelLanguage(1);\n" + "const d = M.toDoc(A[0], A[1]);\n" + "out([d.cards[0].lockedWhy, M.checkCard({ ...d.cards[1], qty: 0, qtyFormula: null }, d)]);", + TABLE_LOCKED, + META, + ) + assert got == [ + "Looked up from the standard table", + "0 is not allowed — remove the line instead", + ] + + +def test_화면_글에_금지_낱말_없음() -> None: + src = MODEL.read_text(encoding="utf-8") + block = src[src.index("const TEXT = {") : src.index("} as const;")] + korean = [ko for _, ko in re.findall(r'(\w+):\s*\[\s*"([^"]*)"', block)] + assert len(korean) == 12 + for text in korean: + for bad in ("키", "식", "호표", "덧줄", "비목", "중간 값", "끝수", "정본", "자체", "별칭"): + assert bad not in text, (bad, text) From 5cc5b2d63abe7217f04c52891491e62602507390 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 09:37:52 +0900 Subject: [PATCH 06/12] =?UTF-8?q?feat(m01-easycalc):=20=EC=89=AC=EC=9A=B4?= =?UTF-8?q?=20=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=20=EA=B3=A0=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0=20=EC=B0=BD=20Pick=20=EC=98=AC=EB=A6=BC=20=E2=80=94?= =?UTF-8?q?=20=EC=82=AC=EB=9E=8C=20=C2=B7=20=EC=9E=AC=EB=A3=8C=20=C2=B7=20?= =?UTF-8?q?=EA=B8=B0=EA=B3=84=20=C2=B7=20=EB=8B=A4=EB=A5=B8=20=EC=9D=BC?= =?UTF-8?q?=EC=9C=84=EB=8C=80=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 물음 셋(무엇 → 어떤 것 → 얼마) · openEasyPick(kind?) → PickResult | null Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU --- M01_MasterData/EasyCalc/M01_EasyCalc_Pick.css | 100 ++++ M01_MasterData/EasyCalc/M01_EasyCalc_Pick.ts | 456 ++++++++++++++++++ 2 files changed, 556 insertions(+) create mode 100644 M01_MasterData/EasyCalc/M01_EasyCalc_Pick.css create mode 100644 M01_MasterData/EasyCalc/M01_EasyCalc_Pick.ts diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Pick.css b/M01_MasterData/EasyCalc/M01_EasyCalc_Pick.css new file mode 100644 index 000000000..3c1b40947 --- /dev/null +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Pick.css @@ -0,0 +1,100 @@ +.m01-easypick { + --modal-max: 86vh; + width: min(760px, 94vw); + font-size: var(--text-body-lg, 1.125rem); +} +.m01-easypick__ask { + margin: 0; + font-size: 1.35em; + color: var(--color-text); +} +.m01-easypick__muted { + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} +.m01-easypick__kinds { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: var(--spacing-12); +} +.m01-easypick__kind, +.m01-easypick__row { + display: flex; + gap: var(--spacing-8); + padding: var(--spacing-12) var(--spacing-16); + border: 2px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface); + color: var(--color-text); + font: inherit; + text-align: left; + cursor: pointer; +} +.m01-easypick__kind { + flex-direction: column; + min-height: 96px; +} +.m01-easypick__kind strong { + font-size: 1.3em; +} +.m01-easypick__kind span { + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} +.m01-easypick__kind:hover, +.m01-easypick__row:hover, +.m01-easypick__kind:focus-visible, +.m01-easypick__row:focus-visible { + border-color: var(--color-accent); + outline: 3px solid var(--color-accent); + outline-offset: 1px; +} +.m01-easypick__bar { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); + align-items: center; +} +.m01-easypick__bar > :last-child { + flex: 1 1 200px; +} +.m01-easypick__list { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + max-height: 46vh; + overflow: auto; +} +.m01-easypick__row { + align-items: center; + justify-content: space-between; + min-height: 52px; +} +.m01-easypick__name { + flex: 1 1 auto; + min-width: 0; +} +.m01-easypick__unit, +.m01-easypick__price { + flex: none; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} +.m01-easypick__tag { + margin-left: var(--spacing-8); + padding: 0 var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-pills); + color: var(--color-text-secondary); + font-size: var(--text-caption); +} +.m01-easypick__chosen { + margin: 0; + font-size: 1.15em; +} +.m01-easypick__rough { + min-height: 1.4em; + margin: 0; + color: var(--color-accent); + font-weight: 600; +} diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Pick.ts b/M01_MasterData/EasyCalc/M01_EasyCalc_Pick.ts new file mode 100644 index 000000000..e08cb5e85 --- /dev/null +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Pick.ts @@ -0,0 +1,456 @@ +/* ============================================================================= + * M01_EasyCalc_Pick.ts + * 쉬운 단가산출 고르기 창 — 물음 셋(① 무엇 → ② 어떤 것 → ③ 얼마) · 한 화면 한 물음. + * 돌려주는 PickResult.element = 호표 「요소」 칸에 들어갈 값 그대로. 모양은 설계.md 3절. + * 글 [한, 영] 짝은 아래 TEXT. 이 폴더째 지워도 됨. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { + createButton, + createInputField, + createSelectField, + el, + showToast, +} from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import { fetchPick, fetchSubs, type SubInfo } from "../M01_MasterData_Api_Fetch"; +import { fetchMaterials } from "../M01_MasterData_UI_Logic_Api"; +import { listEasy, listMachines, machineElement } from "./M01_EasyCalc_Api"; +import type { PickResult } from "./M01_EasyCalc_Model"; +import "./M01_EasyCalc_Pick.css"; + +type Kind = PickResult["kind"]; + +const TEXT = { + Title: ["넣을 것 고르기", "Pick what to add"], + Close: ["닫기", "Close"], + Back: ["← 뒤로", "← Back"], + AskKind: ["무엇을 넣을까요?", "What do you want to add?"], + AskWhich: ["어떤 것인가요?", "Which one?"], + AskQty: ["얼마나 쓰나요?", "How much?"], + Person: ["사람", "People"], + Material: ["재료", "Material"], + Machine: ["기계", "Machine"], + Logic: ["다른 일위대가", "Another unit-price job"], + PersonDesc: ["일하는 사람 — 석공 · 보통인부", "Workers — mason, laborer"], + MaterialDesc: ["쓰는 물건 — 돌 · 시멘트 · 관", "Things used — stone, cement, pipe"], + MachineDesc: ["쓰는 기계 — 굴삭기 · 덤프트럭", "Machines used — excavator, truck"], + LogicDesc: ["이미 만들어 둔 일을 가져다 씀", "Reuse a job already made"], + Search: ["이름을 쳐서 찾기", "Type a name to search"], + AllSub: ["전체", "All"], + SubLabel: ["구분", "Group"], + DetailLabel: ["상세구분", "Detail"], + Hint: ["구분을 고르거나 이름을 쳐 보세요", "Choose a group or type a name"], + Loading: ["찾는 중…", "Searching…"], + None: ["찾는 것 없음", "Nothing found"], + Count: ["{n} 개 중 앞 {m} 개", "First {m} of {n}"], + NoPrice: ["단가 없음", "No price"], + PerDay: ["하루 {p} 원", "{p} won per day"], + PerUnit: ["{u} 당 {p} 원", "{p} won per {u}"], + Picked: ["고른 것", "Chosen"], + QtyPlace: ["예: 0.072", "e.g. 0.072"], + QtyLabel: ["{u} (숫자)", "{u} (number)"], + Rough: ["금액 어림 약 {m} 원", "About {m} won"], + Add: ["넣기", "Add"], + QtyBad: ["0 보다 큰 숫자를 쳐 주세요", "Enter a number above 0"], + Failed: ["불러오지 못함", "Could not load"], +} as const; + +const te = (key: keyof typeof TEXT, vars: Record = {}): string => + Object.entries(vars).reduce( + (text, [k, v]) => text.replace(`{${k}}`, String(v)), + TEXT[key][currentLanguageIndex] as string, + ); + +const won = (n: number): string => Math.round(n).toLocaleString("ko-KR"); +const MAT_FILE = "재료_자재품목.json"; + +/** 목록 한 줄 — 고르면 PickResult(수량 빼고) 를 만들어 줌 */ +interface Candidate { + name: string; + spec: string; + unit: string; + /** 줄 단위 1 당 단가 · 모르면 null — 금액 어림용 */ + price: number | null; + /** 줄 오른쪽 글(단가 표시) */ + priceText: string; + tag?: string; + make: () => Promise>; +} + +interface Filter { + q: string; + sub: string; + detail: string; +} + +interface Source { + title: string; + desc: string; + /** 구분 · 상세구분 칸을 보일지(재료만) */ + grouped?: boolean; + /** 이름 · 구분이 비면 목록을 안 부르고 null(안내 글만) */ + load: (f: Filter) => Promise<{ total: number; items: Candidate[] } | null>; +} + +const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null); + +const SOURCES: Partial> = { + person: { + title: te("Person"), + desc: te("PersonDesc"), + load: async (f) => { + const { total, items } = await fetchPick("job", f.q); + return { + total, + items: items.map((it) => { + const price = num(it.값); + return { + name: it.이름, + spec: it.규격, + unit: "인", + price, + priceText: price === null ? te("NoPrice") : te("PerDay", { p: won(price) }), + make: async () => ({ + kind: "person", + name: it.이름, + spec: it.규격, + unit: "인", + element: it.ref, + }), + }; + }), + }; + }, + }, + material: { + title: te("Material"), + desc: te("MaterialDesc"), + grouped: true, + load: async (f) => { + if (!f.q && !f.sub) return null; + const found = f.q + ? await fetchPick("price", f.q).then((r) => ({ + total: r.total, + items: r.items + .filter((it) => !f.sub || (it as { 구분?: string }).구분 === f.sub) + .map((it) => ({ ...it, 구분: (it as { 구분?: string }).구분, 상세구분: undefined })), + })) + : await fetchMaterials({ sub: f.sub, detail: f.detail || undefined }).then((r) => ({ + total: r.total, + items: r.items.map((it) => ({ ...it, 구분: it.구분 ?? f.sub })), + })); + return { + total: found.total, + items: found.items.map((it) => { + const name = it.이름 ?? ""; + const spec = it.규격 ?? ""; + const unit = typeof it.단위 === "string" ? it.단위 : ""; + const price = num(it.값); + const element: Record = { 구분: it.구분 ?? f.sub }; + const detail = (it as { 상세구분?: string }).상세구분 ?? f.detail; + if (detail) element["상세구분"] = detail; + element["검색어"] = `${name} ${spec}`.trim(); + return { + name, + spec, + unit, + price, + priceText: + price === null ? te("NoPrice") : te("PerUnit", { u: unit || "1", p: won(price) }), + tag: element["구분"], + make: async () => ({ kind: "material", name, spec, unit, element }), + }; + }), + }; + }, + }, + machine: { + title: te("Machine"), + desc: te("MachineDesc"), + load: async (f) => { + const items = await listMachines(f.q); + return { + total: items.length, + items: items.map((m) => ({ + name: m.이름, + spec: m.규격, + unit: m.단위, + price: null, + priceText: "", + make: async () => { + const { element, needInputs } = await machineElement(m.분류번호); + return { + kind: "machine", + name: m.이름, + spec: m.규격, + unit: m.단위, + element, + needInputs, + }; + }, + })), + }; + }, + }, + logic: { + title: te("Logic"), + desc: te("LogicDesc"), + load: async (f) => { + // 돈으로 나오는 일위대가만 — 「원/㎡」 꼴 + const items = (await listEasy(f.q)).filter((e) => e.resultUnit.startsWith("원/")); + return { + total: items.length, + items: items.map((e) => { + const unit = e.resultUnit.slice(2); + return { + name: e.name, + spec: "", + unit, + price: null, + priceText: "", + tag: e.owner, + make: async () => ({ + kind: "logic", + name: e.name, + spec: "", + unit, + element: `로직(${e.key})`, + }), + }; + }), + }; + }, + }, +}; + +/** 물음 셋 — 고른 것 · 취소는 null */ +export function openEasyPick(kind?: Kind): Promise { + return new Promise((resolve) => { + let settled = false; + const finish = (r: PickResult | null): void => { + if (!settled) resolve(r); + settled = true; + }; + openModal({ + title: te("Title"), + closeLabel: te("Close"), + dialogClass: "m01-easypick", + beforeClose: () => (finish(null), true), + mount: (body, close) => { + const done = (r: PickResult): void => (finish(r), close()); + const stepKinds = (): void => { + body.replaceChildren( + el("h4", { className: "m01-easypick__ask", text: te("AskKind") }), + kindButtons(), + ); + body.querySelector("button")?.focus(); + }; + const kindButtons = (): HTMLElement => { + const order: Kind[] = ["person", "material", "machine", "logic"]; + const grid = el("div", { className: "m01-easypick__kinds" }); + order.forEach((k, i) => { + const src = SOURCES[k]; + const b = el("button", { + className: "m01-easypick__kind", + attrs: { type: "button", "data-kind": k, "aria-keyshortcuts": String(i + 1) }, + children: [ + el("strong", { text: src?.title ?? labelOf(k) }), + el("span", { text: src?.desc ?? descOf(k) }), + ], + }); + b.addEventListener("click", () => stepList(k, true)); + grid.append(b); + }); + grid.addEventListener("keydown", (ev) => { + const k = order[Number(ev.key) - 1]; + if (k) stepList(k, true); + }); + return grid; + }; + const stepList = (k: Kind, canBack: boolean): void => { + const src = SOURCES[k]; + if (!src) return; + const back = canBack ? stepKinds : null; + const search = createInputField({ type: "search", placeholder: te("Search") }); + const filter: Filter = { q: "", sub: "", detail: "" }; + const list = el("div", { className: "m01-easypick__list", attrs: { role: "listbox" } }); + const count = el("span", { className: "m01-easypick__muted" }); + const bar: HTMLElement[] = [search.root]; + let seq = 0; + const load = async (): Promise => { + const mine = ++seq; + count.textContent = ""; + list.replaceChildren( + el("p", { className: "m01-easypick__muted", text: te("Loading") }), + ); + try { + const found = await src.load(filter); + if (mine !== seq) return; // 최신 응답만 + if (!found) + return void list.replaceChildren( + el("p", { className: "m01-easypick__muted", text: te("Hint") }), + ); + count.textContent = te("Count", { n: found.total, m: found.items.length }); + list.replaceChildren( + ...(found.items.length + ? found.items.map((c) => + rowOf(c, () => stepQty(k, c, back ? () => stepList(k, canBack) : null)), + ) + : [el("p", { className: "m01-easypick__muted", text: te("None") })]), + ); + } catch { + if (mine === seq) showToast(te("Failed"), "error"); + } + }; + if (src.grouped) bar.unshift(...subFilters(filter, load)); + let timer = 0; + search.input.addEventListener("input", () => { + filter.q = search.input.value.trim(); + window.clearTimeout(timer); + timer = window.setTimeout(load, 250); + }); + body.replaceChildren( + ...(back ? [createButton({ label: te("Back"), variant: "ghost", onClick: back })] : []), + el("h4", { className: "m01-easypick__ask", text: `${src.title} — ${te("AskWhich")}` }), + el("div", { className: "m01-easypick__bar", children: bar }), + count, + list, + ); + body.onkeydown = (ev) => { + if (back && ev.altKey && ev.key === "ArrowLeft") back(); + else if (ev.key === "ArrowDown" || ev.key === "ArrowUp") moveFocus(body, ev); + }; + search.input.focus(); + void load(); + }; + const stepQty = (k: Kind, c: Candidate, back: (() => void) | null): void => { + const qty = createInputField({ + type: "text", + label: te("QtyLabel", { u: c.unit || "—" }), + placeholder: te("QtyPlace"), + }); + qty.input.inputMode = "decimal"; + const rough = el("p", { className: "m01-easypick__rough" }); + const parse = (): number | null => { + const n = Number(qty.input.value.trim().replace(/,/g, "")); + return qty.input.value.trim() !== "" && Number.isFinite(n) && n > 0 ? n : null; + }; + const refresh = (): void => { + const n = parse(); + rough.textContent = + n !== null && c.price !== null ? te("Rough", { m: won(n * c.price) }) : ""; + qty.setError(undefined); + }; + const submit = async (): Promise => { + const n = parse(); + if (n === null) return qty.setError(te("QtyBad")); + done({ ...(await c.make()), qty: n }); + }; + qty.input.addEventListener("input", refresh); + qty.input.addEventListener("keydown", (ev) => ev.key === "Enter" && void submit()); + body.replaceChildren( + ...(back ? [createButton({ label: te("Back"), variant: "ghost", onClick: back })] : []), + el("h4", { className: "m01-easypick__ask", text: te("AskQty") }), + el("p", { + className: "m01-easypick__chosen", + children: [ + el("span", { className: "m01-easypick__muted", text: te("Picked") }), + el("strong", { text: ` ${c.name} ${c.spec}`.trimEnd() }), + ], + }), + qty.root, + rough, + createButton({ label: te("Add"), variant: "filled", onClick: () => void submit() }), + ); + body.onkeydown = (ev) => ev.altKey && ev.key === "ArrowLeft" && back?.(); + qty.input.focus(); + void k; + }; + if (kind) stepList(kind, false); + else stepKinds(); + }, + }); + }); +} + +const labelOf = (k: Kind): string => + te(({ person: "Person", material: "Material", machine: "Machine", logic: "Logic" } as const)[k]); +const descOf = (k: Kind): string => + te( + ( + { + person: "PersonDesc", + material: "MaterialDesc", + machine: "MachineDesc", + logic: "LogicDesc", + } as const + )[k], + ); + +/** 목록 줄 — 이름 · 크기 · 단위 · 단가 */ +function rowOf(c: Candidate, onPick: () => void): HTMLElement { + const b = el("button", { + className: "m01-easypick__row", + attrs: { type: "button", role: "option" }, + children: [ + el("span", { + className: "m01-easypick__name", + children: [ + el("strong", { text: c.name }), + ...(c.spec ? [el("span", { className: "m01-easypick__muted", text: ` ${c.spec}` })] : []), + ...(c.tag ? [el("span", { className: "m01-easypick__tag", text: c.tag })] : []), + ], + }), + el("span", { className: "m01-easypick__unit", text: c.unit }), + el("span", { className: "m01-easypick__price", text: c.priceText }), + ], + }); + b.addEventListener("click", onPick); + return b; +} + +/** 위·아래 화살표로 찾기 칸 ↔ 줄 사이 오감 */ +function moveFocus(body: HTMLElement, ev: KeyboardEvent): void { + const items = [...body.querySelectorAll("input[type=search], .m01-easypick__row")]; + const at = items.indexOf(document.activeElement as HTMLElement); + if (at < 0) return; + const next = items[at + (ev.key === "ArrowDown" ? 1 : -1)]; + if (!next) return; + ev.preventDefault(); + next.focus(); +} + +/** 재료 구분 → 상세구분 고르기 칸 둘 — 바꾸면 load 다시 */ +function subFilters(filter: Filter, load: () => Promise): HTMLElement[] { + let subs: SubInfo[] = []; + const all = { value: "", text: te("AllSub") }; + const subBox = createSelectField({ + options: [all], + value: "", + compact: true, + ariaLabel: te("SubLabel"), + onChange: (v) => ((filter.sub = v), (filter.detail = ""), fillDetail(), void load()), + }); + const detailBox = createSelectField({ + options: [all], + value: "", + compact: true, + ariaLabel: te("DetailLabel"), + onChange: (v) => ((filter.detail = v), void load()), + }); + const fillDetail = (): void => { + const details = subs.find((s) => s.name === filter.sub)?.details ?? []; + detailBox.root.hidden = !details.length; + detailBox.setOptions([all, ...details.map((d) => ({ value: d, text: d }))], ""); + }; + fillDetail(); + void fetchSubs({ file: MAT_FILE }) + .then((r) => { + subs = r; + subBox.setOptions([all, ...r.map((s) => ({ value: s.name, text: s.name }))], filter.sub); + }) + .catch(() => showToast(te("Failed"), "error")); + return [subBox.root, detailBox.root]; +} From 2165722dc6674470368862271cd11f0d90f8bd19 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 09:38:02 +0900 Subject: [PATCH 07/12] =?UTF-8?q?chore(M01):=20=EC=89=AC=EC=9A=B4=20?= =?UTF-8?q?=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=20=EA=B2=80=ED=86=A0=20B?= =?UTF-8?q?=20=E2=80=94=20=EC=A7=80=EA=B8=88=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EA=B1=B8=EC=9D=8C=20=EC=9E=AC=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YAxqrz1iadqdygC6bE3pic --- docs/raw/M01_쉬운단가/쉬운단가_검토_B_화면.md | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 docs/raw/M01_쉬운단가/쉬운단가_검토_B_화면.md diff --git a/docs/raw/M01_쉬운단가/쉬운단가_검토_B_화면.md b/docs/raw/M01_쉬운단가/쉬운단가_검토_B_화면.md new file mode 100644 index 000000000..66d047bc6 --- /dev/null +++ b/docs/raw/M01_쉬운단가/쉬운단가_검토_B_화면.md @@ -0,0 +1,141 @@ +# 34-1B 지금 단가산출 화면 걸음 재기 (sub6 · 조사만) + +⬜ 브레인 답 필요 — 화면에 「추가」라는 글의 단추가 없음. 사용자가 말한 「추가」 = 위 단추 줄 첫째 진한 단추 「식으로 새로 만들기」로 봄(유일한 새로 만들기 단추). 다르면 34-3a 단추 자리가 바뀜. + +- 서버 8008 · 화면 5181 · ORCA 탭 하나(끝나고 닫음) · 판정 전 세션 SYSTEM_ADMIN 확인. +- 저장 · 지우기 안 누름. 줄 추가 · 수량 · 단가 바꾸기는 캐시 초안(sessionStorage)에만 넣어 재고 끝에 지움. 끝난 뒤 서버 로직 1,469 개 · 자체(GX) 0 개 그대로 · git 깨끗. +- 사진 — `tmp/쉬운단가_B_사진/` (s01 목록 … s10 알약 창). + +## 1. 화면 자리 + +| 무엇 | 화면 위치 | 파일:줄 | +| --- | --- | --- | +| 들어가는 곳 | 왼쪽 패널 맨 아래 「단가산출 로직 ▸」 상자 | `M01_MasterData_UI_Side.ts:439-440` · 전환 `M01_MasterData_UI_Page.ts` `openLogicTab` | +| 로직 목록 | 가운데 표 · 30 줄씩 49 쪽 · 1,469 줄 · 이름 찾기 · 「막힘만」 | `M01_MasterData_UI_Logic_List.ts` · 걸기 `M01_MasterData_UI_LogicLab.ts:97` | +| 상세 | 줄 누르면 네 상자 — 기본정보 · 단가산출 상세 · 텍스트 수식 · 시험 계산 | `M01_MasterData_UI_LogicLab_Detail.ts:479-505` | +| 위 단추 줄 | 「← 로직 목록」 「단가산출 로직」 (빈칸) [식으로 새로 만들기] [로직 지우기] [고친 것 버리기] [저장] [본떠 만들기] | `M01_MasterData_UI_LogicLab.ts:394-418` | +| 「추가」로 본 단추 | 「식으로 새로 만들기」 — 진한 남색 · 단추 줄 왼쪽 첫째 | `M01_MasterData_UI_LogicLab.ts:400` | +| 그 옆 빈 자리 | 제목과 첫 단추 사이 넓은 빈칸(제목 오른쪽 여백 auto) — `pending`(「저장 안 한 로직 N 개」) 글 뒤 · `:400` 앞에 끼우면 「식으로 새로 만들기」 바로 왼쪽 | `M01_MasterData_UI_LogicLab.ts:398-400` · 줄 모양 `M01_MasterData_UI_Logic_Style.css:64-75` | + +- 목록일 때도 「로직 지우기」(빨강) · 「본떠 만들기」가 켜져 있음 — 누르면 아무 일 없음 / 「먼저 로직을 고르세요」 알림. + +## 2. 대표 일 여섯 — 걸음 + +누름 = 단추 누름 + 칸에 글 넣기 1 번씩. 창 = 지나간 화면·띄움창 수. + +| 일 | 길 | 누름 | 창 | 막힌 곳 | +| --- | --- | --- | --- | --- | +| ① 새 로직(인력 2 · 재료 1 · 경비 1) | 식으로 새로 만들기 | 38 | 5~6 | 첫 식이 단위 검사에 걸림 · 기계 고르기 깨짐 · 시험 계산 멈춤 | +| ② 있는 로직에 줄 하나 | 기본 로직 → 본떠 만들기 → 사본 편집기 | 10 (자체 로직이면 5) | 5 | 기본 로직은 줄 추가 단추 없음 · 사본 시트도 호표 줄 추가 없음 | +| ③ 수량 바꾸기 | 기본: 본떠 만들기 / 자체: 수량 칸 | 5 / 2 | 3 / 1 | 원본은 못 바꿈(사본이 생김) · 수량 칸이 식 글 | +| ④ 단가 자료 바꾸기 | 자체 로직 「찾기」 | 인력 3 · 재료 불가 · 기계 글 고침 | 2 | 재료 조건 줄 로직은 편집기 빈 화면 · 기계는 코드 글 | +| ⑤ 줄 지우기 | 자체 로직 「×」 | 2 | 1 | 기본 로직은 불가 · 확인창 없음 · 줄 하나 되돌리기 없음 | +| ⑥ 로직 지우기 · 되돌리기 | 로직 지우기 → 확인 → 저장 | 4 | 2 | 저장 뒤 되돌릴 길 없음 · 확인창이 원문번호로 부름 | + +### ① 새 로직 — 「식으로 새로 만들기」 (실측) + +1. 「단가산출 로직」 1 · 「식으로 새로 만들기」 1 · 식 적기 1 · 「분석」 1 = 4 +2. 처음 적은 식 `공사비 = 0.5 * 보통인부 + 0.2 * 특별인부 + 1.1 * 시멘트 + 0.3 * 굴착기` → 「「+」 양쪽 단위가 다름 — 원/인 + 원/포」. 수량마다 이름을 붙여 다시 적고(1) 다시 분석(1) = 2 +3. 마스터 요소 넷 — 무엇인가 1 · 고르기 1 · 고름 1 = 인력 3 × 2. 재료 · 기계는 고르기 창이 늘 「인력」으로 열려 종류 바꿈 1 · 찾기 글 1 더 = 5 × 2. 기계는 단위 칸 「[object Object]」 고침 1 더 = 17 +4. 고정값 넷(보통인부품 · 특별인부품 · 시멘트량 · 굴착기시간) — 무엇인가 · 값 · 단위 = 3 × 4 = 12 +5. 로직 이름 1 · 결과 단위 1 · 저장 1 = 3 +6. 합 38 · 창 = 본 화면 + 고르기 창 4 (+ 단가 없는 자재를 바꾸려면 「자재품목 고르기」 1) +7. 결과 — 시험 계산 「멈춤 · 이 조건으로는 셈하지 못함 — 입력값 · 표에 그 조합이 있는지 확인」. 실제 까닭은 고른 시멘트(MT004177)에 단가 없음. 고르기 창이 재료 값을 안 보여 줘 미리 알 길 없음. 멈춤인데도 [저장] 켜짐. +8. 「본떠 만들기」 길(비슷한 로직 사본) = 줄 누름 1 · 본떠 만들기 1 · 이름 1 · 저장(자체 로직) 1 = 4 — 그러나 사본 시트에서는 호표 줄 추가 · 지우기 불가 → 사본 상세 편집기에서 다시 고쳐야 함. + +### ② 있는 로직에 줄 하나 (편집기는 캐시 초안으로 실측) + +- 기본 로직 1,469 개 모두 상세가 읽기 전용(「기본 로직은 품셈 원문 그대로라 여기서 고치지 못함 — 비고만 고칠 수 있음」). +- 길: 찾기 글 1 · 줄 누름 1 · 본떠 만들기 1 · 저장(자체 로직) 1 → 사본 상세 · 호표 「줄 추가」 1 · 「찾기」 1 · 고름 1 · 수량 1 · 저장 1 = 9 (재료면 종류 바꿈 +1 = 10). +- 사본 상세(자체 로직) 편집기에는 같은 이름 「줄 추가」 단추가 넷 — 설계에서 받을 값 · 호표 · 중간 값 · 덧줄. +- 새 줄 기본값 = 종류 인력 · 단위 인 · 비목 노무비 · 요소 · 이름 · 수량 빈칸. +- 금액 보려면 시험 계산 — 초안은 견본값이 안 채워져 받을 값 셋(횡단경사 · 지역 · 보정작업)을 넣을 때마다 「「지역」 값을 아직 안 넣었습니다」로 하나씩 멈춤 → 3 칸 + 계산 1 = 4 더. + +### ③ 수량 바꾸기 + +- 자체 로직 — 수량 칸 글 고침 1 · 저장 1. 칸 값이 `찾기(QC000544, 도색=도색).특별인부 / 시공량` 같은 식 글. +- 기본 로직 — 본떠 만들기 시트의 식 줄 칸(`(절토량 / 작업량) * (1 + 할증 / 100)`)을 고쳐 저장(자체 로직) → 원본 대신 「… 복사본」이 하나 더 생김. +- 표 안 숫자(예 시공량 700)는 이 화면에서 못 고침 — 알약 → 「마스터 화면에서 보기」로 소요량 표 화면에 가야 함. + +### ④ 단가 자료(자재 · 노임) 바꾸기 + +- 인력 줄(요소 = `LB000002`) — 「찾기」 1 · 고름 1 · 저장 1 = 3. +- 재료 조건 줄(요소 = `{구분 · 상세구분 · 검색어}`) — 이런 줄이 든 로직 230 개(재료 줄 672). 사본을 열면 상세가 빈 화면(2장 흠 B2) → 불가. +- 경비(기계) 줄 — 마스터에 「기계」 종류 줄 0 · 모두 `로직(GC000267, 기계='0201-0020', 지역=지역, 보정작업=보정작업)` 부르기(1,075 줄). 굴착기 0.2㎥ → 0.4㎥는 글 속 코드 `'0201-0020'`를 손으로 바꿔야 함. 「찾기」는 로직 1,469 개를 보여 주고 고르면 괄호 속 인자(기계= …)가 사라짐. +- 「이름·규격」 칸은 꼬리표일 뿐 — 바꿔도 단가 그대로. +- 시험 계산의 「품목 고르기」(물가자료 · 유통물가 · 물가정보 · 거래가격 넷 보임)는 시험에만 쓰고 로직에 안 남음 — 바꾼 줄로 착각 쉬움. + +### ⑤ 줄 지우기 + +- 자체 로직 — 줄 끝 「×」 1 · 저장 1. 확인창 없음. +- 되돌리기 = 「고친 것 버리기」 하나뿐 — 모든 로직의 고친 것을 통째로 버림. 줄 하나 되살리기 없음. +- 기본 로직 · 본떠 만들기 시트 — 줄 지우기 단추 없음. + +### ⑥ 로직 지우기 · 되돌리기 + +- 줄 누름 1 · 로직 지우기 1 · 확인 1 · 저장 1 = 4 (실측은 확인창에서 취소). +- 확인창 글 「로직 「3-4-2」 을 지울까요? [저장] 때 파일에서 빠짐」 — 이름이 아니라 원문번호로 부름. 같은 3-4-2 가 GF000003 · GF000004 둘. +- 「고치지 못함」 기본 로직도 지우기는 됨(서버 `/save` 가 지움을 막지 않음 · 다른 로직이 부르면 저장 때 검사로 막힘). +- 저장 전 되돌리기 = 「고친 것 버리기」(전부) · 저장 뒤 = 길 없음(휴지통 · 초기화 없음). +- 저장 안 한 새 로직을 지우면 확인 뒤 바로 사라짐. + +## 3. 처음 보는 사람이 모를 낱말 + +- 호표 · 덧줄 · 중간 값 · 끝수 · 비목 · 몫(「노무비 몫」) · 소계 · 결과 단위 · 원문번호 · 출처 · 막힘 +- 기본 로직 · 정본 · 자체 로직 · 본뜸 · 복사본 +- 별칭(복사 시트 「부르는 표·로직」 상자) · 그대로 부르기 · 같이 복사하기 · 표 바꾸기 +- 마스터 요소 · 설계 입력 · 고정값 · 표 찾기 · 덧줄 비목 · 「비목 합 — 덧줄에서만 쓰는 이름」 · 식으로 정해짐 +- 키 머리글 — GF · GC · GX · LB · MT · EQ · QF · QC +- 식 낱말 — `찾기(QF000028, 구분='m당 절토량').값` · `로직(GC000267, 기계='0201-0020', …)` · `만약(횡단경사 >= 55, …, 0)` · `LB:{공수직종}` · `MO:휘발유:{지역}` +- 소유 — 세 화면이 다 다름: 식 화면 빈 글칸 · 편집기 「공용 / 개인」 · 복사 시트 「현장 / 공용」 +- 시험 계산 — 계산 때 · 고친 줄로 계산(저장 전) · 멈춤 · 물가자료 · 유통물가 · 물가정보 · 거래가격 · 대표 +- 텍스트 수식 속 기계가 지은 이름 — `굴착기무한궤도노무비단가` · 알약 꼬리표 `실선 · 5ton`(표 칸 `트럭2_5ton` 줄임) + +## 4. 화면에 안 보이는 규칙 + +1. 기본 로직은 줄 · 수량 · 단가 모두 못 고침 → 먼저 「본떠 만들기」로 자체 사본을 만들어야 함. 원본은 그대로. +2. 본떠 만들기 시트는 호표 줄 추가 · 지우기 불가 — 덧줄 추가만. +3. 「같이 복사하기」는 [저장] 없이 그 자리에서 서버에 새 로직을 씀 — 시트를 취소해도 남음(`M01_MasterData_UI_LogicLab_Copy.ts:197` → `/logic/copy` → `M01_MasterData_Store_Make.py:215` `logic_new`). +4. 「식으로 새로 만들기」는 위 [저장]과 따로 — ④ 칸의 [저장]이 바로 서버에 씀 · 캐시에 안 쌓임 · 「닫기」는 묻지 않고 다 버림. 한 화면에 [저장] 둘. +5. 식의 숫자는 단위가 없어 바로 곱하면 단위 검사에 걸림 — 수량마다 고정값 변수 + 단위를 적어야 통과. +6. 변수 목록은 식 순서가 아니라 가나다 순. +7. 경비(기계)는 요소가 아니라 「기계 시간당 경비 로직」을 기계 코드로 부르는 꼴. 식 화면은 기계 요소(EQ)를 바로 넣어 마스터와 다른 모양을 만듦. +8. 한 기계 줄이 인력 · 재료 · 경비 세 묶음에 「몫」으로 쪼개 보임 — 실제 줄은 하나. +9. 「이름」 칸은 꼬리표 — 단가 자료는 「요소」 칸 글이 정함. +10. 고르기 창이 셋 — 식 화면 「마스터 요소」(50 개 · 구분 거름 · 재료 값 안 보임) · 편집기 「요소 찾기」(100 개 · 거름 없음) · 시험 계산 「자재품목 고르기」(물가 넷). 열 때마다 종류가 「인력」으로 돌아가고 변수 이름으로 미리 찾아 두지 않음. +11. 시험 계산 견본값은 저장된 로직만 자동으로 채움(`M01_MasterData_UI_Logic_Calc.ts:153-155`) — 기본 로직은 바로 금액이 나오나 새 로직 · 사본 초안은 빈칸이라 받을 값을 다 채울 때까지 한 칸씩 멈춤. +12. 「고친 것 버리기」는 로직 하나가 아니라 저장 안 한 모든 로직을 버림. +13. 식 화면은 결과(원)와 결과 단위(원/㎡)가 어긋나도 검사 안 함. +14. 고르기 창 단위와 줄 단위가 다를 수 있음 — 노임 「원/일」을 골라도 줄 단위는 「인」. + +## 5. 다섯 기능 — 하려던 것 · 어려운 것 + +- **본떠 만들기** — 고칠 수 없는 기본 로직의 사본(자체)을 떠서 고치게 하려던 것. 어려움: 줄 추가 · 지우기 불가(덧줄만, 그마저 안 보임) · 별칭 상자와 「그대로 부르기 / 같이 복사하기」(즉시 저장) 갈래를 알아야 함 · 사본 뒤 다시 편집기로 가야 함. +- **식으로 새로 만들기** — 「이름 = 식」 글 몇 줄로 새 로직을 짓게 하려던 것. 어려움: 빈 글에서 식 문법과 단위 맞춤을 알아야 함 · 변수마다 무엇인가 네 갈래 · 기계 고르기 깨짐 · 닫기에 안 물음. +- **텍스트 수식** — 계산을 「변수 이름 식 = 값 넣은 식」 두 칸 글로 보여 검산하게 하려던 것. 어려움: 읽기만 · 기계가 지은 긴 이름 · 계산 전엔 오른쪽이 「시험 계산을 누르면 식이 글로 나옴」. +- **시험 계산** — 받을 값을 넣어 금액을 미리 보게 하려던 것. 어려움: 새 로직 · 초안은 한 칸씩 멈춤 · 멈춤 까닭이 엉뚱할 때 있음 · 품목 고르기가 시험에만 남음. +- **알약** — 수량 식 속 표 · 요소 · 로직 이름을 누르면 그 자료(걸린 줄 노란색)를 바로 보이려던 것. 창 자체는 쉬움. 어려움: 보기만(고치기는 「마스터 화면에서 보기」로 딴 화면) · 꼬리표가 표 칸 줄임 · 로직에 따라 알약 대신 글자만(GF000004). + +## 6. 쓰다 만난 흠 (고치지 않음 · 새 길이 같은 자리를 밟지 않게) + +| # | 흠 | 자리 | 사진 | +| --- | --- | --- | --- | +| B1 | 기계 고르기 창에 날 JSON(`{"가격":66538,…}`) · 이름이 한 글자씩 세로 · 고르면 단위 「[object Object]」 · 결과 「원/[object Object]」 | `M01_MasterData_UI_LogicLab_New_Find.ts:124-147` (기계 줄의 값 · 단위가 객체) | s02 | +| B2 | 재료 조건 줄이 든 자체 로직을 열면 상세 빈 화면 — `item.요소.includes is not a function` · 대상 230 로직(본뜨면 터짐) | `M01_MasterData_UI_Logic_Edit.ts:278` · 같은 꼴 `:294` | — | +| B3 | 본떠 만들기 「덧줄 추가」를 눌러도 새 줄이 안 보임 — 안 보이는 덧줄(식 0)이 저장엔 실림 | `M01_MasterData_UI_LogicLab_Copy.ts:236` · `:296` (줄 목록이 서버 첫 응답만) | s06 | +| B4 | 상세 표의 금액이 「금액」 칸이 아니라 단가 밑 둘째 줄에 앉음 | `M01_MasterData_UI_LogicLab_Detail.ts` 묶음 표 | s05 · s09 | +| B5 | 시험 계산 멈춤 까닭이 엉뚱 — 단가 없는 자재인데 「입력값 · 표 조합 확인」 | `safeReason` `M01_MasterData_UI_Logic_Text.ts` | — | +| B6 | 복사 시트 「취소」 단추가 세로로 찌그러짐(「취/소」) | 복사 시트 머리 | s06 | +| B7 | 목록에서 「로직 지우기」 · 「본떠 만들기」가 켜진 채 | `M01_MasterData_UI_LogicLab.ts:401-417` | s01 | + +## 7. 새 길이 피할 것 (설계 후보 · 정함은 브레인 몫) + +1. 사용자가 「본뜨기」 · 「자체 / 기본」을 몰라도 되게 — 기본 로직에서 바로 고치기를 누르면 사본이 생긴다는 것을 한 줄로 알림. +2. 줄은 인력 · 재료 · 경비 세 상자에 한 줄씩 — 몫 쪼개기 · 덧줄 · 중간 값을 처음 화면에 안 보임. +3. 고르기 창 하나 — 그 상자 종류로 열림 · 줄 이름으로 미리 찾음 · 단가가 보임 · 단가 없는 품목은 흐리게. +4. 수량은 숫자 칸 먼저 · 단위는 줄 단위에서 자동 · 식은 「고급」 뒤로. +5. 경비(기계)는 「기계 고르기」로 — 속에서는 지금 형식 `로직(GC…, 기계='코드', …)`으로 읽고 씀. +6. 재료 조건 줄(`{구분 · 상세구분 · 검색어}`)을 읽고 쓸 수 있어야 함 — 230 로직. +7. [저장] 하나 · 닫기 전 물음 · 줄 하나 되돌리기. +8. 새 로직 · 사본에도 시험 견본값을 채워(`/logic/auto` 꼴) 한 번에 금액이 나오게 · 멈춤 까닭은 줄 이름으로. +9. 지울 때 이름으로 묻기 · 저장 전까지 되살리기. From 87aabc4a0d8d704c3391e4b7d0a9eebfad79ff3e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 09:42:02 +0900 Subject: [PATCH 08/12] =?UTF-8?q?feat(m01-easycalc):=20=EC=89=AC=EC=9A=B4?= =?UTF-8?q?=20=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=20Api=20=EB=AA=B8=20?= =?UTF-8?q?=E2=80=94=20=EC=84=9C=EB=B2=84=20=EA=B8=B8=20=EA=B0=90=EC=8B=B8?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EA=B8=B0=EA=B3=84=20=EC=A7=9D=20=C2=B7=20?= =?UTF-8?q?=EC=89=AC=EC=9A=B4=20=EB=A7=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - listEasy(품셈 기본 / 내가 만든 것 · 돈 아닌 로직 「쉬운 길로 못 엶」) · openEasy(자동값) · calcLater(0.3초 · 최신 응답만) - newEasy · copyEasy · editEasy · deleteEasy — /logic/* 만(/save 안 씀) · usesOf = 담은 조합 + 부르는 내 로직 - listMachines · machineElement — 기종 → 기계 시간당 경비 로직 부름 글 · 넘길 설계 값(부착 전용 · 손료만은 뒤로) - plainError — 0 · 403 · 404 · 409 · 422 검사 글을 쉬운 말로(키 · 날 글 걸름) - 시험 test_m01_easycalc_api.py — 사본 폴더 왕복 · 쓰는 곳 막음 · 기계 짝 · 쉬운 말 · 금지 낱말 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL --- M01_MasterData/EasyCalc/M01_EasyCalc_Api.ts | 412 ++++++++++++++++++-- resources/tester/test_m01_easycalc_api.py | 321 +++++++++++++++ 2 files changed, 704 insertions(+), 29 deletions(-) create mode 100644 resources/tester/test_m01_easycalc_api.py diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Api.ts b/M01_MasterData/EasyCalc/M01_EasyCalc_Api.ts index 5d8860a01..2fb76bacf 100644 --- a/M01_MasterData/EasyCalc/M01_EasyCalc_Api.ts +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Api.ts @@ -3,9 +3,22 @@ * 쉬운 단가산출 Api — 서버 길 감싸기(목록 · 열기 + 자동값 · 시험 계산 뒤에서 · 새로 · 본뜨기 · * 고치기 · 지우기 · 쓰는 곳) · 403 · 409 · 422 를 쉬운 말로 · 기계 기종 → 부를 기계경비 로직 · 넘길 값 * 모양은 `docs/raw/M01_쉬운단가/설계.md` 3절 그대로 · 쓰기는 `/logic/*` 만(`/save` 안 씀) + * 시험: `resources/tester/test_m01_easycalc_api.py`(서버 왕복 + 아래 「순수」 칸을 Node 로) * ========================================================================== */ -import type { CalcAnswer, LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api"; +import { API_BASE_URL } from "@config/config_frontend"; +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { + ApiError, + copyLogic, + fetchAuto, + fetchLogic, + runCalc, + type CalcAnswer, + type LogicInput, + type LogicRow, +} from "../M01_MasterData_UI_Logic_Api"; +import { safeReason } from "../M01_MasterData_UI_Logic_Text"; /** 목록 한 줄 */ export interface EasyListItem { @@ -62,55 +75,396 @@ export interface EasyMachineElement { needInputs: LogicInput[]; } -const notReady = (): never => { - throw new Error("준비 중"); -}; - -export async function listEasy(_q: string): Promise { - return notReady(); +/** 기계 시간당 경비 로직 하나 — 기종 짝 규칙의 재료 */ +export interface MachineLogic { + key: string; + name: string; + resultUnit: string; + inputs: LogicInput[]; } -export async function openEasy(_key: string): Promise { - return notReady(); +/* ── 순수 시작 — 아래 끝까지 밖 이름을 안 씀(`currentLanguageIndex` 만) · 시험이 Node 로 바로 돌림 ── */ +const TEXT = { + Base: ["품셈 기본", "Standard"], + Mine: ["내가 만든 것", "Mine"], + NotMoney: [ + "금액이 아닌 값(작업량 등)을 내는 것이라 쉬운 길로 못 엶 — 지금 화면에서 봄", + "Gives a non-money value (output etc.) — open it in the full screen", + ], + E403: [ + "품셈 기본은 바로 못 고침 — 「이걸 바탕으로 고치기」로 내 것을 만들어 고침", + "Standard items are read-only — use “Edit a copy”", + ], + E404: [ + "찾을 수 없음 — 이미 지워졌을 수 있음 · 목록을 다시 열어 주세요", + "Not found — it may be deleted; reopen the list", + ], + E409: [ + "다른 곳에서 먼저 고쳤음 — 다시 열어 최신 것으로 고쳐 주세요", + "Changed elsewhere first — reopen and edit again", + ], + Net: ["서버와 이어지지 않음 — 잠시 뒤 다시 해 주세요", "Server unreachable — try again"], + Generic: [ + "저장 검사에 걸림 — 고친 줄을 다시 확인해 주세요", + "Did not pass the check — review your changes", + ], + More: ["그 밖 {n} 건", "{n} more"], + UnknownName: [ + "「{name}」 값이 어디에도 없음 — 설계 때 정하는 값으로 더해 주세요", + "「{name}」 is not defined — add it as a design value", + ], + NeedCost: [ + "넣을 자리(사람 품값 · 재료값 · 기계·경비)를 골라 주세요", + "Pick where it goes (labor · material · equipment)", + ], + NoLines: [ + "줄이 하나도 없음 — 사람 · 재료 · 기계 줄을 하나 이상 더해 주세요", + "No lines — add at least one labor, material or machine line", + ], + Source: ["근거 글 모양이 맞지 않음", "The source text has a wrong shape"], + CallNeeds: [ + "부르는 다른 일위대가가 「{name}」 값을 받아야 함 — 설계 때 정하는 값에 더해 주세요", + "The called item needs 「{name}」 — add it as a design value", + ], + CallExtra: [ + "부르는 다른 일위대가는 「{name}」 값을 받지 않음", + "The called item does not take 「{name}」", + ], + Kind: [ + "고른 항목과 줄 종류가 맞지 않음 — 다시 골라 주세요", + "The picked item does not match the line type — pick again", + ], + Loop: [ + "서로를 부르는 일위대가가 생김 — 한쪽을 빼 주세요", + "Items call each other — remove one side", + ], + Unread: ["수량 글을 읽지 못함 — 숫자로 넣어 주세요", "Cannot read the amount — enter a number"], + Gone: [ + "이어진 항목이 없어짐 — 다른 곳에서 쓰고 있으면 지울 수 없음", + "A linked item is missing — items still in use cannot be deleted", + ], + NoName: ["이름을 넣어 주세요", "Enter a name"], + Broken: ["저장 모양이 덜 갖춰짐 — 다시 열어 주세요", "The saved shape is incomplete — reopen it"], + Cut: ["합계 자르기 설정이 맞지 않음", "The rounding setting is wrong"], + Material: [ + "재료 찾는 말에 맞는 자료가 없음 — 다시 골라 주세요", + "No material matches — pick again", + ], + Stop: [ + "이 값으로는 셈하지 못함 — 설계 때 정하는 값을 확인해 주세요", + "Cannot be calculated with these values — check the design values", + ], + NoMachine: [ + "이 기계는 시간당 값을 셀 자료가 없음 — 다른 기계를 골라 주세요", + "No hourly cost data for this machine — pick another", + ], +} as const satisfies Record; + +type SayKey = keyof typeof TEXT; + +function say(key: SayKey, fill: Record = {}): string { + const text: string = TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]; + return text.replace(/\{(\w+)\}/g, (m, k: string) => (k in fill ? String(fill[k]) : m)); } +/** 검사 글 한 줄 → 쉬운 말 — 모르는 꼴은 뭉뚱그림(키 · 날 글이 화면에 안 나가게) */ +function plainCheck(line: string): string { + const name = (re: RegExp): string | null => re.exec(line)?.[1] ?? null; + const unknown = name(/모르는 이름 「([^」]+)」/); + if (unknown) return say("UnknownName", { name: unknown }); + const needs = name(/입력 「([^」]+)」 안 줌/); + if (needs) return say("CallNeeds", { name: needs }); + const extra = name(/에 입력 「([^」]+)」 없음/); + if (extra) return say("CallExtra", { name: extra }); + if (/「호표」 도 「결과」 도 없음/.test(line)) return say("NoLines"); + if (/비목 「/.test(line)) return say("NeedCost"); + if (/출처 모양/.test(line)) return say("Source"); + if (/요소 그룹이 종류와 다름|종류 「|고르기 조건은 종류/.test(line)) return say("Kind"); + if (/돌고 도는 참조/.test(line)) return say("Loop"); + if (/식 못 읽음/.test(line)) return say("Unread"); + if (/없는 (로직|요소|키)|로직 키/.test(line)) return say("Gone"); + if (/「이름」 이 없음|이름 없음/.test(line)) return say("NoName"); + if (/끝수/.test(line)) return say("Cut"); + if (/검색어|후보 0|고르기 조건/.test(line)) return say("Material"); + if (/칸 「[^」]+」 없음/.test(line)) return say("Broken"); + return say("Generic"); +} + +/** 상태 번호 + 서버 몸 → 쉬운 말(여러 줄은 줄바꿈 · 셋까지) · 0 = 서버에 못 닿음 */ +function plainOf(status: number, detail: unknown): string { + if (status === 0) return say("Net"); + if (status === 403) return say("E403"); + if (status === 404) return say("E404"); + if (status === 409) return say("E409"); + const body = detail as { errors?: unknown } | null; + const lines = + status === 422 && body && Array.isArray(body.errors) + ? body.errors.map((x) => String(x)) + : typeof detail === "string" + ? [detail] + : []; + if (!lines.length) return say("Generic"); + const plain = [...new Set(lines.map(plainCheck))]; + const shown = plain.slice(0, 3); + if (plain.length > 3) shown.push(say("More", { n: plain.length - 3 })); + return shown.join("\n"); +} + +/** 계산 멈춤 까닭 — 키(`GX000001`)가 섞인 글은 뭉뚱그림 */ +function plainStop(text: string): string { + return /[A-Z]{2}\d{6}/.test(text) ? say("Stop") : text; +} + +const MACHINE = "기계"; +/** 부착물 아닌 입력 — 이 밖의 입력(집게 · 브레이커)을 받는 로직은 부착 장비 전용 */ +const MACHINE_PLAIN = new Set(["기계", "지역", "보정작업", "조종원직종"]); + +/** 그 로직이 받는 기종(분류번호) 목록 */ +function machineCodes(m: MachineLogic): string[] { + const spec = m.inputs.find((i) => i.이름 === MACHINE); + return (spec?.고르기 ?? []).map(String); +} + +/** 짝 고르는 차례 — [부착 전용, 손료만, 손료만이면서 보정, 기종 수] 가 작은 것부터. + * 손료만 = 「지역」(연료 · 노임 값 자리)을 안 받음 · 기종 수가 작을수록 그 기종 전용(굴착기 · 조종원 없음 …) */ +function machineRank(m: MachineLogic): number[] { + const names = m.inputs.map((i) => i.이름); + const attach = names.some((n) => !MACHINE_PLAIN.has(n)) ? 1 : 0; + const lossOnly = names.includes("지역") ? 0 : 1; + const boost = lossOnly && names.includes("보정작업") ? 1 : 0; + return [attach, lossOnly, boost, machineCodes(m).length]; +} + +/** 기종 → 부를 기계경비 로직 하나 · 받는 로직이 없으면 null */ +export function pickMachine(family: MachineLogic[], code: string): MachineLogic | null { + const ranked = family + .filter((m) => machineCodes(m).includes(code)) + .map((m) => ({ m, rank: machineRank(m) })) + .sort((a, b) => { + for (let i = 0; i < a.rank.length; i += 1) { + if (a.rank[i] !== b.rank[i]) return a.rank[i] - b.rank[i]; + } + return a.m.key < b.m.key ? -1 : 1; + }); + return ranked[0]?.m ?? null; +} + +/** 로직 부름 글 — 기계는 고정 분류번호 · 나머지 입력은 같은 이름의 설계 때 정하는 값으로 넘김 */ +export function machineCall(m: MachineLogic, code: string): EasyMachineElement { + const quote = (n: string): string => (/^[\w가-힣]+$/.test(n) ? n : `'${n}'`); + const needInputs = m.inputs.filter((i) => i.이름 !== MACHINE); + const rest = needInputs.map((i) => `, ${quote(i.이름)}=${quote(i.이름)}`).join(""); + return { element: `로직(${m.key}, ${MACHINE}='${code}'${rest})`, needInputs }; +} +/* ── 순수 끝 ── */ + +const OWN = "로직_자체.json"; // 내가 만든 것 한 자리(서버 `Store_Make.OWN`) +const OWNER = "현장"; +const CALC_WAIT_MS = 300; +const MACHINE_FILE = "건설기계"; // 기계 시간당 경비 로직이 든 장 파일(「08장 건설기계」) + +async function request(path: string, body?: unknown): Promise { + let response: Response; + try { + response = await fetch(`${API_BASE_URL}/m01${path}`, { + method: body === undefined ? "GET" : "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + } catch { + throw new ApiError(0, ""); + } + const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T; + if (!response.ok) throw new ApiError(response.status, data.detail ?? response.statusText); + return data; +} + +const query = (params: Record): string => new URLSearchParams(params).toString(); + +interface Summary { + file: string; + 키: string; + 구분: string; + 상세구분: string; + 이름: string; + 결과단위: string; +} + +const isMoney = (unit: string): boolean => unit.startsWith("원"); + +/** 목록 — 내가 만든 것 먼저 · 돈 아닌 로직은 「쉬운 길로 못 엶」 */ +export async function listEasy(q: string): Promise { + const got = await request<{ logics: Summary[] }>(`/logics?${query({ q })}`); + const items = got.logics.map((s): EasyListItem => { + const isBase = s.file !== OWN; + const canOpen = isMoney(s.결과단위); + return { + key: s.키, + name: s.이름, + isBase, + owner: say(isBase ? "Base" : "Mine"), + resultUnit: s.결과단위, + canOpen, + whyNot: canOpen ? null : say("NotMoney"), + where: isBase ? `${s.구분} ${s.상세구분}`.trim() : s.상세구분, + }; + }); + return [...items.filter((x) => !x.isBase), ...items.filter((x) => x.isBase)]; +} + +/** 열기 — 로직 줄 · 판본 · 견본 설계 값(자동값 · 못 받으면 빈 채로) */ +export async function openEasy(key: string): Promise { + const [one, auto] = await Promise.all([fetchLogic(key), fetchAuto(key)]); + return { + row: one.logic, + version: one.version, + isBase: one.file !== OWN, + trial: trialFor(one.logic, auto), + }; +} + +/** 로직이 받는 설계 값만 — 모르는 이름을 보내면 서버가 멈춤 */ +function trialFor(row: LogicRow, trial: Record): Record { + return Object.fromEntries( + (row.입력 ?? []).filter((i) => i.이름 in trial).map((i) => [i.이름, trial[i.이름]]), + ); +} + +let calcSeq = 0; +let calcTimer: ReturnType | undefined; + /** 0.3초 뒤 서버 시험 계산 · 최신 응답만 `onDone` · 멈춤 · 실패는 `plain` 쉬운 말 */ export function calcLater( - _row: LogicRow, - _trial: Record, - _onDone: (ans: CalcAnswer | null, plain: string | null) => void, + row: LogicRow, + trial: Record, + onDone: (ans: CalcAnswer | null, plain: string | null) => void, ): void { - notReady(); + const mine = ++calcSeq; + clearTimeout(calcTimer); + calcTimer = setTimeout(() => { + runCalc({ key: row.키 || "", inputs: trialFor(row, trial), row }) + .then((ans) => { + if (mine === calcSeq) onDone(ans, ans.ok ? null : plainStop(safeReason(ans.reason))); + }) + .catch((e: unknown) => { + if (mine === calcSeq) onDone(null, plainError(e)); + }); + }, CALC_WAIT_MS); } -export async function newEasy(_row: LogicRow): Promise { - return notReady(); +/** 새로 — 키 · 갈래는 서버가 줌 · 근거 글이 비면 「자체」(검사가 빈 글을 막음) */ +export async function newEasy(row: LogicRow): Promise { + const logic = { ...row, 출처: row.출처 || "자체" }; + const got = await request<{ version: string; key: string; logic: LogicRow }>("/logic/new", { + logic, + owner: OWNER, + }); + return { key: got.key, version: got.version, row: got.logic }; } -export async function copyEasy(_key: string): Promise { - return notReady(); +/** 이걸 바탕으로 고치기 — 품셈 기본 · 내 것 모두 본뜸 */ +export async function copyEasy(key: string): Promise { + const got = await copyLogic(key); + return { key: got.key, version: got.version, row: got.logic }; } -export async function editEasy(_key: string, _version: string, _row: LogicRow): Promise { - return notReady(); +/** 고치기 — 내가 만든 것만(품셈 기본은 403) · 판본이 다르면 409 */ +export async function editEasy(key: string, version: string, row: LogicRow): Promise { + const got = await request<{ version: string; logic: LogicRow }>("/logic/edit", { + key, + version, + logic: row, + }); + return { key, version: got.version, row: got.logic }; } -export async function deleteEasy(_key: string, _version: string): Promise<{ version: string }> { - return notReady(); +/** 지우기 — 내가 만든 것만 · 쓰는 곳이 남으면 422 */ +export async function deleteEasy(key: string, version: string): Promise<{ version: string }> { + const got = await request<{ version: string }>("/logic/delete", { key, version }); + return { version: got.version }; } -export async function usesOf(_key: string): Promise { - return notReady(); +/** 쓰는 곳 — 담은 일위대가 조합(마스터 층) + 이 로직을 부르는 내가 만든 것. + * ponytail: 부르는 로직은 내가 만든 것만 하나씩 받아 훑음(품셈 기본은 내 것을 안 부름) — + * 내 것이 수백이 되면 서버에 「부르는 로직」 길을 더할 것 */ +export async function usesOf(key: string): Promise { + const [combos, mine] = await Promise.all([ + request<{ combos: { 키: string; 이름: string }[] }>(`/logic/combos?${query({ key })}`), + request<{ logics: Summary[] }>(`/logics?${query({ sub: "자체" })}`), + ]); + const call = `로직(${key}`; + const others = mine.logics.filter((s) => s.키 !== key); + const rows = await Promise.all(others.map((s) => fetchLogic(s.키))); + const callers = rows + .filter((one) => JSON.stringify(one.logic).includes(call)) + .map((one): EasyUse => ({ kind: "logic", key: one.logic.키, name: one.logic.이름 })); + return [ + ...combos.combos.map((c): EasyUse => ({ kind: "combo", key: c.키, name: c.이름 })), + ...callers, + ]; } -export async function listMachines(_q: string): Promise { - return notReady(); +let family: Promise | null = null; + +/** 기계 시간당 경비 로직 묶음 — 한 번 받아 둠(실패하면 다음에 다시) */ +function machineFamily(): Promise { + family ??= request<{ logics: Summary[] }>("/logics") + .then((got) => + Promise.all( + got.logics + .filter((s) => s.file.includes(MACHINE_FILE) && isMoney(s.결과단위)) + .map((s) => fetchLogic(s.키)), + ), + ) + .then((all) => + all + .map((one) => ({ + key: one.logic.키, + name: one.logic.이름, + resultUnit: one.logic.결과단위, + inputs: one.logic.입력 ?? [], + })) + .filter((m) => machineCodes(m).length > 0), + ) + .catch((e: unknown) => { + family = null; + throw e; + }); + return family; } -export async function machineElement(_기종: string): Promise { - return notReady(); +interface MachineRow { + 원문번호?: string; + 이름?: string; + 규격?: string; } -export function plainError(_e: unknown): string { - return notReady(); +/** 기계 고르기 — 이름 · 분류번호 찾기 · 시간당 값을 셀 수 있는 기종만 */ +export async function listMachines(q: string): Promise { + const [all, got] = await Promise.all([ + machineFamily(), + request<{ items: MachineRow[] }>(`/elements?${query({ group: MACHINE, q, limit: "200" })}`), + ]); + return got.items.flatMap((row): EasyMachine[] => { + const code = String(row.원문번호 ?? ""); + const m = pickMachine(all, code); + if (!m) return []; + const unit = m.resultUnit.split("/")[1] ?? ""; + return [{ 분류번호: code, 이름: row.이름 ?? "", 규격: row.규격 ?? "", 단위: unit }]; + }); +} + +/** 기종 → 호표 「요소」 글 · 받아야 할 설계 값(지역 등) — 호표 종류는 「로직」 */ +export async function machineElement(기종: string): Promise { + const m = pickMachine(await machineFamily(), 기종); + if (!m) throw new Error(say("NoMachine")); + return machineCall(m, 기종); +} + +/** 어떤 실패든 화면에 찍을 쉬운 말 한두 줄 */ +export function plainError(e: unknown): string { + if (e instanceof ApiError) return plainOf(e.status, e.detail); + if (e instanceof Error && e.message === say("NoMachine")) return e.message; + return say("Net"); } diff --git a/resources/tester/test_m01_easycalc_api.py b/resources/tester/test_m01_easycalc_api.py new file mode 100644 index 000000000..6f3001696 --- /dev/null +++ b/resources/tester/test_m01_easycalc_api.py @@ -0,0 +1,321 @@ +"""M01 쉬운 단가산출 Api(`M01_MasterData/EasyCalc/M01_EasyCalc_Api.ts`) 시험 — PLAN 34-3b. + +· 서버 길 왕복 — Api 가 부르는 몸 그대로 새로 → 고치기 → 시험 계산 → 지우기(임시 폴더 사본에서만) +· 쓰는 곳 — 담은 조합 · 부르는 내 로직이 지우기를 막음 +· 기계 짝 규칙 · 쉬운 말 바꾸기 — Api 파일의 「순수」 칸을 Node 로 바로 돌림 +견본 로직 줄은 시험 안에 둠 — 단가 값은 사본에서 읽어 비교(진짜 값에 안 기댐). +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from decimal import Decimal +from pathlib import Path + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from M01_MasterData import M01_MasterData_Router as router_module +from M01_MasterData import M01_MasterData_Store as store + +ROOT = Path(__file__).resolve().parents[2] +API = ROOT / "M01_MasterData" / "EasyCalc" / "M01_EasyCalc_Api.ts" +REAL = store.FOLDER +OWN = "로직_자체.json" +NO_NODE = shutil.which("node") is None + + +@pytest.fixture +def client(tmp_path: Path, monkeypatch) -> TestClient: + for path in REAL.glob("*.json"): + if path.name == OWN: + continue # 내 로직은 빈 자리에서 시작 + if not path.name.startswith("_") or path.name == store.mk.BOOK.name: + shutil.copy(path, tmp_path / path.name) + monkeypatch.setattr(store, "FOLDER", tmp_path) + app = FastAPI() + app.include_router(router_module.router) + return TestClient(app) + + +def _call(client: TestClient, url: str, body: dict | None = None, want: int = 200) -> dict: + res = client.get(url) if body is None else client.post(url, json=body) + assert res.status_code == want, res.text + return res.json() + + +def _labor() -> tuple[str, Decimal]: + """값이 있는 인력 한 줄(키 · 노임) — 사본에서 읽음.""" + rows = json.loads((store.FOLDER / "인력.json").read_text(encoding="utf-8"))["줄"] + row = next(r for r in rows if r.get("상태") == "공표" and isinstance(r.get("값"), (int, float))) + return row["키"], Decimal(str(row["값"])) + + +def _row(name: str, lines: list[dict], inputs: list | None = None) -> dict: + """쉬운 길이 보내는 최소 줄 — 키 · 갈래 · 소유는 서버가 채움.""" + return { + "키": "", + "원문번호": "", + "이름": name, + "결과단위": "원/㎡", + "출처": "자체", + "입력": inputs or [], + "중간": [], + "호표": lines, + "덧줄": [], + "끝수": None, + } + + +def _person(key: str, qty: str) -> dict: + return { + "종류": "인력", + "요소": key, + "이름": "사람", + "단위": "인", + "수량": qty, + "비목": "노무비", + } + + +def _node(expr: str) -> object: + """Api 파일의 「순수」 칸 + 식 하나를 Node 로 돌려 JSON 답.""" + src = API.read_text(encoding="utf-8") + block = re.search(r"/\* ── 순수 시작[^\n]*\n([\s\S]*?)/\* ── 순수 끝", src) + assert block, "순수 칸 표시를 못 찾음" + script = ( + "const currentLanguageIndex = 0;\n" + + block.group(1).replace("export function", "function") + + f"\nconsole.log(JSON.stringify({expr}));" + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +# ── 서버 길 왕복 ─────────────────────────────────────────────────────── +def test_새로_고치기_시험계산_지우기_왕복(client: TestClient) -> None: + real_before = (REAL / OWN).exists() + key_l, price = _labor() + + made = _call( + client, + "/api/m01/logic/new", + {"logic": _row("시험 쉬운 줄", [_person(key_l, "0.5")]), "owner": "현장"}, + ) + key = made["key"] + assert key.startswith("GX") and made["file"] == OWN and made["logic"]["소유"] == "현장" + + # 목록 · 열기 — 내 것은 사본의 자체 파일에서 + listed = _call(client, "/api/m01/logics?q=시험 쉬운 줄")["logics"] + assert [x["file"] for x in listed] == [OWN] and listed[0]["결과단위"].startswith("원") + opened = _call(client, f"/api/m01/logic?key={key}") + assert opened["version"] == made["version"] + + # 고치기 → 시험 계산(저장 전 줄을 그대로 보냄) — 금액 = 수량 × 노임 + row = {**opened["logic"], "호표": [_person(key_l, "2")]} + edited = _call( + client, "/api/m01/logic/edit", {"key": key, "version": opened["version"], "logic": row} + ) + calc = _call(client, "/api/m01/calc", {"key": key, "inputs": {}, "row": edited["logic"]}) + assert calc["ok"] is True + assert Decimal(str(calc["sums"]["노무비"])) == 2 * price == Decimal(str(calc["sums"]["계"])) + + # 저장 안 한 새 줄도 키 "" 로 셈 + draft = _call( + client, + "/api/m01/calc", + {"key": "", "inputs": {}, "row": _row("새 것", [_person(key_l, "1")])}, + ) + assert Decimal(str(draft["sums"]["계"])) == price + + # 낡은 판본 409 · 품셈 기본 고치기 403 + _call( + client, + "/api/m01/logic/edit", + {"key": key, "version": made["version"], "logic": row}, + want=409, + ) + base = next(x for x in _call(client, "/api/m01/logics")["logics"] if x["file"] != OWN) + base_one = _call(client, f"/api/m01/logic?key={base['키']}") + body = {"key": base["키"], "version": base_one["version"], "logic": base_one["logic"]} + _call(client, "/api/m01/logic/edit", body, want=403) + + _call(client, "/api/m01/logic/delete", {"key": key, "version": edited["version"]}) + assert client.get(f"/api/m01/logic?key={key}").status_code == 404 + assert (REAL / OWN).exists() == real_before # 진짜 마스터 폴더는 안 건드림 + + +def test_쓰는_곳_조합과_부르는_로직이_지우기를_막음(client: TestClient) -> None: + key_l, _ = _labor() + a = _call(client, "/api/m01/logic/new", {"logic": _row("바탕 줄", [_person(key_l, "1")])}) + call = {"종류": "로직", "요소": f"로직({a['key']})", "이름": "바탕", "단위": "㎡", "수량": "1"} + b = _call(client, "/api/m01/logic/new", {"logic": _row("부르는 줄", [call])}) + + # 부르는 내 로직 — usesOf 가 훑는 목록(구분 「자체」) · 몸에 「로직(<키>」 + mine = _call(client, "/api/m01/logics?sub=자체")["logics"] + assert {x["키"] for x in mine} == {a["key"], b["key"]} + body = json.dumps(_call(client, f"/api/m01/logic?key={b['key']}")["logic"], ensure_ascii=False) + assert f"로직({a['key']}" in body + + blocked = client.post("/api/m01/logic/delete", json={"key": a["key"], "version": b["version"]}) + assert blocked.status_code == 422, blocked.text + errors = blocked.json()["detail"]["errors"] + if not NO_NODE: # 서버 글 그대로 쉬운 말로 — 키가 안 새어 나감 + plain = _node(f"plainOf(422, {json.dumps({'errors': errors}, ensure_ascii=False)})") + assert "지울 수 없음" in plain and not re.search(r"[A-Z]{2}\d{6}", plain) + + # 부르는 줄을 지우면 바탕을 지울 수 있음 + _call(client, "/api/m01/logic/delete", {"key": b["key"], "version": b["version"]}) + now = _call(client, f"/api/m01/logic?key={a['key']}")["version"] + + # 담은 조합(마스터 층 파일)도 막음 — /logic/combos 가 그 조합을 줌 + combo_file = store.FOLDER / "일위대가조합.json" + combos = ( + json.loads(combo_file.read_text(encoding="utf-8")) + if combo_file.exists() + else {"그룹": "일위대가조합", "판": "2026", "줄": []} + ) + combos["줄"].append( + {"키": "UA999999", "이름": "시험 조합", "구분": "자체", "상세구분": "", "단위": "㎡", + "담은로직": [{"로직": a["key"], "메모": None}], "본뜬키": None, "출처": "자체", + "소유": "현장", "비고": None} + ) # fmt: skip + combo_file.write_text(json.dumps(combos, ensure_ascii=False, indent=2), encoding="utf-8") + store.cache.stale(["일위대가조합.json"], set()) + used = _call(client, f"/api/m01/logic/combos?key={a['key']}")["combos"] + assert [c["키"] for c in used] == ["UA999999"] + _call(client, "/api/m01/logic/delete", {"key": a["key"], "version": now}, want=422) + + +# ── 기계 짝 규칙 ─────────────────────────────────────────────────────── +def _m(key: str, codes: list[str], *extra: str) -> dict: + inputs = [{"이름": "기계", "고르기": codes}] + [{"이름": n, "고르기": ["가"]} for n in extra] + return {"key": key, "name": key, "resultUnit": "원/시간", "inputs": inputs} + + +FAMILY = [ + _m("M_GENERAL", ["A", "B", "C", "D"], "지역"), + _m("M_BOOST", ["A", "B", "C", "D", "E"], "지역", "보정작업"), + _m("M_DIGGER", ["B"], "지역", "보정작업"), + _m("M_NOOP", ["C"], "지역"), + _m("M_CLAW", ["B"], "집게", "지역", "보정작업"), + _m("M_LOSS", ["A", "F", "G"]), + _m("M_LOSS_BOOST", ["F", "G", "H"], "보정작업"), +] + + +@pytest.mark.skipif(NO_NODE, reason="node 가 없음") +def test_기계_짝_규칙() -> None: + fam = json.dumps(FAMILY, ensure_ascii=False) + picks = _node( + f"['A','B','C','D','E','F','H','Z'].map((c) => pickMachine({fam}, c)?.key ?? null)" + ) + # 일반 > 보정 · 전용(굴착기 · 조종원 없음)이 일반보다 먼저 · 부착 전용은 안 고름 · + # 손료만은 다른 길이 없을 때만 · 손료만 안에서는 보정 아닌 것 먼저 · 없는 기종 null + assert picks == [ + "M_GENERAL", + "M_DIGGER", + "M_NOOP", + "M_GENERAL", + "M_BOOST", + "M_LOSS", + "M_LOSS_BOOST", + None, + ] + + call = _node(f"machineCall({fam}[2], 'B')") + assert call["element"] == "로직(M_DIGGER, 기계='B', 지역=지역, 보정작업=보정작업)" + assert [i["이름"] for i in call["needInputs"]] == ["지역", "보정작업"] + quoted = _node( + "machineCall({key:'K', name:'', resultUnit:'원/시간', inputs:[{이름:'기계'},{이름:'조종 직종'}]}, 'X')" + ) + assert quoted["element"] == "로직(K, 기계='X', '조종 직종'='조종 직종')" + + +@pytest.mark.skipif(NO_NODE, reason="node 가 없음") +def test_기계_부름글이_서버_검사와_계산을_지남(client: TestClient) -> None: + """사본의 기계 시간당 경비 로직으로 짝을 지어 새 줄에 넣음 — 저장 · 계산이 됨(종류 「기계」 꼴 안 만듦).""" + family = [] + for path in store.FOLDER.glob("로직_*건설기계*.json"): + for row in json.loads(path.read_text(encoding="utf-8"))["줄"]: + if row["결과단위"].startswith("원"): + family.append( + { + "key": row["키"], + "name": row["이름"], + "resultUnit": row["결과단위"], + "inputs": row["입력"], + } + ) + if not family: + pytest.skip("사본에 기계 시간당 경비 로직이 없음") + fam = json.dumps(family, ensure_ascii=False) + code = next( + str(c) + for m in family + for i in m["inputs"] + if i["이름"] == "기계" + for c in i.get("고르기", []) + ) + got = _node( + f"(() => {{ const m = pickMachine({fam}, '{code}'); return m && machineCall(m, '{code}'); }})()" + ) + assert got and got["element"].startswith("로직(") + + line = {"종류": "로직", "요소": got["element"], "이름": "기계", "단위": "시간", "수량": "0.5"} + made = _call( + client, "/api/m01/logic/new", {"logic": _row("기계 시험", [line], got["needInputs"])} + ) + trial = {i["이름"]: (i.get("고르기") or [None])[0] for i in got["needInputs"]} + calc = _call(client, "/api/m01/calc", {"key": made["key"], "inputs": trial}) + assert calc["ok"] is True, calc + assert Decimal(str(calc["sums"]["계"])) > 0 and "비목" in calc["lines"][0] + _call(client, "/api/m01/logic/delete", {"key": made["key"], "version": made["version"]}) + + +# ── 쉬운 말 ───────────────────────────────────────────────────────────── +@pytest.mark.skipif(NO_NODE, reason="node 가 없음") +def test_plainError_상태와_검사글을_쉬운_말로() -> None: + simple = _node("[0, 403, 404, 409, 500].map((s) => plainOf(s, 'x'))") + assert simple[0].startswith("서버와 이어지지") + assert "바탕으로 고치기" in simple[1] and "찾을 수 없음" in simple[2] + assert "다른 곳에서 먼저 고쳤음" in simple[3] and "검사에 걸림" in simple[4] + + errors = [ + "로직_자체.json · GX000001 호표 사람 수량 · 모르는 이름 「면적」", + "로직_자체.json · GX000001 호표 사람 · 비목 「None」", + "로직_자체.json · GX000001 시험 · 「호표」 도 「결과」 도 없음", + "로직_자체.json · GX000001 호표 기계 요소 · 「기계경비」 입력 「지역」 안 줌", + "알 수 없는 글 GX000009 찾기(QF000001).값", + ] + plain = _node(f"plainOf(422, {json.dumps({'errors': errors}, ensure_ascii=False)})").split("\n") + assert plain[0] == "「면적」 값이 어디에도 없음 — 설계 때 정하는 값으로 더해 주세요" + assert "넣을 자리" in plain[1] and "줄이 하나도 없음" in plain[2] + assert plain[3] == "그 밖 2 건" # 셋까지 + 나머지 수 + assert not any(re.search(r"[A-Z]{2}\d{6}|찾기\(", x) for x in plain) + assert "「지역」" in _node( + f"plainOf(422, {json.dumps({'errors': errors[3:4]}, ensure_ascii=False)})" + ) + assert _node("plainOf(400, '로직 줄에 「이름」 이 없음')") == "이름을 넣어 주세요" + assert _node("plainStop('「GX000001」 입력 「돌」 없음')").startswith("이 값으로는") + + +def test_화면_글에_금지_낱말이_없음() -> None: + """설계 1절 8 — 키 · 식 · 호표 · 덧줄 · 비목 · 중간 값 · 끝수 · 정본 · 자체 · 별칭 안 씀(한국어 글).""" + src = API.read_text(encoding="utf-8") + block = re.search(r"const TEXT = \{([\s\S]*?)\} as const", src) + assert block + korean = re.findall(r'\[\s*"([^"]*)",', block.group(1)) + assert len(korean) > 20 + banned = ("키", "식", "호표", "덧줄", "비목", "중간 값", "끝수", "정본", "자체", "별칭") + for text in korean: + assert not any(word in text for word in banned), text From e2fed2941fd558f5d4c5782675349ea8aeb04c39 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 09:42:06 +0900 Subject: [PATCH 09/12] auto: 2026-10-01 09:42 (ESD_LAPTOP) --- docs/raw/M01_쉬운단가/쉬운단가_검토_B_화면.md | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 docs/raw/M01_쉬운단가/쉬운단가_검토_B_화면.md diff --git a/docs/raw/M01_쉬운단가/쉬운단가_검토_B_화면.md b/docs/raw/M01_쉬운단가/쉬운단가_검토_B_화면.md new file mode 100644 index 000000000..66d047bc6 --- /dev/null +++ b/docs/raw/M01_쉬운단가/쉬운단가_검토_B_화면.md @@ -0,0 +1,141 @@ +# 34-1B 지금 단가산출 화면 걸음 재기 (sub6 · 조사만) + +⬜ 브레인 답 필요 — 화면에 「추가」라는 글의 단추가 없음. 사용자가 말한 「추가」 = 위 단추 줄 첫째 진한 단추 「식으로 새로 만들기」로 봄(유일한 새로 만들기 단추). 다르면 34-3a 단추 자리가 바뀜. + +- 서버 8008 · 화면 5181 · ORCA 탭 하나(끝나고 닫음) · 판정 전 세션 SYSTEM_ADMIN 확인. +- 저장 · 지우기 안 누름. 줄 추가 · 수량 · 단가 바꾸기는 캐시 초안(sessionStorage)에만 넣어 재고 끝에 지움. 끝난 뒤 서버 로직 1,469 개 · 자체(GX) 0 개 그대로 · git 깨끗. +- 사진 — `tmp/쉬운단가_B_사진/` (s01 목록 … s10 알약 창). + +## 1. 화면 자리 + +| 무엇 | 화면 위치 | 파일:줄 | +| --- | --- | --- | +| 들어가는 곳 | 왼쪽 패널 맨 아래 「단가산출 로직 ▸」 상자 | `M01_MasterData_UI_Side.ts:439-440` · 전환 `M01_MasterData_UI_Page.ts` `openLogicTab` | +| 로직 목록 | 가운데 표 · 30 줄씩 49 쪽 · 1,469 줄 · 이름 찾기 · 「막힘만」 | `M01_MasterData_UI_Logic_List.ts` · 걸기 `M01_MasterData_UI_LogicLab.ts:97` | +| 상세 | 줄 누르면 네 상자 — 기본정보 · 단가산출 상세 · 텍스트 수식 · 시험 계산 | `M01_MasterData_UI_LogicLab_Detail.ts:479-505` | +| 위 단추 줄 | 「← 로직 목록」 「단가산출 로직」 (빈칸) [식으로 새로 만들기] [로직 지우기] [고친 것 버리기] [저장] [본떠 만들기] | `M01_MasterData_UI_LogicLab.ts:394-418` | +| 「추가」로 본 단추 | 「식으로 새로 만들기」 — 진한 남색 · 단추 줄 왼쪽 첫째 | `M01_MasterData_UI_LogicLab.ts:400` | +| 그 옆 빈 자리 | 제목과 첫 단추 사이 넓은 빈칸(제목 오른쪽 여백 auto) — `pending`(「저장 안 한 로직 N 개」) 글 뒤 · `:400` 앞에 끼우면 「식으로 새로 만들기」 바로 왼쪽 | `M01_MasterData_UI_LogicLab.ts:398-400` · 줄 모양 `M01_MasterData_UI_Logic_Style.css:64-75` | + +- 목록일 때도 「로직 지우기」(빨강) · 「본떠 만들기」가 켜져 있음 — 누르면 아무 일 없음 / 「먼저 로직을 고르세요」 알림. + +## 2. 대표 일 여섯 — 걸음 + +누름 = 단추 누름 + 칸에 글 넣기 1 번씩. 창 = 지나간 화면·띄움창 수. + +| 일 | 길 | 누름 | 창 | 막힌 곳 | +| --- | --- | --- | --- | --- | +| ① 새 로직(인력 2 · 재료 1 · 경비 1) | 식으로 새로 만들기 | 38 | 5~6 | 첫 식이 단위 검사에 걸림 · 기계 고르기 깨짐 · 시험 계산 멈춤 | +| ② 있는 로직에 줄 하나 | 기본 로직 → 본떠 만들기 → 사본 편집기 | 10 (자체 로직이면 5) | 5 | 기본 로직은 줄 추가 단추 없음 · 사본 시트도 호표 줄 추가 없음 | +| ③ 수량 바꾸기 | 기본: 본떠 만들기 / 자체: 수량 칸 | 5 / 2 | 3 / 1 | 원본은 못 바꿈(사본이 생김) · 수량 칸이 식 글 | +| ④ 단가 자료 바꾸기 | 자체 로직 「찾기」 | 인력 3 · 재료 불가 · 기계 글 고침 | 2 | 재료 조건 줄 로직은 편집기 빈 화면 · 기계는 코드 글 | +| ⑤ 줄 지우기 | 자체 로직 「×」 | 2 | 1 | 기본 로직은 불가 · 확인창 없음 · 줄 하나 되돌리기 없음 | +| ⑥ 로직 지우기 · 되돌리기 | 로직 지우기 → 확인 → 저장 | 4 | 2 | 저장 뒤 되돌릴 길 없음 · 확인창이 원문번호로 부름 | + +### ① 새 로직 — 「식으로 새로 만들기」 (실측) + +1. 「단가산출 로직」 1 · 「식으로 새로 만들기」 1 · 식 적기 1 · 「분석」 1 = 4 +2. 처음 적은 식 `공사비 = 0.5 * 보통인부 + 0.2 * 특별인부 + 1.1 * 시멘트 + 0.3 * 굴착기` → 「「+」 양쪽 단위가 다름 — 원/인 + 원/포」. 수량마다 이름을 붙여 다시 적고(1) 다시 분석(1) = 2 +3. 마스터 요소 넷 — 무엇인가 1 · 고르기 1 · 고름 1 = 인력 3 × 2. 재료 · 기계는 고르기 창이 늘 「인력」으로 열려 종류 바꿈 1 · 찾기 글 1 더 = 5 × 2. 기계는 단위 칸 「[object Object]」 고침 1 더 = 17 +4. 고정값 넷(보통인부품 · 특별인부품 · 시멘트량 · 굴착기시간) — 무엇인가 · 값 · 단위 = 3 × 4 = 12 +5. 로직 이름 1 · 결과 단위 1 · 저장 1 = 3 +6. 합 38 · 창 = 본 화면 + 고르기 창 4 (+ 단가 없는 자재를 바꾸려면 「자재품목 고르기」 1) +7. 결과 — 시험 계산 「멈춤 · 이 조건으로는 셈하지 못함 — 입력값 · 표에 그 조합이 있는지 확인」. 실제 까닭은 고른 시멘트(MT004177)에 단가 없음. 고르기 창이 재료 값을 안 보여 줘 미리 알 길 없음. 멈춤인데도 [저장] 켜짐. +8. 「본떠 만들기」 길(비슷한 로직 사본) = 줄 누름 1 · 본떠 만들기 1 · 이름 1 · 저장(자체 로직) 1 = 4 — 그러나 사본 시트에서는 호표 줄 추가 · 지우기 불가 → 사본 상세 편집기에서 다시 고쳐야 함. + +### ② 있는 로직에 줄 하나 (편집기는 캐시 초안으로 실측) + +- 기본 로직 1,469 개 모두 상세가 읽기 전용(「기본 로직은 품셈 원문 그대로라 여기서 고치지 못함 — 비고만 고칠 수 있음」). +- 길: 찾기 글 1 · 줄 누름 1 · 본떠 만들기 1 · 저장(자체 로직) 1 → 사본 상세 · 호표 「줄 추가」 1 · 「찾기」 1 · 고름 1 · 수량 1 · 저장 1 = 9 (재료면 종류 바꿈 +1 = 10). +- 사본 상세(자체 로직) 편집기에는 같은 이름 「줄 추가」 단추가 넷 — 설계에서 받을 값 · 호표 · 중간 값 · 덧줄. +- 새 줄 기본값 = 종류 인력 · 단위 인 · 비목 노무비 · 요소 · 이름 · 수량 빈칸. +- 금액 보려면 시험 계산 — 초안은 견본값이 안 채워져 받을 값 셋(횡단경사 · 지역 · 보정작업)을 넣을 때마다 「「지역」 값을 아직 안 넣었습니다」로 하나씩 멈춤 → 3 칸 + 계산 1 = 4 더. + +### ③ 수량 바꾸기 + +- 자체 로직 — 수량 칸 글 고침 1 · 저장 1. 칸 값이 `찾기(QC000544, 도색=도색).특별인부 / 시공량` 같은 식 글. +- 기본 로직 — 본떠 만들기 시트의 식 줄 칸(`(절토량 / 작업량) * (1 + 할증 / 100)`)을 고쳐 저장(자체 로직) → 원본 대신 「… 복사본」이 하나 더 생김. +- 표 안 숫자(예 시공량 700)는 이 화면에서 못 고침 — 알약 → 「마스터 화면에서 보기」로 소요량 표 화면에 가야 함. + +### ④ 단가 자료(자재 · 노임) 바꾸기 + +- 인력 줄(요소 = `LB000002`) — 「찾기」 1 · 고름 1 · 저장 1 = 3. +- 재료 조건 줄(요소 = `{구분 · 상세구분 · 검색어}`) — 이런 줄이 든 로직 230 개(재료 줄 672). 사본을 열면 상세가 빈 화면(2장 흠 B2) → 불가. +- 경비(기계) 줄 — 마스터에 「기계」 종류 줄 0 · 모두 `로직(GC000267, 기계='0201-0020', 지역=지역, 보정작업=보정작업)` 부르기(1,075 줄). 굴착기 0.2㎥ → 0.4㎥는 글 속 코드 `'0201-0020'`를 손으로 바꿔야 함. 「찾기」는 로직 1,469 개를 보여 주고 고르면 괄호 속 인자(기계= …)가 사라짐. +- 「이름·규격」 칸은 꼬리표일 뿐 — 바꿔도 단가 그대로. +- 시험 계산의 「품목 고르기」(물가자료 · 유통물가 · 물가정보 · 거래가격 넷 보임)는 시험에만 쓰고 로직에 안 남음 — 바꾼 줄로 착각 쉬움. + +### ⑤ 줄 지우기 + +- 자체 로직 — 줄 끝 「×」 1 · 저장 1. 확인창 없음. +- 되돌리기 = 「고친 것 버리기」 하나뿐 — 모든 로직의 고친 것을 통째로 버림. 줄 하나 되살리기 없음. +- 기본 로직 · 본떠 만들기 시트 — 줄 지우기 단추 없음. + +### ⑥ 로직 지우기 · 되돌리기 + +- 줄 누름 1 · 로직 지우기 1 · 확인 1 · 저장 1 = 4 (실측은 확인창에서 취소). +- 확인창 글 「로직 「3-4-2」 을 지울까요? [저장] 때 파일에서 빠짐」 — 이름이 아니라 원문번호로 부름. 같은 3-4-2 가 GF000003 · GF000004 둘. +- 「고치지 못함」 기본 로직도 지우기는 됨(서버 `/save` 가 지움을 막지 않음 · 다른 로직이 부르면 저장 때 검사로 막힘). +- 저장 전 되돌리기 = 「고친 것 버리기」(전부) · 저장 뒤 = 길 없음(휴지통 · 초기화 없음). +- 저장 안 한 새 로직을 지우면 확인 뒤 바로 사라짐. + +## 3. 처음 보는 사람이 모를 낱말 + +- 호표 · 덧줄 · 중간 값 · 끝수 · 비목 · 몫(「노무비 몫」) · 소계 · 결과 단위 · 원문번호 · 출처 · 막힘 +- 기본 로직 · 정본 · 자체 로직 · 본뜸 · 복사본 +- 별칭(복사 시트 「부르는 표·로직」 상자) · 그대로 부르기 · 같이 복사하기 · 표 바꾸기 +- 마스터 요소 · 설계 입력 · 고정값 · 표 찾기 · 덧줄 비목 · 「비목 합 — 덧줄에서만 쓰는 이름」 · 식으로 정해짐 +- 키 머리글 — GF · GC · GX · LB · MT · EQ · QF · QC +- 식 낱말 — `찾기(QF000028, 구분='m당 절토량').값` · `로직(GC000267, 기계='0201-0020', …)` · `만약(횡단경사 >= 55, …, 0)` · `LB:{공수직종}` · `MO:휘발유:{지역}` +- 소유 — 세 화면이 다 다름: 식 화면 빈 글칸 · 편집기 「공용 / 개인」 · 복사 시트 「현장 / 공용」 +- 시험 계산 — 계산 때 · 고친 줄로 계산(저장 전) · 멈춤 · 물가자료 · 유통물가 · 물가정보 · 거래가격 · 대표 +- 텍스트 수식 속 기계가 지은 이름 — `굴착기무한궤도노무비단가` · 알약 꼬리표 `실선 · 5ton`(표 칸 `트럭2_5ton` 줄임) + +## 4. 화면에 안 보이는 규칙 + +1. 기본 로직은 줄 · 수량 · 단가 모두 못 고침 → 먼저 「본떠 만들기」로 자체 사본을 만들어야 함. 원본은 그대로. +2. 본떠 만들기 시트는 호표 줄 추가 · 지우기 불가 — 덧줄 추가만. +3. 「같이 복사하기」는 [저장] 없이 그 자리에서 서버에 새 로직을 씀 — 시트를 취소해도 남음(`M01_MasterData_UI_LogicLab_Copy.ts:197` → `/logic/copy` → `M01_MasterData_Store_Make.py:215` `logic_new`). +4. 「식으로 새로 만들기」는 위 [저장]과 따로 — ④ 칸의 [저장]이 바로 서버에 씀 · 캐시에 안 쌓임 · 「닫기」는 묻지 않고 다 버림. 한 화면에 [저장] 둘. +5. 식의 숫자는 단위가 없어 바로 곱하면 단위 검사에 걸림 — 수량마다 고정값 변수 + 단위를 적어야 통과. +6. 변수 목록은 식 순서가 아니라 가나다 순. +7. 경비(기계)는 요소가 아니라 「기계 시간당 경비 로직」을 기계 코드로 부르는 꼴. 식 화면은 기계 요소(EQ)를 바로 넣어 마스터와 다른 모양을 만듦. +8. 한 기계 줄이 인력 · 재료 · 경비 세 묶음에 「몫」으로 쪼개 보임 — 실제 줄은 하나. +9. 「이름」 칸은 꼬리표 — 단가 자료는 「요소」 칸 글이 정함. +10. 고르기 창이 셋 — 식 화면 「마스터 요소」(50 개 · 구분 거름 · 재료 값 안 보임) · 편집기 「요소 찾기」(100 개 · 거름 없음) · 시험 계산 「자재품목 고르기」(물가 넷). 열 때마다 종류가 「인력」으로 돌아가고 변수 이름으로 미리 찾아 두지 않음. +11. 시험 계산 견본값은 저장된 로직만 자동으로 채움(`M01_MasterData_UI_Logic_Calc.ts:153-155`) — 기본 로직은 바로 금액이 나오나 새 로직 · 사본 초안은 빈칸이라 받을 값을 다 채울 때까지 한 칸씩 멈춤. +12. 「고친 것 버리기」는 로직 하나가 아니라 저장 안 한 모든 로직을 버림. +13. 식 화면은 결과(원)와 결과 단위(원/㎡)가 어긋나도 검사 안 함. +14. 고르기 창 단위와 줄 단위가 다를 수 있음 — 노임 「원/일」을 골라도 줄 단위는 「인」. + +## 5. 다섯 기능 — 하려던 것 · 어려운 것 + +- **본떠 만들기** — 고칠 수 없는 기본 로직의 사본(자체)을 떠서 고치게 하려던 것. 어려움: 줄 추가 · 지우기 불가(덧줄만, 그마저 안 보임) · 별칭 상자와 「그대로 부르기 / 같이 복사하기」(즉시 저장) 갈래를 알아야 함 · 사본 뒤 다시 편집기로 가야 함. +- **식으로 새로 만들기** — 「이름 = 식」 글 몇 줄로 새 로직을 짓게 하려던 것. 어려움: 빈 글에서 식 문법과 단위 맞춤을 알아야 함 · 변수마다 무엇인가 네 갈래 · 기계 고르기 깨짐 · 닫기에 안 물음. +- **텍스트 수식** — 계산을 「변수 이름 식 = 값 넣은 식」 두 칸 글로 보여 검산하게 하려던 것. 어려움: 읽기만 · 기계가 지은 긴 이름 · 계산 전엔 오른쪽이 「시험 계산을 누르면 식이 글로 나옴」. +- **시험 계산** — 받을 값을 넣어 금액을 미리 보게 하려던 것. 어려움: 새 로직 · 초안은 한 칸씩 멈춤 · 멈춤 까닭이 엉뚱할 때 있음 · 품목 고르기가 시험에만 남음. +- **알약** — 수량 식 속 표 · 요소 · 로직 이름을 누르면 그 자료(걸린 줄 노란색)를 바로 보이려던 것. 창 자체는 쉬움. 어려움: 보기만(고치기는 「마스터 화면에서 보기」로 딴 화면) · 꼬리표가 표 칸 줄임 · 로직에 따라 알약 대신 글자만(GF000004). + +## 6. 쓰다 만난 흠 (고치지 않음 · 새 길이 같은 자리를 밟지 않게) + +| # | 흠 | 자리 | 사진 | +| --- | --- | --- | --- | +| B1 | 기계 고르기 창에 날 JSON(`{"가격":66538,…}`) · 이름이 한 글자씩 세로 · 고르면 단위 「[object Object]」 · 결과 「원/[object Object]」 | `M01_MasterData_UI_LogicLab_New_Find.ts:124-147` (기계 줄의 값 · 단위가 객체) | s02 | +| B2 | 재료 조건 줄이 든 자체 로직을 열면 상세 빈 화면 — `item.요소.includes is not a function` · 대상 230 로직(본뜨면 터짐) | `M01_MasterData_UI_Logic_Edit.ts:278` · 같은 꼴 `:294` | — | +| B3 | 본떠 만들기 「덧줄 추가」를 눌러도 새 줄이 안 보임 — 안 보이는 덧줄(식 0)이 저장엔 실림 | `M01_MasterData_UI_LogicLab_Copy.ts:236` · `:296` (줄 목록이 서버 첫 응답만) | s06 | +| B4 | 상세 표의 금액이 「금액」 칸이 아니라 단가 밑 둘째 줄에 앉음 | `M01_MasterData_UI_LogicLab_Detail.ts` 묶음 표 | s05 · s09 | +| B5 | 시험 계산 멈춤 까닭이 엉뚱 — 단가 없는 자재인데 「입력값 · 표 조합 확인」 | `safeReason` `M01_MasterData_UI_Logic_Text.ts` | — | +| B6 | 복사 시트 「취소」 단추가 세로로 찌그러짐(「취/소」) | 복사 시트 머리 | s06 | +| B7 | 목록에서 「로직 지우기」 · 「본떠 만들기」가 켜진 채 | `M01_MasterData_UI_LogicLab.ts:401-417` | s01 | + +## 7. 새 길이 피할 것 (설계 후보 · 정함은 브레인 몫) + +1. 사용자가 「본뜨기」 · 「자체 / 기본」을 몰라도 되게 — 기본 로직에서 바로 고치기를 누르면 사본이 생긴다는 것을 한 줄로 알림. +2. 줄은 인력 · 재료 · 경비 세 상자에 한 줄씩 — 몫 쪼개기 · 덧줄 · 중간 값을 처음 화면에 안 보임. +3. 고르기 창 하나 — 그 상자 종류로 열림 · 줄 이름으로 미리 찾음 · 단가가 보임 · 단가 없는 품목은 흐리게. +4. 수량은 숫자 칸 먼저 · 단위는 줄 단위에서 자동 · 식은 「고급」 뒤로. +5. 경비(기계)는 「기계 고르기」로 — 속에서는 지금 형식 `로직(GC…, 기계='코드', …)`으로 읽고 씀. +6. 재료 조건 줄(`{구분 · 상세구분 · 검색어}`)을 읽고 쓸 수 있어야 함 — 230 로직. +7. [저장] 하나 · 닫기 전 물음 · 줄 하나 되돌리기. +8. 새 로직 · 사본에도 시험 견본값을 채워(`/logic/auto` 꼴) 한 번에 금액이 나오게 · 멈춤 까닭은 줄 이름으로. +9. 지울 때 이름으로 묻기 · 저장 전까지 되살리기. From 73daf910ad26be00bd0af2e817168008c4a2eb63 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 09:42:06 +0900 Subject: [PATCH 10/12] =?UTF-8?q?feat(M01):=20=EC=89=AC=EC=9A=B4=20?= =?UTF-8?q?=EB=8B=A8=EA=B0=80=EC=82=B0=EC=B6=9C=202=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=ED=91=9C=20=EB=B3=B4=EA=B3=A0=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20?= =?UTF-8?q?=E2=80=94=20openEasyTable(PLAN=2034-3c)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 잠금 카드 수량 식 속 표 찾기를 엔진 문법대로 읽음(따옴표 · 괄호 · 겹친 찾기) · 못 읽는 꼴은 한 줄 안내 - 표를 그대로(머리 · 줄) · 지금 시험값으로 걸린 줄 노랑(서버 /text 표 조각 줄 차례) · 값 칸 굵게 - 줄 누르면 그 줄 값으로 · 조건마다 「설계 때 정함 / 이 값으로 고정」 · 범위 칸은 범위규칙대로 안쪽 수 - 바뀐 식으로 이 줄 금액 · 합계 미리 보임(/text · /calc) · 고른 줄이 안 걸리면 [이걸로] 막음 - 시험 test_m01_easycalc_table.py 6 통과 · typecheck 통과 - ORCA: GF000219 석공 줄 35 · 깬잡석 고정 → 87,395 원(서버 87,395.1) · 합계 132,133 · GC000129 범위 줄 5~8 → 나무높이 6.5 · 541,940 → 709,080 · 값 칸 · 취소 · 닫기 null Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ly2oHAaTDYRjcniAmHR81z --- .../EasyCalc/M01_EasyCalc_Table.css | 145 ++++ M01_MasterData/EasyCalc/M01_EasyCalc_Table.ts | 676 ++++++++++++++++++ resources/tester/test_m01_easycalc_table.py | 155 ++++ 3 files changed, 976 insertions(+) create mode 100644 M01_MasterData/EasyCalc/M01_EasyCalc_Table.css create mode 100644 M01_MasterData/EasyCalc/M01_EasyCalc_Table.ts create mode 100644 resources/tester/test_m01_easycalc_table.py diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Table.css b/M01_MasterData/EasyCalc/M01_EasyCalc_Table.css new file mode 100644 index 000000000..79aa7fd24 --- /dev/null +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Table.css @@ -0,0 +1,145 @@ +/* 쉬운 단가산출 2단계 — 표 보고 고르기 창 */ +.ui-modal.m01-easytable { + width: min(1100px, 96vw); +} + +.m01-easytable .ui-modal__body { + display: flex; + flex-direction: column; + gap: var(--spacing-12); +} + +.m01-easytable__note { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +.m01-easytable__which, +.m01-easytable__actions, +.m01-easytable__seg { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} + +.m01-easytable__which:empty { + display: none; +} + +.m01-easytable__actions { + justify-content: flex-end; +} + +.m01-easytable__head { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.m01-easytable__conds { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +.m01-easytable__conds h4 { + margin: 0; + font-size: var(--text-body); +} + +.m01-easytable__cond { + display: grid; + grid-template-columns: minmax(6rem, 10rem) minmax(5rem, 1fr) auto; + align-items: center; + gap: var(--spacing-8); + font-size: var(--text-body-sm); +} + +.m01-easytable__cond-value { + font-weight: 600; +} + +.m01-easytable__grid { + max-height: 46vh; + overflow: auto; + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); +} + +.m01-easytable__grid table { + width: 100%; + border-collapse: collapse; + font-size: var(--text-body-sm); +} + +.m01-easytable__grid th, +.m01-easytable__grid td { + padding: var(--spacing-4) var(--spacing-8); + border-bottom: 1px solid var(--color-border); + text-align: left; + white-space: nowrap; +} + +.m01-easytable__grid thead th { + position: sticky; + top: 0; + background: var(--color-surface); + color: var(--color-text-secondary); +} + +.m01-easytable__grid td.is-value { + text-align: right; +} + +.m01-easytable__grid .is-col { + font-weight: 700; + color: var(--color-text); +} + +.m01-easytable__grid tbody tr { + cursor: pointer; +} + +.m01-easytable__grid tbody tr:hover { + background: color-mix(in srgb, var(--color-accent) 10%, var(--color-canvas)); +} + +.m01-easytable__grid tr.is-hit { + background: color-mix(in srgb, var(--color-warning) 28%, var(--color-canvas)); +} + +.m01-easytable__grid tr.is-picked td:first-child { + box-shadow: inset 3px 0 0 var(--color-accent); +} + +.m01-easytable__money { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--spacing-16); +} + +.m01-easytable__money.is-busy { + opacity: 0.5; +} + +.m01-easytable__warn { + margin: 0; + color: var(--color-danger); + font-size: var(--text-body-sm); +} + +.m01-easytable__warn:empty { + display: none; +} + +@media (max-width: 600px) { + .m01-easytable__cond { + grid-template-columns: 1fr 1fr; + } + + .m01-easytable__seg { + grid-column: 1 / -1; + } +} diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Table.ts b/M01_MasterData/EasyCalc/M01_EasyCalc_Table.ts new file mode 100644 index 000000000..68af5ab3e --- /dev/null +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Table.ts @@ -0,0 +1,676 @@ +/* ============================================================================= + * M01_EasyCalc_Table.ts + * 쉬운 단가산출 2단계 — 표 보고 고르기(설계.md 1절 7 · 3절 `openEasyTable`) + * 잠금 카드 수량 식 속 `찾기(표, 조건=값 …).값칸` 을 읽어 그 표를 그대로 보이고, + * 줄 · 칸을 눌러 조건 값 · 값 칸을 바꾼 새 식을 돌려줌(취소 null). + * + * 식 읽기 = 엔진 문법(`master_formula._Parser` 의 find · args · field) 그대로 화면에서 — + * 서버에 찾기 마디를 돌려주는 길이 없어서. 걸린 줄 · 금액은 서버(`/text` 표 조각 `참조.줄` · `/calc`). + * 화면 글에 식 · 키 안 보임 — 표 이름 · 칸 이름 · 값만. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import { fetchTable } from "../M01_MasterData_Api_Fetch"; +import { + fetchAuto, + runCalc, + runText, + searchElements, + type HoLine, + type LogicRow, + type NamedFormula, +} from "../M01_MasterData_UI_Logic_Api"; +import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; +import { toRow, type EasyCard, type EasyDoc } from "./M01_EasyCalc_Model"; +import "./M01_EasyCalc_Table.css"; + +const TEXT = { + Title: ["표 보고 고르기", "Pick from the table"], + Close: ["닫기", "Close"], + Cannot: ["이 줄은 지금 화면에서 바꿀 수 없음", "This line can't be changed here yet"], + NoTable: ["표를 불러오지 못함", "Couldn't load the table"], + Loading: ["불러오는 중", "Loading"], + Hint: [ + "줄을 누르면 그 줄 값으로 정함 · 노란 줄 = 지금 쓰는 줄 · 굵은 칸 = 수량을 가져오는 칸", + "Tap a row to use it · yellow = row in use · bold = column the quantity comes from", + ], + Conds: ["어느 줄을 쓸까", "Which row"], + Design: ["설계 때 정함", "Set at design"], + Fix: ["이 값으로 고정", "Fix this value"], + AsIs: ["지금대로", "As is"], + NotYet: ["(아직 없음)", "(none yet)"], + Amount: ["이 줄 금액", "This line"], + Total: ["합계", "Total"], + Wait: ["계산 중", "Calculating"], + Stopped: ["계산 멈춤", "Calculation stopped"], + Mismatch: [ + "고른 줄이 이 조건으로는 안 걸림 — 조건을 다시 보세요", + "The picked row isn't matched by these conditions", + ], + Use: ["이걸로", "Use this"], + Cancel: ["취소", "Cancel"], + Which: ["표 고르기", "Choose table"], +} as const; + +const tt = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex as 0 | 1]; + +/* ── 식 읽기 · 짜기(DOM 없음) ─────────────────────────────────────────── */ + +export interface FindArg { + /** 표 조건 칸 이름 */ + key: string; + /** `=` 오른쪽 식 글 그대로 */ + rhs: string; +} + +export interface FindCall { + /** 식 안 자리 — `찾기` 첫 글자부터 값 칸 끝까지 */ + start: number; + end: number; + table: string; + args: FindArg[]; + col: string; +} + +const NAME = /[\p{L}\p{N}_]/u; +const isName = (ch: string | undefined): boolean => !!ch && NAME.test(ch); + +class Reader { + i = 0; + constructor(readonly s: string) {} + ws(): void { + while (this.i < this.s.length && /\s/.test(this.s[this.i])) this.i++; + } + take(ch: string): boolean { + this.ws(); + if (this.s[this.i] !== ch) return false; + this.i++; + return true; + } + name(): string | null { + this.ws(); + const start = this.i; + while (isName(this.s[this.i])) this.i++; + return this.i > start ? this.s.slice(start, this.i) : null; + } + quoted(): string | null { + const end = this.s.indexOf("'", this.i); + if (end < 0) return null; + const text = this.s.slice(this.i, end); + this.i = end + 1; + return text; + } + /** 괄호 · 따옴표 밖 `,` · `)` 앞까지 */ + until(head: boolean): string | null { + const start = this.i; + let depth = 0; + while (this.i < this.s.length) { + const ch = this.s[this.i]; + if (ch === "'" && !head) { + const end = this.s.indexOf("'", this.i + 1); + if (end < 0) return null; + this.i = end + 1; + continue; + } + if (ch === "(" || ch === "{") depth++; + else if (ch === ")" || ch === "}") { + if (depth === 0) break; + depth--; + } else if (ch === "," && depth === 0) break; + this.i++; + } + return this.i < this.s.length ? this.s.slice(start, this.i).trim() : null; + } +} + +/** 한 자리의 찾기 — 못 읽으면 null */ +function readOne(s: string, at: number): FindCall | null { + const r = new Reader(s); + r.i = at + 2; + if (!r.take("(")) return null; + r.ws(); + const table = r.until(true); + if (!table || !/^[A-Za-z]{2}\d+$/.test(table)) return null; // {자리} 낀 키는 화면에서 안 다룸 + const args: FindArg[] = []; + while (r.take(",")) { + r.ws(); + const key = r.s[r.i] === "'" ? (r.i++, r.quoted()) : r.name(); + if (!key || !r.take("=")) return null; + r.ws(); + const rhs = r.until(false); + if (!rhs) return null; + args.push({ key, rhs }); + } + if (!r.take(")") || !r.take(".")) return null; + r.ws(); + const col = r.s[r.i] === "'" ? (r.i++, r.quoted()) : r.name(); + if (!col) return null; + return { start: at, end: r.i, table, args, col }; +} + +/** 식 속 찾기 전부(바깥 것만 · 차례대로) — 하나라도 못 읽으면 null · 없으면 [] */ +export function readFinds(formula: string): FindCall[] | null { + const out: FindCall[] = []; + let i = 0; + while (i < formula.length) { + const ch = formula[i]; + if (ch === "'") { + const end = formula.indexOf("'", i + 1); + if (end < 0) return null; + i = end + 1; + continue; + } + if (formula.startsWith("찾기", i) && !isName(formula[i - 1]) && !isName(formula[i + 2])) { + const call = readOne(formula, i); + if (!call) return null; + out.push(call); + i = call.end; + continue; + } + i++; + } + return out; +} + +const plain = (word: string): string => (/^[\p{L}\p{N}_]+$/u.test(word) ? word : `'${word}'`); + +/** 찾기 하나를 글로 — 마스터 꼴 `찾기(표, 가=나, 다=라).칸` */ +export function writeFind(table: string, args: FindArg[], col: string): string { + const parts = [table, ...args.map((a) => `${plain(a.key)}=${a.rhs}`)]; + return `찾기(${parts.join(", ")}).${plain(col)}`; +} + +/** 식의 찾기들을 갈아 끼움 — `swaps` 의 차례 = `readFinds` 차례 · null 은 그대로 */ +export function replaceFinds(formula: string, calls: FindCall[], swaps: (string | null)[]): string { + let out = formula; + for (let k = calls.length - 1; k >= 0; k--) { + const text = swaps[k]; + if (text !== null && text !== undefined) + out = out.slice(0, calls[k].start) + text + out.slice(calls[k].end); + } + return out; +} + +/** 표 칸 값 → 식 글(수 · 따옴표 글) — 못 쓰면 null */ +export function literal(value: unknown, rule = ""): string | null { + if (typeof value === "number") return String(value); + if (typeof value === "string") return value.includes("'") ? null : `'${value}'`; + if (Array.isArray(value)) { + const n = rangeNumber(value as [number | null, number | null], rule); + return n === null ? null : String(n); + } + return null; +} + +/** 범위 칸 [아래, 위] 안에 드는 수 하나 — 엔진 `_match` 의 범위규칙대로 */ +export function rangeNumber(cell: [number | null, number | null], rule: string): number | null { + const [low, high] = cell; + const kind = (rule || "초과·이하").split(" · ")[0].split(" (")[0]; + const round = (n: number): number => Number(n.toFixed(6)); + if (low !== null && high !== null) return round((low + high) / 2); + if (high !== null) { + if (kind !== "이상·미만") return high; + return round(high > 1 ? high - 1 : high / 2); + } + if (low !== null) return kind.startsWith("이상") ? low : low + 1; + return null; +} + +/** 식 글이 따옴표 글 · 수면 그 값 */ +export function literalValue(rhs: string): string | number | null { + const q = rhs.match(/^'([^']*)'$/); + if (q) return q[1]; + return /^-?\d+(\.\d+)?$/.test(rhs) ? Number(rhs) : null; +} + +/* ── 표 · 서버 ──────────────────────────────────────────────────────── */ + +type Cell = string | number | [number | null, number | null] | null | undefined; +interface EasyTable { + 이름?: string; + 기준?: string; + 범위규칙?: string; + 조건: Record; + 값칸: Record; + 줄: Record[]; +} + +const tables = new Map>(); + +function loadTable(key: string): Promise { + const hit = tables.get(key); + if (hit) return hit; + const groups = key[0] === "C" ? ["계수", "소요량"] : ["소요량", "계수"]; + const job = (async (): Promise => { + for (const group of groups) { + const found = await searchElements(group, key).catch(() => null); + const file = found?.items.find((x) => x.ref === key)?.file; + if (!file) continue; + const got = await fetchTable(file, key).catch(() => null); + if (got?.table) return got.table as unknown as EasyTable; + } + return null; + })(); + tables.set(key, job); + return job; +} + +/** 카드 줄을 새 식으로 — 호표면 `수량` · 덧붙임이면 `식` */ +function cardLine(card: EasyCard, formula: string): HoLine | NamedFormula { + const raw = (card.raw ?? {}) as Record; + if (card.source?.part === "덧줄") return { ...raw, 이름: card.name, 식: formula } as NamedFormula; + return { + 종류: "인력", + 요소: String(card.element ?? ""), + 이름: card.name, + 단위: card.unit, + ...raw, + 수량: formula, + } as HoLine; +} + +/** 이 카드 하나만 든 시험 줄 — `/text` 답에서 그 줄 표 조각을 바로 찾으려고 */ +function probeRow(card: EasyCard, doc: EasyDoc, formula: string): LogicRow { + const line = cardLine(card, formula); + if (card.source?.part === "덧줄") return { ...doc.row, 키: "", 덧줄: [line as NamedFormula] }; + return { ...doc.row, 키: "", 호표: [line as HoLine], 덧줄: [] }; +} + +/** 로직 전체 줄 — 카드 식만 바꿈 · Model 이 아직이면 원래 줄의 그 자리만 */ +function fullRow(card: EasyCard, doc: EasyDoc, formula: string): LogicRow { + try { + const cards = doc.cards.map((c) => (c.id === card.id ? { ...c, qtyFormula: formula } : c)); + return { ...toRow({ ...doc, cards }), 키: "" }; + } catch { + const row: LogicRow = { ...doc.row, 키: "" }; + const at = card.source?.index ?? -1; + if (card.source?.part === "덧줄" && row.덧줄?.[at]) + row.덧줄 = row.덧줄.map((x, i) => (i === at ? { ...x, 식: formula } : x)); + else if (row.호표?.[at]) + row.호표 = row.호표.map((x, i) => (i === at ? { ...x, 수량: formula } : x)); + return row; + } +} + +interface Preview { + ok: boolean; + /** 찾기 차례 → 걸린 줄 차례(못 찾으면 null) */ + hits: (number | null)[]; + line: number | null; + total: number | null; +} + +async function preview( + card: EasyCard, + doc: EasyDoc, + formula: string, + calls: FindCall[], + inputs: Record, +): Promise { + const [text, calc] = await Promise.all([ + runText({ key: "", inputs, row: probeRow(card, doc, formula) }).catch(() => null), + runCalc({ key: "", inputs, row: fullRow(card, doc, formula) }).catch(() => null), + ]); + const lines = text?.ok + ? text.groups.flatMap((g) => g.줄).filter((l) => l.이름 === card.name) + : []; + const pieces = (lines[0]?.조각 ?? []).filter((p) => p.kind === "표"); + const seen = new Map(); + const hits = calls.map((c) => { + const nth = seen.get(c.table) ?? 0; + seen.set(c.table, nth + 1); + const piece = pieces.filter((p) => p.참조?.키 === c.table)[nth]; + return typeof piece?.참조?.줄 === "number" ? piece.참조.줄 : null; + }); + const sum = lines.reduce((s, l) => s + (Number(l.금액) || 0), 0); + return { + ok: !!text?.ok && !!calc?.ok, + hits, + line: lines.length ? sum : null, + total: calc?.ok && calc.sums ? (calc.sums["계"] ?? null) : null, + }; +} + +/* ── 창 ─────────────────────────────────────────────────────────────── */ + +type Mode = "design" | "fix" | "asis"; +interface CondState { + key: string; + /** 원래 식 글 · 새로 더한 조건은 null */ + orig: string | null; + mode: Mode; + /** 설계 값 이름 — 있으면 「설계 때 정함」 고를 수 있음 */ + input: string | null; + fix: Cell; +} +interface CallState { + conds: CondState[]; + col: string; + picked: number | null; + /** 「설계 때 정함」 조건을 고른 줄 값으로 미리 봄 */ + tryValues: Record; +} + +const showCell = (v: Cell): string => { + if (v === null || v === undefined) return ""; + if (Array.isArray(v)) return `${v[0] ?? ""}~${v[1] ?? ""}`; + return String(v); +}; + +function startState(call: FindCall, doc: EasyDoc): CallState { + const names = new Set(doc.inputs.map((x) => x.이름)); + const conds = call.args.map((a): CondState => { + const input = names.has(a.rhs) ? a.rhs : names.has(a.key) ? a.key : null; + const lit = literalValue(a.rhs); + const mode: Mode = names.has(a.rhs) ? "design" : lit !== null ? "fix" : "asis"; + return { key: a.key, orig: a.rhs, mode, input, fix: lit }; + }); + return { conds, col: call.col, picked: null, tryValues: {} }; +} + +function stateArgs(st: CallState, table: EasyTable | null): FindArg[] | null { + const out: FindArg[] = []; + for (const c of st.conds) { + if (c.mode === "design" && c.input) out.push({ key: c.key, rhs: c.input }); + else if (c.mode === "fix") { + const rhs = literal(c.fix, table?.범위규칙); + if (rhs === null) return null; + out.push({ key: c.key, rhs }); + } else if (c.orig !== null) out.push({ key: c.key, rhs: c.orig }); + } + return out; +} + +/** 이 창 모양 그대로의 새 식 — 바뀐 것이 없으면 원래 식 */ +function compose( + formula: string, + calls: FindCall[], + states: CallState[], + tbl: (EasyTable | null)[], +) { + const swaps = calls.map((call, k) => { + const args = stateArgs(states[k], tbl[k]); + if (!args) return null; + const same = + states[k].col === call.col && + args.length === call.args.length && + args.every((a, i) => a.key === call.args[i].key && a.rhs === call.args[i].rhs); + return same ? null : writeFind(call.table, args, states[k].col); + }); + return replaceFinds(formula, calls, swaps); +} + +/** 잠금 카드 수량을 표에서 골라 바꿈 — 새 식 · 취소 null(설계.md 3절) · trial 없으면 자동값 */ +export function openEasyTable( + card: EasyCard, + doc: EasyDoc, + trial?: Record, +): Promise { + const formula = card.qtyFormula ?? ""; + const calls = readFinds(formula); + return new Promise((resolve) => { + let done = false; + const finish = (value: string | null): void => { + if (done) return; + done = true; + resolve(value); + }; + openModal({ + title: tt("Title"), + closeLabel: tt("Close"), + dialogClass: "m01-easytable", + beforeClose: () => (finish(null), true), + mount: (body, close) => { + if (!calls || !calls.length) { + body.append(el("p", { className: "m01-easytable__note", text: tt("Cannot") })); + return; + } + void mountTable(body, { card, doc, formula, calls, trial }, (v) => { + finish(v); + close(); + }); + }, + }); + }); +} + +async function mountTable( + body: HTMLElement, + ctx: { + card: EasyCard; + doc: EasyDoc; + formula: string; + calls: FindCall[]; + trial?: Record; + }, + finish: (value: string | null) => void, +): Promise { + const { card, doc, formula, calls } = ctx; + body.append(el("p", { className: "m01-easytable__note", text: tt("Loading") })); + const [base, tbl] = await Promise.all([ + ctx.trial ?? (doc.key ? fetchAuto(doc.key) : Promise.resolve({})), + Promise.all(calls.map((c) => loadTable(c.table))), + ]); + const states = calls.map((c) => startState(c, doc)); + let now = 0; + let last: Preview = { ok: false, hits: calls.map(() => null), line: null, total: null }; + let seq = 0; + let busy = false; + + const which = el("div", { className: "m01-easytable__which" }); + const head = el("div", { className: "m01-easytable__head" }); + const condBox = el("div", { className: "m01-easytable__conds" }); + const grid = el("div", { className: "m01-easytable__grid" }); + const money = el("div", { className: "m01-easytable__money" }); + const warn = el("p", { className: "m01-easytable__warn" }); + const use = createButton({ label: tt("Use"), onClick: () => finish(newFormula()) }); + const cancel = createButton({ + label: tt("Cancel"), + variant: "ghost", + onClick: () => finish(null), + }); + + const newFormula = (): string => compose(formula, calls, states, tbl); + const inputs = (): Record => ({ ...base, ...states[now].tryValues }); + + const refresh = async (): Promise => { + const mine = ++seq; + busy = true; + paint(); + const got = await preview(card, doc, newFormula(), calls, inputs()); + if (mine !== seq) return; + last = got; + busy = false; + paint(); + }; + + const pickRow = (idx: number, col: string | null): void => { + const table = tbl[now]; + const st = states[now]; + const row = table?.줄[idx]; + if (!table || !row) return; + for (const key of Object.keys(table.조건)) { + if (row[key] === undefined) continue; + let c = st.conds.find((x) => x.key === key); + if (!c) { + const input = doc.inputs.some((x) => x.이름 === key) ? key : null; + c = { key, orig: null, mode: "fix", input, fix: null }; + st.conds.push(c); + } + if (c.mode === "design" && c.input) { + const n = Array.isArray(row[key]) + ? rangeNumber(row[key] as [number | null, number | null], table.범위규칙 ?? "") + : row[key]; + st.tryValues[c.input] = n; + } else { + c.mode = "fix"; + c.fix = row[key]; + } + } + if (col) st.col = col; + st.picked = idx; + void refresh(); + }; + + const setMode = (c: CondState, mode: Mode): void => { + if (mode === "fix" && (c.fix === null || c.fix === undefined)) { + const hit = last.hits[now]; + const row = hit === null ? null : tbl[now]?.줄[hit]; + c.fix = row?.[c.key] ?? (c.input ? (inputs()[c.input] as Cell) : null); + } + c.mode = mode; + void refresh(); + }; + + const paintConds = (): void => { + const st = states[now]; + condBox.replaceChildren( + el("h4", { text: tt("Conds") }), + ...st.conds.map((c) => { + const opts: [Mode, string][] = []; + if (c.input) opts.push(["design", tt("Design")]); + if (c.orig !== null && c.orig !== c.input && literalValue(c.orig) === null) + opts.push(["asis", tt("AsIs")]); + opts.push(["fix", tt("Fix")]); + const hit = last.hits[now]; + const used = hit === null ? "" : showCell(tbl[now]?.줄[hit]?.[c.key]); + const value = + c.mode === "fix" + ? showCell(c.fix) + : c.mode === "design" && c.input + ? showCell(inputs()[c.input] as Cell) || used + : used; + return el("div", { + className: "m01-easytable__cond", + children: [ + el("span", { className: "m01-easytable__cond-name", text: c.key }), + el("span", { className: "m01-easytable__cond-value", text: value || tt("NotYet") }), + el("span", { + className: "m01-easytable__seg", + children: opts.map(([mode, label]) => { + const b = createButton({ + label, + size: "sm", + variant: c.mode === mode ? "filled" : "ghost", + onClick: () => setMode(c, mode), + }); + b.setAttribute("aria-pressed", String(c.mode === mode)); + return b; + }), + }), + ], + }); + }), + ); + }; + + const paintGrid = (): void => { + const table = tbl[now]; + if (!table) { + grid.replaceChildren(el("p", { className: "m01-easytable__note", text: tt("NoTable") })); + return; + } + const st = states[now]; + const conds = Object.keys(table.조건); + const vals = Object.keys(table.값칸); + const hit = last.hits[now]; + const th = (text: string, cls = ""): HTMLElement => el("th", { className: cls, text }); + const rows = table.줄.map((row, idx) => { + const tr = el("tr", { + className: [ + idx === hit && !busy ? "is-hit" : "", + idx === st.picked ? "is-picked" : "", + ].join(" "), + children: [ + ...conds.map((k) => el("td", { text: showCell(row[k]) })), + ...vals.map((k) => + el("td", { + className: `is-value${k === st.col ? " is-col" : ""}`, + text: showCell(row[k]), + attrs: { "data-col": k }, + }), + ), + ], + }); + tr.addEventListener("click", (ev) => { + const cell = (ev.target as HTMLElement).closest("td"); + pickRow(idx, cell?.dataset.col ?? null); + }); + return tr; + }); + grid.replaceChildren( + el("table", { + children: [ + el("thead", { + children: [ + el("tr", { + children: [ + ...conds.map((k) => th(k)), + ...vals.map((k) => + th( + `${k}${table.값칸[k] ? ` (${table.값칸[k]})` : ""}`, + k === st.col ? "is-col" : "", + ), + ), + ], + }), + ], + }), + el("tbody", { children: rows }), + ], + }), + ); + }; + + const paint = (): void => { + const table = tbl[now]; + which.replaceChildren( + ...(calls.length > 1 + ? calls.map((_c, k) => + createButton({ + label: tbl[k]?.이름 ?? `${tt("Which")} ${k + 1}`, + size: "sm", + variant: k === now ? "filled" : "ghost", + onClick: () => { + now = k; + void refresh(); + }, + }), + ) + : []), + ); + head.replaceChildren( + el("strong", { text: [table?.이름, table?.기준].filter(Boolean).join(" · ") }), + el("span", { className: "m01-easytable__note", text: tt("Hint") }), + ); + paintConds(); + paintGrid(); + const picked = states[now].picked; + const mismatch = !busy && picked !== null && last.hits[now] !== picked; + warn.textContent = mismatch ? tt("Mismatch") : ""; + const fmt = (v: number | null): string => (v === null ? "—" : `${formatMoney(v)} 원`); + money.replaceChildren( + el("span", { text: `${tt("Amount")} ${fmt(last.line)}` }), + el("strong", { text: `${tt("Total")} ${fmt(last.total)}` }), + el("span", { + className: "m01-easytable__note", + text: busy ? tt("Wait") : last.ok ? "" : tt("Stopped"), + }), + ); + money.classList.toggle("is-busy", busy); + use.disabled = busy || mismatch || !last.ok; + }; + + body.replaceChildren( + which, + head, + condBox, + grid, + money, + warn, + el("div", { className: "m01-easytable__actions", children: [cancel, use] }), + ); + await refresh(); +} diff --git a/resources/tester/test_m01_easycalc_table.py b/resources/tester/test_m01_easycalc_table.py new file mode 100644 index 000000000..58ff0f93b --- /dev/null +++ b/resources/tester/test_m01_easycalc_table.py @@ -0,0 +1,155 @@ +"""쉬운 단가산출 2단계 — 표 보고 고르기(`M01_EasyCalc_Table.ts`)의 식 읽기 · 새 식 짜기. + +화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 화면 부품 · 서버는 빈 흉내(순수 함수만 부름). +견본 식은 이 시험 안에 둠(진짜 마스터 내용에 기대지 않음). +""" + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SOURCE = ROOT / "M01_MasterData" / "EasyCalc" / "M01_EasyCalc_Table.ts" + +RUNNER = r""" +const fs = require("fs"); +const path = require("path"); +const [root, file] = process.argv.slice(1); +const ts = require(path.join(root, "config", "node_modules", "typescript")); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const stub = new Proxy({}, { get: () => () => undefined }); +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, () => stub); +const m = box.exports; +const jobs = JSON.parse(fs.readFileSync(0, "utf8")); +const out = jobs.map((job) => { + if (job.op === "read") return m.readFinds(job.formula); + if (job.op === "swap") { + const calls = m.readFinds(job.formula); + const swaps = calls.map((c, i) => { + const s = job.swaps[i]; + return s ? m.writeFind(c.table, s.args ?? c.args, s.col ?? c.col) : null; + }); + return m.replaceFinds(job.formula, calls, swaps); + } + if (job.op === "literal") return m.literal(job.value, job.rule ?? ""); + return null; +}); +process.stdout.write(JSON.stringify(out)); +""" + + +def run(jobs: list[dict]) -> list: + if shutil.which("node") is None: + pytest.skip("node 가 없음") + done = subprocess.run( + ["node", "-e", RUNNER, str(ROOT), str(SOURCE)], + input=json.dumps(jobs, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(done.stdout) + + +MESSANGI = "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 증가율 / 100)" + + +def test_식_읽기_흔한_꼴(): + [calls] = run([{"op": "read", "formula": MESSANGI}]) + assert len(calls) == 1 + c = calls[0] + assert c["table"] == "QF000421" and c["col"] == "석공" + assert [(a["key"], a["rhs"]) for a in c["args"]] == [ + ("뒷길이", "뒷길이"), + ("돌", "돌"), + ("쌓기", "쌓기"), + ] + assert MESSANGI[c["start"] : c["end"]] == "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공" + + +def test_식_읽기_따옴표_쉼표_괄호_겹침(): + f = ( + "(찾기(QC000524, 형식=형식).특별인부 - 만약(정밀절단 == '아님', " + "찾기(QC000583, 구분='가, 나 (다)').값, 0)) / 시공량" + ) + [calls] = run([{"op": "read", "formula": f}]) + assert [c["table"] for c in calls] == ["QC000524", "QC000583"] + assert calls[1]["args"] == [{"key": "구분", "rhs": "'가, 나 (다)'"}] + assert calls[1]["col"] == "값" + + +def test_식_읽기_못_읽는_꼴과_없는_꼴(): + out = run( + [ + {"op": "read", "formula": "찾기(QF:{지역}, 구분='a').값"}, # {자리} 낀 키 + {"op": "read", "formula": "찾기(QF000001, 구분='a'"}, # 안 닫힘 + {"op": "read", "formula": "품 * 1.2"}, + {"op": "read", "formula": "'찾기(QF000001, 구분=1).값'"}, # 따옴표 글 안 + ] + ) + assert out[0] is None and out[1] is None + assert out[2] == [] and out[3] == [] + + +def test_새_식_짜기_줄_고정과_값칸_바꾸기(): + [new] = run( + [ + { + "op": "swap", + "formula": MESSANGI, + "swaps": [ + { + "args": [ + {"key": "뒷길이", "rhs": "35"}, + {"key": "돌", "rhs": "'깬잡석'"}, + {"key": "쌓기", "rhs": "쌓기"}, + ], + "col": "보통인부", + } + ], + } + ] + ) + assert new == ( + "찾기(QF000421, 뒷길이=35, 돌='깬잡석', 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)" + ) + + +def test_새_식_짜기_둘째_찾기만_바꿈_나머지_글_그대로(): + f = "찾기(QC1, 구분='가새').수량 * 찾기(QC2, 공기=공기).손율 / 100" + [new] = run( + [ + { + "op": "swap", + "formula": f, + "swaps": [None, {"args": [{"key": "공기", "rhs": "'1개월'"}]}], + } + ] + ) + assert new == "찾기(QC1, 구분='가새').수량 * 찾기(QC2, 공기='1개월').손율 / 100" + # 다시 읽어도 같은 모양(왕복) + [calls] = run([{"op": "read", "formula": new}]) + assert calls[1]["args"] == [{"key": "공기", "rhs": "'1개월'"}] + + +def test_칸_값을_식_글로(): + out = run( + [ + {"op": "literal", "value": 25}, + {"op": "literal", "value": "깬잡석"}, + {"op": "literal", "value": "it's"}, # 따옴표 든 글은 못 씀 + {"op": "literal", "value": [20, 40], "rule": "초과·이하"}, + {"op": "literal", "value": [None, 60], "rule": "이하"}, + {"op": "literal", "value": [None, 60], "rule": "이상·미만"}, + {"op": "literal", "value": [80, None], "rule": "초과·이하"}, + {"op": "literal", "value": [80, None], "rule": "이상·미만"}, + ] + ) + assert out == ["25", "'깬잡석'", None, "30", "60", "59", "81", "80"] From 4aa81e13b2999d0b4395ba74a12bd655351501a3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 09:43:18 +0900 Subject: [PATCH 11/12] =?UTF-8?q?fix(m01-easycalc):=20Pick=20=EC=9E=AC?= =?UTF-8?q?=EB=A3=8C=20=EC=83=81=EC=84=B8=EA=B5=AC=EB=B6=84=20=EB=B9=88=20?= =?UTF-8?q?=EA=B0=92=20=EB=B3=B4=EB=83=84=20=EA=B3=A0=EC=B9=A8=20=C2=B7=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EA=B2=B0=EA=B3=BC=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=EA=B5=AC=EB=B6=84=20=EC=95=88=20=EB=B6=99=EC=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019ACSGaHdLgnkEkoA4LDtMU --- M01_MasterData/EasyCalc/M01_EasyCalc_Pick.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Pick.ts b/M01_MasterData/EasyCalc/M01_EasyCalc_Pick.ts index e08cb5e85..a7b9bc8cb 100644 --- a/M01_MasterData/EasyCalc/M01_EasyCalc_Pick.ts +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Pick.ts @@ -137,10 +137,12 @@ const SOURCES: Partial> = { .filter((it) => !f.sub || (it as { 구분?: string }).구분 === f.sub) .map((it) => ({ ...it, 구분: (it as { 구분?: string }).구분, 상세구분: undefined })), })) - : await fetchMaterials({ sub: f.sub, detail: f.detail || undefined }).then((r) => ({ - total: r.total, - items: r.items.map((it) => ({ ...it, 구분: it.구분 ?? f.sub })), - })); + : await fetchMaterials({ sub: f.sub, ...(f.detail ? { detail: f.detail } : {}) }).then( + (r) => ({ + total: r.total, + items: r.items.map((it) => ({ ...it, 구분: it.구분 ?? f.sub })), + }), + ); return { total: found.total, items: found.items.map((it) => { @@ -149,7 +151,7 @@ const SOURCES: Partial> = { const unit = typeof it.단위 === "string" ? it.단위 : ""; const price = num(it.값); const element: Record = { 구분: it.구분 ?? f.sub }; - const detail = (it as { 상세구분?: string }).상세구분 ?? f.detail; + const detail = (it as { 상세구분?: string }).상세구분; if (detail) element["상세구분"] = detail; element["검색어"] = `${name} ${spec}`.trim(); return { From 30fe41dbd4049ecee3104b996180ce171a0ee2d3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 09:43:19 +0900 Subject: [PATCH 12/12] auto: 2026-10-01 09:43 (ESD_LAPTOP) --- docs/raw/M01_쉬운단가/쉬운단가_검토_B_화면.md | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 docs/raw/M01_쉬운단가/쉬운단가_검토_B_화면.md diff --git a/docs/raw/M01_쉬운단가/쉬운단가_검토_B_화면.md b/docs/raw/M01_쉬운단가/쉬운단가_검토_B_화면.md new file mode 100644 index 000000000..66d047bc6 --- /dev/null +++ b/docs/raw/M01_쉬운단가/쉬운단가_검토_B_화면.md @@ -0,0 +1,141 @@ +# 34-1B 지금 단가산출 화면 걸음 재기 (sub6 · 조사만) + +⬜ 브레인 답 필요 — 화면에 「추가」라는 글의 단추가 없음. 사용자가 말한 「추가」 = 위 단추 줄 첫째 진한 단추 「식으로 새로 만들기」로 봄(유일한 새로 만들기 단추). 다르면 34-3a 단추 자리가 바뀜. + +- 서버 8008 · 화면 5181 · ORCA 탭 하나(끝나고 닫음) · 판정 전 세션 SYSTEM_ADMIN 확인. +- 저장 · 지우기 안 누름. 줄 추가 · 수량 · 단가 바꾸기는 캐시 초안(sessionStorage)에만 넣어 재고 끝에 지움. 끝난 뒤 서버 로직 1,469 개 · 자체(GX) 0 개 그대로 · git 깨끗. +- 사진 — `tmp/쉬운단가_B_사진/` (s01 목록 … s10 알약 창). + +## 1. 화면 자리 + +| 무엇 | 화면 위치 | 파일:줄 | +| --- | --- | --- | +| 들어가는 곳 | 왼쪽 패널 맨 아래 「단가산출 로직 ▸」 상자 | `M01_MasterData_UI_Side.ts:439-440` · 전환 `M01_MasterData_UI_Page.ts` `openLogicTab` | +| 로직 목록 | 가운데 표 · 30 줄씩 49 쪽 · 1,469 줄 · 이름 찾기 · 「막힘만」 | `M01_MasterData_UI_Logic_List.ts` · 걸기 `M01_MasterData_UI_LogicLab.ts:97` | +| 상세 | 줄 누르면 네 상자 — 기본정보 · 단가산출 상세 · 텍스트 수식 · 시험 계산 | `M01_MasterData_UI_LogicLab_Detail.ts:479-505` | +| 위 단추 줄 | 「← 로직 목록」 「단가산출 로직」 (빈칸) [식으로 새로 만들기] [로직 지우기] [고친 것 버리기] [저장] [본떠 만들기] | `M01_MasterData_UI_LogicLab.ts:394-418` | +| 「추가」로 본 단추 | 「식으로 새로 만들기」 — 진한 남색 · 단추 줄 왼쪽 첫째 | `M01_MasterData_UI_LogicLab.ts:400` | +| 그 옆 빈 자리 | 제목과 첫 단추 사이 넓은 빈칸(제목 오른쪽 여백 auto) — `pending`(「저장 안 한 로직 N 개」) 글 뒤 · `:400` 앞에 끼우면 「식으로 새로 만들기」 바로 왼쪽 | `M01_MasterData_UI_LogicLab.ts:398-400` · 줄 모양 `M01_MasterData_UI_Logic_Style.css:64-75` | + +- 목록일 때도 「로직 지우기」(빨강) · 「본떠 만들기」가 켜져 있음 — 누르면 아무 일 없음 / 「먼저 로직을 고르세요」 알림. + +## 2. 대표 일 여섯 — 걸음 + +누름 = 단추 누름 + 칸에 글 넣기 1 번씩. 창 = 지나간 화면·띄움창 수. + +| 일 | 길 | 누름 | 창 | 막힌 곳 | +| --- | --- | --- | --- | --- | +| ① 새 로직(인력 2 · 재료 1 · 경비 1) | 식으로 새로 만들기 | 38 | 5~6 | 첫 식이 단위 검사에 걸림 · 기계 고르기 깨짐 · 시험 계산 멈춤 | +| ② 있는 로직에 줄 하나 | 기본 로직 → 본떠 만들기 → 사본 편집기 | 10 (자체 로직이면 5) | 5 | 기본 로직은 줄 추가 단추 없음 · 사본 시트도 호표 줄 추가 없음 | +| ③ 수량 바꾸기 | 기본: 본떠 만들기 / 자체: 수량 칸 | 5 / 2 | 3 / 1 | 원본은 못 바꿈(사본이 생김) · 수량 칸이 식 글 | +| ④ 단가 자료 바꾸기 | 자체 로직 「찾기」 | 인력 3 · 재료 불가 · 기계 글 고침 | 2 | 재료 조건 줄 로직은 편집기 빈 화면 · 기계는 코드 글 | +| ⑤ 줄 지우기 | 자체 로직 「×」 | 2 | 1 | 기본 로직은 불가 · 확인창 없음 · 줄 하나 되돌리기 없음 | +| ⑥ 로직 지우기 · 되돌리기 | 로직 지우기 → 확인 → 저장 | 4 | 2 | 저장 뒤 되돌릴 길 없음 · 확인창이 원문번호로 부름 | + +### ① 새 로직 — 「식으로 새로 만들기」 (실측) + +1. 「단가산출 로직」 1 · 「식으로 새로 만들기」 1 · 식 적기 1 · 「분석」 1 = 4 +2. 처음 적은 식 `공사비 = 0.5 * 보통인부 + 0.2 * 특별인부 + 1.1 * 시멘트 + 0.3 * 굴착기` → 「「+」 양쪽 단위가 다름 — 원/인 + 원/포」. 수량마다 이름을 붙여 다시 적고(1) 다시 분석(1) = 2 +3. 마스터 요소 넷 — 무엇인가 1 · 고르기 1 · 고름 1 = 인력 3 × 2. 재료 · 기계는 고르기 창이 늘 「인력」으로 열려 종류 바꿈 1 · 찾기 글 1 더 = 5 × 2. 기계는 단위 칸 「[object Object]」 고침 1 더 = 17 +4. 고정값 넷(보통인부품 · 특별인부품 · 시멘트량 · 굴착기시간) — 무엇인가 · 값 · 단위 = 3 × 4 = 12 +5. 로직 이름 1 · 결과 단위 1 · 저장 1 = 3 +6. 합 38 · 창 = 본 화면 + 고르기 창 4 (+ 단가 없는 자재를 바꾸려면 「자재품목 고르기」 1) +7. 결과 — 시험 계산 「멈춤 · 이 조건으로는 셈하지 못함 — 입력값 · 표에 그 조합이 있는지 확인」. 실제 까닭은 고른 시멘트(MT004177)에 단가 없음. 고르기 창이 재료 값을 안 보여 줘 미리 알 길 없음. 멈춤인데도 [저장] 켜짐. +8. 「본떠 만들기」 길(비슷한 로직 사본) = 줄 누름 1 · 본떠 만들기 1 · 이름 1 · 저장(자체 로직) 1 = 4 — 그러나 사본 시트에서는 호표 줄 추가 · 지우기 불가 → 사본 상세 편집기에서 다시 고쳐야 함. + +### ② 있는 로직에 줄 하나 (편집기는 캐시 초안으로 실측) + +- 기본 로직 1,469 개 모두 상세가 읽기 전용(「기본 로직은 품셈 원문 그대로라 여기서 고치지 못함 — 비고만 고칠 수 있음」). +- 길: 찾기 글 1 · 줄 누름 1 · 본떠 만들기 1 · 저장(자체 로직) 1 → 사본 상세 · 호표 「줄 추가」 1 · 「찾기」 1 · 고름 1 · 수량 1 · 저장 1 = 9 (재료면 종류 바꿈 +1 = 10). +- 사본 상세(자체 로직) 편집기에는 같은 이름 「줄 추가」 단추가 넷 — 설계에서 받을 값 · 호표 · 중간 값 · 덧줄. +- 새 줄 기본값 = 종류 인력 · 단위 인 · 비목 노무비 · 요소 · 이름 · 수량 빈칸. +- 금액 보려면 시험 계산 — 초안은 견본값이 안 채워져 받을 값 셋(횡단경사 · 지역 · 보정작업)을 넣을 때마다 「「지역」 값을 아직 안 넣었습니다」로 하나씩 멈춤 → 3 칸 + 계산 1 = 4 더. + +### ③ 수량 바꾸기 + +- 자체 로직 — 수량 칸 글 고침 1 · 저장 1. 칸 값이 `찾기(QC000544, 도색=도색).특별인부 / 시공량` 같은 식 글. +- 기본 로직 — 본떠 만들기 시트의 식 줄 칸(`(절토량 / 작업량) * (1 + 할증 / 100)`)을 고쳐 저장(자체 로직) → 원본 대신 「… 복사본」이 하나 더 생김. +- 표 안 숫자(예 시공량 700)는 이 화면에서 못 고침 — 알약 → 「마스터 화면에서 보기」로 소요량 표 화면에 가야 함. + +### ④ 단가 자료(자재 · 노임) 바꾸기 + +- 인력 줄(요소 = `LB000002`) — 「찾기」 1 · 고름 1 · 저장 1 = 3. +- 재료 조건 줄(요소 = `{구분 · 상세구분 · 검색어}`) — 이런 줄이 든 로직 230 개(재료 줄 672). 사본을 열면 상세가 빈 화면(2장 흠 B2) → 불가. +- 경비(기계) 줄 — 마스터에 「기계」 종류 줄 0 · 모두 `로직(GC000267, 기계='0201-0020', 지역=지역, 보정작업=보정작업)` 부르기(1,075 줄). 굴착기 0.2㎥ → 0.4㎥는 글 속 코드 `'0201-0020'`를 손으로 바꿔야 함. 「찾기」는 로직 1,469 개를 보여 주고 고르면 괄호 속 인자(기계= …)가 사라짐. +- 「이름·규격」 칸은 꼬리표일 뿐 — 바꿔도 단가 그대로. +- 시험 계산의 「품목 고르기」(물가자료 · 유통물가 · 물가정보 · 거래가격 넷 보임)는 시험에만 쓰고 로직에 안 남음 — 바꾼 줄로 착각 쉬움. + +### ⑤ 줄 지우기 + +- 자체 로직 — 줄 끝 「×」 1 · 저장 1. 확인창 없음. +- 되돌리기 = 「고친 것 버리기」 하나뿐 — 모든 로직의 고친 것을 통째로 버림. 줄 하나 되살리기 없음. +- 기본 로직 · 본떠 만들기 시트 — 줄 지우기 단추 없음. + +### ⑥ 로직 지우기 · 되돌리기 + +- 줄 누름 1 · 로직 지우기 1 · 확인 1 · 저장 1 = 4 (실측은 확인창에서 취소). +- 확인창 글 「로직 「3-4-2」 을 지울까요? [저장] 때 파일에서 빠짐」 — 이름이 아니라 원문번호로 부름. 같은 3-4-2 가 GF000003 · GF000004 둘. +- 「고치지 못함」 기본 로직도 지우기는 됨(서버 `/save` 가 지움을 막지 않음 · 다른 로직이 부르면 저장 때 검사로 막힘). +- 저장 전 되돌리기 = 「고친 것 버리기」(전부) · 저장 뒤 = 길 없음(휴지통 · 초기화 없음). +- 저장 안 한 새 로직을 지우면 확인 뒤 바로 사라짐. + +## 3. 처음 보는 사람이 모를 낱말 + +- 호표 · 덧줄 · 중간 값 · 끝수 · 비목 · 몫(「노무비 몫」) · 소계 · 결과 단위 · 원문번호 · 출처 · 막힘 +- 기본 로직 · 정본 · 자체 로직 · 본뜸 · 복사본 +- 별칭(복사 시트 「부르는 표·로직」 상자) · 그대로 부르기 · 같이 복사하기 · 표 바꾸기 +- 마스터 요소 · 설계 입력 · 고정값 · 표 찾기 · 덧줄 비목 · 「비목 합 — 덧줄에서만 쓰는 이름」 · 식으로 정해짐 +- 키 머리글 — GF · GC · GX · LB · MT · EQ · QF · QC +- 식 낱말 — `찾기(QF000028, 구분='m당 절토량').값` · `로직(GC000267, 기계='0201-0020', …)` · `만약(횡단경사 >= 55, …, 0)` · `LB:{공수직종}` · `MO:휘발유:{지역}` +- 소유 — 세 화면이 다 다름: 식 화면 빈 글칸 · 편집기 「공용 / 개인」 · 복사 시트 「현장 / 공용」 +- 시험 계산 — 계산 때 · 고친 줄로 계산(저장 전) · 멈춤 · 물가자료 · 유통물가 · 물가정보 · 거래가격 · 대표 +- 텍스트 수식 속 기계가 지은 이름 — `굴착기무한궤도노무비단가` · 알약 꼬리표 `실선 · 5ton`(표 칸 `트럭2_5ton` 줄임) + +## 4. 화면에 안 보이는 규칙 + +1. 기본 로직은 줄 · 수량 · 단가 모두 못 고침 → 먼저 「본떠 만들기」로 자체 사본을 만들어야 함. 원본은 그대로. +2. 본떠 만들기 시트는 호표 줄 추가 · 지우기 불가 — 덧줄 추가만. +3. 「같이 복사하기」는 [저장] 없이 그 자리에서 서버에 새 로직을 씀 — 시트를 취소해도 남음(`M01_MasterData_UI_LogicLab_Copy.ts:197` → `/logic/copy` → `M01_MasterData_Store_Make.py:215` `logic_new`). +4. 「식으로 새로 만들기」는 위 [저장]과 따로 — ④ 칸의 [저장]이 바로 서버에 씀 · 캐시에 안 쌓임 · 「닫기」는 묻지 않고 다 버림. 한 화면에 [저장] 둘. +5. 식의 숫자는 단위가 없어 바로 곱하면 단위 검사에 걸림 — 수량마다 고정값 변수 + 단위를 적어야 통과. +6. 변수 목록은 식 순서가 아니라 가나다 순. +7. 경비(기계)는 요소가 아니라 「기계 시간당 경비 로직」을 기계 코드로 부르는 꼴. 식 화면은 기계 요소(EQ)를 바로 넣어 마스터와 다른 모양을 만듦. +8. 한 기계 줄이 인력 · 재료 · 경비 세 묶음에 「몫」으로 쪼개 보임 — 실제 줄은 하나. +9. 「이름」 칸은 꼬리표 — 단가 자료는 「요소」 칸 글이 정함. +10. 고르기 창이 셋 — 식 화면 「마스터 요소」(50 개 · 구분 거름 · 재료 값 안 보임) · 편집기 「요소 찾기」(100 개 · 거름 없음) · 시험 계산 「자재품목 고르기」(물가 넷). 열 때마다 종류가 「인력」으로 돌아가고 변수 이름으로 미리 찾아 두지 않음. +11. 시험 계산 견본값은 저장된 로직만 자동으로 채움(`M01_MasterData_UI_Logic_Calc.ts:153-155`) — 기본 로직은 바로 금액이 나오나 새 로직 · 사본 초안은 빈칸이라 받을 값을 다 채울 때까지 한 칸씩 멈춤. +12. 「고친 것 버리기」는 로직 하나가 아니라 저장 안 한 모든 로직을 버림. +13. 식 화면은 결과(원)와 결과 단위(원/㎡)가 어긋나도 검사 안 함. +14. 고르기 창 단위와 줄 단위가 다를 수 있음 — 노임 「원/일」을 골라도 줄 단위는 「인」. + +## 5. 다섯 기능 — 하려던 것 · 어려운 것 + +- **본떠 만들기** — 고칠 수 없는 기본 로직의 사본(자체)을 떠서 고치게 하려던 것. 어려움: 줄 추가 · 지우기 불가(덧줄만, 그마저 안 보임) · 별칭 상자와 「그대로 부르기 / 같이 복사하기」(즉시 저장) 갈래를 알아야 함 · 사본 뒤 다시 편집기로 가야 함. +- **식으로 새로 만들기** — 「이름 = 식」 글 몇 줄로 새 로직을 짓게 하려던 것. 어려움: 빈 글에서 식 문법과 단위 맞춤을 알아야 함 · 변수마다 무엇인가 네 갈래 · 기계 고르기 깨짐 · 닫기에 안 물음. +- **텍스트 수식** — 계산을 「변수 이름 식 = 값 넣은 식」 두 칸 글로 보여 검산하게 하려던 것. 어려움: 읽기만 · 기계가 지은 긴 이름 · 계산 전엔 오른쪽이 「시험 계산을 누르면 식이 글로 나옴」. +- **시험 계산** — 받을 값을 넣어 금액을 미리 보게 하려던 것. 어려움: 새 로직 · 초안은 한 칸씩 멈춤 · 멈춤 까닭이 엉뚱할 때 있음 · 품목 고르기가 시험에만 남음. +- **알약** — 수량 식 속 표 · 요소 · 로직 이름을 누르면 그 자료(걸린 줄 노란색)를 바로 보이려던 것. 창 자체는 쉬움. 어려움: 보기만(고치기는 「마스터 화면에서 보기」로 딴 화면) · 꼬리표가 표 칸 줄임 · 로직에 따라 알약 대신 글자만(GF000004). + +## 6. 쓰다 만난 흠 (고치지 않음 · 새 길이 같은 자리를 밟지 않게) + +| # | 흠 | 자리 | 사진 | +| --- | --- | --- | --- | +| B1 | 기계 고르기 창에 날 JSON(`{"가격":66538,…}`) · 이름이 한 글자씩 세로 · 고르면 단위 「[object Object]」 · 결과 「원/[object Object]」 | `M01_MasterData_UI_LogicLab_New_Find.ts:124-147` (기계 줄의 값 · 단위가 객체) | s02 | +| B2 | 재료 조건 줄이 든 자체 로직을 열면 상세 빈 화면 — `item.요소.includes is not a function` · 대상 230 로직(본뜨면 터짐) | `M01_MasterData_UI_Logic_Edit.ts:278` · 같은 꼴 `:294` | — | +| B3 | 본떠 만들기 「덧줄 추가」를 눌러도 새 줄이 안 보임 — 안 보이는 덧줄(식 0)이 저장엔 실림 | `M01_MasterData_UI_LogicLab_Copy.ts:236` · `:296` (줄 목록이 서버 첫 응답만) | s06 | +| B4 | 상세 표의 금액이 「금액」 칸이 아니라 단가 밑 둘째 줄에 앉음 | `M01_MasterData_UI_LogicLab_Detail.ts` 묶음 표 | s05 · s09 | +| B5 | 시험 계산 멈춤 까닭이 엉뚱 — 단가 없는 자재인데 「입력값 · 표 조합 확인」 | `safeReason` `M01_MasterData_UI_Logic_Text.ts` | — | +| B6 | 복사 시트 「취소」 단추가 세로로 찌그러짐(「취/소」) | 복사 시트 머리 | s06 | +| B7 | 목록에서 「로직 지우기」 · 「본떠 만들기」가 켜진 채 | `M01_MasterData_UI_LogicLab.ts:401-417` | s01 | + +## 7. 새 길이 피할 것 (설계 후보 · 정함은 브레인 몫) + +1. 사용자가 「본뜨기」 · 「자체 / 기본」을 몰라도 되게 — 기본 로직에서 바로 고치기를 누르면 사본이 생긴다는 것을 한 줄로 알림. +2. 줄은 인력 · 재료 · 경비 세 상자에 한 줄씩 — 몫 쪼개기 · 덧줄 · 중간 값을 처음 화면에 안 보임. +3. 고르기 창 하나 — 그 상자 종류로 열림 · 줄 이름으로 미리 찾음 · 단가가 보임 · 단가 없는 품목은 흐리게. +4. 수량은 숫자 칸 먼저 · 단위는 줄 단위에서 자동 · 식은 「고급」 뒤로. +5. 경비(기계)는 「기계 고르기」로 — 속에서는 지금 형식 `로직(GC…, 기계='코드', …)`으로 읽고 씀. +6. 재료 조건 줄(`{구분 · 상세구분 · 검색어}`)을 읽고 쓸 수 있어야 함 — 230 로직. +7. [저장] 하나 · 닫기 전 물음 · 줄 하나 되돌리기. +8. 새 로직 · 사본에도 시험 견본값을 채워(`/logic/auto` 꼴) 한 번에 금액이 나오게 · 멈춤 까닭은 줄 이름으로. +9. 지울 때 이름으로 묻기 · 저장 전까지 되살리기.