Merge remote-tracking branch 'origin/dev' into sub_laptop_4
This commit is contained in:
@@ -1,33 +1,431 @@
|
||||
/* =============================================================================
|
||||
* M01_EasyCalc_Entry.ts
|
||||
* 쉬운 단가산출 시험 길 — 들어가는 단추 + 빈 창(틀만). 이 폴더째 지워도 됨.
|
||||
* 쉬운 단가산출 시험 길 — 들어가는 단추 · 큰 창 틀 · 시작 화면 · 고치는 화면 틀(위 띠 · 자리들)
|
||||
* 모양은 `docs/raw/M01_쉬운단가/설계.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄).
|
||||
* 1걸음 = 틀만 — 카드 · 설계 때 정하는 값 · 실무 일위대가표 · 저장 · 지우기 · 되돌리기는 2걸음에 이음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
createTag,
|
||||
el,
|
||||
showConfirmDialog,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { fetchLogic, fetchLogics, type LogicRow } from "../M01_MasterData_UI_Logic_Api";
|
||||
import {
|
||||
listEasy,
|
||||
openEasy,
|
||||
plainError,
|
||||
type EasyListItem,
|
||||
type EasyOpened,
|
||||
} from "./M01_EasyCalc_Api";
|
||||
import type { EasyDoc } from "./M01_EasyCalc_Model";
|
||||
import "./M01_EasyCalc_Style.css";
|
||||
|
||||
const TEXT = {
|
||||
Open: ["쉬운 단가산출(시험)", "Easy unit-price (trial)"],
|
||||
Title: ["쉬운 단가산출", "Easy unit-price"],
|
||||
Ready: ["준비 중", "Coming soon"],
|
||||
CloseWindow: ["창 닫기", "Close window"],
|
||||
Close: ["닫기", "Close"],
|
||||
Loading: ["불러오는 중", "Loading"],
|
||||
LoadFailed: ["목록을 못 불러옴 — {v}", "Could not load the list — {v}"],
|
||||
Start_Ask: ["무엇과 비슷한가요?", "What is it similar to?"],
|
||||
Start_Search: ["이름으로 찾기 — 예: 거푸집", "Search by name — e.g. formwork"],
|
||||
Start_Blank: ["빈 종이로 새로 만들기", "Start from a blank sheet"],
|
||||
Start_Mine: ["내가 만든 것", "Made by me"],
|
||||
Start_MineNone: ["아직 없음", "None yet"],
|
||||
Start_Found: ["찾은 것", "Found"],
|
||||
Start_Count: ["{n} 개 중 {m} 개 보임 — 더 좁혀 찾기", "Showing {m} of {n} — narrow the search"],
|
||||
Start_None: ["찾은 것 없음", "Nothing found"],
|
||||
Base: ["품셈 기본", "Standard"],
|
||||
Mine: ["내가 만든 것", "Made by me"],
|
||||
CannotOpen: ["쉬운 길로 못 엶", "Not for the easy view"],
|
||||
Per: ["{u} 당", "per {u}"],
|
||||
Name: ["이름", "Name"],
|
||||
NamePlaceholder: ["예: 거푸집 설치", "e.g. formwork setup"],
|
||||
Spec: ["크기", "Size"],
|
||||
Unit: ["단위", "Unit"],
|
||||
Total: ["{u} 만드는 데 {v} 원", "{v} won to make {u}"],
|
||||
Undo: ["되돌리기", "Undo"],
|
||||
Save: ["저장", "Save"],
|
||||
Delete: ["지우기", "Delete"],
|
||||
Unsaved: ["저장 안 함", "Not saved"],
|
||||
BaseNote: [
|
||||
"품셈 기본은 읽기만 — 고치려면 아래 단추로 내 것을 만듦",
|
||||
"Standard items are read-only — use the button below to make your own copy",
|
||||
],
|
||||
BaseCopy: ["이걸 바탕으로 고치기", "Edit a copy of this"],
|
||||
Trial: ["설계 때 정하는 값", "Values set at design time"],
|
||||
Cards: ["무엇이 들어가나", "What goes in"],
|
||||
Sheet: ["실무 일위대가표로 보기", "View as a unit-cost sheet"],
|
||||
NotYet: ["준비 중", "Coming soon"],
|
||||
Leave_Ask: [
|
||||
"저장 안 한 것이 있음 — 저장하지 않고 닫을까요?",
|
||||
"There are unsaved changes — close without saving?",
|
||||
],
|
||||
Leave_Ok: ["저장 안 하고 닫기", "Close without saving"],
|
||||
} as const;
|
||||
|
||||
const te = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex];
|
||||
type TextKey = keyof typeof TEXT;
|
||||
|
||||
/** 「식으로 새로 만들기」 옆에 놓는 단추 — 누르면 빈 창 */
|
||||
export function createEasyCalcButton(): HTMLButtonElement {
|
||||
return createButton({
|
||||
label: te("Open"),
|
||||
variant: "filled",
|
||||
onClick: () =>
|
||||
openModal({
|
||||
title: te("Title"),
|
||||
closeLabel: te("Close"),
|
||||
mount: (body) =>
|
||||
body.append(el("p", { className: "m01-easycalc__ready", text: te("Ready") })),
|
||||
const te = (key: TextKey, vars: Record<string, string | number> = {}): string =>
|
||||
Object.entries(vars).reduce(
|
||||
(text, [k, v]) => text.replace(`{${k}}`, String(v)),
|
||||
(TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string,
|
||||
);
|
||||
|
||||
/** 목록에 한 번에 보이는 줄 수 */
|
||||
const SHOWN = 50;
|
||||
|
||||
/* 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」 은 그대로 */
|
||||
const amountOf = (resultUnit: string): string => {
|
||||
const unit = unitOf(resultUnit);
|
||||
return /^\d/.test(unit) ? unit : `1${unit}`;
|
||||
};
|
||||
|
||||
/** 「추가」 옆에 놓는 단추 — 누르면 큰 창 */
|
||||
export function createEasyCalcButton(): HTMLButtonElement {
|
||||
return createButton({ label: te("Open"), variant: "filled", onClick: openEasyCalc });
|
||||
}
|
||||
|
||||
function openEasyCalc(): void {
|
||||
let body: HTMLElement;
|
||||
let paper: EasyDoc | null = null;
|
||||
let dirty = false;
|
||||
let logics: EasyListItem[] | null = null;
|
||||
let query = "";
|
||||
|
||||
/** 저장 안 한 것이 있으면 한 번 물음 — 닫아도 되면 true */
|
||||
const mayLeave = async (): Promise<boolean> =>
|
||||
!paper || !dirty || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok"));
|
||||
|
||||
const notYet = (): void => showToast(te("NotYet"), "info");
|
||||
|
||||
/* ---------- 시작 화면 ---------- */
|
||||
|
||||
const listRow = (item: EasyListItem): HTMLElement => {
|
||||
const ok = item.canOpen;
|
||||
const row = el("button", {
|
||||
className: "m01-easycalc__item",
|
||||
attrs: { type: "button" },
|
||||
children: [
|
||||
el("span", { className: "m01-easycalc__item-name", text: item.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,
|
||||
}),
|
||||
...(ok ? [] : [createTag(item.whyNot ?? te("CannotOpen"), "warning")]),
|
||||
],
|
||||
});
|
||||
row.disabled = !ok;
|
||||
row.title = item.where;
|
||||
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" });
|
||||
const search = createInputField({
|
||||
type: "search",
|
||||
placeholder: te("Start_Search"),
|
||||
value: query,
|
||||
onInput: (v) => {
|
||||
query = v;
|
||||
drawFound();
|
||||
},
|
||||
});
|
||||
search.root.classList.add("m01-easycalc__search");
|
||||
|
||||
const drawFound = (): void => {
|
||||
if (!logics) return;
|
||||
const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||
const hits = words.length
|
||||
? logics.filter((x) => words.every((w) => x.name.toLowerCase().includes(w)))
|
||||
: logics;
|
||||
status.textContent = !hits.length
|
||||
? te("Start_None")
|
||||
: hits.length > SHOWN
|
||||
? te("Start_Count", { n: hits.length.toLocaleString(), m: SHOWN })
|
||||
: "";
|
||||
foundHost.replaceChildren(...hits.slice(0, SHOWN).map(listRow));
|
||||
};
|
||||
|
||||
body.replaceChildren(
|
||||
el("div", {
|
||||
className: "m01-easycalc__start",
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01-easycalc__start-col",
|
||||
children: [
|
||||
el("h3", { className: "m01-easycalc__ask", text: te("Start_Ask") }),
|
||||
search.root,
|
||||
el("h4", { text: te("Start_Found") }),
|
||||
status,
|
||||
foundHost,
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "m01-easycalc__start-col m01-easycalc__start-side",
|
||||
children: [
|
||||
createBigButton(te("Start_Blank"), () => openBlank()),
|
||||
el("h4", { text: te("Start_Mine") }),
|
||||
mineHost,
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
search.input.focus();
|
||||
|
||||
try {
|
||||
logics ??= await loadList();
|
||||
} catch (error) {
|
||||
status.textContent = te("LoadFailed", { v: plain(error) });
|
||||
return;
|
||||
}
|
||||
const mine = logics.filter((x) => !x.isBase);
|
||||
mineHost.replaceChildren(
|
||||
...(mine.length
|
||||
? mine.map(listRow)
|
||||
: [el("p", { className: "m01-easycalc__muted", text: te("Start_MineNone") })]),
|
||||
);
|
||||
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);
|
||||
};
|
||||
|
||||
const showPaper = (): void => {
|
||||
const p = paper;
|
||||
if (!p) return;
|
||||
const readOnly = p.isBase;
|
||||
const unsaved = createTag(te("Unsaved"), "warning");
|
||||
unsaved.hidden = true;
|
||||
const total = el("div", { className: "m01-easycalc__total" });
|
||||
const drawTotal = (): void => {
|
||||
total.textContent = te("Total", { u: amountOf(p.resultUnit), v: "—" });
|
||||
};
|
||||
const touched = (): void => {
|
||||
dirty = true;
|
||||
unsaved.hidden = false;
|
||||
};
|
||||
|
||||
const name = createInputField({
|
||||
label: te("Name"),
|
||||
placeholder: te("NamePlaceholder"),
|
||||
value: p.name,
|
||||
onInput: (v) => {
|
||||
p.name = v;
|
||||
touched();
|
||||
},
|
||||
});
|
||||
const spec = createInputField({
|
||||
label: te("Spec"),
|
||||
value: p.spec,
|
||||
onInput: (v) => {
|
||||
p.spec = v;
|
||||
touched();
|
||||
},
|
||||
});
|
||||
const units = unitChoices();
|
||||
if (!units.includes(p.resultUnit)) units.unshift(p.resultUnit);
|
||||
const unit = createSelectField({
|
||||
label: te("Unit"),
|
||||
options: units.map((u) => ({ value: u, text: unitOf(u) })),
|
||||
value: p.resultUnit,
|
||||
disabled: readOnly,
|
||||
onChange: (v) => {
|
||||
p.resultUnit = v;
|
||||
drawTotal();
|
||||
touched();
|
||||
},
|
||||
});
|
||||
name.input.disabled = readOnly;
|
||||
spec.input.disabled = readOnly;
|
||||
name.root.classList.add("m01-easycalc__name");
|
||||
|
||||
const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: notYet });
|
||||
undo.disabled = true;
|
||||
const close = createButton({
|
||||
label: te("Close"),
|
||||
variant: "ghost",
|
||||
onClick: () =>
|
||||
void (async () => {
|
||||
if (await mayLeave()) void showStart();
|
||||
})(),
|
||||
});
|
||||
const actions = readOnly
|
||||
? [close]
|
||||
: [
|
||||
undo,
|
||||
createButton({ label: te("Save"), onClick: notYet }),
|
||||
...(p.key && !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",
|
||||
children: [
|
||||
el("summary", { text: te("Sheet") }),
|
||||
el("p", { className: "m01-easycalc__muted", text: te("NotYet") }),
|
||||
],
|
||||
});
|
||||
|
||||
drawTotal();
|
||||
body.replaceChildren(
|
||||
el("div", {
|
||||
className: "m01-easycalc__strip",
|
||||
children: [
|
||||
name.root,
|
||||
spec.root,
|
||||
unit.root,
|
||||
total,
|
||||
unsaved,
|
||||
el("div", { className: "m01-easycalc__actions", children: actions }),
|
||||
],
|
||||
}),
|
||||
...(readOnly
|
||||
? [
|
||||
el("div", {
|
||||
className: "m01-easycalc__base",
|
||||
children: [
|
||||
el("p", { text: te("BaseNote") }),
|
||||
createBigButton(te("BaseCopy"), notYet),
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
section(te("Trial"), "m01-easycalc__trial"),
|
||||
section(te("Cards"), "m01-easycalc__cards"),
|
||||
sheet,
|
||||
);
|
||||
if (!readOnly) name.input.focus();
|
||||
};
|
||||
|
||||
// 화면 함수들을 다 만든 뒤에 엶 — mount 가 바로 showStart 를 부름
|
||||
openModal({
|
||||
title: te("Title"),
|
||||
closeLabel: te("CloseWindow"),
|
||||
dialogClass: "m01-easycalc",
|
||||
beforeClose: mayLeave,
|
||||
mount: (host) => {
|
||||
body = host;
|
||||
void showStart();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function createBigButton(label: string, onClick: () => void): HTMLButtonElement {
|
||||
const button = createButton({ label, variant: "filled", onClick });
|
||||
button.classList.add("m01-easycalc__big");
|
||||
return button;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
/* =============================================================================
|
||||
* M01_EasyCalc_Model.ts
|
||||
* 쉬운 단가산출 Model — 로직 줄(LogicRow) ↔ 카드(EasyDoc · EasyCard) 바꾸기 · 카드 조작 · 검사 · 되돌리기
|
||||
* DOM 없음 · 모양은 `docs/raw/M01_쉬운단가/설계.md` 3절 그대로
|
||||
* DOM 없음 · 바깥 값은 타입만 가져옴(Node 시험이 이 파일을 바로 돌림) · 모양은 `설계.md` 3절 그대로
|
||||
*
|
||||
* 호표 줄 → 카드: 인력 = person · 재료 = material · 기계경비 로직(GC000267~276) 부름 = machine · 그 밖 로직 = logic
|
||||
* 수량이 숫자 글이면 qty · 아니면 qtyFormula + lockedWhy(쉬운 말)
|
||||
* 덧줄 → 카드: 「바탕 * 숫자 / 100」 + 바탕 = 노무비 · 재료비 · 경비 또는 사람 줄 노임 합 = percent · 그 밖 = locked
|
||||
* 카드 → 줄: 카드 차례대로 호표 · 덧줄을 다시 짬 · 안 바뀐 줄은 원래 글자 그대로(왕복 같음) · 안 다룬 칸은 raw 에서 살림
|
||||
* 글 [한, 영] — 화면이 `setEasyModelLanguage(currentLanguageIndex)` 로 맞춤
|
||||
* ========================================================================== */
|
||||
|
||||
import type { LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api";
|
||||
import type { HoLine, LogicInput, LogicRow, NamedFormula } from "../M01_MasterData_UI_Logic_Api";
|
||||
|
||||
export type CardKind = "person" | "material" | "machine" | "logic" | "percent" | "locked";
|
||||
export type CostKind = "노무비" | "재료비" | "경비";
|
||||
@@ -66,55 +72,516 @@ export interface PickResult {
|
||||
needInputs?: LogicInput[];
|
||||
}
|
||||
|
||||
const notReady = (): never => {
|
||||
throw new Error("준비 중");
|
||||
/* ── 글 ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const TEXT = {
|
||||
WhyTable: ["품셈 표에서 찾는 값", "Looked up from the standard table"],
|
||||
WhyCond: ["조건에 따라 달라지는 값", "Changes with conditions"],
|
||||
WhyInput: ["설계 때 정하는 값으로 정해짐", "Set by values chosen at design time"],
|
||||
WhyCalc: ["계산으로 정해지는 값", "Worked out by a calculation"],
|
||||
WhyKind: ["쉬운 길에서 못 고치는 줄", "This line can't be changed here"],
|
||||
NoNumber: ["얼마인지 숫자로 넣어야 함", "Enter the amount as a number"],
|
||||
Zero: ["0 은 안 됨 — 빼려면 지움", "0 is not allowed — remove the line instead"],
|
||||
Negative: ["0 보다 큰 수만 됨", "Only numbers above 0"],
|
||||
NoName: ["이름이 비어 있음", "The name is empty"],
|
||||
SameLine: ["같은 줄이 이미 있음 — 한 줄에 합쳐 넣음", "Same line already exists — merge them"],
|
||||
SameName: ["내가 만든 것에 같은 이름이 있음", "One of yours already has this name"],
|
||||
/** {per} {qty} {unit} 을 채움 */
|
||||
TooBig: ["{per} 에 {qty}{unit} — 너무 큰 수 아닌지?", "{qty}{unit} for {per} — too large?"],
|
||||
} as const;
|
||||
|
||||
let lang = 0;
|
||||
|
||||
/** 화면 말 고르기 — 0 한국어 · 1 영어 */
|
||||
export function setEasyModelLanguage(index: number): void {
|
||||
lang = index === 1 ? 1 : 0;
|
||||
}
|
||||
|
||||
const tx = (key: keyof typeof TEXT): string => TEXT[key][lang];
|
||||
|
||||
/* ── 작은 도움 ──────────────────────────────────────────────────────────── */
|
||||
|
||||
const COSTS: readonly string[] = ["노무비", "재료비", "경비"];
|
||||
const isCost = (v: unknown): v is CostKind => typeof v === "string" && COSTS.includes(v);
|
||||
/** 기계경비 로직 — `로직_건설품셈_08장_건설기계.json` GC000267~276 */
|
||||
const MACHINE_CALL = /^\s*로직\(\s*GC0002(6[7-9]|7[0-6])\s*[,)]/;
|
||||
const NUMBER_TEXT = /^\s*-?\d+(\.\d+)?\s*$/;
|
||||
const SIMPLE_TERM = /^[가-힣A-Za-z0-9_.]+$/;
|
||||
|
||||
let seq = 0;
|
||||
const newId = (): string => `c${++seq}`;
|
||||
|
||||
const clone = <T>(v: T): T => (v === undefined ? v : (JSON.parse(JSON.stringify(v)) as T));
|
||||
const isObj = (v: unknown): v is Record<string, unknown> =>
|
||||
typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
const norm = (s: string): string => s.replace(/\s+/g, "");
|
||||
|
||||
/** 수를 글로 — 지수 꼴 없이 · 뒤 0 뗌 */
|
||||
function numText(n: number): string {
|
||||
const s = String(Number(n.toPrecision(12)));
|
||||
return s.includes("e") ? n.toFixed(12).replace(/\.?0+$/, "") : s;
|
||||
}
|
||||
|
||||
/** 바깥을 통째 감싼 괄호 하나 벗김 */
|
||||
function unwrap(s: string): string {
|
||||
const t = s.trim();
|
||||
if (!t.startsWith("(") || !t.endsWith(")")) return t;
|
||||
let depth = 0;
|
||||
for (let i = 0; i < t.length; i++) {
|
||||
if (t[i] === "(") depth++;
|
||||
else if (t[i] === ")" && --depth === 0 && i < t.length - 1) return t;
|
||||
}
|
||||
return t.slice(1, -1).trim();
|
||||
}
|
||||
|
||||
/** 괄호 밖의 + 로 나눔 */
|
||||
function splitPlus(s: string): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let cur = "";
|
||||
for (const ch of s) {
|
||||
if (ch === "(") depth++;
|
||||
if (ch === ")") depth--;
|
||||
if (ch === "+" && depth === 0) {
|
||||
out.push(cur.trim());
|
||||
cur = "";
|
||||
} else cur += ch;
|
||||
}
|
||||
out.push(cur.trim());
|
||||
return out;
|
||||
}
|
||||
|
||||
const hasName = (formula: string, name: string): boolean => {
|
||||
const esc = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return new RegExp(`(^|[^가-힣A-Za-z0-9_.'])${esc}($|[^가-힣A-Za-z0-9_'])`).test(formula);
|
||||
};
|
||||
|
||||
/** 잠금 까닭 — 표에서 찾음 > 조건 > 설계 값 > 계산 · 중간 값은 안까지 따라감 */
|
||||
function lockedWhy(formula: string, row: LogicRow): string {
|
||||
const seen = new Set<string>();
|
||||
const flags = { table: false, cond: false, input: false };
|
||||
const walk = (f: string, depth: number): void => {
|
||||
if (/찾기\s*\(/.test(f)) flags.table = true;
|
||||
if (/(만약|최소|최대|올림|버림|반올림)\s*\(|[<>]|==|!=/.test(f)) flags.cond = true;
|
||||
for (const i of row.입력 ?? []) if (hasName(f, i.이름)) flags.input = true;
|
||||
if (depth > 6) return;
|
||||
for (const m of row.중간 ?? []) {
|
||||
if (seen.has(m.이름) || !hasName(f, m.이름)) continue;
|
||||
seen.add(m.이름);
|
||||
walk(m.식, depth + 1);
|
||||
}
|
||||
};
|
||||
walk(formula, 0);
|
||||
if (flags.table) return tx("WhyTable");
|
||||
if (flags.cond) return tx("WhyCond");
|
||||
if (flags.input) return tx("WhyInput");
|
||||
return tx("WhyCalc");
|
||||
}
|
||||
|
||||
/* ── 덧붙임(%) 꼴 ───────────────────────────────────────────────────────── */
|
||||
|
||||
/** 사람 줄 하나의 노임 조각 — 「수량 * 요소」 */
|
||||
function personTerm(qtyText: string, element: unknown): string {
|
||||
const q = SIMPLE_TERM.test(qtyText.trim()) ? qtyText.trim() : `(${qtyText.trim()})`;
|
||||
return `${q} * ${String(element)}`;
|
||||
}
|
||||
|
||||
/** 「바탕 * 숫자 / 100」 → 바탕 글 · 율 · 아니면 null */
|
||||
function parsePercent(formula: string): { base: string; rate: number } | null {
|
||||
const m = /^([\s\S]*)\*\s*(\d+(?:\.\d+)?)\s*\/\s*100\s*$/.exec(formula.trim());
|
||||
if (!m || !m[1].trim()) return null;
|
||||
return { base: unwrap(m[1]), rate: Number(m[2]) };
|
||||
}
|
||||
|
||||
/** 바탕이 사람 줄 노임 합인가 — 사람 줄 모두 · 차례대로 · 빠짐없이 */
|
||||
function isPersonSum(base: string, persons: { qtyText: string; element: unknown }[]): boolean {
|
||||
if (persons.length === 0) return false;
|
||||
const terms = splitPlus(base);
|
||||
if (terms.length !== persons.length) return false;
|
||||
return terms.every((t, i) => {
|
||||
const p = persons[i];
|
||||
const want = personTerm(p.qtyText, p.element);
|
||||
return norm(t) === norm(want) || norm(t) === norm(`${p.qtyText} * ${String(p.element)}`);
|
||||
});
|
||||
}
|
||||
|
||||
function baseOf(
|
||||
base: string,
|
||||
persons: { qtyText: string; element: unknown }[],
|
||||
): EasyCard["percentBase"] {
|
||||
if (isCost(base)) return base;
|
||||
return isPersonSum(base, persons) ? "사람줄합" : null;
|
||||
}
|
||||
|
||||
/* ── 로직 줄 → 카드 ─────────────────────────────────────────────────────── */
|
||||
|
||||
const qtyTextOf = (line: HoLine): string => String(line.수량 ?? "");
|
||||
|
||||
function hoCard(line: HoLine, index: number, row: LogicRow): EasyCard {
|
||||
const kind: CardKind =
|
||||
line.종류 === "인력"
|
||||
? "person"
|
||||
: line.종류 === "재료"
|
||||
? "material"
|
||||
: line.종류 === "로직"
|
||||
? MACHINE_CALL.test(String(line.요소))
|
||||
? "machine"
|
||||
: "logic"
|
||||
: "locked";
|
||||
const qtyText = qtyTextOf(line);
|
||||
const numeric = NUMBER_TEXT.test(qtyText);
|
||||
return {
|
||||
id: newId(),
|
||||
kind,
|
||||
name: line.이름 ?? "",
|
||||
spec: line.규격 ?? "",
|
||||
unit: line.단위 ?? "",
|
||||
qty: numeric ? Number(qtyText) : null,
|
||||
qtyFormula: numeric ? null : qtyText,
|
||||
cost: isCost(line.비목) ? line.비목 : null,
|
||||
percentBase: null,
|
||||
element: clone(line.요소),
|
||||
lockedWhy: kind === "locked" ? tx("WhyKind") : numeric ? null : lockedWhy(qtyText, row),
|
||||
source: { part: "호표", index },
|
||||
raw: clone(line),
|
||||
};
|
||||
}
|
||||
|
||||
function addCardOf(
|
||||
line: NamedFormula,
|
||||
index: number,
|
||||
row: LogicRow,
|
||||
persons: { qtyText: string; element: unknown }[],
|
||||
): EasyCard {
|
||||
const p = parsePercent(line.식);
|
||||
const base = p ? baseOf(p.base, persons) : null;
|
||||
const percent = p !== null && base !== null;
|
||||
return {
|
||||
id: newId(),
|
||||
kind: percent ? "percent" : "locked",
|
||||
name: line.이름 ?? "",
|
||||
spec: "",
|
||||
unit: percent ? "%" : "",
|
||||
qty: percent ? p.rate : null,
|
||||
qtyFormula: percent ? null : line.식,
|
||||
cost: isCost(line.비목) ? line.비목 : null,
|
||||
percentBase: percent ? base : null,
|
||||
element: null,
|
||||
lockedWhy: percent ? null : lockedWhy(line.식, row),
|
||||
source: { part: "덧줄", index },
|
||||
raw: clone(line),
|
||||
};
|
||||
}
|
||||
|
||||
export function toDoc(
|
||||
_row: LogicRow,
|
||||
_meta: { key: string | null; version: string | null; isBase: boolean },
|
||||
row: LogicRow,
|
||||
meta: { key: string | null; version: string | null; isBase: boolean },
|
||||
): EasyDoc {
|
||||
return notReady();
|
||||
const ho = row.호표 ?? [];
|
||||
const persons = ho
|
||||
.filter((h) => h.종류 === "인력")
|
||||
.map((h) => ({ qtyText: qtyTextOf(h), element: h.요소 }));
|
||||
return {
|
||||
key: meta.key,
|
||||
version: meta.version,
|
||||
isBase: meta.isBase,
|
||||
name: row.이름 ?? "",
|
||||
spec: typeof row["규격"] === "string" ? (row["규격"] as string) : "",
|
||||
resultUnit: row.결과단위 ?? "",
|
||||
inputs: clone(row.입력 ?? []),
|
||||
cards: [
|
||||
...ho.map((h, i) => hoCard(h, i, row)),
|
||||
...(row.덧줄 ?? []).map((d, i) => addCardOf(d, i, row, persons)),
|
||||
],
|
||||
row: clone(row),
|
||||
};
|
||||
}
|
||||
|
||||
export function toRow(_doc: EasyDoc): LogicRow {
|
||||
return notReady();
|
||||
/** 새 로직의 최소 칸 줄 — 검토 A 4-1 · 구분 · 상세구분 · 소유 · 키는 서버가 채움 */
|
||||
export function emptyRow(name = "", per = "㎡"): LogicRow {
|
||||
return {
|
||||
키: "",
|
||||
원문번호: "",
|
||||
이름: name,
|
||||
결과단위: `원/${per}`,
|
||||
출처: "자체",
|
||||
입력: [],
|
||||
중간: [],
|
||||
호표: [],
|
||||
덧줄: [],
|
||||
끝수: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function addCard(_doc: EasyDoc, _pick: PickResult): EasyDoc {
|
||||
return notReady();
|
||||
/** 빈 종이로 새로 */
|
||||
export function newDoc(name = "", per = "㎡"): EasyDoc {
|
||||
return toDoc(emptyRow(name, per), { key: null, version: null, isBase: false });
|
||||
}
|
||||
|
||||
export function updateCard(_doc: EasyDoc, _id: string, _patch: Partial<EasyCard>): EasyDoc {
|
||||
return notReady();
|
||||
/* ── 카드 → 로직 줄 ─────────────────────────────────────────────────────── */
|
||||
|
||||
const partOf = (c: EasyCard): "호표" | "덧줄" =>
|
||||
c.kind === "percent" ? "덧줄" : c.kind === "locked" ? (c.source?.part ?? "덧줄") : "호표";
|
||||
|
||||
const TYPE_OF: Record<string, string> = {
|
||||
person: "인력",
|
||||
material: "재료",
|
||||
machine: "로직",
|
||||
logic: "로직",
|
||||
};
|
||||
|
||||
/** 수량 글 — 식이면 식 · 숫자가 원래 글과 같으면 원래 글(「0.50」 보존) */
|
||||
function qtyTextFor(c: EasyCard, rawText: unknown): string {
|
||||
if (c.qtyFormula !== null) return c.qtyFormula;
|
||||
if (c.qty === null || !Number.isFinite(c.qty)) return "";
|
||||
const keep =
|
||||
typeof rawText === "string" && NUMBER_TEXT.test(rawText) && Number(rawText) === c.qty;
|
||||
return keep ? (rawText as string) : numText(c.qty);
|
||||
}
|
||||
|
||||
export function removeCard(_doc: EasyDoc, _id: string): EasyDoc {
|
||||
return notReady();
|
||||
/** 칸 쓰기 — 원래 있었거나 값이 있을 때만(없던 빈 칸을 만들지 않음) */
|
||||
function setField(o: Record<string, unknown>, k: string, v: string): void {
|
||||
if (v !== "" || k in o) o[k] = v;
|
||||
}
|
||||
|
||||
export function moveCard(_doc: EasyDoc, _id: string, _step: -1 | 1): EasyDoc {
|
||||
return notReady();
|
||||
function hoLineOf(c: EasyCard): HoLine {
|
||||
const raw = isObj(c.raw) ? clone(c.raw) : {};
|
||||
const line: Record<string, unknown> = raw;
|
||||
if (c.kind !== "locked") line.종류 = TYPE_OF[c.kind];
|
||||
line.요소 = clone(c.element);
|
||||
setField(line, "이름", c.name);
|
||||
setField(line, "규격", c.spec);
|
||||
setField(line, "단위", c.unit);
|
||||
line.수량 = qtyTextFor(c, raw.수량);
|
||||
if (c.cost !== null) line.비목 = c.cost;
|
||||
else if (isCost(raw.비목)) delete line.비목;
|
||||
return line as unknown as HoLine;
|
||||
}
|
||||
|
||||
export function checkCard(_card: EasyCard, _doc: EasyDoc): string | null {
|
||||
return notReady();
|
||||
function percentText(c: EasyCard, persons: { qtyText: string; element: unknown }[]): string {
|
||||
const rate = numText(c.qty ?? 0);
|
||||
if (c.percentBase === "사람줄합") {
|
||||
const terms = persons.map((p) => personTerm(p.qtyText, p.element));
|
||||
return `(${terms.length ? terms.join(" + ") : "0"}) * ${rate} / 100`;
|
||||
}
|
||||
return `${c.percentBase ?? "노무비"} * ${rate} / 100`;
|
||||
}
|
||||
|
||||
function addLineOf(c: EasyCard, persons: { qtyText: string; element: unknown }[]): NamedFormula {
|
||||
const raw = isObj(c.raw) ? clone(c.raw) : {};
|
||||
const line: Record<string, unknown> = raw;
|
||||
line.이름 = c.name;
|
||||
const rawText = typeof raw.식 === "string" ? raw.식 : "";
|
||||
if (c.kind === "percent") {
|
||||
const p = parsePercent(rawText);
|
||||
const same = p !== null && p.rate === c.qty && baseOf(p.base, persons) === c.percentBase;
|
||||
line.식 = same ? rawText : percentText(c, persons);
|
||||
} else {
|
||||
line.식 = c.qtyFormula ?? (c.qty !== null ? numText(c.qty) : rawText);
|
||||
}
|
||||
if (c.cost !== null) line.비목 = c.cost;
|
||||
else if (isCost(raw.비목)) delete line.비목;
|
||||
return line as unknown as NamedFormula;
|
||||
}
|
||||
|
||||
export function toRow(doc: EasyDoc): LogicRow {
|
||||
const row = clone(doc.row) as LogicRow & Record<string, unknown>;
|
||||
row.이름 = doc.name;
|
||||
setField(row, "규격", doc.spec);
|
||||
row.결과단위 = doc.resultUnit;
|
||||
row.입력 = clone(doc.inputs);
|
||||
const ho = doc.cards.filter((c) => partOf(c) === "호표").map(hoLineOf);
|
||||
const persons = ho
|
||||
.filter((h) => h.종류 === "인력")
|
||||
.map((h) => ({ qtyText: qtyTextOf(h), element: h.요소 }));
|
||||
const add = doc.cards.filter((c) => partOf(c) === "덧줄").map((c) => addLineOf(c, persons));
|
||||
if (ho.length || "호표" in row) row.호표 = ho;
|
||||
if (add.length || "덧줄" in row) row.덧줄 = add;
|
||||
return row;
|
||||
}
|
||||
|
||||
/* ── 카드 조작 (모두 새 문서를 돌려줌 · 받은 문서는 안 바꿈) ─────────────── */
|
||||
|
||||
const COST_OF: Record<PickResult["kind"], CostKind | null> = {
|
||||
person: "노무비",
|
||||
material: "재료비",
|
||||
machine: null,
|
||||
logic: null,
|
||||
};
|
||||
|
||||
/** 묶음 — 화면의 묶음과 같음(사람 · 재료 · 기계 · 다른 일위대가 · 덧붙임) */
|
||||
const groupOf = (c: EasyCard): string => (partOf(c) === "덧줄" ? "덧붙임" : c.kind);
|
||||
|
||||
/** 같은 묶음 마지막 카드 뒤 · 없으면 같은 쪽(호표 · 덧붙임) 끝 · 그것도 없으면 호표는 맨 앞 · 덧붙임은 맨 뒤 */
|
||||
function insertAt(cards: EasyCard[], card: EasyCard): EasyCard[] {
|
||||
const last = (hit: (c: EasyCard) => boolean): number => {
|
||||
let at = -1;
|
||||
cards.forEach((c, i) => {
|
||||
if (hit(c)) at = i;
|
||||
});
|
||||
return at;
|
||||
};
|
||||
let at = last((c) => groupOf(c) === groupOf(card));
|
||||
if (at < 0) at = last((c) => partOf(c) === partOf(card));
|
||||
if (at < 0 && partOf(card) === "덧줄") at = cards.length - 1;
|
||||
const out = cards.slice();
|
||||
out.splice(at + 1, 0, card);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function addCard(doc: EasyDoc, pick: PickResult): EasyDoc {
|
||||
const card: EasyCard = {
|
||||
id: newId(),
|
||||
kind: pick.kind,
|
||||
name: pick.name,
|
||||
spec: pick.spec,
|
||||
unit: pick.unit,
|
||||
qty: pick.qty,
|
||||
qtyFormula: null,
|
||||
cost: COST_OF[pick.kind],
|
||||
percentBase: null,
|
||||
element: clone(pick.element),
|
||||
lockedWhy: null,
|
||||
source: null,
|
||||
raw: null,
|
||||
};
|
||||
return withInputs({ ...doc, cards: insertAt(doc.cards, card) }, pick.needInputs ?? []);
|
||||
}
|
||||
|
||||
/** 설계 때 정하는 값 더하기 — 없는 이름만(기계를 다시 골랐을 때도 씀) */
|
||||
export function withInputs(doc: EasyDoc, needs: LogicInput[]): EasyDoc {
|
||||
const inputs = doc.inputs.slice();
|
||||
for (const need of needs) if (!inputs.some((i) => i.이름 === need.이름)) inputs.push(clone(need));
|
||||
return inputs.length === doc.inputs.length ? doc : { ...doc, inputs };
|
||||
}
|
||||
|
||||
/** 덧붙임(%) 카드 더하기 — 넣을 자리 기본은 화면이 정함(설계.md 1절 9) */
|
||||
export function addPercent(
|
||||
doc: EasyDoc,
|
||||
name: string,
|
||||
base: CostKind | "사람줄합",
|
||||
rate: number | null,
|
||||
cost: CostKind | null,
|
||||
): EasyDoc {
|
||||
const card: EasyCard = {
|
||||
id: newId(),
|
||||
kind: "percent",
|
||||
name,
|
||||
spec: "",
|
||||
unit: "%",
|
||||
qty: rate,
|
||||
qtyFormula: null,
|
||||
cost,
|
||||
percentBase: base,
|
||||
element: null,
|
||||
lockedWhy: null,
|
||||
source: null,
|
||||
raw: null,
|
||||
};
|
||||
return { ...doc, cards: insertAt(doc.cards, card) };
|
||||
}
|
||||
|
||||
/** 고치기 — 숫자로 바꾸기 = `{ qty: 값, qtyFormula: null }` (잠금 까닭도 지움) */
|
||||
export function updateCard(doc: EasyDoc, id: string, patch: Partial<EasyCard>): EasyDoc {
|
||||
return {
|
||||
...doc,
|
||||
cards: doc.cards.map((c) => {
|
||||
if (c.id !== id) return c;
|
||||
const next: EasyCard = { ...c, ...patch, id: c.id };
|
||||
if (next.qtyFormula === null && next.kind !== "locked") next.lockedWhy = null;
|
||||
return next;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function removeCard(doc: EasyDoc, id: string): EasyDoc {
|
||||
return { ...doc, cards: doc.cards.filter((c) => c.id !== id) };
|
||||
}
|
||||
|
||||
/** ▲(-1) ▼(1) — 같은 묶음 안 이웃과 자리 바꿈 · 끝이면 그대로 */
|
||||
export function moveCard(doc: EasyDoc, id: string, step: -1 | 1): EasyDoc {
|
||||
const from = doc.cards.findIndex((c) => c.id === id);
|
||||
if (from < 0) return doc;
|
||||
const g = groupOf(doc.cards[from]);
|
||||
let to = from + step;
|
||||
while (to >= 0 && to < doc.cards.length && groupOf(doc.cards[to]) !== g) to += step;
|
||||
if (to < 0 || to >= doc.cards.length) return doc;
|
||||
const cards = doc.cards.slice();
|
||||
[cards[from], cards[to]] = [cards[to], cards[from]];
|
||||
return { ...doc, cards };
|
||||
}
|
||||
|
||||
/* ── 검사 (쉬운 말 한 줄 · 없으면 null) ──────────────────────────────────── */
|
||||
|
||||
/** 단위별 어림 위 끝 — 넘으면 물음 꼴 경고 */
|
||||
const TOO_BIG: Record<string, number> = { 인: 10, 일: 10, 시간: 24, "%": 100 };
|
||||
const TOO_BIG_ELSE = 10000;
|
||||
|
||||
export function checkCard(card: EasyCard, doc: EasyDoc): string | null {
|
||||
if (!card.name.trim()) return tx("NoName");
|
||||
if (card.qtyFormula !== null) return null;
|
||||
if (card.qty === null || !Number.isFinite(card.qty)) return tx("NoNumber");
|
||||
if (card.qty === 0) return tx("Zero");
|
||||
if (card.qty < 0) return tx("Negative");
|
||||
const same = doc.cards.some(
|
||||
(c) =>
|
||||
c.id !== card.id &&
|
||||
c.kind === card.kind &&
|
||||
c.name === card.name &&
|
||||
c.spec === card.spec &&
|
||||
JSON.stringify(c.element) === JSON.stringify(card.element) &&
|
||||
(card.kind !== "percent" || c.percentBase === card.percentBase),
|
||||
);
|
||||
if (same) return tx("SameLine");
|
||||
if (card.qty > (TOO_BIG[card.unit] ?? TOO_BIG_ELSE)) {
|
||||
const per = `1${doc.resultUnit.replace(/^원\//, "") || ""}`;
|
||||
return tx("TooBig")
|
||||
.replace("{per}", per)
|
||||
.replace("{qty}", numText(card.qty))
|
||||
.replace("{unit}", card.unit);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 로직 이름 검사 — mine = 내가 만든 것 이름들(자기 이름은 빼고 넘김) */
|
||||
export function checkName(name: string, mine: string[]): string | null {
|
||||
if (!name.trim()) return tx("NoName");
|
||||
return mine.some((m) => m.trim() === name.trim()) ? tx("SameName") : null;
|
||||
}
|
||||
|
||||
/* ── 되돌리기 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
const HISTORY_MAX = 200;
|
||||
|
||||
/** 문서 기록 — 처음 연 문서를 push 한 뒤 바뀔 때마다 push · undo 는 앞 문서 */
|
||||
export class EasyHistory {
|
||||
push(_doc: EasyDoc): void {
|
||||
notReady();
|
||||
private stack: string[] = [];
|
||||
private saved: string | null = null;
|
||||
|
||||
push(doc: EasyDoc): void {
|
||||
const s = JSON.stringify(doc);
|
||||
if (this.stack[this.stack.length - 1] === s) return;
|
||||
this.stack.push(s);
|
||||
if (this.saved === null) this.saved = s;
|
||||
if (this.stack.length > HISTORY_MAX) this.stack.shift();
|
||||
}
|
||||
|
||||
undo(): EasyDoc | null {
|
||||
return notReady();
|
||||
if (this.stack.length < 2) return null;
|
||||
this.stack.pop();
|
||||
return JSON.parse(this.stack[this.stack.length - 1]) as EasyDoc;
|
||||
}
|
||||
|
||||
get canUndo(): boolean {
|
||||
return notReady();
|
||||
return this.stack.length > 1;
|
||||
}
|
||||
|
||||
/** 저장한 뒤(처음은 연 뒤) 바뀜 — 「저장 안 함」 표시 */
|
||||
get dirty(): boolean {
|
||||
return notReady();
|
||||
return this.stack.length > 0 && this.stack[this.stack.length - 1] !== this.saved;
|
||||
}
|
||||
|
||||
markSaved(): void {
|
||||
notReady();
|
||||
this.saved = this.stack[this.stack.length - 1] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
.m01-easypick {
|
||||
--modal-max: 86vh;
|
||||
width: min(760px, 94vw);
|
||||
font-size: var(--text-body-lg, 1.125rem);
|
||||
}
|
||||
.m01-easypick__ask {
|
||||
margin: 0;
|
||||
font-size: 1.35em;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.m01-easypick__muted {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
.m01-easypick__kinds {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
.m01-easypick__kind,
|
||||
.m01-easypick__row {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12) var(--spacing-16);
|
||||
border: 2px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.m01-easypick__kind {
|
||||
flex-direction: column;
|
||||
min-height: 96px;
|
||||
}
|
||||
.m01-easypick__kind strong {
|
||||
font-size: 1.3em;
|
||||
}
|
||||
.m01-easypick__kind span {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
.m01-easypick__kind:hover,
|
||||
.m01-easypick__row:hover,
|
||||
.m01-easypick__kind:focus-visible,
|
||||
.m01-easypick__row:focus-visible {
|
||||
border-color: var(--color-accent);
|
||||
outline: 3px solid var(--color-accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.m01-easypick__bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
}
|
||||
.m01-easypick__bar > :last-child {
|
||||
flex: 1 1 200px;
|
||||
}
|
||||
.m01-easypick__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
max-height: 46vh;
|
||||
overflow: auto;
|
||||
}
|
||||
.m01-easypick__row {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 52px;
|
||||
}
|
||||
.m01-easypick__name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
.m01-easypick__unit,
|
||||
.m01-easypick__price {
|
||||
flex: none;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
.m01-easypick__tag {
|
||||
margin-left: var(--spacing-8);
|
||||
padding: 0 var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-pills);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
.m01-easypick__chosen {
|
||||
margin: 0;
|
||||
font-size: 1.15em;
|
||||
}
|
||||
.m01-easypick__rough {
|
||||
min-height: 1.4em;
|
||||
margin: 0;
|
||||
color: var(--color-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -0,0 +1,456 @@
|
||||
/* =============================================================================
|
||||
* M01_EasyCalc_Pick.ts
|
||||
* 쉬운 단가산출 고르기 창 — 물음 셋(① 무엇 → ② 어떤 것 → ③ 얼마) · 한 화면 한 물음.
|
||||
* 돌려주는 PickResult.element = 호표 「요소」 칸에 들어갈 값 그대로. 모양은 설계.md 3절.
|
||||
* 글 [한, 영] 짝은 아래 TEXT. 이 폴더째 지워도 됨.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import {
|
||||
createButton,
|
||||
createInputField,
|
||||
createSelectField,
|
||||
el,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { fetchPick, fetchSubs, type SubInfo } from "../M01_MasterData_Api_Fetch";
|
||||
import { fetchMaterials } from "../M01_MasterData_UI_Logic_Api";
|
||||
import { listEasy, listMachines, machineElement } from "./M01_EasyCalc_Api";
|
||||
import type { PickResult } from "./M01_EasyCalc_Model";
|
||||
import "./M01_EasyCalc_Pick.css";
|
||||
|
||||
type Kind = PickResult["kind"];
|
||||
|
||||
const TEXT = {
|
||||
Title: ["넣을 것 고르기", "Pick what to add"],
|
||||
Close: ["닫기", "Close"],
|
||||
Back: ["← 뒤로", "← Back"],
|
||||
AskKind: ["무엇을 넣을까요?", "What do you want to add?"],
|
||||
AskWhich: ["어떤 것인가요?", "Which one?"],
|
||||
AskQty: ["얼마나 쓰나요?", "How much?"],
|
||||
Person: ["사람", "People"],
|
||||
Material: ["재료", "Material"],
|
||||
Machine: ["기계", "Machine"],
|
||||
Logic: ["다른 일위대가", "Another unit-price job"],
|
||||
PersonDesc: ["일하는 사람 — 석공 · 보통인부", "Workers — mason, laborer"],
|
||||
MaterialDesc: ["쓰는 물건 — 돌 · 시멘트 · 관", "Things used — stone, cement, pipe"],
|
||||
MachineDesc: ["쓰는 기계 — 굴삭기 · 덤프트럭", "Machines used — excavator, truck"],
|
||||
LogicDesc: ["이미 만들어 둔 일을 가져다 씀", "Reuse a job already made"],
|
||||
Search: ["이름을 쳐서 찾기", "Type a name to search"],
|
||||
AllSub: ["전체", "All"],
|
||||
SubLabel: ["구분", "Group"],
|
||||
DetailLabel: ["상세구분", "Detail"],
|
||||
Hint: ["구분을 고르거나 이름을 쳐 보세요", "Choose a group or type a name"],
|
||||
Loading: ["찾는 중…", "Searching…"],
|
||||
None: ["찾는 것 없음", "Nothing found"],
|
||||
Count: ["{n} 개 중 앞 {m} 개", "First {m} of {n}"],
|
||||
NoPrice: ["단가 없음", "No price"],
|
||||
PerDay: ["하루 {p} 원", "{p} won per day"],
|
||||
PerUnit: ["{u} 당 {p} 원", "{p} won per {u}"],
|
||||
Picked: ["고른 것", "Chosen"],
|
||||
QtyPlace: ["예: 0.072", "e.g. 0.072"],
|
||||
QtyLabel: ["{u} (숫자)", "{u} (number)"],
|
||||
Rough: ["금액 어림 약 {m} 원", "About {m} won"],
|
||||
Add: ["넣기", "Add"],
|
||||
QtyBad: ["0 보다 큰 숫자를 쳐 주세요", "Enter a number above 0"],
|
||||
Failed: ["불러오지 못함", "Could not load"],
|
||||
} as const;
|
||||
|
||||
const te = (key: keyof typeof TEXT, vars: Record<string, string | number> = {}): string =>
|
||||
Object.entries(vars).reduce(
|
||||
(text, [k, v]) => text.replace(`{${k}}`, String(v)),
|
||||
TEXT[key][currentLanguageIndex] as string,
|
||||
);
|
||||
|
||||
const won = (n: number): string => Math.round(n).toLocaleString("ko-KR");
|
||||
const MAT_FILE = "재료_자재품목.json";
|
||||
|
||||
/** 목록 한 줄 — 고르면 PickResult(수량 빼고) 를 만들어 줌 */
|
||||
interface Candidate {
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
/** 줄 단위 1 당 단가 · 모르면 null — 금액 어림용 */
|
||||
price: number | null;
|
||||
/** 줄 오른쪽 글(단가 표시) */
|
||||
priceText: string;
|
||||
tag?: string;
|
||||
make: () => Promise<Omit<PickResult, "qty">>;
|
||||
}
|
||||
|
||||
interface Filter {
|
||||
q: string;
|
||||
sub: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
interface Source {
|
||||
title: string;
|
||||
desc: string;
|
||||
/** 구분 · 상세구분 칸을 보일지(재료만) */
|
||||
grouped?: boolean;
|
||||
/** 이름 · 구분이 비면 목록을 안 부르고 null(안내 글만) */
|
||||
load: (f: Filter) => Promise<{ total: number; items: Candidate[] } | null>;
|
||||
}
|
||||
|
||||
const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
|
||||
|
||||
const SOURCES: Partial<Record<Kind, Source>> = {
|
||||
person: {
|
||||
title: te("Person"),
|
||||
desc: te("PersonDesc"),
|
||||
load: async (f) => {
|
||||
const { total, items } = await fetchPick("job", f.q);
|
||||
return {
|
||||
total,
|
||||
items: items.map((it) => {
|
||||
const price = num(it.값);
|
||||
return {
|
||||
name: it.이름,
|
||||
spec: it.규격,
|
||||
unit: "인",
|
||||
price,
|
||||
priceText: price === null ? te("NoPrice") : te("PerDay", { p: won(price) }),
|
||||
make: async () => ({
|
||||
kind: "person",
|
||||
name: it.이름,
|
||||
spec: it.규격,
|
||||
unit: "인",
|
||||
element: it.ref,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
material: {
|
||||
title: te("Material"),
|
||||
desc: te("MaterialDesc"),
|
||||
grouped: true,
|
||||
load: async (f) => {
|
||||
if (!f.q && !f.sub) return null;
|
||||
const found = f.q
|
||||
? await fetchPick("price", f.q).then((r) => ({
|
||||
total: r.total,
|
||||
items: r.items
|
||||
.filter((it) => !f.sub || (it as { 구분?: string }).구분 === f.sub)
|
||||
.map((it) => ({ ...it, 구분: (it as { 구분?: string }).구분, 상세구분: undefined })),
|
||||
}))
|
||||
: await fetchMaterials({ sub: f.sub, detail: f.detail || undefined }).then((r) => ({
|
||||
total: r.total,
|
||||
items: r.items.map((it) => ({ ...it, 구분: it.구분 ?? f.sub })),
|
||||
}));
|
||||
return {
|
||||
total: found.total,
|
||||
items: found.items.map((it) => {
|
||||
const name = it.이름 ?? "";
|
||||
const spec = it.규격 ?? "";
|
||||
const unit = typeof it.단위 === "string" ? it.단위 : "";
|
||||
const price = num(it.값);
|
||||
const element: Record<string, string> = { 구분: it.구분 ?? f.sub };
|
||||
const detail = (it as { 상세구분?: string }).상세구분 ?? f.detail;
|
||||
if (detail) element["상세구분"] = detail;
|
||||
element["검색어"] = `${name} ${spec}`.trim();
|
||||
return {
|
||||
name,
|
||||
spec,
|
||||
unit,
|
||||
price,
|
||||
priceText:
|
||||
price === null ? te("NoPrice") : te("PerUnit", { u: unit || "1", p: won(price) }),
|
||||
tag: element["구분"],
|
||||
make: async () => ({ kind: "material", name, spec, unit, element }),
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
machine: {
|
||||
title: te("Machine"),
|
||||
desc: te("MachineDesc"),
|
||||
load: async (f) => {
|
||||
const items = await listMachines(f.q);
|
||||
return {
|
||||
total: items.length,
|
||||
items: items.map((m) => ({
|
||||
name: m.이름,
|
||||
spec: m.규격,
|
||||
unit: m.단위,
|
||||
price: null,
|
||||
priceText: "",
|
||||
make: async () => {
|
||||
const { element, needInputs } = await machineElement(m.분류번호);
|
||||
return {
|
||||
kind: "machine",
|
||||
name: m.이름,
|
||||
spec: m.규격,
|
||||
unit: m.단위,
|
||||
element,
|
||||
needInputs,
|
||||
};
|
||||
},
|
||||
})),
|
||||
};
|
||||
},
|
||||
},
|
||||
logic: {
|
||||
title: te("Logic"),
|
||||
desc: te("LogicDesc"),
|
||||
load: async (f) => {
|
||||
// 돈으로 나오는 일위대가만 — 「원/㎡」 꼴
|
||||
const items = (await listEasy(f.q)).filter((e) => e.resultUnit.startsWith("원/"));
|
||||
return {
|
||||
total: items.length,
|
||||
items: items.map((e) => {
|
||||
const unit = e.resultUnit.slice(2);
|
||||
return {
|
||||
name: e.name,
|
||||
spec: "",
|
||||
unit,
|
||||
price: null,
|
||||
priceText: "",
|
||||
tag: e.owner,
|
||||
make: async () => ({
|
||||
kind: "logic",
|
||||
name: e.name,
|
||||
spec: "",
|
||||
unit,
|
||||
element: `로직(${e.key})`,
|
||||
}),
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** 물음 셋 — 고른 것 · 취소는 null */
|
||||
export function openEasyPick(kind?: Kind): Promise<PickResult | null> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (r: PickResult | null): void => {
|
||||
if (!settled) resolve(r);
|
||||
settled = true;
|
||||
};
|
||||
openModal({
|
||||
title: te("Title"),
|
||||
closeLabel: te("Close"),
|
||||
dialogClass: "m01-easypick",
|
||||
beforeClose: () => (finish(null), true),
|
||||
mount: (body, close) => {
|
||||
const done = (r: PickResult): void => (finish(r), close());
|
||||
const stepKinds = (): void => {
|
||||
body.replaceChildren(
|
||||
el("h4", { className: "m01-easypick__ask", text: te("AskKind") }),
|
||||
kindButtons(),
|
||||
);
|
||||
body.querySelector<HTMLElement>("button")?.focus();
|
||||
};
|
||||
const kindButtons = (): HTMLElement => {
|
||||
const order: Kind[] = ["person", "material", "machine", "logic"];
|
||||
const grid = el("div", { className: "m01-easypick__kinds" });
|
||||
order.forEach((k, i) => {
|
||||
const src = SOURCES[k];
|
||||
const b = el("button", {
|
||||
className: "m01-easypick__kind",
|
||||
attrs: { type: "button", "data-kind": k, "aria-keyshortcuts": String(i + 1) },
|
||||
children: [
|
||||
el("strong", { text: src?.title ?? labelOf(k) }),
|
||||
el("span", { text: src?.desc ?? descOf(k) }),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", () => stepList(k, true));
|
||||
grid.append(b);
|
||||
});
|
||||
grid.addEventListener("keydown", (ev) => {
|
||||
const k = order[Number(ev.key) - 1];
|
||||
if (k) stepList(k, true);
|
||||
});
|
||||
return grid;
|
||||
};
|
||||
const stepList = (k: Kind, canBack: boolean): void => {
|
||||
const src = SOURCES[k];
|
||||
if (!src) return;
|
||||
const back = canBack ? stepKinds : null;
|
||||
const search = createInputField({ type: "search", placeholder: te("Search") });
|
||||
const filter: Filter = { q: "", sub: "", detail: "" };
|
||||
const list = el("div", { className: "m01-easypick__list", attrs: { role: "listbox" } });
|
||||
const count = el("span", { className: "m01-easypick__muted" });
|
||||
const bar: HTMLElement[] = [search.root];
|
||||
let seq = 0;
|
||||
const load = async (): Promise<void> => {
|
||||
const mine = ++seq;
|
||||
count.textContent = "";
|
||||
list.replaceChildren(
|
||||
el("p", { className: "m01-easypick__muted", text: te("Loading") }),
|
||||
);
|
||||
try {
|
||||
const found = await src.load(filter);
|
||||
if (mine !== seq) return; // 최신 응답만
|
||||
if (!found)
|
||||
return void list.replaceChildren(
|
||||
el("p", { className: "m01-easypick__muted", text: te("Hint") }),
|
||||
);
|
||||
count.textContent = te("Count", { n: found.total, m: found.items.length });
|
||||
list.replaceChildren(
|
||||
...(found.items.length
|
||||
? found.items.map((c) =>
|
||||
rowOf(c, () => stepQty(k, c, back ? () => stepList(k, canBack) : null)),
|
||||
)
|
||||
: [el("p", { className: "m01-easypick__muted", text: te("None") })]),
|
||||
);
|
||||
} catch {
|
||||
if (mine === seq) showToast(te("Failed"), "error");
|
||||
}
|
||||
};
|
||||
if (src.grouped) bar.unshift(...subFilters(filter, load));
|
||||
let timer = 0;
|
||||
search.input.addEventListener("input", () => {
|
||||
filter.q = search.input.value.trim();
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(load, 250);
|
||||
});
|
||||
body.replaceChildren(
|
||||
...(back ? [createButton({ label: te("Back"), variant: "ghost", onClick: back })] : []),
|
||||
el("h4", { className: "m01-easypick__ask", text: `${src.title} — ${te("AskWhich")}` }),
|
||||
el("div", { className: "m01-easypick__bar", children: bar }),
|
||||
count,
|
||||
list,
|
||||
);
|
||||
body.onkeydown = (ev) => {
|
||||
if (back && ev.altKey && ev.key === "ArrowLeft") back();
|
||||
else if (ev.key === "ArrowDown" || ev.key === "ArrowUp") moveFocus(body, ev);
|
||||
};
|
||||
search.input.focus();
|
||||
void load();
|
||||
};
|
||||
const stepQty = (k: Kind, c: Candidate, back: (() => void) | null): void => {
|
||||
const qty = createInputField({
|
||||
type: "text",
|
||||
label: te("QtyLabel", { u: c.unit || "—" }),
|
||||
placeholder: te("QtyPlace"),
|
||||
});
|
||||
qty.input.inputMode = "decimal";
|
||||
const rough = el("p", { className: "m01-easypick__rough" });
|
||||
const parse = (): number | null => {
|
||||
const n = Number(qty.input.value.trim().replace(/,/g, ""));
|
||||
return qty.input.value.trim() !== "" && Number.isFinite(n) && n > 0 ? n : null;
|
||||
};
|
||||
const refresh = (): void => {
|
||||
const n = parse();
|
||||
rough.textContent =
|
||||
n !== null && c.price !== null ? te("Rough", { m: won(n * c.price) }) : "";
|
||||
qty.setError(undefined);
|
||||
};
|
||||
const submit = async (): Promise<void> => {
|
||||
const n = parse();
|
||||
if (n === null) return qty.setError(te("QtyBad"));
|
||||
done({ ...(await c.make()), qty: n });
|
||||
};
|
||||
qty.input.addEventListener("input", refresh);
|
||||
qty.input.addEventListener("keydown", (ev) => ev.key === "Enter" && void submit());
|
||||
body.replaceChildren(
|
||||
...(back ? [createButton({ label: te("Back"), variant: "ghost", onClick: back })] : []),
|
||||
el("h4", { className: "m01-easypick__ask", text: te("AskQty") }),
|
||||
el("p", {
|
||||
className: "m01-easypick__chosen",
|
||||
children: [
|
||||
el("span", { className: "m01-easypick__muted", text: te("Picked") }),
|
||||
el("strong", { text: ` ${c.name} ${c.spec}`.trimEnd() }),
|
||||
],
|
||||
}),
|
||||
qty.root,
|
||||
rough,
|
||||
createButton({ label: te("Add"), variant: "filled", onClick: () => void submit() }),
|
||||
);
|
||||
body.onkeydown = (ev) => ev.altKey && ev.key === "ArrowLeft" && back?.();
|
||||
qty.input.focus();
|
||||
void k;
|
||||
};
|
||||
if (kind) stepList(kind, false);
|
||||
else stepKinds();
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const labelOf = (k: Kind): string =>
|
||||
te(({ person: "Person", material: "Material", machine: "Machine", logic: "Logic" } as const)[k]);
|
||||
const descOf = (k: Kind): string =>
|
||||
te(
|
||||
(
|
||||
{
|
||||
person: "PersonDesc",
|
||||
material: "MaterialDesc",
|
||||
machine: "MachineDesc",
|
||||
logic: "LogicDesc",
|
||||
} as const
|
||||
)[k],
|
||||
);
|
||||
|
||||
/** 목록 줄 — 이름 · 크기 · 단위 · 단가 */
|
||||
function rowOf(c: Candidate, onPick: () => void): HTMLElement {
|
||||
const b = el("button", {
|
||||
className: "m01-easypick__row",
|
||||
attrs: { type: "button", role: "option" },
|
||||
children: [
|
||||
el("span", {
|
||||
className: "m01-easypick__name",
|
||||
children: [
|
||||
el("strong", { text: c.name }),
|
||||
...(c.spec ? [el("span", { className: "m01-easypick__muted", text: ` ${c.spec}` })] : []),
|
||||
...(c.tag ? [el("span", { className: "m01-easypick__tag", text: c.tag })] : []),
|
||||
],
|
||||
}),
|
||||
el("span", { className: "m01-easypick__unit", text: c.unit }),
|
||||
el("span", { className: "m01-easypick__price", text: c.priceText }),
|
||||
],
|
||||
});
|
||||
b.addEventListener("click", onPick);
|
||||
return b;
|
||||
}
|
||||
|
||||
/** 위·아래 화살표로 찾기 칸 ↔ 줄 사이 오감 */
|
||||
function moveFocus(body: HTMLElement, ev: KeyboardEvent): void {
|
||||
const items = [...body.querySelectorAll<HTMLElement>("input[type=search], .m01-easypick__row")];
|
||||
const at = items.indexOf(document.activeElement as HTMLElement);
|
||||
if (at < 0) return;
|
||||
const next = items[at + (ev.key === "ArrowDown" ? 1 : -1)];
|
||||
if (!next) return;
|
||||
ev.preventDefault();
|
||||
next.focus();
|
||||
}
|
||||
|
||||
/** 재료 구분 → 상세구분 고르기 칸 둘 — 바꾸면 load 다시 */
|
||||
function subFilters(filter: Filter, load: () => Promise<void>): HTMLElement[] {
|
||||
let subs: SubInfo[] = [];
|
||||
const all = { value: "", text: te("AllSub") };
|
||||
const subBox = createSelectField({
|
||||
options: [all],
|
||||
value: "",
|
||||
compact: true,
|
||||
ariaLabel: te("SubLabel"),
|
||||
onChange: (v) => ((filter.sub = v), (filter.detail = ""), fillDetail(), void load()),
|
||||
});
|
||||
const detailBox = createSelectField({
|
||||
options: [all],
|
||||
value: "",
|
||||
compact: true,
|
||||
ariaLabel: te("DetailLabel"),
|
||||
onChange: (v) => ((filter.detail = v), void load()),
|
||||
});
|
||||
const fillDetail = (): void => {
|
||||
const details = subs.find((s) => s.name === filter.sub)?.details ?? [];
|
||||
detailBox.root.hidden = !details.length;
|
||||
detailBox.setOptions([all, ...details.map((d) => ({ value: d, text: d }))], "");
|
||||
};
|
||||
fillDetail();
|
||||
void fetchSubs({ file: MAT_FILE })
|
||||
.then((r) => {
|
||||
subs = r;
|
||||
subBox.setOptions([all, ...r.map((s) => ({ value: s.name, text: s.name }))], filter.sub);
|
||||
})
|
||||
.catch(() => showToast(te("Failed"), "error"));
|
||||
return [subBox.root, detailBox.root];
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
.m01-easysheet__title {
|
||||
margin: 0 0 var(--spacing-8);
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.m01-easysheet__status,
|
||||
.m01-easysheet__muted {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
/* 좁은 폭은 표 안에서만 가로 스크롤 — 쪽 전체는 안 밀림 */
|
||||
.m01-easysheet__scroll {
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.m01-easysheet__table {
|
||||
width: 100%;
|
||||
min-width: 920px;
|
||||
border-collapse: collapse;
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-easysheet__table th,
|
||||
.m01-easysheet__table td {
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.m01-easysheet__table thead th {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-easysheet__table .m01-easysheet__group {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.m01-easysheet__table .m01-easysheet__num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-easysheet__table .m01-easysheet__note {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.m01-easysheet__table tfoot th,
|
||||
.m01-easysheet__table tfoot td {
|
||||
background: var(--color-surface);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.m01-easysheet__strip-title {
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.m01-easysheet__fields {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8) var(--spacing-16);
|
||||
margin: var(--spacing-8) 0;
|
||||
}
|
||||
|
||||
.m01-easysheet__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-easysheet__ctl {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m01-easysheet__input {
|
||||
box-sizing: border-box;
|
||||
width: 160px;
|
||||
max-width: 100%;
|
||||
height: var(--control-height-sm);
|
||||
padding: 3px 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
font: inherit;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-easysheet__input[aria-invalid] {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.m01-easysheet__warn {
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/* =============================================================================
|
||||
* M01_EasyCalc_Sheet.ts
|
||||
* 쉬운 단가산출 — 실무 일위대가표(읽기만) · 「설계 때 정하는 값」 띠(시험값만, 저장 안 됨)
|
||||
* 계약 = docs/raw/M01_쉬운단가/설계.md 3절 Sheet. 이 폴더째 지워도 됨.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createSelectField, el } from "@ui/ui_template_elements";
|
||||
import {
|
||||
searchElements,
|
||||
type CalcAnswer,
|
||||
type CalcLine,
|
||||
type LogicInput,
|
||||
} from "../M01_MasterData_UI_Logic_Api";
|
||||
import { formatMoney, formatPrice } from "../M01_MasterData_UI_Logic_Money";
|
||||
import { safeReason } from "../M01_MasterData_UI_Logic_Text";
|
||||
import "./M01_EasyCalc_Sheet.css";
|
||||
|
||||
const TEXT = {
|
||||
Busy: ["계산 중", "Calculating"],
|
||||
Stopped: ["계산하지 못했어요", "Could not calculate"],
|
||||
Name: ["명칭", "Name"],
|
||||
Spec: ["규격", "Size"],
|
||||
Qty: ["수량", "Qty"],
|
||||
Unit: ["단위", "Unit"],
|
||||
Total: ["합계", "Total"],
|
||||
Labor: ["노무비", "Labor"],
|
||||
Material: ["재료비", "Material"],
|
||||
Expense: ["경비", "Expense"],
|
||||
Note: ["비고", "Note"],
|
||||
Price: ["단가", "Price"],
|
||||
Amount: ["금액", "Amount"],
|
||||
Strip: ["설계 때 정하는 값", "Values set at design time"],
|
||||
Trial: ["여기 값은 시험용 — 저장 안 됨", "Trial values only — not saved"],
|
||||
Range: ["{a} ∼ {b} 사이", "between {a} and {b}"],
|
||||
Pick: ["고르세요", "Choose"],
|
||||
Bad: ["숫자를 넣어 주세요", "Enter a number"],
|
||||
OutOfRange: ["범위 밖이에요", "Out of range"],
|
||||
} as const;
|
||||
|
||||
const ts = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex];
|
||||
|
||||
const COSTS = ["노무비", "재료비", "경비"] as const;
|
||||
const COST_HEAD = { 노무비: "Labor", 재료비: "Material", 경비: "Expense" } as const;
|
||||
|
||||
const qty = (n: number): string => n.toLocaleString("ko-KR", { maximumFractionDigits: 6 });
|
||||
const cell = (text: string, className = ""): HTMLElement => el("td", { className, text });
|
||||
const money = (text: string): HTMLElement => cell(text, "m01-easysheet__num");
|
||||
|
||||
/** 준용 · 산정 글에 붙은 마스터 번호는 뺌 */
|
||||
const note = (line: CalcLine): string =>
|
||||
[line.출처?.replace(/[A-Z]{2}\d{6}\./g, ""), line.단위경고].filter(Boolean).join(" · ");
|
||||
|
||||
function lineRow(line: CalcLine): HTMLElement {
|
||||
// 비목별 단가 = 줄 단가 × (그 비목 금액 ÷ 줄 금액) — 금액 반올림 오차가 단가에 안 섞임
|
||||
const per = (k: string): number =>
|
||||
line.금액 ? (line.단가 * (line.비목?.[k] ?? 0)) / line.금액 : 0;
|
||||
return el("tr", {
|
||||
children: [
|
||||
cell(line.이름),
|
||||
cell(line.품목?.규격 ?? ""),
|
||||
money(qty(line.수량)),
|
||||
cell(line.단위),
|
||||
money(formatPrice(line.단가)),
|
||||
money(formatMoney(line.금액)),
|
||||
...COSTS.flatMap((k) => [
|
||||
money(formatPrice(per(k))),
|
||||
money(formatMoney(line.비목?.[k] ?? 0)),
|
||||
]),
|
||||
cell(note(line), "m01-easysheet__note"),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 실무 일위대가표 — 읽기만 · 맨 아래 합계 줄 */
|
||||
export function renderSheet(
|
||||
host: HTMLElement,
|
||||
head: { name: string; resultUnit: string },
|
||||
ans: CalcAnswer | null,
|
||||
): void {
|
||||
const title = el("p", {
|
||||
className: "m01-easysheet__title",
|
||||
text: [head.name, head.resultUnit].filter(Boolean).join(" · "),
|
||||
});
|
||||
if (!ans || !ans.ok) {
|
||||
host.replaceChildren(
|
||||
title,
|
||||
el("p", {
|
||||
className: "m01-easysheet__status",
|
||||
text: ans ? `${ts("Stopped")} — ${safeReason(ans.reason)}` : ts("Busy"),
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const lines = ans.lines ?? [];
|
||||
const sums = ans.sums ?? {};
|
||||
const group = (label: string): HTMLElement =>
|
||||
el("th", { className: "m01-easysheet__group", text: label, attrs: { colspan: "2" } });
|
||||
const one = (label: string): HTMLElement => el("th", { text: label, attrs: { rowspan: "2" } });
|
||||
const pair = (): HTMLElement[] => [ts("Price"), ts("Amount")].map((h) => el("th", { text: h }));
|
||||
const table = el("table", {
|
||||
className: "m01-easysheet__table",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [
|
||||
el("tr", {
|
||||
children: [
|
||||
one(ts("Name")),
|
||||
one(ts("Spec")),
|
||||
one(ts("Qty")),
|
||||
one(ts("Unit")),
|
||||
group(ts("Total")),
|
||||
...COSTS.map((k) => group(ts(COST_HEAD[k]))),
|
||||
one(ts("Note")),
|
||||
],
|
||||
}),
|
||||
el("tr", { children: [...pair(), ...pair(), ...pair(), ...pair()] }),
|
||||
],
|
||||
}),
|
||||
el("tbody", { children: lines.map(lineRow) }),
|
||||
el("tfoot", {
|
||||
children: [
|
||||
el("tr", {
|
||||
children: [
|
||||
el("th", { text: ts("Total"), attrs: { colspan: "4" } }),
|
||||
cell(""),
|
||||
money(formatMoney(sums["계"] ?? 0)),
|
||||
...COSTS.flatMap((k) => [cell(""), money(formatMoney(sums[k] ?? 0))]),
|
||||
cell(""),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
host.replaceChildren(title, el("div", { className: "m01-easysheet__scroll", children: [table] }));
|
||||
}
|
||||
|
||||
/** 마스터 번호 꼴 고르기 값 → 이름(한 번 물은 것은 다시 안 물음) */
|
||||
const KEY_RE = /^[A-Z]{2}\d{6}$/;
|
||||
const KEY_GROUP: Record<string, string> = { MT: "재료", EQ: "기계", LB: "인력" };
|
||||
const nameCache = new Map<string, string | null>();
|
||||
|
||||
async function keyName(key: string): Promise<string | null> {
|
||||
if (nameCache.has(key)) return nameCache.get(key)!;
|
||||
const group = KEY_GROUP[key.slice(0, 2)];
|
||||
let name: string | null = null;
|
||||
if (group) {
|
||||
try {
|
||||
const hit = (await searchElements(group, key)).items.find((it) => it.ref === key);
|
||||
name = hit ? `${hit.이름 ?? ""}${hit.규격 ? ` ${hit.규격}` : ""}`.trim() || null : null;
|
||||
} catch {
|
||||
name = null;
|
||||
}
|
||||
}
|
||||
nameCache.set(key, name);
|
||||
return name;
|
||||
}
|
||||
|
||||
/** state = 띠가 들고 있는 시험값(고칠 때마다 같은 객체를 바꿈) · emit = 바뀐 값을 밖에 알림 */
|
||||
type Trial = Record<string, unknown>;
|
||||
|
||||
function numberBox(spec: LogicInput, state: Trial, emit: () => void): HTMLElement {
|
||||
const [lo, hi] = spec.범위 ?? [];
|
||||
const hint = spec.범위 ? ts("Range").replace("{a}", String(lo)).replace("{b}", String(hi)) : "";
|
||||
const box = el("input", {
|
||||
className: "m01-easysheet__input",
|
||||
attrs: { type: "text", inputmode: "decimal", placeholder: hint },
|
||||
});
|
||||
const warn = el("span", { className: "m01-easysheet__warn" });
|
||||
box.value = state[spec.이름] === undefined ? "" : String(state[spec.이름]);
|
||||
box.addEventListener("change", () => {
|
||||
const raw = box.value.trim();
|
||||
const num = Number(raw);
|
||||
const bad = raw !== "" && Number.isNaN(num) ? ts("Bad") : "";
|
||||
const out = !bad && raw !== "" && spec.범위 && (num < lo! || num > hi!) ? ts("OutOfRange") : "";
|
||||
warn.textContent = bad || out;
|
||||
box.toggleAttribute("aria-invalid", !!(bad || out));
|
||||
if (bad) return;
|
||||
if (raw === "") delete state[spec.이름];
|
||||
else state[spec.이름] = num;
|
||||
emit();
|
||||
});
|
||||
return el("span", { className: "m01-easysheet__ctl", children: [box, warn] });
|
||||
}
|
||||
|
||||
function pickBox(spec: LogicInput, state: Trial, emit: () => void): HTMLElement {
|
||||
const picks = spec.고르기 ?? [];
|
||||
const optionText = (o: string | number): string =>
|
||||
(typeof o === "string" && nameCache.get(o)) || String(o);
|
||||
const options = (): { value: string; text: string }[] => [
|
||||
{ value: "", text: ts("Pick") },
|
||||
...picks.map((o) => ({ value: String(o), text: optionText(o) })),
|
||||
];
|
||||
const cur = state[spec.이름] === undefined ? "" : String(state[spec.이름]);
|
||||
const field = createSelectField({
|
||||
options: options(),
|
||||
value: cur,
|
||||
compact: true,
|
||||
ariaLabel: spec.이름,
|
||||
onChange: (v) => {
|
||||
const hit = picks.find((o) => String(o) === v); // 원래 값(수) 그대로 보냄
|
||||
if (hit === undefined) delete state[spec.이름];
|
||||
else state[spec.이름] = hit;
|
||||
emit();
|
||||
},
|
||||
});
|
||||
const keys = picks.filter((o): o is string => typeof o === "string" && KEY_RE.test(o));
|
||||
if (keys.length) {
|
||||
void Promise.all(keys.map(keyName)).then(() => field.setOptions(options(), cur));
|
||||
}
|
||||
return field.root;
|
||||
}
|
||||
|
||||
/** 「설계 때 정하는 값」 띠 — 바꾸면 onChange(새 시험값) · 값이 없으면 띠를 숨김 */
|
||||
export function renderTrialStrip(
|
||||
host: HTMLElement,
|
||||
inputs: LogicInput[],
|
||||
trial: Record<string, unknown>,
|
||||
onChange: (trial: Record<string, unknown>) => void,
|
||||
): void {
|
||||
if (!inputs.length) {
|
||||
host.replaceChildren();
|
||||
host.hidden = true;
|
||||
return;
|
||||
}
|
||||
host.hidden = false;
|
||||
const state = { ...trial };
|
||||
const emit = (): void => onChange({ ...state });
|
||||
host.replaceChildren(
|
||||
el("strong", { className: "m01-easysheet__strip-title", text: ts("Strip") }),
|
||||
el("div", {
|
||||
className: "m01-easysheet__fields",
|
||||
children: inputs.map((spec) =>
|
||||
el("label", {
|
||||
className: "m01-easysheet__field",
|
||||
children: [
|
||||
el("span", { text: spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름 }),
|
||||
spec.고르기?.length ? pickBox(spec, state, emit) : numberBox(spec, state, emit),
|
||||
],
|
||||
}),
|
||||
),
|
||||
}),
|
||||
el("p", { className: "m01-easysheet__muted", text: ts("Trial") }),
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,159 @@
|
||||
.m01-easycalc__ready {
|
||||
/* 쉬운 단가산출 — 큰 창 틀 · 시작 화면 · 위 띠 */
|
||||
|
||||
/* 공용 모달(위 고정)을 화면 대부분으로 — 높이를 고정해 화면이 바뀌어도 창이 안 움직임 */
|
||||
.ui-modal.m01-easycalc {
|
||||
--modal-max: 90vh;
|
||||
width: min(1280px, 96vw);
|
||||
height: var(--modal-max);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m01-easycalc .ui-modal__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
gap: var(--spacing-16);
|
||||
}
|
||||
|
||||
.m01-easycalc__muted {
|
||||
margin: 0;
|
||||
padding: var(--spacing-16) 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m01-easycalc__big {
|
||||
min-height: 56px;
|
||||
padding: 0 var(--spacing-24);
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
/* 시작 화면 — 왼쪽 찾기 · 오른쪽 새로 만들기 + 내가 만든 것 */
|
||||
.m01-easycalc__start {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
||||
gap: var(--spacing-24);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.m01-easycalc__start-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m01-easycalc__start-col h4 {
|
||||
margin: var(--spacing-8) 0 0;
|
||||
}
|
||||
|
||||
.m01-easycalc__ask {
|
||||
margin: 0;
|
||||
font-size: var(--text-subheading);
|
||||
}
|
||||
|
||||
.m01-easycalc__search .ui-input {
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.m01-easycalc__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.m01-easycalc__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
width: 100%;
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.m01-easycalc__item:hover:not(:disabled) {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.m01-easycalc__item:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.m01-easycalc__item-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m01-easycalc__item-unit {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--text-body-sm);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 고치는 화면 — 위 띠 */
|
||||
.m01-easycalc__strip {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: var(--spacing-12);
|
||||
padding-bottom: var(--spacing-12);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.m01-easycalc__strip .ui-field {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.m01-easycalc__name {
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
|
||||
.m01-easycalc__total {
|
||||
font-size: var(--text-subheading);
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m01-easycalc__actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.m01-easycalc__base {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-16);
|
||||
}
|
||||
|
||||
.m01-easycalc__base p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.m01-easycalc__part {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.m01-easycalc__part h4 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.m01-easycalc__cards {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.m01-easycalc__sheet summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
"""M01 쉬운 단가산출 Model 시험(34-3b) — Node 로 `M01_EasyCalc_Model.ts` 를 바로 돌림 · 화면 조작은 ORCA.
|
||||
|
||||
· 견본 로직 줄 셋(인력+재료 · 기계+% 덧줄 · 표 찾기 잠금)은 이 시험 안에 둠 — 진짜 마스터에 기대지 않음
|
||||
· 왕복(toDoc → toRow 가 원래 줄과 같음) · 카드 종류 · 잠금 까닭 · 더하기 · 지우기 · 차례 · 숫자로 바꾸기 · 되돌리기 · 검사
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
MODEL = ROOT / "M01_MasterData" / "EasyCalc" / "M01_EasyCalc_Model.ts"
|
||||
|
||||
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
|
||||
# ── 견본 줄 ───────────────────────────────────────────────────────────────
|
||||
HEAD = {"원문번호": "", "구분": "자체", "상세구분": "시험", "출처": "자체", "소유": "현장"}
|
||||
|
||||
PERSON_MATERIAL = {
|
||||
**HEAD,
|
||||
"키": "GX900001",
|
||||
"이름": "시험 찰쌓기",
|
||||
"결과단위": "원/㎡",
|
||||
"입력": [],
|
||||
"중간": [],
|
||||
"호표": [
|
||||
{
|
||||
"종류": "인력",
|
||||
"요소": "LB000005",
|
||||
"이름": "석공",
|
||||
"단위": "인",
|
||||
"수량": "0.072",
|
||||
"비목": "노무비",
|
||||
},
|
||||
{
|
||||
"종류": "재료",
|
||||
"요소": {"구분": "시멘트", "검색어": "시멘트 40kg"},
|
||||
"이름": "시멘트",
|
||||
"규격": "40kg",
|
||||
"단위": "포",
|
||||
"수량": "0.50",
|
||||
"비목": "재료비",
|
||||
"출처": "산림품셈 12-1-1",
|
||||
},
|
||||
],
|
||||
"덧줄": [],
|
||||
"끝수": None,
|
||||
"비고": "견본",
|
||||
}
|
||||
|
||||
MACHINE_PERCENT = {
|
||||
**HEAD,
|
||||
"키": "GX900002",
|
||||
"이름": "시험 깬돌 쌓기",
|
||||
"결과단위": "원/㎡",
|
||||
"입력": [{"이름": "지역", "고르기": ["전국평균", "서울"]}],
|
||||
"중간": [],
|
||||
"호표": [
|
||||
{
|
||||
"종류": "인력",
|
||||
"요소": "LB000002",
|
||||
"이름": "보통인부",
|
||||
"단위": "인",
|
||||
"수량": "0.036",
|
||||
"비목": "노무비",
|
||||
},
|
||||
{
|
||||
"종류": "로직",
|
||||
"요소": "로직(GC000270, 기계='0201-0060', 집게='7206-0070', 지역=지역)",
|
||||
"이름": "굴삭기+집게",
|
||||
"규격": "0.6㎥",
|
||||
"단위": "시간",
|
||||
"수량": "0.27",
|
||||
},
|
||||
{
|
||||
"종류": "로직",
|
||||
"요소": "로직(GX000777)",
|
||||
"이름": "모르터바르기",
|
||||
"단위": "㎥",
|
||||
"수량": "0.009",
|
||||
},
|
||||
],
|
||||
"덧줄": [
|
||||
{"이름": "공구손료", "식": "(0.036 * LB000002) * 2 / 100", "비목": "재료비"},
|
||||
{"이름": "잡재료비", "식": "노무비 * 3 / 100", "비목": "재료비", "출처": "산림품셈 1-2-6"},
|
||||
],
|
||||
"끝수": {"대상": "계", "자리": 0, "방법": "버림"},
|
||||
}
|
||||
|
||||
TABLE_LOCKED = {
|
||||
**HEAD,
|
||||
"키": "GX900003",
|
||||
"이름": "시험 메쌓기",
|
||||
"결과단위": "원/㎡",
|
||||
"입력": [{"이름": "뒷길이", "단위": "㎝"}, {"이름": "보정", "단위": "배"}],
|
||||
"중간": [{"이름": "석공품", "식": "찾기(QF000041, 뒷길이=뒷길이).석공"}],
|
||||
"호표": [
|
||||
{
|
||||
"종류": "인력",
|
||||
"요소": "LB000005",
|
||||
"이름": "석공",
|
||||
"단위": "인",
|
||||
"수량": "석공품",
|
||||
"비목": "노무비",
|
||||
},
|
||||
{
|
||||
"종류": "인력",
|
||||
"요소": "LB000002",
|
||||
"이름": "보통인부",
|
||||
"단위": "인",
|
||||
"수량": "만약(뒷길이 > 40, 0.4, 0.3)",
|
||||
"비목": "노무비",
|
||||
},
|
||||
{
|
||||
"종류": "인력",
|
||||
"요소": "LB000003",
|
||||
"이름": "특별인부",
|
||||
"단위": "인",
|
||||
"수량": "보정 * 0.1",
|
||||
"비목": "노무비",
|
||||
},
|
||||
{
|
||||
"종류": "인력",
|
||||
"요소": "LB000001",
|
||||
"이름": "작업반장",
|
||||
"단위": "인",
|
||||
"수량": "0.1 * 2",
|
||||
"비목": "노무비",
|
||||
},
|
||||
],
|
||||
"덧줄": [
|
||||
{
|
||||
"이름": "공구손료",
|
||||
"식": "(석공품 * LB000005 + (만약(뒷길이 > 40, 0.4, 0.3)) * LB000002"
|
||||
" + (보정 * 0.1) * LB000003 + (0.1 * 2) * LB000001) * 3 / 100",
|
||||
"비목": "경비",
|
||||
},
|
||||
{
|
||||
"이름": "잡재료",
|
||||
"식": "주재료비 * 찾기(QC000589, 구분='잡재료비').값 / 100",
|
||||
"비목": "재료비",
|
||||
},
|
||||
],
|
||||
"끝수": None,
|
||||
}
|
||||
|
||||
SAMPLES = [PERSON_MATERIAL, MACHINE_PERCENT, TABLE_LOCKED]
|
||||
META = {"key": "GX900001", "version": "v1", "isBase": False}
|
||||
|
||||
|
||||
def _run(body: str, *args: object) -> object:
|
||||
"""견본을 받아 Model 을 돌리는 Node 글 — `M` = Model 모듈 · `A` = 넘긴 인자 · `out(x)` 로 답"""
|
||||
script = (
|
||||
f"const M = await import({json.dumps(MODEL.as_uri())});\n"
|
||||
"const A = JSON.parse(process.argv[1]);\n"
|
||||
"const out = (x: unknown) => console.log(JSON.stringify(x));\n" + body
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", script, json.dumps(list(args))],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60, cwd=ROOT,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def _doc(row: dict) -> dict:
|
||||
return _run("out(M.toDoc(A[0], A[1]));", row, META)
|
||||
|
||||
|
||||
# ── 왕복 ───────────────────────────────────────────────────────────────────
|
||||
@pytest.mark.parametrize("row", SAMPLES, ids=["인력재료", "기계덧붙임", "표찾기잠금"])
|
||||
def test_왕복_같음(row: dict) -> None:
|
||||
back = _run("out(M.toRow(M.toDoc(A[0], A[1])));", row, META)
|
||||
assert back == row
|
||||
|
||||
|
||||
def test_새_로직_최소칸() -> None:
|
||||
row = _run("out(M.toRow(M.newDoc('새 일', 'm')));")
|
||||
assert row == {
|
||||
"키": "",
|
||||
"원문번호": "",
|
||||
"이름": "새 일",
|
||||
"결과단위": "원/m",
|
||||
"출처": "자체",
|
||||
"입력": [],
|
||||
"중간": [],
|
||||
"호표": [],
|
||||
"덧줄": [],
|
||||
"끝수": None,
|
||||
}
|
||||
|
||||
|
||||
# ── 카드 바꾸기 ─────────────────────────────────────────────────────────────
|
||||
def test_카드_종류와_숫자() -> None:
|
||||
d = _doc(MACHINE_PERCENT)
|
||||
assert [c["kind"] for c in d["cards"]] == ["person", "machine", "logic", "percent", "percent"]
|
||||
person, machine, logic, tool, misc = d["cards"]
|
||||
assert person["qty"] == 0.036 and person["cost"] == "노무비" and person["qtyFormula"] is None
|
||||
assert machine["cost"] is None and machine["spec"] == "0.6㎥"
|
||||
assert logic["cost"] is None
|
||||
assert (tool["percentBase"], tool["qty"], tool["unit"], tool["cost"]) == (
|
||||
"사람줄합",
|
||||
2,
|
||||
"%",
|
||||
"재료비",
|
||||
)
|
||||
assert misc["percentBase"] == "노무비" and misc["qty"] == 3
|
||||
assert d["inputs"] == MACHINE_PERCENT["입력"] and d["resultUnit"] == "원/㎡"
|
||||
|
||||
|
||||
def test_잠금_까닭_쉬운말() -> None:
|
||||
d = _doc(TABLE_LOCKED)
|
||||
kinds = [c["kind"] for c in d["cards"]]
|
||||
assert kinds == ["person"] * 4 + ["percent", "locked"]
|
||||
why = [c["lockedWhy"] for c in d["cards"]]
|
||||
assert why[:4] == [
|
||||
"품셈 표에서 찾는 값",
|
||||
"조건에 따라 달라지는 값",
|
||||
"설계 때 정하는 값으로 정해짐",
|
||||
"계산으로 정해지는 값",
|
||||
]
|
||||
assert d["cards"][0]["qtyFormula"] == "석공품" and d["cards"][0]["qty"] is None
|
||||
assert d["cards"][4]["percentBase"] == "사람줄합"
|
||||
assert why[5] == "품셈 표에서 찾는 값"
|
||||
for w in filter(None, why):
|
||||
for bad in ("키", "식", "호표", "덧줄", "비목", "정본", "자체"):
|
||||
assert bad not in w
|
||||
|
||||
|
||||
# ── 카드 조작 ───────────────────────────────────────────────────────────────
|
||||
def test_더하기_묶음_뒤_기계는_설계값_더함() -> None:
|
||||
pick_person = {
|
||||
"kind": "person",
|
||||
"name": "석공",
|
||||
"spec": "",
|
||||
"unit": "인",
|
||||
"element": "LB000005",
|
||||
"qty": 0.5,
|
||||
}
|
||||
pick_machine = {
|
||||
"kind": "machine",
|
||||
"name": "굴삭기",
|
||||
"spec": "0.7㎥",
|
||||
"unit": "시간",
|
||||
"element": "로직(GC000267, 기계='0201-0020', 지역=지역, 보정작업='해당 없음')",
|
||||
"qty": 0.3,
|
||||
"needInputs": [{"이름": "지역", "고르기": ["전국평균"]}],
|
||||
}
|
||||
got = _run(
|
||||
"let d = M.toDoc(A[0], A[1]);\n"
|
||||
"d = M.addCard(d, A[2]); d = M.addCard(d, A[3]); d = M.addCard(d, A[3]);\n"
|
||||
"d = M.addPercent(d, '공구손료', '사람줄합', 2, '경비');\n"
|
||||
"out(M.toRow(d));",
|
||||
PERSON_MATERIAL,
|
||||
META,
|
||||
pick_person,
|
||||
pick_machine,
|
||||
)
|
||||
ho = got["호표"]
|
||||
assert [h["이름"] for h in ho] == ["석공", "석공", "시멘트", "굴삭기", "굴삭기"]
|
||||
assert ho[1] == {
|
||||
"종류": "인력",
|
||||
"요소": "LB000005",
|
||||
"이름": "석공",
|
||||
"단위": "인",
|
||||
"수량": "0.5",
|
||||
"비목": "노무비",
|
||||
}
|
||||
assert ho[3]["종류"] == "로직" and "비목" not in ho[3] and ho[3]["규격"] == "0.7㎥"
|
||||
assert got["입력"] == [{"이름": "지역", "고르기": ["전국평균"]}]
|
||||
assert got["덧줄"] == [
|
||||
{"이름": "공구손료", "식": "(0.072 * LB000005 + 0.5 * LB000005) * 2 / 100", "비목": "경비"}
|
||||
]
|
||||
|
||||
|
||||
def test_지우기와_사람줄합_다시짬() -> None:
|
||||
got = _run(
|
||||
"let d = M.toDoc(A[0], A[1]);\n"
|
||||
"const extra = M.addCard(d, {kind:'person', name:'석공', spec:'', unit:'인', element:'LB000005', qty:0.1});\n"
|
||||
"const removed = M.removeCard(extra, d.cards[0].id);\n"
|
||||
"out([M.toRow(extra), M.toRow(removed), M.toRow(M.removeCard(d, d.cards[0].id))]);",
|
||||
MACHINE_PERCENT,
|
||||
META,
|
||||
)
|
||||
two, one, none = got
|
||||
assert two["덧줄"][0]["식"] == "(0.036 * LB000002 + 0.1 * LB000005) * 2 / 100"
|
||||
assert two["덧줄"][1] == MACHINE_PERCENT["덧줄"][1]
|
||||
assert one["덧줄"][0]["식"] == "(0.1 * LB000005) * 2 / 100"
|
||||
assert [h["이름"] for h in none["호표"]] == ["굴삭기+집게", "모르터바르기"]
|
||||
assert none["덧줄"][0]["식"] == "(0) * 2 / 100"
|
||||
|
||||
|
||||
def test_차례_같은_묶음_안에서만() -> None:
|
||||
got = _run(
|
||||
"const d = M.toDoc(A[0], A[1]);\n"
|
||||
"const ids = d.cards.map((c: any) => c.id);\n"
|
||||
"const names = (x: any) => x.cards.map((c: any) => c.name);\n"
|
||||
"out([names(M.moveCard(d, ids[1], 1)), names(M.moveCard(d, ids[0], -1)),"
|
||||
" names(M.moveCard(d, ids[3], 1)), names(M.moveCard(d, ids[0], 1)) ]);",
|
||||
TABLE_LOCKED,
|
||||
META,
|
||||
)
|
||||
down, top, add_down, end = got
|
||||
assert down == ["석공", "특별인부", "보통인부", "작업반장", "공구손료", "잡재료"]
|
||||
assert top == ["석공", "보통인부", "특별인부", "작업반장", "공구손료", "잡재료"]
|
||||
assert add_down == top # 사람 묶음 끝 → 덧붙임으로 안 넘어감
|
||||
assert end == ["보통인부", "석공", "특별인부", "작업반장", "공구손료", "잡재료"]
|
||||
|
||||
|
||||
def test_잠금을_숫자로_바꾸기() -> None:
|
||||
got = _run(
|
||||
"const d = M.toDoc(A[0], A[1]);\n"
|
||||
"const n = M.updateCard(d, d.cards[0].id, { qty: 0.5, qtyFormula: null });\n"
|
||||
"out([n.cards[0], M.toRow(n), d.cards[0].qtyFormula]);",
|
||||
TABLE_LOCKED,
|
||||
META,
|
||||
)
|
||||
card, row, before = got
|
||||
assert card["lockedWhy"] is None and card["qty"] == 0.5
|
||||
assert row["호표"][0]["수량"] == "0.5"
|
||||
assert before == "석공품" # 받은 문서는 안 바뀜
|
||||
# 석공 수량이 바뀌어 사람 줄 합이 다시 짜임
|
||||
assert row["덧줄"][0]["식"].startswith("(0.5 * LB000005 + ")
|
||||
|
||||
|
||||
def test_되돌리기와_저장안함() -> None:
|
||||
got = _run(
|
||||
"const h = new M.EasyHistory();\n"
|
||||
"const d0 = M.toDoc(A[0], A[1]);\n"
|
||||
"const log: unknown[] = [];\n"
|
||||
"h.push(d0); log.push([h.canUndo, h.dirty]);\n"
|
||||
"const d1 = M.updateCard(d0, d0.cards[0].id, { qty: 0.08 });\n"
|
||||
"h.push(d1); h.push(d1); log.push([h.canUndo, h.dirty]);\n"
|
||||
"h.markSaved(); log.push([h.dirty]);\n"
|
||||
"const back = h.undo(); log.push([back.cards[0].qty, h.canUndo, h.dirty, h.undo()]);\n"
|
||||
"out(log);",
|
||||
PERSON_MATERIAL,
|
||||
META,
|
||||
)
|
||||
assert got == [[False, False], [True, True], [False], [0.072, False, True, None]]
|
||||
|
||||
|
||||
# ── 검사 ───────────────────────────────────────────────────────────────────
|
||||
def test_검사_쉬운말_한줄() -> None:
|
||||
got = _run(
|
||||
"const d = M.toDoc(A[0], A[1]);\n"
|
||||
"const c = d.cards[0];\n"
|
||||
"const t = (p: any) => M.checkCard({ ...c, ...p }, d);\n"
|
||||
"out([t({}), t({ qty: null }), t({ qty: Number.NaN }), t({ qty: 0 }), t({ qty: -1 }),"
|
||||
" t({ name: ' ' }), t({ qty: 12 }), t({ qty: null, qtyFormula: '석공품' }),"
|
||||
" M.checkCard(M.addCard(d, {kind:'person', name:'석공', spec:'', unit:'인', element:'LB000005',"
|
||||
" qty:0.1}).cards[1], M.addCard(d, {kind:'person', name:'석공', spec:'', unit:'인',"
|
||||
" element:'LB000005', qty:0.1})),"
|
||||
" M.checkName('시험 찰쌓기', ['시험 찰쌓기']), M.checkName('', []), M.checkName('새것', ['헌것'])]);",
|
||||
PERSON_MATERIAL,
|
||||
META,
|
||||
)
|
||||
assert got == [
|
||||
None,
|
||||
"얼마인지 숫자로 넣어야 함",
|
||||
"얼마인지 숫자로 넣어야 함",
|
||||
"0 은 안 됨 — 빼려면 지움",
|
||||
"0 보다 큰 수만 됨",
|
||||
"이름이 비어 있음",
|
||||
"1㎡ 에 12인 — 너무 큰 수 아닌지?",
|
||||
None,
|
||||
"같은 줄이 이미 있음 — 한 줄에 합쳐 넣음",
|
||||
"내가 만든 것에 같은 이름이 있음",
|
||||
"이름이 비어 있음",
|
||||
None,
|
||||
]
|
||||
|
||||
|
||||
def test_영어_글() -> None:
|
||||
got = _run(
|
||||
"M.setEasyModelLanguage(1);\n"
|
||||
"const d = M.toDoc(A[0], A[1]);\n"
|
||||
"out([d.cards[0].lockedWhy, M.checkCard({ ...d.cards[1], qty: 0, qtyFormula: null }, d)]);",
|
||||
TABLE_LOCKED,
|
||||
META,
|
||||
)
|
||||
assert got == [
|
||||
"Looked up from the standard table",
|
||||
"0 is not allowed — remove the line instead",
|
||||
]
|
||||
|
||||
|
||||
def test_화면_글에_금지_낱말_없음() -> None:
|
||||
src = MODEL.read_text(encoding="utf-8")
|
||||
block = src[src.index("const TEXT = {") : src.index("} as const;")]
|
||||
korean = [ko for _, ko in re.findall(r'(\w+):\s*\[\s*"([^"]*)"', block)]
|
||||
assert len(korean) == 12
|
||||
for text in korean:
|
||||
for bad in ("키", "식", "호표", "덧줄", "비목", "중간 값", "끝수", "정본", "자체", "별칭"):
|
||||
assert bad not in text, (bad, text)
|
||||
Reference in New Issue
Block a user