diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Cards.css b/M01_MasterData/EasyCalc/M01_EasyCalc_Cards.css new file mode 100644 index 000000000..2d5692659 --- /dev/null +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Cards.css @@ -0,0 +1,192 @@ +.m01-easycards { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + font-size: var(--text-body); + color: var(--color-text-body); +} + +.m01-easycards-group { + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface); +} + +.m01-easycards-group__head, +.m01-easycards-group--empty { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--spacing-8); + min-height: 44px; + padding: var(--spacing-8) var(--spacing-16); +} + +.m01-easycards-group__head { + cursor: pointer; +} + +.m01-easycards-group--empty { + border-style: dashed; + color: var(--color-text-muted); +} + +.m01-easycards-group__title { + font-weight: var(--font-weight-bold); +} + +.m01-easycards-group__sum { + font-weight: var(--font-weight-medium); +} + +.m01-easycards-list { + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + list-style: none; +} + +.m01-easycards-card { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding: var(--spacing-8) var(--spacing-16); + border-top: 1px solid var(--color-border); +} + +.m01-easycards-card--locked { + border-left: 4px dashed var(--color-border); + background: var(--color-surface-raised); +} + +.m01-easycards-line { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8) var(--spacing-16); +} + +.m01-easycards-sentence { + display: flex; + flex: 1 1 24em; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4); + min-width: 0; +} + +.m01-easycards-tag { + font-weight: var(--font-weight-bold); +} + +.m01-easycards-name { + font-weight: var(--font-weight-medium); +} + +.m01-easycards-name--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; + cursor: pointer; +} + +.m01-easycards-name--pick:focus-visible { + outline: 2px solid var(--color-focus-ring); +} + +.m01-easycards .ui-input, +.m01-easycards .ui-select { + min-height: 44px; + font-size: var(--text-body); +} + +.m01-easycards-qty { + flex: none; + width: 7em; +} + +.m01-easycards-text { + flex: none; + width: 10em; +} + +.m01-easycards-select { + flex: none; + width: auto; +} + +.m01-easycards-auto, +.m01-easycards-why { + color: var(--color-text-secondary); +} + +.m01-easycards-amount { + margin-left: auto; + font-weight: var(--font-weight-bold); + white-space: nowrap; +} + +.m01-easycards-amount--wait { + opacity: 0.5; + font-weight: var(--font-weight-regular); +} + +.m01-easycards-tools { + display: flex; + gap: var(--spacing-4); +} + +.m01-easycards-tools .ui-btn, +.m01-easycards-locknote .ui-btn { + min-height: 44px; + font-size: var(--text-body); +} + +.m01-easycards-warn { + margin: 0; + padding: var(--spacing-4) var(--spacing-8); + border-left: 4px solid var(--color-warning); + font-weight: var(--font-weight-medium); +} + +.m01-easycards-locknote { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.m01-easycards-lock { + padding: 0 var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-pills); + font-size: var(--text-body-sm); + font-weight: var(--font-weight-bold); +} + +.m01-easycards-add { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} + +.m01-easycards-add .ui-btn { + min-height: 44px; + font-size: var(--text-body); +} + +@media (max-width: 640px) { + .m01-easycards-amount { + margin-left: 0; + } + + .m01-easycards-tools { + width: 100%; + } +} diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Cards.ts b/M01_MasterData/EasyCalc/M01_EasyCalc_Cards.ts new file mode 100644 index 000000000..7c9efb7b9 --- /dev/null +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Cards.ts @@ -0,0 +1,542 @@ +/* ============================================================================= + * M01_EasyCalc_Cards.ts + * 쉬운 단가산출 카드 그리기 — 묶음 + 소계 · 문장 칸 · 잠금 카드 · 지움 · ▲▼ · 카드 안 경고 · 더하기 단추 + * 모양은 `docs/raw/M01_쉬운단가/설계.md` 3절 그대로 · 바뀐 doc 는 on.change 로만 알림(그리기는 부른 쪽이 다시) + * 글 [한, 영] 짝은 아래 TEXT + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements"; +import type { CalcAnswer, CalcLine } from "../M01_MasterData_UI_Logic_Api"; +import { formatNumber } from "../M01_MasterData_UI_Logic_Edit"; +import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; +import { + addCard, + addPercent, + checkCard, + removeCard, + updateCard, + withInputs, + type CostKind, + type EasyCard, + type EasyDoc, + type PickResult, +} from "./M01_EasyCalc_Model"; +import { openEasyPick } from "./M01_EasyCalc_Pick"; +import { openEasyCond, readCond } from "./M01_EasyCalc_Cond"; +import { openEasyTable, readFinds } from "./M01_EasyCalc_Table"; +import "./M01_EasyCalc_Cards.css"; + +const TEXT = { + Group_person: ["사람", "Workers"], + Group_material: ["재료", "Materials"], + Group_machine: ["기계·경비", "Machines"], + Group_logic: ["다른 일위대가", "Other unit prices"], + Group_percent: ["덧붙임", "Add-ons"], + Subtotal: ["소계", "Subtotal"], + Won: ["원", "KRW"], + Empty: ["아직 없음", "none yet"], + Pending: ["계산 중", "calculating"], + Tag_person: ["사람", "Worker"], + Tag_material: ["재료", "Material"], + Tag_machine: ["기계", "Machine"], + Tag_logic: ["다른 일위대가", "Other unit price"], + Tag_percent: ["덧붙임", "Add-on"], + Pre_person: [" 이 ", " works "], + Post_person: [" 일함", ""], + Pre_pick: [" 를 ", " uses "], + Post_material: [" 씀", ""], + Post_machine: [" 씀", ""], + Post_logic: ["", ""], + Pre_percent: [" 의 ", " of "], + Mid_percent: [" % 를 ", " % as "], + Post_percent: [" 로 더함", ""], + Slot: ["넣을 자리", "put into"], + Pick_Empty: ["고르기", "choose"], + Repick: ["다른 것으로 다시 고르기", "Choose another"], + Eg_qty: ["예: 0.072", "e.g. 0.072"], + Eg_percent: ["예: 2", "e.g. 2"], + Eg_name: ["예: 공구손료", "e.g. tool wear"], + Qty: ["수량", "Amount"], + Name: ["이름", "Name"], + Auto: ["자동", "auto"], + Locked: ["잠김", "Locked"], + ToNumber: ["숫자로 바꾸기", "Make it a number"], + ByTable: ["표 보고 고르기", "Choose from table"], + ByCond: ["조건 보고 고치기", "Edit conditions"], + ToNumber_Wait: ["계산이 끝난 뒤 가능", "Available after calculation"], + ToNumber_Ask: [ + "자동 값을 그대로 숫자로 바꿈 · 이후 자동으로 달라지지 않음", + "Freeze the auto value as a number", + ], + ToNumber_Ok: ["바꾸기", "Convert"], + Remove: ["지움", "Remove"], + Up: ["위로", "Move up"], + Down: ["아래로", "Move down"], + Add_person: ["+ 사람", "+ Worker"], + Add_material: ["+ 재료", "+ Material"], + Add_machine: ["+ 기계", "+ Machine"], + Add_logic: ["+ 다른 일위대가", "+ Other unit price"], + Add_percent: ["+ 덧붙임", "+ Add-on"], + Base_노무비: ["사람 품값", "labor cost"], + Base_재료비: ["재료값", "material cost"], + Base_경비: ["기계·경비", "machine & expenses"], + Base_사람줄합: ["사람 줄의 합", "sum of worker lines"], +} as const; + +const tc = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex]; + +type Group = "person" | "material" | "machine" | "logic" | "percent"; +const GROUPS: Group[] = ["person", "material", "machine", "logic", "percent"]; +type PercentBase = NonNullable; +const BASES: PercentBase[] = ["노무비", "재료비", "경비", "사람줄합"]; +const COSTS: CostKind[] = ["노무비", "재료비", "경비"]; + +interface Ctx { + doc: EasyDoc; + lines: Map; + readOnly: boolean; + on: { change(doc: EasyDoc): void; trial?(): Record }; + /** 숫자 아님으로 친 글 — 다시 그려도 칸에 남김 */ + badText: Map; +} + +const badTexts = new WeakMap>(); +const closedGroups = new WeakMap>(); +let picking = false; + +const groupOf = (card: EasyCard): Group => { + if (card.kind !== "locked") return card.kind; + if (card.source?.part === "덧줄") return "percent"; + const kind = (card.raw as { 종류?: string } | null)?.종류; + return kind === "인력" ? "person" : kind === "재료" ? "material" : "logic"; +}; + +/** Model 의 toRow 와 같은 나눔 — 줄 차례가 어긋나지 않게 */ +const partOf = (card: EasyCard): "호표" | "덧줄" => + card.kind === "percent" + ? "덧줄" + : card.kind === "locked" + ? (card.source?.part ?? "덧줄") + : "호표"; + +/** 식 수량이거나 종류를 못 알아본 줄 — 숫자로 바꾸기 전까지 잠김 */ +const isLocked = (card: EasyCard): boolean => + card.qtyFormula !== null || (card.kind === "locked" && card.lockedWhy !== null); + +/** 카드 → 서버 계산 줄 — 호표 줄 차례 뒤에 덧줄 차례 · 줄 수가 안 맞으면(옛 응답) 없음 */ +function lineMap(doc: EasyDoc, ans: CalcAnswer | null): Map { + const out = new Map(); + if (!ans || !ans.ok || !ans.lines) return out; + const ordered = [ + ...doc.cards.filter((c) => partOf(c) === "호표"), + ...doc.cards.filter((c) => partOf(c) === "덧줄"), + ]; + if (ordered.length !== ans.lines.length) return out; + ordered.forEach((card, i) => out.set(card.id, ans.lines![i])); + return out; +} + +const parseQty = (text: string): number | null => { + const t = text.replace(/[,\s]/g, ""); + if (t === "") return null; + return /^[+-]?(\d+\.?\d*|\.\d+)$/.test(t) ? Number(t) : Number.NaN; +}; + +/** 검사가 막혀도 그리기는 계속 */ +function warnOf(ctx: Ctx, card: EasyCard): string | null { + if (ctx.doc.isBase) return null; + try { + return checkCard(card, ctx.doc); + } catch { + return null; + } +} + +function patchCard(ctx: Ctx, card: EasyCard, patch: Partial): void { + ctx.on.change(updateCard(ctx.doc, card.id, patch)); +} + +/** 화면 묶음 안 이웃과 자리 바꿈 — 잠금 카드도 같은 묶음이라 Model.moveCard 의 묶음과 달라 여기서 바꿈 */ +function swapInGroup(doc: EasyDoc, card: EasyCard, step: -1 | 1): EasyDoc { + const same = doc.cards.filter((c) => groupOf(c) === groupOf(card)); + const neighbor = same[same.indexOf(card) + step]; + if (!neighbor) return doc; + const cards = doc.cards.slice(); + const a = cards.indexOf(card); + const b = cards.indexOf(neighbor); + [cards[a], cards[b]] = [cards[b], cards[a]]; + return { ...doc, cards }; +} + +async function repick(ctx: Ctx, card: EasyCard): Promise { + if (picking || card.kind === "locked" || card.kind === "percent") return; + picking = true; + try { + const pick = await openEasyPick(card.kind, { qty: card.qty }); + if (!pick) return; + const next = updateCard(ctx.doc, card.id, { + name: pick.name, + spec: pick.spec, + unit: pick.unit, + element: pick.element, + qty: pick.qty, + }); + ctx.on.change(withInputs(next, pick.needInputs ?? [])); + } finally { + picking = false; + } +} + +async function addByPick(ctx: Ctx, kind: PickResult["kind"]): Promise { + if (picking) return; + picking = true; + try { + const pick = await openEasyPick(kind); + if (pick) ctx.on.change(addCard(ctx.doc, pick)); + } finally { + picking = false; + } +} + +function addBlankPercent(ctx: Ctx): void { + ctx.on.change(addPercent(ctx.doc, "", "노무비", null, "경비")); +} + +async function lockToNumber(ctx: Ctx, card: EasyCard, value: number): Promise { + const ask = `${tc("ToNumber_Ask")} (${formatNumber(value)})`; + if (await showConfirmDialog(ask, tc("ToNumber_Ok"))) { + patchCard(ctx, card, { qty: value, qtyFormula: null, lockedWhy: null }); + } +} + +/** 표 · 조건 창 — 답(새 식)이 있으면 그 카드 식만 바꿈 */ +async function editFormula( + ctx: Ctx, + card: EasyCard, + open: typeof openEasyTable | typeof openEasyCond, +): Promise { + if (picking) return; + picking = true; + try { + const formula = await open(card, ctx.doc, ctx.on.trial?.()); + if (formula !== null) patchCard(ctx, card, { qtyFormula: formula }); + } finally { + picking = false; + } +} + +/* ------------------------------ 칸 만들기 ------------------------------ */ + +const text = (cls: string, value: string): HTMLSpanElement => + el("span", { className: cls, text: value }); + +function qtyInput(ctx: Ctx, card: EasyCard): HTMLInputElement { + const bad = ctx.badText.get(card.id); + const shown = card.qty === null ? "" : Number.isNaN(card.qty) ? (bad ?? "") : String(card.qty); + const input = el("input", { + className: "ui-input m01-easycards-qty", + attrs: { + type: "text", + inputmode: "decimal", + placeholder: tc(card.kind === "percent" ? "Eg_percent" : "Eg_qty"), + "aria-label": tc("Qty"), + }, + }); + input.value = shown; + if (ctx.readOnly) input.readOnly = true; + input.addEventListener("change", () => { + const qty = parseQty(input.value); + if (Number.isNaN(qty)) ctx.badText.set(card.id, input.value); + else ctx.badText.delete(card.id); + if (Object.is(qty, card.qty)) return; + patchCard(ctx, card, { qty }); + }); + return input; +} + +function nameWidget(ctx: Ctx, card: EasyCard): HTMLElement { + if (card.kind === "percent") { + const input = el("input", { + className: "ui-input m01-easycards-text", + attrs: { type: "text", placeholder: tc("Eg_name"), "aria-label": tc("Name") }, + }); + input.value = card.name; + if (ctx.readOnly) input.readOnly = true; + input.addEventListener("change", () => { + if (input.value !== card.name) patchCard(ctx, card, { name: input.value.trim() }); + }); + return input; + } + const label = [card.name, card.spec].filter(Boolean).join(" ") || tc("Pick_Empty"); + if (ctx.readOnly || card.kind === "locked") return text("m01-easycards-name", label); + const button = el("button", { + className: "m01-easycards-name m01-easycards-name--pick", + text: `${label} ▾`, + attrs: { type: "button", "aria-label": `${label} — ${tc("Repick")}` }, + }); + button.addEventListener("click", () => void repick(ctx, card)); + return button; +} + +function selectBox( + ctx: Ctx, + values: T[], + current: T | null, + label: (v: T) => string, + onPick: (v: T) => void, + aria: string, +): HTMLElement { + if (ctx.readOnly) return text("m01-easycards-name", current ? label(current) : ""); + const select = el("select", { + className: "ui-select m01-easycards-select", + attrs: { "aria-label": aria }, + }); + if (current === null) + select.append(el("option", { attrs: { value: "" }, text: tc("Pick_Empty") })); + for (const v of values) select.append(el("option", { attrs: { value: v }, text: label(v) })); + select.value = current ?? ""; + select.addEventListener("change", () => { + if (select.value) onPick(select.value as T); + }); + return select; +} + +function amountText(ctx: Ctx, card: EasyCard): HTMLElement { + const line = ctx.lines.get(card.id); + if (!line) return text("m01-easycards-amount m01-easycards-amount--wait", `= ${tc("Pending")}`); + return text("m01-easycards-amount", `= ${formatMoney(line.금액)} ${tc("Won")}`); +} + +function toolButtons(ctx: Ctx, card: EasyCard, group: EasyCard[]): HTMLElement | null { + if (ctx.readOnly) return null; + const at = group.indexOf(card); + const move = (label: string, glyph: string, step: -1 | 1, off: boolean) => { + const b = createButton({ + label: glyph, + variant: "ghost", + size: "sm", + disabled: off, + onClick: () => ctx.on.change(swapInGroup(ctx.doc, card, step)), + }); + b.setAttribute("aria-label", label); + return b; + }; + return el("div", { + className: "m01-easycards-tools", + children: [ + move(tc("Up"), "▲", -1, at <= 0), + move(tc("Down"), "▼", 1, at >= group.length - 1), + createButton({ + label: tc("Remove"), + variant: "ghost", + size: "sm", + onClick: () => ctx.on.change(removeCard(ctx.doc, card.id)), + }), + ], + }); +} + +/** 한 카드 = 문장 한 줄(좁으면 두 줄로 접힘) */ +function cardView(ctx: Ctx, card: EasyCard, group: EasyCard[]): HTMLElement { + const g = groupOf(card); + const locked = isLocked(card); + const percent = card.kind === "percent"; + const plain = card.kind === "locked"; + const name = nameWidget(ctx, card); + const value = locked ? lockedValue(ctx, card) : qtyInput(ctx, card); + const parts: (HTMLElement | string)[] = [text("m01-easycards-tag", `【${tc(`Tag_${g}`)}】`)]; + + if (percent) { + parts.push( + selectBox( + ctx, + BASES, + card.percentBase, + (v) => tc(`Base_${v}`), + (v) => patchCard(ctx, card, { percentBase: v }), + tc("Slot"), + ), + tc("Pre_percent"), + value, + tc("Mid_percent"), + name, + tc("Post_percent"), + ); + } else { + parts.push( + name, + plain ? " " : g === "person" ? tc("Pre_person") : tc("Pre_pick"), + value, + text("m01-easycards-unit", ` ${card.unit}`), + plain ? "" : tc(`Post_${g}` as "Post_person"), + ); + } + + const slot = percent + ? el("span", { + className: "m01-easycards-slot", + children: [ + ` · ${tc("Slot")} `, + selectBox( + ctx, + COSTS, + card.cost, + (v) => tc(`Base_${v}`), + (v) => patchCard(ctx, card, { cost: v }), + tc("Slot"), + ), + ], + }) + : null; + + const head = el("div", { + className: "m01-easycards-line", + children: [ + el("div", { + className: "m01-easycards-sentence", + children: slot ? [...parts, slot] : parts, + }), + amountText(ctx, card), + toolButtons(ctx, card, group) ?? "", + ], + }); + const rows: HTMLElement[] = [head]; + + const warn = warnOf(ctx, card); + if (warn) rows.push(el("p", { className: "m01-easycards-warn", text: `⚠ ${warn}` })); + if (locked) rows.push(lockedNote(ctx, card)); + return el("li", { + className: `m01-easycards-card${locked ? " m01-easycards-card--locked" : ""}`, + children: rows, + }); +} + +/** 잠금 카드 값 자리 — 「자동 0.50」 */ +function lockedValue(ctx: Ctx, card: EasyCard): HTMLElement { + const line = ctx.lines.get(card.id); + const value = line ? formatNumber(line.수량) : tc("Pending"); + return text("m01-easycards-auto", `(${tc("Auto")} ${value})`); +} + +function lockedNote(ctx: Ctx, card: EasyCard): HTMLElement { + const line = ctx.lines.get(card.id); + const note: (HTMLElement | string)[] = [ + text("m01-easycards-lock", `🔒 ${tc("Locked")}`), + text("m01-easycards-why", card.lockedWhy ?? ""), + ]; + if (!ctx.readOnly) { + const button = createButton({ + label: tc("ToNumber"), + variant: "ghost", + size: "sm", + disabled: !line, + onClick: () => line && void lockToNumber(ctx, card, line.수량), + }); + if (!line) button.title = tc("ToNumber_Wait"); + note.push(button); + const formula = card.qtyFormula; + const tools: [boolean, keyof typeof TEXT, typeof openEasyTable | typeof openEasyCond][] = [ + [formula !== null && !!readFinds(formula)?.length, "ByTable", openEasyTable], + [formula !== null && !!readCond(formula, ctx.doc.row), "ByCond", openEasyCond], + ]; + for (const [has, label, open] of tools) { + if (has) + note.push( + createButton({ + label: tc(label), + variant: "ghost", + size: "sm", + onClick: () => void editFormula(ctx, card, open), + }), + ); + } + } + return el("div", { className: "m01-easycards-locknote", children: note }); +} + +/* ------------------------------ 묶음 · 전체 ------------------------------ */ + +function groupView(ctx: Ctx, host: HTMLElement, group: Group, cards: EasyCard[]): HTMLElement { + const title = tc(`Group_${group}`); + if (!cards.length) { + return el("section", { + className: "m01-easycards-group m01-easycards-group--empty", + children: [ + text("m01-easycards-group__title", title), + text("m01-easycards-group__none", tc("Empty")), + ], + }); + } + const found = cards.map((c) => ctx.lines.get(c.id)).filter((l): l is CalcLine => !!l); + const sum = + found.length === cards.length + ? `${tc("Subtotal")} ${formatMoney(found.reduce((s, l) => s + l.금액, 0))} ${tc("Won")}` + : `${tc("Subtotal")} ${tc("Pending")}`; + const details = el("details", { + className: "m01-easycards-group", + children: [ + el("summary", { + className: "m01-easycards-group__head", + children: [ + text("m01-easycards-group__title", title), + text( + `m01-easycards-group__sum${found.length === cards.length ? "" : " m01-easycards-amount--wait"}`, + sum, + ), + ], + }), + el("ul", { + className: "m01-easycards-list", + children: cards.map((c) => cardView(ctx, c, cards)), + }), + ], + }); + const closed = closedGroups.get(host) ?? new Set(); + closedGroups.set(host, closed); + details.open = !closed.has(group); + details.addEventListener("toggle", () => { + if (details.open) closed.delete(group); + else closed.add(group); + }); + return details; +} + +function addBar(ctx: Ctx): HTMLElement { + const add = (key: keyof typeof TEXT, run: () => void) => + createButton({ label: tc(key), variant: "ghost", onClick: run }); + return el("div", { + className: "m01-easycards-add", + children: [ + add("Add_person", () => void addByPick(ctx, "person")), + add("Add_material", () => void addByPick(ctx, "material")), + add("Add_machine", () => void addByPick(ctx, "machine")), + add("Add_logic", () => void addByPick(ctx, "logic")), + add("Add_percent", () => addBlankPercent(ctx)), + ], + }); +} + +/** 카드 묶음 그리기 — ans 는 줄 금액 · null 이면 「계산 중」 */ +export function renderCards( + host: HTMLElement, + doc: EasyDoc, + ans: CalcAnswer | null, + on: { change(doc: EasyDoc): void; trial?(): Record }, +): void { + const badText = badTexts.get(host) ?? new Map(); + badTexts.set(host, badText); + const ctx: Ctx = { doc, lines: lineMap(doc, ans), readOnly: doc.isBase, on, badText }; + host.classList.add("m01-easycards"); + host.replaceChildren( + ...GROUPS.map((g) => + groupView( + ctx, + host, + g, + doc.cards.filter((c) => groupOf(c) === g), + ), + ), + ...(ctx.readOnly ? [] : [addBar(ctx)]), + ); +} diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Cond.css b/M01_MasterData/EasyCalc/M01_EasyCalc_Cond.css new file mode 100644 index 000000000..892145742 --- /dev/null +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Cond.css @@ -0,0 +1,97 @@ +/* ============================================================================= + * M01_EasyCalc_Cond.css — 조건 보고 고치기 창 + * ========================================================================== */ + +.m01-easycond { + width: min(760px, calc(100vw - 32px)); +} + +.m01-easycond__lines { + display: flex; + flex-direction: column; + gap: 12px; +} + +.m01-easycond__line { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + font-size: 16px; + line-height: 1.6; +} + +.m01-easycond__slot { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.m01-easycond__select, +.m01-easycond__num { + min-height: 36px; + padding: 4px 8px; + border: 1px solid var(--color-border, #c9ced6); + border-radius: 8px; + background: var(--color-surface, #fff); + color: inherit; + font: inherit; +} + +.m01-easycond__num { + width: 7em; + text-align: right; +} + +.m01-easycond__num.is-bad { + border-color: var(--color-danger, #d64545); + outline: 2px solid var(--color-danger, #d64545); +} + +.m01-easycond__word { + padding: 4px 8px; + border-radius: 8px; + background: var(--color-surface-raised, #eef1f5); + font-weight: 600; +} + +.m01-easycond__chip { + min-height: 36px; + padding: 4px 12px; + border: 1px dashed var(--color-accent, #3a6fd8); + border-radius: 999px; + background: transparent; + color: var(--color-accent, #3a6fd8); + font: inherit; + cursor: pointer; +} + +.m01-easycond__chip:disabled { + border-color: var(--color-border, #c9ced6); + color: inherit; + cursor: default; +} + +.m01-easycond__money { + margin-top: 16px; + padding: 12px; + border-radius: 8px; + background: var(--color-surface-raised, #eef1f5); + font-size: 16px; + font-weight: 600; +} + +.m01-easycond__money.is-waiting { + opacity: 0.5; +} + +.m01-easycond__foot { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; +} + +.m01-easycond__cannot { + font-size: 16px; +} diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Cond.ts b/M01_MasterData/EasyCalc/M01_EasyCalc_Cond.ts new file mode 100644 index 000000000..fb304fc91 --- /dev/null +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Cond.ts @@ -0,0 +1,620 @@ +/* ============================================================================= + * M01_EasyCalc_Cond.ts + * 쉬운 단가산출 2단계 — 조건 보고 고치기. 잠금 카드(수량 · 그 밖 덧붙임)의 조건을 문장으로 보이고 + * 고르기 · 숫자 칸으로 고침 → 새 식 글(qtyFormula) · 취소 null + * + * 엔진에 식 구조를 주는 길이 없어(`/text` 는 값을 넣은 글 조각뿐) 화면에서 읽음 — 문법은 `master_formula._Parser` 와 같음 + * 덩어리 = 만약(비교, 값, 값)(아니면 자리에 만약이 이어지는 사슬 포함) · 최소/최대(값, …) · 올림/버림/반올림(값, 자리) + * 식 전체가 덩어리이거나 · 식 속에 덩어리가 있으면(「표 값 × 만약(…)」) 덩어리만 문장으로 · 나머지 글은 그대로 + * 식이 중간 값 이름 하나면 그 중간 식을 읽음(고치면 수량 자리에 풀어 씀 · 중간 값은 그대로) + * 다시 짠 덩어리 글이 원래와 같을 때만 씀 — 하나도 못 읽으면 「이 줄은 지금 화면에서」 한 줄 + * 금액 미리 보기 = Api `calcLater`(준비 전이면 `/calc` 를 0.3초 뒤 · 최신만) + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el } from "@ui/ui_template_elements"; +import { openModal } from "@ui/ui_template_modal"; +import { + runCalc, + type CalcAnswer, + type LogicInput, + type LogicRow, +} from "../M01_MasterData_UI_Logic_Api"; +import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; +import { calcLater } from "./M01_EasyCalc_Api"; +import { toRow, updateCard, type EasyCard, type EasyDoc } from "./M01_EasyCalc_Model"; +import "./M01_EasyCalc_Cond.css"; + +const TEXT = { + Title: ["조건 보고 고치기", "Change by conditions"], + Cannot: ["이 줄은 지금 화면에서 고침", "Change this line on the current screen"], + Part: ["조건", "Condition"], + If: ["만약", "If"], + ElseIf: ["아니고 만약", "Else if"], + Then: ["→", "→"], + Else: ["아니면 →", "Otherwise →"], + Subject: ["가", "is"], + Big: ["가운데 큰 값", "whichever is larger"], + Small: ["가운데 작은 값", "whichever is smaller"], + And: ["와", "and"], + RoundA: ["을 소수", "to"], + RoundB: ["자리까지", "decimal places,"], + 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"], + ChipTable: ["표에서 찾은 값", "Value from table"], + ChipTableCalc: ["표에서 찾은 값으로 계산", "Worked out from a table"], + ChipInputCalc: ["설계 값으로 계산", "Worked out from design values"], + ChipCalc: ["계산한 값", "Calculated value"], + ToNumber: ["숫자로", "Use a number"], + Line: ["이 줄", "This line"], + Total: ["합계", "Total"], + Was: ["전", "before"], + Waiting: ["계산 중 …", "Calculating …"], + Use: ["이걸로", "Use this"], + Cancel: ["취소", "Cancel"], + NotNumber: ["숫자만 넣음", "Numbers only"], +} as const; + +const te = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex]; + +/* ── 읽기 · 짜기 (DOM 없음 · 시험이 이 구역만 떼어 Node 로 돌림) ───────────── */ + +/** 식 마디 — src = 원래 글 · p = [처음, 끝] 자리 */ +type CNode = { src: string; p: [number, number] } & ( + | { t: "num" | "str" | "name" | "ref"; v: string } + | { t: "call"; f: string; items: CNode[] } + | { t: "bin"; op: string; a: CNode; b: CNode } + | { t: "neg" | "paren"; a: CNode } + | { t: "field"; a: CNode; f: string } +); + +const COND_FUNCS = ["만약", "올림", "버림", "반올림", "최소", "최대"]; +const COMPARE_OPS = ["<=", ">=", "==", "!=", "<", ">"]; +const NAME_RE = /[\p{L}\p{N}_]+/uy; +const NUM_RE = /\d+(?:\.\d+)?/y; + +/** 엔진 `_Parser` 와 같은 문법 · 찾기(…) · 로직(…) · ID:원문번호 는 통째 한 마디 */ +function parseCond(s: string): CNode | null { + let i = 0; + const fail = (): never => { + throw new Error("parse"); + }; + const ws = (): void => { + while (i < s.length && /\s/.test(s[i])) i++; + }; + const take = (w: string): boolean => { + ws(); + if (!s.startsWith(w, i)) return false; + i += w.length; + return true; + }; + const need = (w: string): void => void (take(w) || fail()); + const mk = (start: number, body: T): CNode => + ({ ...body, src: s.slice(start, i), p: [start, i] }) as unknown as CNode; + const sticky = (re: RegExp): string | null => { + re.lastIndex = i; + const m = re.exec(s); + if (m) i = re.lastIndex; + return m ? m[0] : null; + }; + const word = (): string => (ws(), sticky(NAME_RE) ?? fail()); + const quoted = (): string => { + const end = s.indexOf("'", i); + if (end < 0) fail(); + const v = s.slice(i, end); + i = end + 1; + return v; + }; + /** 괄호 짝까지 — 작은따옴표 안은 건너뜀 */ + const skipGroup = (): void => { + let depth = 0; + let q = false; + for (; i < s.length; i++) { + if (s[i] === "'") q = !q; + else if (!q && s[i] === "(") depth++; + else if (!q && s[i] === ")" && --depth === 0) return void i++; + } + fail(); + }; + /** ID:원문번호 — 괄호 밖 , ) · 빈칸+연산자 · .칸 앞에서 멈춤 */ + const skipRef = (): void => { + for (let depth = 0; i < s.length; i++) { + const ch = s[i]; + if ("({".includes(ch)) depth++; + else if (")}".includes(ch) && depth-- === 0) return; + else if (depth === 0 && (ch === "," || /^\s[-+*/<>=!]/.test(s.slice(i, i + 2)))) return; + else if (depth === 0 && ch === "." && !/\d/.test(s[i + 1] ?? "")) return; + } + }; + const fields = (start: number, node: CNode): CNode => { + while (take(".")) { + const f = take("'") ? quoted() : word(); + node = mk(start, { t: "field", a: node, f }); + } + return node; + }; + const primary = (): CNode => { + ws(); + const start = i; + if (take("(")) { + const a = expr(); + need(")"); + return fields(start, mk(start, { t: "paren", a })); + } + if (take("'")) return mk(start, { t: "str", v: quoted() }); + const num = sticky(NUM_RE); + if (num) return mk(start, { t: "num", v: num }); + const w = word(); + if (w === "찾기" || w === "로직") { + ws(); + if (s[i] !== "(") fail(); + skipGroup(); + return fields(start, mk(start, { t: "ref", v: w })); + } + if (COND_FUNCS.includes(w)) { + need("("); + const items = [expr()]; + while (take(",")) items.push(expr()); + need(")"); + return fields(start, mk(start, { t: "call", f: w, items })); + } + if (s[i] === ":") { + i++; + skipRef(); + return fields(start, mk(start, { t: "ref", v: w })); + } + return fields(start, mk(start, { t: "name", v: w })); + }; + const unary = (): CNode => { + ws(); + const start = i; + return take("-") ? mk(start, { t: "neg", a: unary() }) : primary(); + }; + const chain = (next: () => CNode, ops: string[], once = false): CNode => { + ws(); + const start = i; + let node = next(); + for (let op = ops.find(take); op; op = once ? undefined : ops.find(take)) + node = mk(start, { t: "bin", op, a: node, b: next() }); + return node; + }; + const mul = (): CNode => chain(unary, ["*", "/"]); + const add = (): CNode => chain(mul, ["+", "-"]); + const compare = (): CNode => chain(add, COMPARE_OPS, true); + const both = (): CNode => chain(compare, ["그리고"]); + const expr = (): CNode => chain(both, ["또는"]); + try { + const node = expr(); + ws(); + return i === s.length ? node : null; + } catch { + return null; + } +} + +/** 값 자리 — 화면이 그 자리에서 고침(같은 물건을 바꿔 씀) */ +export type CondSlot = + | { kind: "num"; value: number } + | { kind: "text"; value: string } + | { kind: "input"; name: string } + | { kind: "table" | "calc"; src: string }; +export type CondOp = "==" | "!=" | ">" | ">=" | "<" | "<="; +export interface CondTest { + left: CondSlot; + op: CondOp; + right: CondSlot; +} +export type CondShape = + | { type: "if"; branches: { test: CondTest; value: CondSlot }[]; otherwise: CondSlot } + | { type: "pick"; fn: "최소" | "최대"; items: CondSlot[] } + | { + type: "round"; + fn: "올림" | "버림" | "반올림"; + value: CondSlot; + places: number; + hadPlaces: boolean; + }; +/** 덩어리 하나 — p = 읽은 글(text) 안 자리 */ +export interface CondBlock { + shape: CondShape; + p: [number, number]; +} +/** 읽은 결과 — text = 읽은 글(중간 값을 따라가면 그 식) · via = 따라간 중간 값 이름 */ +export interface CondRead { + text: string; + via: string | null; + blocks: CondBlock[]; +} + +const unparen = (n: CNode): CNode => (n.t === "paren" ? unparen(n.a) : n); + +function slotOf(node: CNode, inputs: LogicInput[]): CondSlot { + const n = unparen(node); + if (n.t === "num") return { kind: "num", value: Number(n.v) }; + if (n.t === "neg" && unparen(n.a).t === "num") + return { kind: "num", value: -Number(unparen(n.a).src) }; + if (n.t === "str") return { kind: "text", value: n.v }; + if (n.t === "name" && inputs.some((x) => x.이름 === n.v)) return { kind: "input", name: n.v }; + if (n.t === "field" && n.a.t === "ref" && n.src.startsWith("찾기")) + return { kind: "table", src: n.src }; + return { kind: "calc", src: node.src.trim() }; +} + +function shapeOf(n: CNode, inputs: LogicInput[]): CondShape | null { + if (n.t !== "call") return null; + const slot = (x: CNode): CondSlot => slotOf(x, inputs); + if (n.f === "만약") { + const t = unparen(n.items[0]); + if (n.items.length !== 3 || t.t !== "bin" || !COMPARE_OPS.includes(t.op)) return null; + const test: CondTest = { left: slot(t.a), op: t.op as CondOp, right: slot(t.b) }; + const branch = { test, value: slot(n.items[1]) }; + const rest = shapeOf(unparen(n.items[2]), inputs); + return rest?.type === "if" + ? { type: "if", branches: [branch, ...rest.branches], otherwise: rest.otherwise } + : { type: "if", branches: [branch], otherwise: slot(n.items[2]) }; + } + if (n.f === "최소" || n.f === "최대") + return n.items.length >= 2 ? { type: "pick", fn: n.f, items: n.items.map(slot) } : null; + const places = n.items[1] ? unparen(n.items[1]) : null; + if (n.items.length > 2 || (places && places.t !== "num")) return null; + const fn = n.f as "올림" | "버림" | "반올림"; + const value = slot(n.items[0]); + return { type: "round", fn, value, places: places ? Number(places.src) : 0, hadPlaces: !!places }; +} + +const condNum = (n: number): string => { + const t = String(Number(n.toPrecision(12))); + return t.includes("e") ? n.toFixed(12).replace(/\.?0+$/, "") : t; +}; + +function slotText(s: CondSlot): string { + if (s.kind === "num") return condNum(s.value); + if (s.kind === "text") return `'${s.value}'`; + return s.kind === "input" ? s.name : s.src; +} + +/** 덩어리 → 식 글 */ +export function writeCond(shape: CondShape): string { + if (shape.type === "pick") return `${shape.fn}(${shape.items.map(slotText).join(", ")})`; + if (shape.type === "round") { + const v = slotText(shape.value); + return shape.places === 0 && !shape.hadPlaces + ? `${shape.fn}(${v})` + : `${shape.fn}(${v}, ${condNum(shape.places)})`; + } + return shape.branches.reduceRight((acc, b) => { + const t = b.test; + return `만약(${slotText(t.left)} ${t.op} ${slotText(t.right)}, ${slotText(b.value)}, ${acc})`; + }, slotText(shape.otherwise)); +} + +const flat = (s: string): string => s.replace(/[\s()]+/g, ""); + +/** 바깥부터 덩어리를 찾음 — 읽은 덩어리 안은 값 자리 · 못 읽은 부름은 안을 다시 봄 */ +function blocksOf(n: CNode, inputs: LogicInput[]): CondBlock[] { + if (n.t === "call") { + const shape = shapeOf(n, inputs); + if (shape && flat(writeCond(shape)) === flat(n.src)) return [{ shape, p: n.p }]; + return n.items.flatMap((x) => blocksOf(x, inputs)); + } + if (n.t === "bin") return [...blocksOf(n.a, inputs), ...blocksOf(n.b, inputs)]; + if (n.t === "neg" || n.t === "paren" || n.t === "field") return blocksOf(n.a, inputs); + return []; +} + +/** 식 글 → 덩어리들 · 하나도 못 읽으면 null */ +export function readCond(formula: string, row: Pick): CondRead | null { + let text = formula.trim(); + let via: string | null = null; + for (let hop = 0; hop < 5; hop++) { + const mid = (row.중간 ?? []).find((m) => m.이름 === text); + if (!mid) break; + via = mid.이름; + text = mid.식.trim(); + } + const node = parseCond(text); + const blocks = node ? blocksOf(node, row.입력 ?? []) : []; + return blocks.length ? { text, via, blocks } : null; +} + +/** 덩어리를 고친 뒤 식 글 전체 — 덩어리 밖 글은 그대로 */ +export function writeRead(read: CondRead): string { + let out = read.text; + for (const b of [...read.blocks].sort((x, y) => y.p[0] - x.p[0])) + out = out.slice(0, b.p[0]) + writeCond(b.shape) + out.slice(b.p[1]); + return out; +} + +/* ── 화면 ───────────────────────────────────────────────────────────────── */ + +const OPS: [CondOp, keyof typeof TEXT][] = [ + ["==", "OpEq"], + ["!=", "OpNe"], + [">", "OpGt"], + [">=", "OpGe"], + ["<", "OpLt"], + ["<=", "OpLe"], +]; + +/** 2단계 표 고르기(sub8) — 파일이 생기면 표 칩을 누를 때 엶 · 없으면 칩만 */ +const TABLE_MODULE = import.meta.glob<{ + openEasyTable: ( + card: EasyCard, + doc: EasyDoc, + trial?: Record, + ) => Promise; +}>("./M01_EasyCalc_Table.ts"); + +/** 값 자리 바꿔 쓰기 — 같은 물건 그대로(문장이 쥔 자리를 놓치지 않음) */ +const put = (slot: CondSlot, next: CondSlot): void => { + for (const k of Object.keys(slot)) delete (slot as Record)[k]; + Object.assign(slot, next); +}; + +function select( + options: { value: string; text: string }[], + value: string, + onChange: (v: string) => void, +): HTMLSelectElement { + const sel = el("select", { className: "m01-easycond__select" }); + const all = options.some((o) => o.value === value) + ? options + : [...options, { value, text: value }]; + for (const o of all) sel.append(el("option", { text: o.text, attrs: { value: o.value } })); + sel.value = value; + sel.addEventListener("change", () => onChange(sel.value)); + return sel; +} + +function numberBox(value: number, onChange: (v: number) => void): HTMLInputElement { + const box = el("input", { + className: "m01-easycond__num", + attrs: { type: "text", inputmode: "decimal", value: condNum(value) }, + }); + box.addEventListener("input", () => { + const ok = /^\s*-?\d+(\.\d+)?\s*$/.test(box.value); + box.classList.toggle("is-bad", !ok); + box.title = ok ? "" : te("NotNumber"); + if (ok) onChange(Number(box.value)); + }); + return box; +} + +function chipLabel(slot: CondSlot, inputs: LogicInput[]): string { + if (slot.kind === "table") return te("ChipTable"); + if (slot.kind !== "calc") return ""; + if (/찾기\s*\(/.test(slot.src)) return te("ChipTableCalc"); + const words: string[] = slot.src.match(/[\p{L}\p{N}_]+/gu) ?? []; + return inputs.some((x) => words.includes(x.이름)) ? te("ChipInputCalc") : te("ChipCalc"); +} + +/** 문장 짓기 — 고칠 때마다 changed() */ +class Sentence { + constructor( + private card: EasyCard, + private doc: EasyDoc, + private changed: () => void, + private trial: Record, + ) {} + + /** 값 칸 — 숫자는 숫자 칸 · 글 · 설계 값은 글자 · 나머지는 칩(+ 숫자로) */ + slot(s: CondSlot): HTMLElement { + const host = el("span", { className: "m01-easycond__slot" }); + const draw = (): void => { + host.replaceChildren(); + if (s.kind === "num") { + host.append(numberBox(s.value, (v) => (put(s, { kind: "num", value: v }), this.changed()))); + return; + } + if (s.kind === "text" || s.kind === "input") { + const text = s.kind === "text" ? s.value : s.name; + host.append(el("span", { className: "m01-easycond__word", text })); + return; + } + const chip = el("button", { + className: "m01-easycond__chip", + text: chipLabel(s, this.doc.inputs), + attrs: { type: "button" }, + }); + const load = TABLE_MODULE["./M01_EasyCalc_Table.ts"]; + if (load && s.kind === "table") + chip.addEventListener("click", async () => { + const got = await ( + await load() + ).openEasyTable({ ...this.card, qtyFormula: s.src }, this.doc, this.trial); + if (got) (put(s, { kind: "table", src: got }), draw(), this.changed()); + }); + else chip.disabled = true; + const toNumber = (): void => (put(s, { kind: "num", value: 0 }), draw(), this.changed()); + host.append( + chip, + createButton({ label: te("ToNumber"), variant: "ghost", size: "sm", onClick: toNumber }), + ); + }; + draw(); + return host; + } + + /** 비교 — 왼쪽 설계 값 고르기 · 오른쪽은 그 값의 고르기 목록 또는 값 칸 · 맺음 고르기 */ + test(t: CondTest): HTMLElement[] { + const inputs = this.doc.inputs; + const right = el("span", { className: "m01-easycond__slot" }); + const drawRight = (): void => { + const name = t.left.kind === "input" ? t.left.name : null; + const inp = inputs.find((x) => x.이름 === name); + const choices = inp?.고르기 ?? []; + const r = t.right; + if (!choices.length || (r.kind !== "text" && r.kind !== "num")) + return right.replaceChildren(this.slot(r)); + const pickOne = (v: string): void => { + const asNum = choices.some((c) => typeof c === "number" && String(c) === v); + put(r, asNum ? { kind: "num", value: Number(v) } : { kind: "text", value: v }); + this.changed(); + }; + const opts = choices.map((c) => ({ value: String(c), text: String(c) })); + right.replaceChildren(select(opts, String(r.value), pickOne)); + }; + drawRight(); + const l = t.left; + const left = + l.kind === "input" + ? select( + inputs.map((x) => ({ value: x.이름, text: x.이름 })), + l.name, + (v) => (put(l, { kind: "input", name: v }), drawRight(), this.changed()), + ) + : this.slot(l); + const opts = OPS.map(([v, k]) => ({ value: v, text: te(k) })); + const op = select(opts, t.op, (v) => ((t.op = v as CondOp), this.changed())); + return [left, el("span", { text: te("Subject") }), right, op]; + } + + /** 덩어리 하나 → 줄들 */ + block(shape: CondShape): HTMLElement[] { + if (shape.type === "if") + return [ + ...shape.branches.map((b, i) => + line(te(i ? "ElseIf" : "If"), ...this.test(b.test), te("Then"), this.slot(b.value)), + ), + line(te("Else"), this.slot(shape.otherwise)), + ]; + if (shape.type === "pick") { + const kids = shape.items.flatMap((s, i) => (i ? [te("And"), this.slot(s)] : [this.slot(s)])); + const opts = [ + { value: "최대", text: te("Big") }, + { value: "최소", text: te("Small") }, + ]; + const fn = select(opts, shape.fn, (v) => ((shape.fn = v as "최소" | "최대"), this.changed())); + return [line(...kids, fn)]; + } + const ways = [ + { value: "올림", text: te("Up") }, + { value: "버림", text: te("Down") }, + { value: "반올림", text: te("Half") }, + ]; + const setPlaces = (v: number): void => ( + (shape.places = Math.max(0, Math.round(v))), + this.changed() + ); + const way = select(ways, shape.fn, (v) => ((shape.fn = v as "올림"), this.changed())); + const places = numberBox(shape.places, setPlaces); + return [line(this.slot(shape.value), te("RoundA"), places, te("RoundB"), way)]; + } +} + +const line = (...kids: (HTMLElement | string)[]): HTMLElement => + el("div", { + className: "m01-easycond__line", + children: kids.map((k) => (typeof k === "string" ? el("span", { text: k }) : k)), + }); + +/** 금액 미리 보기 — 이 줄 · 합계(처음 값과 다르면 「전」) */ +function moneyView(card: EasyCard, doc: EasyDoc, trial: Record) { + const box = el("div", { className: "m01-easycond__money", text: te("Waiting") }); + const ho = doc.cards.filter( + (c) => c.kind !== "percent" && (c.kind !== "locked" || c.source?.part === "호표"), + ); + const at = ho.findIndex((c) => c.id === card.id); + let first: [number | null, number | null] | null = null; + let seq = 0; + let timer = 0; + const show = (ans: CalcAnswer | null, plain: string | null): void => { + box.classList.remove("is-waiting"); + if (!ans || !ans.ok) + return void (box.textContent = plain ?? (ans && !ans.ok ? ans.reason : "")); + const lineSum = at >= 0 ? (ans.lines?.[at]?.금액 ?? null) : null; + const sums = ans.sums ?? {}; + const total = sums["계"] ?? Object.values(sums).reduce((a, b) => a + b, 0); + first ??= [lineSum, total]; + const part = (label: string, v: number | null, was: number | null): string => + v === null + ? "" + : `${label} ${formatMoney(v)} 원${was !== null && was !== v ? ` (${te("Was")} ${formatMoney(was)} 원)` : ""}`; + const parts = [part(te("Line"), lineSum, first[0]), part(te("Total"), total, first[1])]; + box.replaceChildren(...parts.filter(Boolean).map((t) => el("div", { text: t }))); + }; + const update = (text: string): void => { + box.classList.add("is-waiting"); + const row: LogicRow = toRow(updateCard(doc, card.id, { qtyFormula: text, qty: null })); + const mine = ++seq; + const done = (ans: CalcAnswer | null, plain: string | null = null): void => + void (mine === seq && show(ans, plain)); + try { + calcLater(row, trial, done); + } catch { + // Api 준비 전 — 같은 서버 계산을 0.3초 뒤 직접(최신만) + window.clearTimeout(timer); + timer = window.setTimeout(() => { + runCalc({ key: "", inputs: trial, row }).then( + (a) => done(a), + () => done(null), + ); + }, 300); + } + }; + return { box, update }; +} + +/** 잠금 카드의 조건을 문장으로 고침 → 새 식 글 · 취소 · 못 읽으면 null */ +export function openEasyCond( + card: EasyCard, + doc: EasyDoc, + trial: Record = {}, +): Promise { + const read = readCond(card.qtyFormula ?? "", doc.row); + return new Promise((resolve) => { + let done = false; + const finish = (v: string | null): void => { + if (!done) ((done = true), resolve(v)); + }; + openModal({ + title: te("Title"), + closeLabel: te("Cancel"), + dialogClass: "m01-easycond", + beforeClose: () => (finish(null), true), + mount: (body, close) => { + if (!read) + return void body.append( + el("p", { className: "m01-easycond__cannot", text: te("Cannot") }), + ); + const base = flat(writeRead(read)); + const money = moneyView(card, doc, trial); + const use = createButton({ + label: te("Use"), + variant: "filled", + disabled: true, + onClick: () => (finish(writeRead(read)), close()), + }); + const changed = (): void => { + const text = writeRead(read); + use.disabled = flat(text) === base; + money.update(text); + }; + const say = new Sentence(card, doc, changed, trial); + const lines = el("div", { className: "m01-easycond__lines" }); + read.blocks.forEach((b, i) => { + if (read.blocks.length > 1) + lines.append( + el("div", { className: "m01-easycond__part", text: `${te("Part")} ${i + 1}` }), + ); + lines.append(...say.block(b.shape)); + }); + const cancel = createButton({ + label: te("Cancel"), + variant: "ghost", + onClick: () => (finish(null), close()), + }); + const foot = el("div", { className: "m01-easycond__foot", children: [cancel, use] }); + body.append(lines, money.box, foot); + money.update(writeRead(read)); + }, + }); + }); +} diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Entry.ts b/M01_MasterData/EasyCalc/M01_EasyCalc_Entry.ts index 5b052cb8f..79366262c 100644 --- a/M01_MasterData/EasyCalc/M01_EasyCalc_Entry.ts +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Entry.ts @@ -1,8 +1,10 @@ /* ============================================================================= * M01_EasyCalc_Entry.ts - * 쉬운 단가산출 시험 길 — 들어가는 단추 · 큰 창 틀 · 시작 화면 · 고치는 화면 틀(위 띠 · 자리들) + * 쉬운 단가산출 시험 길 — 들어가는 단추 · 큰 창 틀 · 시작 화면 · 고치는 화면(위 띠) · 모든 파일 잇기 * 모양은 `docs/raw/M01_쉬운단가/설계.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄). - * 1걸음 = 틀만 — 카드 · 설계 때 정하는 값 · 실무 일위대가표 · 저장 · 지우기 · 되돌리기는 2걸음에 이음. + * 잇기: 열기(openEasy) → toDoc → renderCards · renderSheet · renderTrialStrip → 바뀔 때마다 + * EasyHistory.push · calcLater → 저장(새로 newEasy · 내 것 editEasy · 품셈 기본은 copyEasy 뒤 그 복사본) + * · 지우기(usesOf 먼저 · 물음 한 번) · Ctrl+Z · Ctrl+S * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; @@ -16,15 +18,32 @@ import { showToast, } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; -import { fetchLogic, fetchLogics, type LogicRow } from "../M01_MasterData_UI_Logic_Api"; +import type { CalcAnswer, LogicInput } from "../M01_MasterData_UI_Logic_Api"; +import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; import { + calcLater, + copyEasy, + deleteEasy, + editEasy, listEasy, + newEasy, openEasy, plainError, + usesOf, type EasyListItem, - type EasyOpened, } from "./M01_EasyCalc_Api"; -import type { EasyDoc } from "./M01_EasyCalc_Model"; +import { renderCards } from "./M01_EasyCalc_Cards"; +import { + checkName, + EasyHistory, + newDoc, + setEasyModelLanguage, + toDoc, + toRow, + type EasyCard, + type EasyDoc, +} from "./M01_EasyCalc_Model"; +import { renderSheet, renderTrialStrip } from "./M01_EasyCalc_Sheet"; import "./M01_EasyCalc_Style.css"; const TEXT = { @@ -42,10 +61,8 @@ const TEXT = { Start_Found: ["찾은 것", "Found"], Start_Count: ["{n} 개 중 {m} 개 보임 — 더 좁혀 찾기", "Showing {m} of {n} — narrow the search"], Start_None: ["찾은 것 없음", "Nothing found"], - Base: ["품셈 기본", "Standard"], - Mine: ["내가 만든 것", "Made by me"], - CannotOpen: ["쉬운 길로 못 엶", "Not for the easy view"], Per: ["{u} 당", "per {u}"], + CannotOpen: ["쉬운 길로 못 엶", "Not for the easy view"], Name: ["이름", "Name"], NamePlaceholder: ["예: 거푸집 설치", "e.g. formwork setup"], Spec: ["크기", "Size"], @@ -55,15 +72,27 @@ const TEXT = { Save: ["저장", "Save"], Delete: ["지우기", "Delete"], Unsaved: ["저장 안 함", "Not saved"], + Saving: ["저장 중", "Saving"], + Saved: ["저장했음", "Saved"], + SaveFailed: ["저장 못 함", "Not saved"], BaseNote: [ "품셈 기본은 읽기만 — 고치려면 아래 단추로 내 것을 만듦", "Standard items are read-only — use the button below to make your own copy", ], BaseCopy: ["이걸 바탕으로 고치기", "Edit a copy of this"], - Trial: ["설계 때 정하는 값", "Values set at design time"], + Copying: ["내 것으로 만드는 중", "Making your copy"], + Copied: ["내 것으로 만들었음 — 이제 고칠 수 있음", "Your copy is ready — you can edit it now"], Cards: ["무엇이 들어가나", "What goes in"], Sheet: ["실무 일위대가표로 보기", "View as a unit-cost sheet"], - NotYet: ["준비 중", "Coming soon"], + BadLines: [ + "⚠ 표시 줄을 먼저 고쳐 주세요 — 빈 이름 · 숫자 아님 · 0 · 0 보다 작은 수는 저장 안 됨", + "Fix the ⚠ lines first — empty names, non-numbers, 0 or negative amounts can't be saved", + ], + Uses_Wait: ["쓰는 곳을 찾는 중 — 10초쯤 걸림", "Looking for places that use it — about 10 s"], + Uses_Block: ["쓰는 곳이 있어 못 지움 — {v}", "Cannot delete — it is used by {v}"], + Delete_Ask: ["「{v}」 을(를) 지울까요? 되살릴 수 없음", "Delete “{v}”? This cannot be undone"], + Delete_Ok: ["지우기", "Delete"], + Deleted: ["지웠음", "Deleted"], Leave_Ask: [ "저장 안 한 것이 있음 — 저장하지 않고 닫을까요?", "There are unsaved changes — close without saving?", @@ -82,68 +111,6 @@ const te = (key: TextKey, vars: Record = {}): string => /** 목록에 한 번에 보이는 줄 수 */ const SHOWN = 50; -/* ponytail: Api(sub4) 속이 「준비 중」 인 동안만 Logic_Api 로 대신함 — Api 가 채워지면 아래 셋의 catch 를 지움 */ -const loadList = (): Promise => - listEasy("").catch(() => - fetchLogics().then((all) => - all.map((x) => { - const isBase = !x.키.startsWith("GX"); - const canOpen = x.결과단위.startsWith("원/"); - return { - key: x.키, - name: x.이름, - isBase, - owner: isBase ? te("Base") : te("Mine"), - resultUnit: x.결과단위, - canOpen, - whyNot: canOpen ? null : te("CannotOpen"), - where: `${x.구분} ${x.상세구분}`.trim(), - }; - }), - ), - ); -const loadOne = (key: string): Promise => - openEasy(key).catch(() => - fetchLogic(key).then((one) => ({ - row: one.logic, - version: one.version, - isBase: !one.logic.키.startsWith("GX"), - trial: {}, - })), - ); -const plain = (error: unknown): string => { - try { - return plainError(error); - } catch { - return error instanceof Error ? error.message : String(error); - } -}; - -/** 빈 종이 — 꼭 채울 칸만(검토 A 「꼭 채울 것」) · 구분 · 상세구분 · 소유 · 키는 서버가 채움 */ -const blankRow = (resultUnit: string): LogicRow => ({ - 키: "", - 원문번호: "", - 이름: "", - 결과단위: resultUnit, - 출처: "자체", - 입력: [], - 중간: [], - 호표: [], - 덧줄: [], - 끝수: null, -}); - -const docOf = (opened: EasyOpened, key: string | null): EasyDoc => ({ - key, - version: opened.version, - isBase: opened.isBase, - name: opened.row.이름, - spec: "", - resultUnit: opened.row.결과단위, - inputs: opened.row.입력 ?? [], - cards: [], - row: opened.row, -}); /** 「원/㎡」 → 「㎡」 */ const unitOf = (resultUnit: string): string => resultUnit.replace(/^원\//, ""); /** 「㎡」 → 「1㎡」 · 「100m」 은 그대로 */ @@ -152,50 +119,82 @@ const amountOf = (resultUnit: string): string => { return /^\d/.test(unit) ? unit : `1${unit}`; }; +/** 저장을 막는 줄 — 이름 빔 · 수량이 숫자 아님 · 0 · 음수(서버가 0 · 음수를 받아 줌) · 같은 줄 · 너무 큼은 경고만 */ +const blocksSave = (c: EasyCard): boolean => + !c.name.trim() || + (c.qtyFormula === null && !(c.qty !== null && Number.isFinite(c.qty) && c.qty > 0)); + +/** 카드를 넣어 새로 생긴 설계 값의 시험값 — 서버 자동값(`master_auto.values`)과 같은 규칙: + * 고르기 = 첫째 · 범위 = 낮은 끝 · 그 밖 숫자 0 · 값이 「원」 인 가격 값은 비워 둠(0 원처럼 안 보이게) */ +function fillNew( + inputs: LogicInput[], + trial: Record, + known: Set, +): Record { + const out = { ...trial }; + for (const spec of inputs) { + if (known.has(spec.이름)) continue; + known.add(spec.이름); + if (out[spec.이름] !== undefined || String(spec.단위 ?? "").includes("원")) continue; + out[spec.이름] = spec.고르기?.length ? spec.고르기[0] : (spec.범위?.[0] ?? 0); + } + return out; +} + /** 「추가」 옆에 놓는 단추 — 누르면 큰 창 */ export function createEasyCalcButton(): HTMLButtonElement { return createButton({ label: te("Open"), variant: "filled", onClick: openEasyCalc }); } function openEasyCalc(): void { + setEasyModelLanguage(currentLanguageIndex); let body: HTMLElement; - let paper: EasyDoc | null = null; - let dirty = false; + /** 고치는 화면이 열려 있을 때만 */ + let paper: { history: EasyHistory; save: () => Promise; undo: () => void } | null = null; let logics: EasyListItem[] | null = null; let query = ""; /** 저장 안 한 것이 있으면 한 번 물음 — 닫아도 되면 true */ const mayLeave = async (): Promise => - !paper || !dirty || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); + !paper?.history.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(); + } + }; /* ---------- 시작 화면 ---------- */ const listRow = (item: EasyListItem): HTMLElement => { - const ok = item.canOpen; const row = el("button", { className: "m01-easycalc__item", - attrs: { type: "button" }, + attrs: { type: "button", title: item.where }, children: [ el("span", { className: "m01-easycalc__item-name", text: item.name }), createTag(item.owner, item.isBase ? "neutral" : "accent"), el("span", { className: "m01-easycalc__item-unit", - text: ok ? te("Per", { u: amountOf(item.resultUnit) }) : item.resultUnit, + text: item.canOpen ? te("Per", { u: amountOf(item.resultUnit) }) : item.resultUnit, }), - ...(ok ? [] : [createTag(item.whyNot ?? te("CannotOpen"), "warning")]), + ...(item.canOpen ? [] : [createTag(te("CannotOpen"), "warning")]), ], }); - row.disabled = !ok; - row.title = item.where; + if (item.whyNot) row.title = item.whyNot; + row.disabled = !item.canOpen; row.addEventListener("click", () => void openPaper(item.key)); return row; }; const showStart = async (): Promise => { paper = null; - dirty = false; const status = el("p", { className: "m01-easycalc__muted", text: te("Loading") }); const mineHost = el("div", { className: "m01-easycalc__list" }); const foundHost = el("div", { className: "m01-easycalc__list" }); @@ -252,9 +251,9 @@ function openEasyCalc(): void { search.input.focus(); try { - logics ??= await loadList(); + logics ??= await listEasy(""); } catch (error) { - status.textContent = te("LoadFailed", { v: plain(error) }); + status.textContent = te("LoadFailed", { v: plainError(error) }); return; } const mine = logics.filter((x) => !x.isBase); @@ -266,85 +265,73 @@ function openEasyCalc(): void { drawFound(); }; - /* ---------- 고치는 화면 ---------- */ - - const openBlank = (): void => { - const row = blankRow(unitChoices()[0] ?? "원/㎡"); - paper = { ...docOf({ row, version: "", isBase: false, trial: {} }, null), version: null }; - showPaper(); - }; - - const openPaper = async (key: string): Promise => { - body.replaceChildren(el("p", { className: "m01-easycalc__muted", text: te("Loading") })); - try { - paper = docOf(await loadOne(key), key); - showPaper(); - } catch (error) { - showToast(plain(error), "error"); - void showStart(); - } - }; - - /** 단위 고르기 후보 — 목록에 많이 쓰인 「원/…」 차례 */ + /** 단위 고르기 후보 — 목록에 다섯 번 넘게 쓰인 「원/…」 차례 · 설명 붙은 드문 단위는 뺌(연 로직 것은 따로 더함) */ const unitChoices = (): string[] => { const count = new Map(); for (const x of logics ?? []) { if (x.canOpen) count.set(x.resultUnit, (count.get(x.resultUnit) ?? 0) + 1); } - return [...count.entries()].sort((a, b) => b[1] - a[1]).map(([u]) => u); + return [...count.entries()] + .filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u)) + .sort((a, b) => b[1] - a[1]) + .map(([u]) => u); }; - const showPaper = (): void => { - const p = paper; - if (!p) return; - const readOnly = p.isBase; + /* ---------- 고치는 화면 ---------- */ + + const openBlank = (): void => showPaper(newDoc("", unitOf(unitChoices()[0] ?? "원/㎡")), {}); + + const openPaper = async (key: string): Promise => { + body.replaceChildren(el("p", { className: "m01-easycalc__muted", text: te("Loading") })); + try { + const opened = await openEasy(key); + showPaper( + toDoc(opened.row, { key, version: opened.version, isBase: opened.isBase }), + opened.trial, + ); + } catch (error) { + showToast(plainError(error), "error"); + void showStart(); + } + }; + + const showPaper = (first: EasyDoc, firstTrial: Record): void => { + let doc = first; + let trial = firstTrial; + let ans: CalcAnswer | null = null; + let history = new EasyHistory(); + history.push(doc); + let busy = false; + const unsaved = createTag(te("Unsaved"), "warning"); - unsaved.hidden = true; const total = el("div", { className: "m01-easycalc__total" }); - const drawTotal = (): void => { - total.textContent = te("Total", { u: amountOf(p.resultUnit), v: "—" }); - }; - const touched = (): void => { - dirty = true; - unsaved.hidden = false; - }; + const note = el("p", { className: "m01-easycalc__note", attrs: { role: "status" } }); + const trialHost = el("div", { className: "m01-easycalc__part m01-easycalc__trial" }); + const cardsHost = el("div", { className: "m01-easycalc__cards" }); + const sheetHost = el("div"); + let trialShape = ""; + /** 연 때 있던 설계 값 — 서버 자동값을 그대로 둠 · 이 밖에 새로 생긴 값만 채움 */ + const known = new Set(doc.inputs.map((i) => i.이름)); const name = createInputField({ label: te("Name"), placeholder: te("NamePlaceholder"), - value: p.name, - onInput: (v) => { - p.name = v; - touched(); - }, + onInput: (v) => edit({ ...doc, name: v }), }); - const spec = createInputField({ - label: te("Spec"), - value: p.spec, - onInput: (v) => { - p.spec = v; - touched(); - }, - }); - const units = unitChoices(); - if (!units.includes(p.resultUnit)) units.unshift(p.resultUnit); + name.root.classList.add("m01-easycalc__name"); + const spec = createInputField({ label: te("Spec"), onInput: (v) => edit({ ...doc, spec: v }) }); const unit = createSelectField({ label: te("Unit"), - options: units.map((u) => ({ value: u, text: unitOf(u) })), - value: p.resultUnit, - disabled: readOnly, - onChange: (v) => { - p.resultUnit = v; - drawTotal(); - touched(); - }, + options: [], + onChange: (v) => edit({ ...doc, resultUnit: v }), + }); + const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: () => doUndo() }); + const saveButton = createButton({ label: te("Save"), onClick: () => void doSave() }); + const deleteButton = createButton({ + label: te("Delete"), + variant: "danger", + onClick: () => void doDelete(), }); - name.input.disabled = readOnly; - spec.input.disabled = readOnly; - name.root.classList.add("m01-easycalc__name"); - - const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: notYet }); - undo.disabled = true; const close = createButton({ label: te("Close"), variant: "ghost", @@ -353,62 +340,210 @@ function openEasyCalc(): void { if (await mayLeave()) void showStart(); })(), }); - const actions = readOnly - ? [close] - : [ - undo, - createButton({ label: te("Save"), onClick: notYet }), - ...(p.key && !p.isBase - ? [createButton({ label: te("Delete"), variant: "danger", onClick: notYet })] - : []), - close, - ]; - - const section = (title: string, extra = ""): HTMLElement => - el("section", { - className: `m01-easycalc__part ${extra}`.trim(), - children: [ - el("h4", { text: title }), - el("p", { className: "m01-easycalc__muted", text: te("NotYet") }), - ], - }); - const sheet = el("details", { - className: "m01-easycalc__part m01-easycalc__sheet", + const actions = el("div", { className: "m01-easycalc__actions" }); + const baseBar = el("div", { + className: "m01-easycalc__base", children: [ - el("summary", { text: te("Sheet") }), - el("p", { className: "m01-easycalc__muted", text: te("NotYet") }), + el("p", { text: te("BaseNote") }), + createBigButton(te("BaseCopy"), () => void doCopy()), ], }); - drawTotal(); + /** 위 띠 칸을 doc 로 — 열 때 · 되돌릴 때 · 저장 뒤 */ + const fillStrip = (): void => { + const units = unitChoices(); + if (!units.includes(doc.resultUnit)) units.unshift(doc.resultUnit); + unit.setOptions( + units.map((u) => ({ value: u, text: unitOf(u) })), + doc.resultUnit, + ); + name.input.value = doc.name; + spec.input.value = doc.spec; + for (const box of [name.input, spec.input, unit.select]) box.disabled = doc.isBase; + actions.replaceChildren( + ...(doc.isBase ? [] : [undo, saveButton]), + ...(doc.key && !doc.isBase ? [deleteButton] : []), + close, + ); + baseBar.hidden = !doc.isBase; + }; + + /** 금액 · 카드 · 표 — 바뀔 때마다(pending) · 계산 답이 올 때마다(answer) */ + const draw = (pending: boolean, answer = false): void => { + const value = ans?.ok ? formatMoney(ans.result ?? ans.sums?.["계"] ?? 0) : "—"; + total.textContent = te("Total", { u: amountOf(doc.resultUnit), v: value }); + total.classList.toggle("is-pending", pending); + sheetHost.classList.toggle("is-pending", pending); + unsaved.hidden = !history.dirty; + undo.disabled = !history.canUndo; + const shape = JSON.stringify(doc.inputs); + if (shape !== trialShape) { + trialShape = shape; + trial = fillNew(doc.inputs, trial, known); + renderTrialStrip(trialHost, doc.inputs, trial, (next) => { + trial = next; + calc(); + }); + } + drawCards(answer); + renderSheet(sheetHost, { name: doc.name, resultUnit: doc.resultUnit }, ans); + }; + + /** 계산 답만 왔을 때 카드 칸에 글을 넣는 중이면 그 칸을 떠난 뒤 그림(넣던 글이 안 날아가게). + * doc 가 바뀌면 늘 바로 그림 — 카드가 옛 doc 를 쥐고 있으면 그다음 조작이 옛 판본 위에 쌓임 */ + let waiting = false; + const drawCards = (answer = false): void => { + const active = document.activeElement; + if (answer && active instanceof HTMLInputElement && cardsHost.contains(active)) { + if (!waiting) { + waiting = true; + cardsHost.addEventListener( + "focusout", + () => + setTimeout(() => { + waiting = false; + drawCards(true); + }), + { once: true }, + ); + } + return; + } + renderCards(cardsHost, doc, ans, { change: edit, trial: () => trial }); + }; + + const calc = (): void => { + draw(true); + calcLater(toRow(doc), trial, (next, plain) => { + ans = next; + note.textContent = plain ?? ""; + draw(false, true); + }); + }; + + const edit = (next: EasyDoc): void => { + doc = next; + history.push(doc); + calc(); + }; + + const doUndo = (): void => { + const back = history.undo(); + if (!back) return; + doc = back; + fillStrip(); + calc(); + }; + + /** 저장 뒤 · 본뜬 뒤 — 서버가 준 줄로 다시 엶 · 되돌리기 기록은 여기서 새로 */ + const reopen = (next: EasyDoc): void => { + doc = next; + history = new EasyHistory(); + history.push(doc); + logics = null; + fillStrip(); + calc(); + }; + + const doSave = async (): Promise => { + if (doc.isBase || busy) return; + const mine = (logics ?? []).filter((x) => !x.isBase && x.key !== doc.key).map((x) => x.name); + const bad = checkName(doc.name, mine); + name.setError(bad ?? undefined); + if (bad) return void name.input.focus(); + if (doc.cards.some(blocksSave)) return void (note.textContent = te("BadLines")); + busy = true; + saveButton.disabled = true; + note.textContent = te("Saving"); + try { + const row = toRow(doc); + const saved = + doc.key && doc.version ? await editEasy(doc.key, doc.version, row) : await newEasy(row); + reopen(toDoc(saved.row, { key: saved.key, version: saved.version, isBase: false })); + note.textContent = ""; + showToast(te("Saved"), "success"); + } catch (error) { + note.textContent = `${te("SaveFailed")} — ${plainError(error)}`; + } finally { + busy = false; + saveButton.disabled = false; + } + }; + + const doCopy = async (): Promise => { + if (!doc.key || busy) return; + busy = true; + note.textContent = te("Copying"); + try { + const saved = await copyEasy(doc.key); + reopen(toDoc(saved.row, { key: saved.key, version: saved.version, isBase: false })); + showToast(te("Copied"), "success"); + } catch (error) { + note.textContent = plainError(error); + } finally { + busy = false; + } + }; + + const doDelete = async (): Promise => { + if (!doc.key || !doc.version || doc.isBase || busy) return; + busy = true; + deleteButton.disabled = true; + note.textContent = te("Uses_Wait"); + try { + const uses = await usesOf(doc.key); + const blocking = uses.filter((u) => u.key); + if (blocking.length) { + note.textContent = te("Uses_Block", { + v: blocking.map((u) => (u.where ? `${u.where} ${u.name}` : u.name)).join(", "), + }); + return; + } + note.textContent = uses.map((u) => u.name).join(" · "); + if (!(await showConfirmDialog(te("Delete_Ask", { v: doc.name }), te("Delete_Ok")))) { + return; + } + await deleteEasy(doc.key, doc.version); + logics = null; + paper = null; + showToast(te("Deleted"), "success"); + void showStart(); + } catch (error) { + note.textContent = plainError(error); + } finally { + busy = false; + deleteButton.disabled = false; + } + }; + + paper = { + get history() { + return history; + }, + save: doSave, + undo: doUndo, + }; + body.replaceChildren( el("div", { className: "m01-easycalc__strip", - children: [ - name.root, - spec.root, - unit.root, - total, - unsaved, - el("div", { className: "m01-easycalc__actions", children: actions }), - ], + children: [name.root, spec.root, unit.root, total, unsaved, actions], + }), + note, + baseBar, + trialHost, + el("section", { + className: "m01-easycalc__part", + children: [el("h4", { text: te("Cards") }), cardsHost], + }), + el("details", { + className: "m01-easycalc__part m01-easycalc__sheet", + children: [el("summary", { text: te("Sheet") }), sheetHost], }), - ...(readOnly - ? [ - el("div", { - className: "m01-easycalc__base", - children: [ - el("p", { text: te("BaseNote") }), - createBigButton(te("BaseCopy"), notYet), - ], - }), - ] - : []), - section(te("Trial"), "m01-easycalc__trial"), - section(te("Cards"), "m01-easycalc__cards"), - sheet, ); - if (!readOnly) name.input.focus(); + fillStrip(); + calc(); + if (!doc.isBase) name.input.focus(); }; // 화면 함수들을 다 만든 뒤에 엶 — mount 가 바로 showStart 를 부름 @@ -419,6 +554,7 @@ function openEasyCalc(): void { beforeClose: mayLeave, mount: (host) => { body = host; + (host.parentElement ?? host).addEventListener("keydown", onKey); void showStart(); }, }); diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Pick.ts b/M01_MasterData/EasyCalc/M01_EasyCalc_Pick.ts index a7b9bc8cb..04d8a375d 100644 --- a/M01_MasterData/EasyCalc/M01_EasyCalc_Pick.ts +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Pick.ts @@ -15,12 +15,14 @@ import { } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import { fetchPick, fetchSubs, type SubInfo } from "../M01_MasterData_Api_Fetch"; -import { fetchMaterials } from "../M01_MasterData_UI_Logic_Api"; -import { listEasy, listMachines, machineElement } from "./M01_EasyCalc_Api"; +import { fetchLogic, fetchMaterials, type ElementBrief } from "../M01_MasterData_UI_Logic_Api"; +import { listEasy, listMachines, machineElement, plainError } from "./M01_EasyCalc_Api"; import type { PickResult } from "./M01_EasyCalc_Model"; import "./M01_EasyCalc_Pick.css"; type Kind = PickResult["kind"]; +/** 후보 줄 — 서버는 구분 · 상세구분도 주지만 ElementBrief 타입에는 없음 */ +type Brief = ElementBrief & { 구분?: string; 상세구분?: string }; const TEXT = { Title: ["넣을 것 고르기", "Pick what to add"], @@ -64,6 +66,8 @@ const te = (key: keyof typeof TEXT, vars: Record = {}): ); const won = (n: number): string => Math.round(n).toLocaleString("ko-KR"); +/** 목록에 그릴 줄 수 — 더 찾으려면 이름을 쳐서 좁힘 */ +const SHOW = 100; const MAT_FILE = "재료_자재품목.json"; /** 목록 한 줄 — 고르면 PickResult(수량 빼고) 를 만들어 줌 */ @@ -94,6 +98,23 @@ interface Source { load: (f: Filter) => Promise<{ total: number; items: Candidate[] } | null>; } +/** 찾는 말과 이름이 똑같으면 0 · 그 말로 시작하고 말이 거기서 끝나면(「모래, 서울」 「모래(건조)」) 1 · + * 더 이어지면(「모래적사장」) 2 · 나머지 3 — 앞에 놓음(같은 차례끼리 순서는 그대로) */ +const byName = (items: T[], q: string): T[] => { + const squash = (t: string): string => t.replace(/\s+/g, "").toLowerCase(); + const w = squash(q); + const rank = (it: T): number => { + const n = squash(it.이름 ?? ""); + if (n === w) return 0; + if (!n.startsWith(w)) return 3; + return /[가-힣a-z0-9]/.test(n.charAt(w.length)) ? 2 : 1; + }; + return items + .map((it, i) => ({ it, i, r: rank(it) })) + .sort((a, b) => a.r - b.r || a.i - b.i) + .map((x) => x.it); +}; + const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null); const SOURCES: Partial> = { @@ -133,14 +154,14 @@ const SOURCES: Partial> = { const found = f.q ? await fetchPick("price", f.q).then((r) => ({ total: r.total, - items: r.items - .filter((it) => !f.sub || (it as { 구분?: string }).구분 === f.sub) - .map((it) => ({ ...it, 구분: (it as { 구분?: string }).구분, 상세구분: undefined })), + items: byName(r.items, f.q) + .filter((it) => !f.sub || (it as Brief).구분 === f.sub) + .map((it) => ({ ...it, 구분: (it as Brief).구분 })), })) : await fetchMaterials({ sub: f.sub, ...(f.detail ? { detail: f.detail } : {}) }).then( (r) => ({ total: r.total, - items: r.items.map((it) => ({ ...it, 구분: it.구분 ?? f.sub })), + items: r.items.map((it) => ({ ...it, 구분: (it as Brief).구분 ?? f.sub })), }), ); return { @@ -151,7 +172,7 @@ const SOURCES: Partial> = { const unit = typeof it.단위 === "string" ? it.단위 : ""; const price = num(it.값); const element: Record = { 구분: it.구분 ?? f.sub }; - const detail = (it as { 상세구분?: string }).상세구분; + const detail = (it as Brief).상세구분; if (detail) element["상세구분"] = detail; element["검색어"] = `${name} ${spec}`.trim(); return { @@ -213,13 +234,20 @@ const SOURCES: Partial> = { price: null, priceText: "", tag: e.owner, - make: async () => ({ - kind: "logic", - name: e.name, - spec: "", - unit, - element: `로직(${e.key})`, - }), + make: async () => { + // 그 일위대가가 받는 값은 같은 이름으로 넘기고 「설계 때 정하는 값」으로 돌려줌(기계와 같은 꼴) + const needInputs = (await fetchLogic(e.key)).logic.입력; + const quote = (n: string): string => (/^[\w가-힣]+$/.test(n) ? n : `'${n}'`); + const rest = needInputs.map((i) => `, ${quote(i.이름)}=${quote(i.이름)}`).join(""); + return { + kind: "logic", + name: e.name, + spec: "", + unit, + element: `로직(${e.key}${rest})`, + needInputs, + }; + }, }; }), }; @@ -228,7 +256,10 @@ const SOURCES: Partial> = { }; /** 물음 셋 — 고른 것 · 취소는 null */ -export function openEasyPick(kind?: Kind): Promise { +export function openEasyPick( + kind?: Kind, + opts: { qty?: number | null } = {}, +): Promise { return new Promise((resolve) => { let settled = false; const finish = (r: PickResult | null): void => { @@ -294,16 +325,17 @@ export function openEasyPick(kind?: Kind): Promise { return void list.replaceChildren( el("p", { className: "m01-easypick__muted", text: te("Hint") }), ); - count.textContent = te("Count", { n: found.total, m: found.items.length }); + const shown = found.items.slice(0, SHOW); + count.textContent = te("Count", { n: found.total, m: shown.length }); list.replaceChildren( - ...(found.items.length - ? found.items.map((c) => + ...(shown.length + ? shown.map((c) => rowOf(c, () => stepQty(k, c, back ? () => stepList(k, canBack) : null)), ) : [el("p", { className: "m01-easypick__muted", text: te("None") })]), ); - } catch { - if (mine === seq) showToast(te("Failed"), "error"); + } catch (e) { + if (mine === seq) showToast(plainError(e), "error"); } }; if (src.grouped) bar.unshift(...subFilters(filter, load)); @@ -334,6 +366,7 @@ export function openEasyPick(kind?: Kind): Promise { placeholder: te("QtyPlace"), }); qty.input.inputMode = "decimal"; + if (opts.qty != null) qty.input.value = String(opts.qty); // 다시 고르기 — 지금 수량 미리 채움 const rough = el("p", { className: "m01-easypick__rough" }); const parse = (): number | null => { const n = Number(qty.input.value.trim().replace(/,/g, "")); @@ -348,7 +381,11 @@ export function openEasyPick(kind?: Kind): Promise { const submit = async (): Promise => { const n = parse(); if (n === null) return qty.setError(te("QtyBad")); - done({ ...(await c.make()), qty: n }); + try { + done({ ...(await c.make()), qty: n }); + } catch (e) { + qty.setError(plainError(e)); + } }; qty.input.addEventListener("input", refresh); qty.input.addEventListener("keydown", (ev) => ev.key === "Enter" && void submit()); diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Style.css b/M01_MasterData/EasyCalc/M01_EasyCalc_Style.css index e23ed19f7..5f98a5da2 100644 --- a/M01_MasterData/EasyCalc/M01_EasyCalc_Style.css +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Style.css @@ -157,3 +157,19 @@ cursor: pointer; font-weight: 600; } + +/* 고친 뒤 금액을 다시 셈하는 동안 옛 금액을 흐리게 */ +.m01-easycalc .is-pending { + opacity: 0.45; +} + +.m01-easycalc__note { + margin: 0; + color: var(--color-text-secondary); + font-size: var(--text-body-sm); + white-space: pre-line; +} + +.m01-easycalc__note:empty { + display: none; +} diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Table.css b/M01_MasterData/EasyCalc/M01_EasyCalc_Table.css index 79aa7fd24..b2788c7b7 100644 --- a/M01_MasterData/EasyCalc/M01_EasyCalc_Table.css +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Table.css @@ -124,6 +124,22 @@ opacity: 0.5; } +.m01-easytable__hint { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); + margin: 0; + padding: var(--spacing-8) var(--spacing-12); + border-radius: var(--radius-lg); + background: color-mix(in srgb, var(--color-accent) 10%, var(--color-canvas)); + font-size: var(--text-body-sm); +} + +.m01-easytable__hint:empty { + display: none; +} + .m01-easytable__warn { margin: 0; color: var(--color-danger); diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Table.ts b/M01_MasterData/EasyCalc/M01_EasyCalc_Table.ts index 68af5ab3e..14b8be121 100644 --- a/M01_MasterData/EasyCalc/M01_EasyCalc_Table.ts +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Table.ts @@ -45,6 +45,10 @@ const TEXT = { Total: ["합계", "Total"], Wait: ["계산 중", "Calculating"], Stopped: ["계산 멈춤", "Calculation stopped"], + DesignHint: [ + "이 값은 설계 때 정함 — 늘 이 줄로 하려면", + "This value is set at design — to always use this row,", + ], Mismatch: [ "고른 줄이 이 조건으로는 안 걸림 — 조건을 다시 보세요", "The picked row isn't matched by these conditions", @@ -351,6 +355,8 @@ interface CallState { picked: number | null; /** 「설계 때 정함」 조건을 고른 줄 값으로 미리 봄 */ tryValues: Record; + /** 마지막으로 누른 줄이 미리 보기만 한 「설계 때 정함」 조건 */ + designHint: string[]; } const showCell = (v: Cell): string => { @@ -367,7 +373,7 @@ function startState(call: FindCall, doc: EasyDoc): CallState { const mode: Mode = names.has(a.rhs) ? "design" : lit !== null ? "fix" : "asis"; return { key: a.key, orig: a.rhs, mode, input, fix: lit }; }); - return { conds, col: call.col, picked: null, tryValues: {} }; + return { conds, col: call.col, picked: null, tryValues: {}, designHint: [] }; } function stateArgs(st: CallState, table: EasyTable | null): FindArg[] | null { @@ -465,6 +471,7 @@ async function mountTable( const grid = el("div", { className: "m01-easytable__grid" }); const money = el("div", { className: "m01-easytable__money" }); const warn = el("p", { className: "m01-easytable__warn" }); + const hint = el("p", { className: "m01-easytable__hint" }); const use = createButton({ label: tt("Use"), onClick: () => finish(newFormula()) }); const cancel = createButton({ label: tt("Cancel"), @@ -491,6 +498,7 @@ async function mountTable( const st = states[now]; const row = table?.줄[idx]; if (!table || !row) return; + st.designHint = []; for (const key of Object.keys(table.조건)) { if (row[key] === undefined) continue; let c = st.conds.find((x) => x.key === key); @@ -504,6 +512,7 @@ async function mountTable( ? rangeNumber(row[key] as [number | null, number | null], table.범위규칙 ?? "") : row[key]; st.tryValues[c.input] = n; + st.designHint.push(key); } else { c.mode = "fix"; c.fix = row[key]; @@ -521,9 +530,36 @@ async function mountTable( c.fix = row?.[c.key] ?? (c.input ? (inputs()[c.input] as Cell) : null); } c.mode = mode; + states[now].designHint = states[now].designHint.filter((k) => k !== c.key); void refresh(); }; + /** 누른 줄의 「설계 때 정함」 조건을 한 번에 그 줄 값으로 고정 */ + const fixPicked = (): void => { + const st = states[now]; + const row = st.picked === null ? null : tbl[now]?.줄[st.picked]; + if (!row) return; + for (const c of st.conds) + if (st.designHint.includes(c.key)) { + c.mode = "fix"; + c.fix = row[c.key]; + } + st.designHint = []; + void refresh(); + }; + + const paintHint = (): void => { + const st = states[now]; + hint.replaceChildren( + ...(st.picked !== null && st.designHint.length + ? [ + el("span", { text: `${tt("DesignHint")} ` }), + createButton({ label: tt("Fix"), size: "sm", onClick: fixPicked }), + ] + : []), + ); + }; + const paintConds = (): void => { const st = states[now]; condBox.replaceChildren( @@ -579,10 +615,7 @@ async function mountTable( const th = (text: string, cls = ""): HTMLElement => el("th", { className: cls, text }); const rows = table.줄.map((row, idx) => { const tr = el("tr", { - className: [ - idx === hit && !busy ? "is-hit" : "", - idx === st.picked ? "is-picked" : "", - ].join(" "), + className: `${idx === hit && !busy ? "is-hit" : ""} ${idx === st.picked ? "is-picked" : ""}`, children: [ ...conds.map((k) => el("td", { text: showCell(row[k]) })), ...vals.map((k) => @@ -600,28 +633,13 @@ async function mountTable( }); return tr; }); - grid.replaceChildren( - el("table", { - children: [ - el("thead", { - children: [ - el("tr", { - children: [ - ...conds.map((k) => th(k)), - ...vals.map((k) => - th( - `${k}${table.값칸[k] ? ` (${table.값칸[k]})` : ""}`, - k === st.col ? "is-col" : "", - ), - ), - ], - }), - ], - }), - el("tbody", { children: rows }), - ], - }), - ); + const unit = (k: string): string => (table.값칸[k] ? `${k} (${table.값칸[k]})` : k); + const top = [ + ...conds.map((k) => th(k)), + ...vals.map((k) => th(unit(k), k === st.col ? "is-col" : "")), + ]; + const thead = el("thead", { children: [el("tr", { children: top })] }); + grid.replaceChildren(el("table", { children: [thead, el("tbody", { children: rows })] })); }; const paint = (): void => { @@ -646,6 +664,7 @@ async function mountTable( el("span", { className: "m01-easytable__note", text: tt("Hint") }), ); paintConds(); + paintHint(); paintGrid(); const picked = states[now].picked; const mismatch = !busy && picked !== null && last.hits[now] !== picked; @@ -667,6 +686,7 @@ async function mountTable( which, head, condBox, + hint, grid, money, warn, diff --git a/resources/tester/test_m01_easycalc_cond.py b/resources/tester/test_m01_easycalc_cond.py new file mode 100644 index 000000000..2d74cabf7 --- /dev/null +++ b/resources/tester/test_m01_easycalc_cond.py @@ -0,0 +1,198 @@ +"""M01 쉬운 단가산출 조건 보고 고치기 시험(34-3c) — 읽기 · 짜기 구역을 떼어 Node 로 돌림 · 화면 조작은 ORCA. + +· 견본 식은 이 시험 안에 둠 — 진짜 마스터에 기대지 않음 +· 만약(사슬) · 최소/최대 · 올림 · 식 속 덩어리 · 중간 값 따라가기 · 못 읽는 꼴 · 고친 뒤 짜기 왕복 +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +COND = ROOT / "M01_MasterData" / "EasyCalc" / "M01_EasyCalc_Cond.ts" + +pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") + +INPUTS = [ + {"이름": "비탈구배", "고르기": ["해당 없음", "1:1.0", "1:1.5"]}, + {"이름": "뒷길이", "단위": "㎝"}, + {"이름": "교환측", "고르기": ["양측", "한측"]}, + {"이름": "층수", "고르기": [1, 2, 3]}, +] +ROW = {"입력": INPUTS, "중간": [{"이름": "석공품", "식": "만약(뒷길이 > 40, 0.4, 0.3)"}]} + + +def _run(body: str, *args: object) -> object: + """읽기 · 짜기 구역 + 시험 글 — `A` = 넘긴 인자 · `out(x)` 로 답""" + src = COND.read_text(encoding="utf-8") + part = src[src.index("/* ── 읽기 · 짜기") : src.index("/* ── 화면")] + script = ( + "type LogicInput = any; type LogicRow = any;\n" + + part + + "\nconst A = JSON.parse(process.argv[1]);\n" + + "const out = (x: unknown) => console.log(JSON.stringify(x));\n" + + body + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script, json.dumps(list(args))], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + return json.loads(result.stdout) + + +def _read(formula: str) -> dict | None: + return _run("out(readCond(A[0], A[1]));", formula, ROW) + + +def _edit(formula: str, js: str) -> str: + """읽은 뒤 `js` 로 덩어리(`B` = 첫 덩어리 꼴)를 고치고 다시 짬""" + return _run( + f"const R = readCond(A[0], A[1]); const B = R.blocks[0].shape; {js}; out(writeRead(R));", + formula, + ROW, + ) + + +# ── 읽기 ─────────────────────────────────────────────────────────────────── +def test_만약_한겹_설계값_표() -> None: + f = "만약(비탈구배 == '해당 없음', 0, 찾기(QF000001, 구배=비탈구배).값)" + r = _read(f) + assert r["via"] is None and r["text"] == f and len(r["blocks"]) == 1 + b = r["blocks"][0] + assert b["p"] == [0, len(f)] + assert b["shape"] == { + "type": "if", + "branches": [ + { + "test": { + "left": {"kind": "input", "name": "비탈구배"}, + "op": "==", + "right": {"kind": "text", "value": "해당 없음"}, + }, + "value": {"kind": "num", "value": 0}, + } + ], + "otherwise": {"kind": "table", "src": "찾기(QF000001, 구배=비탈구배).값"}, + } + + +def test_만약_사슬() -> None: + r = _read("만약(층수 == 1, 2, 만약(층수 == 2, 3.5, -1))") + shape = r["blocks"][0]["shape"] + assert [x["value"] for x in shape["branches"]] == [ + {"kind": "num", "value": 2}, + {"kind": "num", "value": 3.5}, + ] + assert shape["otherwise"] == {"kind": "num", "value": -1} + assert shape["branches"][1]["test"]["right"] == {"kind": "num", "value": 2} + + +def test_최대_올림() -> None: + pick = _read("최대(시공량 * 0.5, 3)")["blocks"][0]["shape"] + assert pick == { + "type": "pick", + "fn": "최대", + "items": [{"kind": "calc", "src": "시공량 * 0.5"}, {"kind": "num", "value": 3}], + } + up = _read("올림(찾기(QC000001).값 / 시공량, 2)")["blocks"][0]["shape"] + assert (up["type"], up["fn"], up["places"], up["hadPlaces"]) == ("round", "올림", 2, True) + assert up["value"] == {"kind": "calc", "src": "찾기(QC000001).값 / 시공량"} + assert _read("버림(뒷길이)")["blocks"][0]["shape"]["hadPlaces"] is False + + +def test_식_속_덩어리와_중간값() -> None: + f = "찾기(QC001975, 구간=구간).궤도공 * 만약(교환측 == '양측', 1, 0.65)" + r = _read(f) + assert len(r["blocks"]) == 1 and r["blocks"][0]["p"] == [f.index("만약"), len(f)] + many = _read("(1 + 만약(층수 > 1, 10, 0) + 최대(뒷길이, 30)) / 100") + assert [b["shape"]["type"] for b in many["blocks"]] == ["if", "pick"] + via = _read("석공품") + assert via["via"] == "석공품" and via["text"] == "만약(뒷길이 > 40, 0.4, 0.3)" + + +@pytest.mark.parametrize( + "formula", + [ + "만약(뒷길이 > 40 그리고 층수 < 2, 1, 0)", + "찾기(QC000001, 항목=만약(교환측 == '양측', 'a', 'b')).수량", + "찾기(QC000001).값 * 2", + "0.5", + "만약(뒷길이 > 40, 1", + "", + ], + ids=["그리고", "찾기속", "조건없음", "숫자", "괄호깨짐", "빈글"], +) +def test_못_읽는_꼴(formula: str) -> None: + assert _read(formula) is None + + +# ── 짜기 왕복 ─────────────────────────────────────────────────────────────── +@pytest.mark.parametrize( + "formula", + [ + "만약(비탈구배 == '해당 없음', 0, 찾기(QF000001, 구배=비탈구배).값)", + "만약(층수 == 1, 2, 만약(층수 == 2, 3.5, -1))", + "최소(시공량 * 0.5, 3, 뒷길이)", + "반올림(찾기(QC000001).값 / 시공량, 2)", + "찾기(QC001975, 구간=구간).궤도공 * 만약(교환측 == '양측', 1, 0.65) * (1 + 할증 / 100)", + "(용접공 * LB000012 + 특별인부 * LB000003) * 만약(교환측 == '양측', 22, 18) / 100", + ], +) +def test_안_고치면_같은_글(formula: str) -> None: + assert _run("out(writeRead(readCond(A[0], A[1])));", formula, ROW) == formula + + +def test_고친_뒤_짜기() -> None: + f = "만약(비탈구배 == '해당 없음', 0, 찾기(QF000001, 구배=비탈구배).값)" + assert ( + _edit(f, "B.branches[0].test.right.value = '1:1.5'; B.branches[0].test.op = '!='") + == "만약(비탈구배 != '1:1.5', 0, 찾기(QF000001, 구배=비탈구배).값)" + ) + assert ( + _edit(f, "B.otherwise = { kind: 'num', value: 0.25 }") + == "만약(비탈구배 == '해당 없음', 0, 0.25)" + ) + assert ( + _edit( + "찾기(QC001975, 구간=구간).궤도공 * 만약(교환측 == '양측', 1, 0.65)", + "B.branches[0].value.value = 1.1", + ) + == "찾기(QC001975, 구간=구간).궤도공 * 만약(교환측 == '양측', 1.1, 0.65)" + ) + assert ( + _edit("최대(시공량 * 0.5, 3)", "B.fn = '최소'; B.items[1].value = 4") + == "최소(시공량 * 0.5, 4)" + ) + assert _edit("올림(뒷길이 / 3)", "B.places = 1; B.fn = '반올림'") == "반올림(뒷길이 / 3, 1)" + assert _edit("석공품", "B.branches[0].test.left.name = '층수'; B.otherwise.value = 0.2") == ( + "만약(층수 > 40, 0.4, 0.2)" + ) + + +def test_둘째_덩어리_고침() -> None: + f = "(1 + 만약(층수 > 1, 10, 0) + 최대(뒷길이, 30)) / 100" + got = _run( + "const R = readCond(A[0], A[1]); R.blocks[1].shape.items[1].value = 45; out(writeRead(R));", + f, + ROW, + ) + assert got == "(1 + 만약(층수 > 1, 10, 0) + 최대(뒷길이, 45)) / 100" + + +# ── 화면 글 ───────────────────────────────────────────────────────────────── +def test_화면_글에_금지_낱말_없음() -> None: + src = COND.read_text(encoding="utf-8") + block = src[src.index("const TEXT = {") : src.index("} as const;")] + korean = [ko for _, ko in re.findall(r'(\w+):\s*\[\s*"([^"]*)"', block)] + assert len(korean) >= 30 + for text in korean: + for bad in ("키", "식", "호표", "덧줄", "비목", "중간 값", "끝수", "정본", "자체", "별칭"): + assert bad not in text, (bad, text)