From 73daf910ad26be00bd0af2e817168008c4a2eb63 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 09:42:06 +0900 Subject: [PATCH] =?UTF-8?q?feat(M01):=20=EC=89=AC=EC=9A=B4=20=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=EC=82=B0=EC=B6=9C=202=EB=8B=A8=EA=B3=84=20=ED=91=9C?= =?UTF-8?q?=20=EB=B3=B4=EA=B3=A0=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=E2=80=94?= =?UTF-8?q?=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"]