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/M01_MasterData/LogicBuilder/M01_LB_Entry.css b/M01_MasterData/LogicBuilder/M01_LB_Entry.css new file mode 100644 index 000000000..9975c9b32 --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Entry.css @@ -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; + } +} diff --git a/M01_MasterData/LogicBuilder/M01_LB_Entry.ts b/M01_MasterData/LogicBuilder/M01_LB_Entry.ts new file mode 100644 index 000000000..0ceb0e6cd --- /dev/null +++ b/M01_MasterData/LogicBuilder/M01_LB_Entry.ts @@ -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 => + 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; + 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 => + !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 => { + 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 => { + 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 => { + 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, + onChange: (trial: Record) => 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; +} diff --git a/M01_MasterData/M01_MasterData_UI_LogicLab.ts b/M01_MasterData/M01_MasterData_UI_LogicLab.ts index 05942c5a5..934988e52 100644 --- a/M01_MasterData/M01_MasterData_UI_LogicLab.ts +++ b/M01_MasterData/M01_MasterData_UI_LogicLab.ts @@ -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, 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