diff --git a/M01_MasterData/LogicBuilder/M01_LB_Api.ts b/M01_MasterData/LogicBuilder/M01_LB_Api.ts index c48f7d03c..3b63641c6 100644 --- a/M01_MasterData/LogicBuilder/M01_LB_Api.ts +++ b/M01_MasterData/LogicBuilder/M01_LB_Api.ts @@ -84,6 +84,24 @@ export function lbCalcLater( }, CALC_WAIT_MS); } +/** 기다림 없이 바로 셈(기대값 시험표처럼 여러 줄을 차례로) — `lbCalcLater` 의 최신만과 안 걸림. + * 서버에 못 닿으면 null · 멈춤(ok false)은 답 그대로 · 둘 다 `onPlain` 에 쉬운 말 */ +export async function lbCalcNow( + row: LogicRow, + inputs: Record, + tables: LbTables, + onPlain?: (plain: string) => void, +): Promise { + try { + const ans = await send("/calc", { row, inputs: inputsFor(row, inputs), tables }); + if (!ans.ok) onPlain?.(plainStop(ans.reason)); + return ans; + } catch (e: unknown) { + onPlain?.(plainError(e)); + return null; + } +} + /** 사용처 — 그 표를 쓰는 로직(표 「용도」 + 로직 글 대조) */ export async function tableUsers(key: string): Promise { const got = await send<{ users: LbTableUser[] }>( diff --git a/M01_MasterData/LogicBuilder/M01_LB_Check.ts b/M01_MasterData/LogicBuilder/M01_LB_Check.ts new file mode 100644 index 000000000..95d28fc75 --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Check.ts @@ -0,0 +1,266 @@ +/* ============================================================================= + * M01_LB_Check.ts + * 로직 만들기 판 — 검사(정함 7 · 설계초안 4절) + * ① gaps(lb, tables) — 표 조건 칸에 묶인 입력값 선택 목록 조합 × 표 줄 → 해당 없음 · 중복 목록(브라우저 셈) + * ② lookupHits(lb, tables, values) — 예시값으로 표마다 걸린 줄(노랑) · 해당 없음 · 중복 + * ③ runExpect(lb, tables, rows) — 기대값 시험표 · 예시값마다 시험 계산 · 통과 / 실패 + * 시험표 줄은 sessionStorage 초안만(저장 안 함) + * + * 줄 찾기 = 엔진 `master_formula._match · find` 와 같은 규칙(범위규칙 넷 · 빈 칸은 무관 · + * 여러 줄이면 위 끝 가장 작은 줄 · 결과 칸 있는 줄) — 엔진을 바꾸면 이 파일도 같이 바꿈. + * DOM 없음. + * ========================================================================== */ + +import type { CalcAnswer, LogicRow } from "../M01_MasterData_UI_Logic_Api"; +import { lbCalcNow, type LbTables } from "./M01_LB_Api"; +import { writeLogic, type LbLogic, type LbLookup } from "./M01_LB_Blocks"; + +type Cell = string | number | boolean | null | [number | null, number | null]; +export interface CheckTable { + 키?: string; + 범위규칙?: string; + 조건?: Record; + 값칸?: Record; + 줄: Record[]; +} + +/* ── 엔진과 같은 줄 맞추기 ─────────────────────────────────────────── */ + +const isNum = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v); + +function match( + table: CheckTable, + row: Record, + cond: string, + want: unknown, +): boolean { + if (!(cond in row)) return true; // 빈 조건 칸 = 무관 + const have = row[cond] as Cell; + if (table.조건?.[cond] === "범위") { + if (!Array.isArray(have) || !isNum(want)) return false; + const [low, high] = have; + const [head, ...more] = (table.범위규칙 ?? "초과·이하").split(" · "); + const rule = head.split(" (")[0]; + const above = (strict: boolean): boolean => low === null || (strict ? want > low : want >= low); + if (rule === "이상·이하") return above(false) && (high === null || want <= high); + if (rule === "초과·이하" || rule === "이하") + return above(true) && (high === null || want <= high); + if (rule === "이상·미만") { + const tops = table.줄 + .map((r) => r[cond]) + .filter((c): c is [number | null, number] => Array.isArray(c) && c[1] !== null) + .map((c) => c[1]); + const closed = + more.some((x) => x.includes("이하 포함")) && tops.length > 0 && high === Math.max(...tops); + return above(false) && (high === null || want < high || (closed && want === high)); + } + return false; // 모르는 범위규칙 — 엔진은 멈춤 + } + if (isNum(have) && isNum(want)) return have === want; + return typeof have === "string" && typeof want === "string" && have === want; +} + +/** 엔진 `find` 처럼 맞는 줄 차례 — 하나면 그 줄 · 0 · 2+ 는 그대로 돌려줌 */ +export function findRows(table: CheckTable, conds: Record, want = ""): number[] { + let hits = table.줄 + .map((row, i) => ({ row, i })) + .filter(({ row }) => Object.entries(conds).every(([c, v]) => match(table, row, c, v))); + const first = (table.범위규칙 ?? "").split(" · ")[0]; + if (hits.length > 1 && (first === "이하" || first === "이상·이하")) { + for (const c of Object.keys(conds)) { + if (table.조건?.[c] !== "범위") continue; + const ups = hits.filter(({ row }) => row[c]).map(({ row }) => (row[c] as Cell[])[1]); + if (!ups.length || ups.includes(null)) continue; + const low = Math.min(...(ups as number[])); + hits = hits.filter(({ row }) => !row[c] || (row[c] as Cell[])[1] === low); + } + } + if (hits.length > 1 && want) hits = hits.filter(({ row }) => want in row); + return hits.map((h) => h.i); +} + +/* ── ① 빈틈 · 중복 ──────────────────────────────────────────────────── */ + +export interface Gap { + table: string; + /** 어느 표 조회에서 나왔나(화면 번호) */ + lookup: string; + conds: Record; + kind: "없음" | "중복"; + /** 중복이면 걸린 줄 차례 */ + rows: number[]; +} + +/** 조합마다 조건 값 — 입력값은 선택 목록 · 고정값은 그 값 · 그 밖(계산값 등)은 셀 수 없어 null */ +function combos(lb: LbLogic, lk: LbLookup): Record[] | null { + const lists: [string, unknown[]][] = []; + for (const { col, from } of lk.conds) { + if (from.k === "fixed") lists.push([col, [from.value]]); + else if (from.k === "input") { + const spec = lb.inputs.find((x) => x.이름 === from.name); + if (!spec?.고르기?.length) return null; + lists.push([col, spec.고르기]); + } else return null; + } + let out: Record[] = [{}]; + for (const [col, values] of lists) + out = out.flatMap((o) => values.map((v) => ({ ...o, [col]: v }))); + return out; +} + +/** 입력값 선택 목록 조합마다 표 줄 대조 — 해당 없음 · 중복만 돌려줌(셀 수 없는 표 조회는 건너뜀) */ +export function gaps(lb: LbLogic, tables: Record): Gap[] { + const out: Gap[] = []; + const seen = new Set(); + for (const lk of lb.lookups) { + const table = tables[lk.table] as CheckTable | undefined; + const sets = table?.줄 ? combos(lb, lk) : null; + if (!table || !sets) continue; + for (const conds of sets) { + const rows = findRows(table, conds, lk.col); + if (rows.length === 1) continue; + const tag = `${lk.table}|${JSON.stringify(conds)}|${rows.join(",")}`; + if (seen.has(tag)) continue; + seen.add(tag); + out.push({ + table: lk.table, + lookup: lk.id, + conds, + kind: rows.length ? "중복" : "없음", + rows, + }); + } + } + return out; +} + +/** 셀 수 없어 빈틈 검사를 건너뛴 표 조회(계산값 · 숫자 입력에 묶인 조건) */ +export function gapSkipped(lb: LbLogic, tables: Record): string[] { + return lb.lookups.filter((lk) => !tables[lk.table] || !combos(lb, lk)).map((lk) => lk.id); +} + +/* ── ② 예시값으로 걸린 줄 ─────────────────────────────────────────── */ + +/** 표 조회 번호 → 걸린 줄 차례 · 「없음」 · 「중복」 · null(계산값에 묶여 화면이 모름) */ +export type LbHit = number | null | "없음" | "중복"; + +export function lookupHits( + lb: LbLogic, + tables: Record, + values: Record, +): Record { + const out: Record = {}; + for (const lk of lb.lookups) { + const table = tables[lk.table] as CheckTable | undefined; + const conds: Record = {}; + let known = !!table?.줄; + for (const { col, from } of lk.conds) { + if (from.k === "fixed") conds[col] = from.value; + else if (from.k === "input" && values[from.name] !== undefined) + conds[col] = values[from.name]; + else known = false; + } + if (!known || !table) { + out[lk.id] = null; + continue; + } + const rows = findRows(table, conds, lk.col); + out[lk.id] = rows.length === 1 ? rows[0] : rows.length ? "중복" : "없음"; + } + return out; +} + +/* ── ③ 기대값 시험표 ───────────────────────────────────────────────── */ + +export interface ExpectRow { + /** 예시값 */ + inputs: Record; + /** 볼 값 — 「계」(합계) · 「결과」 · 항목 이름(수량) · 「항목 이름 금액」 */ + expect: Record; +} + +export interface ExpectResult { + ok: boolean; + got: Record; + /** 계산 멈춤 · 서버 오류 까닭(쉬운 말은 부른 쪽이 다듬음) */ + why: string | null; +} + +/** 계산 답 → 볼 값 모음 */ +export function pickValues(ans: CalcAnswer): Record { + const got: Record = {}; + if (!ans.ok) return got; + if (ans.sums && isNum(ans.sums["계"])) got["계"] = ans.sums["계"]; + if (isNum(ans.result)) got["결과"] = ans.result; + for (const line of ans.lines ?? []) { + if (isNum(line.수량)) got[line.이름] = line.수량; + if (isNum(line.금액)) got[`${line.이름} 금액`] = line.금액; + } + return got; +} + +/** 기대값과 같은가 — 기대값에 적은 자릿수의 반 올림 안쪽이면 같음(52,437 ↔ 52,437.06) */ +export function sameAsExpect(got: number | undefined, expect: number): boolean { + if (!isNum(got)) return false; + const places = (String(expect).split(".")[1] ?? "").length; + return Math.abs(got - expect) <= 0.5 * 10 ** -places + 1e-9; +} + +type CalcFn = ( + row: LogicRow, + inputs: Record, + tables: LbTables, +) => Promise; + +/** LB Api 바로 계산 — 화면 예시값 계산(0.3초 · 최신만)과 안 부딪힘 · 멈춤 까닭은 쉬운 말로 */ +const calcNow: CalcFn = async (row, inputs, tables) => { + let plain = ""; + const ans = await lbCalcNow(row, inputs, tables, (p) => (plain = p)); + if (!ans) throw new Error(plain); + return ans.ok ? ans : { ok: false, reason: plain || ans.reason }; +}; + +/** 줄마다 시험 계산(차례로 · 하나씩) — 로직은 블록을 글로 되돌린 줄 · `calc` 는 시험에서 바꿔 끼움 */ +export async function runExpect( + lb: LbLogic, + tables: LbTables, + rows: ExpectRow[], + calc: CalcFn = calcNow, +): Promise { + const row = writeLogic(lb); + const out: ExpectResult[] = []; + for (const r of rows) { + try { + const ans = await calc(row, r.inputs, tables); + const got = pickValues(ans); + const ok = ans.ok && Object.entries(r.expect).every(([k, v]) => sameAsExpect(got[k], v)); + out.push({ ok, got, why: ans.ok ? null : ans.reason }); + } catch (e) { + out.push({ ok: false, got: {}, why: e instanceof Error ? e.message : String(e) }); + } + } + return out; +} + +/* ── 시험표 초안(sessionStorage · 로직마다) ───────────────────────────── */ + +const draftKey = (logic: string): string => `m01.lb.expect.${logic || "새"}`; + +export function loadExpectRows(logic: string): ExpectRow[] { + try { + const raw = sessionStorage.getItem(draftKey(logic)); + const rows = raw ? (JSON.parse(raw) as ExpectRow[]) : []; + return Array.isArray(rows) ? rows : []; + } catch { + return []; + } +} + +export function saveExpectRows(logic: string, rows: ExpectRow[]): void { + try { + if (rows.length) sessionStorage.setItem(draftKey(logic), JSON.stringify(rows)); + else sessionStorage.removeItem(draftKey(logic)); + } catch { + /* 저장 칸이 막힌 브라우저 — 이번 화면에서만 씀 */ + } +} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Entry.css b/M01_MasterData/LogicBuilder/M01_LB_Entry.css index 9975c9b32..26f9e736e 100644 --- a/M01_MasterData/LogicBuilder/M01_LB_Entry.css +++ b/M01_MasterData/LogicBuilder/M01_LB_Entry.css @@ -201,3 +201,42 @@ overflow: auto; } } + +/* 결과 칸 — 빈틈 검사 · 기대값 시험표 */ +.m01-lb__check { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding-top: var(--spacing-8); + border-top: 1px solid var(--color-border); +} + +.m01-lb__check-tools { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} + +.m01-lb__check-out ul { + margin: 0; + padding-left: var(--spacing-16); + font-size: var(--text-body-sm); +} + +.m01-lb__expect-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 96px auto auto; + align-items: center; + gap: var(--spacing-8); + font-size: var(--text-body-sm); +} + +.m01-lb__expect-row .is-ok { + color: var(--color-success, var(--color-text)); + font-weight: 600; +} + +.m01-lb__expect-row .is-bad { + color: var(--color-danger); + font-weight: 600; +} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Entry.ts b/M01_MasterData/LogicBuilder/M01_LB_Entry.ts index 0ceb0e6cd..792c39700 100644 --- a/M01_MasterData/LogicBuilder/M01_LB_Entry.ts +++ b/M01_MasterData/LogicBuilder/M01_LB_Entry.ts @@ -1,9 +1,11 @@ /* ============================================================================= * M01_LB_Entry.ts - * 로직 만들기 판(시험) — 들어가는 단추 · 큰 창 · 시작 화면 · 세 칸 틀(원문 · 산출근거 · 결과) · 위 띠 + * 로직 만들기 판(시험) — 들어가는 단추 · 큰 창 · 시작 화면 · 세 칸(원문 · 산출근거 · 결과) · 위 띠 · 잇기 * 모양은 `docs/raw/M01_쉬운단가/로직판_A_정함.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄). - * 1걸음 = 틀 — 원문 · 산출근거 칸은 자리만 · 결과 칸은 EasyCalc 계산 · 일위대가표로 바로 보임. - * 2걸음에 Blocks · Api · Source · Steps · Grid · Wizard 를 이음. + * 잇기: 열기(openEasy) → readLogic → 원문(renderSource · 걸린 줄 lookupHits) · 산출근거(renderSteps / renderGrid) + * · 결과(합계 · 예시값 · 일위대가표 · 빈틈 · 기대값 시험표) → 바뀔 때마다 기록 · lbCalcLater(고친 표 사본) + * → 저장(현장 = newEasy · editEasy · writeLogic / 표준은 복사 편집 뒤) · 실행 취소 · Ctrl+Z · Ctrl+S · 단계 안내. + * 표 칸 고침은 메모리 사본만 — 저장 안 함(정함 5). * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; @@ -20,16 +22,25 @@ import type { CalcAnswer, LogicInput, LogicRow } from "../M01_MasterData_UI_Logi import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; import { formatNumber } from "../M01_MasterData_UI_Logic_Edit"; import { - calcLater, copyEasy, + deleteEasy, + editEasy, listEasy, + newEasy, openEasy, plainError, + usesOf, type EasyListItem, } from "../EasyCalc/M01_EasyCalc_Api"; import { renderSheet } from "../EasyCalc/M01_EasyCalc_Sheet"; -import { lbCalcLater, type LbTables } from "./M01_LB_Api"; -import { readLogic, type LbLogic } from "./M01_LB_Blocks"; +import { fetchLbTable, lbCalcLater, type LbTables } from "./M01_LB_Api"; +import { readLogic, setCond, writeLogic, type LbLogic, type TableEdit } from "./M01_LB_Blocks"; +import { lookupHits } from "./M01_LB_Check"; +import { drawExamples, renderChecks } from "./M01_LB_Entry_Result"; +import { renderGrid } from "./M01_LB_Grid"; +import { renderSource, type LbRole } from "./M01_LB_Source"; +import { renderSteps } from "./M01_LB_Steps"; +import { openWizard } from "./M01_LB_Wizard"; import "./M01_LB_Entry.css"; const TEXT = { @@ -69,14 +80,23 @@ const TEXT = { View_Grid: ["격자", "Grid"], Money: ["{u} 만드는 데 {v} 원", "{v} won to make {u}"], Value: ["결과 {v} {u}", "Result {v} {u}"], - Examples: ["예시값", "Example values"], - Examples_Note: [ - "예시값은 계산을 보려는 값 — 저장 안 됨", - "Example values are only for trying — not saved", - ], - Pick: ["선택", "Choose"], Sheet: ["일위대가표", "Unit-cost sheet"], - NotYet: ["준비 중", "Coming soon"], + NoBlocks: ["이 로직은 블록으로 못 읽음 — 일위대가표만 봄", "This logic can't be shown as blocks"], + Blocked: ["못 읽은 곳:", "Not readable:"], + Role_Result: ["결과 칸 바꾸기는 아직 안 됨", "Changing the result column is not ready yet"], + Role_NoInput: [ + "「{v}」 을(를) 조건으로 쓰려면 같은 이름 입력값이 있어야 함", + "To use “{v}” as a condition, add an input with the same name", + ], + NoName: ["이름을 넣어 주세요", "Enter a name"], + Saving: ["저장 중", "Saving"], + Saved: ["저장했음", "Saved"], + SaveFailed: ["저장 못 함", "Not saved"], + Delete: ["삭제", "Delete"], + Delete_Ask: ["「{v}」 을(를) 삭제할까요? 되살릴 수 없음", "Delete “{v}”? This cannot be undone"], + Deleted: ["삭제했음", "Deleted"], + Uses_Wait: ["사용처를 찾는 중 — 몇 초 걸림", "Looking for places that use it"], + Uses_Block: ["사용처가 있어 삭제 못 함 — {v}", "Cannot delete — used by {v}"], Leave_Ask: [ "저장 안 한 것이 있음 — 저장하지 않고 닫을까요?", "There are unsaved changes — close without saving?", @@ -106,35 +126,38 @@ const amountOf = (resultUnit: string): string => { }; const isMoney = (resultUnit: string): boolean => resultUnit.startsWith("원/"); -/** 고치는 화면이 쥔 것 — row = 저장할 로직 줄 · lb = 블록(못 읽으면 null) · tables = 고친 표 사본(저장 안 함) */ +/** 세 칸 화면이 쥔 것 — row = 연 로직 줄 · lb = 블록(못 읽으면 null) · tables = 원문 표 사본 · changed = 고친 표 */ interface Paper { - key: string; - version: string; + key: string | null; + version: string | null; isBase: boolean; row: LogicRow; trial: Record; lb: LbLogic | null; tables: LbTables; + changed: Set; } +/** 실행 취소 한 칸 — 객체를 그대로 쥠(JSON 복사 안 함 · 안 바뀐 조각은 원래 글 보존) */ +interface Snap { + lb: LbLogic | null; + tables: LbTables; + changed: Set; +} +type View = "sentence" | "grid"; -/* ponytail: Blocks(sub7) · LB Api(sub4) 속이 「준비 중」 인 동안만 대신함 — 채워지면 두 catch 를 지움 */ -const blocksOf = (row: LogicRow, key: string, version: string, isBase: boolean): LbLogic | null => { +/** 블록 읽기 — 못 읽는 꼴이면 null(일위대가표만 보임) */ +const blocksOf = ( + row: LogicRow, + key: string | null, + version: string | null, + isBase: boolean, +): LbLogic | null => { try { return readLogic(row, { key, version, isBase }); } catch { return null; } }; -const calcWith = ( - p: Paper, - onDone: (ans: CalcAnswer | null, plain: string | null) => void, -): void => { - try { - lbCalcLater(p.row, p.trial, p.tables, onDone); - } catch { - calcLater(p.row, p.trial, onDone); - } -}; /** 「쉬운 단가산출(시험)」 옆에 놓는 단추 — 누르면 큰 창 */ export function createLogicBuilderButton(): HTMLButtonElement { @@ -143,16 +166,27 @@ export function createLogicBuilderButton(): HTMLButtonElement { function openLogicBuilder(): void { let body: HTMLElement; - let paper: Paper | null = null; - let dirty = false; + /** 세 칸 화면이 열려 있을 때만 */ + let paper: { dirty(): boolean; save(): Promise; undo(): void } | null = null; let logics: EasyListItem[] | null = null; let query = ""; - let view: "sentence" | "grid" = "sentence"; + let view: View = "sentence"; const mayLeave = async (): Promise => - !paper || !dirty || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); + !paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); - const notYet = (): void => showToast(te("NotYet"), "info"); + /** Ctrl+Z · Ctrl+S — 이 창 안에서만(단계 안내 등 다른 창은 제 몫) */ + const onKey = (ev: KeyboardEvent): void => { + if (!paper || !(ev.ctrlKey || ev.metaKey)) return; + const key = ev.key.toLowerCase(); + if (key === "z" && !ev.shiftKey) { + ev.preventDefault(); + paper.undo(); + } else if (key === "s") { + ev.preventDefault(); + void paper.save(); + } + }; /* ---------- 시작 화면 ---------- */ @@ -177,7 +211,6 @@ function openLogicBuilder(): void { const showStart = async (): Promise => { paper = null; - dirty = false; const status = el("p", { className: "m01-lb__muted", text: te("Loading") }); const trialHost = el("div", { className: "m01-lb__list" }); const fieldHost = el("div", { className: "m01-lb__list" }); @@ -226,7 +259,7 @@ function openLogicBuilder(): void { el("div", { className: "m01-lb__start-col", children: [ - bigButton(te("Start_Wizard"), notYet), + bigButton(te("Start_Wizard"), () => void openNew()), el("h4", { text: te("Start_Field") }), fieldHost, ], @@ -251,9 +284,7 @@ function openLogicBuilder(): void { ); const field = logics.filter((x) => !x.isBase); fieldHost.replaceChildren( - ...(field.length - ? field.map(listRow) - : [el("p", { className: "m01-lb__muted", text: te("Start_FieldNone") })]), + ...(field.length ? field.map(listRow) : [muted(te("Start_FieldNone"))]), ); drawFound(); }; @@ -261,76 +292,232 @@ function openLogicBuilder(): void { /* ---------- 세 칸 화면 ---------- */ const openPaper = async (key: string): Promise => { - body.replaceChildren(el("p", { className: "m01-lb__muted", text: te("Loading") })); + body.replaceChildren(muted(te("Loading"))); try { - const opened = await openEasy(key); - const lb = blocksOf(opened.row, key, opened.version, opened.isBase); - paper = { key, ...opened, lb, tables: {} }; - dirty = false; - showPaper(); + const o = await openEasy(key); + const lb = blocksOf(o.row, key, o.version, o.isBase); + showPaper({ key, version: o.version, isBase: o.isBase, row: o.row, lb, trial: o.trial }); } catch (error) { showToast(plainError(error), "error"); void showStart(); } }; - const showPaper = (): void => { - const p = paper; - if (!p) return; + const openNew = async (): Promise => { + const lb = await openWizard(); + if (!lb) return; + const row = writeLogic(lb); + showPaper({ key: null, version: null, isBase: false, row, lb, trial: autoTrial(lb.inputs) }); + }; + + const showPaper = (first: Omit): void => { + const p: Paper = { ...first, tables: {}, changed: new Set() }; let ans: CalcAnswer | null = null; let busy = false; + const savedLb = p.key ? p.lb : null; + let stack: Snap[] = []; + const readOnly = p.isBase || !p.lb; + paper = { dirty: () => p.lb !== savedLb, save: () => doSave(), undo: () => doUndo() }; const unsaved = createTag(te("Unsaved"), "warning"); - unsaved.hidden = !dirty; const total = el("div", { className: "m01-lb__total" }); const note = el("p", { className: "m01-lb__note", attrs: { role: "status" } }); const sheetHost = el("div"); const examplesHost = el("div", { className: "m01-lb__examples" }); + const checksHost = el("div"); const sourceHost = el("div", { className: "m01-lb__slot" }); const basisHost = el("div", { className: "m01-lb__slot" }); + const blockedNote = el("p", { className: "m01-lb__note" }); - const name = createInputField({ label: te("Name"), value: p.row.이름 }); - name.input.disabled = true; // ponytail: 이름 고치기는 2걸음(Blocks · writeLogic) 에 이음 + const rowNow = (): LogicRow => (p.lb ? writeLogic(p.lb) : p.row); + const unitNow = (): string => p.lb?.doc.resultUnit ?? p.row.결과단위 ?? ""; + const tableName = (key: string): string | null => + (p.tables[key] as { 이름?: string } | undefined)?.이름 ?? null; + /** 서버에 넘길 표 — 고친 표만 */ + const edited = (): LbTables => Object.fromEntries([...p.changed].map((k) => [k, p.tables[k]])); + + const name = createInputField({ + label: te("Name"), + value: p.lb?.doc.name ?? p.row.이름, + onInput: (v) => { + if (p.lb) edit({ ...p.lb, doc: { ...p.lb.doc, name: v } }); + }, + }); + name.input.disabled = readOnly; name.root.classList.add("m01-lb__name"); - const drawResult = (pending: boolean): void => { - const unit = p.row.결과단위 ?? ""; - const value = ans?.ok ? (ans.result ?? ans.sums?.["계"]) : undefined; + /** 계산 답만 왔을 때 글을 넣는 중인 칸이 있으면 그 칸을 떠난 뒤 그림(넣던 글이 안 날아가게) */ + const later = (host: HTMLElement, draw: () => void, answer: boolean): void => { + const a = document.activeElement; + const typing = a instanceof HTMLInputElement || a instanceof HTMLSelectElement; + if (answer && typing && host.contains(a)) { + host.addEventListener("focusout", () => setTimeout(draw), { once: true }); + return; + } + draw(); + }; + + const drawSource = (): void => { + if (!p.lb) return void sourceHost.replaceChildren(muted(te("NoBlocks"))); + renderSource(sourceHost, p.lb, p.tables, lookupHits(p.lb, p.tables, p.trial), { + edit: onTableEdit, + role: onRole, + }); + }; + const drawBasis = (): void => { + if (!p.lb) return void basisHost.replaceChildren(muted(te("NoBlocks"))); + const on = { change: (lb: LbLogic) => edit(lb) }; + if (view === "sentence") renderSteps(basisHost, p.lb, ans, { ...on, tableName }); + else renderGrid(basisHost, p.lb, ans, on, { trial: p.trial }); + }; + + /** 합계 · 일위대가표 · 원문 · 산출근거 — 바뀔 때(pending) · 계산 답이 올 때(answer) */ + const draw = (pending: boolean, answer = false): void => { + const unit = unitNow(); + const v = ans?.ok ? (ans.result ?? ans.sums?.["계"]) : undefined; total.textContent = isMoney(unit) - ? te("Money", { u: amountOf(unit), v: value === undefined ? "—" : formatMoney(value) }) - : te("Value", { u: unit, v: value === undefined ? "—" : formatNumber(value) }); + ? te("Money", { u: amountOf(unit), v: v === undefined ? "—" : formatMoney(v) }) + : te("Value", { u: unit, v: v === undefined ? "—" : formatNumber(v) }); total.classList.toggle("is-pending", pending); sheetHost.classList.toggle("is-pending", pending); - renderSheet(sheetHost, { name: p.row.이름, resultUnit: unit }, ans); + unsaved.hidden = !paper?.dirty(); + undo.disabled = !stack.length; + blockedNote.textContent = p.lb?.blocked.length + ? `${te("Blocked")} ${p.lb.blocked.join(" · ")}` + : ""; + renderSheet(sheetHost, { name: p.lb?.doc.name ?? p.row.이름, resultUnit: unit }, ans); + later(sourceHost, drawSource, answer); + later(basisHost, drawBasis, answer); }; const calc = (): void => { - drawResult(true); - calcWith(p, (next, plain) => { + draw(true); + lbCalcLater(rowNow(), p.trial, edited(), (next, plain) => { ans = next; note.textContent = plain ?? ""; - drawResult(false); + draw(false, true); }); }; + const drawExamplesNow = (): void => + drawExamples(examplesHost, p.lb?.inputs ?? p.row.입력 ?? [], p.trial, (next) => { + p.trial = next; + calc(); + }); + + /** 바뀐 조각만 새 객체로 온 lb 를 그대로 쌓음 */ + const edit = (next: LbLogic): void => { + if (readOnly || next === p.lb) return; + stack = [...stack, { lb: p.lb, tables: p.tables, changed: p.changed }]; + const inputsChanged = next.inputs !== p.lb?.inputs; + p.lb = next; + if (inputsChanged) { + p.trial = { ...autoTrial(next.inputs), ...p.trial }; + drawExamplesNow(); + } + void loadTables(); + calc(); + }; + + /** 표 칸 고침 — 메모리 사본만 · 저장 안 함(정함 5) */ + const onTableEdit = (e: TableEdit): void => { + const t = p.tables[e.table] as { 줄: Record[] } | undefined; + if (!t) return; + stack = [...stack, { lb: p.lb, tables: p.tables, changed: p.changed }]; + const 줄 = t.줄.map((r, i) => (i === e.row ? { ...r, [e.col]: e.value } : r)); + p.tables = { ...p.tables, [e.table]: { ...t, 줄 } }; + p.changed = new Set([...p.changed, e.table]); + calc(); + }; + + /** 칸 역할 — 조건 = 같은 이름 입력값 · 고정 = 걸린 줄(없으면 첫 줄) 값 · 결과 칸 바꾸기는 아직 */ + const onRole = (table: string, col: string, role: LbRole): void => { + const lb = p.lb; + if (!lb || readOnly) return; + if (role === "결과") return void (note.textContent = te("Role_Result")); + const hits = lookupHits(lb, p.tables, p.trial); + const rows = (p.tables[table] as { 줄?: Record[] } | undefined)?.줄; + let next = lb; + lb.lookups.forEach((lk, i) => { + if (lk.table !== table || lk.col === col) return; + const id = next.lookups[i]?.id ?? lk.id; + if (role === "조건") { + if (lb.inputs.some((x) => x.이름 === col)) + next = setCond(next, id, col, { k: "input", name: col }); + return; + } + const hit = hits[lk.id]; + const cell = rows?.[typeof hit === "number" ? hit : 0]?.[col]; + const value = Array.isArray(cell) ? (cell[0] ?? cell[1]) : cell; + if (typeof value === "number" || typeof value === "string") + next = setCond(next, id, col, { k: "fixed", value }); + }); + if (next === lb) note.textContent = te("Role_NoInput", { v: col }); + else edit(next); + }; + + /** 로직이 보는 원문 표 — 처음 보는 표만 받아 옴 */ + const loadTables = async (): Promise => { + const keys = [...new Set(p.lb?.lookups.map((lk) => lk.table) ?? [])]; + const want = keys.filter((k) => !(k in p.tables)); + if (!want.length) return; + const got = await Promise.all( + want.map((k) => + fetchLbTable(k).then( + (t) => [k, t.table] as const, + () => [k, undefined] as const, + ), + ), + ); + p.tables = { ...p.tables, ...Object.fromEntries(got) }; + drawSource(); + drawBasis(); + }; + + const doUndo = (): void => { + const back = stack[stack.length - 1]; + if (!back) return; + stack = stack.slice(0, -1); + ({ lb: p.lb, tables: p.tables, changed: p.changed } = back); + name.input.value = p.lb?.doc.name ?? p.row.이름; + drawExamplesNow(); + calc(); + }; + + /** 저장 뒤 · 복사 편집 뒤 — 서버가 준 줄로 다시 엶 · 실행 취소 기록은 새로 */ + const reopen = (row: LogicRow, key: string, version: string): void => { + const lb = blocksOf(row, key, version, false); + showPaper({ key, version, isBase: false, row, lb, trial: p.trial }); + }; + + const doSave = async (): Promise => { + if (readOnly || busy || !p.lb) return; + const row = writeLogic(p.lb); + if (!row.이름.trim()) return void (note.textContent = te("NoName")); + busy = true; + note.textContent = te("Saving"); + try { + const saved = + p.key && p.version ? await editEasy(p.key, p.version, row) : await newEasy(row); + logics = null; + showToast(te("Saved"), "success"); + reopen(saved.row, saved.key, saved.version); + } catch (error) { + note.textContent = `${te("SaveFailed")} — ${plainError(error)}`; + } finally { + busy = false; + } + }; + const doCopy = async (): Promise => { - if (busy) return; + if (!p.key || busy) return; busy = true; note.textContent = te("Copying"); try { const saved = await copyEasy(p.key); - paper = { - key: saved.key, - version: saved.version, - isBase: false, - row: saved.row, - trial: p.trial, - lb: blocksOf(saved.row, saved.key, saved.version, false), - tables: {}, - }; logics = null; showToast(te("Copied"), "success"); - showPaper(); + reopen(saved.row, saved.key, saved.version); } catch (error) { note.textContent = plainError(error); } finally { @@ -338,44 +525,80 @@ function openLogicBuilder(): void { } }; - const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: notYet }); - undo.disabled = true; + const doDelete = async (): Promise => { + if (!p.key || !p.version || p.isBase || busy) return; + busy = true; + note.textContent = te("Uses_Wait"); + try { + const uses = (await usesOf(p.key)).filter((u) => u.key); + if (uses.length) { + note.textContent = te("Uses_Block", { v: uses.map((u) => u.name).join(", ") }); + return; + } + note.textContent = ""; + const ask = te("Delete_Ask", { v: rowNow().이름 }); + if (!(await showConfirmDialog(ask, te("Delete")))) return; + await deleteEasy(p.key, p.version); + logics = null; + paper = null; + showToast(te("Deleted"), "success"); + void showStart(); + } catch (error) { + note.textContent = plainError(error); + } finally { + busy = false; + } + }; + + const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: doUndo }); + const close = createButton({ + label: te("Close"), + variant: "ghost", + onClick: () => + void (async () => { + if (await mayLeave()) void showStart(); + })(), + }); + const fieldActions = [ + undo, + createButton({ label: te("Save"), onClick: () => void doSave() }), + ...(p.key + ? [ + createButton({ + label: te("Delete"), + variant: "danger", + onClick: () => void doDelete(), + }), + ] + : []), + close, + ]; const actions = el("div", { className: "m01-lb__actions", - children: [ - ...(p.isBase - ? [createButton({ label: te("CopyEdit"), onClick: () => void doCopy() })] - : [undo, createButton({ label: te("Save"), onClick: notYet })]), - createButton({ - label: te("Close"), - variant: "ghost", - onClick: () => - void (async () => { - if (await mayLeave()) void showStart(); - })(), - }), - ], + children: p.isBase + ? [createButton({ label: te("CopyEdit"), onClick: () => void doCopy() }), close] + : fieldActions, }); - const viewButton = (which: "sentence" | "grid", label: string): HTMLButtonElement => { + const viewButtons = (["sentence", "grid"] as View[]).map((which) => { const b = createButton({ - label, + label: te(which === "sentence" ? "View_Sentence" : "View_Grid"), variant: view === which ? "filled" : "ghost", size: "sm", onClick: () => { view = which; - showPaper(); + for (const x of viewButtons) { + const on = x.dataset.view === view; + x.classList.toggle("ui-btn--filled", on); + x.classList.toggle("ui-btn--ghost", !on); + x.setAttribute("aria-pressed", String(on)); + } + drawBasis(); }, }); + b.dataset.view = which; b.setAttribute("aria-pressed", String(view === which)); return b; - }; - - sourceHost.replaceChildren(el("p", { className: "m01-lb__muted", text: te("NotYet") })); - basisHost.replaceChildren(el("p", { className: "m01-lb__muted", text: te("NotYet") })); - drawExamples(examplesHost, p.row.입력 ?? [], p.trial, (next) => { - p.trial = next; - calc(); }); body.replaceChildren( @@ -393,25 +616,33 @@ function openLogicBuilder(): void { ], }), note, - ...(p.isBase ? [el("p", { className: "m01-lb__muted", text: te("StandardNote") })] : []), + ...(p.isBase ? [muted(te("StandardNote"))] : []), el("div", { className: "m01-lb__cols", children: [ column(te("Col_Source"), [sourceHost]), - column( - te("Col_Basis"), - [basisHost], - [viewButton("sentence", te("View_Sentence")), viewButton("grid", te("View_Grid"))], - ), + column(te("Col_Basis"), [blockedNote, basisHost], viewButtons), column(te("Col_Result"), [ total, examplesHost, el("h5", { text: te("Sheet") }), sheetHost, + checksHost, ]), ], }), ); + drawExamplesNow(); + if (p.lb) { + renderChecks(checksHost, { + lb: () => p.lb as LbLogic, + tables: () => p.tables, + trial: () => p.trial, + ans: () => ans, + draftKey: p.key ?? "새", + }); + } + void loadTables(); calc(); }; @@ -423,11 +654,24 @@ function openLogicBuilder(): void { beforeClose: mayLeave, mount: (host) => { body = host; + (host.parentElement ?? host).addEventListener("keydown", onKey); void showStart(); }, }); } +/** 단계 안내 · 새 입력값의 예시값 — 서버 자동값과 같은 규칙(선택 = 첫째 · 범위 = 낮은 끝 · 숫자 0 · 가격 값은 비움) */ +function autoTrial(inputs: LogicInput[]): Record { + const out: Record = {}; + for (const spec of inputs) { + if (String(spec.단위 ?? "").includes("원")) continue; + out[spec.이름] = spec.고르기?.length ? spec.고르기[0] : (spec.범위?.[0] ?? 0); + } + return out; +} + +const muted = (text: string): HTMLElement => el("p", { className: "m01-lb__muted", text }); + /** 세 칸 가운데 하나 — 머리(제목 · 단추) + 몸 */ function column(title: string, children: HTMLElement[], tools: HTMLElement[] = []): HTMLElement { return el("section", { @@ -442,62 +686,6 @@ function column(title: string, children: HTMLElement[], tools: HTMLElement[] = [ }); } -/** 예시값 띠 — 입력값마다 선택 또는 숫자 · 바꾸면 onChange(새 예시값) · 저장 안 함 */ -function drawExamples( - host: HTMLElement, - inputs: LogicInput[], - trial: Record, - onChange: (trial: Record) => void, -): void { - host.hidden = !inputs.length; - if (!inputs.length) return void host.replaceChildren(); - const state = { ...trial }; - const field = (spec: LogicInput): HTMLElement => { - const label = spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름; - let box: HTMLInputElement | HTMLSelectElement; - if (spec.고르기?.length) { - box = el("select", { - className: "ui-select ui-select--sm", - children: [ - el("option", { attrs: { value: "" }, text: te("Pick") }), - ...spec.고르기.map((v) => el("option", { attrs: { value: String(v) }, text: String(v) })), - ], - }); - box.value = state[spec.이름] === undefined ? "" : String(state[spec.이름]); - box.addEventListener("change", () => { - const picked = spec.고르기?.find((v) => String(v) === box.value); - if (picked === undefined) delete state[spec.이름]; - else state[spec.이름] = picked; - onChange({ ...state }); - }); - } else { - box = el("input", { - className: "ui-input ui-input--sm", - attrs: { - type: "number", - ...(spec.범위 ? { min: String(spec.범위[0]), max: String(spec.범위[1]) } : {}), - }, - }); - box.value = state[spec.이름] == null ? "" : String(state[spec.이름]); - box.addEventListener("change", () => { - if (box.value === "") delete state[spec.이름]; - else state[spec.이름] = Number(box.value); - onChange({ ...state }); - }); - } - box.setAttribute("aria-label", spec.이름); - return el("label", { - className: "m01-lb__example", - children: [el("span", { text: label }), box], - }); - }; - host.replaceChildren( - el("strong", { text: te("Examples") }), - el("div", { className: "m01-lb__example-list", children: inputs.map(field) }), - el("p", { className: "m01-lb__muted", text: te("Examples_Note") }), - ); -} - function bigButton(label: string, onClick: () => void): HTMLButtonElement { const button = createButton({ label, variant: "filled", onClick }); button.classList.add("m01-lb__big"); diff --git a/M01_MasterData/LogicBuilder/M01_LB_Entry_Result.ts b/M01_MasterData/LogicBuilder/M01_LB_Entry_Result.ts new file mode 100644 index 000000000..14c22a068 --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Entry_Result.ts @@ -0,0 +1,273 @@ +/* ============================================================================= + * M01_LB_Entry_Result.ts + * 로직 만들기 판 오른쪽 결과 칸의 도구 — 예시값 띠 · 빈틈 검사 · 기대값 시험표(Entry 가 부름) + * 빈틈 · 시험표 셈은 Check(sub8) 것 · 시험표 줄은 화면 초안(sessionStorage)만 · 저장 안 함(정함 7) + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el } from "@ui/ui_template_elements"; +import type { CalcAnswer, LogicInput } from "../M01_MasterData_UI_Logic_Api"; +import { formatNumber } from "../M01_MasterData_UI_Logic_Edit"; +import type { LbTables } from "./M01_LB_Api"; +import type { LbLogic } from "./M01_LB_Blocks"; +import { + gapSkipped, + gaps, + loadExpectRows, + pickValues, + runExpect, + saveExpectRows, + type ExpectRow, +} from "./M01_LB_Check"; + +const TEXT = { + Examples: ["예시값", "Example values"], + Examples_Note: [ + "예시값은 계산을 보려는 값 — 저장 안 됨", + "Example values are only for trying — not saved", + ], + Pick: ["선택", "Choose"], + Gaps: ["빈틈 검사", "Check for gaps"], + Gaps_None: [ + "빈틈 없음 — 모든 선택 조합에 맞는 줄이 하나씩 있음", + "No gaps — every combination finds one row", + ], + Gaps_No: ["해당 없음", "No match"], + Gaps_Dup: ["중복", "Duplicate"], + Gaps_Rows: ["줄 {v}", "rows {v}"], + Gaps_Skip: [ + "못 따진 표 조회 {n} 개 — 입력값이 숫자라 조합을 셀 수 없음", + "{n} lookups not checked — numeric inputs", + ], + Gaps_More: ["그 밖 {n} 건", "{n} more"], + Expect: ["기대값 시험표", "Expected-value table"], + Expect_Add: ["지금 예시값으로 한 줄 더하기", "Add a row from the current examples"], + Expect_Run: ["모두 돌리기", "Run all"], + Expect_Total: ["합계", "Total"], + Expect_Ok: ["맞음", "Match"], + Expect_Bad: ["다름 — 나온 값 {v}", "Differs — got {v}"], + Expect_Del: ["삭제", "Delete"], + Expect_None: [ + "줄 없음 — 예시값을 정한 뒤 한 줄 더하기", + "No rows — set examples, then add a row", + ], + Expect_Note: [ + "시험표는 이 화면에만 남음 — 저장 안 됨", + "The table stays on this screen only — not saved", + ], +} as const; + +type TextKey = keyof typeof TEXT; + +const tr = (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 totalKey = (ans: CalcAnswer | null): "계" | "결과" => + ans?.ok && ans.result !== undefined ? "결과" : "계"; + +/** 예시값 띠 — 입력값마다 선택 또는 숫자 · 바꾸면 onChange(새 예시값) · 저장 안 함 */ +export function drawExamples( + host: HTMLElement, + inputs: LogicInput[], + trial: Record, + onChange: (trial: Record) => void, +): void { + host.hidden = !inputs.length; + if (!inputs.length) return void host.replaceChildren(); + const state = { ...trial }; + const field = (spec: LogicInput): HTMLElement => { + const label = spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름; + let box: HTMLInputElement | HTMLSelectElement; + if (spec.고르기?.length) { + box = el("select", { + className: "ui-select ui-select--sm", + children: [ + el("option", { attrs: { value: "" }, text: tr("Pick") }), + ...spec.고르기.map((v) => el("option", { attrs: { value: String(v) }, text: String(v) })), + ], + }); + box.value = state[spec.이름] === undefined ? "" : String(state[spec.이름]); + box.addEventListener("change", () => { + const picked = spec.고르기?.find((v) => String(v) === box.value); + if (picked === undefined) delete state[spec.이름]; + else state[spec.이름] = picked; + onChange({ ...state }); + }); + } else { + box = el("input", { + className: "ui-input ui-input--sm", + attrs: { + type: "number", + ...(spec.범위 ? { min: String(spec.범위[0]), max: String(spec.범위[1]) } : {}), + }, + }); + box.value = state[spec.이름] == null ? "" : String(state[spec.이름]); + box.addEventListener("change", () => { + if (box.value === "") delete state[spec.이름]; + else state[spec.이름] = Number(box.value); + onChange({ ...state }); + }); + } + box.setAttribute("aria-label", spec.이름); + return el("label", { + className: "m01-lb__example", + children: [el("span", { text: label }), box], + }); + }; + host.replaceChildren( + el("strong", { text: tr("Examples") }), + el("div", { className: "m01-lb__example-list", children: inputs.map(field) }), + el("p", { className: "m01-lb__muted", text: tr("Examples_Note") }), + ); +} + +const condText = (conds: Record): string => + Object.entries(conds) + .map(([k, v]) => `${k} ${Array.isArray(v) ? v.join("~") : String(v)}`) + .join(" · "); + +/** 빈틈 검사 · 기대값 시험표 — 화면 초안 열쇠 = 로직 번호(새 로직은 「새」) */ +export function renderChecks( + host: HTMLElement, + s: { + lb(): LbLogic; + tables(): LbTables; + trial(): Record; + ans(): CalcAnswer | null; + draftKey: string; + }, +): void { + const gapHost = el("div", { className: "m01-lb__check-out" }); + const rowsHost = el("div", { className: "m01-lb__check-out" }); + let rows: ExpectRow[] = loadExpectRows(s.draftKey); + let results: (boolean | number | null)[] = []; + const keep = (): void => saveExpectRows(s.draftKey, rows); + + const runGaps = (): void => { + const found = gaps(s.lb(), s.tables()); + const skipped = gapSkipped(s.lb(), s.tables()).length; + const lines = found.slice(0, 20).map((g) => + el("li", { + text: [ + g.kind === "없음" ? tr("Gaps_No") : tr("Gaps_Dup"), + condText(g.conds), + g.kind === "중복" ? tr("Gaps_Rows", { v: g.rows.map((r) => r + 1).join(", ") }) : "", + ] + .filter(Boolean) + .join(" — "), + }), + ); + gapHost.replaceChildren( + found.length + ? el("ul", { children: lines }) + : el("p", { className: "m01-lb__muted", text: tr("Gaps_None") }), + ...(found.length > 20 ? [el("p", { text: tr("Gaps_More", { n: found.length - 20 }) })] : []), + ...(skipped + ? [el("p", { className: "m01-lb__muted", text: tr("Gaps_Skip", { n: skipped }) })] + : []), + ); + }; + + const drawRows = (): void => { + if (!rows.length) { + rowsHost.replaceChildren(el("p", { className: "m01-lb__muted", text: tr("Expect_None") })); + return; + } + rowsHost.replaceChildren( + ...rows.map((row, i) => { + const key = Object.keys(row.expect)[0] ?? "계"; + const box = el("input", { + className: "ui-input ui-input--sm", + attrs: { type: "number", "aria-label": tr("Expect_Total") }, + }); + box.value = String(row.expect[key] ?? ""); + box.addEventListener("change", () => { + row.expect = { [key]: Number(box.value) }; + results[i] = null; + keep(); + }); + const r = results[i]; + return el("div", { + className: "m01-lb__expect-row", + children: [ + el("span", { text: condText(row.inputs) || "—" }), + box, + el("span", { + className: r === true ? "is-ok" : typeof r === "number" ? "is-bad" : "", + text: + r === true + ? tr("Expect_Ok") + : typeof r === "number" + ? tr("Expect_Bad", { v: formatNumber(r) }) + : "", + }), + createButton({ + label: tr("Expect_Del"), + variant: "ghost", + size: "sm", + onClick: () => { + rows = rows.filter((_, j) => j !== i); + results = results.filter((_, j) => j !== i); + keep(); + drawRows(); + }, + }), + ], + }); + }), + ); + }; + + const addRow = (): void => { + const ans = s.ans(); + const key = totalKey(ans); + const now = ans?.ok ? pickValues(ans)[key] : undefined; + rows = [...rows, { inputs: { ...s.trial() }, expect: { [key]: now ?? 0 } }]; + keep(); + drawRows(); + }; + + const runRows = async (): Promise => { + const got = await runExpect(s.lb(), s.tables(), rows); + results = got.map((g, i) => { + const key = Object.keys(rows[i].expect)[0] ?? "계"; + return g.ok ? true : (g.got[key] ?? null); + }); + drawRows(); + }; + + host.replaceChildren( + el("div", { + className: "m01-lb__check", + children: [ + createButton({ label: tr("Gaps"), variant: "ghost", size: "sm", onClick: runGaps }), + gapHost, + el("strong", { text: tr("Expect") }), + el("div", { + className: "m01-lb__check-tools", + children: [ + createButton({ + label: tr("Expect_Add"), + variant: "ghost", + size: "sm", + onClick: addRow, + }), + createButton({ + label: tr("Expect_Run"), + variant: "ghost", + size: "sm", + onClick: () => void runRows(), + }), + ], + }), + rowsHost, + el("p", { className: "m01-lb__muted", text: tr("Expect_Note") }), + ], + }), + ); + drawRows(); +} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Grid.css b/M01_MasterData/LogicBuilder/M01_LB_Grid.css new file mode 100644 index 000000000..03a75d0cb --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Grid.css @@ -0,0 +1,109 @@ +.m01-lbgrid__scroll { + max-width: 100%; + max-height: 70vh; + overflow: auto; +} + +.m01-lbgrid__table { + width: 100%; + min-width: 720px; + border-collapse: separate; + border-spacing: 0; + color: var(--color-text-body); + font-size: var(--text-body-sm); +} + +.m01-lbgrid__table th, +.m01-lbgrid__table td { + padding: var(--spacing-4) var(--spacing-8); + border-right: 1px solid var(--color-border); + border-bottom: 1px solid var(--color-border); + text-align: left; + vertical-align: middle; +} + +.m01-lbgrid__table th:first-child, +.m01-lbgrid__table td:first-child { + border-left: 1px solid var(--color-border); +} + +.m01-lbgrid__table thead th { + position: sticky; + top: 0; + z-index: 1; + border-top: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text-secondary); + font-weight: 600; + white-space: nowrap; +} + +.m01-lbgrid__row { + cursor: pointer; +} + +.m01-lbgrid__row--first td { + border-top: 2px solid var(--color-border); +} + +.m01-lbgrid__row--sub .m01-lbgrid__name { + padding-left: var(--spacing-16); + color: var(--color-text-secondary); +} + +.m01-lbgrid__step { + color: var(--color-text-secondary); + font-weight: 600; + white-space: nowrap; +} + +.m01-lbgrid__table td.m01-lbgrid__num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.m01-lbgrid__src { + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +/* 고른 줄 = 굵은 테두리 · 앞 줄 = 연한 칠 + 왼쪽 막대(색만으로 뜻을 안 전함) */ +.m01-lbgrid__row--on td { + background: color-mix(in srgb, var(--color-primary) 18%, transparent); + font-weight: 600; +} + +.m01-lbgrid__row--pre td { + background: color-mix(in srgb, var(--color-warning) 28%, transparent); +} + +.m01-lbgrid__row--pre td:first-child { + box-shadow: inset 4px 0 0 var(--color-warning); +} + +.m01-lbgrid__input { + box-sizing: border-box; + width: 96px; + 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); + text-align: right; +} + +.m01-lbgrid__msg { + margin: var(--spacing-4) 0 0; + color: var(--color-danger); + font-size: var(--text-caption); +} + +.m01-lbgrid__note, +.m01-lbgrid__hint { + margin: var(--spacing-4) 0; + color: var(--color-text-muted); + font-size: var(--text-caption); +} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Grid.ts b/M01_MasterData/LogicBuilder/M01_LB_Grid.ts new file mode 100644 index 000000000..1932674df --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Grid.ts @@ -0,0 +1,540 @@ +/* ============================================================================= + * M01_LB_Grid.ts + * 로직 만들기 판 — 가운데 격자 보기(엑셀처럼 줄 · 칸). 문장 보기(Steps)와 같은 LbLogic · 같은 on.change + * 열 = 단계 · 이름 · 무엇으로 · 값 · 단위 · 출처 / 줄 = 입력값 · 표 조회(+조건) · 계산(+조각) · 항목 · 결과값 + * 칸은 고르기 · 숫자 칸만 · 줄을 누르면 그 줄이 쓰는 줄(앞 줄)을 칠함 · 표준은 읽기만 + * 모양 = docs/raw/M01_쉬운단가/로직판_A_정함.md · 로직판_A_설계초안.md 7-1. 이 폴더째 지워도 됨. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createSelectField, el } from "@ui/ui_template_elements"; +import type { CalcAnswer } from "../M01_MasterData_UI_Logic_Api"; +import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; +import { safeReason } from "../M01_MasterData_UI_Logic_Text"; +import { updateCard, type EasyCard } from "../EasyCalc/M01_EasyCalc_Model"; +import { + setCalc, + setCond, + type LbCalc, + type LbExpr, + type LbLogic, + type LbRef, +} from "./M01_LB_Blocks"; +import "./M01_LB_Grid.css"; + +const TEXT = { + Step1: ["① 입력값", "① Input"], + Step2: ["② 표 조회", "② Table lookup"], + Step3: ["③ 계산", "③ Calculation"], + Step4: ["④ 항목 연결", "④ Items"], + Step5: ["⑤ 결과값", "⑤ Result"], + HStep: ["단계", "Step"], + HName: ["이름", "Name"], + HHow: ["무엇으로", "From"], + HValue: ["값", "Value"], + HUnit: ["단위", "Unit"], + HSource: ["출처", "Source"], + Busy: ["계산 중", "Calculating"], + Stopped: ["계산하지 못했어요", "Could not calculate"], + ReadOnly: [ + "표준은 읽기만 — 「복사 편집」 뒤에 고칠 수 있어요", + "Standard is read-only — use “Copy and edit” first", + ], + Hint: ["줄을 누르면 그 줄이 쓰는 줄이 칠해져요", "Click a row to paint the rows it uses"], + Bad: ["숫자를 넣어 주세요", "Enter a number"], + Cant: ["지금은 이 칸을 고칠 수 없어요", "This cell cannot be changed now"], + Typed: ["숫자 칸", "Number"], + Choice: ["고르기", "Choice"], + Range: ["범위", "Range"], + FromInput: ["입력값", "Input"], + FromCalc: ["계산", "Calc"], + FromLookup: ["표 조회", "Lookup"], + FromFixed: ["고정값", "Fixed"], + FromLogic: ["다른 일위대가 결과", "Other unit-price result"], + FromPeople: ["사람 품값 합", "People cost total"], + FromNone: ["무관", "Any"], + Own: ["직접 쓴 계산", "Written by hand"], + Cond: ["조건", "Condition"], + Table: ["표", "Table"], + Number: ["숫자", "Number"], + Calc: ["계산", "Calculation"], + Sum: ["항목 합계", "Item total"], + UseName: ["계산 이름", "Calc name"], + UseQty: ["항목 수량", "Item amount"], + UseRate: ["항목 비율", "Item rate"], + UseResult: ["결과값", "Result"], + Won: ["원", "KRW"], + Unnamed: ["수량 계산", "Amount calc"], + Price: ["단가", "Unit price"], + Result: ["결과", "Result"], + KValue: ["값", "Value"], + KMul: ["곱하기 · 나누기", "Multiply / divide"], + KRaise: ["할증", "Surcharge"], + KAdd: ["더하기", "Add"], + KCond: ["조건 목록", "Conditions"], + KMinMax: ["최소 · 최대", "Min / max"], + KRound: ["올림 · 버림", "Round"], + KSteps: ["여러 단계", "Several steps"], + Person: ["사람", "Person"], + Material: ["재료", "Material"], + Machine: ["기계", "Machine"], + Logic: ["다른 일위대가", "Other unit price"], + Percent: ["덧붙임", "Add-on %"], + Locked: ["그 밖 줄", "Other line"], + Base: ["바탕", "Base"], + Rate: ["율(%)", "Rate (%)"], + First: ["처음", "First"], + Times: ["곱하기", "times"], + Divide: ["나누기", "divided by"], + Plus: ["더하기", "plus"], + Minus: ["빼기", "minus"], +} as const; + +type TextKey = keyof typeof TEXT; +const tg = (key: TextKey): string => TEXT[key][currentLanguageIndex]; + +type Part = Exclude; +type Ref = LbRef | LbExpr; +type Cell = string | HTMLElement; + +interface GridRow { + id: string; + step: string; + sub: boolean; + name: Cell; + how: Cell; + value: Cell; + unit: string; + source: string; + /** 이 줄이 쓰는 줄 번호 */ + uses: string[]; +} + +const KIND_KEY: Record = { + 값: "KValue", + 곱나눔: "KMul", + 할증: "KRaise", + 더하기: "KAdd", + 조건목록: "KCond", + 최소최대: "KMinMax", + 올림: "KRound", + 단계: "KSteps", +}; +const CARD_KEY: Record = { + person: "Person", + material: "Material", + machine: "Machine", + logic: "Logic", + percent: "Percent", + locked: "Locked", +}; +const USE_KEY: Record = { + 중간: "UseName", + 수량: "UseQty", + 비율: "UseRate", + 결과: "UseResult", +}; + +const num = (n: number): string => n.toLocaleString("ko-KR", { maximumFractionDigits: 6 }); +const show = (v: unknown): string => + typeof v === "number" + ? num(v) + : v === undefined || v === null || typeof v === "object" + ? "" + : String(v); + +/** 선택된 줄 — 다시 그려도 남도록 칸(host)마다 기억 */ +const picked = new WeakMap(); + +/** 이름이 글 안에 따로 있는지 — 영문 이름은 낱말 경계로 */ +function mentions(text: string, name: string): boolean { + if (!name) return false; + if (!/^\w+$/.test(name)) return text.includes(name); + return new RegExp(`(^|[^A-Za-z0-9_])${name}($|[^A-Za-z0-9_])`).test(text); +} + +export interface GridOptions { + /** 입력값 칸에 보일 예시값 — 없으면 계산 답에서 찾음 */ + trial?: Record; +} + +/** 격자 보기 — on.change 로 고친 LbLogic 을 알림(읽기만이면 안 알림) */ +export function renderGrid( + host: HTMLElement, + lb: LbLogic, + ans: CalcAnswer | null, + on: { change(lb: LbLogic): void }, + opts: GridOptions = {}, +): void { + const readOnly = lb.doc.isBase; + const okAns = ans && ans.ok ? ans : null; + const middle = okAns?.middle ?? {}; + const lines = okAns?.lines ?? []; + const msg = el("p", { className: "m01-lbgrid__msg" }); + const calcIndex = (name: string): number => lb.calcs.findIndex((c) => c.name === name); + const apply = (make: () => LbLogic): void => { + if (readOnly) return; + msg.textContent = ""; + try { + on.change(make()); + } catch { + msg.textContent = tg("Cant"); + } + }; + + /* ---- 값 · 줄 번호 ---- */ + const rowOf = (r: Ref): string[] => { + if (r.k === "input") return [`in:${r.name}`]; + if (r.k === "lookup") return [`lk:${r.id}`]; + if (r.k === "calc") return calcIndex(r.name) >= 0 ? [`ca:${calcIndex(r.name)}`] : []; + if (r.k === "expr") return textRows(r.text); + return []; + }; + const textRows = (text: string, self = -1): string[] => [ + ...lb.inputs.filter((i) => mentions(text, i.이름)).map((i) => `in:${i.이름}`), + ...lb.calcs.flatMap((c, i) => + i !== self && c.name && mentions(text, c.name) ? [`ca:${i}`] : [], + ), + ]; + const lookupsAt = (part: Part | "요소", index: number): string[] => + lb.lookups + .filter((l) => l.at.some((a) => a.part === part && a.index === index)) + .map((l) => `lk:${l.id}`); + const lineOf = (card: EasyCard) => + lines.find((l) => l.이름 === card.name && l.단위 === card.unit); + const lookupValue = (id: string): unknown => { + const solo = lb.calcs.find( + (c) => c.parts.length === 1 && c.parts[0].k === "lookup" && c.parts[0].id === id, + ); + if (solo?.name && middle[solo.name] !== undefined) return middle[solo.name]; + const at = lb.lookups + .find((l) => l.id === id) + ?.at.find((a) => a.part === "수량" && a.start === 0); + const card = lb.doc.cards.find( + (c) => c.source?.part === "호표" && c.source.index === at?.index, + ); + const line = card && lineOf(card); + return line && card?.qtyFormula && at && at.end === card.qtyFormula.length ? line.수량 : ""; + }; + const valueOf = (r: Ref): unknown => { + if (r.k === "input") return opts.trial?.[r.name] ?? middle[r.name]; + if (r.k === "fixed") return r.value; + if (r.k === "calc") return middle[r.name]; + if (r.k === "lookup") return lookupValue(r.id); + if (r.k === "cost") return okAns?.sums?.[r.name === "사람줄합" ? "노무비" : r.name]; + return ""; + }; + const unitOf = (r: Ref): string => + r.k === "input" ? (lb.inputs.find((i) => i.이름 === r.name)?.단위 ?? "") : ""; + + /* ---- 칸 만들기 ---- */ + const numberCell = (value: unknown, make: (n: number) => LbLogic): HTMLElement => { + const box = el("input", { + className: "m01-lbgrid__input", + attrs: { type: "text", inputmode: "decimal", "aria-label": tg("Number") }, + }); + box.value = show(value); + box.readOnly = readOnly; + box.addEventListener("click", (e) => e.stopPropagation()); + box.addEventListener("change", () => { + const n = Number(box.value.trim()); + if (box.value.trim() === "" || Number.isNaN(n)) { + msg.textContent = tg("Bad"); + box.value = show(value); + return; + } + apply(() => make(n)); + }); + return box; + }; + + const refLabel = (r: Ref): string => { + if (r.k === "input") return `${tg("FromInput")} · ${r.name}`; + if (r.k === "calc") return `${tg("FromCalc")} · ${r.name}`; + if (r.k === "lookup") { + const l = lb.lookups.find((x) => x.id === r.id); + return `${tg("FromLookup")} · ${l ? `${l.table} ${l.col}` : r.id}`; + } + if (r.k === "fixed") return tg("FromFixed"); + if (r.k === "logic") return tg("FromLogic"); + if (r.k === "cost") return r.name === "사람줄합" ? tg("FromPeople") : r.name; + return tg("Own"); + }; + const refKey = (r: Ref): string => + r.k === "input" + ? `input:${r.name}` + : r.k === "calc" + ? `calc:${r.name}` + : r.k === "lookup" + ? `lookup:${r.id}` + : r.k === "fixed" + ? "fixed" + : r.k === "logic" + ? `logic:${r.key}` + : r.k === "cost" + ? `cost:${r.name}` + : "expr"; + + /** 고를 수 있는 곳 — 입력값 · (앞선) 계산 · 표 조회 · 지금 값 */ + const refSelect = ( + cur: Ref, + pool: Ref[], + none: boolean, + pick: (r: Ref | null) => LbLogic, + ): HTMLElement => { + const all = [...pool]; + if (!all.some((r) => refKey(r) === refKey(cur))) all.unshift(cur); + const options = [ + ...(none ? [{ value: "none", text: tg("FromNone") }] : []), + ...all.map((r) => ({ value: refKey(r), text: refLabel(r) })), + ]; + const field = createSelectField({ + options, + value: refKey(cur), + compact: true, + ariaLabel: refLabel(cur), + disabled: readOnly || cur.k === "expr", + onChange: (v) => { + if (v === refKey(cur)) return; + apply(() => pick(v === "none" ? null : (all.find((r) => refKey(r) === v) ?? cur))); + }, + }); + field.root.addEventListener("click", (e) => e.stopPropagation()); + return field.root; + }; + const inputRefs: Ref[] = lb.inputs.map((i) => ({ k: "input", name: i.이름 })); + const calcRefs = (before: number): Ref[] => + lb.calcs.flatMap((c, i): Ref[] => (c.name && i < before ? [{ k: "calc", name: c.name }] : [])); + const lookupRefs: Ref[] = lb.lookups.map((l) => ({ k: "lookup", id: l.id })); + + /* ---- 줄 모으기 ---- */ + const rows: GridRow[] = []; + const add = (r: Partial & { id: string; name: Cell }): GridRow => { + const row: GridRow = { + step: "", + sub: false, + how: "", + value: "", + unit: "", + source: "", + uses: [], + ...r, + }; + rows.push(row); + return row; + }; + + lb.inputs.forEach((inp, i) => { + add({ + id: `in:${inp.이름}`, + step: i === 0 ? tg("Step1") : "", + name: inp.이름, + how: inp.고르기?.length ? tg("Choice") : tg("Typed"), + value: show(valueOf({ k: "input", name: inp.이름 })), + unit: inp.단위 ?? "", + source: inp.고르기?.length + ? inp.고르기.join(" · ") + : inp.범위 + ? `${tg("Range")} ${inp.범위[0]}∼${inp.범위[1]}` + : "", + }); + }); + + lb.lookups.forEach((lk, i) => { + const head = add({ + id: `lk:${lk.id}`, + step: i === 0 ? tg("Step2") : "", + name: lk.col, + how: tg("FromLookup"), + value: show(lookupValue(lk.id)), + source: `${tg("Table")} ${lk.table}`, + }); + lk.conds.forEach((cd) => { + const from = cd.from; + const fixedNum = from.k === "fixed" && typeof from.value === "number"; + const subRow = add({ + id: `lc:${lk.id}:${cd.col}`, + sub: true, + name: `${tg("Cond")} · ${cd.col}`, + how: refSelect( + from, + [ + ...inputRefs, + ...calcRefs(lb.calcs.length).filter((r) => { + const c = lb.calcs.find((x) => x.name === (r as { name: string }).name); + return c && !c.parts.some((p) => p.k === "lookup") && !c.text.includes("찾기("); + }), + ], + true, + (r) => setCond(lb, lk.id, cd.col, r), + ), + value: + fixedNum && from.k === "fixed" + ? numberCell(from.value, (n) => setCond(lb, lk.id, cd.col, { k: "fixed", value: n })) + : show(valueOf(from)), + unit: unitOf(from), + uses: rowOf(from), + }); + head.uses.push(...subRow.uses); + }); + }); + + lb.calcs.forEach((c, i) => { + const first = i === 0; + const own = lookupsAt(c.at.part, c.at.index); + const head = add({ + id: `ca:${i}`, + step: first ? tg("Step3") : "", + name: c.name ?? `${tg("Unnamed")} ${i + 1}`, + how: tg(KIND_KEY[c.kind]), + value: show(c.name ? middle[c.name] : c.at.part === "수량" ? lines[c.at.index]?.수량 : ""), + source: tg(USE_KEY[c.at.part]), + uses: [...textRows(c.text, i), ...own], + }); + if (!["값", "곱나눔", "할증", "더하기"].includes(c.kind)) return; + c.parts.forEach((p, pi) => { + const label = + c.kind === "할증" + ? tg(pi === 0 ? "Base" : "Rate") + : c.kind === "값" + ? tg("KValue") + : pi === 0 + ? tg("First") + : tg( + ({ "*": "Times", "/": "Divide", "+": "Plus", "-": "Minus" } as const)[ + c.ops?.[pi - 1] ?? (c.kind === "더하기" ? "+" : "*") + ], + ); + const fixedNum = p.k === "fixed" && typeof p.value === "number"; + const setPart = (r: Ref | null): LbLogic => { + const parts = c.parts.slice(); + if (r) parts[pi] = r; + return setCalc(lb, c, { parts }); + }; + const sub = add({ + id: `cp:${i}:${pi}`, + sub: true, + name: label, + how: refSelect(p, [...inputRefs, ...calcRefs(i), ...lookupRefs], false, setPart), + value: + fixedNum && p.k === "fixed" + ? numberCell(p.value, (n) => setPart({ k: "fixed", value: n })) + : show(valueOf(p)), + unit: unitOf(p), + uses: rowOf(p), + }); + head.uses.push(...sub.uses); + }); + }); + + lb.doc.cards.forEach((card, i) => { + const sp = card.source; + const parts: Part[] = sp?.part === "덧줄" ? ["비율"] : ["수량"]; + const viaLookup = sp + ? parts + .flatMap((p) => lookupsAt(p, sp.index)) + .concat(sp.part === "호표" ? lookupsAt("요소", sp.index) : []) + : []; + const viaCalc = sp + ? lb.calcs.flatMap((c, ci) => + parts.includes(c.at.part) && c.at.index === sp.index ? [`ca:${ci}`] : [], + ) + : []; + const line = lineOf(card); + const how = card.qty !== null ? tg("Number") : viaLookup.length ? tg("FromLookup") : tg("Calc"); + add({ + id: `it:${card.id}`, + step: i === 0 ? tg("Step4") : "", + name: card.spec ? `${card.name} ${card.spec}` : card.name, + how: tg(CARD_KEY[card.kind]), + value: + card.qty !== null + ? numberCell(card.qty, (n) => ({ ...lb, doc: updateCard(lb.doc, card.id, { qty: n }) })) + : show(line?.수량), + unit: card.unit, + source: `${how}${line ? ` · ${formatMoney(line.금액)} ${tg("Won")}` : ""}`, + uses: [...viaLookup, ...viaCalc, ...(card.qtyFormula ? textRows(card.qtyFormula) : [])], + }); + }); + + const res = lb.result; + add({ + id: "res", + step: tg("Step5"), + name: tg(res.kind === "금액" ? "Price" : "Result"), + how: res.kind, + value: + res.kind === "금액" + ? okAns + ? formatMoney(okAns.sums?.["계"] ?? 0) + : "" + : show(okAns?.result), + unit: res.unit, + source: res.kind === "금액" ? tg("Sum") : tg("Calc"), + uses: + res.kind === "금액" + ? lb.doc.cards.map((c) => `it:${c.id}`) + : [ + ...textRows(res.calc.text), + ...lb.calcs.flatMap((c, i) => (c.at.part === "결과" ? [`ca:${i}`] : [])), + ], + }); + + /* ---- 그리기 ---- */ + const tbody = el("tbody"); + const paint = (): void => { + const sel = picked.get(host); + const uses = new Set(rows.find((r) => r.id === sel)?.uses ?? []); + tbody.querySelectorAll("tr").forEach((tr) => { + const id = tr.dataset.row ?? ""; + tr.classList.toggle("m01-lbgrid__row--on", id === sel); + tr.classList.toggle("m01-lbgrid__row--pre", uses.has(id)); + }); + }; + const td = (c: Cell, className = ""): HTMLElement => + el("td", { className, ...(typeof c === "string" ? { text: c } : { children: [c] }) }); + rows.forEach((r) => { + const tr = el("tr", { + className: `m01-lbgrid__row${r.sub ? " m01-lbgrid__row--sub" : ""}${r.step ? " m01-lbgrid__row--first" : ""}`, + attrs: { "data-row": r.id }, + children: [ + td(r.step, "m01-lbgrid__step"), + td(r.name, "m01-lbgrid__name"), + td(r.how), + td(r.value, "m01-lbgrid__num"), + td(r.unit), + td(r.source, "m01-lbgrid__src"), + ], + }); + tr.addEventListener("click", () => { + if (picked.get(host) === r.id) picked.delete(host); + else picked.set(host, r.id); + paint(); + }); + tbody.append(tr); + }); + const heads: TextKey[] = ["HStep", "HName", "HHow", "HValue", "HUnit", "HSource"]; + const status = + ans && !ans.ok ? `${tg("Stopped")} — ${safeReason(ans.reason)}` : ans ? "" : tg("Busy"); + host.replaceChildren( + ...(readOnly ? [el("p", { className: "m01-lbgrid__note", text: tg("ReadOnly") })] : []), + ...(status ? [el("p", { className: "m01-lbgrid__note", text: status })] : []), + el("div", { + className: "m01-lbgrid__scroll", + children: [ + el("table", { + className: "m01-lbgrid__table", + children: [ + el("thead", { + children: [el("tr", { children: heads.map((h) => el("th", { text: tg(h) })) })], + }), + tbody, + ], + }), + ], + }), + msg, + el("p", { className: "m01-lbgrid__hint", text: tg("Hint") }), + ); + paint(); +} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Source.css b/M01_MasterData/LogicBuilder/M01_LB_Source.css new file mode 100644 index 000000000..0d90c4871 --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Source.css @@ -0,0 +1,196 @@ +/* 로직 만들기 판 — 왼쪽 원문 */ +.m01-lbsrc { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + min-width: 0; + font-size: var(--text-body-sm); +} + +.m01-lbsrc p { + margin: 0; +} + +.m01-lbsrc__muted { + color: var(--color-text-muted); +} + +.m01-lbsrc__muted:empty { + display: none; +} + +.m01-lbsrc__preview { + padding: var(--spacing-8) var(--spacing-12); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--color-warning) 14%, var(--color-canvas)); + color: var(--color-text); +} + +.m01-lbsrc__table { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface); + min-width: 0; +} + +.m01-lbsrc__head { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--spacing-8); +} + +.m01-lbsrc__users { + margin-left: auto; + padding: 0; + border: 0; + background: none; + color: var(--color-accent); + font: inherit; + cursor: pointer; + text-decoration: underline; +} + +.m01-lbsrc__warn { + color: var(--color-danger); + font-weight: 600; +} + +/* 엑셀 같은 격자 */ +.m01-lbsrc__grid { + max-height: 22rem; + overflow: auto; + border: 1px solid var(--color-border); +} + +.m01-lbsrc__grid table { + border-collapse: collapse; + width: 100%; +} + +.m01-lbsrc__grid th, +.m01-lbsrc__grid td { + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid var(--color-border); + white-space: nowrap; + text-align: left; +} + +.m01-lbsrc__grid thead th { + position: sticky; + top: 0; + z-index: 1; + background: var(--color-paper); + color: var(--color-text-secondary); + font-weight: 500; + vertical-align: top; +} + +.m01-lbsrc__grid thead th span { + display: block; +} + +.m01-lbsrc__grid .m01-lbsrc__no { + width: 2.5rem; + background: var(--color-paper); + color: var(--color-text-muted); + text-align: right; +} + +.m01-lbsrc__grid td { + cursor: cell; +} + +.m01-lbsrc__grid td.is-value { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.m01-lbsrc__grid td.is-col { + font-weight: 700; +} + +.m01-lbsrc__grid tr.is-hit td { + background: color-mix(in srgb, var(--color-warning) 28%, var(--color-canvas)); +} + +.m01-lbsrc__grid td.is-edited { + box-shadow: inset 0 0 0 2px var(--color-accent); +} + +.m01-lbsrc__grid td.is-bad { + box-shadow: inset 0 0 0 2px var(--color-danger); +} + +.m01-lbsrc__input { + width: 100%; + min-width: 4rem; + padding: 0 var(--spacing-4); + border: 1px solid var(--color-accent); + font: inherit; + background: var(--color-canvas); + color: var(--color-text); +} + +/* 칸 역할 칩 — 조건 파랑 · 고정 회색 · 결과 초록 · 무관 점선 */ +.m01-lbsrc__chip { + margin-top: var(--spacing-4); + padding: 0 var(--spacing-8); + border: 1px solid currentColor; + border-radius: 999px; + background: transparent; + font: inherit; + font-size: var(--text-caption); + cursor: pointer; +} + +.m01-lbsrc__chip.is-cond { + color: var(--color-accent); + background: color-mix(in srgb, var(--color-accent) 12%, transparent); +} + +.m01-lbsrc__chip.is-fixed { + color: var(--color-text-secondary); +} + +.m01-lbsrc__chip.is-result { + color: var(--color-success, #2f6b4f); + background: color-mix(in srgb, var(--color-success, #2f6b4f) 12%, transparent); +} + +.m01-lbsrc__chip.is-free { + color: var(--color-text-muted); + border-style: dashed; +} + +.m01-lbsrc__notes summary { + cursor: pointer; + color: var(--color-text-secondary); +} + +.m01-lbsrc__notes ol { + margin: var(--spacing-4) 0 0; + padding-left: var(--spacing-16); + list-style: none; + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.m01-lbsrc__notes li.is-used { + font-weight: 600; +} + +.m01-lbsrc__tag { + margin-left: var(--spacing-8); + padding: 0 var(--spacing-4); + border-radius: 4px; + background: color-mix(in srgb, var(--color-accent) 14%, transparent); + color: var(--color-accent); + font-size: var(--text-caption); + font-weight: 500; +} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Source.ts b/M01_MasterData/LogicBuilder/M01_LB_Source.ts new file mode 100644 index 000000000..8a16e89a9 --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Source.ts @@ -0,0 +1,371 @@ +/* ============================================================================= + * M01_LB_Source.ts + * 로직 만들기 판 — 왼쪽 원문(정함 5 · 7 · 설계초안 6절) + * 로직이 쓰는 원문 표마다: 표 이름 · 원문번호 · 기준 · 엑셀 같은 격자(칸 머리 역할 칩 조건 / 고정 / 결과) + * · 예시값으로 걸린 줄 노랑 · 해당 없음 / 중복 한 줄 · 칸 눌러 고침(메모리 사본 — on.edit) · 주 목록 · 사용처. + * 표 고침은 미리 보기 — 저장하지 않음(정함 5). 화면 글에 키 · 식 안 씀(정함 9). + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { el } from "@ui/ui_template_elements"; +import { tableUsers, type LbTables } from "./M01_LB_Api"; +import type { LbLogic, LbLookup, TableEdit } from "./M01_LB_Blocks"; +import type { LbHit } from "./M01_LB_Check"; +import "./M01_LB_Source.css"; + +const TEXT = { + Preview: [ + "표 고침은 미리 보기 — 저장은 결정 뒤", + "Table edits are a preview — saving comes later", + ], + Users: ["사용처", "Used by"], + UsersN: ["이 표를 쓰는 로직 {n} 개", "{n} logics use this table"], + UsersNone: ["이 표를 쓰는 로직 없음", "No logic uses this table"], + UsersFail: ["사용처를 불러오지 못함", "Couldn't load users"], + Loading: ["불러오는 중", "Loading"], + Missing: ["표를 불러오지 못함", "Couldn't load the table"], + NoRow: ["해당 없음 — 예시값에 맞는 줄이 없음", "No match — no row fits the sample values"], + DupRow: [ + "중복 — 예시값에 맞는 줄이 둘 이상", + "Duplicate — more than one row fits the sample values", + ], + Notes: ["주", "Notes"], + NoteUsed: ["계산에 쓰임", "Used in the calculation"], + Cond: ["조건", "Condition"], + Fixed: ["고정", "Fixed"], + Result: ["결과", "Result"], + Free: ["무관", "Any"], + RoleHint: ["눌러 역할 바꿈", "Tap to change the role"], + EditHint: [ + "칸을 눌러 고침 · Enter 로 끝 · Esc 로 취소", + "Tap a cell to edit · Enter to finish · Esc to cancel", + ], + Edited: ["고친 칸", "Edited cell"], +} as const; + +const ts = (key: keyof typeof TEXT, n?: number): string => + TEXT[key][currentLanguageIndex as 0 | 1].replace("{n}", String(n ?? "")); + +export type LbRole = "조건" | "고정" | "결과"; +type Cell = string | number | boolean | null | [number | null, number | null] | undefined; + +interface SourceTable { + 키?: string; + 이름?: string; + 원문번호?: string; + 기준?: string; + 범위규칙?: string; + 조건?: Record; + 값칸?: Record; + 주?: string[]; + 용도?: { 로직키?: string[] }; + 줄: Record[]; +} + +export interface SourceOn { + edit(e: TableEdit): void; + role(table: string, col: string, role: LbRole): void; +} + +/* ── 칸 보이기 · 읽기 ─────────────────────────────────────────────── */ + +const isRange = (v: unknown): v is [number | null, number | null] => + Array.isArray(v) && v.length === 2; + +/** 칸 글 — 범위 [a, b] 는 「a~b」 · 끝이 없으면 「~b」 · 「a~」 */ +export function showCell(v: Cell): string { + if (v === null || v === undefined) return ""; + if (isRange(v)) return `${v[0] ?? ""}~${v[1] ?? ""}`; + return String(v); +} + +/** 고친 글 → 칸 값 — 원래 칸이 범위면 범위 · 수면 수 · 아니면 글 · 못 읽으면 null */ +export function parseCell(text: string, before: Cell): TableEdit["value"] | null { + const t = text.trim(); + const num = (s: string): number | null | undefined => { + const c = s.replace(/,/g, "").trim(); + if (!c) return null; + return /^-?\d+(\.\d+)?$/.test(c) ? Number(c) : undefined; + }; + if (isRange(before) || /^[^~~]*[~~][^~~]*$/.test(t)) { + const parts = t.split(/[~~]/); + if (parts.length !== 2) return null; + const [a, b] = parts.map(num); + if (a === undefined || b === undefined || (a === null && b === null)) return null; + if (a !== null && b !== null && a > b) return null; + return [a, b]; + } + if (!t) return null; + const n = num(t); + if (typeof before === "number" || (typeof n === "number" && typeof before !== "string")) { + return typeof n === "number" ? n : null; + } + return t; +} + +/* ── 역할 · 주 · 걸린 줄 ─────────────────────────────────────────── */ + +/** 표 칸 하나의 역할 — 이 로직의 표 조회가 그 칸을 어떻게 쓰나(첫 조회 기준) · 안 쓰면 null */ +export function roleOf(lookups: LbLookup[], col: string): LbRole | null { + for (const lk of lookups) { + if (lk.col === col) return "결과"; + const c = lk.conds.find((x) => x.col === col); + if (c) return c.from.k === "fixed" ? "고정" : "조건"; + } + return null; +} + +const NEXT: Record = { 조건: "고정", 고정: "결과", 결과: "조건" }; + +/** 로직 글에 적힌 주 — 「13-4-1 주③」 은 그 원문번호 표의 ③ · 번호 없는 「주③」 은 모든 표 */ +export function usedNotes(row: unknown): Map> { + const out = new Map>(); + const add = (where: string, mark: string): void => { + const set = out.get(where) ?? new Set(); + set.add(mark); + out.set(where, set); + }; + const walk = (v: unknown): void => { + if (typeof v === "string") { + for (const m of v.matchAll(/(\d+(?:-\d+)+)?\s*주\s*([①-⑳])/g)) add(m[1] ?? "*", m[2]); + } else if (Array.isArray(v)) v.forEach(walk); + else if (v && typeof v === "object") Object.values(v).forEach(walk); + }; + walk(row); + return out; +} + +/** 표에 걸린 줄들 · 해당 없음 · 중복 — 표 조회 번호로 찾고 없으면 표 키로 */ +function hitsOf( + key: string, + lookups: LbLookup[], + hits: Record, +): { rows: Set; none: boolean; dup: boolean } { + const got = lookups.map((lk) => hits[lk.id] ?? hits[key] ?? null); + return { + rows: new Set(got.filter((h): h is number => typeof h === "number")), + none: got.includes("없음"), + dup: got.includes("중복"), + }; +} + +/* ── 그리기 ─────────────────────────────────────────────────────── */ + +/** 처음 본 표 값 — 고친 칸 표시용(화면이 열려 있는 동안) */ +const originals = new Map(); + +function snapshot(key: string, table: SourceTable, cols: string[]): string[][] { + const had = originals.get(key); + if (had && had.length === table.줄.length) return had; + const shot = table.줄.map((r) => cols.map((c) => showCell(r[c]))); + originals.set(key, shot); + return shot; +} + +function roleChip( + key: string, + col: string, + role: LbRole | null, + isCond: boolean, + on: SourceOn, +): HTMLElement { + const shown = role ?? (isCond ? null : undefined); + if (shown === undefined) return el("span"); + const label = shown + ? ts(shown === "조건" ? "Cond" : shown === "고정" ? "Fixed" : "Result") + : ts("Free"); + const chip = el("button", { + className: `m01-lbsrc__chip is-${shown ? { 조건: "cond", 고정: "fixed", 결과: "result" }[shown] : "free"}`, + text: label, + attrs: { type: "button", title: ts("RoleHint") }, + }); + chip.addEventListener("click", (ev) => { + ev.stopPropagation(); + on.role(key, col, shown ? NEXT[shown] : "조건"); + }); + return chip; +} + +function editCell( + td: HTMLTableCellElement, + before: Cell, + done: (v: TableEdit["value"]) => void, +): void { + if (td.querySelector("input")) return; + const input = el("input", { className: "m01-lbsrc__input", attrs: { type: "text" } }); + input.value = showCell(before); + const text = td.textContent ?? ""; + td.replaceChildren(input); + input.focus(); + input.select(); + let closed = false; + const close = (save: boolean): void => { + if (closed) return; + closed = true; + const value = save ? parseCell(input.value, before) : null; + const same = value !== null && showCell(value) === showCell(before); + td.textContent = value === null || same ? text : showCell(value); + td.classList.toggle("is-bad", save && value === null && input.value.trim() !== text); + if (value !== null && !same) done(value); + }; + input.addEventListener("keydown", (ev) => { + if (ev.key === "Enter") close(true); + if (ev.key === "Escape") close(false); + ev.stopPropagation(); + }); + input.addEventListener("blur", () => close(true)); +} + +function renderTable( + key: string, + table: SourceTable | undefined, + lb: LbLogic, + hits: Record, + notes: Map>, + on: SourceOn, +): HTMLElement { + const lookups = lb.lookups.filter((lk) => lk.table === key); + if (!table?.줄) { + return el("section", { + className: "m01-lbsrc__table", + children: [el("p", { className: "m01-lbsrc__muted", text: ts("Missing") })], + }); + } + const conds = Object.keys(table.조건 ?? {}); + const vals = Object.keys(table.값칸 ?? {}); + const cols = [...conds, ...vals]; + const orig = snapshot(key, table, cols); + const hit = hitsOf(key, lookups, hits); + + const users = (table.용도?.로직키 ?? []).length; + const userBtn = el("button", { + className: "m01-lbsrc__users", + text: users ? ts("UsersN", users) : ts("UsersNone"), + attrs: { type: "button" }, + }); + const userList = el("p", { className: "m01-lbsrc__muted" }); + userBtn.addEventListener("click", () => { + userList.textContent = ts("Loading"); + tableUsers(key) + .then((list) => { + userBtn.textContent = list.length ? ts("UsersN", list.length) : ts("UsersNone"); + userList.textContent = list.map((u) => u.이름).join(" · "); + }) + .catch(() => (userList.textContent = ts("UsersFail"))); + }); + + const head = el("tr", { + children: [ + el("th", { className: "m01-lbsrc__no", text: "" }), + ...cols.map((c) => { + const unit = table.값칸?.[c]; + return el("th", { + className: vals.includes(c) ? "is-value" : "", + children: [ + el("span", { text: unit ? `${c} (${unit})` : c }), + roleChip(key, c, roleOf(lookups, c), conds.includes(c), on), + ], + }); + }), + ], + }); + const body = table.줄.map((row, i) => { + const tr = el("tr", { + className: hit.rows.has(i) ? "is-hit" : "", + children: [ + el("td", { className: "m01-lbsrc__no", text: String(i + 1) }), + ...cols.map((c, j) => { + const td = el("td", { + className: [ + vals.includes(c) ? "is-value" : "", + lookups.some((lk) => lk.col === c) ? "is-col" : "", + showCell(row[c]) !== orig[i]?.[j] ? "is-edited" : "", + ].join(" "), + text: showCell(row[c]), + }); + td.addEventListener("click", () => + editCell(td, row[c], (value) => { + td.classList.toggle("is-edited", showCell(value) !== orig[i]?.[j]); + on.edit({ table: key, row: i, col: c, value }); + }), + ); + return td; + }), + ], + }); + return tr; + }); + + const marks = new Set([...(notes.get(table.원문번호 ?? "") ?? []), ...(notes.get("*") ?? [])]); + const noteList = (table.주 ?? []).map((n) => { + const used = marks.has(n.trim().charAt(0)); + return el("li", { + className: used ? "is-used" : "", + children: [ + el("span", { text: n }), + ...(used ? [el("span", { className: "m01-lbsrc__tag", text: ts("NoteUsed") })] : []), + ], + }); + }); + + return el("section", { + className: "m01-lbsrc__table", + children: [ + el("div", { + className: "m01-lbsrc__head", + children: [ + el("strong", { text: table.이름 ?? "" }), + el("span", { + className: "m01-lbsrc__muted", + text: [table.원문번호, table.기준].filter(Boolean).join(" · "), + }), + userBtn, + ], + }), + userList, + ...(hit.none ? [el("p", { className: "m01-lbsrc__warn", text: ts("NoRow") })] : []), + ...(hit.dup ? [el("p", { className: "m01-lbsrc__warn", text: ts("DupRow") })] : []), + el("div", { + className: "m01-lbsrc__grid", + children: [ + el("table", { + children: [el("thead", { children: [head] }), el("tbody", { children: body })], + }), + ], + }), + ...(noteList.length + ? [ + el("details", { + className: "m01-lbsrc__notes", + attrs: marks.size ? { open: "" } : {}, + children: [el("summary", { text: ts("Notes") }), el("ol", { children: noteList })], + }), + ] + : []), + ], + }); +} + +/** 왼쪽 원문 — 표 조회가 쓰는 표마다 한 덩어리 · hits = 표 조회 번호(또는 표 키) → 걸린 줄 */ +export function renderSource( + host: HTMLElement, + lb: LbLogic, + tables: LbTables, + hits: Record, + on: SourceOn, +): void { + const keys = [...new Set(lb.lookups.map((lk) => lk.table))]; + const notes = usedNotes(lb.doc?.row); + host.replaceChildren( + el("div", { + className: "m01-lbsrc", + children: [ + el("p", { className: "m01-lbsrc__preview", text: ts("Preview") }), + el("p", { className: "m01-lbsrc__muted", text: ts("EditHint") }), + ...keys.map((k) => + renderTable(k, tables[k] as SourceTable | undefined, lb, hits, notes, on), + ), + ], + }), + ); +} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Steps.css b/M01_MasterData/LogicBuilder/M01_LB_Steps.css new file mode 100644 index 000000000..6ae219619 --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Steps.css @@ -0,0 +1,201 @@ +.m01-lbsteps { + display: flex; + flex-direction: column; + gap: var(--spacing-16); + font-size: var(--text-body); + color: var(--color-text-body); +} + +.m01-lbsteps-banner { + margin: 0; + padding: var(--spacing-8) var(--spacing-16); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); + font-weight: var(--font-weight-medium); +} + +.m01-lbsteps-banner.is-warn { + border-left: 4px solid var(--color-warning); +} + +.m01-lbsteps-banner p { + margin: 0; +} + +.m01-lbsteps-why { + color: var(--color-text-secondary); + font-weight: var(--font-weight-regular); +} + +.m01-lbsteps-sec { + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface); + padding: var(--spacing-8) var(--spacing-16) var(--spacing-16); +} + +.m01-lbsteps-title { + margin: 0 0 var(--spacing-8); + font-size: var(--text-subheading); + font-weight: var(--font-weight-bold); +} + +.m01-lbsteps-list, +.m01-lbsteps-branches { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + margin: 0; + padding: 0; + list-style: none; +} + +.m01-lbsteps-row { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding-top: var(--spacing-8); + border-top: 1px solid var(--color-border); +} + +.m01-lbsteps-row:first-child { + padding-top: 0; + border-top: 0; +} + +.m01-lbsteps-sentence { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4) var(--spacing-8); + min-width: 0; +} + +.m01-lbsteps-ref, +.m01-lbsteps-cond, +.m01-lbsteps-chips { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4); +} + +.m01-lbsteps-shapes, +.m01-lbsteps-shape { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding-left: var(--spacing-16); + border-left: 2px solid var(--color-border); +} + +.m01-lbsteps-branch { + padding-left: var(--spacing-8); +} + +.m01-lbsteps-order { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.6em; + height: 1.6em; + border: 1px solid var(--color-border); + border-radius: var(--radius-pills); + font-weight: var(--font-weight-bold); +} + +.m01-lbsteps-name, +.m01-lbsteps-tag { + font-weight: var(--font-weight-bold); +} + +.m01-lbsteps-chip { + display: inline-block; + padding: 2px var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-pills); + background: var(--color-surface-raised); +} + +.m01-lbsteps-note { + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +.m01-lbsteps-value, +.m01-lbsteps-sum { + color: var(--color-text-secondary); + font-weight: var(--font-weight-medium); + white-space: nowrap; +} + +.m01-lbsteps-amount, +.m01-lbsteps-total { + margin-left: auto; + font-weight: var(--font-weight-bold); + white-space: nowrap; +} + +.m01-lbsteps-amount.is-wait { + opacity: 0.5; + font-weight: var(--font-weight-regular); +} + +.m01-lbsteps-pick { + min-height: 44px; + padding: 0 var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: var(--color-surface); + color: inherit; + font: inherit; + font-weight: var(--font-weight-medium); + cursor: pointer; +} + +.m01-lbsteps-pick:focus-visible { + outline: 2px solid var(--color-focus-ring); +} + +.m01-lbsteps .ui-input, +.m01-lbsteps .ui-select, +.m01-lbsteps .ui-btn { + min-height: 44px; + font-size: var(--text-body); +} + +.m01-lbsteps-num { + flex: none; + width: 6em; +} + +.m01-lbsteps-text { + flex: none; + width: 9em; +} + +.m01-lbsteps-select { + flex: none; + width: auto; + max-width: 100%; +} + +.m01-lbsteps .ui-input.is-bad { + border-color: var(--color-danger); + border-style: dashed; +} + +.m01-lbsteps-add { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); + margin-top: var(--spacing-8); +} + +@media (max-width: 640px) { + .m01-lbsteps-amount, + .m01-lbsteps-total { + margin-left: 0; + } +} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Steps.ts b/M01_MasterData/LogicBuilder/M01_LB_Steps.ts new file mode 100644 index 000000000..0dca8c47b --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Steps.ts @@ -0,0 +1,392 @@ +/* ============================================================================= + * M01_LB_Steps.ts + * 로직 만들기 판 가운데 문장 보기 — ① 입력값 ② 표 조회 ③ 계산 ④ 항목 연결 ⑤ 결과값 + * 모양은 `docs/raw/M01_쉬운단가/로직판_A_정함.md` · `로직판_A_설계초안.md` 7-1 그대로 · 모두 고르기 · 숫자 칸 · 바뀐 lb 는 on.change 로만 알림 + * 글 [한, 영] 짝은 아래 TEXT + * ========================================================================== */ + +import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { + fetchLogics, + type CalcAnswer, + type CalcLine, + type LogicInput, +} from "../M01_MasterData_UI_Logic_Api"; +import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; +import { + addCard, + addPercent, + removeCard, + updateCard, + withInputs, + type CostKind, + type EasyCard, + type PickResult, +} from "../EasyCalc/M01_EasyCalc_Model"; +import { openEasyPick } from "../EasyCalc/M01_EasyCalc_Pick"; +import { setCond, type LbLogic, type LbLookup, type LbRef } from "./M01_LB_Blocks"; +import { calcBody, calcView } from "./M01_LB_Steps_Calc"; +import { + chip, + logicNames, + lookupValue, + note, + numBox, + parseNum, + refText, + selectBox, + sentence, + span, + textBox, + tx, + valueNote, + type Ctx, + type TEXT, +} from "./M01_LB_Steps_Cell"; +import "./M01_LB_Steps.css"; + +type Kind = "person" | "material" | "machine" | "logic" | "percent" | "locked"; + +let picking = false; +let namesLoading = false; + +/* ------------------------------ ① 입력값 ------------------------------ */ + +function inputView(ctx: Ctx, input: LogicInput, at: number): HTMLElement { + const put = (patch: Partial): void => + ctx.commit(() => { + const inputs = ctx.lb.inputs.map((x, i) => (i === at ? { ...x, ...patch } : x)); + return { ...ctx.lb, inputs, doc: { ...ctx.lb.doc, inputs } }; + }); + const parts: (HTMLElement | string)[] = [span("m01-lbsteps-name", input.이름)]; + if (input.고르기) { + parts.push( + note(tx("PickList")), + el("span", { + className: "m01-lbsteps-chips", + children: input.고르기.map((v) => chip(String(v))), + }), + ); + } else if (input.범위) { + const [lo, hi] = input.범위; + parts.push( + note(tx("Range")), + numBox(ctx, lo, (n) => put({ 범위: [n, hi] })), + "~", + numBox(ctx, hi, (n) => put({ 범위: [lo, n] })), + ); + } else parts.push(note(tx("Number"))); + parts.push( + note(tx("Unit")), + textBox(ctx, input.단위 ?? "", (v) => put({ 단위: v })), + ); + return el("li", { className: "m01-lbsteps-row", children: [sentence(...parts)] }); +} + +/* ------------------------------ ② 표 조회 ------------------------------ */ + +function condView(ctx: Ctx, lk: LbLookup, cond: LbLookup["conds"][number]): HTMLElement { + const from = cond.from; + const set = (next: LbRef | null): void => + ctx.commit(() => setCond(ctx.lb, lk.id, cond.col, next)); + const parts: (HTMLElement | string)[] = [chip(cond.col), tx("LkIs")]; + if (from.k === "input" || from.k === "fixed") { + const options = [ + ...ctx.lb.inputs.map((i) => ({ value: `i:${i.이름}`, text: `${tx("SrcInput")}${i.이름}` })), + { value: "f", text: tx("SrcFixed") }, + { value: "n", text: tx("SrcAny") }, + ]; + const current = from.k === "input" ? `i:${from.name}` : "f"; + parts.push( + selectBox( + ctx, + options, + current, + (v) => + set( + v === "n" + ? null + : v === "f" + ? { k: "fixed", value: "" } + : { k: "input", name: v.slice(2) }, + ), + cond.col, + ), + ); + if (from.k === "fixed") { + parts.push( + textBox(ctx, String(from.value), (v) => set({ k: "fixed", value: parseNum(v) ?? v })), + ); + } + } else parts.push(chip(refText(ctx, from))); + return el("span", { className: "m01-lbsteps-cond", children: parts }); +} + +function lookupView(ctx: Ctx, lk: LbLookup, n: number): HTMLElement { + const name = ctx.on.tableName?.(lk.table) ?? lk.table; + return el("li", { + className: "m01-lbsteps-row", + children: [ + sentence( + span("m01-lbsteps-name", `${tx("LkNum")}${n}`), + chip(name), + tx("LkFrom"), + ...lk.conds.map((c) => condView(ctx, lk, c)), + tx("LkRow"), + chip(lk.col), + valueNote(lookupValue(ctx, lk)), + ), + ], + }); +} + +/* ------------------------------ ④ 항목 연결 ------------------------------ */ + +const BASES: (CostKind | "사람줄합")[] = ["노무비", "재료비", "경비", "사람줄합"]; + +function lineMap(ctx: Ctx): Map { + const out = new Map(); + const ans = ctx.ans; + if (!ans || !ans.ok || !ans.lines) return out; + const cards = ctx.lb.doc.cards; + const part = (c: EasyCard): string => + c.kind === "percent" ? "덧줄" : c.kind === "locked" ? (c.source?.part ?? "덧줄") : "호표"; + const ordered = [ + ...cards.filter((c) => part(c) === "호표"), + ...cards.filter((c) => part(c) === "덧줄"), + ]; + if (ordered.length === ans.lines.length) ordered.forEach((c, i) => out.set(c.id, ans.lines![i])); + return out; +} + +const patchItem = (ctx: Ctx, id: string, patch: Partial): void => + ctx.commit(() => ({ ...ctx.lb, doc: updateCard(ctx.lb.doc, id, patch) })); + +async function pickItem(ctx: Ctx, kind: PickResult["kind"], card?: EasyCard): Promise { + if (picking) return; + picking = true; + try { + const pick = await openEasyPick(kind); + if (!pick) return; + ctx.commit(() => { + if (!card) return { ...ctx.lb, doc: addCard(ctx.lb.doc, pick) }; + const next = updateCard(ctx.lb.doc, card.id, { + name: pick.name, + spec: pick.spec, + unit: pick.unit, + element: pick.element, + }); + return { ...ctx.lb, doc: withInputs(next, pick.needInputs ?? []) }; + }); + } finally { + picking = false; + } +} + +function itemView(ctx: Ctx, card: EasyCard): HTMLElement { + const kind = card.kind as Kind; + const percent = kind === "percent"; + const part = card.source?.part === "호표" ? "수량" : "비율"; + const calc = card.source + ? ctx.lb.calcs.find((c) => c.at.part === part && c.at.index === card.source?.index) + : undefined; + const line = ctx.lines.get(card.id); + const qty: HTMLElement = calc + ? el("span", { + className: "m01-lbsteps-ref", + children: [chip(`${tx("Calc")}${ctx.lb.calcs.indexOf(calc) + 1}`), valueNote(line?.수량)], + }) + : card.qtyFormula === null + ? numBox(ctx, card.qty, (n) => patchItem(ctx, card.id, { qty: n })) + : chip(tx("Hard")); + const name = + ctx.ro || kind === "locked" || percent + ? span("m01-lbsteps-name", [card.name, card.spec].filter(Boolean).join(" ")) + : (() => { + const b = el("button", { + className: "m01-lbsteps-pick", + text: `${[card.name, card.spec].filter(Boolean).join(" ")} ▾`, + attrs: { type: "button", "aria-label": tx("Repick") }, + }); + b.addEventListener("click", () => void pickItem(ctx, kind as PickResult["kind"], card)); + return b; + })(); + const head: (HTMLElement | string)[] = [span("m01-lbsteps-tag", `【${tx(`Tag_${kind}`)}】`)]; + if (percent) { + const options = BASES.map((v) => ({ value: v, text: tx(`Base_${v}`) })); + head.push( + selectBox( + ctx, + options, + card.percentBase ?? "노무비", + (v) => patchItem(ctx, card.id, { percentBase: v as EasyCard["percentBase"] }), + tx("S4"), + ), + tx("Pre_percent"), + qty, + tx("Post_percent"), + name, + ); + } else { + head.push( + name, + kind === "person" ? tx("Pre_person") : tx("Pre_pick"), + qty, + span("m01-lbsteps-unit", ` ${card.unit}`), + ); + } + head.push( + line + ? span("m01-lbsteps-amount", `= ${formatMoney(line.금액)} ${tx("Won")}`) + : span("m01-lbsteps-amount is-wait", `= ${tx("Pending")}`), + ); + if (!ctx.ro) { + head.push( + createButton({ + label: tx("Remove"), + variant: "ghost", + size: "sm", + onClick: () => ctx.commit(() => ({ ...ctx.lb, doc: removeCard(ctx.lb.doc, card.id) })), + }), + ); + } + return el("li", { className: "m01-lbsteps-row", children: [sentence(...head)] }); +} + +function addBar(ctx: Ctx): HTMLElement { + const add = (key: keyof typeof TEXT, run: () => void): HTMLButtonElement => + createButton({ label: tx(key), variant: "ghost", size: "sm", onClick: run }); + const pick = (kind: PickResult["kind"]) => () => void pickItem(ctx, kind); + return el("div", { + className: "m01-lbsteps-add", + children: [ + add("Add_person", pick("person")), + add("Add_material", pick("material")), + add("Add_machine", pick("machine")), + add("Add_logic", pick("logic")), + add("Add_percent", () => + ctx.commit(() => ({ ...ctx.lb, doc: addPercent(ctx.lb.doc, "", "노무비", null, "경비") })), + ), + ], + }); +} + +/* ------------------------------ ⑤ 결과값 ------------------------------ */ + +function resultView(ctx: Ctx): HTMLElement { + const result = ctx.lb.result; + const ans = ctx.ans; + if (result.kind === "값") { + const value = ans?.ok ? ans.result : undefined; + return el("div", { + children: [ + sentence(note(tx("ResultValue")), chip(result.unit), valueNote(value)), + calcBody(ctx, result.calc), + ], + }); + } + const sums = ans?.ok ? ans.sums : undefined; + const parts: (HTMLElement | string)[] = [note(tx("ResultMoney")), chip(result.unit)]; + if (sums) { + for (const k of ["노무비", "재료비", "경비"]) { + if (k in sums) + parts.push( + span("m01-lbsteps-sum", `${tx(`Base_${k as CostKind}`)} ${formatMoney(sums[k])}`), + ); + } + parts.push( + span("m01-lbsteps-total", `${tx("Total")} ${formatMoney(sums["계"] ?? 0)} ${tx("Won")}`), + ); + } else parts.push(span("m01-lbsteps-amount is-wait", `= ${tx("Pending")}`)); + return sentence(...parts); +} + +/* ------------------------------ 전체 ------------------------------ */ + +function section(title: string, items: HTMLElement[], extra?: HTMLElement): HTMLElement { + const body = items.length + ? el("ol", { className: "m01-lbsteps-list", children: items }) + : note(tx("None")); + return el("section", { + className: "m01-lbsteps-sec", + children: [ + el("h3", { className: "m01-lbsteps-title", text: title }), + body, + ...(extra ? [extra] : []), + ], + }); +} + +/** 가운데 문장 보기 그리기 — ans 는 칸마다 곁에 보이는 예시값 · null 이면 「계산 중」 */ +export function renderSteps( + host: HTMLElement, + lb: LbLogic, + ans: CalcAnswer | null, + on: { change(lb: LbLogic): void; tableName?(table: string): string | null }, +): void { + const ctx: Ctx = { + lb, + ans, + ro: lb.doc.isBase, + on, + lines: new Map(), + commit: (make) => { + try { + on.change(make()); + } catch { + showToast(tx("Cannot"), "error"); + } + }, + }; + ctx.lines = lineMap(ctx); + host.classList.add("m01-lbsteps"); + const top: HTMLElement[] = []; + if (ctx.ro) top.push(el("p", { className: "m01-lbsteps-banner", text: tx("BaseNote") })); + if (lb.blocked.length) { + top.push( + el("div", { + className: "m01-lbsteps-banner is-warn", + children: [ + el("p", { text: `⚠ ${tx("Blocked")}` }), + ...lb.blocked.map((b) => el("p", { className: "m01-lbsteps-why", text: b })), + ], + }), + ); + } + host.replaceChildren( + ...top, + section( + tx("S1"), + lb.inputs.map((x, i) => inputView(ctx, x, i)), + ), + section( + tx("S2"), + lb.lookups.map((l, i) => lookupView(ctx, l, i + 1)), + ), + section( + tx("S3"), + lb.calcs.map((c, i) => calcView(ctx, c, i + 1)), + ), + section( + tx("S4"), + lb.doc.cards.map((c) => itemView(ctx, c)), + ctx.ro ? undefined : addBar(ctx), + ), + section(tx("S5"), [el("li", { className: "m01-lbsteps-row", children: [resultView(ctx)] })]), + ); + // 다른 일위대가 이름 — 목록을 한 번 받은 뒤 다시 그림 + if ( + !logicNames.size && + !namesLoading && + JSON.stringify([lb.calcs, lb.lookups]).includes('"k":"logic"') + ) { + namesLoading = true; + fetchLogics() + .then((all) => { + for (const l of all) logicNames.set(l.키, l.이름); + renderSteps(host, lb, ans, on); + }) + .catch(() => undefined) + .finally(() => (namesLoading = false)); + } +} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Steps_Calc.ts b/M01_MasterData/LogicBuilder/M01_LB_Steps_Calc.ts new file mode 100644 index 000000000..213b1ebcd --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Steps_Calc.ts @@ -0,0 +1,201 @@ +/* ============================================================================= + * M01_LB_Steps_Calc.ts + * 로직 만들기 판 문장 보기 ③ 계산 — 값 · 곱나눔 · 할증 · 조건 목록 · 최소/최대 · 올림 + * ========================================================================== */ + +import { el } from "@ui/ui_template_elements"; +import type { CondOp, CondRead, CondShape, CondSlot } from "../EasyCalc/M01_EasyCalc_Cond"; +import { setCalc, type LbCalc } from "./M01_LB_Blocks"; +import { + chip, + middleOf, + note, + numBox, + refBox, + selectBox, + sentence, + span, + textBox, + tx, + valueNote, + type Ctx, + type TEXT, +} from "./M01_LB_Steps_Cell"; + +const OPS: [CondOp, keyof typeof TEXT][] = [ + ["==", "OpEq"], + ["!=", "OpNe"], + [">", "OpGt"], + [">=", "OpGe"], + ["<", "OpLt"], + ["<=", "OpLe"], +]; + +function slotBox(ctx: Ctx, slot: CondSlot, put: (s: CondSlot) => void): HTMLElement { + if (slot.kind === "num") return numBox(ctx, slot.value, (n) => put({ kind: "num", value: n })); + if (slot.kind === "text") return textBox(ctx, slot.value, (v) => put({ kind: "text", value: v })); + if (slot.kind === "input") { + const options = ctx.lb.inputs.map((i) => ({ value: i.이름, text: i.이름 })); + return selectBox(ctx, options, slot.name, (v) => put({ kind: "input", name: v }), tx("S1")); + } + return chip(tx(slot.kind === "table" ? "SlotTable" : "SlotCalc")); +} + +function shapeView(ctx: Ctx, shape: CondShape, put: (s: CondShape) => void): HTMLElement { + if (shape.type === "pick") { + const items: (HTMLElement | string)[] = []; + shape.items.forEach((s, i) => { + if (i) items.push(tx("And")); + items.push( + slotBox(ctx, s, (v) => + put({ ...shape, items: shape.items.map((x, j) => (j === i ? v : x)) }), + ), + ); + }); + const fn = [ + { value: "최소", text: tx("Small") }, + { value: "최대", text: tx("Big") }, + ]; + return sentence( + ...items, + selectBox(ctx, fn, shape.fn, (v) => put({ ...shape, fn: v as "최소" | "최대" }), tx("S3")), + ); + } + if (shape.type === "round") { + const fns = [ + { value: "올림", text: tx("Up") }, + { value: "버림", text: tx("Down") }, + { value: "반올림", text: tx("Half") }, + ]; + return sentence( + slotBox(ctx, shape.value, (v) => put({ ...shape, value: v })), + tx("RoundA"), + numBox(ctx, shape.places, (n) => put({ ...shape, places: n, hadPlaces: true })), + tx("RoundB"), + selectBox( + ctx, + fns, + shape.fn, + (v) => put({ ...shape, fn: v as "올림" | "버림" | "반올림" }), + tx("S3"), + ), + ); + } + const ops = OPS.map(([value, key]) => ({ value, text: tx(key) })); + const rows = shape.branches.map((b, i) => { + const edit = (patch: Partial): void => + put({ ...shape, branches: shape.branches.map((x, j) => (j === i ? { ...x, ...patch } : x)) }); + return el("li", { + className: "m01-lbsteps-branch", + children: [ + sentence( + span("m01-lbsteps-order", String(i + 1)), + slotBox(ctx, b.test.left, (v) => edit({ test: { ...b.test, left: v } })), + tx("Subject"), + slotBox(ctx, b.test.right, (v) => edit({ test: { ...b.test, right: v } })), + selectBox( + ctx, + ops, + b.test.op, + (v) => edit({ test: { ...b.test, op: v as CondOp } }), + tx("S3"), + ), + "→", + slotBox(ctx, b.value, (v) => edit({ value: v })), + ), + ], + }); + }); + const last = el("li", { + className: "m01-lbsteps-branch", + children: [ + sentence( + tx("Otherwise"), + slotBox(ctx, shape.otherwise, (v) => put({ ...shape, otherwise: v })), + ), + ], + }); + return el("div", { + className: "m01-lbsteps-shape", + children: [ + note(tx("Cond1")), + el("ol", { className: "m01-lbsteps-branches", children: [...rows, last] }), + ], + }); +} + +function placeText(ctx: Ctx, calc: LbCalc): string { + if (calc.name !== null) return calc.name; + const { part, index } = calc.at; + if (part === "결과") return tx("PlaceResult"); + const card = ctx.lb.doc.cards.find( + (c) => c.source?.index === index && c.source.part === (part === "수량" ? "호표" : "덧줄"), + ); + return `${card?.name ?? ""} ${tx(part === "수량" ? "PlaceQty" : "PlaceRate")}`.trim(); +} + +export function calcBody(ctx: Ctx, calc: LbCalc): HTMLElement { + const put = (patch: Partial): void => ctx.commit(() => setCalc(ctx.lb, calc, patch)); + const refAt = (i: number): HTMLElement => + refBox(ctx, calc.parts[i], calc, (r) => + put({ parts: calc.parts.map((p, j) => (j === i ? r : p)) }), + ); + if (calc.cond && calc.cond.blocks.length) { + const read: CondRead = calc.cond; + return el("div", { + className: "m01-lbsteps-shapes", + children: read.blocks.map((b, bi) => + shapeView(ctx, b.shape, (shape) => + put({ + cond: { ...read, blocks: read.blocks.map((x, j) => (j === bi ? { ...x, shape } : x)) }, + }), + ), + ), + }); + } + if (calc.kind === "값" && calc.parts.length) return sentence(refAt(0)); + if (calc.kind === "할증" && calc.parts.length >= 2) { + return sentence(refAt(0), tx("Mark1"), refAt(1), tx("Mark2")); + } + if ((calc.kind === "곱나눔" || calc.kind === "더하기") && calc.parts.length) { + const ops = calc.kind === "곱나눔" ? ["*", "/"] : ["+", "-"]; + const glyph = (o: string): string => + tx(o === "*" ? "Mul" : o === "/" ? "Div" : o === "+" ? "Plus" : "Minus"); + const out: (HTMLElement | string)[] = [refAt(0)]; + calc.parts.slice(1).forEach((_, i) => { + const op = calc.ops?.[i] ?? ops[0]; + const choices = ops.map((o) => ({ value: o, text: glyph(o) })); + out.push( + selectBox( + ctx, + choices, + op, + (v) => + put({ + ops: calc.parts + .slice(1) + .map((_, j) => (j === i ? v : (calc.ops?.[j] ?? ops[0])) as "*" | "/" | "+" | "-"), + }), + tx("S3"), + ), + refAt(i + 1), + ); + }); + return sentence(...out); + } + return sentence(note(tx("Steps"))); +} + +export function calcView(ctx: Ctx, calc: LbCalc, n: number): HTMLElement { + return el("li", { + className: "m01-lbsteps-row", + children: [ + sentence( + span("m01-lbsteps-name", `${tx("Calc")}${n}`), + chip(placeText(ctx, calc)), + valueNote(middleOf(ctx, calc.name)), + ), + calcBody(ctx, calc), + ], + }); +} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Steps_Cell.ts b/M01_MasterData/LogicBuilder/M01_LB_Steps_Cell.ts new file mode 100644 index 000000000..35a73f203 --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Steps_Cell.ts @@ -0,0 +1,287 @@ +/* ============================================================================= + * M01_LB_Steps_Cell.ts + * 로직 만들기 판 문장 보기 공용 — 글 [한, 영] 짝 · 작은 칸(숫자 · 글 · 고르기) · 값이 오는 곳 고르기 + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { el } from "@ui/ui_template_elements"; +import type { CalcAnswer, CalcLine } from "../M01_MasterData_UI_Logic_Api"; +import { formatNumber } from "../M01_MasterData_UI_Logic_Edit"; +import type { LbCalc, LbExpr, LbLogic, LbLookup, LbRef } from "./M01_LB_Blocks"; + +export const TEXT = { + Blocked: ["이 화면에서 읽지 못하는 줄이 있음", "Some lines cannot be read on this screen"], + Cannot: ["이 고침은 지금 할 수 없음", "This change cannot be made now"], + BaseNote: ["표준 로직은 읽기만 — 복사해서 고침", "Standard logic is read-only — copy to edit"], + S1: ["① 입력값", "① Input values"], + S2: ["② 표 조회", "② Table lookup"], + S3: ["③ 계산", "③ Calculation"], + S4: ["④ 항목 연결", "④ Items"], + S5: ["⑤ 결과값", "⑤ Result"], + None: ["없음", "none"], + PickList: ["선택 목록", "choose from"], + Number: ["숫자", "number"], + Range: ["범위", "range"], + Unit: ["단위", "unit"], + Eg: ["예: 3", "e.g. 3"], + NotNumber: ["숫자만 넣음", "Numbers only"], + LkFrom: [" 에서 ", " : "], + LkIs: [" 이 ", " is "], + LkRow: [" 인 줄의 ", " in the row, take "], + LkNum: ["표 조회 ", "Lookup "], + SrcInput: ["입력값 · ", "input · "], + SrcCalc: ["계산 · ", "calc · "], + SrcFixed: ["고정값", "fixed value"], + SrcAny: ["무관(조건에서 뺌)", "any (remove condition)"], + Other: ["다른 일위대가", "Other unit price"], + Hard: ["이 화면에서 못 고치는 값", "Value not editable here"], + Mul: ["×", "×"], + Div: ["÷", "÷"], + Plus: ["+", "+"], + Minus: ["−", "−"], + Mark1: ["에 ", ": add "], + Mark2: [" % 를 더 얹음", " % on top"], + Steps: ["여러 단계로 된 계산 — 읽기만", "Multi-step calculation — read only"], + Cond1: ["위에서 처음 맞는 줄의 값을 씀", "The first matching line from the top is used"], + Subject: ["가", "is"], + Otherwise: ["그 외 →", "Otherwise →"], + Big: ["가운데 큰 값", "the larger"], + Small: ["가운데 작은 값", "the smaller"], + And: ["와", "and"], + RoundA: ["을 소수 ", "round to "], + RoundB: [" 자리까지 ", " decimals, "], + Up: ["올림", "round up"], + Down: ["버림", "round down"], + Half: ["반올림", "round off"], + OpEq: ["이면", "equal to"], + OpNe: ["이 아니면", "not equal to"], + OpGt: ["보다 크면", "greater than"], + OpGe: ["이상이면", "at least"], + OpLt: ["보다 작으면", "less than"], + OpLe: ["이하이면", "at most"], + SlotTable: ["표에서 찾은 값", "Value from table"], + SlotCalc: ["계산한 값", "Calculated value"], + PlaceQty: ["수량", "amount"], + PlaceRate: ["비율", "rate"], + PlaceResult: ["결과값", "result"], + Calc: ["계산 ", "Calc "], + Tag_person: ["노무", "Labor"], + Tag_material: ["재료", "Material"], + Tag_machine: ["장비", "Equipment"], + Tag_logic: ["일위대가", "Unit price"], + Tag_percent: ["비율", "Rate"], + Tag_locked: ["기타", "Other"], + Pre_person: [" 이 ", " works "], + Pre_pick: [" 를 ", " uses "], + Pre_percent: [" 의 ", " of "], + Post_percent: [" % ", " % "], + Repick: ["다른 것으로 다시 고르기", "Choose another"], + Remove: ["지움", "Remove"], + Pending: ["계산 중", "calculating"], + Won: ["원", "KRW"], + Add_person: ["+ 노무", "+ Labor"], + Add_material: ["+ 재료", "+ Material"], + Add_machine: ["+ 장비", "+ Equipment"], + Add_logic: ["+ 일위대가", "+ Unit price"], + Add_percent: ["+ 비율", "+ Rate"], + Base_노무비: ["노무비", "labor cost"], + Base_재료비: ["재료비", "material cost"], + Base_경비: ["경비", "expenses"], + Base_사람줄합: ["노무 줄의 합", "sum of labor lines"], + ResultMoney: ["결과 = 금액", "Result = amount"], + ResultValue: [ + "결과 = 값 — 다른 일위대가가 불러 쓸 수 있음", + "Result = value — other unit prices can use it", + ], + Total: ["합계", "Total"], +} as const; + +export const tx = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex]; + +export type Ref = LbRef | LbExpr; + +export interface Ctx { + lb: LbLogic; + ans: CalcAnswer | null; + ro: boolean; + on: { change(lb: LbLogic): void; tableName?(table: string): string | null }; + lines: Map; + /** 바꾸기 — 못 하면 쉬운 말 한 줄 */ + commit(make: () => LbLogic): void; +} + +/* ------------------------------ 작은 칸 ------------------------------ */ + +export const span = (cls: string, text: string): HTMLSpanElement => + el("span", { className: cls, text }); +export const chip = (text: string, title?: string): HTMLSpanElement => { + const c = span("m01-lbsteps-chip", text); + if (title) c.title = title; + return c; +}; +export const note = (text: string): HTMLSpanElement => span("m01-lbsteps-note", text); +export const sentence = (...parts: (HTMLElement | string)[]): HTMLElement => + el("div", { className: "m01-lbsteps-sentence", children: parts }); + +export const parseNum = (text: string): number | null => { + const t = text.replace(/[,\s]/g, ""); + return /^[+-]?(\d+\.?\d*|\.\d+)$/.test(t) ? Number(t) : null; +}; + +export function numBox(ctx: Ctx, value: number | null, commit: (n: number) => void): HTMLElement { + if (ctx.ro) return chip(value === null ? "" : String(value)); + const input = el("input", { + className: "ui-input m01-lbsteps-num", + attrs: { type: "text", inputmode: "decimal", placeholder: tx("Eg") }, + }); + input.value = value === null ? "" : String(value); + input.addEventListener("change", () => { + const n = parseNum(input.value); + input.classList.toggle("is-bad", n === null); + input.title = n === null ? tx("NotNumber") : ""; + if (n !== null && n !== value) commit(n); + }); + return input; +} + +export function textBox(ctx: Ctx, value: string, commit: (v: string) => void): HTMLElement { + if (ctx.ro) return chip(value); + const input = el("input", { className: "ui-input m01-lbsteps-text", attrs: { type: "text" } }); + input.value = value; + input.addEventListener("change", () => input.value !== value && commit(input.value.trim())); + return input; +} + +export function selectBox( + ctx: Ctx, + options: { value: string; text: string }[], + current: string, + commit: (v: string) => void, + aria: string, +): HTMLElement { + const hit = options.find((o) => o.value === current); + if (ctx.ro) return chip(hit?.text ?? ""); + const select = el("select", { + className: "ui-select m01-lbsteps-select", + attrs: { "aria-label": aria }, + }); + for (const o of options) select.append(el("option", { attrs: { value: o.value }, text: o.text })); + select.value = hit ? current : (options[0]?.value ?? ""); + select.addEventListener("change", () => commit(select.value)); + return select; +} + +export const show = (v: unknown): string => (v === undefined || v === null ? "" : formatNumber(v)); +export const valueNote = (v: unknown): HTMLElement | string => + v === undefined || v === null ? "" : span("m01-lbsteps-value", `= ${show(v)}`); + +/* ------------------------------ 값 찾기 ------------------------------ */ + +export const middleOf = (ctx: Ctx, name: string | null): unknown => + name !== null && ctx.ans?.ok ? ctx.ans.middle[name] : undefined; + +/** 표 조회 값 — 그 값 하나만 이름 붙은 계산이 되어 있을 때만 앎 */ +export function lookupValue(ctx: Ctx, lk: LbLookup): unknown { + const calc = ctx.lb.calcs.find( + (c) => + c.kind === "값" && + c.name !== null && + c.parts.length === 1 && + isRef(c.parts[0], "lookup", lk.id), + ); + return middleOf(ctx, calc?.name ?? null); +} + +export const isRef = (r: Ref, k: "lookup", id: string): boolean => r.k === k && r.id === id; + +export const lookupNo = (ctx: Ctx, id: string): number => + ctx.lb.lookups.findIndex((l) => l.id === id) + 1; + +/** 로직 이름 — 목록을 한 번 받아 채움(키 → 이름) · 못 받으면 「다른 일위대가」 */ +export const logicNames = new Map(); + +export function refText(ctx: Ctx, r: Ref): string { + switch (r.k) { + case "input": + return r.name; + case "fixed": + return String(r.value); + case "calc": + return r.name; + case "lookup": { + const lk = ctx.lb.lookups.find((l) => l.id === r.id); + return `${tx("LkNum")}${lookupNo(ctx, r.id)}${lk ? ` · ${lk.col}` : ""}`; + } + case "logic": + return `${logicNames.get(r.key) ?? tx("Other")}${r.field ? ` · ${r.field}` : ""}`; + case "cost": + return tx(`Base_${r.name}`); + default: + return tx("Hard"); + } +} + +export function refValue(ctx: Ctx, r: Ref): unknown { + if (r.k === "calc") return middleOf(ctx, r.name); + if (r.k === "fixed") return r.value; + if (r.k === "lookup") { + const lk = ctx.lb.lookups.find((l) => l.id === r.id); + return lk ? lookupValue(ctx, lk) : undefined; + } + return undefined; +} + +/** 값이 오는 곳 고르기 — 입력값 · 표 조회 · 다른 계산 · 고정값 */ +export function refBox( + ctx: Ctx, + ref: Ref, + self: LbCalc | null, + pick: (r: LbRef) => void, +): HTMLElement { + const label = refText(ctx, ref); + const editable = ref.k === "input" || ref.k === "calc" || ref.k === "lookup" || ref.k === "fixed"; + if (ctx.ro || !editable) { + return el("span", { + className: "m01-lbsteps-ref", + children: [chip(label), valueNote(refValue(ctx, ref))], + }); + } + const options = [ + ...ctx.lb.inputs.map((i) => ({ value: `i:${i.이름}`, text: `${tx("SrcInput")}${i.이름}` })), + ...ctx.lb.lookups.map((l) => ({ + value: `l:${l.id}`, + text: refText(ctx, { k: "lookup", id: l.id }), + })), + ...ctx.lb.calcs + .filter((c) => c.name !== null && c !== self) + .map((c) => ({ value: `c:${c.name}`, text: `${tx("SrcCalc")}${c.name}` })), + { value: "f", text: tx("SrcFixed") }, + ]; + const current = + ref.k === "input" + ? `i:${ref.name}` + : ref.k === "calc" + ? `c:${ref.name}` + : ref.k === "lookup" + ? `l:${ref.id}` + : "f"; + const toRef = (v: string): LbRef => + v === "f" + ? { k: "fixed", value: 0 } + : v.startsWith("i:") + ? { k: "input", name: v.slice(2) } + : v.startsWith("l:") + ? { k: "lookup", id: v.slice(2) } + : { k: "calc", name: v.slice(2) }; + const parts: (HTMLElement | string)[] = [ + selectBox(ctx, options, current, (v) => pick(toRef(v)), tx("S3")), + ]; + if (ref.k === "fixed") { + parts.push( + numBox(ctx, typeof ref.value === "number" ? ref.value : null, (n) => + pick({ k: "fixed", value: n }), + ), + ); + } else parts.push(valueNote(refValue(ctx, ref))); + return el("span", { className: "m01-lbsteps-ref", children: parts }); +} diff --git a/M01_MasterData/SheetLogic/.gitignore b/M01_MasterData/SheetLogic/.gitignore new file mode 100644 index 000000000..c10c784e3 --- /dev/null +++ b/M01_MasterData/SheetLogic/.gitignore @@ -0,0 +1 @@ +_data/ diff --git a/M01_MasterData/SheetLogic/M01_SL_Api.ts b/M01_MasterData/SheetLogic/M01_SL_Api.ts new file mode 100644 index 000000000..26b5b84df --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Api.ts @@ -0,0 +1,108 @@ +/* ============================================================================= + * M01_SL_Api.ts + * 시트 로직 Api — 목록 · 열기 · 저장(서버가 바깥 값을 다시 넣고 같은 엔진으로 다시 셈) · 지우기 · + * 바깥 값 한 번에 받기(품셈 표 · 노임 · 자재 · 장비 · 기존 로직 결과) + * 서버 = `M01_SL_Router.py`(`/api/m01/sl/*`) · 정함 `docs/raw/M01_쉬운단가/시트로직_정함.md` 4 · 5 · 6 + * ========================================================================== */ + +import type { Workbook } from "../../A00_Common/spreadsheet/spreadsheet_types"; + +/** 바깥 값 하나 — 값은 열 때 · 저장 때 마스터에서 새로(표는 사본으로 들고 있지 않음) + * · 표 = 품셈 표 통째(머리 줄 + 줄 · 범위 조건 · 값은 「이름·아래」 「이름·위」 두 칸) + * · 노임 · 자재 = 그 줄의 단가(`/calc` 와 같은 셈 · 준용 · 낮은 값) + * · 장비 = 기계경비 로직 부름 글(EasyCalc `machineElement` 의 element) + 넘길 입력값 → 한 시간 값 + * · 로직 = 기존 로직 결과(`/calc` · 돈 로직은 `field` 비목 · 기본 계) */ +export type SlOutside = + | { id: string; kind: "표"; table: string } + | { id: string; kind: "노임"; key: string } + | { id: string; kind: "자재"; key: string } + | { + id: string; + kind: "장비"; + element: string; + inputs: Record; + field?: SlField; + } + | { + id: string; + kind: "로직"; + key: string; + inputs: Record; + field?: SlField; + }; +export type SlField = "노무비" | "재료비" | "경비" | "계"; + +/** 바깥 값이 들어갈 자리 — 시트 id · 칸(표는 이 칸을 왼쪽 위로 펼침) */ +export interface SlOutsideAt { + item: SlOutside; + sheet: string; + cell: string; +} + +/** 바깥 값 답 — 표는 `rows`(머리 줄 포함) · 그 밖은 `value` · 못 구하면 ok false + 쉬운 말 */ +export interface SlOutsideValue { + id: string; + ok: boolean; + value?: number | null; + rows?: (string | number | null)[][]; + name?: string; + unit?: string; + reason?: string; +} + +/** 시트 로직 한 벌 = 통합문서 하나(상자 = 시트) + 바깥 값 자리 */ +export interface SlLogic { + /** 새 로직 null · 저장하면 서버가 `SL-0001` 꼴로 줌 */ + key: string | null; + 이름: string; + 결과단위: string; + book: Workbook; + outside: SlOutsideAt[]; + /** 다른 창이 더하는 칸(상자 · 알약 · 선 …)은 그대로 저장 · 돌려줌 */ + [extra: string]: unknown; +} + +/** 목록 한 줄 */ +export interface SlListItem { + key: string; + 이름: string; + 결과단위: string; + /** 마지막 저장 때(ISO) */ + saved: string; +} + +/** 저장 답 — `logic.book.계산값` 은 서버가 다시 셈한 값 · `values` 는 넣은 바깥 값 */ +export interface SlSaved { + key: string; + logic: SlLogic; + values: SlOutsideValue[]; +} + +const notReady = (): never => { + throw new Error("준비 중"); +}; + +export async function slList(): Promise { + return notReady(); +} + +export async function slGet(_key: string): Promise { + return notReady(); +} + +export async function slSave(_logic: SlLogic): Promise { + return notReady(); +} + +export async function slDelete(_key: string): Promise { + return notReady(); +} + +export async function slOutside(_items: SlOutside[]): Promise { + return notReady(); +} + +/** 실패 → 쉬운 말(EasyCalc `plainError` 그대로) */ +export function slPlain(_e: unknown): string { + return notReady(); +} diff --git a/M01_MasterData/SheetLogic/M01_SL_Eval.ts b/M01_MasterData/SheetLogic/M01_SL_Eval.ts new file mode 100644 index 000000000..00dec78e7 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Eval.ts @@ -0,0 +1,49 @@ +/* ============================================================================= + * M01_SL_Eval.ts + * 시트 로직 화면 계산 — 바깥 값 넣기 → 우리 스프레드시트 엔진(브라우저) → 출력 알약 · 결과 읽기 · 검사 + * 저장 때는 서버가 같은 엔진을 Node 로(정함 5) · DOM 없음 + * ========================================================================== */ + +import type { SheetLogic, SlOutside } from "./M01_SL_Model"; + +/** 알약 값 — 수 · 글 · 참거짓 · 빈 칸 */ +export type SlValue = number | string | boolean | null; + +/** 바깥 값 하나 — 표는 칸 덩어리(cell 이 왼위) */ +export interface SlOutsideValue { + node: string; + cell: string; + value: SlValue | SlValue[][]; +} + +export interface SlEvalResult { + /** 상자 id → 출력 알약 이름 → 값 */ + pills: Record>; + result: { 노무비: number | null; 재료비: number | null; 경비: number | null; 계: number | null }; + /** 쉬운 말 */ + errors: string[]; +} + +const notReady = (): never => { + throw new Error("준비 중"); +}; + +/** 바깥 값 칸 목록 — 화면 · 서버가 이 칸에 값을 넣음 */ +export function outsideCells(_sl: SheetLogic): SlOutside[] { + return notReady(); +} + +/** 바깥 값 넣기 — 새 시트 로직(받은 것은 안 바꿈) */ +export function applyOutside(_sl: SheetLogic, _values: SlOutsideValue[]): SheetLogic { + return notReady(); +} + +/** 화면 계산 */ +export function evalLogic(_sl: SheetLogic): SlEvalResult { + return notReady(); +} + +/** 검사 — 빈 입력 알약 · 순환 · 오류 칸(쉬운 말 목록) */ +export function checkLogic(_sl: SheetLogic): string[] { + return notReady(); +} diff --git a/M01_MasterData/SheetLogic/M01_SL_Model.ts b/M01_MasterData/SheetLogic/M01_SL_Model.ts new file mode 100644 index 000000000..dc6d86f02 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Model.ts @@ -0,0 +1,133 @@ +/* ============================================================================= + * M01_SL_Model.ts + * 시트 로직 Model — 로직 한 벌 = 통합문서 하나 · 상자 하나 = 시트 하나 · 알약 = 이름 붙은 칸 + * 선 = 받는 입력 알약 칸에 「'시트 이름'!칸」 참조 수식 · DOM 없음 + * 모양은 `docs/raw/M01_쉬운단가/시트로직_정함.md` 정함 1~5 그대로 + * ========================================================================== */ + +import type { Workbook } from "../../A00_Common/spreadsheet/spreadsheet_types"; + +/** 상자 종류 — 입력값 · 고정값 · 표 · 계산 · 항목 · 다른 로직 · 결과 */ +export type SlKind = "input" | "const" | "table" | "calc" | "item" | "logic" | "result"; + +/** 알약 = 이름 붙은 칸 */ +export interface SlPill { + name: string; + /** 「B3」 */ + cell: string; + unit?: string; + /** 바깥 값 칸 — 열 때 · 저장 때 마스터에서 넣음 */ + outside?: boolean; +} + +/** 상자 = 시트 하나 */ +export interface SlNode { + id: string; + kind: SlKind; + name: string; + sheetId: string; + pillsIn: SlPill[]; + pillsOut: SlPill[]; + /** 표 키 · 요소 · 기존 로직 키 · 내 표 id */ + ref?: string; + /** 띠 단계 — 0 입력값 · 1 표 · 2 계산 · 3 항목 · 4 결과 */ + stage: number; +} + +/** 선 — 출력 알약 → 입력 알약 */ +export interface SlEdge { + id: string; + from: { node: string; pill: string }; + to: { node: string; pill: string }; +} + +/** 바깥 값 칸 */ +export interface SlOutside { + node: string; + cell: string; + kind: "table" | "price" | "logic"; + ref: string; +} + +/** 내 표 — 품셈에 없는 표(사용자가 만듦) */ +export interface SlUserTable { + id: string; + name: string; + cols: string[]; + rows: (number | string | null)[][]; +} + +export interface SheetLogic { + key: string | null; + name: string; + resultUnit: string; + workbook: Workbook; + nodes: SlNode[]; + edges: SlEdge[]; + userTables?: SlUserTable[]; +} + +/** 알약 한쪽 끝 */ +export interface SlEnd { + node: string; + pill: string; +} + +const notReady = (): never => { + throw new Error("준비 중"); +}; + +/** 빈 시트 로직 */ +export function newLogic(_name?: string, _resultUnit?: string): SheetLogic { + return notReady(); +} + +/** 상자 더하기 — 그 종류 틀(칸 A 이름 · B 값 · C 설명)로 시트를 만듦 */ +export function addNode( + _sl: SheetLogic, + _kind: SlKind, + _name: string, +): { sl: SheetLogic; node: SlNode } { + return notReady(); +} + +/** 상자 지우기 — 시트 · 이어진 선 · 받던 참조 수식을 함께 지움 */ +export function removeNode(_sl: SheetLogic, _node: string): SheetLogic { + return notReady(); +} + +/** 선 잇기 — 받는 알약 칸에 참조 수식 · 순환 · 없는 알약이면 막고 까닭(쉬운 말) */ +export function connect( + _sl: SheetLogic, + _from: SlEnd, + _to: SlEnd, +): { sl: SheetLogic; why: string | null } { + return notReady(); +} + +/** 선 끊기 — 그 참조를 받는 칸 수식에서 뺌 */ +export function disconnect(_sl: SheetLogic, _edge: string): SheetLogic { + return notReady(); +} + +/** 알약 정하기 — 같은 칸이나 같은 이름이 있으면 바꿈 */ +export function setPill( + _sl: SheetLogic, + _node: string, + _cell: string, + _dir: "in" | "out", + _name: string, + _extra?: Pick, +): SheetLogic { + return notReady(); +} + +/** 알약 빼기 — 이어진 선도 끊음 */ +export function removePill( + _sl: SheetLogic, + _node: string, + _dir: "in" | "out", + _name: string, +): SheetLogic { + return notReady(); +} diff --git a/resources/master_data/_키대장.json b/resources/master_data/_키대장.json index 1a86176db..bade8a4aa 100644 --- a/resources/master_data/_키대장.json +++ b/resources/master_data/_키대장.json @@ -1,5 +1,5 @@ { - "다음": {"LB": 409, "EQ": 655, "RT": 20, "MN": 7000, "MT": 30719, "MO": 74, "MP": 364, "FX": 6, "CF": 159, "QF": 477, "GF": 331, "CC": 341, "GC": 1140, "QC": 2060, "GX": 1, "UA": 38}, + "다음": {"LB": 409, "EQ": 655, "RT": 20, "MN": 7000, "MT": 30719, "MO": 74, "MP": 364, "FX": 6, "CF": 159, "QF": 477, "GF": 331, "CC": 341, "GC": 1140, "QC": 2060, "GX": 4, "UA": 38}, "폐기": {"MT000001": "자재품목 휘발유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000002": "자재품목 경유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000003": "자재품목 선박용경유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000004": "자재품목 중유 — 값이 비고 글뿐 · 유가 테이블로 감", "MT000005": "자재품목 등유 — 값이 비고 글뿐 · 유가 테이블로 감", "LB000399": "이름 「〃」 — 직종이 아니라 되풀이 기호 · 소요량 13-6-9 에서 윗줄 직종으로 바로잡음", "MN": "재료_나라장터자재 — old 로 옮김 · 로직은 자재품목·품셈재료로 갈아 끼움", "LB000372": "미확보 고급기능사 — 측량노임 항공사진고급기능사 옛이름으로 옮김", "LB000393": "미확보 리베팅공 — 건설노임 철골공 옛이름으로 옮김(2010 통합표 연번10)", "LB000405": "미확보 Belt Conveyor 설치공 — 품셈 13-10-1 주② 배분으로 다섯 직종에 나눔", "MT000006": "자재품목 전력 — 재료_유가전력 전력 줄로 감", "MT000007": "자재품목 전력 — 재료_유가전력 전력 줄로 감", "LB000368": "미확보 기술사 — 기술사(건설) 옛이름으로 옮김", "LB000369": "미확보 특급기술자 — 특급기술자(건설) 옛이름으로 옮김", "LB000370": "미확보 고급기술자 — 고급기술자(건설) 옛이름으로 옮김", "LB000361": "미확보 중급기술자 — 중급기술자(건설) 옛이름으로 옮김", "LB000364": "미확보 초급기술자 — 초급기술자(건설) 옛이름으로 옮김", "LB000365": "미확보 고급숙련기술자 — 고급숙련기술자(건설) 옛이름으로 옮김", "LB000366": "미확보 중급숙련기술자 — 중급숙련기술자(건설) 옛이름으로 옮김", "LB000367": "미확보 초급숙련기술자 — 초급숙련기술자(건설) 옛이름으로 옮김", "LB000389": "미확보 기계기사 — 초급기술자(기계·설비) 옛이름으로 옮김", "LB000388": "미확보 기계산업기사 — 초급숙련기술자(기계·설비) 옛이름으로 옮김", "MO000055": {"원문번호": "주택용저압:기본_200이하", "파일": "재료_유가전력.json"}, "MO000056": {"원문번호": "주택용저압:기본_201~400", "파일": "재료_유가전력.json"}, "MO000057": {"원문번호": "주택용저압:기본_400초과", "파일": "재료_유가전력.json"}, "MO000059": {"원문번호": "주택용저압:전력량_다음200", "파일": "재료_유가전력.json"}, "MO000060": {"원문번호": "주택용저압:전력량_400초과", "파일": "재료_유가전력.json"}, "MO000061": {"원문번호": "주택용저압:월간최저요금", "파일": "재료_유가전력.json"}, "MO000062": {"원문번호": "일반용갑I저압:기본", "파일": "재료_유가전력.json"}, "MO000063": {"원문번호": "일반용갑I저압:전력량_여름철", "파일": "재료_유가전력.json"}, "MO000064": {"원문번호": "일반용갑I저압:전력량_봄가을철", "파일": "재료_유가전력.json"}, "MO000065": {"원문번호": "일반용갑I저압:전력량_겨울철", "파일": "재료_유가전력.json"}, "MO000066": {"원문번호": "산업용갑I저압:기본", "파일": "재료_유가전력.json"}, "MO000067": {"원문번호": "산업용갑I저압:전력량_여름철", "파일": "재료_유가전력.json"}, "MO000068": {"원문번호": "산업용갑I저압:전력량_봄가을철", "파일": "재료_유가전력.json"}, "MO000069": {"원문번호": "산업용갑I저압:전력량_겨울철", "파일": "재료_유가전력.json"}, "MO000070": {"원문번호": "임시전력갑:기본요금적용", "파일": "재료_유가전력.json"}, "MO000071": {"원문번호": "임시전력갑:전력량요금적용", "파일": "재료_유가전력.json"}, "MO000072": {"원문번호": "임시전력갑:월간최저요금", "파일": "재료_유가전력.json"}}, "키": { "CC000001": {"원문번호": "공통 1-3-1 콘크리트 및 포장용 재료", "파일": "계수_건설품셈_01장_적용기준.json"}, @@ -2628,6 +2628,9 @@ "GF000328": {"원문번호": "13-11-1 원형 돌망태", "파일": "로직_산림품셈_13장_구조물.json"}, "GF000329": {"원문번호": "13-11-2 타원형 돌망태", "파일": "로직_산림품셈_13장_구조물.json"}, "GF000330": {"원문번호": "13-15-1 목재 껍질벗기기", "파일": "로직_산림품셈_13장_구조물.json"}, + "GX000001": {"원문번호": "13-4-1 돌쌓기 메쌓기(인력)", "파일": "로직_자체.json"}, + "GX000002": {"원문번호": "12-34-4 암거이음받침 채움재(T=20㎜)", "파일": "로직_자체.json"}, + "GX000003": {"원문번호": "13-4-1 돌쌓기 메쌓기(인력) (2)", "파일": "로직_자체.json"}, "LB000001": {"원문번호": "1001", "파일": "인력.json"}, "LB000002": {"원문번호": "1002", "파일": "인력.json"}, "LB000003": {"원문번호": "1003", "파일": "인력.json"}, diff --git a/resources/master_data/로직_자체.json b/resources/master_data/로직_자체.json new file mode 100644 index 000000000..87528c301 --- /dev/null +++ b/resources/master_data/로직_자체.json @@ -0,0 +1,75 @@ +{ + "그룹": "로직", + "원문": "자체", + "판": "2026", + "줄": [ + { + "키": "GX000001", + "원문번호": "13-4-1", + "구분": "자체", + "상세구분": "13장 구조물", + "이름": "돌쌓기 메쌓기(인력)", + "결과단위": "원/㎡", + "출처": "산림품셈 13-4-1", + "소유": "현장", + "비고": "고르기 첫 값 = 원문에 있는 조합(뒷길이 25 · 깬잡석 · 골쌓기) · 「원문에 없는 조합: 뒷길이=25 · 돌=견치돌 · 쌓기=골쌓기 / 뒷길이=25 · 돌=견치돌 · 쌓기=켜쌓기 / 뒷길이=25 · 돌=깬돌 · 쌓기=골쌓기 / 뒷길이=25 · 돌=깬돌 · 쌓기=켜쌓기 / 뒷길이=30 · 돌=견치돌 · 쌓기=골쌓기 외 5」 · GF000219 를 본뜸", + "입력": [ + { "이름": "뒷길이", "설명": "쌓을 돌의 뒤쪽 길이", "단위": "㎝", "고르기": [25, 30, 35, 45, 55, 60, 75] }, + { "이름": "돌", "설명": "쌓을 돌 종류", "고르기": ["깬잡석", "견치돌", "깬돌", "호박돌 및 야면석"] }, + { "이름": "쌓기", "설명": "돌을 어긋나게 쌓는지 줄 맞춰 쌓는지", "고르기": ["골쌓기", "켜쌓기"] }, + { "이름": "높이", "설명": "쌓는 벽 높이", "단위": "m" }, + { "이름": "초과증가율", "설명": "기준 높이를 넘어 더 드는 품 비율", "단위": "%", "범위": [80, 100] } + ], + "중간": [{ "이름": "증가율", "식": "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=높이).증가율, 초과증가율))", "출처": "산림품셈 13-4-1 주③" }], + "호표": [ + { "종류": "인력", "요소": "LB000033", "이름": "석공", "단위": "인", "수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 증가율 / 100)", "비목": "노무비" }, + { "종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)", "비목": "노무비" } + ], + "덧줄": [], + "끝수": null, + "시험입력": { "높이": 4.0, "돌": "깬잡석" } + }, + { + "키": "GX000002", + "원문번호": "12-34-4", + "구분": "자체", + "상세구분": "12장 철근콘크리트", + "이름": "암거이음받침 채움재(T=20㎜)", + "결과단위": "원/㎡", + "출처": "산림품셈 12-34-4", + "소유": "현장", + "비고": "해석: 원문 재료비 「JOINT FILLER」 뿐 — ㎡당 1㎡ · 설치 품 원문 없음 · GF000208 를 본뜸", + "입력": [], + "중간": [], + "호표": [{ "종류": "재료", "요소": { "구분": "도료·방수·접착", "상세구분": "방수·실링", "검색어": "JOINT FILLER" }, "이름": "JOINT FILLER(T=20㎜)", "단위": "㎡", "수량": "1", "비목": "재료비" }], + "덧줄": [], + "끝수": null + }, + { + "키": "GX000003", + "원문번호": "13-4-1", + "구분": "자체", + "상세구분": "13장 구조물", + "이름": "돌쌓기 메쌓기(인력)", + "결과단위": "원/㎡", + "출처": "산림품셈 13-4-1", + "소유": "현장", + "비고": "고르기 첫 값 = 원문에 있는 조합(뒷길이 25 · 깬잡석 · 골쌓기) · 「원문에 없는 조합: 뒷길이=25 · 돌=견치돌 · 쌓기=골쌓기 / 뒷길이=25 · 돌=견치돌 · 쌓기=켜쌓기 / 뒷길이=25 · 돌=깬돌 · 쌓기=골쌓기 / 뒷길이=25 · 돌=깬돌 · 쌓기=켜쌓기 / 뒷길이=30 · 돌=견치돌 · 쌓기=골쌓기 외 5」 · GF000219 를 본뜸", + "입력": [ + { "이름": "뒷길이", "설명": "쌓을 돌의 뒤쪽 길이", "단위": "㎝", "고르기": [25, 30, 35, 45, 55, 60, 75] }, + { "이름": "돌", "설명": "쌓을 돌 종류", "고르기": ["깬잡석", "견치돌", "깬돌", "호박돌 및 야면석"] }, + { "이름": "쌓기", "설명": "돌을 어긋나게 쌓는지 줄 맞춰 쌓는지", "고르기": ["골쌓기", "켜쌓기"] }, + { "이름": "높이", "설명": "쌓는 벽 높이", "단위": "m" }, + { "이름": "초과증가율", "설명": "기준 높이를 넘어 더 드는 품 비율", "단위": "%", "범위": [80, 100] } + ], + "중간": [{ "이름": "증가율", "식": "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=높이).증가율, 초과증가율))", "출처": "산림품셈 13-4-1 주③" }], + "호표": [ + { "종류": "인력", "요소": "LB000033", "이름": "석공", "단위": "인", "수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 증가율 / 100)", "비목": "노무비" }, + { "종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)", "비목": "노무비" } + ], + "덧줄": [], + "끝수": null, + "시험입력": { "높이": 4.0, "돌": "깬잡석" } + } + ] +} diff --git a/resources/tester/test_m01_lb_check.py b/resources/tester/test_m01_lb_check.py new file mode 100644 index 000000000..a8fbeb80d --- /dev/null +++ b/resources/tester/test_m01_lb_check.py @@ -0,0 +1,382 @@ +"""로직 만들기 판 — 검사(`M01_LB_Check.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" / "LogicBuilder" / "M01_LB_Check.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")); +Promise.all( + jobs.map(async (job) => { + if (job.op === "gaps") return m.gaps(job.lb, job.tables); + if (job.op === "skipped") return m.gapSkipped(job.lb, job.tables); + if (job.op === "hits") return m.lookupHits(job.lb, job.tables, job.values); + if (job.op === "find") return m.findRows(job.table, job.conds, job.want ?? ""); + if (job.op === "same") return m.sameAsExpect(job.got, job.expect); + if (job.op === "expect") { + const answers = job.answers; + let i = 0; + return m.runExpect({}, {}, job.rows, async () => answers[i++]); + } + return null; + }), +).then((out) => 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) + + +# 견본 표 ① — 돌쌓기 꼴: 뒷길이 × 돌 × 쌓기 · 「호박돌」 줄은 쌓기 칸이 비어 무관 +STONE = { + "키": "T1", + "조건": {"뒷길이": "수", "돌": "고르기", "쌓기": "고르기"}, + "값칸": {"석공": "인"}, + "줄": [ + {"뒷길이": 25, "돌": "깬잡석", "쌓기": "골쌓기", "석공": 0.15}, + {"뒷길이": 25, "돌": "깬잡석", "쌓기": "켜쌓기", "석공": 0.13}, + {"뒷길이": 25, "돌": "호박돌", "석공": 0.10}, + {"뒷길이": 30, "돌": "깬잡석", "쌓기": "골쌓기", "석공": 0.22}, + {"뒷길이": 30, "돌": "깬잡석", "쌓기": "골쌓기", "석공": 0.23}, # 같은 조합 두 줄 = 중복 + ], +} + +# 견본 표 ② — 범위 줄(이상·미만 · 마지막 칸 이하 포함) +HEIGHT = { + "키": "T2", + "범위규칙": "이상·미만 · 마지막 칸(4~6)은 이하 포함", + "조건": {"높이": "범위"}, + "값칸": {"증가율": "%"}, + "줄": [ + {"높이": [None, 2], "증가율": 0}, + {"높이": [2, 4], "증가율": 30}, + {"높이": [4, 6], "증가율": 40}, + ], +} + +# 견본 표 ③ — 범위 줄(이하 · 위 끝만 · 여러 줄이면 위 끝 가장 작은 줄) +UPTO = { + "키": "T3", + "범위규칙": "이하", + "조건": {"거리": "범위"}, + "값칸": {"값": ""}, + "줄": [{"거리": [None, 10], "값": 1}, {"거리": [None, 20], "값": 2}], +} + +TABLES = {"T1": STONE, "T2": HEIGHT, "T3": UPTO} + + +def lk(id_, table, conds, col): + return {"id": id_, "table": table, "conds": conds, "col": col} + + +LOGIC = { + "inputs": [ + {"이름": "뒷길이", "고르기": [25, 30]}, + {"이름": "돌", "고르기": ["깬잡석", "호박돌"]}, + {"이름": "쌓기", "고르기": ["골쌓기", "켜쌓기"]}, + {"이름": "높이", "고르기": [1, 3, 6, 7]}, + {"이름": "길이"}, # 숫자 입력 — 선택 목록 없음 + ], + "lookups": [ + lk( + "a", + "T1", + [ + {"col": "뒷길이", "from": {"k": "input", "name": "뒷길이"}}, + {"col": "돌", "from": {"k": "input", "name": "돌"}}, + {"col": "쌓기", "from": {"k": "input", "name": "쌓기"}}, + ], + "석공", + ), + lk("b", "T2", [{"col": "높이", "from": {"k": "input", "name": "높이"}}], "증가율"), + lk("c", "T3", [{"col": "거리", "from": {"k": "input", "name": "길이"}}], "값"), + ], +} + + +def by(gaps, lookup): + return sorted( + ( + (g["kind"], json.dumps(g["conds"], ensure_ascii=False)) + for g in gaps + if g["lookup"] == lookup + ) + ) + + +def test_빈틈_중복_무관_빈칸(): + [gaps] = run([{"op": "gaps", "lb": LOGIC, "tables": TABLES}]) + got = by(gaps, "a") + # 뒷길이 2 × 돌 2 × 쌓기 2 = 8 조합 + # 무관 빈칸 — 25 · 호박돌 은 쌓기 둘 다 한 줄로 걸림(빈틈 아님) + # 빈틈 — 30 · 깬잡석 · 켜쌓기 · 30 · 호박돌 · 골/켜 + # 중복 — 30 · 깬잡석 · 골쌓기(두 줄) + assert got == sorted( + [ + ( + "없음", + json.dumps({"뒷길이": 30, "돌": "깬잡석", "쌓기": "켜쌓기"}, ensure_ascii=False), + ), + ( + "없음", + json.dumps({"뒷길이": 30, "돌": "호박돌", "쌓기": "골쌓기"}, ensure_ascii=False), + ), + ( + "없음", + json.dumps({"뒷길이": 30, "돌": "호박돌", "쌓기": "켜쌓기"}, ensure_ascii=False), + ), + ( + "중복", + json.dumps({"뒷길이": 30, "돌": "깬잡석", "쌓기": "골쌓기"}, ensure_ascii=False), + ), + ] + ) + dup = next(g for g in gaps if g["kind"] == "중복") + assert dup["rows"] == [3, 4] + + +def test_범위_줄_이상미만_마지막칸_이하포함(): + [gaps] = run([{"op": "gaps", "lb": LOGIC, "tables": TABLES}]) + # 높이 1 → [null,2) · 3 → [2,4) · 6 → 마지막 칸 이하 포함 · 7 → 없음 + assert by(gaps, "b") == [("없음", json.dumps({"높이": 7}, ensure_ascii=False))] + out = run( + [ + {"op": "find", "table": HEIGHT, "conds": {"높이": 2}}, + {"op": "find", "table": HEIGHT, "conds": {"높이": 4}}, + {"op": "find", "table": HEIGHT, "conds": {"높이": 6}}, + ] + ) + assert out == [[1], [2], [2]] + + +def test_범위_이하_위끝_가장_작은_줄(): + out = run( + [ + {"op": "find", "table": UPTO, "conds": {"거리": 5}}, + {"op": "find", "table": UPTO, "conds": {"거리": 15}}, + {"op": "find", "table": UPTO, "conds": {"거리": 25}}, + ] + ) + assert out == [[0], [1], []] + + +def test_셀_수_없는_조회는_건너뜀(): + gaps, skipped = run( + [ + {"op": "gaps", "lb": LOGIC, "tables": TABLES}, + {"op": "skipped", "lb": LOGIC, "tables": TABLES}, + ] + ) + assert by(gaps, "c") == [] + assert skipped == ["c"] + + +def test_예시값으로_걸린_줄(): + [hits] = run( + [ + { + "op": "hits", + "lb": LOGIC, + "tables": TABLES, + "values": {"뒷길이": 30, "돌": "깬잡석", "쌓기": "골쌓기", "높이": 3, "길이": 15}, + } + ] + ) + assert hits == {"a": "중복", "b": 1, "c": 1} + [hits] = run( + [ + { + "op": "hits", + "lb": LOGIC, + "tables": TABLES, + "values": {"뒷길이": 30, "돌": "호박돌", "쌓기": "골쌓기"}, + } + ] + ) + assert hits == {"a": "없음", "b": None, "c": None} # 높이 · 길이 예시값 없음 = 모름 + + +def test_고정값_조건(): + lb = { + "inputs": [], + "lookups": [ + lk( + "f", + "T1", + [ + {"col": "돌", "from": {"k": "fixed", "value": "호박돌"}}, + {"col": "뒷길이", "from": {"k": "fixed", "value": 25}}, + ], + "석공", + ), + lk("g", "T1", [{"col": "돌", "from": {"k": "calc", "name": "x"}}], "석공"), + ], + } + gaps, hits = run( + [ + {"op": "gaps", "lb": lb, "tables": TABLES}, + {"op": "hits", "lb": lb, "tables": TABLES, "values": {}}, + ] + ) + assert gaps == [] + assert hits == {"f": 2, "g": None} + + +def test_기대값_시험표(): + answers = [ + { + "ok": True, + "lines": [{"이름": "석공", "수량": 0.26, "금액": 52437.06}], + "sums": {"계": 52437.06}, + "middle": {}, + }, + { + "ok": True, + "lines": [{"이름": "석공", "수량": 0.25, "금액": 50000}], + "sums": {"계": 50000}, + "middle": {}, + }, + {"ok": False, "reason": "표에 줄이 없음"}, + ] + rows = [ + {"inputs": {"뒷길이": 30}, "expect": {"석공": 0.26, "계": 52437}}, + {"inputs": {"뒷길이": 35}, "expect": {"석공": 0.26}}, + {"inputs": {"뒷길이": 99}, "expect": {"계": 1}}, + ] + [res] = run([{"op": "expect", "rows": rows, "answers": answers}]) + assert [r["ok"] for r in res] == [True, False, False] + assert res[0]["got"]["석공 금액"] == 52437.06 + assert res[2]["why"] == "표에 줄이 없음" + same = run( + [ + {"op": "same", "got": 52437.4, "expect": 52437}, + {"op": "same", "got": 52437.6, "expect": 52437}, + {"op": "same", "got": 0.264, "expect": 0.26}, + ] + ) + assert same == [True, False, True] + + +SOURCE_UI = ROOT / "M01_MasterData" / "LogicBuilder" / "M01_LB_Source.ts" + +RUNNER_UI = 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 === "parse") return m.parseCell(job.text, job.before); + if (job.op === "notes") return [...m.usedNotes(job.row)].map(([k, v]) => [k, [...v]]); + if (job.op === "role") return m.roleOf(job.lookups, job.col); + return null; +}); +process.stdout.write(JSON.stringify(out)); +""" + + +def run_ui(jobs: list[dict]) -> list: + if shutil.which("node") is None: + pytest.skip("node 가 없음") + done = subprocess.run( + ["node", "-e", RUNNER_UI, str(ROOT), str(SOURCE_UI)], + input=json.dumps(jobs, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(done.stdout) + + +def test_원문_칸_고친_글_읽기(): + out = run_ui( + [ + {"op": "parse", "text": "0.3", "before": 0.26}, + {"op": "parse", "text": "1,200", "before": 1000}, + {"op": "parse", "text": "abc", "before": 0.26}, # 수 칸에 글 — 못 읽음 + {"op": "parse", "text": "3~4", "before": [2, 3]}, + {"op": "parse", "text": "~5", "before": [None, 4]}, + {"op": "parse", "text": "7~", "before": [6, None]}, + {"op": "parse", "text": "5~3", "before": [3, 5]}, # 거꾸로 — 못 읽음 + {"op": "parse", "text": "깬돌", "before": "깬잡석"}, + {"op": "parse", "text": "25", "before": "25"}, # 글 칸은 글 그대로 + {"op": "parse", "text": "", "before": 1}, + ] + ) + assert out == [0.3, 1200, None, [3, 4], [None, 5], [7, None], None, "깬돌", "25", None] + + +def test_계산에_쓰인_주와_칸_역할(): + [notes] = run_ui( + [ + { + "op": "notes", + "row": { + "중간": [{"이름": "증가율", "출처": "산림품셈 13-4-1 주③"}], + "비고": "주① 참고", + }, + } + ] + ) + assert sorted(notes) == [["*", ["①"]], ["13-4-1", ["③"]]] + lookups = [ + { + "id": "a", + "table": "T1", + "col": "석공", + "conds": [ + {"col": "뒷길이", "from": {"k": "input", "name": "뒷길이"}}, + {"col": "돌", "from": {"k": "fixed", "value": "깬잡석"}}, + ], + } + ] + out = run_ui( + [ + {"op": "role", "lookups": lookups, "col": "석공"}, + {"op": "role", "lookups": lookups, "col": "뒷길이"}, + {"op": "role", "lookups": lookups, "col": "돌"}, + {"op": "role", "lookups": lookups, "col": "쌓기"}, + ] + ) + assert out == ["결과", "조건", "고정", None]