Merge remote-tracking branch 'origin/dev' into sub_laptop_4
This commit is contained in:
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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<EasyCard["percentBase"]>;
|
||||
const BASES: PercentBase[] = ["노무비", "재료비", "경비", "사람줄합"];
|
||||
const COSTS: CostKind[] = ["노무비", "재료비", "경비"];
|
||||
|
||||
interface Ctx {
|
||||
doc: EasyDoc;
|
||||
lines: Map<string, CalcLine>;
|
||||
readOnly: boolean;
|
||||
on: { change(doc: EasyDoc): void; trial?(): Record<string, unknown> };
|
||||
/** 숫자 아님으로 친 글 — 다시 그려도 칸에 남김 */
|
||||
badText: Map<string, string>;
|
||||
}
|
||||
|
||||
const badTexts = new WeakMap<HTMLElement, Map<string, string>>();
|
||||
const closedGroups = new WeakMap<HTMLElement, Set<Group>>();
|
||||
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<string, CalcLine> {
|
||||
const out = new Map<string, CalcLine>();
|
||||
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<EasyCard>): 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<T extends string>(
|
||||
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<Group>();
|
||||
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<string, unknown> },
|
||||
): void {
|
||||
const badText = badTexts.get(host) ?? new Map<string, string>();
|
||||
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)]),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 = <T extends object>(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<LogicRow, "입력" | "중간">): 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<string, unknown>,
|
||||
) => Promise<string | null>;
|
||||
}>("./M01_EasyCalc_Table.ts");
|
||||
|
||||
/** 값 자리 바꿔 쓰기 — 같은 물건 그대로(문장이 쥔 자리를 놓치지 않음) */
|
||||
const put = (slot: CondSlot, next: CondSlot): void => {
|
||||
for (const k of Object.keys(slot)) delete (slot as Record<string, unknown>)[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<string, unknown>,
|
||||
) {}
|
||||
|
||||
/** 값 칸 — 숫자는 숫자 칸 · 글 · 설계 값은 글자 · 나머지는 칩(+ 숫자로) */
|
||||
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<string, unknown>) {
|
||||
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<string, unknown> = {},
|
||||
): Promise<string | null> {
|
||||
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));
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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, string | number> = {}): string =>
|
||||
/** 목록에 한 번에 보이는 줄 수 */
|
||||
const SHOWN = 50;
|
||||
|
||||
/* ponytail: Api(sub4) 속이 「준비 중」 인 동안만 Logic_Api 로 대신함 — Api 가 채워지면 아래 셋의 catch 를 지움 */
|
||||
const loadList = (): Promise<EasyListItem[]> =>
|
||||
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<EasyOpened> =>
|
||||
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<string, unknown>,
|
||||
known: Set<string>,
|
||||
): Record<string, unknown> {
|
||||
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<void>; undo: () => void } | null = null;
|
||||
let logics: EasyListItem[] | null = null;
|
||||
let query = "";
|
||||
|
||||
/** 저장 안 한 것이 있으면 한 번 물음 — 닫아도 되면 true */
|
||||
const mayLeave = async (): Promise<boolean> =>
|
||||
!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<void> => {
|
||||
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<void> => {
|
||||
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<string, number>();
|
||||
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<void> => {
|
||||
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<string, unknown>): 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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<string, string | number> = {}):
|
||||
);
|
||||
|
||||
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 = <T extends { 이름?: string }>(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<Record<Kind, Source>> = {
|
||||
@@ -133,14 +154,14 @@ const SOURCES: Partial<Record<Kind, Source>> = {
|
||||
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<Record<Kind, Source>> = {
|
||||
const unit = typeof it.단위 === "string" ? it.단위 : "";
|
||||
const price = num(it.값);
|
||||
const element: Record<string, string> = { 구분: 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<Record<Kind, Source>> = {
|
||||
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<Record<Kind, Source>> = {
|
||||
};
|
||||
|
||||
/** 물음 셋 — 고른 것 · 취소는 null */
|
||||
export function openEasyPick(kind?: Kind): Promise<PickResult | null> {
|
||||
export function openEasyPick(
|
||||
kind?: Kind,
|
||||
opts: { qty?: number | null } = {},
|
||||
): Promise<PickResult | null> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (r: PickResult | null): void => {
|
||||
@@ -294,16 +325,17 @@ export function openEasyPick(kind?: Kind): Promise<PickResult | null> {
|
||||
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<PickResult | null> {
|
||||
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<PickResult | null> {
|
||||
const submit = async (): Promise<void> => {
|
||||
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());
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
/** 마지막으로 누른 줄이 미리 보기만 한 「설계 때 정함」 조건 */
|
||||
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,
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user