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

This commit is contained in:
2026-10-01 09:46:59 +09:00
13 changed files with 4145 additions and 42 deletions
+470
View File
@@ -0,0 +1,470 @@
/* =============================================================================
* M01_EasyCalc_Api.ts
* 쉬운 단가산출 Api — 서버 길 감싸기(목록 · 열기 + 자동값 · 시험 계산 뒤에서 · 새로 · 본뜨기 ·
* 고치기 · 지우기 · 쓰는 곳) · 403 · 409 · 422 를 쉬운 말로 · 기계 기종 → 부를 기계경비 로직 · 넘길 값
* 모양은 `docs/raw/M01_쉬운단가/설계.md` 3절 그대로 · 쓰기는 `/logic/*` 만(`/save` 안 씀)
* 시험: `resources/tester/test_m01_easycalc_api.py`(서버 왕복 + 아래 「순수」 칸을 Node 로)
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import {
ApiError,
copyLogic,
fetchAuto,
fetchLogic,
runCalc,
type CalcAnswer,
type LogicInput,
type LogicRow,
} from "../M01_MasterData_UI_Logic_Api";
import { safeReason } from "../M01_MasterData_UI_Logic_Text";
/** 목록 한 줄 */
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[];
}
/** 기계 시간당 경비 로직 하나 — 기종 짝 규칙의 재료 */
export interface MachineLogic {
key: string;
name: string;
resultUnit: string;
inputs: LogicInput[];
}
/* ── 순수 시작 — 아래 끝까지 밖 이름을 안 씀(`currentLanguageIndex` 만) · 시험이 Node 로 바로 돌림 ── */
const TEXT = {
Base: ["품셈 기본", "Standard"],
Mine: ["내가 만든 것", "Mine"],
NotMoney: [
"금액이 아닌 값(작업량 등)을 내는 것이라 쉬운 길로 못 엶 — 지금 화면에서 봄",
"Gives a non-money value (output etc.) — open it in the full screen",
],
E403: [
"품셈 기본은 바로 못 고침 — 「이걸 바탕으로 고치기」로 내 것을 만들어 고침",
"Standard items are read-only — use “Edit a copy”",
],
E404: [
"찾을 수 없음 — 이미 지워졌을 수 있음 · 목록을 다시 열어 주세요",
"Not found — it may be deleted; reopen the list",
],
E409: [
"다른 곳에서 먼저 고쳤음 — 다시 열어 최신 것으로 고쳐 주세요",
"Changed elsewhere first — reopen and edit again",
],
Net: ["서버와 이어지지 않음 — 잠시 뒤 다시 해 주세요", "Server unreachable — try again"],
Generic: [
"저장 검사에 걸림 — 고친 줄을 다시 확인해 주세요",
"Did not pass the check — review your changes",
],
More: ["그 밖 {n} 건", "{n} more"],
UnknownName: [
"「{name}」 값이 어디에도 없음 — 설계 때 정하는 값으로 더해 주세요",
"「{name}」 is not defined — add it as a design value",
],
NeedCost: [
"넣을 자리(사람 품값 · 재료값 · 기계·경비)를 골라 주세요",
"Pick where it goes (labor · material · equipment)",
],
NoLines: [
"줄이 하나도 없음 — 사람 · 재료 · 기계 줄을 하나 이상 더해 주세요",
"No lines — add at least one labor, material or machine line",
],
Source: ["근거 글 모양이 맞지 않음", "The source text has a wrong shape"],
CallNeeds: [
"부르는 다른 일위대가가 「{name}」 값을 받아야 함 — 설계 때 정하는 값에 더해 주세요",
"The called item needs 「{name}」 — add it as a design value",
],
CallExtra: [
"부르는 다른 일위대가는 「{name}」 값을 받지 않음",
"The called item does not take 「{name}」",
],
Kind: [
"고른 항목과 줄 종류가 맞지 않음 — 다시 골라 주세요",
"The picked item does not match the line type — pick again",
],
Loop: [
"서로를 부르는 일위대가가 생김 — 한쪽을 빼 주세요",
"Items call each other — remove one side",
],
Unread: ["수량 글을 읽지 못함 — 숫자로 넣어 주세요", "Cannot read the amount — enter a number"],
Gone: [
"이어진 항목이 없어짐 — 다른 곳에서 쓰고 있으면 지울 수 없음",
"A linked item is missing — items still in use cannot be deleted",
],
NoName: ["이름을 넣어 주세요", "Enter a name"],
Broken: ["저장 모양이 덜 갖춰짐 — 다시 열어 주세요", "The saved shape is incomplete — reopen it"],
Cut: ["합계 자르기 설정이 맞지 않음", "The rounding setting is wrong"],
Material: [
"재료 찾는 말에 맞는 자료가 없음 — 다시 골라 주세요",
"No material matches — pick again",
],
Stop: [
"이 값으로는 셈하지 못함 — 설계 때 정하는 값을 확인해 주세요",
"Cannot be calculated with these values — check the design values",
],
NoMachine: [
"이 기계는 시간당 값을 셀 자료가 없음 — 다른 기계를 골라 주세요",
"No hourly cost data for this machine — pick another",
],
} as const satisfies Record<string, readonly [string, string]>;
type SayKey = keyof typeof TEXT;
function say(key: SayKey, fill: Record<string, string | number> = {}): string {
const text: string = TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0];
return text.replace(/\{(\w+)\}/g, (m, k: string) => (k in fill ? String(fill[k]) : m));
}
/** 검사 글 한 줄 → 쉬운 말 — 모르는 꼴은 뭉뚱그림(키 · 날 글이 화면에 안 나가게) */
function plainCheck(line: string): string {
const name = (re: RegExp): string | null => re.exec(line)?.[1] ?? null;
const unknown = name(/모르는 이름 「([^」]+)」/);
if (unknown) return say("UnknownName", { name: unknown });
const needs = name(/입력 「([^」]+)」 안 줌/);
if (needs) return say("CallNeeds", { name: needs });
const extra = name(/에 입력 「([^」]+)」 없음/);
if (extra) return say("CallExtra", { name: extra });
if (/「호표」 도 「결과」 도 없음/.test(line)) return say("NoLines");
if (/비목 「/.test(line)) return say("NeedCost");
if (/출처 모양/.test(line)) return say("Source");
if (/요소 그룹이 종류와 다름|종류 「|고르기 조건은 종류/.test(line)) return say("Kind");
if (/돌고 도는 참조/.test(line)) return say("Loop");
if (/식 못 읽음/.test(line)) return say("Unread");
if (/없는 (로직|요소|키)|로직 키/.test(line)) return say("Gone");
if (/「이름」 이 없음|이름 없음/.test(line)) return say("NoName");
if (/끝수/.test(line)) return say("Cut");
if (/검색어|후보 0|고르기 조건/.test(line)) return say("Material");
if (/칸 「[^」]+」 없음/.test(line)) return say("Broken");
return say("Generic");
}
/** 상태 번호 + 서버 몸 → 쉬운 말(여러 줄은 줄바꿈 · 셋까지) · 0 = 서버에 못 닿음 */
function plainOf(status: number, detail: unknown): string {
if (status === 0) return say("Net");
if (status === 403) return say("E403");
if (status === 404) return say("E404");
if (status === 409) return say("E409");
const body = detail as { errors?: unknown } | null;
const lines =
status === 422 && body && Array.isArray(body.errors)
? body.errors.map((x) => String(x))
: typeof detail === "string"
? [detail]
: [];
if (!lines.length) return say("Generic");
const plain = [...new Set(lines.map(plainCheck))];
const shown = plain.slice(0, 3);
if (plain.length > 3) shown.push(say("More", { n: plain.length - 3 }));
return shown.join("\n");
}
/** 계산 멈춤 까닭 — 키(`GX000001`)가 섞인 글은 뭉뚱그림 */
function plainStop(text: string): string {
return /[A-Z]{2}\d{6}/.test(text) ? say("Stop") : text;
}
const MACHINE = "기계";
/** 부착물 아닌 입력 — 이 밖의 입력(집게 · 브레이커)을 받는 로직은 부착 장비 전용 */
const MACHINE_PLAIN = new Set(["기계", "지역", "보정작업", "조종원직종"]);
/** 그 로직이 받는 기종(분류번호) 목록 */
function machineCodes(m: MachineLogic): string[] {
const spec = m.inputs.find((i) => i.이름 === MACHINE);
return (spec?.고르기 ?? []).map(String);
}
/** 짝 고르는 차례 — [부착 전용, 손료만, 손료만이면서 보정, 기종 수] 가 작은 것부터.
* 손료만 = 「지역」(연료 · 노임 값 자리)을 안 받음 · 기종 수가 작을수록 그 기종 전용(굴착기 · 조종원 없음 …) */
function machineRank(m: MachineLogic): number[] {
const names = m.inputs.map((i) => i.이름);
const attach = names.some((n) => !MACHINE_PLAIN.has(n)) ? 1 : 0;
const lossOnly = names.includes("지역") ? 0 : 1;
const boost = lossOnly && names.includes("보정작업") ? 1 : 0;
return [attach, lossOnly, boost, machineCodes(m).length];
}
/** 기종 → 부를 기계경비 로직 하나 · 받는 로직이 없으면 null */
export function pickMachine(family: MachineLogic[], code: string): MachineLogic | null {
const ranked = family
.filter((m) => machineCodes(m).includes(code))
.map((m) => ({ m, rank: machineRank(m) }))
.sort((a, b) => {
for (let i = 0; i < a.rank.length; i += 1) {
if (a.rank[i] !== b.rank[i]) return a.rank[i] - b.rank[i];
}
return a.m.key < b.m.key ? -1 : 1;
});
return ranked[0]?.m ?? null;
}
/** 로직 부름 글 — 기계는 고정 분류번호 · 나머지 입력은 같은 이름의 설계 때 정하는 값으로 넘김 */
export function machineCall(m: MachineLogic, code: string): EasyMachineElement {
const quote = (n: string): string => (/^[\w가-힣]+$/.test(n) ? n : `'${n}'`);
const needInputs = m.inputs.filter((i) => i.이름 !== MACHINE);
const rest = needInputs.map((i) => `, ${quote(i.이름)}=${quote(i.이름)}`).join("");
return { element: `로직(${m.key}, ${MACHINE}='${code}'${rest})`, needInputs };
}
/* ── 순수 끝 ── */
const OWN = "로직_자체.json"; // 내가 만든 것 한 자리(서버 `Store_Make.OWN`)
const OWNER = "현장";
const CALC_WAIT_MS = 300;
const MACHINE_FILE = "건설기계"; // 기계 시간당 경비 로직이 든 장 파일(「08장 건설기계」)
async function request<T>(path: string, body?: unknown): Promise<T> {
let response: Response;
try {
response = await fetch(`${API_BASE_URL}/m01${path}`, {
method: body === undefined ? "GET" : "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: body === undefined ? undefined : JSON.stringify(body),
});
} catch {
throw new ApiError(0, "");
}
const data = (await response.json().catch(() => ({}))) as { detail?: unknown } & T;
if (!response.ok) throw new ApiError(response.status, data.detail ?? response.statusText);
return data;
}
const query = (params: Record<string, string>): string => new URLSearchParams(params).toString();
interface Summary {
file: string;
키: string;
구분: string;
상세구분: string;
이름: string;
결과단위: string;
}
const isMoney = (unit: string): boolean => unit.startsWith("원");
/** 목록 — 내가 만든 것 먼저 · 돈 아닌 로직은 「쉬운 길로 못 엶」 */
export async function listEasy(q: string): Promise<EasyListItem[]> {
const got = await request<{ logics: Summary[] }>(`/logics?${query({ q })}`);
const items = got.logics.map((s): EasyListItem => {
const isBase = s.file !== OWN;
const canOpen = isMoney(s.결과단위);
return {
key: s.키,
name: s.이름,
isBase,
owner: say(isBase ? "Base" : "Mine"),
resultUnit: s.결과단위,
canOpen,
whyNot: canOpen ? null : say("NotMoney"),
where: isBase ? `${s.구분} ${s.상세구분}`.trim() : s.상세구분,
};
});
return [...items.filter((x) => !x.isBase), ...items.filter((x) => x.isBase)];
}
/** 열기 — 로직 줄 · 판본 · 견본 설계 값(자동값 · 못 받으면 빈 채로) */
export async function openEasy(key: string): Promise<EasyOpened> {
const [one, auto] = await Promise.all([fetchLogic(key), fetchAuto(key)]);
return {
row: one.logic,
version: one.version,
isBase: one.file !== OWN,
trial: trialFor(one.logic, auto),
};
}
/** 로직이 받는 설계 값만 — 모르는 이름을 보내면 서버가 멈춤 */
function trialFor(row: LogicRow, trial: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(
(row.입력 ?? []).filter((i) => i.이름 in trial).map((i) => [i.이름, trial[i.이름]]),
);
}
let calcSeq = 0;
let calcTimer: ReturnType<typeof setTimeout> | undefined;
/** 0.3초 뒤 서버 시험 계산 · 최신 응답만 `onDone` · 멈춤 · 실패는 `plain` 쉬운 말 */
export function calcLater(
row: LogicRow,
trial: Record<string, unknown>,
onDone: (ans: CalcAnswer | null, plain: string | null) => void,
): void {
const mine = ++calcSeq;
clearTimeout(calcTimer);
calcTimer = setTimeout(() => {
runCalc({ key: row.키 || "", inputs: trialFor(row, trial), row })
.then((ans) => {
if (mine === calcSeq) onDone(ans, ans.ok ? null : plainStop(safeReason(ans.reason)));
})
.catch((e: unknown) => {
if (mine === calcSeq) onDone(null, plainError(e));
});
}, CALC_WAIT_MS);
}
/** 새로 — 키 · 갈래는 서버가 줌 · 근거 글이 비면 「자체」(검사가 빈 글을 막음) */
export async function newEasy(row: LogicRow): Promise<EasySaved> {
const logic = { ...row, 출처: row.출처 || "자체" };
const got = await request<{ version: string; key: string; logic: LogicRow }>("/logic/new", {
logic,
owner: OWNER,
});
return { key: got.key, version: got.version, row: got.logic };
}
/** 이걸 바탕으로 고치기 — 품셈 기본 · 내 것 모두 본뜸 */
export async function copyEasy(key: string): Promise<EasySaved> {
const got = await copyLogic(key);
return { key: got.key, version: got.version, row: got.logic };
}
/** 고치기 — 내가 만든 것만(품셈 기본은 403) · 판본이 다르면 409 */
export async function editEasy(key: string, version: string, row: LogicRow): Promise<EasySaved> {
const got = await request<{ version: string; logic: LogicRow }>("/logic/edit", {
key,
version,
logic: row,
});
return { key, version: got.version, row: got.logic };
}
/** 지우기 — 내가 만든 것만 · 쓰는 곳이 남으면 422 */
export async function deleteEasy(key: string, version: string): Promise<{ version: string }> {
const got = await request<{ version: string }>("/logic/delete", { key, version });
return { version: got.version };
}
/** 쓰는 곳 — 담은 일위대가 조합(마스터 층) + 이 로직을 부르는 내가 만든 것.
* ponytail: 부르는 로직은 내가 만든 것만 하나씩 받아 훑음(품셈 기본은 내 것을 안 부름) —
* 내 것이 수백이 되면 서버에 「부르는 로직」 길을 더할 것 */
export async function usesOf(key: string): Promise<EasyUse[]> {
const [combos, mine] = await Promise.all([
request<{ combos: { 키: string; 이름: string }[] }>(`/logic/combos?${query({ key })}`),
request<{ logics: Summary[] }>(`/logics?${query({ sub: "자체" })}`),
]);
const call = `로직(${key}`;
const others = mine.logics.filter((s) => s.키 !== key);
const rows = await Promise.all(others.map((s) => fetchLogic(s.키)));
const callers = rows
.filter((one) => JSON.stringify(one.logic).includes(call))
.map((one): EasyUse => ({ kind: "logic", key: one.logic.키, name: one.logic.이름 }));
return [
...combos.combos.map((c): EasyUse => ({ kind: "combo", key: c.키, name: c.이름 })),
...callers,
];
}
let family: Promise<MachineLogic[]> | null = null;
/** 기계 시간당 경비 로직 묶음 — 한 번 받아 둠(실패하면 다음에 다시) */
function machineFamily(): Promise<MachineLogic[]> {
family ??= request<{ logics: Summary[] }>("/logics")
.then((got) =>
Promise.all(
got.logics
.filter((s) => s.file.includes(MACHINE_FILE) && isMoney(s.결과단위))
.map((s) => fetchLogic(s.키)),
),
)
.then((all) =>
all
.map((one) => ({
key: one.logic.키,
name: one.logic.이름,
resultUnit: one.logic.결과단위,
inputs: one.logic.입력 ?? [],
}))
.filter((m) => machineCodes(m).length > 0),
)
.catch((e: unknown) => {
family = null;
throw e;
});
return family;
}
interface MachineRow {
원문번호?: string;
이름?: string;
규격?: string;
}
/** 기계 고르기 — 이름 · 분류번호 찾기 · 시간당 값을 셀 수 있는 기종만 */
export async function listMachines(q: string): Promise<EasyMachine[]> {
const [all, got] = await Promise.all([
machineFamily(),
request<{ items: MachineRow[] }>(`/elements?${query({ group: MACHINE, q, limit: "200" })}`),
]);
return got.items.flatMap((row): EasyMachine[] => {
const code = String(row.원문번호 ?? "");
const m = pickMachine(all, code);
if (!m) return [];
const unit = m.resultUnit.split("/")[1] ?? "";
return [{ 분류번호: code, 이름: row.이름 ?? "", 규격: row.규격 ?? "", 단위: unit }];
});
}
/** 기종 → 호표 「요소」 글 · 받아야 할 설계 값(지역 등) — 호표 종류는 「로직」 */
export async function machineElement(기종: string): Promise<EasyMachineElement> {
const m = pickMachine(await machineFamily(), 기종);
if (!m) throw new Error(say("NoMachine"));
return machineCall(m, 기종);
}
/** 어떤 실패든 화면에 찍을 쉬운 말 한두 줄 */
export function plainError(e: unknown): string {
if (e instanceof ApiError) return plainOf(e.status, e.detail);
if (e instanceof Error && e.message === say("NoMachine")) return e.message;
return say("Net");
}
+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,458 @@
/* =============================================================================
* 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, ...(f.detail ? { detail: f.detail } : {}) }).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 }).상세구분;
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,145 @@
/* 쉬운 단가산출 2단계 — 표 보고 고르기 창 */
.ui-modal.m01-easytable {
width: min(1100px, 96vw);
}
.m01-easytable .ui-modal__body {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
}
.m01-easytable__note {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
.m01-easytable__which,
.m01-easytable__actions,
.m01-easytable__seg {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
}
.m01-easytable__which:empty {
display: none;
}
.m01-easytable__actions {
justify-content: flex-end;
}
.m01-easytable__head {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.m01-easytable__conds {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.m01-easytable__conds h4 {
margin: 0;
font-size: var(--text-body);
}
.m01-easytable__cond {
display: grid;
grid-template-columns: minmax(6rem, 10rem) minmax(5rem, 1fr) auto;
align-items: center;
gap: var(--spacing-8);
font-size: var(--text-body-sm);
}
.m01-easytable__cond-value {
font-weight: 600;
}
.m01-easytable__grid {
max-height: 46vh;
overflow: auto;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
}
.m01-easytable__grid table {
width: 100%;
border-collapse: collapse;
font-size: var(--text-body-sm);
}
.m01-easytable__grid th,
.m01-easytable__grid td {
padding: var(--spacing-4) var(--spacing-8);
border-bottom: 1px solid var(--color-border);
text-align: left;
white-space: nowrap;
}
.m01-easytable__grid thead th {
position: sticky;
top: 0;
background: var(--color-surface);
color: var(--color-text-secondary);
}
.m01-easytable__grid td.is-value {
text-align: right;
}
.m01-easytable__grid .is-col {
font-weight: 700;
color: var(--color-text);
}
.m01-easytable__grid tbody tr {
cursor: pointer;
}
.m01-easytable__grid tbody tr:hover {
background: color-mix(in srgb, var(--color-accent) 10%, var(--color-canvas));
}
.m01-easytable__grid tr.is-hit {
background: color-mix(in srgb, var(--color-warning) 28%, var(--color-canvas));
}
.m01-easytable__grid tr.is-picked td:first-child {
box-shadow: inset 3px 0 0 var(--color-accent);
}
.m01-easytable__money {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--spacing-16);
}
.m01-easytable__money.is-busy {
opacity: 0.5;
}
.m01-easytable__warn {
margin: 0;
color: var(--color-danger);
font-size: var(--text-body-sm);
}
.m01-easytable__warn:empty {
display: none;
}
@media (max-width: 600px) {
.m01-easytable__cond {
grid-template-columns: 1fr 1fr;
}
.m01-easytable__seg {
grid-column: 1 / -1;
}
}
@@ -0,0 +1,676 @@
/* =============================================================================
* M01_EasyCalc_Table.ts
* 쉬운 단가산출 2단계 — 표 보고 고르기(설계.md 1절 7 · 3절 `openEasyTable`)
* 잠금 카드 수량 식 속 `찾기(표, 조건=값 …).값칸` 을 읽어 그 표를 그대로 보이고,
* 줄 · 칸을 눌러 조건 값 · 값 칸을 바꾼 새 식을 돌려줌(취소 null).
*
* 식 읽기 = 엔진 문법(`master_formula._Parser` 의 find · args · field) 그대로 화면에서 —
* 서버에 찾기 마디를 돌려주는 길이 없어서. 걸린 줄 · 금액은 서버(`/text` 표 조각 `참조.줄` · `/calc`).
* 화면 글에 식 · 키 안 보임 — 표 이름 · 칸 이름 · 값만.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { fetchTable } from "../M01_MasterData_Api_Fetch";
import {
fetchAuto,
runCalc,
runText,
searchElements,
type HoLine,
type LogicRow,
type NamedFormula,
} from "../M01_MasterData_UI_Logic_Api";
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
import { toRow, type EasyCard, type EasyDoc } from "./M01_EasyCalc_Model";
import "./M01_EasyCalc_Table.css";
const TEXT = {
Title: ["표 보고 고르기", "Pick from the table"],
Close: ["닫기", "Close"],
Cannot: ["이 줄은 지금 화면에서 바꿀 수 없음", "This line can't be changed here yet"],
NoTable: ["표를 불러오지 못함", "Couldn't load the table"],
Loading: ["불러오는 중", "Loading"],
Hint: [
"줄을 누르면 그 줄 값으로 정함 · 노란 줄 = 지금 쓰는 줄 · 굵은 칸 = 수량을 가져오는 칸",
"Tap a row to use it · yellow = row in use · bold = column the quantity comes from",
],
Conds: ["어느 줄을 쓸까", "Which row"],
Design: ["설계 때 정함", "Set at design"],
Fix: ["이 값으로 고정", "Fix this value"],
AsIs: ["지금대로", "As is"],
NotYet: ["(아직 없음)", "(none yet)"],
Amount: ["이 줄 금액", "This line"],
Total: ["합계", "Total"],
Wait: ["계산 중", "Calculating"],
Stopped: ["계산 멈춤", "Calculation stopped"],
Mismatch: [
"고른 줄이 이 조건으로는 안 걸림 — 조건을 다시 보세요",
"The picked row isn't matched by these conditions",
],
Use: ["이걸로", "Use this"],
Cancel: ["취소", "Cancel"],
Which: ["표 고르기", "Choose table"],
} as const;
const tt = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex as 0 | 1];
/* ── 식 읽기 · 짜기(DOM 없음) ─────────────────────────────────────────── */
export interface FindArg {
/** 표 조건 칸 이름 */
key: string;
/** `=` 오른쪽 식 글 그대로 */
rhs: string;
}
export interface FindCall {
/** 식 안 자리 — `찾기` 첫 글자부터 값 칸 끝까지 */
start: number;
end: number;
table: string;
args: FindArg[];
col: string;
}
const NAME = /[\p{L}\p{N}_]/u;
const isName = (ch: string | undefined): boolean => !!ch && NAME.test(ch);
class Reader {
i = 0;
constructor(readonly s: string) {}
ws(): void {
while (this.i < this.s.length && /\s/.test(this.s[this.i])) this.i++;
}
take(ch: string): boolean {
this.ws();
if (this.s[this.i] !== ch) return false;
this.i++;
return true;
}
name(): string | null {
this.ws();
const start = this.i;
while (isName(this.s[this.i])) this.i++;
return this.i > start ? this.s.slice(start, this.i) : null;
}
quoted(): string | null {
const end = this.s.indexOf("'", this.i);
if (end < 0) return null;
const text = this.s.slice(this.i, end);
this.i = end + 1;
return text;
}
/** 괄호 · 따옴표 밖 `,` · `)` 앞까지 */
until(head: boolean): string | null {
const start = this.i;
let depth = 0;
while (this.i < this.s.length) {
const ch = this.s[this.i];
if (ch === "'" && !head) {
const end = this.s.indexOf("'", this.i + 1);
if (end < 0) return null;
this.i = end + 1;
continue;
}
if (ch === "(" || ch === "{") depth++;
else if (ch === ")" || ch === "}") {
if (depth === 0) break;
depth--;
} else if (ch === "," && depth === 0) break;
this.i++;
}
return this.i < this.s.length ? this.s.slice(start, this.i).trim() : null;
}
}
/** 한 자리의 찾기 — 못 읽으면 null */
function readOne(s: string, at: number): FindCall | null {
const r = new Reader(s);
r.i = at + 2;
if (!r.take("(")) return null;
r.ws();
const table = r.until(true);
if (!table || !/^[A-Za-z]{2}\d+$/.test(table)) return null; // {자리} 낀 키는 화면에서 안 다룸
const args: FindArg[] = [];
while (r.take(",")) {
r.ws();
const key = r.s[r.i] === "'" ? (r.i++, r.quoted()) : r.name();
if (!key || !r.take("=")) return null;
r.ws();
const rhs = r.until(false);
if (!rhs) return null;
args.push({ key, rhs });
}
if (!r.take(")") || !r.take(".")) return null;
r.ws();
const col = r.s[r.i] === "'" ? (r.i++, r.quoted()) : r.name();
if (!col) return null;
return { start: at, end: r.i, table, args, col };
}
/** 식 속 찾기 전부(바깥 것만 · 차례대로) — 하나라도 못 읽으면 null · 없으면 [] */
export function readFinds(formula: string): FindCall[] | null {
const out: FindCall[] = [];
let i = 0;
while (i < formula.length) {
const ch = formula[i];
if (ch === "'") {
const end = formula.indexOf("'", i + 1);
if (end < 0) return null;
i = end + 1;
continue;
}
if (formula.startsWith("찾기", i) && !isName(formula[i - 1]) && !isName(formula[i + 2])) {
const call = readOne(formula, i);
if (!call) return null;
out.push(call);
i = call.end;
continue;
}
i++;
}
return out;
}
const plain = (word: string): string => (/^[\p{L}\p{N}_]+$/u.test(word) ? word : `'${word}'`);
/** 찾기 하나를 글로 — 마스터 꼴 `찾기(표, 가=나, 다=라).칸` */
export function writeFind(table: string, args: FindArg[], col: string): string {
const parts = [table, ...args.map((a) => `${plain(a.key)}=${a.rhs}`)];
return `찾기(${parts.join(", ")}).${plain(col)}`;
}
/** 식의 찾기들을 갈아 끼움 — `swaps` 의 차례 = `readFinds` 차례 · null 은 그대로 */
export function replaceFinds(formula: string, calls: FindCall[], swaps: (string | null)[]): string {
let out = formula;
for (let k = calls.length - 1; k >= 0; k--) {
const text = swaps[k];
if (text !== null && text !== undefined)
out = out.slice(0, calls[k].start) + text + out.slice(calls[k].end);
}
return out;
}
/** 표 칸 값 → 식 글(수 · 따옴표 글) — 못 쓰면 null */
export function literal(value: unknown, rule = ""): string | null {
if (typeof value === "number") return String(value);
if (typeof value === "string") return value.includes("'") ? null : `'${value}'`;
if (Array.isArray(value)) {
const n = rangeNumber(value as [number | null, number | null], rule);
return n === null ? null : String(n);
}
return null;
}
/** 범위 칸 [아래, 위] 안에 드는 수 하나 — 엔진 `_match` 의 범위규칙대로 */
export function rangeNumber(cell: [number | null, number | null], rule: string): number | null {
const [low, high] = cell;
const kind = (rule || "초과·이하").split(" · ")[0].split(" (")[0];
const round = (n: number): number => Number(n.toFixed(6));
if (low !== null && high !== null) return round((low + high) / 2);
if (high !== null) {
if (kind !== "이상·미만") return high;
return round(high > 1 ? high - 1 : high / 2);
}
if (low !== null) return kind.startsWith("이상") ? low : low + 1;
return null;
}
/** 식 글이 따옴표 글 · 수면 그 값 */
export function literalValue(rhs: string): string | number | null {
const q = rhs.match(/^'([^']*)'$/);
if (q) return q[1];
return /^-?\d+(\.\d+)?$/.test(rhs) ? Number(rhs) : null;
}
/* ── 표 · 서버 ──────────────────────────────────────────────────────── */
type Cell = string | number | [number | null, number | null] | null | undefined;
interface EasyTable {
이름?: string;
기준?: string;
범위규칙?: string;
조건: Record<string, string>;
값칸: Record<string, string>;
줄: Record<string, Cell>[];
}
const tables = new Map<string, Promise<EasyTable | null>>();
function loadTable(key: string): Promise<EasyTable | null> {
const hit = tables.get(key);
if (hit) return hit;
const groups = key[0] === "C" ? ["계수", "소요량"] : ["소요량", "계수"];
const job = (async (): Promise<EasyTable | null> => {
for (const group of groups) {
const found = await searchElements(group, key).catch(() => null);
const file = found?.items.find((x) => x.ref === key)?.file;
if (!file) continue;
const got = await fetchTable(file, key).catch(() => null);
if (got?.table) return got.table as unknown as EasyTable;
}
return null;
})();
tables.set(key, job);
return job;
}
/** 카드 줄을 새 식으로 — 호표면 `수량` · 덧붙임이면 `식` */
function cardLine(card: EasyCard, formula: string): HoLine | NamedFormula {
const raw = (card.raw ?? {}) as Record<string, unknown>;
if (card.source?.part === "덧줄") return { ...raw, 이름: card.name, 식: formula } as NamedFormula;
return {
종류: "인력",
요소: String(card.element ?? ""),
이름: card.name,
단위: card.unit,
...raw,
수량: formula,
} as HoLine;
}
/** 이 카드 하나만 든 시험 줄 — `/text` 답에서 그 줄 표 조각을 바로 찾으려고 */
function probeRow(card: EasyCard, doc: EasyDoc, formula: string): LogicRow {
const line = cardLine(card, formula);
if (card.source?.part === "덧줄") return { ...doc.row, 키: "", 덧줄: [line as NamedFormula] };
return { ...doc.row, 키: "", 호표: [line as HoLine], 덧줄: [] };
}
/** 로직 전체 줄 — 카드 식만 바꿈 · Model 이 아직이면 원래 줄의 그 자리만 */
function fullRow(card: EasyCard, doc: EasyDoc, formula: string): LogicRow {
try {
const cards = doc.cards.map((c) => (c.id === card.id ? { ...c, qtyFormula: formula } : c));
return { ...toRow({ ...doc, cards }), 키: "" };
} catch {
const row: LogicRow = { ...doc.row, 키: "" };
const at = card.source?.index ?? -1;
if (card.source?.part === "덧줄" && row.덧줄?.[at])
row.덧줄 = row.덧줄.map((x, i) => (i === at ? { ...x, 식: formula } : x));
else if (row.호표?.[at])
row.호표 = row.호표.map((x, i) => (i === at ? { ...x, 수량: formula } : x));
return row;
}
}
interface Preview {
ok: boolean;
/** 찾기 차례 → 걸린 줄 차례(못 찾으면 null) */
hits: (number | null)[];
line: number | null;
total: number | null;
}
async function preview(
card: EasyCard,
doc: EasyDoc,
formula: string,
calls: FindCall[],
inputs: Record<string, unknown>,
): Promise<Preview> {
const [text, calc] = await Promise.all([
runText({ key: "", inputs, row: probeRow(card, doc, formula) }).catch(() => null),
runCalc({ key: "", inputs, row: fullRow(card, doc, formula) }).catch(() => null),
]);
const lines = text?.ok
? text.groups.flatMap((g) => g.줄).filter((l) => l.이름 === card.name)
: [];
const pieces = (lines[0]?.조각 ?? []).filter((p) => p.kind === "표");
const seen = new Map<string, number>();
const hits = calls.map((c) => {
const nth = seen.get(c.table) ?? 0;
seen.set(c.table, nth + 1);
const piece = pieces.filter((p) => p.참조?.키 === c.table)[nth];
return typeof piece?.참조?.줄 === "number" ? piece.참조.줄 : null;
});
const sum = lines.reduce((s, l) => s + (Number(l.금액) || 0), 0);
return {
ok: !!text?.ok && !!calc?.ok,
hits,
line: lines.length ? sum : null,
total: calc?.ok && calc.sums ? (calc.sums["계"] ?? null) : null,
};
}
/* ── 창 ─────────────────────────────────────────────────────────────── */
type Mode = "design" | "fix" | "asis";
interface CondState {
key: string;
/** 원래 식 글 · 새로 더한 조건은 null */
orig: string | null;
mode: Mode;
/** 설계 값 이름 — 있으면 「설계 때 정함」 고를 수 있음 */
input: string | null;
fix: Cell;
}
interface CallState {
conds: CondState[];
col: string;
picked: number | null;
/** 「설계 때 정함」 조건을 고른 줄 값으로 미리 봄 */
tryValues: Record<string, unknown>;
}
const showCell = (v: Cell): string => {
if (v === null || v === undefined) return "";
if (Array.isArray(v)) return `${v[0] ?? ""}~${v[1] ?? ""}`;
return String(v);
};
function startState(call: FindCall, doc: EasyDoc): CallState {
const names = new Set(doc.inputs.map((x) => x.이름));
const conds = call.args.map((a): CondState => {
const input = names.has(a.rhs) ? a.rhs : names.has(a.key) ? a.key : null;
const lit = literalValue(a.rhs);
const mode: Mode = names.has(a.rhs) ? "design" : lit !== null ? "fix" : "asis";
return { key: a.key, orig: a.rhs, mode, input, fix: lit };
});
return { conds, col: call.col, picked: null, tryValues: {} };
}
function stateArgs(st: CallState, table: EasyTable | null): FindArg[] | null {
const out: FindArg[] = [];
for (const c of st.conds) {
if (c.mode === "design" && c.input) out.push({ key: c.key, rhs: c.input });
else if (c.mode === "fix") {
const rhs = literal(c.fix, table?.범위규칙);
if (rhs === null) return null;
out.push({ key: c.key, rhs });
} else if (c.orig !== null) out.push({ key: c.key, rhs: c.orig });
}
return out;
}
/** 이 창 모양 그대로의 새 식 — 바뀐 것이 없으면 원래 식 */
function compose(
formula: string,
calls: FindCall[],
states: CallState[],
tbl: (EasyTable | null)[],
) {
const swaps = calls.map((call, k) => {
const args = stateArgs(states[k], tbl[k]);
if (!args) return null;
const same =
states[k].col === call.col &&
args.length === call.args.length &&
args.every((a, i) => a.key === call.args[i].key && a.rhs === call.args[i].rhs);
return same ? null : writeFind(call.table, args, states[k].col);
});
return replaceFinds(formula, calls, swaps);
}
/** 잠금 카드 수량을 표에서 골라 바꿈 — 새 식 · 취소 null(설계.md 3절) · trial 없으면 자동값 */
export function openEasyTable(
card: EasyCard,
doc: EasyDoc,
trial?: Record<string, unknown>,
): Promise<string | null> {
const formula = card.qtyFormula ?? "";
const calls = readFinds(formula);
return new Promise((resolve) => {
let done = false;
const finish = (value: string | null): void => {
if (done) return;
done = true;
resolve(value);
};
openModal({
title: tt("Title"),
closeLabel: tt("Close"),
dialogClass: "m01-easytable",
beforeClose: () => (finish(null), true),
mount: (body, close) => {
if (!calls || !calls.length) {
body.append(el("p", { className: "m01-easytable__note", text: tt("Cannot") }));
return;
}
void mountTable(body, { card, doc, formula, calls, trial }, (v) => {
finish(v);
close();
});
},
});
});
}
async function mountTable(
body: HTMLElement,
ctx: {
card: EasyCard;
doc: EasyDoc;
formula: string;
calls: FindCall[];
trial?: Record<string, unknown>;
},
finish: (value: string | null) => void,
): Promise<void> {
const { card, doc, formula, calls } = ctx;
body.append(el("p", { className: "m01-easytable__note", text: tt("Loading") }));
const [base, tbl] = await Promise.all([
ctx.trial ?? (doc.key ? fetchAuto(doc.key) : Promise.resolve({})),
Promise.all(calls.map((c) => loadTable(c.table))),
]);
const states = calls.map((c) => startState(c, doc));
let now = 0;
let last: Preview = { ok: false, hits: calls.map(() => null), line: null, total: null };
let seq = 0;
let busy = false;
const which = el("div", { className: "m01-easytable__which" });
const head = el("div", { className: "m01-easytable__head" });
const condBox = el("div", { className: "m01-easytable__conds" });
const grid = el("div", { className: "m01-easytable__grid" });
const money = el("div", { className: "m01-easytable__money" });
const warn = el("p", { className: "m01-easytable__warn" });
const use = createButton({ label: tt("Use"), onClick: () => finish(newFormula()) });
const cancel = createButton({
label: tt("Cancel"),
variant: "ghost",
onClick: () => finish(null),
});
const newFormula = (): string => compose(formula, calls, states, tbl);
const inputs = (): Record<string, unknown> => ({ ...base, ...states[now].tryValues });
const refresh = async (): Promise<void> => {
const mine = ++seq;
busy = true;
paint();
const got = await preview(card, doc, newFormula(), calls, inputs());
if (mine !== seq) return;
last = got;
busy = false;
paint();
};
const pickRow = (idx: number, col: string | null): void => {
const table = tbl[now];
const st = states[now];
const row = table?.줄[idx];
if (!table || !row) return;
for (const key of Object.keys(table.조건)) {
if (row[key] === undefined) continue;
let c = st.conds.find((x) => x.key === key);
if (!c) {
const input = doc.inputs.some((x) => x.이름 === key) ? key : null;
c = { key, orig: null, mode: "fix", input, fix: null };
st.conds.push(c);
}
if (c.mode === "design" && c.input) {
const n = Array.isArray(row[key])
? rangeNumber(row[key] as [number | null, number | null], table.범위규칙 ?? "")
: row[key];
st.tryValues[c.input] = n;
} else {
c.mode = "fix";
c.fix = row[key];
}
}
if (col) st.col = col;
st.picked = idx;
void refresh();
};
const setMode = (c: CondState, mode: Mode): void => {
if (mode === "fix" && (c.fix === null || c.fix === undefined)) {
const hit = last.hits[now];
const row = hit === null ? null : tbl[now]?.줄[hit];
c.fix = row?.[c.key] ?? (c.input ? (inputs()[c.input] as Cell) : null);
}
c.mode = mode;
void refresh();
};
const paintConds = (): void => {
const st = states[now];
condBox.replaceChildren(
el("h4", { text: tt("Conds") }),
...st.conds.map((c) => {
const opts: [Mode, string][] = [];
if (c.input) opts.push(["design", tt("Design")]);
if (c.orig !== null && c.orig !== c.input && literalValue(c.orig) === null)
opts.push(["asis", tt("AsIs")]);
opts.push(["fix", tt("Fix")]);
const hit = last.hits[now];
const used = hit === null ? "" : showCell(tbl[now]?.줄[hit]?.[c.key]);
const value =
c.mode === "fix"
? showCell(c.fix)
: c.mode === "design" && c.input
? showCell(inputs()[c.input] as Cell) || used
: used;
return el("div", {
className: "m01-easytable__cond",
children: [
el("span", { className: "m01-easytable__cond-name", text: c.key }),
el("span", { className: "m01-easytable__cond-value", text: value || tt("NotYet") }),
el("span", {
className: "m01-easytable__seg",
children: opts.map(([mode, label]) => {
const b = createButton({
label,
size: "sm",
variant: c.mode === mode ? "filled" : "ghost",
onClick: () => setMode(c, mode),
});
b.setAttribute("aria-pressed", String(c.mode === mode));
return b;
}),
}),
],
});
}),
);
};
const paintGrid = (): void => {
const table = tbl[now];
if (!table) {
grid.replaceChildren(el("p", { className: "m01-easytable__note", text: tt("NoTable") }));
return;
}
const st = states[now];
const conds = Object.keys(table.조건);
const vals = Object.keys(table.값칸);
const hit = last.hits[now];
const th = (text: string, cls = ""): HTMLElement => el("th", { className: cls, text });
const rows = table.줄.map((row, idx) => {
const tr = el("tr", {
className: [
idx === hit && !busy ? "is-hit" : "",
idx === st.picked ? "is-picked" : "",
].join(" "),
children: [
...conds.map((k) => el("td", { text: showCell(row[k]) })),
...vals.map((k) =>
el("td", {
className: `is-value${k === st.col ? " is-col" : ""}`,
text: showCell(row[k]),
attrs: { "data-col": k },
}),
),
],
});
tr.addEventListener("click", (ev) => {
const cell = (ev.target as HTMLElement).closest("td");
pickRow(idx, cell?.dataset.col ?? null);
});
return tr;
});
grid.replaceChildren(
el("table", {
children: [
el("thead", {
children: [
el("tr", {
children: [
...conds.map((k) => th(k)),
...vals.map((k) =>
th(
`${k}${table.값칸[k] ? ` (${table.값칸[k]})` : ""}`,
k === st.col ? "is-col" : "",
),
),
],
}),
],
}),
el("tbody", { children: rows }),
],
}),
);
};
const paint = (): void => {
const table = tbl[now];
which.replaceChildren(
...(calls.length > 1
? calls.map((_c, k) =>
createButton({
label: tbl[k]?.이름 ?? `${tt("Which")} ${k + 1}`,
size: "sm",
variant: k === now ? "filled" : "ghost",
onClick: () => {
now = k;
void refresh();
},
}),
)
: []),
);
head.replaceChildren(
el("strong", { text: [table?.이름, table?.기준].filter(Boolean).join(" · ") }),
el("span", { className: "m01-easytable__note", text: tt("Hint") }),
);
paintConds();
paintGrid();
const picked = states[now].picked;
const mismatch = !busy && picked !== null && last.hits[now] !== picked;
warn.textContent = mismatch ? tt("Mismatch") : "";
const fmt = (v: number | null): string => (v === null ? "—" : `${formatMoney(v)} 원`);
money.replaceChildren(
el("span", { text: `${tt("Amount")} ${fmt(last.line)}` }),
el("strong", { text: `${tt("Total")} ${fmt(last.total)}` }),
el("span", {
className: "m01-easytable__note",
text: busy ? tt("Wait") : last.ok ? "" : tt("Stopped"),
}),
);
money.classList.toggle("is-busy", busy);
use.disabled = busy || mismatch || !last.ok;
};
body.replaceChildren(
which,
head,
condBox,
grid,
money,
warn,
el("div", { className: "m01-easytable__actions", children: [cancel, use] }),
);
await refresh();
}
+321
View File
@@ -0,0 +1,321 @@
"""M01 쉬운 단가산출 Api(`M01_MasterData/EasyCalc/M01_EasyCalc_Api.ts`) 시험 — PLAN 34-3b.
· 서버 길 왕복 — Api 가 부르는 몸 그대로 새로 → 고치기 → 시험 계산 → 지우기(임시 폴더 사본에서만)
· 쓰는 곳 — 담은 조합 · 부르는 내 로직이 지우기를 막음
· 기계 짝 규칙 · 쉬운 말 바꾸기 — Api 파일의 「순수」 칸을 Node 로 바로 돌림
견본 로직 줄은 시험 안에 둠 — 단가 값은 사본에서 읽어 비교(진짜 값에 안 기댐).
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from decimal import Decimal
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from M01_MasterData import M01_MasterData_Router as router_module
from M01_MasterData import M01_MasterData_Store as store
ROOT = Path(__file__).resolve().parents[2]
API = ROOT / "M01_MasterData" / "EasyCalc" / "M01_EasyCalc_Api.ts"
REAL = store.FOLDER
OWN = "로직_자체.json"
NO_NODE = shutil.which("node") is None
@pytest.fixture
def client(tmp_path: Path, monkeypatch) -> TestClient:
for path in REAL.glob("*.json"):
if path.name == OWN:
continue # 내 로직은 빈 자리에서 시작
if not path.name.startswith("_") or path.name == store.mk.BOOK.name:
shutil.copy(path, tmp_path / path.name)
monkeypatch.setattr(store, "FOLDER", tmp_path)
app = FastAPI()
app.include_router(router_module.router)
return TestClient(app)
def _call(client: TestClient, url: str, body: dict | None = None, want: int = 200) -> dict:
res = client.get(url) if body is None else client.post(url, json=body)
assert res.status_code == want, res.text
return res.json()
def _labor() -> tuple[str, Decimal]:
"""값이 있는 인력 한 줄(키 · 노임) — 사본에서 읽음."""
rows = json.loads((store.FOLDER / "인력.json").read_text(encoding="utf-8"))["줄"]
row = next(r for r in rows if r.get("상태") == "공표" and isinstance(r.get("값"), (int, float)))
return row["키"], Decimal(str(row["값"]))
def _row(name: str, lines: list[dict], inputs: list | None = None) -> dict:
"""쉬운 길이 보내는 최소 줄 — 키 · 갈래 · 소유는 서버가 채움."""
return {
"키": "",
"원문번호": "",
"이름": name,
"결과단위": "원/㎡",
"출처": "자체",
"입력": inputs or [],
"중간": [],
"호표": lines,
"덧줄": [],
"끝수": None,
}
def _person(key: str, qty: str) -> dict:
return {
"종류": "인력",
"요소": key,
"이름": "사람",
"단위": "인",
"수량": qty,
"비목": "노무비",
}
def _node(expr: str) -> object:
"""Api 파일의 「순수」 칸 + 식 하나를 Node 로 돌려 JSON 답."""
src = API.read_text(encoding="utf-8")
block = re.search(r"/\* ── 순수 시작[^\n]*\n([\s\S]*?)/\* ── 순수 끝", src)
assert block, "순수 칸 표시를 못 찾음"
script = (
"const currentLanguageIndex = 0;\n"
+ block.group(1).replace("export function", "function")
+ f"\nconsole.log(JSON.stringify({expr}));"
)
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-e", script],
capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
# ── 서버 길 왕복 ───────────────────────────────────────────────────────
def test_새로_고치기_시험계산_지우기_왕복(client: TestClient) -> None:
real_before = (REAL / OWN).exists()
key_l, price = _labor()
made = _call(
client,
"/api/m01/logic/new",
{"logic": _row("시험 쉬운 줄", [_person(key_l, "0.5")]), "owner": "현장"},
)
key = made["key"]
assert key.startswith("GX") and made["file"] == OWN and made["logic"]["소유"] == "현장"
# 목록 · 열기 — 내 것은 사본의 자체 파일에서
listed = _call(client, "/api/m01/logics?q=시험 쉬운 줄")["logics"]
assert [x["file"] for x in listed] == [OWN] and listed[0]["결과단위"].startswith("원")
opened = _call(client, f"/api/m01/logic?key={key}")
assert opened["version"] == made["version"]
# 고치기 → 시험 계산(저장 전 줄을 그대로 보냄) — 금액 = 수량 × 노임
row = {**opened["logic"], "호표": [_person(key_l, "2")]}
edited = _call(
client, "/api/m01/logic/edit", {"key": key, "version": opened["version"], "logic": row}
)
calc = _call(client, "/api/m01/calc", {"key": key, "inputs": {}, "row": edited["logic"]})
assert calc["ok"] is True
assert Decimal(str(calc["sums"]["노무비"])) == 2 * price == Decimal(str(calc["sums"]["계"]))
# 저장 안 한 새 줄도 키 "" 로 셈
draft = _call(
client,
"/api/m01/calc",
{"key": "", "inputs": {}, "row": _row("새 것", [_person(key_l, "1")])},
)
assert Decimal(str(draft["sums"]["계"])) == price
# 낡은 판본 409 · 품셈 기본 고치기 403
_call(
client,
"/api/m01/logic/edit",
{"key": key, "version": made["version"], "logic": row},
want=409,
)
base = next(x for x in _call(client, "/api/m01/logics")["logics"] if x["file"] != OWN)
base_one = _call(client, f"/api/m01/logic?key={base['키']}")
body = {"key": base["키"], "version": base_one["version"], "logic": base_one["logic"]}
_call(client, "/api/m01/logic/edit", body, want=403)
_call(client, "/api/m01/logic/delete", {"key": key, "version": edited["version"]})
assert client.get(f"/api/m01/logic?key={key}").status_code == 404
assert (REAL / OWN).exists() == real_before # 진짜 마스터 폴더는 안 건드림
def test_쓰는_곳_조합과_부르는_로직이_지우기를_막음(client: TestClient) -> None:
key_l, _ = _labor()
a = _call(client, "/api/m01/logic/new", {"logic": _row("바탕 줄", [_person(key_l, "1")])})
call = {"종류": "로직", "요소": f"로직({a['key']})", "이름": "바탕", "단위": "㎡", "수량": "1"}
b = _call(client, "/api/m01/logic/new", {"logic": _row("부르는 줄", [call])})
# 부르는 내 로직 — usesOf 가 훑는 목록(구분 「자체」) · 몸에 「로직(<키>」
mine = _call(client, "/api/m01/logics?sub=자체")["logics"]
assert {x["키"] for x in mine} == {a["key"], b["key"]}
body = json.dumps(_call(client, f"/api/m01/logic?key={b['key']}")["logic"], ensure_ascii=False)
assert f"로직({a['key']}" in body
blocked = client.post("/api/m01/logic/delete", json={"key": a["key"], "version": b["version"]})
assert blocked.status_code == 422, blocked.text
errors = blocked.json()["detail"]["errors"]
if not NO_NODE: # 서버 글 그대로 쉬운 말로 — 키가 안 새어 나감
plain = _node(f"plainOf(422, {json.dumps({'errors': errors}, ensure_ascii=False)})")
assert "지울 수 없음" in plain and not re.search(r"[A-Z]{2}\d{6}", plain)
# 부르는 줄을 지우면 바탕을 지울 수 있음
_call(client, "/api/m01/logic/delete", {"key": b["key"], "version": b["version"]})
now = _call(client, f"/api/m01/logic?key={a['key']}")["version"]
# 담은 조합(마스터 층 파일)도 막음 — /logic/combos 가 그 조합을 줌
combo_file = store.FOLDER / "일위대가조합.json"
combos = (
json.loads(combo_file.read_text(encoding="utf-8"))
if combo_file.exists()
else {"그룹": "일위대가조합", "판": "2026", "줄": []}
)
combos["줄"].append(
{"키": "UA999999", "이름": "시험 조합", "구분": "자체", "상세구분": "", "단위": "㎡",
"담은로직": [{"로직": a["key"], "메모": None}], "본뜬키": None, "출처": "자체",
"소유": "현장", "비고": None}
) # fmt: skip
combo_file.write_text(json.dumps(combos, ensure_ascii=False, indent=2), encoding="utf-8")
store.cache.stale(["일위대가조합.json"], set())
used = _call(client, f"/api/m01/logic/combos?key={a['key']}")["combos"]
assert [c["키"] for c in used] == ["UA999999"]
_call(client, "/api/m01/logic/delete", {"key": a["key"], "version": now}, want=422)
# ── 기계 짝 규칙 ───────────────────────────────────────────────────────
def _m(key: str, codes: list[str], *extra: str) -> dict:
inputs = [{"이름": "기계", "고르기": codes}] + [{"이름": n, "고르기": ["가"]} for n in extra]
return {"key": key, "name": key, "resultUnit": "원/시간", "inputs": inputs}
FAMILY = [
_m("M_GENERAL", ["A", "B", "C", "D"], "지역"),
_m("M_BOOST", ["A", "B", "C", "D", "E"], "지역", "보정작업"),
_m("M_DIGGER", ["B"], "지역", "보정작업"),
_m("M_NOOP", ["C"], "지역"),
_m("M_CLAW", ["B"], "집게", "지역", "보정작업"),
_m("M_LOSS", ["A", "F", "G"]),
_m("M_LOSS_BOOST", ["F", "G", "H"], "보정작업"),
]
@pytest.mark.skipif(NO_NODE, reason="node 가 없음")
def test_기계_짝_규칙() -> None:
fam = json.dumps(FAMILY, ensure_ascii=False)
picks = _node(
f"['A','B','C','D','E','F','H','Z'].map((c) => pickMachine({fam}, c)?.key ?? null)"
)
# 일반 > 보정 · 전용(굴착기 · 조종원 없음)이 일반보다 먼저 · 부착 전용은 안 고름 ·
# 손료만은 다른 길이 없을 때만 · 손료만 안에서는 보정 아닌 것 먼저 · 없는 기종 null
assert picks == [
"M_GENERAL",
"M_DIGGER",
"M_NOOP",
"M_GENERAL",
"M_BOOST",
"M_LOSS",
"M_LOSS_BOOST",
None,
]
call = _node(f"machineCall({fam}[2], 'B')")
assert call["element"] == "로직(M_DIGGER, 기계='B', 지역=지역, 보정작업=보정작업)"
assert [i["이름"] for i in call["needInputs"]] == ["지역", "보정작업"]
quoted = _node(
"machineCall({key:'K', name:'', resultUnit:'원/시간', inputs:[{이름:'기계'},{이름:'조종 직종'}]}, 'X')"
)
assert quoted["element"] == "로직(K, 기계='X', '조종 직종'='조종 직종')"
@pytest.mark.skipif(NO_NODE, reason="node 가 없음")
def test_기계_부름글이_서버_검사와_계산을_지남(client: TestClient) -> None:
"""사본의 기계 시간당 경비 로직으로 짝을 지어 새 줄에 넣음 — 저장 · 계산이 됨(종류 「기계」 꼴 안 만듦)."""
family = []
for path in store.FOLDER.glob("로직_*건설기계*.json"):
for row in json.loads(path.read_text(encoding="utf-8"))["줄"]:
if row["결과단위"].startswith("원"):
family.append(
{
"key": row["키"],
"name": row["이름"],
"resultUnit": row["결과단위"],
"inputs": row["입력"],
}
)
if not family:
pytest.skip("사본에 기계 시간당 경비 로직이 없음")
fam = json.dumps(family, ensure_ascii=False)
code = next(
str(c)
for m in family
for i in m["inputs"]
if i["이름"] == "기계"
for c in i.get("고르기", [])
)
got = _node(
f"(() => {{ const m = pickMachine({fam}, '{code}'); return m && machineCall(m, '{code}'); }})()"
)
assert got and got["element"].startswith("로직(")
line = {"종류": "로직", "요소": got["element"], "이름": "기계", "단위": "시간", "수량": "0.5"}
made = _call(
client, "/api/m01/logic/new", {"logic": _row("기계 시험", [line], got["needInputs"])}
)
trial = {i["이름"]: (i.get("고르기") or [None])[0] for i in got["needInputs"]}
calc = _call(client, "/api/m01/calc", {"key": made["key"], "inputs": trial})
assert calc["ok"] is True, calc
assert Decimal(str(calc["sums"]["계"])) > 0 and "비목" in calc["lines"][0]
_call(client, "/api/m01/logic/delete", {"key": made["key"], "version": made["version"]})
# ── 쉬운 말 ─────────────────────────────────────────────────────────────
@pytest.mark.skipif(NO_NODE, reason="node 가 없음")
def test_plainError_상태와_검사글을_쉬운_말로() -> None:
simple = _node("[0, 403, 404, 409, 500].map((s) => plainOf(s, 'x'))")
assert simple[0].startswith("서버와 이어지지")
assert "바탕으로 고치기" in simple[1] and "찾을 수 없음" in simple[2]
assert "다른 곳에서 먼저 고쳤음" in simple[3] and "검사에 걸림" in simple[4]
errors = [
"로직_자체.json · GX000001 호표 사람 수량 · 모르는 이름 「면적」",
"로직_자체.json · GX000001 호표 사람 · 비목 「None」",
"로직_자체.json · GX000001 시험 · 「호표」 도 「결과」 도 없음",
"로직_자체.json · GX000001 호표 기계 요소 · 「기계경비」 입력 「지역」 안 줌",
"알 수 없는 글 GX000009 찾기(QF000001).값",
]
plain = _node(f"plainOf(422, {json.dumps({'errors': errors}, ensure_ascii=False)})").split("\n")
assert plain[0] == "「면적」 값이 어디에도 없음 — 설계 때 정하는 값으로 더해 주세요"
assert "넣을 자리" in plain[1] and "줄이 하나도 없음" in plain[2]
assert plain[3] == "그 밖 2 건" # 셋까지 + 나머지 수
assert not any(re.search(r"[A-Z]{2}\d{6}|찾기\(", x) for x in plain)
assert "「지역」" in _node(
f"plainOf(422, {json.dumps({'errors': errors[3:4]}, ensure_ascii=False)})"
)
assert _node("plainOf(400, '로직 줄에 「이름」 이 없음')") == "이름을 넣어 주세요"
assert _node("plainStop('「GX000001」 입력 「돌」 없음')").startswith("이 값으로는")
def test_화면_글에_금지_낱말이_없음() -> None:
"""설계 1절 8 — 키 · 식 · 호표 · 덧줄 · 비목 · 중간 값 · 끝수 · 정본 · 자체 · 별칭 안 씀(한국어 글)."""
src = API.read_text(encoding="utf-8")
block = re.search(r"const TEXT = \{([\s\S]*?)\} as const", src)
assert block
korean = re.findall(r'\[\s*"([^"]*)",', block.group(1))
assert len(korean) > 20
banned = ("키", "식", "호표", "덧줄", "비목", "중간 값", "끝수", "정본", "자체", "별칭")
for text in korean:
assert not any(word in text for word in banned), text
+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)
+155
View File
@@ -0,0 +1,155 @@
"""쉬운 단가산출 2단계 — 표 보고 고르기(`M01_EasyCalc_Table.ts`)의 식 읽기 · 새 식 짜기.
화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 화면 부품 · 서버는 빈 흉내(순수 함수만 부름).
견본 식은 이 시험 안에 둠(진짜 마스터 내용에 기대지 않음).
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
SOURCE = ROOT / "M01_MasterData" / "EasyCalc" / "M01_EasyCalc_Table.ts"
RUNNER = r"""
const fs = require("fs");
const path = require("path");
const [root, file] = process.argv.slice(1);
const ts = require(path.join(root, "config", "node_modules", "typescript"));
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const stub = new Proxy({}, { get: () => () => undefined });
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, () => stub);
const m = box.exports;
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
const out = jobs.map((job) => {
if (job.op === "read") return m.readFinds(job.formula);
if (job.op === "swap") {
const calls = m.readFinds(job.formula);
const swaps = calls.map((c, i) => {
const s = job.swaps[i];
return s ? m.writeFind(c.table, s.args ?? c.args, s.col ?? c.col) : null;
});
return m.replaceFinds(job.formula, calls, swaps);
}
if (job.op === "literal") return m.literal(job.value, job.rule ?? "");
return null;
});
process.stdout.write(JSON.stringify(out));
"""
def run(jobs: list[dict]) -> list:
if shutil.which("node") is None:
pytest.skip("node 가 없음")
done = subprocess.run(
["node", "-e", RUNNER, str(ROOT), str(SOURCE)],
input=json.dumps(jobs, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(done.stdout)
MESSANGI = "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 증가율 / 100)"
def test_식_읽기_흔한_꼴():
[calls] = run([{"op": "read", "formula": MESSANGI}])
assert len(calls) == 1
c = calls[0]
assert c["table"] == "QF000421" and c["col"] == "석공"
assert [(a["key"], a["rhs"]) for a in c["args"]] == [
("뒷길이", "뒷길이"),
("돌", "돌"),
("쌓기", "쌓기"),
]
assert MESSANGI[c["start"] : c["end"]] == "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공"
def test_식_읽기_따옴표_쉼표_괄호_겹침():
f = (
"(찾기(QC000524, 형식=형식).특별인부 - 만약(정밀절단 == '아님', "
"찾기(QC000583, 구분='가, 나 (다)').값, 0)) / 시공량"
)
[calls] = run([{"op": "read", "formula": f}])
assert [c["table"] for c in calls] == ["QC000524", "QC000583"]
assert calls[1]["args"] == [{"key": "구분", "rhs": "'가, 나 (다)'"}]
assert calls[1]["col"] == "값"
def test_식_읽기_못_읽는_꼴과_없는_꼴():
out = run(
[
{"op": "read", "formula": "찾기(QF:{지역}, 구분='a').값"}, # {자리} 낀 키
{"op": "read", "formula": "찾기(QF000001, 구분='a'"}, # 안 닫힘
{"op": "read", "formula": "품 * 1.2"},
{"op": "read", "formula": "'찾기(QF000001, 구분=1).값'"}, # 따옴표 글 안
]
)
assert out[0] is None and out[1] is None
assert out[2] == [] and out[3] == []
def test_새_식_짜기_줄_고정과_값칸_바꾸기():
[new] = run(
[
{
"op": "swap",
"formula": MESSANGI,
"swaps": [
{
"args": [
{"key": "뒷길이", "rhs": "35"},
{"key": "돌", "rhs": "'깬잡석'"},
{"key": "쌓기", "rhs": "쌓기"},
],
"col": "보통인부",
}
],
}
]
)
assert new == (
"찾기(QF000421, 뒷길이=35, 돌='깬잡석', 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)"
)
def test_새_식_짜기_둘째_찾기만_바꿈_나머지_글_그대로():
f = "찾기(QC1, 구분='가새').수량 * 찾기(QC2, 공기=공기).손율 / 100"
[new] = run(
[
{
"op": "swap",
"formula": f,
"swaps": [None, {"args": [{"key": "공기", "rhs": "'1개월'"}]}],
}
]
)
assert new == "찾기(QC1, 구분='가새').수량 * 찾기(QC2, 공기='1개월').손율 / 100"
# 다시 읽어도 같은 모양(왕복)
[calls] = run([{"op": "read", "formula": new}])
assert calls[1]["args"] == [{"key": "공기", "rhs": "'1개월'"}]
def test_칸_값을_식_글로():
out = run(
[
{"op": "literal", "value": 25},
{"op": "literal", "value": "깬잡석"},
{"op": "literal", "value": "it's"}, # 따옴표 든 글은 못 씀
{"op": "literal", "value": [20, 40], "rule": "초과·이하"},
{"op": "literal", "value": [None, 60], "rule": "이하"},
{"op": "literal", "value": [None, 60], "rule": "이상·미만"},
{"op": "literal", "value": [80, None], "rule": "초과·이하"},
{"op": "literal", "value": [80, None], "rule": "이상·미만"},
]
)
assert out == ["25", "'깬잡석'", None, "30", "60", "59", "81", "80"]