Merge remote-tracking branch 'origin/dev' into sub_laptop_1
This commit is contained in:
@@ -19,15 +19,15 @@
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { colName, quoteSheet } from "../../A00_Common/spreadsheet/spreadsheet_address";
|
||||
import type {
|
||||
CalcValues,
|
||||
Cell,
|
||||
DataValidationKind,
|
||||
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 { applyItemLayout, refreshBases, type ItemRef } from "./M01_SL_Item";
|
||||
import {
|
||||
parseFormula,
|
||||
RULES,
|
||||
@@ -64,6 +64,7 @@ const TEXT = {
|
||||
Field: ["요소의 칸 값 「{v}」", "Element field “{v}”"],
|
||||
FindAll: ["표 줄을 통째로 씀", "Uses a whole table row"],
|
||||
Parse: ["읽을 수 없는 글", "Text that can't be read"],
|
||||
PassCalc: ["넘길 값에 계산이 들어 있음", "A passed value is calculated"],
|
||||
} as const;
|
||||
const t = (k: keyof typeof TEXT, v = ""): string =>
|
||||
TEXT[k][currentLanguageIndex === 1 ? 1 : 0].replace("{v}", v);
|
||||
@@ -75,6 +76,8 @@ export interface SlImportOptions {
|
||||
tables?: Record<string, SlImportTable>;
|
||||
/** 입력값 → 예시값 — 없으면 선택 목록 첫째 · 범위 낮은 끝 · 숫자 0 */
|
||||
examples?: Record<string, number | string>;
|
||||
/** 키 → 보일 이름(직종 · 자재 · 다른 로직) — 상자 이름에 번호 대신 */
|
||||
names?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface SlImportReport {
|
||||
@@ -120,20 +123,26 @@ class Build {
|
||||
readonly tables = new Map<string, TableBox>();
|
||||
readonly logics = new Map<string, SlNode>();
|
||||
readonly consts = new Map<string, SlNode>();
|
||||
readonly specs = new Map<string, LogicInput>();
|
||||
/** 키 → 보일 이름 — 호표 줄 이름 · 받은 이름 */
|
||||
readonly names = new Map<string, string>();
|
||||
|
||||
constructor(readonly opts: SlImportOptions) {}
|
||||
constructor(readonly opts: SlImportOptions) {
|
||||
for (const [k, v] of Object.entries(opts.names ?? {})) this.names.set(k, v);
|
||||
}
|
||||
|
||||
node(kind: SlKind, name: string, stage: number, ref?: string): SlNode {
|
||||
/** 상자 하나 + 시트 — `head` 면 1행 머리(이름 · 값 · 설명) · 항목 틀은 머리 없음 */
|
||||
node(kind: SlKind, name: string, stage: number, ref?: string, head = true): 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);
|
||||
const word = { 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(word)} · ${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") } },
|
||||
칸: head ? { A1: { 값: t("Name") }, B1: { 값: t("Value") }, C1: { 값: t("Note") } } : {},
|
||||
};
|
||||
this.book.시트.push(sheet);
|
||||
this.sheets.set(sheet.id, sheet);
|
||||
@@ -306,7 +315,7 @@ function put(sc: Scope, label: string, text: string, pill: "out" | null, note =
|
||||
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 node = b.node("const", b.names.get(key) ?? 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 });
|
||||
@@ -340,16 +349,17 @@ function logicBox(b: Build, n: Extract<Ast, { t: "logic" }>, field?: string): Sl
|
||||
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);
|
||||
node = b.node("logic", b.names.get(n.head) ?? n.head, 1, 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 });
|
||||
}
|
||||
const money = ["노무비", "재료비", "경비", "계"];
|
||||
for (const name of field && money.includes(field) ? money : [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;
|
||||
}
|
||||
|
||||
@@ -374,7 +384,7 @@ function newTable(b: Build, n: Extract<Ast, { t: "find" }>, sig: string): TableB
|
||||
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 node = b.node("table", table.이름 || b.names.get(n.head) || n.head, 1, n.head);
|
||||
const s = b.sheet(node);
|
||||
// 조건 알약 — 받는 값은 넘긴 식
|
||||
const cond = new Map<string, string>();
|
||||
@@ -383,7 +393,6 @@ function newTable(b: Build, n: Extract<Ast, { t: "find" }>, sig: string): TableB
|
||||
// 표 E1 부터
|
||||
const cols = tableCols(table);
|
||||
const rows = tableRows(table);
|
||||
b.row(node, t("Table"), { 값: n.head }, t("Outside"));
|
||||
// 바깥 값 「표」 알약 — Table 상자와 같은 꼴(출력 · 블록 왼위 · 머리 줄 포함) · Eval · 서버가 이 칸에서 블록을 채움
|
||||
b.pill(node, "out", { name: "표", cell: "E1", outside: true });
|
||||
rows.forEach((r, i) =>
|
||||
@@ -394,8 +403,13 @@ function newTable(b: Build, n: Extract<Ast, { t: "find" }>, sig: string): TableB
|
||||
);
|
||||
const letter = (key: string, part: "v" | "lo" | "hi"): string =>
|
||||
colName(4 + cols.findIndex((c) => c.key === key && c.part === part));
|
||||
const N = table.줄.length;
|
||||
// 마스터 표 줄이 늘어도 따라가게 — 줄 수 이상 · 최소 20 줄까지 셈 · 「표 줄 수」 칸 밖 줄은 안 맞음
|
||||
const N = Math.max(20, table.줄.length);
|
||||
const range = (L: string): string => `$${L}$2:$${L}$${N + 1}`;
|
||||
const count = b.row(node, "표 줄 수", {
|
||||
식: `MAX(${cols.map((_, j) => `COUNTA(${range(colName(4 + j))})`).join(",")})`,
|
||||
});
|
||||
const last = `$B$${count.slice(1)}`;
|
||||
let free = 4 + cols.length + 1;
|
||||
// 맞음 — 줄마다 조건 셈(빈 칸은 무엇이든 맞음 · 범위는 규칙대로)
|
||||
const M = colName(free++);
|
||||
@@ -424,7 +438,8 @@ function newTable(b: Build, n: Extract<Ast, { t: "find" }>, sig: string): TableB
|
||||
: `OR(${H}="",${x}<=${H})`;
|
||||
return `AND(OR(${L}="",${x}${low}${L}),${up})`;
|
||||
});
|
||||
s.칸[`${M}${r}`] = { 식: parts.length ? `IF(AND(${parts.join(",")}),1,0)` : "1" };
|
||||
const fit = parts.length ? `IF(AND(${parts.join(",")}),1,0)` : "1";
|
||||
s.칸[`${M}${r}`] = { 식: `IF(${r - 1}>${last},0,${fit})` };
|
||||
}
|
||||
// 여러 줄이 맞으면 위 끝이 가장 작은 줄(규칙 이하 · 이상·이하 — 엔진 `find`)
|
||||
let hit = M;
|
||||
@@ -484,27 +499,14 @@ function inputBox(b: Build, spec: LogicInput): void {
|
||||
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 rule: DataValidationKind | null = spec.고르기?.length
|
||||
? { 종류: "목록", 값들: spec.고르기.map(String) }
|
||||
: spec.범위 ? { 종류: "수범위", 아래: spec.범위[0], 위: spec.범위[1] } : null; // prettier-ignore
|
||||
if (rule) b.sheet(node).유효성 = [{ id: "v1", 범위: [whole], 규칙: rule, 어기면: "막음" }];
|
||||
const pill = node.pillsOut[0];
|
||||
if (spec.단위) pill.unit = spec.단위;
|
||||
b.inputs.set(spec.이름, node);
|
||||
b.specs.set(spec.이름, spec);
|
||||
}
|
||||
|
||||
/** 수량 — 숫자 · 이름 하나면 바로 · 식이면 계산 상자 「이름 수량」 → [상자, 알약] */
|
||||
@@ -519,54 +521,90 @@ function qtyFrom(b: Build, item: HoLine, name: string): [SlNode, string] | numbe
|
||||
return [node, label];
|
||||
}
|
||||
|
||||
/** 항목 상자(호표 줄) → 비목 출력 알약들 */
|
||||
/** 항목 틀 — Item `applyItemLayout` 으로 그 상자 시트 · 알약을 채움(Item 이 알아보는 꼴 한 벌) */
|
||||
function layout(b: Build, node: SlNode, ref: ItemRef, qty?: number): void {
|
||||
const sheet = b.sheet(node);
|
||||
const mini = { key: null, name: "", resultUnit: "", workbook: { ...b.book, 시트: [sheet] }, nodes: [node], edges: [] }; // prettier-ignore
|
||||
const got = applyItemLayout(mini, node, ref, qty);
|
||||
sheet.칸 = got.workbook.시트[0].칸;
|
||||
const 유효성 = got.workbook.시트[0].유효성;
|
||||
if (유효성) sheet.유효성 = 유효성;
|
||||
Object.assign(node, got.nodes[0]);
|
||||
}
|
||||
|
||||
/** 항목 상자(호표 줄) — Item 틀 그대로(node.ref = ItemRef) → 비목 출력 알약들 */
|
||||
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")]);
|
||||
const el: unknown = item.요소;
|
||||
const call = typeof el === "string" && el.startsWith("로직(") ? parseFormula(el) : null;
|
||||
const logic = call?.t === "logic" ? call : null;
|
||||
const what: ItemRef["what"] = logic
|
||||
? logic.args.some(([k]) => k === "기계") ? "장비" : "일위대가"
|
||||
: item.종류 === "인력" ? "노무" : "재료"; // prettier-ignore
|
||||
// 넘길 값 — 입력값 이름은 예시값으로(Item 이 ref.inputs 로 바깥 값을 받음) · 글 · 수는 요소 글 안에 그대로
|
||||
const need: LogicInput[] = [];
|
||||
const inputs: Record<string, unknown> = {};
|
||||
const bad: string[] = [];
|
||||
for (const [k, v] of logic?.args ?? []) {
|
||||
if (v.t !== "name") {
|
||||
if (v.t !== "num" && v.t !== "str") bad.push(k);
|
||||
continue;
|
||||
}
|
||||
const spec = b.specs.get(v.v);
|
||||
if (!spec) bad.push(k);
|
||||
else {
|
||||
need.push({ ...spec, 이름: k });
|
||||
inputs[k] = example(spec, b.opts.examples?.[spec.이름]).값;
|
||||
}
|
||||
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.요소 ?? {});
|
||||
const element = what === "재료" && typeof el === "string" ? { 키: el } : el;
|
||||
const ref: ItemRef = {
|
||||
what,
|
||||
element,
|
||||
...(item.규격 ? { spec: item.규격 } : {}),
|
||||
...(item.단위 ? { unit: item.단위 } : {}),
|
||||
...(need.length ? { need, inputs } : {}),
|
||||
};
|
||||
const node = b.node("item", name, 3, JSON.stringify(ref), false);
|
||||
const outside: Cost[] = what === "노무" ? ["노무비"] : what === "재료" ? ["재료비"] : [...COSTS];
|
||||
const q = qtyFrom(b, item, name);
|
||||
layout(b, node, ref, typeof q === "number" ? q : undefined);
|
||||
if (typeof q !== "number") b.link(node, q[0], q[1], node.pillsIn[0]);
|
||||
const holes = typeof el === "string" ? [el] : Object.values(el ?? {});
|
||||
const priceAt = what === "재료" ? "B3" : "B2";
|
||||
if (holes.some((v) => typeof v === "string" && /\{[^}]*\}/.test(v)))
|
||||
b.cant(node, price, t("Hole", element), element);
|
||||
b.cant(node, priceAt, t("Hole", typeof el === "string" ? el : JSON.stringify(el)), String(el));
|
||||
if (bad.length) b.cant(node, priceAt, t("PassCalc"), bad.join(", "));
|
||||
lines.set(name, [node, "금액"]);
|
||||
return outside.map((c): [SlNode, Cost] => [node, c]);
|
||||
}
|
||||
|
||||
/** 덧줄 — 「노무비 * 3 / 100」 꼴이면 Item 비율 틀 · 아니면 식 그대로 계산하는 항목(금액 · 비목 · 단가 알약) */
|
||||
const RATE_RE = /^\(?(노무비|재료비|경비)\)?\*(\d+(?:\.\d+)?)\/100$/;
|
||||
|
||||
function extraBox(
|
||||
b: Build,
|
||||
extra: { 이름: string; 식: string; 비목?: string; 출처?: string },
|
||||
sc: Omit<Scope, "here">,
|
||||
): [SlNode, Cost] {
|
||||
const cost = (
|
||||
(COSTS as readonly string[]).includes(item.비목 ?? "") ? item.비목 : "재료비"
|
||||
(COSTS as readonly string[]).includes(extra.비목 ?? "") ? extra.비목 : "경비"
|
||||
) as Cost;
|
||||
b.out(node, cost, { 식: `${qty}*${price}` });
|
||||
lines.set(name, [node, cost]);
|
||||
return [[node, cost]];
|
||||
const m = RATE_RE.exec(extra.식.replace(/\s+/g, ""));
|
||||
if (m) {
|
||||
const base = m[1] as Cost;
|
||||
const ref: ItemRef = { what: "비율", base, into: cost };
|
||||
const node = b.node("item", extra.이름, 3, JSON.stringify(ref), false);
|
||||
b.sheet(node).칸.B1 = { 값: Number(m[2]) }; // 율 — Item 이 이 칸을 살림
|
||||
layout(b, node, ref);
|
||||
return [node, cost];
|
||||
}
|
||||
const node = b.node("item", extra.이름, 3);
|
||||
const at = put({ ...sc, here: node }, "금액", extra.식, "out", extra.출처 ?? "");
|
||||
b.out(node, "단가", { 식: at });
|
||||
b.out(node, cost, { 식: at });
|
||||
b.out(node, "사용 수량", { 값: 1 });
|
||||
return [node, cost];
|
||||
}
|
||||
|
||||
type Cut = { 대상?: string; 자리?: number; 방법?: string };
|
||||
@@ -598,8 +636,10 @@ export function importLogicReport(row: LogicRow, opts: SlImportOptions = {}): Sl
|
||||
b.pill(result, "out", { name: t("Result"), cell: at, unit: row.결과단위 });
|
||||
} else {
|
||||
const lines = new Map<string, [SlNode, string]>();
|
||||
for (const h of row.호표 ?? [])
|
||||
if (typeof h.요소 === "string" && h.이름 && !b.names.has(h.요소)) b.names.set(h.요소, h.이름);
|
||||
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) {
|
||||
@@ -608,27 +648,32 @@ export function importLogicReport(row: LogicRow, opts: SlImportOptions = {}): Sl
|
||||
costs[k] = [[sum, k]];
|
||||
}
|
||||
const extras = new Map<string, [SlNode, string]>();
|
||||
const into: [SlNode, Cost][] = [...outs];
|
||||
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.출처 ?? "");
|
||||
const [node, cost] = extraBox(b, extra, { b, costs, extras, lines });
|
||||
extras.set(extra.이름, [node, cost]);
|
||||
costs[cost] = [...costs[cost], [node, cost]];
|
||||
into.push([node, cost]);
|
||||
}
|
||||
const total: string[] = [];
|
||||
// 결과 = Model 결과 틀 — B2~B4 노무비 · 재료비 · 경비 입력 알약(선이 여럿이면 +) · B5 계
|
||||
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);
|
||||
const at = b.row(result, k, {});
|
||||
b.pill(result, "in", { name: k, cell: at });
|
||||
const refs = into
|
||||
.filter(([, c]) => c === k)
|
||||
.map(([n]) => {
|
||||
const src = n.pillsOut.find((p) => p.name === k)!;
|
||||
const to = { node: result.id, pill: k };
|
||||
b.edges.push({ id: `e${b.edges.length + 1}`, from: { node: n.id, pill: k }, to });
|
||||
return `${quoteSheet(b.sheet(n).이름)}!${src.cell}`;
|
||||
});
|
||||
if (refs.length) b.sheet(result).칸[at] = { 식: refs.join("+") };
|
||||
}
|
||||
b.out(result, "계", { 식: `SUM(${total.join(",")})` }, row.결과단위);
|
||||
const parts = COSTS.map((_, i) => cutText(row, "계", `B${2 + i}`));
|
||||
b.out(result, "계", { 식: parts.join("+") }, row.결과단위);
|
||||
}
|
||||
b.book.활성 = b.book.시트[0]?.id;
|
||||
const sl: SheetLogic = {
|
||||
const built: SheetLogic = {
|
||||
key: null,
|
||||
name: row.이름,
|
||||
resultUnit: row.결과단위,
|
||||
@@ -636,6 +681,8 @@ export function importLogicReport(row: LogicRow, opts: SlImportOptions = {}): Sl
|
||||
nodes: b.nodes,
|
||||
edges: b.edges,
|
||||
};
|
||||
// 비율 항목 바탕 = Item 과 같은 셈(다른 항목 상자의 그 비목 합 · 선 함께)
|
||||
const sl = refreshBases(built);
|
||||
return { sl, problems: b.problems };
|
||||
}
|
||||
|
||||
@@ -644,48 +691,4 @@ export function importLogic(row: LogicRow, opts: SlImportOptions = {}): SheetLog
|
||||
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, ...node.pillsOut].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;
|
||||
}
|
||||
export { outsideOf } from "./M01_SL_Import_Parse";
|
||||
|
||||
@@ -3,10 +3,15 @@
|
||||
* 시트 로직 가져오기 — 엔진 문법(`master_formula._Parser`)으로 로직 식 글 읽기 · 나무 → `M01_SL_Import.ts` 가 엑셀 수식으로
|
||||
* 꼴 = 수 · '글' · 이름 · 찾기(표, 가=나).칸 · 로직(키, 가=나).칸 · 만약/최소/최대/올림/버림/반올림 · 키:… 요소 ·
|
||||
* - · * / · + - · 비교 · 그리고 · 또는 · 못 읽으면 null
|
||||
* 아래 끝 = 품셈 표 → 시트 칸 놓기(조건 칸 → 값 칸 · 범위는 아래 · 위 두 칸) — 바깥 값 「표」 모양과 같음
|
||||
* 아래 = 품셈 표 → 시트 칸 놓기(조건 칸 → 값 칸 · 범위는 아래 · 위 두 칸 — 바깥 값 「표」 모양과 같음) ·
|
||||
* 바깥 값 자리(`outsideOf`)
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import type { CalcValues } from "../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import type { SheetLogic, SlNode } from "./M01_SL_Model";
|
||||
import type { SlField, SlOutside, SlOutsideAt } from "./M01_SL_Api";
|
||||
import { itemRefOf, type ItemRef } from "./M01_SL_Item";
|
||||
|
||||
export type Ast =
|
||||
| { t: "num"; v: string }
|
||||
@@ -184,3 +189,85 @@ export function tableRows(table: SlImportTable): (string | number | null)[][] {
|
||||
};
|
||||
return [cols.map((c) => c.head), ...table.줄.map((r) => cols.map((c) => one(r[c.key], c.part)))];
|
||||
}
|
||||
|
||||
/* ── 바깥 값 자리 ─────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 넘길 값 칸 표시(C 칸) — `M01_SL_Import` TEXT.Pass 한 · 영 */
|
||||
const PASS = ["넘길 값", "Passed value"];
|
||||
const COST3 = ["노무비", "재료비", "경비"] as const;
|
||||
|
||||
/** 칸 값 — 서버 · 화면 계산값이 있으면 그것 · 아니면 넣은 값 */
|
||||
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, ...node.pillsOut].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" && PASS.includes(String(note)))
|
||||
passed[p.name] = valueAt(sl, node.sheetId, p.cell, calc);
|
||||
}
|
||||
const ref = node.ref ?? "";
|
||||
const item = itemRefOf(node);
|
||||
if (item) {
|
||||
out.push(...itemOutside(node, item));
|
||||
continue;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/** 항목 상자 바깥 값 — Item `itemOutsides` 와 같은 칸 · 자재 검색어 묶음은 key = 묶음 JSON 글(서버가 「{」 로 풂) */
|
||||
function itemOutside(node: SlNode, ref: ItemRef): SlOutsideAt[] {
|
||||
const at = (cell: string, item: SlOutside): SlOutsideAt => ({ item, sheet: node.sheetId, cell });
|
||||
const id = (f: string): string => `${node.id}:${f}`;
|
||||
const el = ref.element;
|
||||
const inputs = ref.inputs ?? {};
|
||||
if (ref.what === "노무") return [at("B2", { id: id("노무비"), kind: "노임", key: String(el) })];
|
||||
if (ref.what === "재료") {
|
||||
const keyed = typeof el === "object" && el !== null && "키" in el;
|
||||
const key = keyed ? String((el as { 키: unknown }).키) : JSON.stringify(el);
|
||||
return [at("B3", { id: id("재료비"), kind: "자재", key })];
|
||||
}
|
||||
if (ref.what === "비율") return [];
|
||||
const text = String(el);
|
||||
const key = /로직\(\s*([A-Z]{2}\d{6})/.exec(text)?.[1] ?? "";
|
||||
return COST3.map((f, i) =>
|
||||
at(
|
||||
`B${2 + i}`,
|
||||
ref.what === "장비"
|
||||
? { id: id(f), kind: "장비", element: text, inputs, field: f }
|
||||
: { id: id(f), kind: "로직", key, inputs, field: f },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ 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)?$/;
|
||||
const ALLOW = /(EasyCalc_(Model|Table|Cond)|M01_LB_Blocks|M01_SL_Import(_Parse)?|M01_SL_(Item|Model)|spreadsheet_\w+)(\.ts)?$/;
|
||||
export async function resolve(spec, ctx, next) {
|
||||
if (spec.startsWith(".") && ctx.parentURL && !spec.endsWith(".ts")) {
|
||||
const u = new URL(spec + ".ts", ctx.parentURL);
|
||||
@@ -161,12 +161,25 @@ def _tables_of(row: dict, tables: dict) -> dict:
|
||||
return {k: tables[k] for k in keys if k in tables}
|
||||
|
||||
|
||||
def _names_of(row: dict, master) -> dict:
|
||||
"""다른 로직 · 노임 키 → 이름(화면이 마스터 목록에서 넘기는 것과 같음)"""
|
||||
cm, files, _ = master
|
||||
m = cm.mf.Master(files)
|
||||
keys = set(re.findall(r"(?:G[FCX]|LB|MT)\d{6}", json.dumps(row, ensure_ascii=False)))
|
||||
return {k: str(m.get(k).get("이름") or k) for k in keys}
|
||||
|
||||
|
||||
def _num(v) -> float:
|
||||
"""빈 칸(선이 없는 비목 알약)은 0 · 수가 아니면 nan"""
|
||||
if v is None:
|
||||
return 0.0
|
||||
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`"""
|
||||
from M01_MasterData.LogicBuilder import M01_LB_Calc as lb
|
||||
|
||||
cm, files, _ = master
|
||||
m = cm.mf.Master(files)
|
||||
nodes = {n["id"]: n for n in sl["nodes"]}
|
||||
@@ -180,14 +193,21 @@ def _fill(sl: dict, places: list, py_lines: list, master) -> None:
|
||||
elif kind == "자재":
|
||||
name = nodes[item["id"].split(":")[0]]["name"]
|
||||
value = next(x["단가"] for x in py_lines if x["이름"] == name)
|
||||
elif kind == "장비": # 서버와 같음 — 요소 글 한 줄(수량 1)을 셈해 그 비목
|
||||
line = {
|
||||
"종류": "로직",
|
||||
"요소": item["element"],
|
||||
"이름": "장비",
|
||||
"단위": "시간",
|
||||
"수량": "1",
|
||||
}
|
||||
inputs = item["inputs"]
|
||||
one = {"키": "", "원문번호": "", "이름": "장비", "결과단위": "원/시간", "출처": "",
|
||||
"입력": [{"이름": k} for k in inputs], "중간": [], "호표": [line], "덧줄": []} # fmt: skip
|
||||
value = lb.calc(one, inputs, {})["lines"][0]["비목"].get(item["field"], 0)
|
||||
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
|
||||
got = cm.mf.run(m, item["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
|
||||
@@ -197,7 +217,8 @@ def _fill(sl: dict, places: list, py_lines: list, master) -> None:
|
||||
|
||||
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"]}
|
||||
# 결과 틀 — 노무비 · 재료비 · 경비 = 입력 알약 · 계(또는 결과) = 출력 알약
|
||||
return {p["name"]: (node["sheetId"], p["cell"]) for p in node["pillsIn"] + node["pillsOut"]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("row,given", SAMPLES, ids=[r["키"] for r, _ in SAMPLES])
|
||||
@@ -206,9 +227,14 @@ def test_가져온_시트로직_값이_calc_와_같음(row: dict, given: dict, m
|
||||
from M01_MasterData.LogicBuilder import M01_LB_Calc as lb
|
||||
|
||||
rep = _node(
|
||||
"const r = I.importLogicReport(A.row, { tables: A.tables, examples: A.given });"
|
||||
"const r = I.importLogicReport(A.row, { tables: A.tables, examples: A.given, names: A.names });"
|
||||
"out({ ...r, places: I.outsideOf(r.sl) });",
|
||||
{"row": row, "tables": _tables_of(row, master[2]), "given": given},
|
||||
{
|
||||
"row": row,
|
||||
"tables": _tables_of(row, master[2]),
|
||||
"given": given,
|
||||
"names": _names_of(row, master),
|
||||
},
|
||||
)
|
||||
assert rep["problems"] == []
|
||||
sl = rep["sl"]
|
||||
@@ -219,6 +245,14 @@ def test_가져온_시트로직_값이_calc_와_같음(row: dict, given: dict, m
|
||||
for n in sl["nodes"]: # 표 상자 = 「표」 바깥 값 출력 알약(E1 · Table 상자와 같은 꼴)
|
||||
if n["kind"] == "table":
|
||||
assert {"name": "표", "cell": "E1", "outside": True} in n["pillsOut"]
|
||||
# 상자 이름에 번호(키) 없음 · 항목 상자 = Item 틀(ItemRef · B1 수량 · B6 금액)
|
||||
assert not [n["name"] for n in sl["nodes"] if re.fullmatch(r"[A-Z]{2}\d{6}", n["name"])]
|
||||
for n in sl["nodes"]:
|
||||
if n["kind"] == "item" and n.get("ref", "").startswith("{"):
|
||||
ref = json.loads(n["ref"])
|
||||
assert ref["what"] in ("노무", "재료", "장비", "일위대가", "비율")
|
||||
pills = {p["name"]: p["cell"] for p in n["pillsOut"]}
|
||||
assert pills["금액"] == ("B4" if ref["what"] == "비율" else "B6")
|
||||
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"])
|
||||
|
||||
Reference in New Issue
Block a user