Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_6' and 'origin/sub_laptop_7' into main_laptop_1
This commit is contained in:
@@ -17,8 +17,8 @@ import {
|
||||
} from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { listEasy, plainError, type EasyListItem } from "../EasyCalc/M01_EasyCalc_Api";
|
||||
import { slList, type SlListItem } from "./M01_SL_Api";
|
||||
import { newLogic, type SheetLogic } from "./M01_SL_Model";
|
||||
import type { SlListItem } from "./M01_SL_Api";
|
||||
import type { SheetLogic } from "./M01_SL_Model";
|
||||
import "./M01_SL_Entry.css";
|
||||
|
||||
const TEXT = {
|
||||
@@ -76,15 +76,12 @@ const amountOf = (resultUnit: string): string => {
|
||||
};
|
||||
const isMoney = (resultUnit: string): boolean => resultUnit.startsWith("원/");
|
||||
|
||||
/* ponytail: SL Api(sub4) · Model(sub7) 속이 「준비 중」 인 동안만 대신함 — 채워지면 두 catch 를 지움 */
|
||||
const loadMine = (): Promise<SlListItem[]> => slList().catch(() => []);
|
||||
const blankLogic = (name: string, unit: string): SheetLogic | null => {
|
||||
try {
|
||||
return newLogic(name, unit);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
/* SheetLogic 파일은 늦게 불러옴(import()) — 스프레드시트 엔진이 M01 첫 묶음에 안 들어가게.
|
||||
* ponytail: SL Api(sub4) · Model(sub7) 속이 「준비 중」 인 동안만 빈 목록 · null 로 대신함 — 채워지면 catch 를 지움 */
|
||||
const loadMine = (): Promise<SlListItem[]> =>
|
||||
import("./M01_SL_Api").then((m) => m.slList()).catch(() => []);
|
||||
const blankLogic = (name: string, unit: string): Promise<SheetLogic | null> =>
|
||||
import("./M01_SL_Model").then((m) => m.newLogic(name, unit)).catch(() => null);
|
||||
|
||||
/** 「로직 만들기(시험)」 옆에 놓는 단추 — 누르면 큰 창 */
|
||||
export function createSheetLogicButton(): HTMLButtonElement {
|
||||
@@ -235,7 +232,9 @@ function openSheetLogic(): void {
|
||||
const showPaper = (): void => {
|
||||
let dirty = false;
|
||||
const units = unitChoices();
|
||||
paper = { dirty: () => dirty, sl: blankLogic("", units[0]) };
|
||||
const mine: { dirty(): boolean; sl: SheetLogic | null } = { dirty: () => dirty, sl: null };
|
||||
paper = mine;
|
||||
void blankLogic("", units[0]).then((sl) => (mine.sl = sl));
|
||||
|
||||
const unsaved = createTag(te("Unsaved"), "warning");
|
||||
unsaved.hidden = true;
|
||||
|
||||
@@ -0,0 +1,686 @@
|
||||
/* =============================================================================
|
||||
* M01_SL_Import.ts
|
||||
* 시트 로직 — 기존 로직 가져오기(정함 9) · 로직 줄 → 시트 로직(상자 = 시트 · 알약 · 선) · DOM 없음
|
||||
*
|
||||
* 읽기 = LogicBuilder Blocks(`readLogic`)로 입력값을 받고 · 식은 엔진 문법(`master_formula`) 그대로 읽어
|
||||
* 엑셀 수식으로: 만약 → IF · 최소/최대 → MIN/MAX · 올림/버림/반올림 → ROUNDUP/ROUNDDOWN/ROUND ·
|
||||
* == → = · != → <> · 그리고/또는 → AND/OR · 찾기 → 표 상자 · 로직 → 다른 로직 상자 · 노임 키 → 바깥 값.
|
||||
* 다른 상자 값은 받는 상자에 입력 알약 한 칸(「'시트'!칸」 참조 수식) + 선 하나 — 식은 제 상자 칸만 봄.
|
||||
*
|
||||
* 상자 시트 꼴 — A 이름 · B 값 · C 설명(1행 머리) · 알약은 B 칸.
|
||||
* 표 상자 = 조건 알약(B) · 결과 알약(B) · E1 부터 품셈 표(머리 줄 + 줄 · 조건 칸 차례 → 값 칸 차례 ·
|
||||
* 범위 조건은 「이름·아래」 「이름·위」 두 칸) · 그 오른쪽에 「맞음」 칸(줄마다 조건 셈 → 1/0) ·
|
||||
* 결과 = 맞는 줄이 하나일 때 INDEX/MATCH · 아니면 #N/A(엔진 `find` 의 해당 없음 · 중복).
|
||||
* 빈 조건 칸은 무엇이든 맞음 · 범위규칙(초과·이하 · 이상·이하 · 이상·미만 · 이하)은 엔진과 같음.
|
||||
* 바깥 값(노임 · 자재 · 장비 · 로직 · 표)은 알약 `outside` · 실제 값은 열 때 · 저장 때 넣음(`outsideOf`).
|
||||
* 못 바꾸는 꼴 = 그 칸 #N/A + 칸 메모(원래 글) + 쉬운 말 한 줄(`problems`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { colName, quoteSheet } from "../../A00_Common/spreadsheet/spreadsheet_address";
|
||||
import type {
|
||||
CalcValues,
|
||||
Cell,
|
||||
Sheet,
|
||||
Workbook,
|
||||
} from "../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import type { HoLine, LogicInput, LogicRow } from "../M01_MasterData_UI_Logic_Api";
|
||||
import { readLogic } from "../LogicBuilder/M01_LB_Blocks";
|
||||
import type { SheetLogic, SlEdge, SlKind, SlNode, SlPill } from "./M01_SL_Model";
|
||||
import type { SlField, SlOutside, SlOutsideAt } from "./M01_SL_Api";
|
||||
import {
|
||||
parseFormula,
|
||||
RULES,
|
||||
tableCols,
|
||||
tableRows,
|
||||
type Ast,
|
||||
type SlImportTable,
|
||||
} from "./M01_SL_Import_Parse";
|
||||
|
||||
const TEXT = {
|
||||
Name: ["이름", "Name"],
|
||||
Value: ["값", "Value"],
|
||||
Note: ["설명", "Note"],
|
||||
Qty: ["수량", "Quantity"],
|
||||
Price: ["단가", "Unit price"],
|
||||
Amount: ["금액", "Amount"],
|
||||
Result: ["결과", "Result"],
|
||||
Sum: ["항목 합", "Item total"],
|
||||
Input: ["입력값", "Input"],
|
||||
Table: ["표", "Table"],
|
||||
Calc: ["계산", "Calc"],
|
||||
Item: ["항목", "Item"],
|
||||
Logic: ["다른 로직", "Other logic"],
|
||||
Const: ["고정값", "Constant"],
|
||||
Outside: ["바깥 값 — 열 때 · 저장 때 넣음", "Outside value — filled on open and save"],
|
||||
Match: ["맞음", "Match"],
|
||||
Pass: ["넘길 값", "Passed value"],
|
||||
Cant: ["못 바꿈 — ", "Not converted — "],
|
||||
NoTable: ["품셈 표 「{v}」 를 못 받음", "Table “{v}” was not given"],
|
||||
NoCol: ["표에 없는 칸 「{v}」", "Column “{v}” is not in the table"],
|
||||
Rule: ["모르는 범위규칙 「{v}」", "Unknown range rule “{v}”"],
|
||||
Unknown: ["모르는 이름 「{v}」", "Unknown name “{v}”"],
|
||||
Hole: ["입력에 따라 바뀌는 요소 「{v}」", "Element changes with an input “{v}”"],
|
||||
Field: ["요소의 칸 값 「{v}」", "Element field “{v}”"],
|
||||
FindAll: ["표 줄을 통째로 씀", "Uses a whole table row"],
|
||||
Parse: ["읽을 수 없는 글", "Text that can't be read"],
|
||||
} as const;
|
||||
const t = (k: keyof typeof TEXT, v = ""): string =>
|
||||
TEXT[k][currentLanguageIndex === 1 ? 1 : 0].replace("{v}", v);
|
||||
|
||||
export type { SlImportTable };
|
||||
|
||||
export interface SlImportOptions {
|
||||
/** 표 키 → 품셈 표(열 때 받은 것) — 없으면 그 표 상자는 못 바꿈 */
|
||||
tables?: Record<string, SlImportTable>;
|
||||
/** 입력값 → 예시값 — 없으면 선택 목록 첫째 · 범위 낮은 끝 · 숫자 0 */
|
||||
examples?: Record<string, number | string>;
|
||||
}
|
||||
|
||||
export interface SlImportReport {
|
||||
sl: SheetLogic;
|
||||
/** 못 바꾼 꼴(쉬운 말) — 비면 다 바뀜 */
|
||||
problems: string[];
|
||||
}
|
||||
|
||||
/* ── 짓기 ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
class Fail extends Error {}
|
||||
const KEY = /^[A-Z]{2}\d{6}$/;
|
||||
const COSTS = ["노무비", "재료비", "경비"] as const;
|
||||
type Cost = (typeof COSTS)[number];
|
||||
const lit = (v: unknown): string =>
|
||||
typeof v === "number" ? String(v) : `"${String(v).replace(/"/g, '""')}"`;
|
||||
const cellOf = (v: unknown): Cell | null =>
|
||||
typeof v === "number" || typeof v === "string" || typeof v === "boolean" ? { 값: v } : null;
|
||||
|
||||
/** 표 상자 하나 = 표 + 조건 묶음 하나(같은 표 · 같은 조건이면 결과 알약만 더함) */
|
||||
interface TableBox {
|
||||
node: SlNode;
|
||||
table: SlImportTable;
|
||||
/** 결과 칸 이름 → 표 열 글자 */
|
||||
colOf: Map<string, string>;
|
||||
rows: number;
|
||||
/** 다음 빈 열(맞음 · 결과 셈 칸) */
|
||||
free: number;
|
||||
/** 결과 셈에 쓸 「맞음」 열(엔진 `find` 의 위 끝 고르기까지) */
|
||||
hit: string;
|
||||
}
|
||||
|
||||
class Build {
|
||||
readonly book: Workbook = { 종류: "통합문서", 판: 1, 열: "sheet-logic", 서식: [{}], 시트: [] };
|
||||
readonly nodes: SlNode[] = [];
|
||||
readonly edges: SlEdge[] = [];
|
||||
readonly problems: string[] = [];
|
||||
private readonly next = new Map<string, number>();
|
||||
private readonly sheets = new Map<string, Sheet>();
|
||||
private readonly linked = new Map<string, string>();
|
||||
readonly inputs = new Map<string, SlNode>();
|
||||
readonly mids = new Map<string, SlNode>();
|
||||
readonly tables = new Map<string, TableBox>();
|
||||
readonly logics = new Map<string, SlNode>();
|
||||
readonly consts = new Map<string, SlNode>();
|
||||
|
||||
constructor(readonly opts: SlImportOptions) {}
|
||||
|
||||
node(kind: SlKind, name: string, stage: number, ref?: string): SlNode {
|
||||
const n = this.nodes.length + 1;
|
||||
const head = { input: "Input", const: "Const", table: "Table", calc: "Calc", item: "Item", logic: "Logic", result: "Result" }[kind] as keyof typeof TEXT; // prettier-ignore
|
||||
const base = kind === "result" ? t("Result") : `${t(head)} · ${name}`.slice(0, 31);
|
||||
let title = base;
|
||||
for (let k = 2; [...this.sheets.values()].some((s) => s.이름 === title); k++)
|
||||
title = `${base.slice(0, 28)} ${k}`;
|
||||
const sheet: Sheet = {
|
||||
id: `s${n}`,
|
||||
이름: title,
|
||||
칸: { A1: { 값: t("Name") }, B1: { 값: t("Value") }, C1: { 값: t("Note") } },
|
||||
};
|
||||
this.book.시트.push(sheet);
|
||||
this.sheets.set(sheet.id, sheet);
|
||||
const node: SlNode = {
|
||||
id: `n${n}`,
|
||||
kind,
|
||||
name,
|
||||
sheetId: sheet.id,
|
||||
pillsIn: [],
|
||||
pillsOut: [],
|
||||
stage,
|
||||
...(ref ? { ref } : {}),
|
||||
};
|
||||
this.nodes.push(node);
|
||||
this.next.set(node.id, 2);
|
||||
return node;
|
||||
}
|
||||
|
||||
sheet(node: SlNode): Sheet {
|
||||
return this.sheets.get(node.sheetId)!;
|
||||
}
|
||||
|
||||
/** 줄 하나 더함 — A 이름 · B 값(또는 식) · C 설명 → B 칸 주소 */
|
||||
row(node: SlNode, label: string, cell: Cell, note = ""): string {
|
||||
const r = this.next.get(node.id)!;
|
||||
this.next.set(node.id, r + 1);
|
||||
const s = this.sheet(node);
|
||||
s.칸[`A${r}`] = { 값: label };
|
||||
s.칸[`B${r}`] = cell;
|
||||
if (note) s.칸[`C${r}`] = { 값: note };
|
||||
return `B${r}`;
|
||||
}
|
||||
|
||||
pill(node: SlNode, dir: "in" | "out", pill: SlPill): void {
|
||||
const list = dir === "in" ? node.pillsIn : node.pillsOut;
|
||||
let name = pill.name;
|
||||
for (let k = 2; list.some((p) => p.name === name); k++) name = `${pill.name} ${k}`;
|
||||
list.push({ ...pill, name });
|
||||
}
|
||||
|
||||
/** 출력 알약 하나 — 같은 이름이 있으면 그 칸 */
|
||||
out(node: SlNode, name: string, cell: Cell, note = ""): string {
|
||||
const had = node.pillsOut.find((p) => p.name === name);
|
||||
if (had) return had.cell;
|
||||
const at = this.row(node, name, cell, note);
|
||||
this.pill(node, "out", { name, cell: at });
|
||||
return at;
|
||||
}
|
||||
|
||||
/** 다른 상자 출력 알약 → 이 상자 입력 알약 한 칸 + 선 · 이 상자 안 칸 주소
|
||||
* `into` = 이미 있는 입력 알약(넘길 값 칸)에 바로 이음 */
|
||||
link(to: SlNode, from: SlNode, pill: string, into?: SlPill): string {
|
||||
const id = `${to.id}<${from.id}:${pill}`;
|
||||
const had = this.linked.get(id);
|
||||
if (had && !into) return had;
|
||||
const src = from.pillsOut.find((p) => p.name === pill)!;
|
||||
const ref: Cell = { 식: `${quoteSheet(this.sheet(from).이름)}!${src.cell}` };
|
||||
let target = into;
|
||||
if (target) this.sheet(to).칸[target.cell] = ref;
|
||||
else {
|
||||
const name = from.kind === "input" || from.kind === "calc" ? pill : `${pill} · ${from.name}`;
|
||||
this.pill(to, "in", { name, cell: this.row(to, name, ref) });
|
||||
target = to.pillsIn[to.pillsIn.length - 1];
|
||||
}
|
||||
this.edges.push({
|
||||
id: `e${this.edges.length + 1}`,
|
||||
from: { node: from.id, pill },
|
||||
to: { node: to.id, pill: target.name },
|
||||
});
|
||||
if (!had) this.linked.set(id, target.cell);
|
||||
return target.cell;
|
||||
}
|
||||
|
||||
/** 못 바꾸는 꼴 — 칸 #N/A + 메모(원래 글) + 쉬운 말 */
|
||||
cant(node: SlNode, at: string, why: string, src: string): void {
|
||||
const s = this.sheet(node);
|
||||
s.칸[at] = { 식: "NA()" };
|
||||
(s.comments ??= {})[at] = src;
|
||||
s.칸[`C${at.slice(1)}`] = { 값: `${t("Cant")}${why}` };
|
||||
this.problems.push(`${node.name} — ${why}`);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 식 → 엑셀 ───────────────────────────────────────────────────────────── */
|
||||
|
||||
interface Scope {
|
||||
b: Build;
|
||||
/** 식이 놓일 상자 */
|
||||
here: SlNode;
|
||||
/** 덧줄 — 비목 합(앞 덧줄까지) 을 주는 상자 · 알약 */
|
||||
costs?: Record<Cost, [SlNode, string][]>;
|
||||
/** 덧줄 — 앞 덧줄 이름 · 줄.'이름' */
|
||||
extras?: Map<string, [SlNode, string]>;
|
||||
lines?: Map<string, [SlNode, string]>;
|
||||
}
|
||||
|
||||
function excel(n: Ast, sc: Scope): string {
|
||||
const { b, here } = sc;
|
||||
const wrap = (x: Ast): string => (x.t === "bin" ? `(${excel(x, sc)})` : excel(x, sc));
|
||||
switch (n.t) {
|
||||
case "num":
|
||||
return n.v;
|
||||
case "str":
|
||||
return lit(n.v);
|
||||
case "neg":
|
||||
return `-${wrap(n.a)}`;
|
||||
case "eref":
|
||||
throw new Fail(t("Hole", n.v));
|
||||
case "bin": {
|
||||
if (n.op === "그리고" || n.op === "또는")
|
||||
return `${n.op === "그리고" ? "AND" : "OR"}(${excel(n.a, sc)},${excel(n.b, sc)})`;
|
||||
const op = n.op === "==" ? "=" : n.op === "!=" ? "<>" : n.op;
|
||||
return `${wrap(n.a)}${op}${wrap(n.b)}`;
|
||||
}
|
||||
case "call": {
|
||||
const items = n.items.map((x) => excel(x, sc));
|
||||
if (n.f === "만약") return `IF(${items.join(",")})`;
|
||||
if (n.f === "최소" || n.f === "최대")
|
||||
return `${n.f === "최소" ? "MIN" : "MAX"}(${items.join(",")})`;
|
||||
const fn = { 올림: "ROUNDUP", 버림: "ROUNDDOWN", 반올림: "ROUND" }[n.f]!;
|
||||
return `${fn}(${items[0]},${items[1] ?? "0"})`;
|
||||
}
|
||||
case "find":
|
||||
throw new Fail(t("FindAll"));
|
||||
case "logic":
|
||||
return b.link(here, logicBox(b, n), t("Result"));
|
||||
case "field": {
|
||||
const a = n.a;
|
||||
if (a.t === "find") return b.link(here, ...findBox(b, a, n.f));
|
||||
if (a.t === "logic") return b.link(here, logicBox(b, a, n.f), n.f);
|
||||
if (a.t === "name" && a.v === "줄" && sc.lines?.has(n.f))
|
||||
return b.link(here, ...sc.lines.get(n.f)!);
|
||||
throw new Fail(t("Field", `${a.t === "name" ? a.v : ""}.${n.f}`));
|
||||
}
|
||||
case "name": {
|
||||
const v = n.v;
|
||||
if (b.inputs.has(v)) return b.link(here, b.inputs.get(v)!, v);
|
||||
if (b.mids.has(v)) return b.link(here, b.mids.get(v)!, v);
|
||||
if (sc.extras?.has(v)) return b.link(here, ...sc.extras.get(v)!);
|
||||
if (sc.costs && (COSTS as readonly string[]).includes(v)) {
|
||||
const refs = sc.costs[v as Cost].map(([node, pill]) => b.link(here, node, pill));
|
||||
return refs.length ? `(${refs.join("+")})` : "0";
|
||||
}
|
||||
if (KEY.test(v)) return b.link(here, constBox(b, v), t("Price"));
|
||||
throw new Fail(t("Unknown", v));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 식 글 하나를 상자 칸에 — 못 바꾸면 #N/A + 메모 */
|
||||
function put(sc: Scope, label: string, text: string, pill: "out" | null, note = ""): string {
|
||||
const ast = parseFormula(text);
|
||||
let cell: Cell;
|
||||
let why = "";
|
||||
try {
|
||||
if (!ast) throw new Fail(t("Parse"));
|
||||
cell = ast.t === "num" ? { 값: Number(ast.v) } : { 식: excel(ast, sc) };
|
||||
} catch (e) {
|
||||
if (!(e instanceof Fail)) throw e;
|
||||
why = e.message;
|
||||
cell = { 식: "NA()" };
|
||||
}
|
||||
const at =
|
||||
pill === "out" ? sc.b.out(sc.here, label, cell, note) : sc.b.row(sc.here, label, cell, note);
|
||||
if (why) sc.b.cant(sc.here, at, why, text);
|
||||
return at;
|
||||
}
|
||||
|
||||
/** 엔진 노임 키(식 속 LB000006 등) → 고정값 상자 · 바깥 값 알약 */
|
||||
function constBox(b: Build, key: string): SlNode {
|
||||
const had = b.consts.get(key);
|
||||
if (had) return had;
|
||||
const node = b.node("const", key, 0, key);
|
||||
const at = b.row(node, t("Price"), {}, t("Outside"));
|
||||
b.pill(node, "in", { name: t("Price"), cell: at, outside: true });
|
||||
b.pill(node, "out", { name: t("Price"), cell: at });
|
||||
b.consts.set(key, node);
|
||||
return node;
|
||||
}
|
||||
|
||||
/** 넘길 값(로직 부름 인자 · 표 조건) → 입력 알약 — 글 · 수는 그 칸에 값 · 입력값 · 계산 이름은 선 · 식은 셈 */
|
||||
function args(b: Build, node: SlNode, list: [string, Ast][]): void {
|
||||
for (const [k, v] of list) {
|
||||
const at = b.row(node, k, {}, t("Pass"));
|
||||
b.pill(node, "in", { name: k, cell: at });
|
||||
const pill = node.pillsIn[node.pillsIn.length - 1];
|
||||
const s = b.sheet(node);
|
||||
try {
|
||||
if (v.t === "num") s.칸[at] = { 값: Number(v.v) };
|
||||
else if (v.t === "str") s.칸[at] = { 값: v.v };
|
||||
else if (v.t === "name" && b.inputs.has(v.v)) b.link(node, b.inputs.get(v.v)!, v.v, pill);
|
||||
else if (v.t === "name" && b.mids.has(v.v)) b.link(node, b.mids.get(v.v)!, v.v, pill);
|
||||
else s.칸[at] = { 식: excel(v, { b, here: node }) };
|
||||
} catch (e) {
|
||||
if (!(e instanceof Fail)) throw e;
|
||||
b.cant(node, at, e.message, k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 다른 로직 상자(식 속 `로직(키, …)`) — 결과 · 비목 칸은 바깥 값 */
|
||||
function logicBox(b: Build, n: Extract<Ast, { t: "logic" }>, field?: string): SlNode {
|
||||
const sig = n.src.replace(/\s+/g, "");
|
||||
let node = b.logics.get(sig);
|
||||
if (!node) {
|
||||
if (/[{}]/.test(n.head)) throw new Fail(t("Hole", n.head));
|
||||
node = b.node("logic", n.head, 2, n.head);
|
||||
args(b, node, n.args);
|
||||
b.logics.set(sig, node);
|
||||
}
|
||||
const name = field ?? t("Result");
|
||||
if (!node.pillsOut.some((p) => p.name === name)) {
|
||||
const at = b.row(node, name, {}, t("Outside"));
|
||||
b.pill(node, "in", { name, cell: at, outside: true });
|
||||
b.pill(node, "out", { name, cell: at });
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/* ── 표 상자 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 표 상자 — 같은 표 · 같은 조건이면 결과 알약만 더함 → [상자, 결과 알약 이름] */
|
||||
function findBox(b: Build, n: Extract<Ast, { t: "find" }>, col: string): [SlNode, string] {
|
||||
const sig = `${n.head}|${n.args.map(([k]) => k).join(",")}|${n.src.replace(/\s+/g, "").replace(/\)\..*$/, "")}`;
|
||||
let box = b.tables.get(sig);
|
||||
if (!box) box = newTable(b, n, sig);
|
||||
if (!box.colOf.has(col)) throw new Fail(t("NoCol", col));
|
||||
if (!box.node.pillsOut.some((p) => p.name === col)) resultCol(b, box, col);
|
||||
return [box.node, col];
|
||||
}
|
||||
|
||||
function newTable(b: Build, n: Extract<Ast, { t: "find" }>, sig: string): TableBox {
|
||||
const table = b.opts.tables?.[n.head];
|
||||
if (!table) throw new Fail(t("NoTable", n.head));
|
||||
const rule = String(table.범위규칙 ?? "초과·이하")
|
||||
.split(" · ")[0]
|
||||
.split(" (")[0];
|
||||
for (const [k] of n.args) if (!(k in table.조건)) throw new Fail(t("NoCol", k));
|
||||
if (n.args.some(([k]) => table.조건[k] === "범위") && !RULES.includes(rule))
|
||||
throw new Fail(t("Rule", String(table.범위규칙)));
|
||||
const node = b.node("table", table.이름 || n.head, 1, n.head);
|
||||
const s = b.sheet(node);
|
||||
// 조건 알약 — 받는 값은 넘긴 식
|
||||
const cond = new Map<string, string>();
|
||||
args(b, node, n.args);
|
||||
for (const [k] of n.args) cond.set(k, node.pillsIn.find((p) => p.name === k)!.cell);
|
||||
// 표 E1 부터
|
||||
const cols = tableCols(table);
|
||||
const rows = tableRows(table);
|
||||
b.row(node, t("Table"), { 값: n.head }, t("Outside"));
|
||||
b.pill(node, "in", { name: t("Table"), cell: "E1", outside: true });
|
||||
rows.forEach((r, i) =>
|
||||
r.forEach((v, j) => {
|
||||
const c = cellOf(v);
|
||||
if (c) s.칸[`${colName(4 + j)}${i + 1}`] = c;
|
||||
}),
|
||||
);
|
||||
const letter = (key: string, part: "v" | "lo" | "hi"): string =>
|
||||
colName(4 + cols.findIndex((c) => c.key === key && c.part === part));
|
||||
const N = table.줄.length;
|
||||
const range = (L: string): string => `$${L}$2:$${L}$${N + 1}`;
|
||||
let free = 4 + cols.length + 1;
|
||||
// 맞음 — 줄마다 조건 셈(빈 칸은 무엇이든 맞음 · 범위는 규칙대로)
|
||||
const M = colName(free++);
|
||||
s.칸[`${M}1`] = { 값: t("Match") };
|
||||
const top = Math.max(
|
||||
...table.줄.flatMap((r) =>
|
||||
Object.keys(table.조건)
|
||||
.filter((k) => table.조건[k] === "범위" && Array.isArray(r[k]))
|
||||
.map((k) => (r[k] as (number | null)[])[1] ?? -Infinity),
|
||||
),
|
||||
);
|
||||
const closed = String(table.범위규칙 ?? "").includes("이하 포함");
|
||||
for (let r = 2; r <= N + 1; r++) {
|
||||
const parts = [...cond].map(([k, pin]) => {
|
||||
const x = `$${pin.slice(0, 1)}$${pin.slice(1)}`;
|
||||
if (table.조건[k] !== "범위") {
|
||||
const X = `${letter(k, "v")}${r}`;
|
||||
return `OR(${X}="",${X}=${x})`;
|
||||
}
|
||||
const L = `${letter(k, "lo")}${r}`;
|
||||
const H = `${letter(k, "hi")}${r}`;
|
||||
const low = rule === "이상·이하" || rule === "이상·미만" ? ">=" : ">";
|
||||
const up =
|
||||
rule === "이상·미만"
|
||||
? `OR(${H}="",${x}<${H}${closed ? `,AND(${H}=${top},${x}=${H})` : ""})`
|
||||
: `OR(${H}="",${x}<=${H})`;
|
||||
return `AND(OR(${L}="",${x}${low}${L}),${up})`;
|
||||
});
|
||||
s.칸[`${M}${r}`] = { 식: parts.length ? `IF(AND(${parts.join(",")}),1,0)` : "1" };
|
||||
}
|
||||
// 여러 줄이 맞으면 위 끝이 가장 작은 줄(규칙 이하 · 이상·이하 — 엔진 `find`)
|
||||
let hit = M;
|
||||
const ranged = [...cond.keys()].filter((k) => table.조건[k] === "범위");
|
||||
if ((rule === "이하" || rule === "이상·이하") && ranged.length) {
|
||||
hit = colName(free++);
|
||||
s.칸[`${hit}1`] = { 값: `${t("Match")} 2` };
|
||||
for (let r = 2; r <= N + 1; r++) {
|
||||
const least = ranged.map((k) => {
|
||||
const H = letter(k, "hi");
|
||||
return `OR(${H}${r}="",${H}${r}=MINIFS(${range(H)},${range(M)},1))`;
|
||||
});
|
||||
s.칸[`${hit}${r}`] = {
|
||||
식: `IF(AND(${M}${r}=1,OR(COUNTIF(${range(M)},1)<2,${least.join(",")})),1,0)`,
|
||||
};
|
||||
}
|
||||
}
|
||||
const colOf = new Map<string, string>();
|
||||
for (const c of cols) if (c.part === "v") colOf.set(c.key, colName(4 + cols.indexOf(c)));
|
||||
const box: TableBox = { node, table, colOf, rows: N, free, hit };
|
||||
b.tables.set(sig, box);
|
||||
return box;
|
||||
}
|
||||
|
||||
/** 결과 알약 — 그 칸이 빈 줄은 빼고(여러 줄일 때) 하나만 맞으면 INDEX/MATCH · 아니면 #N/A */
|
||||
function resultCol(b: Build, box: TableBox, col: string): void {
|
||||
const s = b.sheet(box.node);
|
||||
const N = box.rows;
|
||||
const range = (L: string): string => `$${L}$2:$${L}$${N + 1}`;
|
||||
const X = box.colOf.get(col)!;
|
||||
const W = colName(box.free++);
|
||||
s.칸[`${W}1`] = { 값: `${t("Match")} · ${col}` };
|
||||
for (let r = 2; r <= N + 1; r++)
|
||||
s.칸[`${W}${r}`] = {
|
||||
식: `IF(AND(${box.hit}${r}=1,OR(COUNTIF(${range(box.hit)},1)<2,${X}${r}<>"")),1,0)`,
|
||||
};
|
||||
b.out(box.node, col, {
|
||||
식: `IF(COUNTIF(${range(W)},1)=1,INDEX(${range(X)},MATCH(1,${range(W)},0)),NA())`,
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 상자들 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
const example = (spec: LogicInput, given?: number | string): Cell => {
|
||||
if (given !== undefined) return { 값: given };
|
||||
if (spec.고르기?.length) return { 값: spec.고르기[0] };
|
||||
if (spec.범위) return { 값: spec.범위[0] };
|
||||
return { 값: 0 };
|
||||
};
|
||||
|
||||
function inputBox(b: Build, spec: LogicInput): void {
|
||||
const node = b.node("input", spec.이름, 0);
|
||||
const at = b.out(node, spec.이름, example(spec, b.opts.examples?.[spec.이름]), spec.단위 ?? "");
|
||||
const r = Number(at.slice(1)) - 1;
|
||||
const whole = { r0: r, c0: 1, r1: r, c1: 1 };
|
||||
if (spec.고르기?.length)
|
||||
b.sheet(node).유효성 = [
|
||||
{
|
||||
id: "v1",
|
||||
범위: [whole],
|
||||
규칙: { 종류: "목록", 값들: spec.고르기.map(String) },
|
||||
어기면: "막음",
|
||||
},
|
||||
];
|
||||
else if (spec.범위)
|
||||
b.sheet(node).유효성 = [
|
||||
{
|
||||
id: "v1",
|
||||
범위: [whole],
|
||||
규칙: { 종류: "수범위", 아래: spec.범위[0], 위: spec.범위[1] },
|
||||
어기면: "막음",
|
||||
},
|
||||
];
|
||||
const pill = node.pillsOut[0];
|
||||
if (spec.단위) pill.unit = spec.단위;
|
||||
b.inputs.set(spec.이름, node);
|
||||
}
|
||||
|
||||
/** 수량 — 숫자 · 이름 하나면 바로 · 식이면 계산 상자 「이름 수량」 → [상자, 알약] */
|
||||
function qtyFrom(b: Build, item: HoLine, name: string): [SlNode, string] | number {
|
||||
const ast = parseFormula(item.수량);
|
||||
if (ast?.t === "num") return Number(ast.v);
|
||||
if (ast?.t === "name" && b.inputs.has(ast.v)) return [b.inputs.get(ast.v)!, ast.v];
|
||||
if (ast?.t === "name" && b.mids.has(ast.v)) return [b.mids.get(ast.v)!, ast.v];
|
||||
const label = `${name} ${t("Qty")}`;
|
||||
const node = b.node("calc", label, 2);
|
||||
put({ b, here: node }, label, item.수량, "out");
|
||||
return [node, label];
|
||||
}
|
||||
|
||||
/** 항목 상자(호표 줄) → 비목 출력 알약들 */
|
||||
function itemBox(b: Build, item: HoLine, lines: Map<string, [SlNode, string]>): [SlNode, Cost][] {
|
||||
const name = item.이름 || String(item.요소);
|
||||
const element = typeof item.요소 === "string" ? item.요소 : JSON.stringify(item.요소);
|
||||
const call =
|
||||
typeof item.요소 === "string" && item.요소.startsWith("로직(") ? parseFormula(item.요소) : null;
|
||||
const node = b.node("item", name, 3, call?.t === "logic" ? (call.args.some(([k]) => k === "기계") ? element : call.head) : element); // prettier-ignore
|
||||
const q = qtyFrom(b, item, name);
|
||||
const qty =
|
||||
typeof q === "number"
|
||||
? b.row(node, t("Qty"), { 값: q }, item.단위 ?? "")
|
||||
: b.link(node, q[0], q[1]);
|
||||
if (typeof q === "number") b.pill(node, "in", { name: t("Qty"), cell: qty, unit: item.단위 });
|
||||
const out: [SlNode, Cost][] = [];
|
||||
if (call?.t === "logic") {
|
||||
args(b, node, call.args);
|
||||
const unit = COSTS.map((k) => {
|
||||
const at = b.row(node, `${t("Price")} · ${k}`, {}, t("Outside"));
|
||||
b.pill(node, "in", { name: k, cell: at, outside: true });
|
||||
return at;
|
||||
});
|
||||
const fixed = item.비목 as Cost | undefined;
|
||||
const total = `${qty}*(${unit.join("+")})`;
|
||||
if (fixed && (COSTS as readonly string[]).includes(fixed)) {
|
||||
b.out(node, fixed, { 식: total });
|
||||
out.push([node, fixed]);
|
||||
lines.set(name, [node, fixed]);
|
||||
} else {
|
||||
COSTS.forEach((k, i) => {
|
||||
b.out(node, k, { 식: `${qty}*${unit[i]}` });
|
||||
out.push([node, k]);
|
||||
});
|
||||
b.out(node, t("Amount"), { 식: total });
|
||||
lines.set(name, [node, t("Amount")]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const price = b.row(node, t("Price"), {}, t("Outside"));
|
||||
b.pill(node, "in", { name: t("Price"), cell: price, outside: true });
|
||||
const holes = typeof item.요소 === "string" ? [item.요소] : Object.values(item.요소 ?? {});
|
||||
if (holes.some((v) => typeof v === "string" && /\{[^}]*\}/.test(v)))
|
||||
b.cant(node, price, t("Hole", element), element);
|
||||
const cost = (
|
||||
(COSTS as readonly string[]).includes(item.비목 ?? "") ? item.비목 : "재료비"
|
||||
) as Cost;
|
||||
b.out(node, cost, { 식: `${qty}*${price}` });
|
||||
lines.set(name, [node, cost]);
|
||||
return [[node, cost]];
|
||||
}
|
||||
|
||||
type Cut = { 대상?: string; 자리?: number; 방법?: string };
|
||||
const cutText = (row: LogicRow, on: string, x: string): string => {
|
||||
const rule = row.끝수 as unknown as Cut | null;
|
||||
if (!rule || typeof rule !== "object" || rule.대상 !== on) return x;
|
||||
const fn = { 올림: "ROUNDUP", 버림: "ROUNDDOWN", 반올림: "ROUND" }[String(rule.방법)];
|
||||
return fn ? `${fn}(${x},${rule.자리 ?? 0})` : x;
|
||||
};
|
||||
|
||||
/* ── 밖으로 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 기존 로직 → 시트 로직 + 못 바꾼 꼴 */
|
||||
export function importLogicReport(row: LogicRow, opts: SlImportOptions = {}): SlImportReport {
|
||||
const lb = readLogic(row, { key: row.키 || null, version: null, isBase: true });
|
||||
const b = new Build(opts);
|
||||
for (const spec of lb.inputs) inputBox(b, spec);
|
||||
for (const m of row.중간 ?? []) b.mids.set(m.이름, b.node("calc", m.이름, 2));
|
||||
for (const m of row.중간 ?? [])
|
||||
put({ b, here: b.mids.get(m.이름)! }, m.이름, m.식, "out", m.출처 ?? "");
|
||||
const result = b.node("result", t("Result"), 4);
|
||||
if (row.결과) {
|
||||
const at = put({ b, here: result }, t("Result"), row.결과.식, null);
|
||||
const s = b.sheet(result);
|
||||
if (s.칸[at].식 && s.칸[at].식 !== "NA()")
|
||||
s.칸[at] = { 식: cutText(row, "결과", s.칸[at].식!) };
|
||||
else if (s.칸[at].값 !== undefined)
|
||||
s.칸[at] = { 식: cutText(row, "결과", String(s.칸[at].값)) };
|
||||
b.pill(result, "out", { name: t("Result"), cell: at, unit: row.결과단위 });
|
||||
} else {
|
||||
const lines = new Map<string, [SlNode, string]>();
|
||||
const outs = (row.호표 ?? []).flatMap((item) => itemBox(b, item, lines));
|
||||
// 항목 합 — 덧줄이 「노무비」 등으로 부르는 호표 줄 비목 합
|
||||
const sum = b.node("calc", t("Sum"), 3);
|
||||
const costs = {} as Record<Cost, [SlNode, string][]>;
|
||||
for (const k of COSTS) {
|
||||
const refs = outs.filter(([, c]) => c === k).map(([n, c]) => b.link(sum, n, c));
|
||||
b.out(sum, k, refs.length ? { 식: `SUM(${refs.join(",")})` } : { 값: 0 });
|
||||
costs[k] = [[sum, k]];
|
||||
}
|
||||
const extras = new Map<string, [SlNode, string]>();
|
||||
for (const extra of row.덧줄 ?? []) {
|
||||
const cost = (
|
||||
(COSTS as readonly string[]).includes(extra.비목 ?? "") ? extra.비목 : "경비"
|
||||
) as Cost;
|
||||
const node = b.node("item", extra.이름, 3, "비율");
|
||||
put({ b, here: node, costs, extras, lines }, cost, extra.식, "out", extra.출처 ?? "");
|
||||
extras.set(extra.이름, [node, cost]);
|
||||
costs[cost] = [...costs[cost], [node, cost]];
|
||||
}
|
||||
const total: string[] = [];
|
||||
for (const k of COSTS) {
|
||||
const refs = costs[k].map(([n, c]) => b.link(result, n, c));
|
||||
const at = b.out(result, k, {
|
||||
식: cutText(row, "계", refs.length > 1 ? `SUM(${refs.join(",")})` : refs[0]),
|
||||
});
|
||||
total.push(at);
|
||||
}
|
||||
b.out(result, "계", { 식: `SUM(${total.join(",")})` }, row.결과단위);
|
||||
}
|
||||
b.book.활성 = b.book.시트[0]?.id;
|
||||
const sl: SheetLogic = {
|
||||
key: null,
|
||||
name: row.이름,
|
||||
resultUnit: row.결과단위,
|
||||
workbook: b.book,
|
||||
nodes: b.nodes,
|
||||
edges: b.edges,
|
||||
};
|
||||
return { sl, problems: b.problems };
|
||||
}
|
||||
|
||||
/** 기존 로직 → 시트 로직(정함 9) */
|
||||
export function importLogic(row: LogicRow, opts: SlImportOptions = {}): SheetLogic {
|
||||
return importLogicReport(row, opts).sl;
|
||||
}
|
||||
|
||||
/** 칸 값 — 서버 · 화면 계산값이 있으면 그것 · 아니면 넣은 값 */
|
||||
function valueAt(sl: SheetLogic, sheetId: string, cell: string, calc?: CalcValues): unknown {
|
||||
const got = calc?.[sheetId]?.[cell];
|
||||
if (got && "수" in got) return Number(got.수);
|
||||
if (got && "글" in got) return got.글;
|
||||
if (got && "참" in got) return got.참;
|
||||
return sl.workbook.시트.find((s) => s.id === sheetId)?.칸[cell]?.값 ?? null;
|
||||
}
|
||||
|
||||
/** 바깥 값 자리(`M01_SL_Api` 모양) — 넘길 입력값은 지금 칸 값(`calc` 가 있으면 그 값)으로 */
|
||||
export function outsideOf(sl: SheetLogic, calc?: CalcValues): SlOutsideAt[] {
|
||||
const out: SlOutsideAt[] = [];
|
||||
for (const node of sl.nodes) {
|
||||
const passed: Record<string, unknown> = {};
|
||||
const outside = node.pillsIn.filter((p) => p.outside);
|
||||
const sheet = sl.workbook.시트.find((s) => s.id === node.sheetId);
|
||||
for (const p of node.pillsIn) {
|
||||
const note = sheet?.칸[`C${p.cell.slice(1)}`]?.값;
|
||||
if (node.kind !== "table" && TEXT.Pass.includes(note as never))
|
||||
passed[p.name] = valueAt(sl, node.sheetId, p.cell, calc);
|
||||
}
|
||||
const ref = node.ref ?? "";
|
||||
for (const p of outside) {
|
||||
const id = `${node.id}:${p.name}`;
|
||||
const field = (["노무비", "재료비", "경비", "계"] as string[]).includes(p.name)
|
||||
? (p.name as SlField)
|
||||
: undefined;
|
||||
let item: SlOutside;
|
||||
if (node.kind === "table") item = { id, kind: "표", table: ref };
|
||||
else if (
|
||||
node.kind === "logic" ||
|
||||
ref.startsWith("GF") ||
|
||||
ref.startsWith("GC") ||
|
||||
ref.startsWith("GX")
|
||||
)
|
||||
item = { id, kind: "로직", key: ref, inputs: passed, ...(field ? { field } : {}) };
|
||||
else if (ref.startsWith("로직("))
|
||||
item = { id, kind: "장비", element: ref, inputs: passed, ...(field ? { field } : {}) };
|
||||
else if (ref.startsWith("LB")) item = { id, kind: "노임", key: ref };
|
||||
else item = { id, kind: "자재", key: ref };
|
||||
out.push({ item, sheet: node.sheetId, cell: p.cell });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
/* =============================================================================
|
||||
* M01_SL_Import_Parse.ts
|
||||
* 시트 로직 가져오기 — 엔진 문법(`master_formula._Parser`)으로 로직 식 글 읽기 · 나무 → `M01_SL_Import.ts` 가 엑셀 수식으로
|
||||
* 꼴 = 수 · '글' · 이름 · 찾기(표, 가=나).칸 · 로직(키, 가=나).칸 · 만약/최소/최대/올림/버림/반올림 · 키:… 요소 ·
|
||||
* - · * / · + - · 비교 · 그리고 · 또는 · 못 읽으면 null
|
||||
* 아래 끝 = 품셈 표 → 시트 칸 놓기(조건 칸 → 값 칸 · 범위는 아래 · 위 두 칸) — 바깥 값 「표」 모양과 같음
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
|
||||
export type Ast =
|
||||
| { t: "num"; v: string }
|
||||
| { t: "str"; v: string }
|
||||
| { t: "name"; v: string }
|
||||
| { t: "eref"; v: string }
|
||||
| { t: "neg"; a: Ast }
|
||||
| { t: "field"; a: Ast; f: string }
|
||||
| { t: "call"; f: string; items: Ast[] }
|
||||
| { t: "find"; head: string; args: [string, Ast][]; src: string }
|
||||
| { t: "logic"; head: string; args: [string, Ast][]; src: string }
|
||||
| { t: "bin"; op: string; a: Ast; b: Ast };
|
||||
|
||||
const CMPS = ["<=", ">=", "==", "!=", "<", ">"];
|
||||
const CALLS = ["만약", "최소", "최대", "올림", "버림", "반올림"];
|
||||
|
||||
export function parseFormula(s: string): Ast | 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 word = (): string => {
|
||||
ws();
|
||||
const m = /[\p{L}\p{N}_]+/uy;
|
||||
m.lastIndex = i;
|
||||
const got = m.exec(s) ?? fail();
|
||||
i = m.lastIndex;
|
||||
return got[0];
|
||||
};
|
||||
const quoted = (): string => {
|
||||
const e = s.indexOf("'", i);
|
||||
if (e < 0) fail();
|
||||
const v = s.slice(i, e);
|
||||
i = e + 1;
|
||||
return v;
|
||||
};
|
||||
/** 로직 · 표 머리 글 — 「,」 「)」 에서 멈춤(괄호 · 중괄호 안은 넘김) */
|
||||
const head = (): string => {
|
||||
ws();
|
||||
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;
|
||||
}
|
||||
return s.slice(st, i).trim();
|
||||
};
|
||||
const fields = (n: Ast): Ast => {
|
||||
while (take(".")) n = { t: "field", a: n, f: take("'") ? quoted() : word() };
|
||||
return n;
|
||||
};
|
||||
const primary = (): Ast => {
|
||||
ws();
|
||||
const st = i;
|
||||
if (take("(")) {
|
||||
const a = expr();
|
||||
need(")");
|
||||
return fields(a);
|
||||
}
|
||||
if (take("'")) return { t: "str", v: quoted() };
|
||||
const num = /\d+(?:\.\d+)?/y;
|
||||
num.lastIndex = i;
|
||||
const n = num.exec(s);
|
||||
if (n) {
|
||||
i = num.lastIndex;
|
||||
return { t: "num", v: n[0] };
|
||||
}
|
||||
const w = word();
|
||||
if (w === "찾기" || w === "로직") {
|
||||
need("(");
|
||||
const h = head();
|
||||
const args: [string, Ast][] = [];
|
||||
while (take(",")) {
|
||||
const k = take("'") ? quoted() : word();
|
||||
need("=");
|
||||
args.push([k, expr()]);
|
||||
}
|
||||
need(")");
|
||||
const call = { head: h, args, src: s.slice(st, i) };
|
||||
return fields(w === "찾기" ? { t: "find", ...call } : { t: "logic", ...call });
|
||||
}
|
||||
if (CALLS.includes(w)) {
|
||||
need("(");
|
||||
const items = [expr()];
|
||||
while (take(",")) items.push(expr());
|
||||
need(")");
|
||||
return fields({ t: "call", f: w, items });
|
||||
}
|
||||
if (s[i] === ":") {
|
||||
for (i++; i < s.length && !/[\s,)*/+-]/.test(s[i]); i++);
|
||||
return { t: "eref", v: s.slice(st, i) };
|
||||
}
|
||||
return fields({ t: "name", v: w });
|
||||
};
|
||||
const unary = (): Ast => (take("-") ? { t: "neg", a: unary() } : primary());
|
||||
const chain = (next: () => Ast, ops: string[], once = false): Ast => {
|
||||
let n = next();
|
||||
for (let op = ops.find(take); op; op = once ? undefined : ops.find(take))
|
||||
n = { t: "bin", op, a: n, b: next() };
|
||||
return n;
|
||||
};
|
||||
const mul = (): Ast => chain(unary, ["*", "/"]);
|
||||
const add = (): Ast => chain(mul, ["+", "-"]);
|
||||
const cmp = (): Ast => chain(add, CMPS, true);
|
||||
const both = (): Ast => chain(cmp, ["그리고"]);
|
||||
const expr = (): Ast => chain(both, ["또는"]);
|
||||
try {
|
||||
const node = expr();
|
||||
ws();
|
||||
return i === s.length ? node : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 품셈 표 칸 놓기 ─────────────────────────────────────────────────────── */
|
||||
|
||||
/** 가져올 때 같이 받는 품셈 표 — 서버 `/table` 답의 쓸 칸만 */
|
||||
export interface SlImportTable {
|
||||
키?: string;
|
||||
이름?: string;
|
||||
조건: Record<string, string>;
|
||||
값칸: Record<string, string>;
|
||||
범위규칙?: string | null;
|
||||
줄: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
/** 범위 칸 꼬리 — 한 · 영 */
|
||||
const LOW_HIGH = currentLanguageIndex === 1 ? ["low", "high"] : ["아래", "위"];
|
||||
|
||||
export const RULES = ["초과·이하", "이상·이하", "이상·미만", "이하"];
|
||||
|
||||
/** 표 칸 놓기 — 조건 칸 차례(범위는 아래 · 위 두 칸) → 값 칸 차례 */
|
||||
export function tableCols(
|
||||
table: SlImportTable,
|
||||
): { key: string; head: string; part: "v" | "lo" | "hi" }[] {
|
||||
const out: { key: string; head: string; part: "v" | "lo" | "hi" }[] = [];
|
||||
for (const [k, kind] of Object.entries(table.조건)) {
|
||||
if (kind === "범위") {
|
||||
out.push({ key: k, head: `${k}·${LOW_HIGH[0]}`, part: "lo" });
|
||||
out.push({ key: k, head: `${k}·${LOW_HIGH[1]}`, part: "hi" });
|
||||
} else out.push({ key: k, head: k, part: "v" });
|
||||
}
|
||||
for (const k of Object.keys(table.값칸))
|
||||
if (!(k in table.조건)) out.push({ key: k, head: k, part: "v" });
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 품셈 표 → 시트 칸(E1 부터 · 머리 줄 + 줄) — 바깥 값 모양(`M01_SL_Api` 「표」)과 같음 */
|
||||
export function tableRows(table: SlImportTable): (string | number | null)[][] {
|
||||
const cols = tableCols(table);
|
||||
const one = (v: unknown, part: "v" | "lo" | "hi"): string | number | null => {
|
||||
if (Array.isArray(v)) {
|
||||
if (part === "v") return v.map((x) => x ?? "").join("~");
|
||||
const x = v[part === "lo" ? 0 : 1];
|
||||
return typeof x === "number" ? x : null;
|
||||
}
|
||||
return typeof v === "number" || typeof v === "string" ? v : null;
|
||||
};
|
||||
return [cols.map((c) => c.head), ...table.줄.map((r) => cols.map((c) => one(r[c.key], c.part)))];
|
||||
}
|
||||
@@ -118,6 +118,13 @@ const TEXT = {
|
||||
PriceNote: ["바깥 값 — 열 때 넣음", "Outside value — filled on open"],
|
||||
LogicNote: ["다른 로직 결과 — 열 때 넣음", "Other logic's result — filled on open"],
|
||||
Sum: ["계", "Total"],
|
||||
PriceLabor: ["노무 단가", "Labor price"],
|
||||
PriceMat: ["재료 단가", "Material price"],
|
||||
PriceExp: ["경비 단가", "Expense price"],
|
||||
Labor3: ["노무비", "Labor cost"],
|
||||
Mat3: ["재료비", "Material cost"],
|
||||
Exp3: ["경비", "Expense"],
|
||||
Won: ["원", "KRW"],
|
||||
NoPill: ["알약이 없음", "No such pill"],
|
||||
Self: ["같은 상자끼리는 못 이음", "A box can't link to itself"],
|
||||
Loop: ["이으면 돌고 도는 선이 됨", "This line would make a loop"],
|
||||
@@ -136,6 +143,9 @@ const tl = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex ==
|
||||
/** 비목 이름(결과 · 항목 칸) — 화면 글 아님 · 알약 이름 열쇠 */
|
||||
const COSTS = ["노무비", "재료비", "경비"] as const;
|
||||
|
||||
/** 일위대가표가 읽는 출력 알약 이름(Item `USED` 와 같음) */
|
||||
const USED = "사용 수량";
|
||||
|
||||
/* ── 작은 도움 ──────────────────────────────────────────────────────────── */
|
||||
|
||||
const STAGE: Record<SlKind, number> = {
|
||||
@@ -228,24 +238,8 @@ function template(kind: SlKind, name: string): { rows: Row[]; ins: SlPill[]; out
|
||||
outs: [{ name: tl("Found"), cell: "B4" }],
|
||||
};
|
||||
case "item":
|
||||
return {
|
||||
rows: [
|
||||
[tl("Qty"), null, tl("QtyNote")],
|
||||
[tl("Price"), null, tl("PriceNote")],
|
||||
[tl("Amount"), f("B2*B3"), ""],
|
||||
["노무비", f("B4"), ""],
|
||||
["재료비", v(0), ""],
|
||||
["경비", v(0), ""],
|
||||
],
|
||||
ins: [
|
||||
{ name: tl("Qty"), cell: "B2" },
|
||||
{ name: tl("Price"), cell: "B3", outside: true },
|
||||
],
|
||||
outs: [
|
||||
{ name: tl("Amount"), cell: "B4" },
|
||||
...COSTS.map((c, i) => ({ name: c, cell: `B${5 + i}` })),
|
||||
],
|
||||
};
|
||||
// 항목 틀은 머리 줄 없음 — Item(sub5) 노무 틀과 한 벌(`itemSheet`)
|
||||
return { rows: [], ins: [], outs: [] };
|
||||
case "logic":
|
||||
return {
|
||||
rows: [[tl("Found"), null, tl("LogicNote")]],
|
||||
@@ -266,6 +260,52 @@ function template(kind: SlKind, name: string): { rows: Row[]; ins: SlPill[]; out
|
||||
}
|
||||
}
|
||||
|
||||
/** 항목 상자 틀 — `M01_SL_Item.ts` moneyLayout(노무)과 같은 칸 · 같은 알약(종류를 바꾸면 Item 이 다시 채움)
|
||||
* B1 수량 · B2~B4 노무/재료/경비 단가(노무는 B2 만 바깥 값) · B5 단가 · B6 금액 · B7~B9 비목 · B10 사용 수량 */
|
||||
function itemSheet(): { cells: Record<string, Cell>; rows: Row[]; ins: SlPill[]; outs: SlPill[] } {
|
||||
const won = tl("Won");
|
||||
const labels: [string, string][] = [
|
||||
["A1", tl("Qty")],
|
||||
["A2", tl("PriceLabor")],
|
||||
["A3", tl("PriceMat")],
|
||||
["A4", tl("PriceExp")],
|
||||
["A5", tl("Price")],
|
||||
["A6", tl("Amount")],
|
||||
["A7", tl("Labor3")],
|
||||
["A8", tl("Mat3")],
|
||||
["A9", tl("Exp3")],
|
||||
["A10", USED],
|
||||
];
|
||||
const cells: Record<string, Cell> = Object.fromEntries(labels.map(([k, v]) => [k, { 값: v }]));
|
||||
Object.assign(cells, {
|
||||
B3: { 값: 0 },
|
||||
B4: { 값: 0 },
|
||||
B5: { 식: "B2+B3+B4" },
|
||||
B6: { 식: "ROUND(B1*B5,1)" },
|
||||
B7: { 식: "ROUND(B1*B2,1)" },
|
||||
B8: { 식: "ROUND(B1*B3,1)" },
|
||||
B9: { 식: "ROUND(B1*B4,1)" },
|
||||
B10: { 식: "B1" },
|
||||
});
|
||||
const out = (name: string, cell: string, unit = won): SlPill => ({ name, cell, unit });
|
||||
return {
|
||||
cells,
|
||||
rows: [],
|
||||
ins: [
|
||||
{ name: tl("Qty"), cell: "B1", unit: "" },
|
||||
{ name: tl("PriceLabor"), cell: "B2", unit: won, outside: true },
|
||||
],
|
||||
outs: [
|
||||
out(tl("Price"), "B5"),
|
||||
out(tl("Amount"), "B6"),
|
||||
out(tl("Labor3"), "B7"),
|
||||
out(tl("Mat3"), "B8"),
|
||||
out(tl("Exp3"), "B9"),
|
||||
out(USED, "B10", ""),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/* ── 만들기 · 상자 ───────────────────────────────────────────────────────── */
|
||||
|
||||
/** 빈 시트 로직 */
|
||||
@@ -293,8 +333,9 @@ export function addNode(
|
||||
book.시트.map((s) => s.id),
|
||||
);
|
||||
const title = uniqueName(book, name);
|
||||
const t = template(kind, title);
|
||||
const 칸: Record<string, Cell> = {
|
||||
const item = kind === "item" ? itemSheet() : null;
|
||||
const t = item ?? template(kind, title);
|
||||
const 칸: Record<string, Cell> = item?.cells ?? {
|
||||
A1: { 값: tl("Name") },
|
||||
B1: { 값: tl("Value") },
|
||||
C1: { 값: tl("Note") },
|
||||
@@ -316,6 +357,7 @@ export function addNode(
|
||||
sheetId,
|
||||
pillsIn: t.ins,
|
||||
pillsOut: t.outs,
|
||||
...(item ? { ref: JSON.stringify({ what: "노무" }) } : {}),
|
||||
stage: STAGE[kind],
|
||||
};
|
||||
out.nodes.push(node);
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
"""M01 시트 로직 가져오기 시험(35-5 · 정함 9) — Node 로 `M01_SL_Import.ts` 를 바로 돌림.
|
||||
|
||||
· 견본 로직 줄 여섯(정함 9 시험 로직)은 이 시험 안에 둠 — 품셈 표 · 노임 · 자재 · 기존 로직 값은 마스터에서 읽기만
|
||||
· 가져온 시트 로직 → 바깥 값 넣기(파이썬 엔진이 마스터에서) → 우리 스프레드시트 엔진(Node 번들)으로 셈
|
||||
→ 합계 · 비목 = 지금 `/calc`(`M01_LB_Calc.calc` = `/calc` 와 같은 길) · 끝수 차이는 원 미만
|
||||
· 마스터 로직 1,469 개(자체 빼고) 모두 가져와 다 바뀌는 수를 세고 · 바뀐 통합문서를 엔진이 다 풂(#NAME? 없음)
|
||||
· 파일 안 씀 — 마스터는 읽기만 · 넘김은 표준입력
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
IMPORT = ROOT / "M01_MasterData" / "SheetLogic" / "M01_SL_Import.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|M01_SL_Import(_Parse)?|spreadsheet_(address|types))(\.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 : "
|
||||
"() => undefined });\n"
|
||||
)
|
||||
|
||||
|
||||
def _plain(x: object) -> object:
|
||||
return float(x) if isinstance(x, Decimal) else str(x)
|
||||
|
||||
|
||||
def _node(body: str, payload: object) -> object:
|
||||
"""`I` = Import 모듈 · `A` = 넘긴 것(표준입력) · `out(x)` 로 답"""
|
||||
script = (
|
||||
"import { register } from 'node:module';\n"
|
||||
"import { readFileSync } from 'node:fs';\n"
|
||||
f"register('data:text/javascript,' + encodeURIComponent({json.dumps(HOOKS)}));\n"
|
||||
+ STUB
|
||||
+ f"const I = await import({json.dumps(IMPORT.as_uri())});\n"
|
||||
"const A = JSON.parse(readFileSync(0, 'utf8'));\n"
|
||||
"const out = (x) => process.stdout.write(JSON.stringify(x));\n" + body
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--no-warnings", "--input-type=module", "-e", script],
|
||||
input=json.dumps(payload, ensure_ascii=False, default=_plain),
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=600, cwd=ROOT,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr[-3000:]
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
# ── 견본 줄 여섯(정함 9) + 넣을 값 ────────────────────────────────────────────
|
||||
H = {"원문번호": "", "출처": "자체", "소유": "공용", "끝수": None}
|
||||
SAMPLES = [
|
||||
(
|
||||
{**H, "키": "GF000208", "이름": "암거이음받침 채움재(T=20㎜)", "결과단위": "원/㎡", "입력": [], "중간": [],
|
||||
"호표": [{"종류": "재료", "요소": {"구분": "도료·방수·접착", "상세구분": "방수·실링", "검색어": "JOINT FILLER"},
|
||||
"이름": "JOINT FILLER(T=20㎜)", "단위": "㎡", "수량": "1", "비목": "재료비"}],
|
||||
"덧줄": []},
|
||||
{},
|
||||
),
|
||||
(
|
||||
{**H, "키": "GF000219", "이름": "돌쌓기 메쌓기(인력)", "결과단위": "원/㎡",
|
||||
"입력": [{"이름": "뒷길이", "단위": "㎝", "고르기": [25, 30, 35, 45, 55, 60, 75]},
|
||||
{"이름": "돌", "고르기": ["깬잡석", "견치돌", "깬돌", "호박돌 및 야면석"]},
|
||||
{"이름": "쌓기", "고르기": ["골쌓기", "켜쌓기"]},
|
||||
{"이름": "높이", "단위": "m"}, {"이름": "초과증가율", "단위": "%", "범위": [80, 100]}],
|
||||
"중간": [{"이름": "증가율", "식": "만약(높이 <= 3, 0, 만약(높이 <= 7.5, 찾기(QF000422, 높이=높이).증가율, 초과증가율))"}],
|
||||
"호표": [{"종류": "인력", "요소": "LB000033", "이름": "석공", "단위": "인", "비목": "노무비",
|
||||
"수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공 * (1 + 증가율 / 100)"},
|
||||
{"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "비목": "노무비",
|
||||
"수량": "찾기(QF000421, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).보통인부 * (1 + 증가율 / 100)"}],
|
||||
"덧줄": []},
|
||||
{"뒷길이": 30, "돌": "깬돌", "쌓기": "골쌓기", "높이": 4.5, "초과증가율": 80},
|
||||
),
|
||||
(
|
||||
{**H, "키": "GF000025", "이름": "떼붙임(재배잔디)", "결과단위": "원/100㎡",
|
||||
"입력": [{"이름": "품", "단위": "인/100㎡"}, {"이름": "비탈구배", "고르기": ["해당 없음", "평균 비탈구배 20˚~45˚ 이하"]}],
|
||||
"중간": [],
|
||||
"호표": [{"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "비목": "노무비",
|
||||
"수량": "품 * (1 + 만약(비탈구배 == '해당 없음', 0, 찾기(QF000099, 구분='비탈구배 해당 시 품 할증').값) / 100)"}],
|
||||
"덧줄": []},
|
||||
{"품": 2.5, "비탈구배": "평균 비탈구배 20˚~45˚ 이하"},
|
||||
),
|
||||
(
|
||||
{**H, "키": "GF000249", "이름": "다짐(플레이트콤펙터 1.5ton)", "결과단위": "원/㎥",
|
||||
"입력": [{"이름": "지역", "고르기": ["전국평균", "전국", "서울", "경기"]}],
|
||||
"중간": [{"이름": "작업량", "식": "로직(GF000114)"}],
|
||||
"호표": [{"종류": "로직", "요소": "로직(GC000272, 기계='1730-0015', 지역=지역)", "이름": "플레이트 콤팩터 1.5ton",
|
||||
"단위": "시간", "수량": "1 / 작업량"}],
|
||||
"덧줄": []},
|
||||
{"지역": "서울"},
|
||||
),
|
||||
(
|
||||
{**H, "키": "GF000133", "이름": "골재운반 덤프트럭 시간당 작업량", "결과단위": "㎥/시간",
|
||||
"입력": [{"이름": "qt", "단위": "㎥"}, {"이름": "f"}, {"이름": "E"}, {"이름": "Cm", "단위": "분"}],
|
||||
"중간": [], "결과": {"식": "반올림(60 * qt * f * E / Cm, 3)"}},
|
||||
{"qt": 6, "f": 1.2, "E": 0.9, "Cm": 25},
|
||||
),
|
||||
(
|
||||
{**H, "키": "GC000051", "이름": "보행자 안전통로 설치 및 해체", "결과단위": "원/m", "입력": [],
|
||||
"중간": [{"이름": "비계공", "식": "찾기(QC000115, 구분='비계공').수량"}],
|
||||
"호표": [{"종류": "인력", "요소": "LB000006", "이름": "비계공", "단위": "인", "수량": "비계공", "비목": "노무비"}],
|
||||
"덧줄": [{"이름": "공구손료 및 경장비", "비목": "경비",
|
||||
"식": "(비계공 * LB000006) * 찾기(QC000116, 구분='공구손료 및 경장비 기계경비(인력품 기준)').값 / 100"}]},
|
||||
{},
|
||||
),
|
||||
] # fmt: skip
|
||||
|
||||
|
||||
# ── 마스터(읽기만) ─────────────────────────────────────────────────────────
|
||||
@pytest.fixture(scope="module")
|
||||
def master():
|
||||
from M01_MasterData import M01_MasterData_Store as store
|
||||
|
||||
cm = store.cm
|
||||
files = cm.load(folder=store.FOLDER)
|
||||
tables = {
|
||||
str(t["키"]): t
|
||||
for data in files.values()
|
||||
if data.get("그룹") in cm.mf.TABLE_GROUPS
|
||||
for t in data.get("표") or []
|
||||
}
|
||||
return cm, files, tables
|
||||
|
||||
|
||||
def _tables_of(row: dict, tables: dict) -> dict:
|
||||
keys = set(re.findall(r"Q[A-Z]\d{6}", json.dumps(row, ensure_ascii=False)))
|
||||
return {k: tables[k] for k in keys if k in tables}
|
||||
|
||||
|
||||
def _num(v) -> float:
|
||||
return float(v["수"]) if isinstance(v, dict) and "수" in v else float("nan")
|
||||
|
||||
|
||||
def _fill(sl: dict, places: list, py_lines: list, master) -> None:
|
||||
"""바깥 값 넣기 — 표는 가져올 때 이미 · 노임 = 마스터 · 자재 = `/calc` 줄 단가 · 장비 · 로직 = 그 로직 `/calc`"""
|
||||
cm, files, _ = master
|
||||
m = cm.mf.Master(files)
|
||||
nodes = {n["id"]: n for n in sl["nodes"]}
|
||||
for at in places:
|
||||
item = at["item"]
|
||||
kind = item["kind"]
|
||||
if kind == "표":
|
||||
continue
|
||||
if kind == "노임":
|
||||
value = cm.mf.element(m, item["key"], {})[0]
|
||||
elif kind == "자재":
|
||||
name = nodes[item["id"].split(":")[0]]["name"]
|
||||
value = next(x["단가"] for x in py_lines if x["이름"] == name)
|
||||
else:
|
||||
key = (
|
||||
item["key"]
|
||||
if kind == "로직"
|
||||
else re.match(r"로직\((\w+)", item["element"]).group(1)
|
||||
)
|
||||
got = cm.mf.run(m, key, {k: Decimal(str(v)) if isinstance(v, (int, float)) else v
|
||||
for k, v in item["inputs"].items()}) # fmt: skip
|
||||
value = got["결과"] if "결과" in got else got[item.get("field") or "계"]
|
||||
if value is None: # 마스터에 값이 없음 — 바깥 값 칸을 비움(`/calc` 도 그 줄을 비우고 0)
|
||||
continue
|
||||
sheet = next(s for s in sl["workbook"]["시트"] if s["id"] == at["sheet"])
|
||||
sheet["칸"][at["cell"]] = {"값": float(value)}
|
||||
|
||||
|
||||
def _result_cells(sl: dict) -> dict:
|
||||
node = next(n for n in sl["nodes"] if n["kind"] == "result")
|
||||
return {p["name"]: (node["sheetId"], p["cell"]) for p in node["pillsOut"]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("row,given", SAMPLES, ids=[r["키"] for r, _ in SAMPLES])
|
||||
def test_가져온_시트로직_값이_calc_와_같음(row: dict, given: dict, master) -> None:
|
||||
from common_util.common_util_spreadsheet import recalc_workbooks
|
||||
from M01_MasterData.LogicBuilder import M01_LB_Calc as lb
|
||||
|
||||
rep = _node(
|
||||
"const r = I.importLogicReport(A.row, { tables: A.tables, examples: A.given });"
|
||||
"out({ ...r, places: I.outsideOf(r.sl) });",
|
||||
{"row": row, "tables": _tables_of(row, master[2]), "given": given},
|
||||
)
|
||||
assert rep["problems"] == []
|
||||
sl = rep["sl"]
|
||||
# 모양 — 상자 = 시트 · 선은 있는 상자 · 알약끼리
|
||||
sheets = {s["id"] for s in sl["workbook"]["시트"]}
|
||||
assert len(sheets) == len(sl["nodes"]) and all(n["sheetId"] in sheets for n in sl["nodes"])
|
||||
ids = {n["id"]: n for n in sl["nodes"]}
|
||||
for e in sl["edges"]:
|
||||
assert any(p["name"] == e["from"]["pill"] for p in ids[e["from"]["node"]]["pillsOut"])
|
||||
assert any(p["name"] == e["to"]["pill"] for p in ids[e["to"]["node"]]["pillsIn"])
|
||||
|
||||
want = lb.calc(row, given, {})
|
||||
assert want["ok"], want
|
||||
# 1차 셈 → 넘길 값(장비 · 로직 인자) 읽기 → 바깥 값 넣기 → 2차 셈
|
||||
first = recalc_workbooks([sl["workbook"]])[0]
|
||||
places = _node("out(I.outsideOf(A.sl, A.calc));", {"sl": sl, "calc": first})
|
||||
_fill(sl, places, want.get("lines", []), master)
|
||||
got = recalc_workbooks([sl["workbook"]])[0]
|
||||
cells = _result_cells(sl)
|
||||
read = {k: _num(got[s].get(c)) for k, (s, c) in cells.items()}
|
||||
print(f"\n{row['키']} 시트 {read} · /calc {want.get('result', want.get('sums'))}")
|
||||
if "result" in want:
|
||||
assert abs(read["결과"] - float(want["result"])) < 1e-9, (read, want["result"])
|
||||
return
|
||||
for k in ("노무비", "재료비", "경비", "계"):
|
||||
diff = abs(read[k] - float(want["sums"][k]))
|
||||
assert diff < 1, f"{row['키']} {k}: 시트 {read[k]} · /calc {want['sums'][k]}"
|
||||
|
||||
|
||||
def test_마스터_1469_가져오기_세기(master) -> None:
|
||||
from common_util.common_util_spreadsheet import recalc_workbooks
|
||||
|
||||
cm, files, tables = master
|
||||
rows = [
|
||||
r
|
||||
for name, data in files.items()
|
||||
if data.get("그룹") == "로직" and name != "로직_자체.json"
|
||||
for r in data.get("줄") or []
|
||||
]
|
||||
assert len(rows) == 1469
|
||||
got = _node(
|
||||
"const res = A.rows.map((row) => { try { const r = I.importLogicReport(row, { tables: A.tables });"
|
||||
"return { key: row.키, problems: r.problems, book: r.problems.length ? null : r.sl.workbook }; }"
|
||||
"catch (e) { return { key: row.키, problems: ['던짐 ' + e.message], book: null }; } });"
|
||||
"out(res);",
|
||||
{"rows": rows, "tables": tables},
|
||||
)
|
||||
thrown = [g["key"] for g in got if any(p.startswith("던짐") for p in g["problems"])]
|
||||
assert thrown == []
|
||||
full = [g for g in got if not g["problems"]]
|
||||
# 다 바뀐 것은 엔진이 다 풂 — 모르는 함수 · 이름(#NAME?) 없음
|
||||
values = recalc_workbooks([g["book"] for g in full])
|
||||
named = [
|
||||
g["key"] for g, v in zip(full, values) if "#NAME?" in json.dumps(v, ensure_ascii=False)
|
||||
]
|
||||
assert named == []
|
||||
reasons: dict[str, int] = {}
|
||||
for g in got:
|
||||
for p in g["problems"]:
|
||||
why = re.sub(r"「[^」]*」", "「…」", p.split(" — ", 1)[-1])
|
||||
reasons[why] = reasons.get(why, 0) + 1
|
||||
print(
|
||||
f"\n다 바뀜 {len(full)} / {len(rows)} · 못 바꾼 까닭 {sorted(reasons.items(), key=lambda x: -x[1])}"
|
||||
)
|
||||
assert len(full) > 1000
|
||||
@@ -71,7 +71,7 @@ let sl = M.newLogic("시험 하나", "원/㎡");
|
||||
let r = M.addNode(sl, "input", "품"); sl = r.sl; const pum = r.node;
|
||||
r = M.addNode(sl, "item", "보통 인부"); sl = r.sl; const man = r.node; man.ref = "LB000002";
|
||||
r = M.addNode(sl, "result", "결과"); sl = r.sl; const res = r.node;
|
||||
sl = E.applyOutside(sl, [{ node: pum.id, cell: "B2", value: 0.5 }, { node: man.id, cell: "B3", value: 100000 }]);
|
||||
sl = E.applyOutside(sl, [{ node: pum.id, cell: "B2", value: 0.5 }, { node: man.id, cell: "B2", value: 100000 }]);
|
||||
sl = link(sl, pum, "품", man, "수량");
|
||||
sl = link(sl, man, "노무비", res, "노무비");
|
||||
"""
|
||||
@@ -87,7 +87,7 @@ r = M.addNode(sl, "item", "보통인부"); sl = r.sl; const man = r.node;
|
||||
r = M.addNode(sl, "result", "결과"); sl = r.sl; const res = r.node;
|
||||
sl = E.applyOutside(sl, [
|
||||
{ node: pum.id, cell: "B2", value: 0.2 }, { node: rate.id, cell: "B2", value: 1.3 },
|
||||
{ node: stone.id, cell: "B3", value: 200000 }, { node: man.id, cell: "B3", value: 100000 },
|
||||
{ node: stone.id, cell: "B2", value: 200000 }, { node: man.id, cell: "B2", value: 100000 },
|
||||
]);
|
||||
sl = link(sl, pum, "품", calc, "값1");
|
||||
sl = link(sl, rate, "할증률", calc, "값2");
|
||||
@@ -101,12 +101,12 @@ sl = link(sl, man, "노무비", res, "노무비");
|
||||
def test_입력값_항목_결과_수식과_값() -> None:
|
||||
got = _run(
|
||||
ONE
|
||||
+ "out([sheet(sl, man.id).칸.B2, sheet(sl, res.id).칸.B2, E.evalLogic(sl), sl.edges, sl.nodes.map(n => [n.kind, n.stage]),"
|
||||
+ "out([sheet(sl, man.id).칸.B1, sheet(sl, res.id).칸.B2, E.evalLogic(sl), sl.edges, sl.nodes.map(n => [n.kind, n.stage]),"
|
||||
" sl.workbook.시트.map(s => s.이름)]);"
|
||||
)
|
||||
qty, total_in, ev, edges, kinds, names = got
|
||||
assert qty == {"식": "품!B2"}
|
||||
assert total_in == {"식": "'보통 인부'!B5"}
|
||||
assert total_in == {"식": "'보통 인부'!B7"}
|
||||
assert ev["result"] == {"노무비": 50000, "재료비": None, "경비": None, "계": 50000}
|
||||
assert ev["pills"]["n2"]["금액"] == 50000 and ev["pillsIn"]["n2"]["수량"] == 0.5
|
||||
assert ev["errors"] == []
|
||||
@@ -132,7 +132,7 @@ def test_계산_두_항목_더함_값바뀜() -> None:
|
||||
# 석공 0.2×1.3×200000 = 52000 · 보통인부 0.2×100000 = 20000
|
||||
assert a == {"노무비": 72000, "재료비": None, "경비": None, "계": 72000}
|
||||
assert c == {"결과": 0.26}
|
||||
assert cell == {"식": "석공!B5+보통인부!B5"}
|
||||
assert cell == {"식": "석공!B7+보통인부!B7"}
|
||||
assert b["계"] == 0.2 * 1.5 * 200000 + 20000
|
||||
|
||||
|
||||
@@ -146,7 +146,7 @@ def test_선_끊기_다시_잇기() -> None:
|
||||
" E.evalLogic(c).result, a.edges.length]);"
|
||||
)
|
||||
cell_a, ra, cell_b, check_b, rc, n_edges = got
|
||||
assert cell_a == {"식": "보통인부!B5"} and ra["계"] == 20000
|
||||
assert cell_a == {"식": "보통인부!B7"} and ra["계"] == 20000
|
||||
assert cell_b is None
|
||||
assert "결과 상자에 이어진 선이 없음" in check_b
|
||||
assert not any("입력 알약" in x for x in check_b)
|
||||
@@ -202,7 +202,7 @@ def test_바깥값_칸목록과_표_덩어리() -> None:
|
||||
)
|
||||
cells, e1, f2, e3, f3, lg, check = got
|
||||
assert cells == [
|
||||
{"id": "n2!B3", "node": "n2", "cell": "B3", "kind": "price", "ref": "LB000002"},
|
||||
{"id": "n2!B2", "node": "n2", "cell": "B2", "kind": "price", "ref": "LB000002"},
|
||||
{
|
||||
"id": "n4!E1",
|
||||
"node": "n4",
|
||||
@@ -230,16 +230,16 @@ def test_알약_정하기_이름바꾸면_선이_따라감() -> None:
|
||||
ONE + "let s2 = M.setPill(sl, pum.id, 'B2', 'out', '품셈 품');\n"
|
||||
"const e = s2.edges[0].from;\n"
|
||||
"s2 = M.setPill(s2, pum.id, 'D2', 'out', '품셈 품');\n"
|
||||
"const moved = sheet(s2, man.id).칸.B2;\n"
|
||||
"const moved = sheet(s2, man.id).칸.B1;\n"
|
||||
"const s3 = M.removePill(s2, pum.id, 'out', '품셈 품');\n"
|
||||
"const s4 = M.setPill(sl, man.id, 'C9', 'in', '할증');\n"
|
||||
"out([e, moved, s3.edges.length, sheet(s3, man.id).칸.B2 ?? null, s4.nodes[1].pillsIn.map(p => [p.name, p.cell])]);"
|
||||
"out([e, moved, s3.edges.length, sheet(s3, man.id).칸.B1 ?? null, s4.nodes[1].pillsIn.map(p => [p.name, p.cell])]);"
|
||||
)
|
||||
e, moved, n_edges, cleared, ins = got
|
||||
assert e == {"node": "n1", "pill": "품셈 품"}
|
||||
assert moved == {"식": "품!D2"}
|
||||
assert n_edges == 1 and cleared is None
|
||||
assert ins == [["수량", "B2"], ["단가", "B3"], ["할증", "C9"]]
|
||||
assert ins == [["수량", "B1"], ["노무 단가", "B2"], ["할증", "C9"]]
|
||||
|
||||
|
||||
def test_상자_지우기() -> None:
|
||||
@@ -250,7 +250,7 @@ def test_상자_지우기() -> None:
|
||||
)
|
||||
names, sheets, n_edges, cell, total, before = got
|
||||
assert names == ["품", "할증률", "할증 품", "보통인부", "결과"] and sheets == names
|
||||
assert n_edges == 4 and cell == {"식": "보통인부!B5"} and total == 20000
|
||||
assert n_edges == 4 and cell == {"식": "보통인부!B7"} and total == 20000
|
||||
assert before == 6 # 받은 것은 안 바뀜
|
||||
|
||||
|
||||
@@ -288,11 +288,11 @@ def test_상자_이름_바꾸기_수식이_따라감() -> None:
|
||||
)
|
||||
why, name, sheet_name, cell, total, tries, before = got
|
||||
assert why is None and name == sheet_name == "석공 (돌쌓기)"
|
||||
assert cell == {"식": "'석공 (돌쌓기)'!B5+보통인부!B5"} and total == 72000
|
||||
assert cell == {"식": "'석공 (돌쌓기)'!B7+보통인부!B7"} and total == 72000
|
||||
assert tries == [
|
||||
"같은 이름 상자가 있음",
|
||||
"이름은 1~31 글자",
|
||||
"이름에 못 쓰는 글자 — [ ] : * ? / \ 와 앞뒤 '",
|
||||
"이름에 못 쓰는 글자 — [ ] : * ? / \\ 와 앞뒤 '",
|
||||
"상자가 없음",
|
||||
"이름은 1~31 글자",
|
||||
]
|
||||
@@ -316,7 +316,7 @@ def test_바깥값_답을_넣을값으로_범위머리() -> None:
|
||||
"const cells = E.outsideCells(sl);\n"
|
||||
"const head = M.rangeHead('높이');\n"
|
||||
"const got = E.answersToValues(cells, [\n"
|
||||
" { id: 'n2!B3', ok: true, value: 172068 },\n"
|
||||
" { id: 'n2!B2', ok: true, value: 172068 },\n"
|
||||
" { id: 'n4!E1', ok: true, rows: [[...head, '증가율'], [3, 4, 30]] },\n"
|
||||
"]);\n"
|
||||
"const s2 = E.applyOutside(sl, got.values);\n"
|
||||
@@ -325,10 +325,39 @@ def test_바깥값_답을_넣을값으로_범위머리() -> None:
|
||||
head, got_values, f1, g2, total = got
|
||||
assert head == ["높이·아래", "높이·위"]
|
||||
assert got_values["errors"] == []
|
||||
assert got_values["values"][0] == {"node": "n2", "cell": "B3", "value": 172068}
|
||||
assert got_values["values"][0] == {"node": "n2", "cell": "B2", "value": 172068}
|
||||
assert (f1, g2, total) == ({"값": "높이·위"}, {"값": 30}, 86034)
|
||||
missing = _run(
|
||||
ONE
|
||||
+ "out(E.answersToValues(E.outsideCells(sl), [{ id: 'n2!B3', ok: false, reason: '없는 직종' }]));"
|
||||
+ "out(E.answersToValues(E.outsideCells(sl), [{ id: 'n2!B2', ok: false, reason: '없는 직종' }]));"
|
||||
)
|
||||
assert missing == {"values": [], "errors": ["B3 칸 바깥 값을 못 받음 없는 직종"]}
|
||||
assert missing == {"values": [], "errors": ["B2 칸 바깥 값을 못 받음 없는 직종"]}
|
||||
|
||||
|
||||
def test_항목_틀은_Item_노무_틀과_같은_칸() -> None:
|
||||
got = _run(
|
||||
"let sl = M.newLogic(); const r = M.addNode(sl, 'item', '석공');"
|
||||
" out([sheet(r.sl, r.node.id).칸, r.node.pillsIn, r.node.pillsOut.map(p => [p.name, p.cell]), r.node.ref]);"
|
||||
)
|
||||
cells, ins, outs, ref = got
|
||||
assert {k: v for k, v in cells.items() if k.startswith("B")} == {
|
||||
"B3": {"값": 0},
|
||||
"B4": {"값": 0},
|
||||
"B5": {"식": "B2+B3+B4"},
|
||||
"B6": {"식": "ROUND(B1*B5,1)"},
|
||||
"B7": {"식": "ROUND(B1*B2,1)"},
|
||||
"B8": {"식": "ROUND(B1*B3,1)"},
|
||||
"B9": {"식": "ROUND(B1*B4,1)"},
|
||||
"B10": {"식": "B1"},
|
||||
}
|
||||
assert [cells[f"A{i}"]["값"] for i in range(1, 11)] == [
|
||||
"수량", "노무 단가", "재료 단가", "경비 단가", "단가", "금액", "노무비", "재료비", "경비", "사용 수량",
|
||||
] # fmt: skip
|
||||
assert ins == [
|
||||
{"name": "수량", "cell": "B1", "unit": ""},
|
||||
{"name": "노무 단가", "cell": "B2", "unit": "원", "outside": True},
|
||||
]
|
||||
assert outs == [
|
||||
["단가", "B5"], ["금액", "B6"], ["노무비", "B7"], ["재료비", "B8"], ["경비", "B9"], ["사용 수량", "B10"],
|
||||
] # fmt: skip
|
||||
assert ref == '{"what":"노무"}'
|
||||
|
||||
Reference in New Issue
Block a user