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

This commit is contained in:
2026-10-01 11:48:45 +09:00
9 changed files with 1961 additions and 38 deletions
+70 -13
View File
@@ -2,9 +2,14 @@
* M01_LB_Api.ts
* 로직 만들기 판 Api — 고친 표로 예시값 계산(0.3초 · 최신만) · 표를 쓰는 로직(사용처) · 표 읽기
* 서버 = `M01_LB_Calc.py`(`/api/m01/lb/*`) · 모양은 `docs/raw/M01_쉬운단가/로직판_A_정함.md` 「맞출 모양」
* 시험: `resources/tester/test_m01_lb_calc.py`
* ========================================================================== */
import type { CalcAnswer, LogicRow } from "../M01_MasterData_UI_Logic_Api";
import { API_BASE_URL } from "@config/config_frontend";
import { fetchTable, fetchTables } from "../M01_MasterData_Api_Fetch";
import { ApiError, type CalcAnswer, type LogicRow } from "../M01_MasterData_UI_Logic_Api";
import { safeReason } from "../M01_MasterData_UI_Logic_Text";
import { plainError } from "../EasyCalc/M01_EasyCalc_Api";
/** 표 키 → 표 사본(고친 값 포함 · 표 통째) — 서버가 메모리에서만 바꿔 끼움 */
export type LbTables = Record<string, unknown>;
@@ -23,24 +28,76 @@ export interface LbTable {
table: Record<string, unknown> & { 줄: Record<string, unknown>[] };
}
const notReady = (): never => {
throw new Error("준비 중");
};
const CALC_WAIT_MS = 300;
const TABLE_GROUPS = ["소요량", "계수"]; // 표가 드는 그룹(계수 표가 소요량 파일에 든 것도 있음)
async function send<T>(path: string, body?: unknown): Promise<T> {
let response: Response;
try {
response = await fetch(`${API_BASE_URL}/m01/lb${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;
}
/** 로직이 받는 입력값만 — 모르는 이름을 보내면 서버가 멈춤 */
function inputsFor(row: LogicRow, inputs: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(
(row.입력 ?? []).filter((i) => i.이름 in inputs).map((i) => [i.이름, inputs[i.이름]]),
);
}
/** 계산 멈춤 까닭 — 표 키(`QF000421`)가 섞인 글은 뭉뚱그림 */
function plainStop(reason: string): string {
const plain = safeReason(reason);
return /[A-Z]{2}\d{6}/.test(plain) ? safeReason("{}") : plain;
}
let calcSeq = 0;
let calcTimer: ReturnType<typeof setTimeout> | undefined;
/** 0.3초 뒤 고친 표로 서버 계산 · 최신 응답만 `onDone` · 멈춤 · 실패는 `plain` 쉬운 말 */
export function lbCalcLater(
_row: LogicRow,
_inputs: Record<string, unknown>,
_tables: LbTables,
_onDone: (ans: CalcAnswer | null, plain: string | null) => void,
row: LogicRow,
inputs: Record<string, unknown>,
tables: LbTables,
onDone: (ans: CalcAnswer | null, plain: string | null) => void,
): void {
notReady();
const mine = ++calcSeq;
clearTimeout(calcTimer);
calcTimer = setTimeout(() => {
send<CalcAnswer>("/calc", { row, inputs: inputsFor(row, inputs), tables })
.then((ans) => {
if (mine === calcSeq) onDone(ans, ans.ok ? null : plainStop(ans.reason));
})
.catch((e: unknown) => {
if (mine === calcSeq) onDone(null, plainError(e));
});
}, CALC_WAIT_MS);
}
export async function tableUsers(_key: string): Promise<LbTableUser[]> {
return notReady();
/** 사용처 — 그 표를 쓰는 로직(표 「용도」 + 로직 글 대조) */
export async function tableUsers(key: string): Promise<LbTableUser[]> {
const got = await send<{ users: LbTableUser[] }>(
`/table-users?${new URLSearchParams({ key }).toString()}`,
);
return got.users;
}
export async function fetchLbTable(_key: string): Promise<LbTable> {
return notReady();
/** 표 키 하나로 표 통째 — 든 파일을 찾아(소요량 · 계수) Api_Fetch 의 표 읽기로 */
export async function fetchLbTable(key: string): Promise<LbTable> {
for (const group of TABLE_GROUPS) {
const page = await fetchTables(group, "", "", key, 1, 5);
const head = page.tables.find((t) => t.키 === key);
if (head) return { file: head.file, ...(await fetchTable(head.file, key)) };
}
throw new ApiError(404, `없는 표 「${key}」`);
}
+570 -25
View File
@@ -2,11 +2,18 @@
* M01_LB_Blocks.ts
* 로직 만들기 판 — 로직 줄 ↔ 블록(① 입력값 ② 표 조회 ③ 계산 ④ 항목 연결 ⑤ 결과값) 읽기 · 쓰기
* DOM 없음 · 모양은 `docs/raw/M01_쉬운단가/로직판_A_설계초안.md` 7-1 · `로직판_A_정함.md` 그대로
*
* 정본 상태 = `lb.doc`(EasyCalc Model 의 EasyDoc) — 쓰기는 `toRow(lb.doc)` 하나
* 읽기 = 식 자리(중간 · 수량 · 요소 · 비율 · 결과)마다 엔진 문법(`master_formula._Parser`)으로 읽어
* ② 표 조회는 Table `readFinds` · ③ 조건 덩어리는 Cond `readCond` · 나머지 사칙 사슬은 여기서
* 고치기 = 그 자리 글만 갈아끼운 줄을 다시 읽음(안 고친 자리는 글자까지 그대로)
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import type { LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api";
import type { CondRead } from "../EasyCalc/M01_EasyCalc_Cond";
import type { EasyDoc } from "../EasyCalc/M01_EasyCalc_Model";
import { readCond, writeRead, type CondRead } from "../EasyCalc/M01_EasyCalc_Cond";
import { emptyRow, toDoc, toRow, type EasyDoc } from "../EasyCalc/M01_EasyCalc_Model";
import { readFinds, writeFind, type FindArg } from "../EasyCalc/M01_EasyCalc_Table";
/** 값이 오는 곳 */
export type LbRef =
@@ -47,7 +54,7 @@ export interface LbCalc {
/** 곱나눔 · 더하기 · 할증(바탕 · 율) */
parts: (LbRef | LbExpr)[];
ops?: ("*" | "/" | "+" | "-")[];
/** 조건목록 · 최소최대 · 올림 — Cond 것 그대로 */
/** 조건목록 · 최소최대 · 올림 — Cond 것 그대로 · 다른 틀이면 글 속 조건 덩어리 */
cond?: CondRead;
at: { part: Exclude<LbPart, "요소">; index: number };
/** 원래 글(안 고치면 그대로 씀) */
@@ -77,44 +84,582 @@ export interface TableEdit {
value: number | string | [number | null, number | null];
}
const notReady = (): never => {
throw new Error("준비 중");
const TEXT = {
BadText: ["읽을 수 없는 글이 있음", "Has text that can't be read"],
CondInLookup: ["표 조회 조건에 계산이 들어 있음", "A table lookup condition is calculated"],
AndOr: ["조건에 「그리고 · 또는」이 섞임", "A condition mixes and/or"],
PassCalc: ["넘길 값에 계산이 들어 있음", "A passed value is calculated"],
Unknown: ["모르는 이름이 있음", "Has an unknown name"],
Result: ["결과값", "Result value"],
Qty: ["수량", "quantity"],
} as const;
const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
/* ── 식 읽기(엔진 문법) ─────────────────────────────────────────────────── */
type Node = {
t:
"num" | "str" | "name" | "eref" | "paren" | "neg" | "field" | "call" | "find" | "logic" | "bin";
v?: string;
f?: string;
op?: string;
a?: Node;
b?: Node;
items?: Node[];
head?: string;
args?: [string, Node][];
src: string;
};
const CMPS = ["<=", ">=", "==", "!=", "<", ">"];
const ROUND = ["올림", "버림", "반올림"];
function parse(s: string): Node | null {
let i = 0;
const fail = (): never => {
throw new Error("parse");
};
const ws = (): void => {
while (i < s.length && /\s/.test(s[i])) i++;
};
const take = (w: string): boolean => {
ws();
if (!s.startsWith(w, i)) return false;
i += w.length;
return true;
};
const need = (w: string): void => void (take(w) || fail());
const stick = (re: RegExp): string | null => {
re.lastIndex = i;
const m = re.exec(s);
if (m) i = re.lastIndex;
return m ? m[0] : null;
};
const word = (): string => (ws(), stick(/[\p{L}\p{N}_]+/uy) ?? fail());
const quoted = (): string => {
const e = s.indexOf("'", i);
if (e < 0) fail();
const v = s.slice(i, e);
i = e + 1;
return v;
};
/** 키 · 원문번호 글 — head 면 , ) 에서만 멈춤 */
const ref = (head: boolean): string => {
const st = i;
for (let d = 0; i < s.length; i++) {
const c = s[i];
if ("({".includes(c)) d++;
else if (")}".includes(c)) {
if (!d) break;
d--;
} else if (!d && c === ",") break;
else if (head) continue;
else if (!d && c === "." && !/\d/.test(s[i + 1] ?? "")) break;
else if (!d && /^\s[-+*/<>=!]/.test(s.slice(i, i + 2))) break;
}
return s.slice(st, i).trim();
};
const mk = (st: number, o: Omit<Node, "src">): Node => ({ ...o, src: s.slice(st, i).trim() });
const fields = (st: number, n: Node): Node => {
while (take(".")) n = mk(st, { t: "field", a: n, f: take("'") ? quoted() : word() });
return n;
};
const args = (): [string, Node][] => {
const out: [string, Node][] = [];
while (take(",")) {
const k = take("'") ? quoted() : word();
need("=");
out.push([k, expr()]);
}
need(")");
return out;
};
const primary = (): Node => {
ws();
const st = i;
if (take("(")) {
const a = expr();
need(")");
return fields(st, mk(st, { t: "paren", a }));
}
if (take("'")) return mk(st, { t: "str", v: quoted() });
const n = stick(/\d+(?:\.\d+)?/y);
if (n) return mk(st, { t: "num", v: n });
const w = word();
if (w === "찾기" || w === "로직") {
need("(");
ws();
const head = ref(true);
return fields(st, mk(st, { t: w === "찾기" ? "find" : "logic", head, args: args() }));
}
if (["만약", "최소", "최대", ...ROUND].includes(w)) {
need("(");
const items = [expr()];
while (take(",")) items.push(expr());
need(")");
return fields(st, mk(st, { t: "call", f: w, items }));
}
if (s[i] === ":") {
i++;
ref(false);
return fields(st, mk(st, { t: "eref" }));
}
return fields(st, mk(st, { t: "name", v: w }));
};
const unary = (): Node => {
ws();
const st = i;
return take("-") ? mk(st, { t: "neg", a: unary() }) : primary();
};
const chain = (next: () => Node, ops: string[], once = false): Node => {
ws();
const st = i;
let n = next();
for (let op = ops.find(take); op; op = once ? undefined : ops.find(take))
n = mk(st, { t: "bin", op, a: n, b: next() });
return n;
};
const mul = (): Node => chain(unary, ["*", "/"]);
const add = (): Node => chain(mul, ["+", "-"]);
const cmp = (): Node => chain(add, CMPS, true);
const both = (): Node => chain(cmp, ["그리고"]);
const expr = (): Node => chain(both, ["또는"]);
try {
const node = expr();
ws();
return i === s.length ? node : null;
} catch {
return null;
}
}
const un = (n: Node): Node => (n.t === "paren" ? un(n.a!) : n);
const flat = (s: string): string => s.replace(/\s+/g, "");
const KEY = /^[A-Z]{2}\d{6}$/;
/* ── 읽기 ───────────────────────────────────────────────────────────────── */
interface Ctx {
row: LogicRow;
inputs: Set<string>;
mids: Set<string>;
/** 찾기 글(빈칸 뺌) → 표 조회 번호 */
finds: Map<string, string>;
/** 사람 줄 노임 합 글(빈칸 뺌) — 비율 바탕 */
personSum: string | null;
bad: Set<string>;
}
/** 조각의 원래 글 — 안 바뀐 조각은 이 글을 그대로 씀 */
const SRC = new WeakMap<object, { text: string; snap: string }>();
const keep = <T extends object>(ref: T, text: string): T => {
SRC.set(ref, { text, snap: JSON.stringify(ref) });
return ref;
};
const isSimple = (n: Node, c: Ctx): boolean => {
const u = un(n);
if (u.t === "num" || u.t === "str") return true;
if (u.t === "neg") return un(u.a!).t === "num";
return u.t === "name" && (c.inputs.has(u.v!) || c.mids.has(u.v!));
};
/** 식 나무를 훑어 못 읽는 까닭을 모음 */
function check(n: Node, c: Ctx): void {
const u = un(n);
if (u.t === "bin" && (u.op === "그리고" || u.op === "또는")) c.bad.add(tl("AndOr"));
if (u.t === "find" && u.args!.some(([, v]) => !isSimple(v, c))) c.bad.add(tl("CondInLookup"));
if (u.t === "logic" && u.args!.some(([, v]) => !isSimple(v, c))) c.bad.add(tl("PassCalc"));
if (u.t === "find" || u.t === "logic") return;
if (u.t === "name") {
const v = u.v!;
const known =
c.inputs.has(v) ||
c.mids.has(v) ||
KEY.test(v) ||
["노무비", "재료비", "경비", "계", "줄"].includes(v);
const item = (c.row.호표 ?? []).some((h) => h.이름 === v);
if (!known && !item) c.bad.add(tl("Unknown"));
}
for (const x of [u.a, u.b, ...(u.items ?? [])]) if (x) check(x, c);
}
/** 잎 하나 → 값이 오는 곳 */
function refOf(node: Node, c: Ctx): LbRef | LbExpr {
const u = un(node);
const text = node.src;
if (u.t === "num") return keep({ k: "fixed", value: Number(u.v) }, text);
if (u.t === "neg" && un(u.a!).t === "num")
return keep({ k: "fixed", value: -Number(un(u.a!).v) }, text);
if (u.t === "str") return keep({ k: "fixed", value: u.v! }, text);
if (u.t === "name" && c.inputs.has(u.v!)) return keep({ k: "input", name: u.v! }, text);
if (u.t === "name" && c.mids.has(u.v!)) return keep({ k: "calc", name: u.v! }, text);
if (u.t === "name" && ["노무비", "재료비", "경비"].includes(u.v!))
return keep({ k: "cost", name: u.v as "노무비" | "재료비" | "경비" }, text);
if (c.personSum !== null && flat(text) === c.personSum)
return keep({ k: "cost", name: "사람줄합" }, text);
if (u.t === "field" && u.a!.t === "find") {
const id = c.finds.get(flat(u.src));
if (id) return keep({ k: "lookup", id }, text);
}
if (u.t === "logic" || (u.t === "field" && u.a!.t === "logic")) {
const call = u.t === "logic" ? u : u.a!;
return keep({ k: "logic", key: call.head!, field: u.t === "field" ? u.f! : null }, text);
}
return { k: "expr", text };
}
const isLeaf = (n: Node): boolean => {
const u = un(n);
return (
["num", "str", "name", "field", "logic", "eref"].includes(u.t) ||
(u.t === "neg" && un(u.a!).t === "num")
);
};
/** 같은 연산 사슬 — a op b op c … */
function chainOf(n: Node, ops: string[]): { items: Node[]; ops: string[] } {
const u = un(n);
if (u.t !== "bin" || !ops.includes(u.op!) || u !== n) return { items: [n], ops: [] };
const left = chainOf(u.a!, ops);
return { items: [...left.items, u.b!], ops: [...left.ops, u.op!] };
}
function calcOf(text: string, at: LbCalc["at"], name: string | null, c: Ctx): LbCalc {
const base = { name, at, text, parts: [] as (LbRef | LbExpr)[] };
const node = parse(text);
if (!node) {
c.bad.add(tl("BadText"));
return { ...base, kind: "단계" };
}
check(node, c);
const u = un(node);
const inner = (): CondRead | undefined => {
const r = readCond(text, c.row);
return r && r.via === null ? r : undefined;
};
if (isLeaf(u)) return { ...base, kind: "값", parts: [refOf(node, c)] };
if (u.t === "call") {
const kind: LbCalcKind =
u.f === "만약" ? "조건목록" : ROUND.includes(u.f!) ? "올림" : "최소최대";
const cond = inner();
const whole =
cond &&
cond.blocks.length === 1 &&
cond.blocks[0].p[0] === 0 &&
cond.blocks[0].p[1] >= cond.text.length;
return whole ? { ...base, kind, cond } : { ...base, kind: "단계", cond };
}
if (u.t === "bin" && u.op === "*") {
const r = un(u.b!);
if (r.t === "bin" && r.op === "+" && un(r.a!).t === "num" && un(r.a!).v === "1") {
const rate = un(r.b!);
if (rate.t === "bin" && rate.op === "/" && un(rate.b!).t === "num" && un(rate.b!).v === "100")
return { ...base, kind: "할증", parts: [refOf(u.a!, c), refOf(rate.a!, c)], cond: inner() };
}
}
for (const [kind, ops] of [
["곱나눔", ["*", "/"]],
["더하기", ["+", "-"]],
] as const) {
const ch = chainOf(u === node ? node : u, [...ops]);
if (ch.items.length > 1)
return {
...base,
kind,
parts: ch.items.map((x) => refOf(x, c)),
ops: ch.ops as LbCalc["ops"],
cond: inner(),
};
}
return { ...base, kind: "단계", cond: inner() };
}
/** 사람 줄 노임 합 — 「(수량 * 노임 + …)」 꼴 글 · 사람 줄이 없으면 null */
function personSumText(row: LogicRow): string | null {
const terms = (row.호표 ?? [])
.filter((h) => h.종류 === "인력")
.map((h) => {
const q = String(h.수량 ?? "").trim();
return `${/^[\p{L}\p{N}_.]+$/u.test(q) ? q : `(${q})`} * ${String(h.요소)}`;
});
return terms.length ? `(${terms.join(" + ")})` : null;
}
/** 식 자리 모두 — 차례 = 중간 · 수량 · 요소 · 비율 · 결과 */
function placesOf(row: LogicRow): { part: LbPart; index: number; text: string; label: string }[] {
const ho = row.호표 ?? [];
return [
...(row.중간 ?? []).map((m, i) => ({
part: "중간" as const,
index: i,
text: m.식,
label: m.이름,
})),
...ho.map((h, i) => ({
part: "수량" as const,
index: i,
text: String(h.수량 ?? ""),
label: `${h.이름 ?? ""} ${tl("Qty")}`,
})),
...ho
.map((h, i) => ({ part: "요소" as const, index: i, text: h.요소, label: h.이름 ?? "" }))
.filter(
(p): p is { part: "요소"; index: number; text: string; label: string } =>
typeof p.text === "string" && /^\s*로직\s*\(/.test(p.text),
),
...(row.덧줄 ?? []).map((d, i) => ({
part: "비율" as const,
index: i,
text: d.식,
label: d.이름,
})),
...(row.결과
? [{ part: "결과" as const, index: 0, text: row.결과.식, label: tl("Result") }]
: []),
];
}
/** 로직 줄 → 블록 */
export function readLogic(
_row: LogicRow,
_meta: { key: string | null; version: string | null; isBase: boolean },
row: LogicRow,
meta: { key: string | null; version: string | null; isBase: boolean },
): LbLogic {
return notReady();
const doc = toDoc(row, meta);
const ps = personSumText(row);
const c: Ctx = {
row,
inputs: new Set((row.입력 ?? []).map((x) => x.이름)),
mids: new Set((row.중간 ?? []).map((x) => x.이름)),
finds: new Map(),
personSum: ps === null ? null : flat(ps),
bad: new Set(),
};
const lookups: LbLookup[] = [];
const blocked: string[] = [];
const places = placesOf(row);
for (const p of places) {
const calls = readFinds(p.text);
if (!calls) {
blocked.push(`${p.label} — ${tl("BadText")}`);
continue;
}
for (const call of calls) {
const key = flat(p.text.slice(call.start, call.end));
let id = c.finds.get(key);
if (!id) {
id = `L${lookups.length + 1}`;
c.finds.set(key, id);
const look: LbLookup = { id, table: call.table, col: call.col, conds: [], at: [] };
look.conds = call.args.map((a) => ({ col: a.key, from: argRef(a, c) }));
ARGS.set(look, call.args);
lookups.push(look);
}
lookups
.find((x) => x.id === id)!
.at.push({ part: p.part, index: p.index, start: call.start, end: call.end });
}
}
const calcs: LbCalc[] = [];
for (const p of places) {
if (p.part === "요소") {
const n = parse(p.text);
c.bad.clear();
if (n) check(n, c);
else c.bad.add(tl("BadText"));
for (const b of c.bad) blocked.push(`${p.label} — ${b}`);
continue;
}
c.bad.clear();
const name = p.part === "중간" ? p.label : null;
calcs.push(calcOf(p.text, { part: p.part, index: p.index }, name, c));
for (const b of c.bad) blocked.push(`${p.label} — ${b}`);
}
const resultCalc = calcs.find((x) => x.at.part === "결과");
return {
doc,
inputs: row.입력 ?? [],
lookups,
calcs,
result: resultCalc
? { kind: "값", unit: row.결과단위, calc: resultCalc }
: { kind: "금액", unit: row.결과단위 },
blocked: [...new Set(blocked)],
};
}
/** 블록 → 로직 줄 — 고친 조각 자리만 글 갈아끼우기 */
export function writeLogic(_lb: LbLogic): LogicRow {
return notReady();
/** 표 조회의 원래 조건 글 — 안 바꾼 조건은 이 글 그대로 */
const ARGS = new WeakMap<LbLookup, FindArg[]>();
function argRef(a: FindArg, c: Ctx): LbRef | LbExpr {
const n = parse(a.rhs);
return n && isSimple(n, c) ? refOf(n, c) : { k: "expr", text: a.rhs };
}
/* ── 쓰기 ───────────────────────────────────────────────────────────────── */
/** 블록 → 로직 줄 */
export function writeLogic(lb: LbLogic): LogicRow {
return toRow(lb.doc);
}
/** 단계 안내가 채울 빈 로직 */
export function newLogic(_name?: string, _per?: string): LbLogic {
return notReady();
export function newLogic(name = "", per = "㎡"): LbLogic {
return readLogic(emptyRow(name, per), { key: null, version: null, isBase: false });
}
const metaOf = (lb: LbLogic) => ({
key: lb.doc.key,
version: lb.doc.version,
isBase: lb.doc.isBase,
});
/** 한 자리 글 읽기 · 쓰기 */
function textAt(row: LogicRow, part: LbPart, index: number): string {
if (part === "중간") return row.중간[index].식;
if (part === "비율") return row.덧줄![index].식;
if (part === "결과") return row.결과!.식;
const h = row.호표![index];
return String(part === "수량" ? h.수량 : h.요소);
}
function putText(row: LogicRow, part: LbPart, index: number, text: string): void {
if (part === "중간") row.중간[index] = { ...row.중간[index], 식: text };
else if (part === "비율") row.덧줄![index] = { ...row.덧줄![index], 식: text };
else if (part === "결과") row.결과 = { ...row.결과!, 식: text };
else if (part === "수량") row.호표![index] = { ...row.호표![index], 수량: text };
else row.호표![index] = { ...row.호표![index], 요소: text };
}
/** 자리마다 [처음, 끝, 새 글] 을 뒤에서부터 갈아끼우고 다시 읽음 */
function splice(
lb: LbLogic,
edits: { part: LbPart; index: number; start: number; end: number; text: string }[],
): LbLogic {
const row = writeLogic(lb);
const groups = new Map<string, typeof edits>();
for (const e of edits)
groups.set(`${e.part}:${e.index}`, [...(groups.get(`${e.part}:${e.index}`) ?? []), e]);
for (const list of groups.values()) {
const { part, index } = list[0];
let text = textAt(row, part, index);
for (const e of [...list].sort((x, y) => y.start - x.start))
text = text.slice(0, e.start) + e.text + text.slice(e.end);
putText(row, part, index, text);
}
return readLogic(row, metaOf(lb));
}
const numText = (n: number): string => {
const t = String(Number(n.toPrecision(12)));
return t.includes("e") ? n.toFixed(12).replace(/\.?0+$/, "") : t;
};
/** 조각 → 글 — 안 바뀐 조각은 원래 글 */
function refText(ref: LbRef | LbExpr, lb: LbLogic): string {
const src = SRC.get(ref);
if (src && src.snap === JSON.stringify(ref)) return src.text;
switch (ref.k) {
case "expr":
return ref.text;
case "input":
case "calc":
return ref.name;
case "fixed":
return typeof ref.value === "number" ? numText(ref.value) : `'${ref.value}'`;
case "cost":
return ref.name === "사람줄합" ? (personSumText(writeLogic(lb)) ?? "0") : ref.name;
case "logic":
return `로직(${ref.key})${ref.field ? `.${ref.field}` : ""}`;
case "lookup": {
const look = lb.lookups.find((x) => x.id === ref.id);
return look ? findText(look, look.conds, look.table) : "0";
}
}
}
/** 사칙 사슬 칸 — 낮은 연산(+ -)이 든 조각은 괄호로 */
function wrap(text: string, strong: boolean): string {
const n = parse(text);
const low = n && n.t === "bin" && ["+", "-"].includes(n.op!);
const mid = n && n.t === "bin" && ["*", "/"].includes(n.op!);
return low || (strong && mid) ? `(${text})` : text;
}
function findText(look: LbLookup, conds: LbLookup["conds"], table: string): string {
const raw = ARGS.get(look) ?? [];
const args: FindArg[] = conds.map((cd) => {
const old = raw.find((a) => a.key === cd.col);
const same = old && look.conds.find((x) => x.col === cd.col)?.from === cd.from;
return {
key: cd.col,
rhs: same ? old.rhs : refText(cd.from, { lookups: [] } as unknown as LbLogic),
};
});
return writeFind(table, args, look.col);
}
/** 표 조회 조건 칸 하나 바꾸기 · from = null 은 조건 뺌(무관) */
export function setCond(
_lb: LbLogic,
_lookupId: string,
_col: string,
_from: LbRef | LbExpr | null,
lb: LbLogic,
lookupId: string,
col: string,
from: LbRef | LbExpr | null,
): LbLogic {
return notReady();
}
/** 계산 하나 바꾸기 — patch 의 틀 · 조각 · 조건으로 그 자리 글을 다시 짬 */
export function setCalc(_lb: LbLogic, _calc: LbCalc, _patch: Partial<LbCalc>): LbLogic {
return notReady();
const look = lb.lookups.find((x) => x.id === lookupId);
if (!look) return lb;
const has = look.conds.some((x) => x.col === col);
const conds =
from === null
? look.conds.filter((x) => x.col !== col)
: has
? look.conds.map((x) => (x.col === col ? { col, from } : x))
: [...look.conds, { col, from }];
const text = findText(look, conds, look.table);
return splice(
lb,
look.at.map((a) => ({ ...a, text })),
);
}
/** 표 조회가 보는 표 바꾸기(현장 사본 등) */
export function setLookupTable(_lb: LbLogic, _lookupId: string, _table: string): LbLogic {
return notReady();
export function setLookupTable(lb: LbLogic, lookupId: string, table: string): LbLogic {
const look = lb.lookups.find((x) => x.id === lookupId);
if (!look) return lb;
const text = findText(look, look.conds, table);
return splice(
lb,
look.at.map((a) => ({ ...a, text })),
);
}
/** 계산 하나 바꾸기 — patch 의 틀 · 조각 · 조건으로 그 자리 글을 다시 짬(patch.text 가 있으면 그 글) */
export function setCalc(lb: LbLogic, calc: LbCalc, patch: Partial<LbCalc>): LbLogic {
const next: LbCalc = { ...calc, ...patch };
const old = textAt(writeLogic(lb), calc.at.part, calc.at.index);
const text = patch.text !== undefined ? patch.text : buildText(next, lb, old);
return splice(lb, [{ ...calc.at, start: 0, end: old.length, text }]);
}
function buildText(c: LbCalc, lb: LbLogic, old: string): string {
const parts = c.parts.map((p) => refText(p, lb));
switch (c.kind) {
case "값":
return parts[0] ?? old;
case "할증":
return `${wrap(parts[0], false)} * (1 + ${wrap(parts[1], true)} / 100)`;
case "곱나눔":
case "더하기": {
const ops = c.ops ?? parts.slice(1).map(() => (c.kind === "곱나눔" ? "*" : "+"));
const strong = c.kind === "곱나눔";
return parts.reduce(
(acc, p, i) =>
i ? `${acc} ${ops[i - 1]} ${wrap(p, strong && ops[i - 1] === "/")}` : wrap(p, false),
"",
);
}
default:
return c.cond ? writeRead(c.cond) : old;
}
}
+114
View File
@@ -0,0 +1,114 @@
"""로직 만들기 판 시험 길(PLAN 35-2) — 고친 표로 시험 계산 · 표를 쓰는 로직.
POST /api/m01/lb/calc {row, inputs, tables: {표 키: 표 통째}} → `/calc` 와 같은 답
GET /api/m01/lb/table-users ?key=표 키 → {key, users: [{key, 이름, via}]}
계산은 `/calc`(`M01_MasterData_Store.calc`)와 같은 길 — 부를 때마다 새로 읽은 마스터 사본
(`cm.load`)에 로직 줄 · 표를 바꿔 끼워 셈. 전역 캐시 · 파일은 안 건드림(동시 요청에 안전) ·
표 고침은 저장하지 않음(정함 5). 권한 = `/api/m01` 과 같음(등록 자리 `main.py` 한 줄 ·
폴더째 지우면 그 줄도 지움).
"""
from __future__ import annotations
import json
import re
from typing import Any
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from M01_MasterData import M01_MasterData_Store as store
from M01_MasterData.M01_MasterData_Router import _num
cm = store.cm
router = APIRouter(prefix="/api/m01/lb", tags=["M01 LogicBuilder (시험)"])
class LbCalcBody(BaseModel):
row: dict[str, Any]
inputs: dict[str, Any] = {}
tables: dict[str, dict[str, Any]] = {}
def _put_tables(files: dict[str, dict], tables: dict[str, dict]) -> None:
"""표 키 → 고친 표 — 그 표가 든 파일 사본 자리에 바꿔 끼움 · 키는 못 바꿈 · 없는 표는 404."""
for key, table in tables.items():
for data in files.values():
if data.get("그룹") not in cm.mf.TABLE_GROUPS:
continue
items = data.get("표") or []
at = next((i for i, t in enumerate(items) if str(t.get("키")) == key), None)
if at is not None:
items[at] = {**store._dec(table), "키": key}
break
else:
raise store.StoreError(404, f"없는 표 「{key}」")
def calc(row: dict, inputs: dict, tables: dict[str, dict]) -> dict:
"""`Store.calc` 와 같은 셈 + 표 바꿔 끼우기 — 답 모양도 같음."""
given = {k: store._dec(v) for k, v in inputs.items()}
files = cm.load(folder=store.FOLDER) # 부를 때마다 새 사본 — 캐시 아님
_put_tables(files, tables)
key = store._swap(files, str(row.get("키") or ""), row, None)
try:
given = cm.mf.fill_empty(cm.mf.Master(files).logic(key), given)
result = cm.calc(files, key, given)
except (cm.mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
return {"ok": False, "reason": str(e) or type(e).__name__}
if "결과" in result:
return {"ok": True, "result": result["결과"], "middle": result["중간"]}
return {
"ok": True,
"lines": result["줄"],
"sums": {k: result[k] for k in cm.mf.BUNDLE},
"middle": result["중간"],
}
def table_users(key: str) -> dict:
"""표를 쓰는 로직 — 표 「용도.로직키」 + 로직 글에 표 키가 든 것(읽기 전용 캐시에서)."""
files, _ = store.loaded()
table = next(
(
t
for data in files.values()
if data.get("그룹") in cm.mf.TABLE_GROUPS
for t in data.get("표") or []
if str(t.get("키")) == key
),
None,
)
if table is None:
raise store.StoreError(404, f"없는 표 「{key}」")
listed = {str(k) for k in (table.get("용도") or {}).get("로직키") or []}
named = re.compile(rf"(?<![A-Z0-9]){re.escape(key)}(?![0-9])")
users = []
for data in files.values():
if data.get("그룹") != "로직":
continue
for row in data.get("줄") or []:
in_use = str(row.get("키")) in listed
in_text = bool(named.search(json.dumps(row, ensure_ascii=False, default=str)))
if in_use or in_text:
via = "둘" if in_use and in_text else "용도" if in_use else "글"
users.append({"key": row.get("키"), "이름": row.get("이름"), "via": via})
return {"key": key, "users": users}
def _call(fn, *args):
try:
return _num(fn(*args))
except store.StoreError as e:
raise HTTPException(status_code=e.status, detail=e.detail) from e
@router.post("/calc")
def post_calc(body: LbCalcBody) -> dict:
return _call(calc, body.row, body.inputs, body.tables)
@router.get("/table-users")
def get_table_users(key: str) -> dict:
return _call(table_users, key)
@@ -0,0 +1,203 @@
/* 로직 만들기 판 — 큰 창 · 시작 화면 · 위 띠 · 세 칸 */
/* 공용 모달(위 고정)을 화면 대부분으로 — 높이 고정 · 칸마다 따로 굴림 */
.ui-modal.m01-lb {
--modal-max: 92vh;
width: min(1680px, 98vw);
height: var(--modal-max);
font-size: var(--text-body);
}
.m01-lb .ui-modal__body {
flex: 1;
min-height: 0;
gap: var(--spacing-12);
}
.m01-lb__muted {
margin: 0;
color: var(--color-text-muted);
font-size: var(--text-body-sm);
}
.m01-lb__note {
margin: 0;
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
white-space: pre-line;
}
.m01-lb__note:empty {
display: none;
}
.m01-lb__big {
min-height: 56px;
padding: 0 var(--spacing-24);
font-size: var(--text-body);
}
.m01-lb .is-pending {
opacity: 0.45;
}
/* 시작 화면 */
.m01-lb__start {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
gap: var(--spacing-24);
min-height: 0;
overflow: auto;
}
.m01-lb__start-col {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
}
.m01-lb__start-col h4 {
margin: var(--spacing-8) 0 0;
}
.m01-lb__list {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.m01-lb__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-lb__item:hover {
border-color: var(--color-accent);
}
.m01-lb__item-name {
flex: 1;
min-width: 0;
}
.m01-lb__item-unit {
color: var(--color-text-muted);
font-size: var(--text-body-sm);
white-space: nowrap;
}
/* 위 띠 */
.m01-lb__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-lb__name {
flex: 1 1 280px;
}
.m01-lb__applied {
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
white-space: nowrap;
}
.m01-lb__actions {
display: flex;
gap: var(--spacing-8);
margin-left: auto;
}
/* 세 칸 — 원문 · 산출근거 · 결과 */
.m01-lb__cols {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr) minmax(0, 1fr);
gap: var(--spacing-12);
}
.m01-lb__col {
display: flex;
flex-direction: column;
min-height: 0;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
}
.m01-lb__col-head {
display: flex;
align-items: center;
gap: var(--spacing-8);
padding: var(--spacing-8) var(--spacing-12);
border-bottom: 1px solid var(--color-border);
}
.m01-lb__col-head h4 {
flex: 1;
margin: 0;
}
.m01-lb__col-body {
flex: 1;
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12);
}
.m01-lb__col-body h5 {
margin: var(--spacing-8) 0 0;
}
.m01-lb__total {
font-size: var(--text-subheading);
font-weight: 700;
}
.m01-lb__examples {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-8);
border-radius: var(--radius-lg);
background: var(--color-surface);
}
.m01-lb__example-list {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
}
.m01-lb__example {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
font-size: var(--text-body-sm);
}
/* 좁은 화면 — 세 칸을 위아래로 */
@media (max-width: 1100px) {
.m01-lb__cols {
grid-template-columns: minmax(0, 1fr);
overflow: auto;
}
}
+505
View File
@@ -0,0 +1,505 @@
/* =============================================================================
* M01_LB_Entry.ts
* 로직 만들기 판(시험) — 들어가는 단추 · 큰 창 · 시작 화면 · 세 칸 틀(원문 · 산출근거 · 결과) · 위 띠
* 모양은 `docs/raw/M01_쉬운단가/로직판_A_정함.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄).
* 1걸음 = 틀 — 원문 · 산출근거 칸은 자리만 · 결과 칸은 EasyCalc 계산 · 일위대가표로 바로 보임.
* 2걸음에 Blocks · Api · Source · Steps · Grid · Wizard 를 이음.
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import {
createButton,
createInputField,
createTag,
el,
showConfirmDialog,
showToast,
} from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import type { CalcAnswer, LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api";
import { formatMoney } from "../M01_MasterData_UI_Logic_Money";
import { formatNumber } from "../M01_MasterData_UI_Logic_Edit";
import {
calcLater,
copyEasy,
listEasy,
openEasy,
plainError,
type EasyListItem,
} from "../EasyCalc/M01_EasyCalc_Api";
import { renderSheet } from "../EasyCalc/M01_EasyCalc_Sheet";
import { lbCalcLater, type LbTables } from "./M01_LB_Api";
import { readLogic, type LbLogic } from "./M01_LB_Blocks";
import "./M01_LB_Entry.css";
const TEXT = {
Open: ["로직 만들기(시험)", "Logic builder (trial)"],
Title: ["로직 만들기", "Logic builder"],
CloseWindow: ["창 닫기", "Close window"],
Close: ["닫기", "Close"],
Loading: ["불러오는 중", "Loading"],
LoadFailed: ["목록을 못 불러옴 — {v}", "Could not load the list — {v}"],
Start_Trial: ["먼저 볼 시험 로직", "Trial logics to start with"],
Start_Search: ["이름으로 찾기 — 예: 돌쌓기", "Search by name — e.g. stone wall"],
Start_Found: ["찾은 것", "Found"],
Start_Count: ["{n} 개 중 {m} 개 보임 — 더 좁혀 찾기", "Showing {m} of {n} — narrow the search"],
Start_None: ["찾은 것 없음", "Nothing found"],
Start_Field: ["현장 일위대가", "Field unit prices"],
Start_FieldNone: ["아직 없음", "None yet"],
Start_Wizard: ["단계 안내로 새로 만들기", "Create step by step"],
Standard: ["표준", "Standard"],
Field: ["현장", "Field"],
Per: ["{u} 당", "per {u}"],
Name: ["이름", "Name"],
Applied: ["적용 품셈", "Applied standard"],
Undo: ["실행 취소", "Undo"],
Save: ["저장", "Save"],
CopyEdit: ["복사 편집", "Copy and edit"],
Copying: ["현장 일위대가로 복사하는 중", "Copying to a field unit price"],
Copied: ["현장 일위대가로 복사했음 — 이제 고칠 수 있음", "Copied — you can edit it now"],
Unsaved: ["저장 안 함", "Not saved"],
StandardNote: [
"표준은 읽기만 — 고치려면 「복사 편집」으로 현장 일위대가를 만듦",
"Standard items are read-only — use “Copy and edit” to make a field copy",
],
Col_Source: ["원문", "Source"],
Col_Basis: ["산출근거", "Basis"],
Col_Result: ["결과", "Result"],
View_Sentence: ["문장", "Sentences"],
View_Grid: ["격자", "Grid"],
Money: ["{u} 만드는 데 {v} 원", "{v} won to make {u}"],
Value: ["결과 {v} {u}", "Result {v} {u}"],
Examples: ["예시값", "Example values"],
Examples_Note: [
"예시값은 계산을 보려는 값 — 저장 안 됨",
"Example values are only for trying — not saved",
],
Pick: ["선택", "Choose"],
Sheet: ["일위대가표", "Unit-cost sheet"],
NotYet: ["준비 중", "Coming soon"],
Leave_Ask: [
"저장 안 한 것이 있음 — 저장하지 않고 닫을까요?",
"There are unsaved changes — close without saving?",
],
Leave_Ok: ["저장 안 하고 닫기", "Close without saving"],
} as const;
type TextKey = keyof typeof TEXT;
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,
);
/** 시작 화면에 먼저 보이는 시험 로직 여섯(정함 10) */
const TRIAL_KEYS = ["GF000208", "GF000219", "GF000025", "GF000249", "GF000133", "GC000051"];
/** 찾은 것 목록에 한 번에 보이는 줄 수 */
const SHOWN = 50;
/** 「원/㎡」 → 「㎡」 */
const unitOf = (resultUnit: string): string => resultUnit.replace(/^원\//, "");
/** 「㎡」 → 「1㎡」 · 「100㎡」 은 그대로 */
const amountOf = (resultUnit: string): string => {
const unit = unitOf(resultUnit);
return /^\d/.test(unit) ? unit : `1${unit}`;
};
const isMoney = (resultUnit: string): boolean => resultUnit.startsWith("원/");
/** 고치는 화면이 쥔 것 — row = 저장할 로직 줄 · lb = 블록(못 읽으면 null) · tables = 고친 표 사본(저장 안 함) */
interface Paper {
key: string;
version: string;
isBase: boolean;
row: LogicRow;
trial: Record<string, unknown>;
lb: LbLogic | null;
tables: LbTables;
}
/* ponytail: Blocks(sub7) · LB Api(sub4) 속이 「준비 중」 인 동안만 대신함 — 채워지면 두 catch 를 지움 */
const blocksOf = (row: LogicRow, key: string, version: string, isBase: boolean): LbLogic | null => {
try {
return readLogic(row, { key, version, isBase });
} catch {
return null;
}
};
const calcWith = (
p: Paper,
onDone: (ans: CalcAnswer | null, plain: string | null) => void,
): void => {
try {
lbCalcLater(p.row, p.trial, p.tables, onDone);
} catch {
calcLater(p.row, p.trial, onDone);
}
};
/** 「쉬운 단가산출(시험)」 옆에 놓는 단추 — 누르면 큰 창 */
export function createLogicBuilderButton(): HTMLButtonElement {
return createButton({ label: te("Open"), variant: "filled", onClick: openLogicBuilder });
}
function openLogicBuilder(): void {
let body: HTMLElement;
let paper: Paper | null = null;
let dirty = false;
let logics: EasyListItem[] | null = null;
let query = "";
let view: "sentence" | "grid" = "sentence";
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 row = el("button", {
className: "m01-lb__item",
attrs: { type: "button", title: item.where },
children: [
el("span", { className: "m01-lb__item-name", text: item.name }),
createTag(item.isBase ? te("Standard") : te("Field"), item.isBase ? "neutral" : "accent"),
el("span", {
className: "m01-lb__item-unit",
text: isMoney(item.resultUnit)
? te("Per", { u: amountOf(item.resultUnit) })
: item.resultUnit,
}),
],
});
row.addEventListener("click", () => void openPaper(item.key));
return row;
};
const showStart = async (): Promise<void> => {
paper = null;
dirty = false;
const status = el("p", { className: "m01-lb__muted", text: te("Loading") });
const trialHost = el("div", { className: "m01-lb__list" });
const fieldHost = el("div", { className: "m01-lb__list" });
const foundHost = el("div", { className: "m01-lb__list" });
const search = createInputField({
type: "search",
placeholder: te("Start_Search"),
value: query,
onInput: (v) => {
query = v;
drawFound();
},
});
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)))
: [];
status.textContent = !words.length
? ""
: !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-lb__start",
children: [
el("div", {
className: "m01-lb__start-col",
children: [
el("h4", { text: te("Start_Trial") }),
trialHost,
search.root,
el("h4", { text: te("Start_Found") }),
status,
foundHost,
],
}),
el("div", {
className: "m01-lb__start-col",
children: [
bigButton(te("Start_Wizard"), notYet),
el("h4", { text: te("Start_Field") }),
fieldHost,
],
}),
],
}),
);
search.input.focus();
try {
logics ??= await listEasy("");
} catch (error) {
status.textContent = te("LoadFailed", { v: plainError(error) });
return;
}
const byKey = new Map(logics.map((x) => [x.key, x]));
trialHost.replaceChildren(
...TRIAL_KEYS.flatMap((k) => {
const item = byKey.get(k);
return item ? [listRow(item)] : [];
}),
);
const field = logics.filter((x) => !x.isBase);
fieldHost.replaceChildren(
...(field.length
? field.map(listRow)
: [el("p", { className: "m01-lb__muted", text: te("Start_FieldNone") })]),
);
drawFound();
};
/* ---------- 세 칸 화면 ---------- */
const openPaper = async (key: string): Promise<void> => {
body.replaceChildren(el("p", { className: "m01-lb__muted", text: te("Loading") }));
try {
const opened = await openEasy(key);
const lb = blocksOf(opened.row, key, opened.version, opened.isBase);
paper = { key, ...opened, lb, tables: {} };
dirty = false;
showPaper();
} catch (error) {
showToast(plainError(error), "error");
void showStart();
}
};
const showPaper = (): void => {
const p = paper;
if (!p) return;
let ans: CalcAnswer | null = null;
let busy = false;
const unsaved = createTag(te("Unsaved"), "warning");
unsaved.hidden = !dirty;
const total = el("div", { className: "m01-lb__total" });
const note = el("p", { className: "m01-lb__note", attrs: { role: "status" } });
const sheetHost = el("div");
const examplesHost = el("div", { className: "m01-lb__examples" });
const sourceHost = el("div", { className: "m01-lb__slot" });
const basisHost = el("div", { className: "m01-lb__slot" });
const name = createInputField({ label: te("Name"), value: p.row.이름 });
name.input.disabled = true; // ponytail: 이름 고치기는 2걸음(Blocks · writeLogic) 에 이음
name.root.classList.add("m01-lb__name");
const drawResult = (pending: boolean): void => {
const unit = p.row.결과단위 ?? "";
const value = ans?.ok ? (ans.result ?? ans.sums?.["계"]) : undefined;
total.textContent = isMoney(unit)
? te("Money", { u: amountOf(unit), v: value === undefined ? "—" : formatMoney(value) })
: te("Value", { u: unit, v: value === undefined ? "—" : formatNumber(value) });
total.classList.toggle("is-pending", pending);
sheetHost.classList.toggle("is-pending", pending);
renderSheet(sheetHost, { name: p.row.이름, resultUnit: unit }, ans);
};
const calc = (): void => {
drawResult(true);
calcWith(p, (next, plain) => {
ans = next;
note.textContent = plain ?? "";
drawResult(false);
});
};
const doCopy = async (): Promise<void> => {
if (busy) return;
busy = true;
note.textContent = te("Copying");
try {
const saved = await copyEasy(p.key);
paper = {
key: saved.key,
version: saved.version,
isBase: false,
row: saved.row,
trial: p.trial,
lb: blocksOf(saved.row, saved.key, saved.version, false),
tables: {},
};
logics = null;
showToast(te("Copied"), "success");
showPaper();
} catch (error) {
note.textContent = plainError(error);
} finally {
busy = false;
}
};
const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: notYet });
undo.disabled = true;
const actions = el("div", {
className: "m01-lb__actions",
children: [
...(p.isBase
? [createButton({ label: te("CopyEdit"), onClick: () => void doCopy() })]
: [undo, createButton({ label: te("Save"), onClick: notYet })]),
createButton({
label: te("Close"),
variant: "ghost",
onClick: () =>
void (async () => {
if (await mayLeave()) void showStart();
})(),
}),
],
});
const viewButton = (which: "sentence" | "grid", label: string): HTMLButtonElement => {
const b = createButton({
label,
variant: view === which ? "filled" : "ghost",
size: "sm",
onClick: () => {
view = which;
showPaper();
},
});
b.setAttribute("aria-pressed", String(view === which));
return b;
};
sourceHost.replaceChildren(el("p", { className: "m01-lb__muted", text: te("NotYet") }));
basisHost.replaceChildren(el("p", { className: "m01-lb__muted", text: te("NotYet") }));
drawExamples(examplesHost, p.row.입력 ?? [], p.trial, (next) => {
p.trial = next;
calc();
});
body.replaceChildren(
el("div", {
className: "m01-lb__strip",
children: [
name.root,
createTag(p.isBase ? te("Standard") : te("Field"), p.isBase ? "neutral" : "accent"),
el("span", {
className: "m01-lb__applied",
text: `${te("Applied")} · ${p.row.출처 || "—"}`,
}),
unsaved,
actions,
],
}),
note,
...(p.isBase ? [el("p", { className: "m01-lb__muted", text: te("StandardNote") })] : []),
el("div", {
className: "m01-lb__cols",
children: [
column(te("Col_Source"), [sourceHost]),
column(
te("Col_Basis"),
[basisHost],
[viewButton("sentence", te("View_Sentence")), viewButton("grid", te("View_Grid"))],
),
column(te("Col_Result"), [
total,
examplesHost,
el("h5", { text: te("Sheet") }),
sheetHost,
]),
],
}),
);
calc();
};
// 화면 함수들을 다 만든 뒤에 엶 — mount 가 바로 showStart 를 부름
openModal({
title: te("Title"),
closeLabel: te("CloseWindow"),
dialogClass: "m01-lb",
beforeClose: mayLeave,
mount: (host) => {
body = host;
void showStart();
},
});
}
/** 세 칸 가운데 하나 — 머리(제목 · 단추) + 몸 */
function column(title: string, children: HTMLElement[], tools: HTMLElement[] = []): HTMLElement {
return el("section", {
className: "m01-lb__col",
children: [
el("div", {
className: "m01-lb__col-head",
children: [el("h4", { text: title }), ...tools],
}),
el("div", { className: "m01-lb__col-body", children }),
],
});
}
/** 예시값 띠 — 입력값마다 선택 또는 숫자 · 바꾸면 onChange(새 예시값) · 저장 안 함 */
function drawExamples(
host: HTMLElement,
inputs: LogicInput[],
trial: Record<string, unknown>,
onChange: (trial: Record<string, unknown>) => void,
): void {
host.hidden = !inputs.length;
if (!inputs.length) return void host.replaceChildren();
const state = { ...trial };
const field = (spec: LogicInput): HTMLElement => {
const label = spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름;
let box: HTMLInputElement | HTMLSelectElement;
if (spec.고르기?.length) {
box = el("select", {
className: "ui-select ui-select--sm",
children: [
el("option", { attrs: { value: "" }, text: te("Pick") }),
...spec.고르기.map((v) => el("option", { attrs: { value: String(v) }, text: String(v) })),
],
});
box.value = state[spec.이름] === undefined ? "" : String(state[spec.이름]);
box.addEventListener("change", () => {
const picked = spec.고르기?.find((v) => String(v) === box.value);
if (picked === undefined) delete state[spec.이름];
else state[spec.이름] = picked;
onChange({ ...state });
});
} else {
box = el("input", {
className: "ui-input ui-input--sm",
attrs: {
type: "number",
...(spec.범위 ? { min: String(spec.범위[0]), max: String(spec.범위[1]) } : {}),
},
});
box.value = state[spec.이름] == null ? "" : String(state[spec.이름]);
box.addEventListener("change", () => {
if (box.value === "") delete state[spec.이름];
else state[spec.이름] = Number(box.value);
onChange({ ...state });
});
}
box.setAttribute("aria-label", spec.이름);
return el("label", {
className: "m01-lb__example",
children: [el("span", { text: label }), box],
});
};
host.replaceChildren(
el("strong", { text: te("Examples") }),
el("div", { className: "m01-lb__example-list", children: inputs.map(field) }),
el("p", { className: "m01-lb__muted", text: te("Examples_Note") }),
);
}
function bigButton(label: string, onClick: () => void): HTMLButtonElement {
const button = createButton({ label, variant: "filled", onClick });
button.classList.add("m01-lb__big");
return button;
}
@@ -41,6 +41,8 @@ import { buildNewLogic } from "./M01_MasterData_UI_LogicLab_New";
import { tn } from "./M01_MasterData_UI_LogicLab_New_Text";
// 쉬운 단가산출 시험 길 — 폴더째 지우면 이 두 줄도 지움
import { createEasyCalcButton } from "./EasyCalc/M01_EasyCalc_Entry";
// 로직 만들기 시험 길 — 폴더째 지우면 이 두 줄도 지움
import { createLogicBuilderButton } from "./LogicBuilder/M01_LB_Entry";
import "./M01_MasterData_UI_Logic_Style.css";
/** 저장 안 한 로직 하나 — origKey null = 새 로직 · row null = 지움 */
@@ -402,6 +404,8 @@ export async function mountM01LogicLab(
createButton({ label: tn("Open"), variant: "filled", onClick: startNew }),
// 쉬운 단가산출 시험 길 — 폴더째 지우면 이 두 줄도 지움
createEasyCalcButton(),
// 로직 만들기 시험 길 — 폴더째 지우면 이 두 줄도 지움
createLogicBuilderButton(),
createButton({ label: tx("Bar_Delete"), variant: "danger", onClick: () => void onDelete() }),
discardButton,
saveButton,
+5
View File
@@ -638,6 +638,11 @@ app.include_router(b07_frame_router, dependencies=protected_with_company)
# 마스터 데이터 — 회사·프로젝트가 아니라 시스템 관리자만 본다.
system_admin_only = [Depends(verify_session), Depends(require_system_admin)]
app.include_router(m01_master_data_router, dependencies=system_admin_only)
# 로직 만들기 시험 길 — 폴더째 지우면 이 줄도 지움
app.include_router(
__import__("M01_MasterData.LogicBuilder.M01_LB_Calc", fromlist=["router"]).router,
dependencies=system_admin_only,
)
# 마스터 템플릿 — 시스템 층은 시스템 관리자만 · 층(회사·개인·프로젝트)·도면 길은 로그인만(권한은 길 안에서).
app.include_router(m02_master_template_router, dependencies=system_admin_only)
app.include_router(m02_layers_router, dependencies=protected)
+309
View File
@@ -0,0 +1,309 @@
"""M01 로직 만들기 판 Blocks 시험(35-2a) — Node 로 `M01_LB_Blocks.ts` 를 바로 돌림 · 화면 조작은 ORCA.
· 견본 로직 줄 여섯(정함 10)은 이 시험 안에 둠 — 진짜 마스터에 기대지 않음
· 왕복(readLogic → writeLogic 이 원래 줄과 같음) · 블록 내용 · 조건 바꾸기 · 표 바꾸기 · 계산 바꾸기 · 빈 로직 · 못 읽는 꼴
· EasyCalc Model · Table · Cond 를 그대로 부름 — 화면 모듈(@ui · 서버 길)은 시험 고리가 빈 것으로 갈음
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
BLOCKS = ROOT / "M01_MasterData" / "LogicBuilder" / "M01_LB_Blocks.ts"
pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
# ── Node 고리 — 허락한 파일만 실제로 읽고 나머지 import 는 빈 것으로 ────────────
HOOKS = r"""
import { readFileSync, existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { stripTypeScriptTypes } from "node:module";
const ALLOW = /(EasyCalc_(Model|Table|Cond)|M01_LB_Blocks)(\.ts)?$/;
export async function resolve(spec, ctx, next) {
if (spec.startsWith(".") && ctx.parentURL && !spec.endsWith(".ts")) {
const u = new URL(spec + ".ts", ctx.parentURL);
if (existsSync(fileURLToPath(u))) return { url: u.href, shortCircuit: true };
}
return next(spec, ctx);
}
export async function load(url, ctx, next) {
if (!url.endsWith(".ts")) return next(url, ctx);
let src = readFileSync(fileURLToPath(url), "utf8");
src = src.replace(/import\s+"[^"]+\.css";/g, "");
src = src.replace(/import\.meta\.glob(?:<[\s\S]*?>)?\(\s*"[^"]*"\s*\)/g, "({})");
src = src.replace(/import\s*\{([^}]*)\}\s*from\s*"([^"]+)";/g, (m, names, from) => {
if (from.startsWith(".") && ALLOW.test(from)) return m;
const list = names.split(",").map((x) => x.trim()).filter((x) => x && !x.startsWith("type "));
return list.length ? `const { ${list.join(", ")} } = globalThis.__stub;` : "";
});
return { format: "module", source: stripTypeScriptTypes(src, { mode: "transform" }), shortCircuit: true };
}
"""
STUB = (
"globalThis.__stub = new Proxy({}, { get: (_, k) => k === 'currentLanguageIndex' ? 0 : "
"k === 'API_BASE_URL' ? '/api' : () => undefined });\n"
)
def _run(body: str, *args: object) -> object:
"""`B` = Blocks 모듈 · `A` = 넘긴 인자 · `out(x)` 로 답"""
script = (
"import { register } from 'node:module';\n"
f"register('data:text/javascript,' + encodeURIComponent({json.dumps(HOOKS)}));\n"
+ STUB
+ f"const B = await import({json.dumps(BLOCKS.as_uri())});\n"
"const A = JSON.parse(process.argv[1]);\n"
"const out = (x) => console.log(JSON.stringify(x));\n"
"const META = { key: 'GX900001', version: 'v1', isBase: false };\n" + body
)
result = subprocess.run(
["node", "--no-warnings", "--input-type=module", "-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)
# ── 견본 줄 여섯(정함 10) ──────────────────────────────────────────────────
H = {"원문번호": "", "출처": "자체", "끝수": None}
GF000208 = {
**H, "키": "GF000208", "이름": "암거이음받침 채움재(T=20㎜)", "결과단위": "원/㎡", "입력": [], "중간": [],
"호표": [{"종류": "재료", "요소": {"구분": "도료·방수·접착", "상세구분": "방수·실링", "검색어": "JOINT FILLER"},
"이름": "JOINT FILLER(T=20㎜)", "단위": "㎡", "수량": "1", "비목": "재료비"}],
"덧줄": [],
} # fmt: skip
GF000219 = {
**H, "키": "GF000219", "이름": "돌쌓기 메쌓기(인력)", "결과단위": "원/㎡",
"입력": [{"이름": "뒷길이", "단위": "㎝", "고르기": [25, 30, 35]}, {"이름": "돌", "고르기": ["깬잡석", "깬돌"]},
{"이름": "쌓기", "고르기": ["골쌓기", "켜쌓기"]}, {"이름": "높이", "단위": "m"},
{"이름": "초과증가율", "단위": "%", "범위": [80, 100]}],
"중간": [{"이름": "증가율", "식": "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=높이).증가율, 초과증가율))"}],
"호표": [
{"종류": "인력", "요소": "LB000033", "이름": "석공", "단위": "인",
"수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 증가율 / 100)", "비목": "노무비"},
{"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인",
"수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)", "비목": "노무비"},
],
"덧줄": [],
} # fmt: skip
GF000025 = {
**H, "키": "GF000025", "이름": "떼붙임(재배잔디)", "결과단위": "원/100㎡",
"입력": [{"이름": "품", "단위": "인/100㎡"}, {"이름": "비탈구배", "고르기": ["해당 없음", "평균 비탈구배 20˚~45˚ 이하"]}],
"중간": [],
"호표": [{"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인",
"수량": "품 * (1 + 만약(비탈구배 == '해당 없음', 0, 찾기(QF000099, 구분='비탈구배 해당 시 품 할증').값) / 100)",
"비목": "노무비"}],
"덧줄": [],
} # fmt: skip
GF000249 = {
**H, "키": "GF000249", "이름": "다짐(플레이트콤펙터 1.5ton)", "결과단위": "원/㎥",
"입력": [{"이름": "지역", "고르기": ["전국평균", "서울"]}],
"중간": [{"이름": "작업량", "식": "로직(GF000114)"}],
"호표": [{"종류": "로직", "요소": "로직(GC000272, 기계='1730-0015', 지역=지역)", "이름": "플레이트 콤팩터 1.5ton",
"단위": "시간", "수량": "1 / 작업량"}],
"덧줄": [],
} # fmt: skip
GF000133 = {
**H, "키": "GF000133", "이름": "골재운반 덤프트럭 시간당 작업량", "결과단위": "㎥/시간",
"입력": [{"이름": "qt", "단위": "㎥"}, {"이름": "f", "단위": ""}, {"이름": "E", "단위": ""}, {"이름": "Cm", "단위": "분"}],
"중간": [], "결과": {"식": "반올림(60 * qt * f * E / Cm, 3)", "출처": "건설품셈 공통 8-1-4 주①"},
"끝수": "소수 3자리 사사오입(건설품셈 공통 8-1-4 주①)",
} # fmt: skip
GC000051 = {
**H, "키": "GC000051", "이름": "보행자 안전통로 설치 및 해체", "결과단위": "원/m", "입력": [],
"중간": [{"이름": "비계공", "식": "찾기(QC000115, 구분='비계공').수량"}],
"호표": [{"종류": "인력", "요소": "LB000006", "이름": "비계공", "단위": "인", "수량": "비계공", "비목": "노무비"}],
"덧줄": [{"이름": "공구손료 및 경장비",
"식": "(비계공 * LB000006) * 찾기(QC000116, 구분='공구손료 및 경장비 기계경비(인력품 기준)').값 / 100",
"비목": "경비", "출처": "건설품셈 공통 2-9-3"}],
} # fmt: skip
SIX = [GF000208, GF000219, GF000025, GF000249, GF000133, GC000051]
def _lb(row: dict) -> dict:
return _run("const lb = B.readLogic(A[0], META); out({ ...lb, doc: undefined });", row)
# ── 왕복 ───────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("row", SIX, ids=[r["키"] for r in SIX])
def test_왕복_같음_다_읽힘(row: dict) -> None:
got = _run("const lb = B.readLogic(A[0], META); out([B.writeLogic(lb), lb.blocked]);", row)
assert got == [row, []]
# ── 블록 내용 ───────────────────────────────────────────────────────────────
def test_메쌓기_표조회_조건목록_할증() -> None:
lb = _lb(GF000219)
assert [(x["id"], x["table"], x["col"]) for x in lb["lookups"]] == [
("L1", "QF000422", "증가율"),
("L2", "QF000421", "석공"),
("L3", "QF000421", "보통인부"),
]
assert lb["lookups"][1]["conds"] == [
{"col": "뒷길이", "from": {"k": "input", "name": "뒷길이"}},
{"col": "돌", "from": {"k": "input", "name": "돌"}},
{"col": "쌓기", "from": {"k": "input", "name": "쌓기"}},
]
assert lb["lookups"][1]["at"] == [{"part": "수량", "index": 0, "start": 0, "end": 36}]
mid, stone, worker = lb["calcs"]
assert (mid["name"], mid["kind"], mid["at"]) == (
"증가율",
"조건목록",
{"part": "중간", "index": 0},
)
assert mid["cond"]["blocks"][0]["shape"]["type"] == "if"
assert (stone["kind"], stone["parts"]) == (
"할증",
[{"k": "lookup", "id": "L2"}, {"k": "calc", "name": "증가율"}],
)
assert worker["parts"][0] == {"k": "lookup", "id": "L3"}
assert lb["result"] == {"kind": "금액", "unit": "원/㎡"}
assert [x["이름"] for x in lb["inputs"]] == ["뒷길이", "돌", "쌓기", "높이", "초과증가율"]
def test_떼붙임_다짐_덤프_안전통로() -> None:
turf = _lb(GF000025)
assert turf["lookups"][0]["conds"] == [
{"col": "구분", "from": {"k": "fixed", "value": "비탈구배 해당 시 품 할증"}}
]
c = turf["calcs"][0]
assert c["kind"] == "할증" and c["parts"][0] == {"k": "input", "name": "품"}
assert c["parts"][1]["k"] == "expr" and c["cond"]["blocks"][0]["shape"]["type"] == "if"
tamp = _lb(GF000249)
work, qty = tamp["calcs"]
assert work["kind"] == "값" and work["parts"] == [
{"k": "logic", "key": "GF000114", "field": None}
]
assert (qty["kind"], qty["ops"]) == ("곱나눔", ["/"])
assert qty["parts"] == [{"k": "fixed", "value": 1}, {"k": "calc", "name": "작업량"}]
dump = _lb(GF000133)
assert dump["result"]["kind"] == "값" and dump["result"]["unit"] == "㎥/시간"
assert dump["result"]["calc"]["kind"] == "올림" and dump["calcs"] == [dump["result"]["calc"]]
walk = _lb(GC000051)
rate = walk["calcs"][-1]
assert rate["at"] == {"part": "비율", "index": 0} and rate["ops"] == ["*", "/"]
assert rate["parts"] == [
{"k": "cost", "name": "사람줄합"},
{"k": "lookup", "id": "L2"},
{"k": "fixed", "value": 100},
]
fill = _lb(GF000208)
assert fill["lookups"] == [] and fill["calcs"][0]["parts"] == [{"k": "fixed", "value": 1}]
# ── 고치기 ─────────────────────────────────────────────────────────────────
def _ho(row: dict) -> list[str]:
return [h["수량"] for h in row["호표"]]
def test_조건_바꾸기_빼기_표_바꾸기() -> None:
got = _run(
"let lb = B.readLogic(A[0], META);\n"
"const a = B.writeLogic(B.setCond(lb, 'L2', '돌', { k: 'fixed', value: '깬돌' }));\n"
"const b = B.writeLogic(B.setCond(lb, 'L2', '쌓기', null));\n"
"const c = B.writeLogic(B.setCond(lb, 'L1', '높이', { k: 'fixed', value: 5 }));\n"
"const d = B.writeLogic(B.setLookupTable(lb, 'L3', 'QX000001'));\n"
"out([a, b, c, d]);",
GF000219,
)
a, b, c, d = got
assert _ho(a) == [
"찾기(QF000421, 뒷길이=뒷길이, 돌='깬돌', 쌓기=쌓기).석공 * (1 + 증가율 / 100)",
GF000219["호표"][1]["수량"],
]
assert _ho(b)[0] == "찾기(QF000421, 뒷길이=뒷길이, 돌=돌).석공 * (1 + 증가율 / 100)"
assert (
c["중간"][0]["식"]
== "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=5).증가율, 초과증가율))"
)
assert (
_ho(d)[1] == "찾기(QX000001, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)"
)
assert _ho(d)[0] == GF000219["호표"][0]["수량"]
def test_계산_바꾸기() -> None:
got = _run(
"const lb = B.readLogic(A[0], META);\n"
"const [mid, stone] = lb.calcs;\n"
"const r1 = B.writeLogic(B.setCalc(lb, stone, { parts: [stone.parts[0], { k: 'fixed', value: 10 }] }));\n"
"const cond = structuredClone(mid.cond); cond.blocks[0].shape.otherwise = { kind: 'num', value: 90 };\n"
"const r2 = B.writeLogic(B.setCalc(lb, mid, { cond }));\n"
"const r3 = B.writeLogic(B.setCalc(lb, stone, { kind: '값', parts: [{ k: 'fixed', value: 0.3 }] }));\n"
"const r4 = B.writeLogic(B.setCalc(lb, stone, { text: '0.25 * 2' }));\n"
"const after = B.readLogic(r1, META);\n"
"out([r1, r2, r3, r4, after.calcs[1].parts, after.lookups.map((x) => x.id)]);",
GF000219,
)
r1, r2, r3, r4, parts, ids = got
assert _ho(r1)[0] == "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 10 / 100)"
assert _ho(r1)[1] == GF000219["호표"][1]["수량"]
assert (
r2["중간"][0]["식"]
== "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=높이).증가율, 90))"
)
assert _ho(r3)[0] == "0.3" and _ho(r4)[0] == "0.25 * 2"
assert parts == [{"k": "lookup", "id": "L2"}, {"k": "fixed", "value": 10}]
assert ids == ["L1", "L2", "L3"]
def test_곱나눔_더하기_괄호() -> None:
got = _run(
"const lb = B.readLogic(A[0], META); const q = lb.calcs[1];\n"
"const r1 = B.writeLogic(B.setCalc(lb, q, { parts: [{ k: 'fixed', value: 2 }, q.parts[1]] }));\n"
"const r2 = B.writeLogic(B.setCalc(lb, q, { parts: [q.parts[0], { k: 'expr', text: '작업량 + 1' }] }));\n"
"const r3 = B.writeLogic(B.setCalc(lb, q, { kind: '더하기', ops: ['-'], parts: [{ k: 'fixed', value: 3 },"
" { k: 'expr', text: '작업량 - 1' }] }));\n"
"out([r1.호표[0].수량, r2.호표[0].수량, r3.호표[0].수량, r1.중간]);",
GF000249,
)
assert got == ["2 / 작업량", "1 / (작업량 + 1)", "3 - (작업량 - 1)", GF000249["중간"]]
# ── 빈 로직 · 못 읽는 꼴 ────────────────────────────────────────────────────
def test_빈_로직() -> None:
got = _run(
"const lb = B.newLogic('새 일', 'm'); out([B.writeLogic(lb), lb.lookups, lb.calcs, lb.result, lb.blocked]);"
)
row, looks, calcs, result, blocked = got
assert row["이름"] == "새 일" and row["결과단위"] == "원/m" and row["출처"] == "자체"
assert (looks, calcs, result, blocked) == ([], [], {"kind": "금액", "unit": "원/m"}, [])
def test_못_읽는_꼴은_쉬운말_왕복은_같음() -> None:
row = {
**GF000219,
"중간": [
{
"이름": "증가율",
"식": "만약(높이 > 3 그리고 높이 <= 7.5, 찾기(QF000422, 높이=최소(높이, 7.5)).증가율, 0)",
}
],
}
got = _run("const lb = B.readLogic(A[0], META); out([B.writeLogic(lb), lb.blocked]);", row)
assert got[0] == row
assert got[1] == [
"증가율 — 조건에 「그리고 · 또는」이 섞임",
"증가율 — 표 조회 조건에 계산이 들어 있음",
]
def test_화면_글에_금지_낱말_없음() -> None:
src = BLOCKS.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) >= 7
for text in korean:
for bad in ("키", "식", "호표", "덧줄", "비목", "정본", "자체", "잠금"):
assert bad not in text, (bad, text)
+181
View File
@@ -0,0 +1,181 @@
"""로직 만들기 판 시험 길(`M01_MasterData/LogicBuilder/M01_LB_Calc.py`) 시험 — PLAN 35-2.
· 고친 표 사본으로 셈하면 값이 바뀌고 · 전역 캐시 · 파일은 그대로
· 표 없이 부르면 `/calc` 와 같은 값 · 없는 표는 404
· 사용처 — 표 「용도.로직키」 + 로직 글 대조
· 권한 — 시스템 관리자 아니면 403
견본 표 · 로직 줄은 시험 안에 둠(임시 폴더 사본에 견본 표를 더함 — 진짜 마스터 값에 안 기댐).
"""
from __future__ import annotations
import copy
import json
import shutil
from decimal import Decimal
from pathlib import Path
import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient
from common_util.common_util_auth import require_system_admin, verify_session
from M01_MasterData import M01_MasterData_Router as router_module
from M01_MasterData import M01_MasterData_Store as store
from M01_MasterData.LogicBuilder import M01_LB_Calc as lb
REAL = store.FOLDER
OWN = "로직_자체.json"
SAMPLE = "QF999999" # 견본 표 — 사본 폴더에만 있음
TABLE = {
"키": SAMPLE,
"원문번호": "",
"구분": "",
"상세구분": "",
"이름": "견본 쌓기 표",
"기준": "1㎡",
"출처": "자체",
"용도": {"공종": "견본", "대상": ["인력"], "로직키": []},
"조건": {"돌": "고르기"},
"값칸": {"석공": "인"},
"줄": [{"돌": "깬돌", "석공": 0.5}, {"돌": "견치돌", "석공": 0.7}],
"주": [],
}
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(labor: str) -> dict:
"""견본 로직 — 입력 「돌」 로 견본 표에서 석공 품을 찾아 노임에 곱함."""
return {
"키": "",
"원문번호": "",
"이름": "견본 쌓기",
"결과단위": "원/㎡",
"출처": "자체",
"입력": [{"이름": "돌", "고르기": ["깬돌", "견치돌"]}],
"중간": [],
"호표": [
{
"종류": "인력",
"요소": labor,
"이름": "석공",
"단위": "인",
"수량": f"찾기({SAMPLE}, 돌=돌).석공",
"비목": "노무비",
}
],
"덧줄": [],
"끝수": None,
}
@pytest.fixture
def folder(tmp_path: Path, monkeypatch) -> Path:
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)
host = sorted(tmp_path.glob("소요량_*.json"))[0] # 견본 표를 사본 소요량 파일 하나에 더함
data = json.loads(host.read_text(encoding="utf-8"))
data["표"].append(TABLE)
host.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
monkeypatch.setattr(store, "FOLDER", tmp_path)
return tmp_path
@pytest.fixture
def client(folder: Path) -> TestClient:
app = FastAPI()
app.include_router(router_module.router)
app.include_router(lb.router)
return TestClient(app)
def _post(client: TestClient, url: str, body: dict, want: int = 200) -> dict:
res = client.post(url, json=body)
assert res.status_code == want, res.text
return res.json()
def test_고친_표로_값이_바뀌고_캐시와_파일은_그대로(client: TestClient, folder: Path) -> None:
labor, price = _labor()
row = _row(labor)
before = {p.name: p.read_bytes() for p in folder.glob("*.json")}
plain = _post(client, "/api/m01/lb/calc", {"row": row, "inputs": {"돌": "깬돌"}})
assert plain["ok"] is True
assert Decimal(str(plain["sums"]["계"])) == Decimal("0.5") * price
edited = copy.deepcopy(TABLE)
edited["줄"][0]["석공"] = 0.8 # 깬돌 줄 품 0.5 → 0.8
body = {"row": row, "inputs": {"돌": "깬돌"}, "tables": {SAMPLE: edited}}
changed = _post(client, "/api/m01/lb/calc", body)
assert Decimal(str(changed["sums"]["계"])) == Decimal("0.8") * price
assert changed["lines"][0]["수량"] == 0.8
# 전역 캐시 · 파일 · 같은 요청 뒤 표 없는 셈은 그대로
files = store.loaded()[0].values()
cached = next(
t for d in files if d.get("그룹") == "소요량" for t in d["표"] if t["키"] == SAMPLE
)
assert cached["줄"][0]["석공"] == Decimal("0.5")
assert {p.name: p.read_bytes() for p in folder.glob("*.json")} == before
again = _post(client, "/api/m01/lb/calc", {"row": row, "inputs": {"돌": "깬돌"}})
assert again == plain
# 키는 못 바꿈 — 사본 표의 「키」 가 달라도 그 자리 키로 셈
_post(client, "/api/m01/lb/calc", {**body, "tables": {SAMPLE: {**edited, "키": "QF000001"}}})
def test_표_없이_부르면_calc_와_같고_없는_표는_404(client: TestClient) -> None:
labor, _ = _labor()
row = _row(labor)
for stone in ("깬돌", "견치돌"):
mine = _post(client, "/api/m01/lb/calc", {"row": row, "inputs": {"돌": stone}})
theirs = _post(client, "/api/m01/calc", {"key": "", "inputs": {"돌": stone}, "row": row})
assert mine == theirs
stop = _post(client, "/api/m01/lb/calc", {"row": row, "inputs": {"돌": "호박돌"}})
assert stop["ok"] is False and stop == _post(
client, "/api/m01/calc", {"key": "", "inputs": {"돌": "호박돌"}, "row": row}
)
_post(client, "/api/m01/lb/calc", {"row": row, "tables": {"QF999998": TABLE}}, want=404)
def test_사용처_용도와_로직_글(client: TestClient, folder: Path) -> None:
labor, _ = _labor()
made = _post(client, "/api/m01/logic/new", {"logic": _row(labor)})
first = next(
r["키"] for p in sorted(folder.glob("로직_*.json")) if p.name != OWN
for r in json.loads(p.read_text(encoding="utf-8"))["줄"]
) # fmt: skip
host = sorted(folder.glob("소요량_*.json"))[0]
data = json.loads(host.read_text(encoding="utf-8"))
next(t for t in data["표"] if t["키"] == SAMPLE)["용도"]["로직키"] = [first, made["key"]]
host.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
store.cache.stale([host.name])
res = client.get("/api/m01/lb/table-users", params={"key": SAMPLE})
assert res.status_code == 200, res.text
users = {u["key"]: u["via"] for u in res.json()["users"]}
assert users == {first: "용도", made["key"]: "둘"}
assert client.get("/api/m01/lb/table-users", params={"key": "QF999998"}).status_code == 404
def test_시스템_관리자가_아니면_403(folder: Path) -> None:
app = FastAPI()
app.include_router(
lb.router, dependencies=[Depends(verify_session), Depends(require_system_admin)]
)
app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 1}
client = TestClient(app)
assert client.post("/api/m01/lb/calc", json={"row": {}}).status_code == 403
assert client.get("/api/m01/lb/table-users", params={"key": SAMPLE}).status_code == 403
app.dependency_overrides[verify_session] = lambda: {"role": "SYSTEM_ADMIN", "company_id": None}
assert client.get("/api/m01/lb/table-users", params={"key": SAMPLE}).status_code == 200