Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_8

This commit is contained in:
2026-10-01 09:43:14 +09:00
10 changed files with 2633 additions and 42 deletions
+116
View File
@@ -0,0 +1,116 @@
/* =============================================================================
* M01_EasyCalc_Api.ts
* 쉬운 단가산출 Api — 서버 길 감싸기(목록 · 열기 + 자동값 · 시험 계산 뒤에서 · 새로 · 본뜨기 ·
* 고치기 · 지우기 · 쓰는 곳) · 403 · 409 · 422 를 쉬운 말로 · 기계 기종 → 부를 기계경비 로직 · 넘길 값
* 모양은 `docs/raw/M01_쉬운단가/설계.md` 3절 그대로 · 쓰기는 `/logic/*` 만(`/save` 안 씀)
* ========================================================================== */
import type { CalcAnswer, LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api";
/** 목록 한 줄 */
export interface EasyListItem {
key: string;
name: string;
/** 품셈 기본(읽기만) · false = 내가 만든 것 */
isBase: boolean;
/** 화면 글 「품셈 기본」 · 「내가 만든 것」 */
owner: string;
/** 결과단위 「원/㎡」 */
resultUnit: string;
/** 쉬운 길로 열 수 있나 — 돈 아닌 로직(작업량 등)은 false */
canOpen: boolean;
/** 못 여는 까닭 쉬운 말 · 열 수 있으면 null */
whyNot: string | null;
/** 어디 것인지 — 「산림품셈 13장 구조물」 · 내가 만든 것은 장만 */
where: string;
}
/** 열기 답 — `trial` = 설계 때 정하는 값의 견본(자동값) */
export interface EasyOpened {
row: LogicRow;
version: string;
isBase: boolean;
trial: Record<string, unknown>;
}
/** 새로 · 본뜨기 · 고치기 답 */
export interface EasySaved {
key: string;
version: string;
row: LogicRow;
}
/** 쓰는 곳 한 줄 — 일위대가 조합 · 이 로직을 부르는 다른 로직 */
export interface EasyUse {
kind: "combo" | "logic";
key: string;
name: string;
}
/** 기계 고르기 목록 한 줄 */
export interface EasyMachine {
분류번호: string;
이름: string;
규격: string;
/** 줄 단위(「시간」) */
단위: string;
}
/** 기종 → 호표 「요소」(기계경비 로직 부름 글) · 그 로직이 받는 설계 때 정하는 값 */
export interface EasyMachineElement {
element: string;
needInputs: LogicInput[];
}
const notReady = (): never => {
throw new Error("준비 중");
};
export async function listEasy(_q: string): Promise<EasyListItem[]> {
return notReady();
}
export async function openEasy(_key: string): Promise<EasyOpened> {
return notReady();
}
/** 0.3초 뒤 서버 시험 계산 · 최신 응답만 `onDone` · 멈춤 · 실패는 `plain` 쉬운 말 */
export function calcLater(
_row: LogicRow,
_trial: Record<string, unknown>,
_onDone: (ans: CalcAnswer | null, plain: string | null) => void,
): void {
notReady();
}
export async function newEasy(_row: LogicRow): Promise<EasySaved> {
return notReady();
}
export async function copyEasy(_key: string): Promise<EasySaved> {
return notReady();
}
export async function editEasy(_key: string, _version: string, _row: LogicRow): Promise<EasySaved> {
return notReady();
}
export async function deleteEasy(_key: string, _version: string): Promise<{ version: string }> {
return notReady();
}
export async function usesOf(_key: string): Promise<EasyUse[]> {
return notReady();
}
export async function listMachines(_q: string): Promise<EasyMachine[]> {
return notReady();
}
export async function machineElement(_기종: string): Promise<EasyMachineElement> {
return notReady();
}
export function plainError(_e: unknown): string {
return notReady();
}
+413 -15
View File
@@ -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;
}
+492 -25
View File
@@ -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") }),
);
}
+155 -2
View File
@@ -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,141 @@
# 34-1B 지금 단가산출 화면 걸음 재기 (sub6 · 조사만)
⬜ 브레인 답 필요 — 화면에 「추가」라는 글의 단추가 없음. 사용자가 말한 「추가」 = 위 단추 줄 첫째 진한 단추 「식으로 새로 만들기」로 봄(유일한 새로 만들기 단추). 다르면 34-3a 단추 자리가 바뀜.
- 서버 8008 · 화면 5181 · ORCA 탭 하나(끝나고 닫음) · 판정 전 세션 SYSTEM_ADMIN 확인.
- 저장 · 지우기 안 누름. 줄 추가 · 수량 · 단가 바꾸기는 캐시 초안(sessionStorage)에만 넣어 재고 끝에 지움. 끝난 뒤 서버 로직 1,469 개 · 자체(GX) 0 개 그대로 · git 깨끗.
- 사진 — `tmp/쉬운단가_B_사진/` (s01 목록 … s10 알약 창).
## 1. 화면 자리
| 무엇 | 화면 위치 | 파일:줄 |
| --- | --- | --- |
| 들어가는 곳 | 왼쪽 패널 맨 아래 「단가산출 로직 ▸」 상자 | `M01_MasterData_UI_Side.ts:439-440` · 전환 `M01_MasterData_UI_Page.ts` `openLogicTab` |
| 로직 목록 | 가운데 표 · 30 줄씩 49 쪽 · 1,469 줄 · 이름 찾기 · 「막힘만」 | `M01_MasterData_UI_Logic_List.ts` · 걸기 `M01_MasterData_UI_LogicLab.ts:97` |
| 상세 | 줄 누르면 네 상자 — 기본정보 · 단가산출 상세 · 텍스트 수식 · 시험 계산 | `M01_MasterData_UI_LogicLab_Detail.ts:479-505` |
| 위 단추 줄 | 「← 로직 목록」 「단가산출 로직」 (빈칸) [식으로 새로 만들기] [로직 지우기] [고친 것 버리기] [저장] [본떠 만들기] | `M01_MasterData_UI_LogicLab.ts:394-418` |
| 「추가」로 본 단추 | 「식으로 새로 만들기」 — 진한 남색 · 단추 줄 왼쪽 첫째 | `M01_MasterData_UI_LogicLab.ts:400` |
| 그 옆 빈 자리 | 제목과 첫 단추 사이 넓은 빈칸(제목 오른쪽 여백 auto) — `pending`(「저장 안 한 로직 N 개」) 글 뒤 · `:400` 앞에 끼우면 「식으로 새로 만들기」 바로 왼쪽 | `M01_MasterData_UI_LogicLab.ts:398-400` · 줄 모양 `M01_MasterData_UI_Logic_Style.css:64-75` |
- 목록일 때도 「로직 지우기」(빨강) · 「본떠 만들기」가 켜져 있음 — 누르면 아무 일 없음 / 「먼저 로직을 고르세요」 알림.
## 2. 대표 일 여섯 — 걸음
누름 = 단추 누름 + 칸에 글 넣기 1 번씩. 창 = 지나간 화면·띄움창 수.
| 일 | 길 | 누름 | 창 | 막힌 곳 |
| --- | --- | --- | --- | --- |
| ① 새 로직(인력 2 · 재료 1 · 경비 1) | 식으로 새로 만들기 | 38 | 5~6 | 첫 식이 단위 검사에 걸림 · 기계 고르기 깨짐 · 시험 계산 멈춤 |
| ② 있는 로직에 줄 하나 | 기본 로직 → 본떠 만들기 → 사본 편집기 | 10 (자체 로직이면 5) | 5 | 기본 로직은 줄 추가 단추 없음 · 사본 시트도 호표 줄 추가 없음 |
| ③ 수량 바꾸기 | 기본: 본떠 만들기 / 자체: 수량 칸 | 5 / 2 | 3 / 1 | 원본은 못 바꿈(사본이 생김) · 수량 칸이 식 글 |
| ④ 단가 자료 바꾸기 | 자체 로직 「찾기」 | 인력 3 · 재료 불가 · 기계 글 고침 | 2 | 재료 조건 줄 로직은 편집기 빈 화면 · 기계는 코드 글 |
| ⑤ 줄 지우기 | 자체 로직 「×」 | 2 | 1 | 기본 로직은 불가 · 확인창 없음 · 줄 하나 되돌리기 없음 |
| ⑥ 로직 지우기 · 되돌리기 | 로직 지우기 → 확인 → 저장 | 4 | 2 | 저장 뒤 되돌릴 길 없음 · 확인창이 원문번호로 부름 |
### ① 새 로직 — 「식으로 새로 만들기」 (실측)
1. 「단가산출 로직」 1 · 「식으로 새로 만들기」 1 · 식 적기 1 · 「분석」 1 = 4
2. 처음 적은 식 `공사비 = 0.5 * 보통인부 + 0.2 * 특별인부 + 1.1 * 시멘트 + 0.3 * 굴착기` → 「「+」 양쪽 단위가 다름 — 원/인 + 원/포」. 수량마다 이름을 붙여 다시 적고(1) 다시 분석(1) = 2
3. 마스터 요소 넷 — 무엇인가 1 · 고르기 1 · 고름 1 = 인력 3 × 2. 재료 · 기계는 고르기 창이 늘 「인력」으로 열려 종류 바꿈 1 · 찾기 글 1 더 = 5 × 2. 기계는 단위 칸 「[object Object]」 고침 1 더 = 17
4. 고정값 넷(보통인부품 · 특별인부품 · 시멘트량 · 굴착기시간) — 무엇인가 · 값 · 단위 = 3 × 4 = 12
5. 로직 이름 1 · 결과 단위 1 · 저장 1 = 3
6. 합 38 · 창 = 본 화면 + 고르기 창 4 (+ 단가 없는 자재를 바꾸려면 「자재품목 고르기」 1)
7. 결과 — 시험 계산 「멈춤 · 이 조건으로는 셈하지 못함 — 입력값 · 표에 그 조합이 있는지 확인」. 실제 까닭은 고른 시멘트(MT004177)에 단가 없음. 고르기 창이 재료 값을 안 보여 줘 미리 알 길 없음. 멈춤인데도 [저장] 켜짐.
8. 「본떠 만들기」 길(비슷한 로직 사본) = 줄 누름 1 · 본떠 만들기 1 · 이름 1 · 저장(자체 로직) 1 = 4 — 그러나 사본 시트에서는 호표 줄 추가 · 지우기 불가 → 사본 상세 편집기에서 다시 고쳐야 함.
### ② 있는 로직에 줄 하나 (편집기는 캐시 초안으로 실측)
- 기본 로직 1,469 개 모두 상세가 읽기 전용(「기본 로직은 품셈 원문 그대로라 여기서 고치지 못함 — 비고만 고칠 수 있음」).
- 길: 찾기 글 1 · 줄 누름 1 · 본떠 만들기 1 · 저장(자체 로직) 1 → 사본 상세 · 호표 「줄 추가」 1 · 「찾기」 1 · 고름 1 · 수량 1 · 저장 1 = 9 (재료면 종류 바꿈 +1 = 10).
- 사본 상세(자체 로직) 편집기에는 같은 이름 「줄 추가」 단추가 넷 — 설계에서 받을 값 · 호표 · 중간 값 · 덧줄.
- 새 줄 기본값 = 종류 인력 · 단위 인 · 비목 노무비 · 요소 · 이름 · 수량 빈칸.
- 금액 보려면 시험 계산 — 초안은 견본값이 안 채워져 받을 값 셋(횡단경사 · 지역 · 보정작업)을 넣을 때마다 「「지역」 값을 아직 안 넣었습니다」로 하나씩 멈춤 → 3 칸 + 계산 1 = 4 더.
### ③ 수량 바꾸기
- 자체 로직 — 수량 칸 글 고침 1 · 저장 1. 칸 값이 `찾기(QC000544, 도색=도색).특별인부 / 시공량` 같은 식 글.
- 기본 로직 — 본떠 만들기 시트의 식 줄 칸(`(절토량 / 작업량) * (1 + 할증 / 100)`)을 고쳐 저장(자체 로직) → 원본 대신 「… 복사본」이 하나 더 생김.
- 표 안 숫자(예 시공량 700)는 이 화면에서 못 고침 — 알약 → 「마스터 화면에서 보기」로 소요량 표 화면에 가야 함.
### ④ 단가 자료(자재 · 노임) 바꾸기
- 인력 줄(요소 = `LB000002`) — 「찾기」 1 · 고름 1 · 저장 1 = 3.
- 재료 조건 줄(요소 = `{구분 · 상세구분 · 검색어}`) — 이런 줄이 든 로직 230 개(재료 줄 672). 사본을 열면 상세가 빈 화면(2장 흠 B2) → 불가.
- 경비(기계) 줄 — 마스터에 「기계」 종류 줄 0 · 모두 `로직(GC000267, 기계='0201-0020', 지역=지역, 보정작업=보정작업)` 부르기(1,075 줄). 굴착기 0.2㎥ → 0.4㎥는 글 속 코드 `'0201-0020'`를 손으로 바꿔야 함. 「찾기」는 로직 1,469 개를 보여 주고 고르면 괄호 속 인자(기계= …)가 사라짐.
- 「이름·규격」 칸은 꼬리표일 뿐 — 바꿔도 단가 그대로.
- 시험 계산의 「품목 고르기」(물가자료 · 유통물가 · 물가정보 · 거래가격 넷 보임)는 시험에만 쓰고 로직에 안 남음 — 바꾼 줄로 착각 쉬움.
### ⑤ 줄 지우기
- 자체 로직 — 줄 끝 「×」 1 · 저장 1. 확인창 없음.
- 되돌리기 = 「고친 것 버리기」 하나뿐 — 모든 로직의 고친 것을 통째로 버림. 줄 하나 되살리기 없음.
- 기본 로직 · 본떠 만들기 시트 — 줄 지우기 단추 없음.
### ⑥ 로직 지우기 · 되돌리기
- 줄 누름 1 · 로직 지우기 1 · 확인 1 · 저장 1 = 4 (실측은 확인창에서 취소).
- 확인창 글 「로직 「3-4-2」 을 지울까요? [저장] 때 파일에서 빠짐」 — 이름이 아니라 원문번호로 부름. 같은 3-4-2 가 GF000003 · GF000004 둘.
- 「고치지 못함」 기본 로직도 지우기는 됨(서버 `/save` 가 지움을 막지 않음 · 다른 로직이 부르면 저장 때 검사로 막힘).
- 저장 전 되돌리기 = 「고친 것 버리기」(전부) · 저장 뒤 = 길 없음(휴지통 · 초기화 없음).
- 저장 안 한 새 로직을 지우면 확인 뒤 바로 사라짐.
## 3. 처음 보는 사람이 모를 낱말
- 호표 · 덧줄 · 중간 값 · 끝수 · 비목 · 몫(「노무비 몫」) · 소계 · 결과 단위 · 원문번호 · 출처 · 막힘
- 기본 로직 · 정본 · 자체 로직 · 본뜸 · 복사본
- 별칭(복사 시트 「부르는 표·로직」 상자) · 그대로 부르기 · 같이 복사하기 · 표 바꾸기
- 마스터 요소 · 설계 입력 · 고정값 · 표 찾기 · 덧줄 비목 · 「비목 합 — 덧줄에서만 쓰는 이름」 · 식으로 정해짐
- 키 머리글 — GF · GC · GX · LB · MT · EQ · QF · QC
- 식 낱말 — `찾기(QF000028, 구분='m당 절토량').값` · `로직(GC000267, 기계='0201-0020', …)` · `만약(횡단경사 >= 55, …, 0)` · `LB:{공수직종}` · `MO:휘발유:{지역}`
- 소유 — 세 화면이 다 다름: 식 화면 빈 글칸 · 편집기 「공용 / 개인」 · 복사 시트 「현장 / 공용」
- 시험 계산 — 계산 때 · 고친 줄로 계산(저장 전) · 멈춤 · 물가자료 · 유통물가 · 물가정보 · 거래가격 · 대표
- 텍스트 수식 속 기계가 지은 이름 — `굴착기무한궤도노무비단가` · 알약 꼬리표 `실선 · 5ton`(표 칸 `트럭2_5ton` 줄임)
## 4. 화면에 안 보이는 규칙
1. 기본 로직은 줄 · 수량 · 단가 모두 못 고침 → 먼저 「본떠 만들기」로 자체 사본을 만들어야 함. 원본은 그대로.
2. 본떠 만들기 시트는 호표 줄 추가 · 지우기 불가 — 덧줄 추가만.
3. 「같이 복사하기」는 [저장] 없이 그 자리에서 서버에 새 로직을 씀 — 시트를 취소해도 남음(`M01_MasterData_UI_LogicLab_Copy.ts:197` → `/logic/copy` → `M01_MasterData_Store_Make.py:215` `logic_new`).
4. 「식으로 새로 만들기」는 위 [저장]과 따로 — ④ 칸의 [저장]이 바로 서버에 씀 · 캐시에 안 쌓임 · 「닫기」는 묻지 않고 다 버림. 한 화면에 [저장] 둘.
5. 식의 숫자는 단위가 없어 바로 곱하면 단위 검사에 걸림 — 수량마다 고정값 변수 + 단위를 적어야 통과.
6. 변수 목록은 식 순서가 아니라 가나다 순.
7. 경비(기계)는 요소가 아니라 「기계 시간당 경비 로직」을 기계 코드로 부르는 꼴. 식 화면은 기계 요소(EQ)를 바로 넣어 마스터와 다른 모양을 만듦.
8. 한 기계 줄이 인력 · 재료 · 경비 세 묶음에 「몫」으로 쪼개 보임 — 실제 줄은 하나.
9. 「이름」 칸은 꼬리표 — 단가 자료는 「요소」 칸 글이 정함.
10. 고르기 창이 셋 — 식 화면 「마스터 요소」(50 개 · 구분 거름 · 재료 값 안 보임) · 편집기 「요소 찾기」(100 개 · 거름 없음) · 시험 계산 「자재품목 고르기」(물가 넷). 열 때마다 종류가 「인력」으로 돌아가고 변수 이름으로 미리 찾아 두지 않음.
11. 시험 계산 견본값은 저장된 로직만 자동으로 채움(`M01_MasterData_UI_Logic_Calc.ts:153-155`) — 기본 로직은 바로 금액이 나오나 새 로직 · 사본 초안은 빈칸이라 받을 값을 다 채울 때까지 한 칸씩 멈춤.
12. 「고친 것 버리기」는 로직 하나가 아니라 저장 안 한 모든 로직을 버림.
13. 식 화면은 결과(원)와 결과 단위(원/㎡)가 어긋나도 검사 안 함.
14. 고르기 창 단위와 줄 단위가 다를 수 있음 — 노임 「원/일」을 골라도 줄 단위는 「인」.
## 5. 다섯 기능 — 하려던 것 · 어려운 것
- **본떠 만들기** — 고칠 수 없는 기본 로직의 사본(자체)을 떠서 고치게 하려던 것. 어려움: 줄 추가 · 지우기 불가(덧줄만, 그마저 안 보임) · 별칭 상자와 「그대로 부르기 / 같이 복사하기」(즉시 저장) 갈래를 알아야 함 · 사본 뒤 다시 편집기로 가야 함.
- **식으로 새로 만들기** — 「이름 = 식」 글 몇 줄로 새 로직을 짓게 하려던 것. 어려움: 빈 글에서 식 문법과 단위 맞춤을 알아야 함 · 변수마다 무엇인가 네 갈래 · 기계 고르기 깨짐 · 닫기에 안 물음.
- **텍스트 수식** — 계산을 「변수 이름 식 = 값 넣은 식」 두 칸 글로 보여 검산하게 하려던 것. 어려움: 읽기만 · 기계가 지은 긴 이름 · 계산 전엔 오른쪽이 「시험 계산을 누르면 식이 글로 나옴」.
- **시험 계산** — 받을 값을 넣어 금액을 미리 보게 하려던 것. 어려움: 새 로직 · 초안은 한 칸씩 멈춤 · 멈춤 까닭이 엉뚱할 때 있음 · 품목 고르기가 시험에만 남음.
- **알약** — 수량 식 속 표 · 요소 · 로직 이름을 누르면 그 자료(걸린 줄 노란색)를 바로 보이려던 것. 창 자체는 쉬움. 어려움: 보기만(고치기는 「마스터 화면에서 보기」로 딴 화면) · 꼬리표가 표 칸 줄임 · 로직에 따라 알약 대신 글자만(GF000004).
## 6. 쓰다 만난 흠 (고치지 않음 · 새 길이 같은 자리를 밟지 않게)
| # | 흠 | 자리 | 사진 |
| --- | --- | --- | --- |
| B1 | 기계 고르기 창에 날 JSON(`{"가격":66538,…}`) · 이름이 한 글자씩 세로 · 고르면 단위 「[object Object]」 · 결과 「원/[object Object]」 | `M01_MasterData_UI_LogicLab_New_Find.ts:124-147` (기계 줄의 값 · 단위가 객체) | s02 |
| B2 | 재료 조건 줄이 든 자체 로직을 열면 상세 빈 화면 — `item.요소.includes is not a function` · 대상 230 로직(본뜨면 터짐) | `M01_MasterData_UI_Logic_Edit.ts:278` · 같은 꼴 `:294` | — |
| B3 | 본떠 만들기 「덧줄 추가」를 눌러도 새 줄이 안 보임 — 안 보이는 덧줄(식 0)이 저장엔 실림 | `M01_MasterData_UI_LogicLab_Copy.ts:236` · `:296` (줄 목록이 서버 첫 응답만) | s06 |
| B4 | 상세 표의 금액이 「금액」 칸이 아니라 단가 밑 둘째 줄에 앉음 | `M01_MasterData_UI_LogicLab_Detail.ts` 묶음 표 | s05 · s09 |
| B5 | 시험 계산 멈춤 까닭이 엉뚱 — 단가 없는 자재인데 「입력값 · 표 조합 확인」 | `safeReason` `M01_MasterData_UI_Logic_Text.ts` | — |
| B6 | 복사 시트 「취소」 단추가 세로로 찌그러짐(「취/소」) | 복사 시트 머리 | s06 |
| B7 | 목록에서 「로직 지우기」 · 「본떠 만들기」가 켜진 채 | `M01_MasterData_UI_LogicLab.ts:401-417` | s01 |
## 7. 새 길이 피할 것 (설계 후보 · 정함은 브레인 몫)
1. 사용자가 「본뜨기」 · 「자체 / 기본」을 몰라도 되게 — 기본 로직에서 바로 고치기를 누르면 사본이 생긴다는 것을 한 줄로 알림.
2. 줄은 인력 · 재료 · 경비 세 상자에 한 줄씩 — 몫 쪼개기 · 덧줄 · 중간 값을 처음 화면에 안 보임.
3. 고르기 창 하나 — 그 상자 종류로 열림 · 줄 이름으로 미리 찾음 · 단가가 보임 · 단가 없는 품목은 흐리게.
4. 수량은 숫자 칸 먼저 · 단위는 줄 단위에서 자동 · 식은 「고급」 뒤로.
5. 경비(기계)는 「기계 고르기」로 — 속에서는 지금 형식 `로직(GC…, 기계='코드', …)`으로 읽고 씀.
6. 재료 조건 줄(`{구분 · 상세구분 · 검색어}`)을 읽고 쓸 수 있어야 함 — 230 로직.
7. [저장] 하나 · 닫기 전 물음 · 줄 하나 되돌리기.
8. 새 로직 · 사본에도 시험 견본값을 채워(`/logic/auto` 꼴) 한 번에 금액이 나오게 · 멈춤 까닭은 줄 이름으로.
9. 지울 때 이름으로 묻기 · 저장 전까지 되살리기.
+402
View File
@@ -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)