diff --git a/M01_MasterData/LogicBuilder/M01_LB_Api.ts b/M01_MasterData/LogicBuilder/M01_LB_Api.ts index 0095a909a..c48f7d03c 100644 --- a/M01_MasterData/LogicBuilder/M01_LB_Api.ts +++ b/M01_MasterData/LogicBuilder/M01_LB_Api.ts @@ -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; @@ -23,24 +28,76 @@ export interface LbTable { table: Record & { 줄: Record[] }; } -const notReady = (): never => { - throw new Error("준비 중"); -}; +const CALC_WAIT_MS = 300; +const TABLE_GROUPS = ["소요량", "계수"]; // 표가 드는 그룹(계수 표가 소요량 파일에 든 것도 있음) + +async function send(path: string, body?: unknown): Promise { + 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): Record { + 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 | undefined; /** 0.3초 뒤 고친 표로 서버 계산 · 최신 응답만 `onDone` · 멈춤 · 실패는 `plain` 쉬운 말 */ export function lbCalcLater( - _row: LogicRow, - _inputs: Record, - _tables: LbTables, - _onDone: (ans: CalcAnswer | null, plain: string | null) => void, + row: LogicRow, + inputs: Record, + tables: LbTables, + onDone: (ans: CalcAnswer | null, plain: string | null) => void, ): void { - notReady(); + const mine = ++calcSeq; + clearTimeout(calcTimer); + calcTimer = setTimeout(() => { + send("/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 { - return notReady(); +/** 사용처 — 그 표를 쓰는 로직(표 「용도」 + 로직 글 대조) */ +export async function tableUsers(key: string): Promise { + const got = await send<{ users: LbTableUser[] }>( + `/table-users?${new URLSearchParams({ key }).toString()}`, + ); + return got.users; } -export async function fetchLbTable(_key: string): Promise { - return notReady(); +/** 표 키 하나로 표 통째 — 든 파일을 찾아(소요량 · 계수) Api_Fetch 의 표 읽기로 */ +export async function fetchLbTable(key: string): Promise { + 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}」`); } diff --git a/M01_MasterData/LogicBuilder/M01_LB_Blocks.ts b/M01_MasterData/LogicBuilder/M01_LB_Blocks.ts index 863ea1917..700d95767 100644 --- a/M01_MasterData/LogicBuilder/M01_LB_Blocks.ts +++ b/M01_MasterData/LogicBuilder/M01_LB_Blocks.ts @@ -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; 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 => ({ ...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; + mids: Set; + /** 찾기 글(빈칸 뺌) → 표 조회 번호 */ + finds: Map; + /** 사람 줄 노임 합 글(빈칸 뺌) — 비율 바탕 */ + personSum: string | null; + bad: Set; +} + +/** 조각의 원래 글 — 안 바뀐 조각은 이 글을 그대로 씀 */ +const SRC = new WeakMap(); +const keep = (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(); + +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(); + 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): 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): 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; + } } diff --git a/M01_MasterData/LogicBuilder/M01_LB_Calc.py b/M01_MasterData/LogicBuilder/M01_LB_Calc.py new file mode 100644 index 000000000..471a562f3 --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Calc.py @@ -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"(? 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) diff --git a/main.py b/main.py index 6813531f4..911eade9d 100644 --- a/main.py +++ b/main.py @@ -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) diff --git a/resources/tester/test_m01_lb_blocks.py b/resources/tester/test_m01_lb_blocks.py new file mode 100644 index 000000000..f9e9f4188 --- /dev/null +++ b/resources/tester/test_m01_lb_blocks.py @@ -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) diff --git a/resources/tester/test_m01_lb_calc.py b/resources/tester/test_m01_lb_calc.py new file mode 100644 index 000000000..120101ae8 --- /dev/null +++ b/resources/tester/test_m01_lb_calc.py @@ -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