feat(m01-easycalc): 쉬운 단가산출 Api 몸 — 서버 길 감싸기 · 기계 짝 · 쉬운 말

- listEasy(품셈 기본 / 내가 만든 것 · 돈 아닌 로직 「쉬운 길로 못 엶」) · openEasy(자동값) · calcLater(0.3초 · 최신 응답만)
- newEasy · copyEasy · editEasy · deleteEasy — /logic/* 만(/save 안 씀) · usesOf = 담은 조합 + 부르는 내 로직
- listMachines · machineElement — 기종 → 기계 시간당 경비 로직 부름 글 · 넘길 설계 값(부착 전용 · 손료만은 뒤로)
- plainError — 0 · 403 · 404 · 409 · 422 검사 글을 쉬운 말로(키 · 날 글 걸름)
- 시험 test_m01_easycalc_api.py — 사본 폴더 왕복 · 쓰는 곳 막음 · 기계 짝 · 쉬운 말 · 금지 낱말

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KmELpdXFDRZHhfbGuRhSmL
This commit is contained in:
2026-10-01 09:42:02 +09:00
co-authored by Claude Opus 5.5
parent 3c7a285f1f
commit 87aabc4a0d
2 changed files with 704 additions and 29 deletions
+383 -29
View File
@@ -3,9 +3,22 @@
* 쉬운 단가산출 Api — 서버 길 감싸기(목록 · 열기 + 자동값 · 시험 계산 뒤에서 · 새로 · 본뜨기 ·
* 고치기 · 지우기 · 쓰는 곳) · 403 · 409 · 422 를 쉬운 말로 · 기계 기종 → 부를 기계경비 로직 · 넘길 값
* 모양은 `docs/raw/M01_쉬운단가/설계.md` 3절 그대로 · 쓰기는 `/logic/*` 만(`/save` 안 씀)
* 시험: `resources/tester/test_m01_easycalc_api.py`(서버 왕복 + 아래 「순수」 칸을 Node 로)
* ========================================================================== */
import type { CalcAnswer, LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api";
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 {
@@ -62,55 +75,396 @@ export interface EasyMachineElement {
needInputs: LogicInput[];
}
const notReady = (): never => {
throw new Error("준비 중");
};
export async function listEasy(_q: string): Promise<EasyListItem[]> {
return notReady();
/** 기계 시간당 경비 로직 하나 — 기종 짝 규칙의 재료 */
export interface MachineLogic {
key: string;
name: string;
resultUnit: string;
inputs: LogicInput[];
}
export async function openEasy(_key: string): Promise<EasyOpened> {
return notReady();
/* ── 순수 시작 — 아래 끝까지 밖 이름을 안 씀(`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,
row: LogicRow,
trial: Record<string, unknown>,
onDone: (ans: CalcAnswer | null, plain: string | null) => void,
): void {
notReady();
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> {
return notReady();
/** 새로 — 키 · 갈래는 서버가 줌 · 근거 글이 비면 「자체」(검사가 빈 글을 막음) */
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> {
return notReady();
/** 이걸 바탕으로 고치기 — 품셈 기본 · 내 것 모두 본뜸 */
export async function copyEasy(key: string): Promise<EasySaved> {
const got = await copyLogic(key);
return { key: got.key, version: got.version, row: got.logic };
}
export async function editEasy(_key: string, _version: string, _row: LogicRow): Promise<EasySaved> {
return notReady();
/** 고치기 — 내가 만든 것만(품셈 기본은 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 };
}
export async function deleteEasy(_key: string, _version: string): Promise<{ version: string }> {
return notReady();
/** 지우기 — 내가 만든 것만 · 쓰는 곳이 남으면 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 };
}
export async function usesOf(_key: string): Promise<EasyUse[]> {
return notReady();
/** 쓰는 곳 — 담은 일위대가 조합(마스터 층) + 이 로직을 부르는 내가 만든 것.
* 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,
];
}
export async function listMachines(_q: string): Promise<EasyMachine[]> {
return notReady();
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;
}
export async function machineElement(_기종: string): Promise<EasyMachineElement> {
return notReady();
interface MachineRow {
원문번호?: string;
이름?: string;
규격?: string;
}
export function plainError(_e: unknown): string {
return notReady();
/** 기계 고르기 — 이름 · 분류번호 찾기 · 시간당 값을 셀 수 있는 기종만 */
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");
}
+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