Merge remote-tracking branch 'origin/dev' into sub_laptop_2

This commit is contained in:
2026-11-05 17:44:39 +09:00
9 changed files with 1863 additions and 31 deletions
@@ -105,7 +105,7 @@ export function mountSangeunDetail(
const editor = el("div", { className: "m01-logic__editor" }); const editor = el("div", { className: "m01-logic__editor" });
const sheet = createSangeunSheet(editor, { const sheet = createSangeunSheet(editor, {
...{ edit, opened: () => opened(), touch: () => touch(), onInput: (n, v) => onInput(n, v) }, ...{ edit, opened: () => opened(), touch: () => touch(), onInput: (n, v) => onInput(n, v) },
...{ addLine: () => addButton.click(), addRate: () => rateButton.click() }, // 790-1 시트 첫 탭 ...{ addLine: () => addButton.click(), addRate: () => rateButton.click(), scope }, // 790-1 · 792 식 검사 층
}); });
const calcHost = el("div", { className: "m01lab__calc" }); const calcHost = el("div", { className: "m01lab__calc" });
const ownHost = el("div"); const ownHost = el("div");
@@ -84,3 +84,59 @@
margin-top: var(--spacing-8); margin-top: var(--spacing-8);
color: var(--color-text-secondary); color: var(--color-text-secondary);
} }
/* 790-3 숫자 넓힘 — 식 속 숫자 · 표 값 칸 · 까닭 · 글 줄 */
.m02-sks__formula,
.m02-sks__find {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-4);
font-size: var(--text-body-sm);
font-weight: var(--font-weight-regular);
color: var(--color-text-body);
font-variant-numeric: tabular-nums;
}
.m02-sks__frag {
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.m02-sks__num {
width: 6.5em;
}
.m02-sks__num[data-cover] {
outline: 2px solid var(--color-warning);
border-radius: var(--radius-sm);
}
.m02-sks__reason .ui-field {
width: min(24em, 100%);
}
.m02-sks__note .ui-field {
width: 100%;
}
.m02-sks__paper--note {
color: var(--color-text-secondary);
font-style: italic;
}
/* 792 식 칸 — 칸 + 검사 알림 한 줄 */
.m02-sks__fx {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
min-width: 0;
}
.m02-sks__fx .ui-field {
width: 100%;
}
.m02-sks__fx .ui-text:empty {
display: none;
}
@@ -6,6 +6,8 @@
* 고침 = 지금 되는 것만(모델 안 바꿈) — 옵션 고름 · 품 · 단가 · 율 % · 줄 빼기 · 되살림 · [+ 자원 줄] · [+ 율 줄] * 고침 = 지금 되는 것만(모델 안 바꿈) — 옵션 고름 · 품 · 단가 · 율 % · 줄 빼기 · 되살림 · [+ 자원 줄] · [+ 율 줄]
* → SK 문서(덮개 · 더한줄 · 뺀줄 = `M02_Sangeun_Cover_Model`) → 상세 `touch` = 서버 셈 한 번(최신 응답만) * → SK 문서(덮개 · 더한줄 · 뺀줄 = `M02_Sangeun_Cover_Model`) → 상세 `touch` = 서버 셈 한 번(최신 응답만)
* 상태줄 = M01 원래 · 지금 · 고치기 전(화면 캐시) + 차이 · 자동저장 없음([저장] = 상세 그대로) * 상태줄 = M01 원래 · 지금 · 고치기 전(화면 캐시) + 차이 · 자동저장 없음([저장] = 상세 그대로)
* 790-3 숫자 넓힘(식 속 상수 · 표값 · 글 줄 · 까닭 · [M01 원래로] · M01 바뀜) = `_Sangeun_SheetEdit`
* 792 식 칸(줄 식 고침 · 칠 때마다 검사 · 변수 줄) = `_Sangeun_SheetFormula`
* 부품 = `ui_template_calc_row` 시트 줄 · 입력 · 고르기 · 단추 · 배지 · 글 — 날 단추 · 입력 없음 * 부품 = `ui_template_calc_row` 시트 줄 · 입력 · 고르기 · 단추 · 배지 · 글 — 날 단추 · 입력 없음
* ========================================================================== */ * ========================================================================== */
@@ -22,6 +24,25 @@ import { formatLineMoney, formatMoney } from "../common_util/common_util_money";
import type { LogicRow, TextAnswer } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; import type { LogicRow, TextAnswer } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import { tl } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Text"; import { tl } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Text";
import { removedStrip } from "./M02_MasterTemplete_Sangeun_Cover"; import { removedStrip } from "./M02_MasterTemplete_Sangeun_Cover";
import {
addTopNote,
formulaEditor,
lostBanner,
noteRows,
reasonCell,
removedReasons,
restoreAll,
spotTail,
} from "./M02_MasterTemplete_Sangeun_SheetEdit";
import {
addVarButton,
formulaToggle,
rowFormula,
varRows,
type FormulaCtx,
} from "./M02_MasterTemplete_Sangeun_SheetFormula";
import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
import { coverKinds, dropAddKeys, type ConstDoc } from "./logic/M02_Sangeun_Const_Model";
import { import {
dropLine, dropLine,
PART_OF, PART_OF,
@@ -35,9 +56,11 @@ import {
} from "./logic/M02_Sangeun_Cover_Model"; } from "./logic/M02_Sangeun_Cover_Model";
import { import {
diffText, diffText,
liftAddKeys,
num, num,
paperLines, paperLines,
sheetItems, sheetItems,
spotsOf,
statusOf, statusOf,
type SheetItem, type SheetItem,
type SheetKind, type SheetKind,
@@ -61,7 +84,6 @@ const TEXT = {
Price: ["단가", "Price"], Price: ["단가", "Price"],
Rate: ["율 %", "Rate %"], Rate: ["율 %", "Rate %"],
Drop: ["빼기", "Remove"], Drop: ["빼기", "Remove"],
Undo: ["되돌림", "Revert"],
AddRes: ["+ 자원 줄", "+ Resource"], AddRes: ["+ 자원 줄", "+ Resource"],
AddRate: ["+ 율 줄", "+ Rate line"], AddRate: ["+ 율 줄", "+ Rate line"],
Table: ["표", "Table"], Table: ["표", "Table"],
@@ -103,6 +125,8 @@ export interface SheetOptions {
onInput: (name: string, value: string) => void; onInput: (name: string, value: string) => void;
addLine: () => void; addLine: () => void;
addRate: () => void; addRate: () => void;
/** 792 식 검사 길 층 */
scope: { layer: Layer; owner: string | null };
} }
type View = "sheet" | "ho" | "est" | "calc" | "formula"; type View = "sheet" | "ho" | "est" | "calc" | "formula";
@@ -118,6 +142,10 @@ export function createSangeunSheet(
let mode: "sheet" | "paper" = "sheet"; let mode: "sheet" | "paper" = "sheet";
let last: SheetDraw | null = null; let last: SheetDraw | null = null;
let before: number | null = null; // 고치기 전 = 연 뒤 · 저장 뒤 첫 셈 계(화면 캐시) let before: number | null = null; // 고치기 전 = 연 뒤 · 저장 뒤 첫 셈 계(화면 캐시)
let ctx: FormulaCtx | null = null; // 790-3 숫자 넓힘 · 792 식 칸 — 그릴 때마다 새로
const open = new Set<string>(); // 식 칸 연 자리
const drafts = new Map<string, string>(); // 치는 중 식 글
let focusNext = ""; // 새 글 줄 칸 — 다음 그림 뒤 초점
const show = (v: View): void => { const show = (v: View): void => {
host.hidden = v !== "sheet"; host.hidden = v !== "sheet";
editor.hidden = v === "sheet"; editor.hidden = v === "sheet";
@@ -169,6 +197,7 @@ export function createSangeunSheet(
part("origin", t("Origin"), s.원래), part("origin", t("Origin"), s.원래),
part("now", t("Now"), s.지금, diffText(s.지금, s.원래)), part("now", t("Now"), s.지금, diffText(s.지금, s.원래)),
part("before", t("Before"), s.전, diffText(s.지금, s.전)), part("before", t("Before"), s.전, diffText(s.지금, s.전)),
...(ctx ? restoreAll(ctx) : []),
); );
} }
@@ -222,8 +251,15 @@ export function createSangeunSheet(
if (!opt.edit) return []; if (!opt.edit) return [];
const parts = Object.keys(r.parts); const parts = Object.keys(r.parts);
const part = PART_OF[parts[0] ?? ""]; const part = PART_OF[parts[0] ?? ""];
const fx = coverKinds(d.doc as ConstDoc, r.cid).includes("식"); // 식 덮개 = 품 칸 안 둠(792)
const cells = [ const cells = [
numCell(`${r.cid}|qty`, r.pct ? t("Rate") : t("Qty"), r.수량, (x) => setCell(d, r, "qty", x)), ...(fx
? []
: [
numCell(`${r.cid}|qty`, r.pct ? t("Rate") : t("Qty"), r.수량, (x) =>
setCell(d, r, "qty", x),
),
]),
...(r.priceEdit && part && parts.length === 1 ...(r.priceEdit && part && parts.length === 1
? [numCell(`${r.cid}|price`, t("Price"), r.단가, (x) => setCell(d, r, part, x))] ? [numCell(`${r.cid}|price`, t("Price"), r.단가, (x) => setCell(d, r, part, x))]
: []), : []),
@@ -231,27 +267,24 @@ export function createSangeunSheet(
return [el("div", { className: "m02-sks__edit", children: cells })]; return [el("div", { className: "m02-sks__edit", children: cells })];
} }
function tailOf(d: SheetDraw, it: SheetItem): HTMLElement { function tailOf(d: SheetDraw, it: SheetItem, nums: boolean): HTMLElement {
const r = it.row; const r = it.row;
const kids: HTMLElement[] = it.비목 const kids: HTMLElement[] = it.비목
? [createBadge(it.비목, it.비목 === "별도" ? "warning" : "neutral")] ? [createBadge(it.비목, it.비목 === "별도" ? "warning" : "neutral")]
: []; : [];
for (const tag of it.표) kids.push(createBadge(`${t("Table")} ${tag}`, "info")); if (!nums) for (const tag of it.표) kids.push(createBadge(`${t("Table")} ${tag}`, "info"));
if (ctx && fx(it)) kids.push(...formulaToggle(ctx, it.자리, ctx.spots?.자리[it.자리]));
if (ctx) kids.push(...spotTail(ctx, it.자리, !!r && r.added !== null));
if (r && opt.edit) { if (r && opt.edit) {
if (r.added === null && (r.covered.qty || r.covered.price))
kids.push(
createButton({
label: t("Undo"),
variant: "ghost",
size: 22,
onClick: () => (setLineCover(d.doc, r, null, opt.opened()), opt.touch()),
}),
);
const drop = createButton({ const drop = createButton({
label: t("Drop"), label: t("Drop"),
variant: "danger-ghost", variant: "danger-ghost",
size: 22, size: 22,
onClick: () => (dropLine(d.doc, r), opt.touch()), onClick: () => {
dropLine(d.doc, r);
if (r.added !== null && !r.sub) apply(d, dropAddKeys(d.doc as ConstDoc, r.added));
else opt.touch();
},
}); });
drop.dataset.sheetDrop = r.cid; drop.dataset.sheetDrop = r.cid;
kids.push(drop); kids.push(drop);
@@ -297,22 +330,43 @@ export function createSangeunSheet(
return el("span", { className: `ui-calc-row__out${key ? " is-key" : ""}`, text: String(text) }); return el("span", { className: `ui-calc-row__out${key ? " is-key" : ""}`, text: String(text) });
}; };
/** 식 칸 되는 줄 — 율 · 로직 더한줄 · 둘째 기초 세부 줄 뺌 */
const fx = (it: SheetItem): boolean =>
!!it.자리 && !(it.row?.sub || (it.row?.added != null && it.row.pct));
function rowOf(d: SheetDraw, it: SheetItem): HTMLElement[] { function rowOf(d: SheetDraw, it: SheetItem): HTMLElement[] {
const own = ctx && fx(it) ? rowFormula(ctx, it.자리, ctx.spots?.자리[it.자리]) : null;
const nums = ctx && it.자리 && !own ? formulaEditor(ctx, it.자리) : null;
const shown = it.식 || (it.kind === "계산" ? t("Hidden") : "");
const middle = const middle =
it.kind === "옵션" it.kind === "옵션"
? optionCell(d, it) ? optionCell(d, it)
: el("div", { : el("div", {
className: "m02-sks__mid", className: "m02-sks__mid",
children: [ children: [
createCalcFormula(it.식 || (it.kind === "계산" ? t("Hidden") : "")), ...(it.row || !nums ? [createCalcFormula(shown)] : []),
...(nums ? [nums] : []),
...(own ? [own] : []),
...(it.row ? editLine(d, it.row) : []), ...(it.row ? editLine(d, it.row) : []),
...(ctx ? reasonCell(ctx, it.자리, it.changed) : []),
], ],
}); });
const row = createCalcSheetRow(it.이름, middle, valueOf(it), tailOf(d, it)); const row = createCalcSheetRow(it.이름, middle, valueOf(it), tailOf(d, it, !!nums));
row.dataset.sheetKind = it.kind; row.dataset.sheetKind = it.kind;
if (it.row) row.dataset.sheetCid = it.row.cid; if (it.row) row.dataset.sheetCid = it.row.cid;
if (it.자리) row.dataset.sheetSpot = it.자리;
if (it.changed) row.classList.add("is-changed"); if (it.changed) row.classList.add("is-changed");
return [row]; const notes = ctx && it.자리 ? noteRows(ctx, (x) => x.자리 === it.자리) : [];
const after = ctx && it.자리 ? varRows(ctx, (v) => v.자리 === it.자리) : [];
return [row, ...notes, ...after];
}
/** 790-3 — 새 문서로 바꿔 끼움(상세가 쥔 같은 객체) → 다시 셈 */
function apply(d: SheetDraw, next: ConstDoc): void {
const doc = d.doc as Record<string, unknown>;
for (const k of Object.keys(doc)) if (!(k in next)) delete doc[k];
Object.assign(doc, next);
opt.touch();
} }
const head = (key: keyof typeof TEXT, extra: HTMLElement[] = []): HTMLElement => const head = (key: keyof typeof TEXT, extra: HTMLElement[] = []): HTMLElement =>
@@ -330,8 +384,14 @@ export function createSangeunSheet(
ans: d.ans, ans: d.ans,
rows: d.rows, rows: d.rows,
text: d.text, text: d.text,
covered: (s) => coverKinds(d.doc as ConstDoc, s).length > 0,
}); });
const out: HTMLElement[] = []; const spots = new Set(items.map((it) => it.자리).filter(Boolean));
const names = new Set(
((d.doc as { 변수줄?: { 이름: string }[] }).변수줄 ?? []).map((v) => v.이름),
);
const varSpot = (s: string): boolean => s.startsWith("mid:") && names.has(s.slice(4));
const out: HTMLElement[] = ctx ? lostBanner(ctx) : [];
const add = (label: keyof typeof TEXT, click: () => void): HTMLElement[] => const add = (label: keyof typeof TEXT, click: () => void): HTMLElement[] =>
opt.edit opt.edit
? [createButton({ label: t(label), variant: "ghost", size: 22, onClick: click })] ? [createButton({ label: t(label), variant: "ghost", size: 22, onClick: click })]
@@ -344,22 +404,39 @@ export function createSangeunSheet(
? add("AddRes", opt.addLine) ? add("AddRes", opt.addLine)
: sec === "율" : sec === "율"
? add("AddRate", opt.addRate) ? add("AddRate", opt.addRate)
: []; : sec === "근거" && ctx
? addTopNote(ctx)
: sec === "상수" && ctx
? addVarButton(ctx)
: [];
if (!mine.length && !tools.length) continue; if (!mine.length && !tools.length) continue;
out.push(head(sec, tools)); out.push(head(sec, tools));
if (sec === "자원") if (sec === "자원")
out.push( out.push(
...removedStrip(d.removed, opt.edit, (cid) => (restoreLine(d.doc, cid), opt.touch())), ...removedStrip(d.removed, opt.edit, (cid) => revive(d, cid)),
...(ctx ? removedReasons(ctx, d.removed) : []),
); );
if (sec === "상수" && ctx)
out.push(...varRows(ctx, (v) => !v.자리 || (!spots.has(v.자리) && !varSpot(v.자리))));
for (const it of mine) out.push(...rowOf(d, it)); for (const it of mine) out.push(...rowOf(d, it));
if (sec === "근거" && ctx) out.push(...noteRows(ctx, (x) => !spots.has(x.자리)));
} }
return out; return out;
} }
/** 뺀 줄 되살림 — 뺀 더한줄(`gone:N`)이면 까닭 · 글줄 열쇠를 그 차례부터 한 칸 밂 */
function revive(d: SheetDraw, cid: string): void {
const gone = /^gone:(\d+)$/.exec(cid);
const at = gone ? d.doc.뺀더한줄?.[Number(gone[1])]?.차례 : undefined;
restoreLine(d.doc, cid);
if (at != null) apply(d, liftAddKeys(d.doc as ConstDoc, at));
else opt.touch();
}
function drawPaper(d: SheetDraw): HTMLElement[] { function drawPaper(d: SheetDraw): HTMLElement[] {
return paperLines(d.text, d.rows, d.ans).map((p) => { return paperLines(d.text, d.rows, d.ans, (d.doc as ConstDoc).글줄).map((p) => {
const line = el("p", { const line = el("p", {
className: `m02-sks__paper m02-sks__paper--${p.kind === "줄" ? "line" : "sum"}`, className: `m02-sks__paper m02-sks__paper--${p.kind === "줄" ? "line" : p.kind === "글" ? "note" : "sum"}`,
attrs: { "data-paper": p.kind }, attrs: { "data-paper": p.kind },
text: p.글, text: p.글,
}); });
@@ -370,9 +447,21 @@ export function createSangeunSheet(
function draw(d: SheetDraw): void { function draw(d: SheetDraw): void {
last = d; last = d;
if (before === null && d.ans?.기준?.계 != null) before = d.ans.기준.계; if (before === null && d.ans?.기준?.계 != null) before = d.ans.기준.계;
ctx = {
doc: d.doc as ConstDoc,
edit: opt.edit,
spots: spotsOf(d.ans),
apply: (next) => apply(d, next),
focus: (key) => void (focusNext = key),
scope: opt.scope,
open,
drafts,
redraw: () => last && draw(last),
};
drawStatus(d); drawStatus(d);
const active = document.activeElement as HTMLElement | null; const active = document.activeElement as HTMLElement | null;
const keep = active && host.contains(active) ? active.dataset.sheetCell : undefined; const keep = focusNext || (active && host.contains(active) ? active.dataset.sheetCell : "");
focusNext = "";
body.dataset.mode = mode; body.dataset.mode = mode;
body.replaceChildren( body.replaceChildren(
...(d.ans ...(d.ans
@@ -0,0 +1,296 @@
/* =============================================================================
* M02_MasterTemplete_Sangeun_SheetEdit.ts
* 단가산출(SK) 시트 숫자 넓힘 칸(PLAN 790-3 · E2 · _분석_650-H3 1 · 2 장) — 시트(`_Sangeun_Sheet`)가 부름.
* 식 칸 숫자 = 상수 덮개(식 모양 그대로 · 옵션 따라감) · 표 꼬리표 값 = 표값 덮개(「표 연결 끊김」)
* 글 줄(근거 글 · 셈 없음 · 산출서에 찍힘) · 까닭 칸 · [M01 원래로] · 「M01 바뀜」 · 짝잃음 띠
* 문서 고치기 = `logic/M02_Sangeun_Const_Model`(790-2 짝) · 금액은 늘 서버 셈(상세 touch)
* 부품 = 입력 · 단추 · 배지 · 띠 알림 · calc_row — 날 단추 · 입력 없음
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createBanner } from "@ui/ui_template_banner";
import { createButton } from "@ui/ui_template_button";
import { createCalcFormula, createCalcSheetRow } from "@ui/ui_template_calc_row";
import { el } from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import {
addText,
coverKinds,
dropText,
editText,
restoreM01,
setConst,
setReason,
setTable,
type ConstDoc,
} from "./logic/M02_Sangeun_Const_Model";
import {
formulaParts,
type NoteLine,
type SheetSpots,
type SpotInfo,
} from "./logic/M02_Sangeun_Sheet_Model";
const TEXT = {
Num: ["식 속 숫자", "Constant"],
TableVal: ["표 값", "Table value"],
Cut: ["표 연결 끊김", "Table link cut"],
Changed: ["M01 바뀜", "M01 changed"],
Restore: ["M01 원래로", "Reset to M01"],
RestoreAll: ["전체 M01 원래로", "Reset all to M01"],
Reason: ["까닭", "Reason"],
ReasonHint: ["고친 까닭(짧게)", "Why changed (short)"],
Note: ["글", "Note"],
NoteHint: ["근거 글", "Basis note"],
AddNote: ["+ 글", "+ Note"],
AddTop: ["+ 근거 글", "+ Basis note"],
DropNote: ["글 지움", "Delete note"],
Lost: ["짝 잃은 고침 — M01 이 바뀌어 붙일 자리가 없음", "Orphan edits — M01 changed"],
} as const satisfies Record<string, readonly [string, string]>;
const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
export interface EditCtx {
doc: ConstDoc;
edit: boolean;
spots: SheetSpots | null;
/** 새 문서로 바꿔 끼움(같은 객체 유지) → 다시 셈 */
apply: (next: ConstDoc) => void;
/** 다음 그림 뒤 이 칸에 초점 */
focus: (key: string) => void;
}
const strip = (v: number | null | undefined): string =>
v == null ? "" : String(Number(Number(v).toFixed(6)));
/** 작은 칸 — 바꾸면(Enter · 떠남) `onSet(글)` */
function smallCell(
key: string,
label: string,
v: string,
hint: string,
onSet: (raw: string) => void,
): HTMLElement {
const box = createInputField({ value: v, compact: true, size: 24, placeholder: hint });
box.input.setAttribute("aria-label", label);
box.input.dataset.sheetCell = key;
box.input.addEventListener("change", () => onSet(box.input.value.trim()));
return box.root;
}
/** 숫자 칸 — 빈 칸 · 원값과 같음 = null(덮개 풂) · 못 읽으면 되돌림 */
function numBox(
key: string,
label: string,
v: number | null,
was: number | null,
cover: boolean,
onSet: (x: number | null) => void,
): HTMLElement {
const root = smallCell(key, label, strip(v), strip(was), (raw) => {
const x = raw ? Number(raw.replaceAll(",", "")) : null;
if (x !== null && !Number.isFinite(x)) return void (input.value = strip(v));
onSet(x === null || (was != null && x === Number(was)) ? null : x);
});
const input = root.querySelector("input")!;
input.inputMode = "decimal";
root.classList.add("m02-sks__num");
if (cover) root.dataset.cover = "";
return root;
}
/** 식 칸 — 숫자 · 표 값을 칸으로(편집 · 서버 차례와 맞을 때만) · 못 그리면 null */
export function formulaEditor(c: EditCtx, spot: string): HTMLElement | null {
const info = c.spots?.자리[spot];
const parts = c.edit && !info?.식덮개 ? formulaParts(info) : null;
if (!info || !parts) return null;
const nums = c.doc.상수덮개?.[spot] ?? {};
const finds = c.doc.표값덮개?.[spot] ?? {};
const kids = parts.map((p): HTMLElement => {
if (p.t === "글") return el("span", { className: "m02-sks__frag", text: p.글 });
if (p.t === "수") {
const one = info.상수![p.n];
const mine = nums[String(p.n)];
return numBox(
`${spot}|n${p.n}`,
`${t("Num")} ${p.n + 1}`,
mine ?? one.값,
one.원값,
mine != null,
(x) => c.apply(setConst(c.doc, spot, p.n, x)),
);
}
const one = info.표값!.find((x) => x.n === p.n);
const was = typeof one?.원값 === "number" ? one.원값 : null;
const mine = finds[String(p.n)];
const shown = mine ?? (typeof one?.값 === "number" ? one.값 : null);
return el("span", {
className: "m02-sks__find",
children: [
numBox(`${spot}|t${p.n}`, `${t("TableVal")} ${p.n + 1}`, shown, was, mine != null, (x) =>
c.apply(setTable(c.doc, spot, p.n, x)),
),
...(p.표.length ? [createBadge(p.표.join(" "), "info")] : []),
...(mine != null ? [createBadge(t("Cut"), "warning")] : []),
],
});
});
const box = el("div", {
className: "m02-sks__formula",
attrs: { "data-sheet-formula": spot },
children: kids,
});
return box;
}
/** 꼬리 표지 · 단추 — M01 바뀜 · 표 연결 끊김 · [M01 원래로] · [+ 글] */
export function spotTail(c: EditCtx, spot: string, added: boolean): HTMLElement[] {
if (!spot) return [];
const info: SpotInfo | undefined = c.spots?.자리[spot];
const kinds = coverKinds(c.doc, spot);
const out: HTMLElement[] = [];
if (info?.M01바뀜) out.push(createBadge(t("Changed"), "danger"));
if (!c.edit) return out;
if (!added && kinds.length) {
const back = createButton({
label: t("Restore"),
variant: "ghost",
size: 22,
onClick: () => c.apply(restoreM01(c.doc, spot)),
});
back.dataset.sheetRestore = spot;
out.push(back);
}
const note = createButton({
label: t("AddNote"),
variant: "ghost",
size: 22,
onClick: () => addNote(c, spot),
});
note.dataset.sheetAddNote = spot;
out.push(note);
return out;
}
/** 까닭 칸 — 고친 자리(덮개 · 새 덮개 · 뺀줄 · 더한줄)만 */
export function reasonCell(c: EditCtx, key: string, need: boolean): HTMLElement[] {
const v = c.doc.까닭?.[key] ?? "";
if (!key || (!need && !v)) return [];
if (!c.edit) return [createCalcFormula(`${t("Reason")} · ${v}`)];
return [
el("span", {
className: "m02-sks__cell m02-sks__reason",
children: [
smallCell(`${key}|why`, t("Reason"), v, t("ReasonHint"), (x) =>
c.apply(setReason(c.doc, key, x)),
),
],
}),
];
}
export const isCovered = (doc: ConstDoc, spot: string): boolean =>
coverKinds(doc, spot).some((k) => k === "상수" || k === "표값");
function addNote(c: EditCtx, spot: string): void {
const lines = c.doc.글줄 ?? [];
let at = lines.length; // 같은 자리 글 줄 뒤
for (let i = lines.length - 1; i >= 0; i--)
if (lines[i].자리 === spot) {
at = i + 1;
break;
}
if (spot === "" && !lines.some((x) => x.자리 === "")) at = 0;
c.focus(`note|${at}`);
c.apply(addText(c.doc, spot, "", at));
}
/** 자리 뒤 글 줄들 — 편집 = 글 칸 · 지움 */
export function noteRows(c: EditCtx, pick: (x: NoteLine) => boolean): HTMLElement[] {
return (c.doc.글줄 ?? []).flatMap((x, i) => {
if (!pick(x)) return [];
const middle = c.edit
? smallCell(`note|${i}`, t("Note"), x.글, t("NoteHint"), (v) =>
c.apply(editText(c.doc, i, v)),
)
: createCalcFormula(x.글);
const tail = c.edit
? [
createButton({
label: t("DropNote"),
variant: "danger-ghost",
size: 22,
onClick: () => c.apply(dropText(c.doc, i)),
}),
]
: [];
const row = createCalcSheetRow(t("Note"), middle, el("span"), el("span", { children: tail }));
row.dataset.sheetKind = "글";
row.classList.add("m02-sks__note");
return [row];
});
}
export const addTopNote = (c: EditCtx): HTMLElement[] =>
c.edit
? [
createButton({
label: t("AddTop"),
variant: "ghost",
size: 22,
onClick: () => addNote(c, ""),
}),
]
: [];
/** 상태줄 [전체 M01 원래로] — 덮개 · 뺀줄 · 상수 · 표값 덮개가 있을 때만 */
export function restoreAll(c: EditCtx): HTMLElement[] {
const d = c.doc;
const any =
Object.keys(d.덮개 ?? {}).length +
(d.뺀줄 ?? []).length +
Object.keys(d.상수덮개 ?? {}).length +
Object.keys(d.표값덮개 ?? {}).length +
Object.keys((d.식덮개 as object | undefined) ?? {}).length;
if (!c.edit || !any) return [];
const b = createButton({
label: t("RestoreAll"),
variant: "ghost",
size: 22,
onClick: () => c.apply(restoreM01(c.doc)),
});
b.dataset.sheetRestore = "*";
return [b];
}
/** 짝 잃은 새 덮개 띠(790-2 `시트.짝잃음`) */
export function lostBanner(c: EditCtx): HTMLElement[] {
const gone = c.spots?.짝잃음 ?? [];
return gone.length
? [
createBanner({
tone: "warning",
text: t("Lost"),
details: gone.map((x) => `${x.cid} — ${x.까닭}`).join("\n"),
}),
]
: [];
}
/** 뺀 바탕 줄 까닭 칸(뺀 줄 띠 아래) */
export function removedReasons(
c: EditCtx,
removed: { cid: string; 이름: string }[],
): HTMLElement[] {
if (!c.edit) return [];
return removed
.filter((x) => (c.doc.뺀줄 ?? []).includes(x.cid))
.map((x) =>
el("div", {
className: "m02-sks__cell m02-sks__reason",
attrs: { "data-sheet-removed": x.cid },
children: [createText(x.이름, "muted"), ...reasonCell(c, x.cid, true)],
}),
);
}
@@ -0,0 +1,287 @@
/* =============================================================================
* M02_MasterTemplete_Sangeun_SheetFormula.ts
* 단가산출(SK) 시트 식 칸(PLAN 792 · E4 · _분석_650-H3 2 장 「식 고침」) — 시트(`_Sangeun_Sheet`)가 부름.
* 식 칸 = 이름 꼴 글(`[노임 보통인부] × 무게 / 운반량`) · 칠 때마다 검사 길(`formula-check` · 옛 요청 끊기)
* → 틀리면 빨간 칸 + 까닭 한 줄 · 되면 바꿀 때(Enter · 떠남) 엔진 식을 문서에(`setFormula`) → 서버 셈
* 변수 줄 = 이름 · 식 · 값 · [+ 변수 줄] · 빼기(`addVar` · `editVar` · `dropVar`)
* 빈 칸 · M01 식 그대로 = 식 덮개 풂 · 같은 자리 상수 · 표값 · 품 덮개는 식 덮개와 같이 못 둠 → 걷고 넣음
* 문서 고치기 = `logic/M02_Sangeun_Formula_Model`(792-1 짝) · 금액은 늘 서버 셈 · 자동저장 없음
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createButton } from "@ui/ui_template_button";
import { createCalcSheetRow } from "@ui/ui_template_calc_row";
import { el } from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch";
import type { EditCtx } from "./M02_MasterTemplete_Sangeun_SheetEdit";
import {
addVar,
dropVar,
editVar,
setFormula,
type FormulaDoc,
type VarLine,
} from "./logic/M02_Sangeun_Formula_Model";
import { num, type SpotInfo } from "./logic/M02_Sangeun_Sheet_Model";
const TEXT = {
Formula: ["식", "Formula"],
Edit: ["식 고침", "Edit formula"],
Close: ["식 닫기", "Close formula"],
Hint: ["예) 무게 / 운반량 × (1 + 할증율 / 100)", "e.g. weight / load × 1.2"],
Var: ["변수", "Variable"],
VarName: ["변수 이름", "Variable name"],
AddVar: ["+ 변수 줄", "+ Variable"],
DropVar: ["변수 빼기", "Remove variable"],
Bad: ["식이 틀려 안 넣음", "Invalid formula — not applied"],
Checking: ["검사 중", "Checking…"],
} as const satisfies Record<string, readonly [string, string]>;
const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
export interface CheckAnswer {
되나: boolean;
까닭: string[];
알림: string[];
/** 엔진 식(문서에 넣을 것) */
식: string;
/** 이름 꼴 글 */
글: string;
}
export interface FormulaCtx extends EditCtx {
scope: { layer: Layer; owner: string | null };
/** 식 칸 연 자리(화면 캐시) */
open: Set<string>;
/** 치는 중 글(다시 그려도 안 잃음) — 넣으면 지움 */
drafts: Map<string, string>;
redraw: () => void;
}
const q = (c: FormulaCtx): string =>
c.scope.owner && c.scope.layer !== "system" ? `?owner=${encodeURIComponent(c.scope.owner)}` : "";
/** 검사 길(792-1) — 셈 · 저장 없음 */
export const checkFormula = (
c: FormulaCtx,
spot: string,
text: string,
signal?: AbortSignal,
): Promise<CheckAnswer> =>
call<CheckAnswer>(`/layers/${c.scope.layer}/sangeun/formula-check${q(c)}`, {
method: "POST",
body: JSON.stringify({ 문서: c.doc, 자리: spot, 식: text }),
signal,
});
/** 식 덮개와 같이 못 두는 같은 자리 상수 · 표값 · 품 덮개 걷음(배율은 둠) */
export function clearNums<T extends FormulaDoc>(doc: T, spot: string): T {
const out: FormulaDoc = { ...doc };
for (const name of ["상수덮개", "표값덮개"] as const) {
const all = { ...(doc[name] ?? {}) };
delete all[spot];
if (Object.keys(all).length) out[name] = all;
else delete out[name];
}
const one = doc.덮개?.[spot];
if (one && "qty" in one) {
const rest = { ...one };
delete rest.qty;
const covers = { ...doc.덮개 };
if (Object.keys(rest).length) covers[spot] = rest;
else delete covers[spot];
out.덮개 = covers;
}
return out as T;
}
/** 식 칸 — 칠 때마다 검사(앞 요청 끊음) · 바꿀 때 넣음 · `put(엔진 식 | null)` */
function formulaBox(
c: FormulaCtx,
spot: string,
key: string,
shown: string,
origin: string,
put: (engine: string | null) => void,
): HTMLElement {
const box = createInputField({
value: c.drafts.get(key) ?? shown,
compact: true,
size: 24,
placeholder: t("Hint"),
});
box.input.setAttribute("aria-label", t("Formula"));
box.input.dataset.sheetCell = key;
box.input.spellcheck = false;
const note = createText("", "muted", 12);
note.dataset.sheetCheck = key;
let timer = 0;
let ctl: AbortController | null = null;
let last: Promise<CheckAnswer | null> | null = null;
const run = (text: string): Promise<CheckAnswer | null> => {
ctl?.abort();
const mine = (ctl = new AbortController());
note.textContent = t("Checking");
last = checkFormula(c, spot, text, mine.signal).then(
(got) => {
if (mine !== ctl) return got;
box.setError(got.되나 ? undefined : got.까닭.join(" · "));
note.textContent = got.알림.join(" · ");
return got;
},
(error: unknown) => {
if (mine !== ctl || mine.signal.aborted) return null;
box.setError(error instanceof Error ? error.message : String(error));
note.textContent = "";
return null;
},
);
return last;
};
box.input.addEventListener("input", () => {
c.drafts.set(key, box.input.value);
window.clearTimeout(timer);
const text = box.input.value.trim();
if (!text) return void (ctl?.abort(), box.setError(undefined), (note.textContent = ""));
timer = window.setTimeout(() => void run(text), 150);
});
box.input.addEventListener("change", async () => {
window.clearTimeout(timer);
const text = box.input.value.trim();
if (!text || text === origin) {
c.drafts.delete(key);
return put(null);
}
const got = await run(text);
if (!got?.되나) return void (note.textContent = t("Bad"));
c.drafts.delete(key);
put(got.식);
});
return el("div", {
className: "m02-sks__fx",
attrs: { "data-sheet-fx": spot },
children: [box.root, note],
});
}
/** 줄 식 칸 — 열었거나 식 덮개 있는 자리만 · 못 고치는 자리(로직 · 율 더한줄) = null */
export function rowFormula(
c: FormulaCtx,
spot: string,
info: SpotInfo | undefined,
): HTMLElement | null {
if (!c.edit || !spot || !info || (!c.open.has(spot) && !info.식덮개)) return null;
const shown = info.식글 ?? info.식 ?? "";
const origin = info.원식글 ?? "";
return formulaBox(c, spot, `${spot}|f`, shown, origin, (engine) => {
const doc = engine ? clearNums(c.doc as FormulaDoc, spot) : (c.doc as FormulaDoc);
c.apply(setFormula(doc, spot, engine));
});
}
/** 꼬리 [식 고침] · [식 닫기] — 식 덮개 있으면 늘 열림(닫기 없음) */
export function formulaToggle(
c: FormulaCtx,
spot: string,
info: SpotInfo | undefined,
): HTMLElement[] {
const addOne = /^add:#\d+$/.test(spot); // 더한줄 수량식(율 · 로직 줄은 부르는 쪽이 뺌)
if (!c.edit || !spot || !info || info.식덮개 || (info.식 == null && !addOne)) return [];
const on = c.open.has(spot);
const b = createButton({
label: on ? t("Close") : t("Edit"),
variant: "ghost",
size: 22,
onClick: () => {
if (on) c.open.delete(spot);
else c.open.add(spot);
c.drafts.delete(`${spot}|f`);
c.focus(on ? "" : `${spot}|f`);
c.redraw();
},
});
b.dataset.sheetFxOpen = spot;
return [b];
}
const vars = (c: FormulaCtx): VarLine[] => (c.doc as FormulaDoc).변수줄 ?? [];
/** 자리 뒤 변수 줄들(이어 붙은 변수까지) */
export function varRows(c: FormulaCtx, after: (v: VarLine) => boolean): HTMLElement[] {
return vars(c).flatMap((v) =>
after(v) ? [varRow(c, v), ...varRows(c, (x) => x.자리 === `mid:${v.이름}`)] : [],
);
}
function varRow(c: FormulaCtx, v: VarLine): HTMLElement {
const spot = `mid:${v.이름}`;
const info = c.spots?.자리[spot];
const label = c.edit
? (() => {
const box = createInputField({ value: v.이름, compact: true, size: 24 });
box.input.setAttribute("aria-label", t("VarName"));
box.input.dataset.sheetCell = `var|${v.이름}`;
box.input.addEventListener("change", () => {
const to = box.input.value.trim();
if (!to || to === v.이름 || vars(c).some((x) => x.이름 === to))
return void (box.input.value = v.이름);
c.focus(`var|${to}`);
c.apply(editVar(c.doc as FormulaDoc, v.이름, { 이름: to }));
});
return box.root;
})()
: el("span", { className: "ui-calc-row__label", text: v.이름 });
const middle = c.edit
? formulaBox(c, spot, `${spot}|f`, info?.식글 ?? v.식, "", (engine) =>
c.apply(editVar(c.doc as FormulaDoc, v.이름, { 식: engine ?? "0" })),
)
: el("span", { className: "ui-calc-formula", text: info?.식글 ?? v.식 });
const value = el("span", {
className: "ui-calc-row__out",
text: typeof info?.지금 === "number" ? num(info.지금) : "—",
});
const tail = el("span", {
children: [
createBadge(t("Var"), "accent"),
...(c.edit
? [
createButton({
label: t("DropVar"),
variant: "danger-ghost",
size: 22,
onClick: () => c.apply(dropVar(c.doc as FormulaDoc, v.이름)),
}),
]
: []),
],
});
const row = createCalcSheetRow(label, middle, value, tail);
row.dataset.sheetKind = "변수";
row.dataset.sheetSpot = spot;
row.classList.add("is-changed");
return row;
}
/** [+ 변수 줄] — 맨 위(자리 "") · 이름 = 안 겹치는 「변수n」 */
export function addVarButton(c: FormulaCtx): HTMLElement[] {
if (!c.edit) return [];
const b = createButton({
label: t("AddVar"),
variant: "ghost",
size: 22,
onClick: () => {
const used = new Set([
...vars(c).map((v) => v.이름),
...(c.spots?.이름표 ?? []).map((x) => x.글),
]);
let n = 1;
while (used.has(`변수${n}`)) n++;
c.focus(`var|변수${n}`);
c.apply(addVar(c.doc as FormulaDoc, { 이름: `변수${n}`, 식: "0", 자리: "" }, 0));
},
});
b.dataset.sheetAddVar = "";
return [b];
}
+555
View File
@@ -0,0 +1,555 @@
"""경쟁사 산근 → 우리 엔진 로직 줄 꼴 내보내기(PLAN 793-2a · 요구서_652 · eval 없음).
793-1 줄 나무(`M02_San_Stmate` · `M02_San_Est`) → `{로직, 원본}` — 꼴 = 793-2 가져온 식 SK
만들기 몸(`M02_Sangeun_Imported.py` 머리) · 로직 = `_틀.md` 7 장 꼴(셈 = `master_formula.run` →
`master_cut.engine` · `요소` 는 SK 서버가 채움).
- 로직 = {이름, 결과단위, 입력 [], 중간 [{이름, 식, 표시?}], 호표,
덧줄 [{이름, 식, 비목, 번지?, 합밖?, 자리?}], 단계? [{이름, 단, 줄, 제외?}], 끝전, 조정?} ·
STmate 변수 = 식(다시 대입 = `이름_2`) · EST 변수 = 얼린 값 · EST 는 블록 성분을 낸 줄만 합
(나머지 = 합밖)
- 호표 줄 = {종류 인력|재료|기계, 이름, 규격, 단위, 수량, 비목, 단가(엑셀), 이음(M01 키 | null)}
(비목 하나 자원) | {종류 로직, 이름, 단위, 수량, 값묶음(엑셀), 이음 null}(기계 셋 · 산근 · 고정) ·
번지? · 합밖? · 자리? · 덧칸 `자원` {꼴, 갈래, 코드?, 종류?, 이름, 규격, 단위, 단가 {L, M, S},
비목?, 옮김?}(경쟁사 코드 · 엑셀 단가 원본 · `reprice`) · 이음 = 1차 줄 판정
`M02_Hopyo_Row.m01_picks` 유일 짝(keys=True 일 때)
- 원본 = {파일, 시트, 행 [처음, 끝], 갈래 STmate|EST, 엑셀금액 {노무비, 재료비, 경비, 계}(1 단위 ·
계약 전), 글줄 [{행, 글, 자리?, 자리들?, 값?, 표지?}], 코드, 표지 [..], 계약? {낙찰률, 금액}}
· 글줄 자리 = 셈 줄 이름 | `mid:이름` | `단계:이름` · 값 = 그 줄 엑셀 금액(계)
표지 = 못 옮긴 까닭(0 이 목표) · STmate 계약단가(낙찰률)는 B09 몫.
"""
from __future__ import annotations
import re
from decimal import Decimal
from typing import Any
from M02_MasterTemplete import M02_San_Eval as ev
from M02_MasterTemplete import M02_San_Expr as ex
COMP = {"L": "노무비", "M": "재료비", "S": "경비"}
_CODE = {v: k for k, v in COMP.items()}
_SUM = "@합{}@" # 누계 % 밑 자리(블록 끝에 `finish` 가 채움)
KIND = {"L": "인력", "M": "재료", "X": "기계", "D": "단가산출", "B": "단가산출"}
KIND_EST = {"노임": "인력", "자재": "재료", "기계": "기계", "견적": "고정", "산근": "단가산출"}
RESERVED = {"노무비", "재료비", "경비", "계", "번지", "단계", "줄", "찾기", "로직", "그리고"}
RESERVED |= {"또는", "만약", "올림", "버림", "반올림", "최소", "최대"}
_KEY = re.compile(r"[A-Z]{2}\d{6}")
_LABEL = re.compile(r"'([^']*)'")
class EmitError(Exception):
"""엔진 식으로 못 옮김 — 줄 표지."""
# ── 이름 · 수 ──────────────────────────────────────────────────────────
def num(v: float) -> str:
"""수 → 엔진 수 글(지수 없음 · 정수는 소수점 없이)."""
if float(v).is_integer():
return str(int(v))
return format(Decimal(repr(float(v))), "f")
class Names:
"""블록 안 이름 — 줄 · 중간은 한 벌(덧줄 · 중간 = 식 이름 꼴) · 단계는 따로."""
def __init__(self) -> None:
self.used: set[str] = set(RESERVED)
self.steps: set[str] = set()
def line(self, base: str) -> str:
"""식 이름 꼴(덧줄 · 중간 · 호표 모두 — `줄.'이름'` · 단계 줄 목록)."""
s = re.sub(r"[^\w가-힣]", "", base or "") or "줄"
if s[0].isdigit() or _KEY.fullmatch(s):
s = "줄" + s
out, n = s, 1
while out in self.used:
n += 1
out = f"{s}_{n}"
self.used.add(out)
return out
def item(self, base: str) -> str:
"""호표 이름(읽는 글 그대로 · 작은따옴표만 뺌 · `줄.'이름'` 으로 부름)."""
s = (base or "").replace("'", "").strip() or "줄"
out, n = s, 1
while out in self.used:
n += 1
out = f"{s} {n}"
self.used.add(out)
return out
def step(self, base: str) -> str:
s = (base or "").replace("'", "").strip() or "계"
out, n = s, 1
while out in self.steps:
n += 1
out = f"{s} {n}"
self.steps.add(out)
return out
# ── 식 나무 → 엔진 글 ──────────────────────────────────────────────────
def text(node: tuple, names: dict[str, str], swap: dict | None = None, kept: dict | None = None):
"""`M02_San_Expr` 나무 → 엔진 식 글 · names = {원 이름: 엔진 이름} · swap = {수 토큰 차례: 글} ·
kept = {id(끼움): 엔진 이름}."""
kind = node[0]
if kind == "오류":
raise EmitError(node[1])
if kind == "수":
return swap[node[2]] if swap and node[2] in swap else num(node[1])
if kind == "이름":
if node[1] not in names:
raise EmitError(f"미정의 변수 {node[1]}")
return names[node[1]]
if kind == "참조":
return "1"
if kind == "끼움":
if kept and id(node) in kept:
return kept[id(node)]
return "(" + text(node[2], names, swap, kept) + ")"
if kind == "음":
return "-(" + text(node[1], names, swap, kept) + ")"
if kind == "함수":
args = [text(a, names, swap, kept) for a in node[2]]
if node[1] == "int":
return f"버림({args[0]}, 0)"
return f"반올림({args[0]}, {args[1] if len(args) > 1 else 0})"
a, b = text(node[2], names, swap, kept), text(node[3], names, swap, kept)
if node[1] == "^":
k = node[3][1] if node[3][0] == "수" else None
if k is None or not float(k).is_integer() or not 0 <= k <= 12:
raise EmitError("거듭제곱 — 엔진에 없음")
return "(" + " * ".join([f"({a})"] * int(k)) + ")" if k else "1"
if node[1] == "*" and "1" in (a, b): # 단가 자리(1) 곱은 뺌
return b if a == "1" else a
return f"({a} {node[1]} {b})"
def _place(rule: str | None) -> dict | None:
"""줄 자리 규칙 이름 → 엔진 `자리`(버림1 = 엔진 기본 = 칸 없음)."""
if not rule or rule == "버림1":
return None
m = re.fullmatch(r"(버림|반올림|올림)(\d)", rule)
if not m:
raise EmitError(f"줄 자리 「{rule}」 — 엔진에 없음")
return {"방법": m.group(1), "자리": int(m.group(2))}
def bundle(src: dict) -> dict[str, Decimal]:
"""자원 이음 열쇠 → 값묶음 — 비목 하나(옮김이면 그 비목으로) · 없으면 셋."""
price, one, to = src["단가"], src.get("비목"), src.get("옮김")
out = dict.fromkeys(COMP.values(), Decimal(0))
if one:
out[COMP[to or one]] = Decimal(str(price.get(one) or 0))
else:
for c, name in COMP.items():
out[name] = Decimal(str(price.get(c) or 0))
return out
def resource(name: str, unit: Any, qty: str, res: dict, link: str | None) -> dict:
"""자원 → 호표 줄 — 비목 하나인 인력 · 재료 · 기계 = 단가 줄 · 그 밖 = 로직 값묶음 줄."""
one = {"이름": name, "규격": res.get("규격") or "", "단위": unit or "", "수량": qty}
if res.get("비목") and res["갈래"] in ("인력", "재료", "기계"):
price = Decimal(str(res["단가"].get(res["비목"]) or 0))
one |= {"종류": res["갈래"], "비목": COMP[res.get("옮김") or res["비목"]], "단가": price}
return {**one, "이음": link, "자원": res}
return {**one, "종류": "로직", "값묶음": bundle(res), "이음": None, "자원": res}
def reprice(logic: dict, where: dict, price: dict[str, float]) -> int:
"""단가 바꿈 — where 칸이 같은 자원 단가 {L, M, S} 고침 · 줄 단가 · 값묶음 다시 · 고친 줄 수."""
n = 0
for item in logic.get("호표", []):
src = item.get("자원") or {}
if all(src.get(k) == v for k, v in where.items()):
src["단가"] = {**src["단가"], **price}
if "단가" in item:
item["단가"] = Decimal(str(src["단가"].get(src["비목"]) or 0))
else:
item["값묶음"] = bundle(src)
n += 1
return n
def _m01(kind: str, src: dict) -> str | None:
"""1차 줄 판정 유일 짝 M01 키 — 없거나 여럿 = None."""
if kind not in ("인력", "재료", "기계"):
return None
from M02_MasterTemplete import M02_Hopyo_Row as hrow
got = hrow.m01_picks(kind, {k: src.get(k) or "" for k in ("이름", "규격", "단위")})
keys = {x.get("키") for x in got if x.get("키")}
return keys.pop() if len(keys) == 1 else None
# ── 내보낼 로직 틀 ─────────────────────────────────────────────────────
class Out:
def __init__(self, name: str, unit: str, form: str, code: Any) -> None:
self.names = Names()
self.mid: list[dict] = []
self.items: list[dict] = []
self.adds: list[dict] = []
self.steps: list[dict] = []
self.texts: list[dict] = [] # 원본 글줄(원문 차례)
self.at: dict[Any, dict] = {} # 행 → 글줄
self.marks: list[str] = []
self.row = {"이름": name or str(code), "결과단위": f"원/{unit or '식'}", "입력": []}
self.head = {"갈래": form, "코드": code}
def glyph(self, at: Any, glyph: str, where: str | None = None, value: Any = None, mark=None):
"""원본 글줄 한 줄(같은 행이면 자리만 더함)."""
one = self.at.get(at)
if one is None:
one = {"행": at, "글": glyph}
self.texts.append(one)
self.at[at] = one
if where and "자리" in one:
one.setdefault("자리들", [one["자리"]]).append(where)
elif where:
one["자리"] = where
if value is not None:
one["값"] = value
if mark:
one["표지"] = mark
self.marks.append(f"{at}: {mark}")
def middle(self, name: str, formula: str, show: int | None, at: Any, glyph: str) -> None:
self.mid.append({"이름": name, "식": formula, **({"표시": show} if show else {})})
self.glyph(at, glyph, f"mid:{name}")
def add_line(self, one: dict, item: bool, at: Any, glyph: str, value: Any) -> dict:
(self.items if item else self.adds).append(one)
self.glyph(at, glyph, one["이름"], value)
return one
def done(self, end: str, adjust: dict | None, check: dict, sheet: str, file: str, **more):
logic = {**self.row, "중간": self.mid, "호표": self.items, "덧줄": self.adds}
logic |= {"단계": self.steps} if self.steps else {}
logic |= {"끝전": end, **({"조정": adjust} if adjust else {})}
rows = [x["행"] for x in self.texts if isinstance(x["행"], int)] or [0]
source = {"파일": file, "시트": sheet, "행": [min(rows), max(rows)], **self.head}
source |= {"엑셀금액": check, "글줄": self.texts, "표지": self.marks, **more}
return {"로직": logic, "원본": source}
def _money(d: dict | None) -> dict[str, Any]:
d = d or {}
return {COMP[c]: d.get(c) or 0 for c in COMP}
# ── STmate ─────────────────────────────────────────────────────────────
def _label(node: dict) -> str:
"""원문 첫 글 조각 「'합판(내수) :'」 → 「합판(내수)」."""
m = _LABEL.search(node.get("글") or "")
return m.group(1).split(":")[0].strip() if m else ""
class _Stm:
"""STmate 블록 한 벌 옮김 — 변수 이름 짝 · 그림자 셈(엑셀 셈 실패 줄 가름) · 계 단계."""
def __init__(self, o: Out, block: dict, prices: dict, keys: bool) -> None:
self.o, self.prices, self.keys = o, prices, keys
self.rows = {ln["행"]: ln for ln in block["몸"]}
self.V: dict[str, float] = {} # 그림자 셈(엑셀 셈과 같은 값 · 실패 자리 가름)
self.names: dict[str, str] = {} # STmate 변수 → 엔진 중간 이름
self.pending: list[str] = [] # 마지막 계 뒤 합에 드는 줄
self.calls: dict[str, tuple[str, set]] = {} # 줄 이름 → (엔진 부름 글, 값 든 비목)
self.tsum: dict[str, str] = {} # 누계번지 → 단계 이름
self.addr: set[str] = set()
self.last_step: str | None = None
self.order: list[str] = [] # 합에 드는 줄(원문 차례)
self.open: list[tuple[int, list[str]]] = [] # 위 단계가 아직 안 담은 단계(엔진 `_cover`)
self.out: dict[str, int] = {} # 제외 단계 줄 → 그 계 줄까지 줄 수(합에서 빠지는 때)
self.fix: list[tuple[dict, int]] = [] # 누계 % 덧줄 · 그때 줄 수
def brackets(self, node: tuple, src: dict, kept: dict) -> None:
"""끼움 `[Q=0.208]` → 중간(왼쪽부터 · 원문 차례)."""
kind = node[0]
if kind == "끼움":
self.brackets(node[2], src, kept)
if node[1]:
body = text(node[2], self.names, kept=kept)
eng = self.o.names.line(node[1].lower())
self.names[node[1].lower()] = eng
kept[id(node)] = eng
self.o.middle(eng, body, None, src["행"], src["글"])
elif kind == "셈":
self.brackets(node[2], src, kept)
self.brackets(node[3], src, kept)
elif kind == "음":
self.brackets(node[1], src, kept)
elif kind == "함수":
for a in node[2]:
self.brackets(a, src, kept)
def coef(self, node: tuple, src: dict) -> str:
kept: dict = {}
self.brackets(node, src, kept)
return text(node, self.names, kept=kept)
def shadow(self, node: tuple) -> float:
trial = dict(self.V)
v = ex.value(node, {"V": trial, "R": 1.0})
self.V = trial
return v
def assign(self, node: dict, src: dict) -> None:
for part in node["대입"]:
try:
v = self.shadow(part["식"])
body = self.coef(part["식"], src)
except (ex.ExprError, EmitError) as error:
if part["이름"] is None:
continue
self.o.glyph(src["행"], src["글"], mark=f"셈 못 함 — {error}")
return
if part["이름"] is None:
continue
self.V[part["이름"]] = ex.rnd(v, 2) if part["찍기"] else v
eng = self.o.names.line(part["이름"])
self.names[part["이름"]] = eng
self.o.middle(eng, body, 2 if part["찍기"] else None, src["행"], src["글"])
def total(self, node: dict, src: dict) -> None:
o = self.o
name = o.names.step(str(self.rows.get(src["행"], {}).get("B") or "계"))
step = {"이름": name, "단": node["단"], "줄": self.pending}
step |= {"제외": True} if node["제외"] else {}
mine, keep = list(self.pending), []
for low, got in self.open:
(mine.extend(got) if low < node["단"] else keep.append((low, got)))
self.open = keep if node["제외"] else [*keep, (node["단"], mine)]
for n in mine if node["제외"] else []:
self.out[n] = len(self.order)
o.glyph(src["행"], src["글"], f"단계:{name}", self.rows.get(src["행"], {}).get("C"))
o.steps.append(step)
self.pending = []
if node["누계번지"]:
self.tsum[node["누계번지"]] = name
self.last_step = name
def line(self, one: dict, node: dict, item: bool, src: dict) -> None:
if node.get("번지") and node["번지"].startswith("A"):
one["번지"] = node["번지"]
self.addr.add(node["번지"])
money = one.get("값묶음") or {}
comps = {c for c in COMP if money.get(COMP[c])} if "값묶음" in one else {_CODE[one["비목"]]}
self.calls[one["이름"]] = (f"줄.'{one['이름']}'" if item else one["이름"], comps)
if node.get("빼기"):
one["합밖"] = True
else:
self.pending.append(one["이름"])
self.order.append(one["이름"])
self.o.add_line(one, item, src["행"], src["글"], self.rows.get(src["행"], {}).get("C"))
def ref(self, node: dict, src: dict, cf: str) -> None:
o, code = self.o, node["자원"]["코드"]
pr = self.prices[code]
unt = (pr.get("meta") or {}).get("UNT")
one_, to = node["자원"]["비목"], node["자원"]["옮김"]
name = _label(node) or str(pr.get("이름") or code)
if unt in ev.SPECIAL: # 특수 % — 처음부터 비목 누계 · 앞 계표시
dst = to or {"L": "L", "M": "M", "S": "S"}.get(code[0])
if unt == "T%":
last = self.last_step
base = f"단계.'{last}'.계" if last else f"({' + '.join(map(_SUM.format, COMP))})"
formula = f"{base} * ({cf})"
else:
formula = f"{_SUM.format(unt[0])} * ({cf}) / 100"
one = {"이름": o.names.line(name), "식": formula, "비목": COMP[dst]}
self.fix.append((one, len(self.order)))
return self.line(one, node, False, src)
res = {"꼴": "STmate", "갈래": KIND.get(code[0], "고정"), "코드": code}
res |= {k: pr.get(k) for k in ("이름", "규격", "단위")}
res |= {"단가": {c: pr.get(c) or 0 for c in COMP}}
sole = [c for c in COMP if res["단가"][c]]
if not one_ and len(sole) == 1: # 비목 꼬리 없어도 값 든 비목 하나면 단가 줄(셈 같음)
one_, to = sole[0], None
res |= {"비목": one_, "옮김": to} if one_ else {}
link = _m01(res["갈래"], res) if self.keys else None
one = resource(o.names.item(name), pr.get("단위"), cf, res, link)
self.line(one, node, True, src)
def rate(self, node: dict, src: dict, cf: str) -> None:
rate, o = node["율"], self.o
area = rate["범위"]
def base(c: str) -> str:
if area == "A":
got = [f"번지.{s}.{COMP[c]}" for s in rate["번지"] if s in self.addr]
return " + ".join(got) or "0"
if area == "T":
step = self.tsum.get(rate["번지"][0])
return f"단계.'{step}'.{COMP[c]}" if step else "0"
if area == "C": # 마지막 계 뒤 줄 — 비목 하나인 줄만 부름으로 더함
got = []
for n in self.pending:
call, comps = self.calls[n]
if len(comps) > 1:
raise EmitError("율 범위 C 에 비목 여럿인 줄 — 엔진 꼴 없음")
got += [call] if comps == {c} else []
return " + ".join(got) or "0"
return _SUM.format(c)
pct = f"({cf})" if rate["곱"] else f"({cf}) / 100"
src_, dst = rate["비목"], rate["옮김"]
if src_:
parts = [(dst or src_, base(src_))]
elif dst:
parts = [(dst, " + ".join(f"({base(c)})" for c in COMP))]
else:
parts = [(c, base(c)) for c in COMP]
label = _label(node) or "율"
for c, b in parts:
nm = o.names.line(label if len(parts) == 1 else f"{label}_{COMP[c]}")
one = {"이름": nm, "식": f"({b}) * {pct}", "비목": COMP[c]}
if "@합" in b:
self.fix.append((one, len(self.order)))
self.line(one, node, False, src)
def finish(self) -> None:
"""누계 % 밑 = 그 줄 앞까지 합 — 엔진 비목 합(호표 다 · 앞 덧줄 · 제외 단계 뺌)에서
뒤에 오는 호표를 빼고 아직 안 빠진 제외 단계 줄을 더함."""
for one, pos in self.fix:
for c, name in COMP.items():
mark = _SUM.format(c)
if mark not in one["식"]:
continue
minus = [n for n in self.order[pos:] if n not in self.out]
plus = [n for n in self.order[:pos] if self.out.get(n, -1) >= pos]
terms = []
for sign, group in (("-", minus), ("+", plus)):
for n in group:
call, comps = self.calls[n]
if c not in comps or (sign == "-" and not call.startswith("줄.")):
continue
if len(comps) > 1:
raise EmitError(f"누계 % 밑에 비목 여럿인 줄 「{n}」 — 엔진 꼴 없음")
terms.append(f" {sign} {call}")
one["식"] = one["식"].replace(mark, f"({name}{''.join(terms)})" if terms else name)
def _cols(row: dict) -> dict[str, Any]:
return _money({"L": row["D"], "M": row["E"], "S": row["F"]}) | {"계": row["C"] or 0}
def stmate(
block: dict, nodes: list[dict], prices: dict, rule: str, keys: bool = False, file: str = ""
) -> dict:
"""STmate 블록(`M02_San_Book.stmate_blocks` 한 개) + 줄 나무 + 같은 책 단가 → {로직, 원본}.
rule = 블록 끝전(비목버림 | 합버림_큰나머지 · 파일 설정) · keys = 자원 M01 키(이음) 찾기."""
me = prices.get(block["코드"]) or {}
meta = me.get("meta") or {}
o = Out(block.get("이름"), me.get("단위"), "STmate", block["코드"])
st = _Stm(o, block, prices, keys)
for node in nodes:
kind, src = node["종류"], {"행": node.get("행"), "글": node.get("글") or ""}
if kind in ("빈", "글"):
if src["글"].strip():
o.glyph(src["행"], src["글"])
elif kind == "대입":
st.assign(node, src)
elif kind == "계":
st.total(node, src)
else:
try:
if node.get("오류"):
raise EmitError(node["오류"])
if kind == "참조" and node["자원"]["코드"] not in prices:
raise EmitError(f"단가 없음 {node['자원']['코드']}")
st.shadow(node["식"])
cf = st.coef(node["식"], src)
(st.rate if kind == "율" else st.ref)(node, src, cf)
except (ex.ExprError, EmitError) as error:
o.glyph(src["행"], src["글"], mark=f"셈 못 함 — {error}")
try:
st.finish()
except EmitError as error:
o.marks.append(str(error))
tails = [t for t in block["꼬리"] if t["A"].startswith("계") and not t["A"].startswith("계약")]
kye = (tails or [block["머리"]])[0]
adjust, sbm = None, (meta.get("SBM") or "").strip()
if meta.get("ADJ") == "T" and sbm in COMP:
adjust = {"옮김": COMP[sbm]}
more = {}
if meta.get("UC") == "T" and meta.get("NAG"): # 계약단가 = B09 몫(엔진 밖)
more["계약"] = {"낙찰률": float(meta["NAG"]), "금액": _cols(block["머리"])}
end = "큰나머지" if rule.startswith("합버림") else "비목버림"
return o.done(end, adjust, _cols(kye), "단가산출근거", file, **more)
# ── EST ────────────────────────────────────────────────────────────────
_VAR = re.compile(r"([A-Za-z][A-Za-z0-9_]*)\s*(?:\([^)]*\))?\s*=")
def _est_res(role: dict, value: float, comp: str | None) -> dict:
"""EST 단가 토큰 정체 → 자원 이음 열쇠(옮김 = 성분 비목이 단가 비목과 다를 때)."""
p = role["정체"]
pc = p["비목"] if p["비목"] in COMP else (comp or "S")
res = {"꼴": "EST", "갈래": KIND_EST.get(p["종류"], "고정"), "종류": p["종류"]}
res |= {"이름": p["이름"], "규격": p["규격"], "단위": None}
res |= {"단가": {pc: value}}
res |= {"비목": pc, "옮김": comp if comp and comp != pc else None, "열쇠비목": p["비목"]}
return res | ({"모호": True} if role.get("모호") else {})
def est(block: dict, lines: list[dict], want: dict, keys: bool = False, file: str = "") -> dict:
"""EST 블록(`M02_San_Book.est_blocks` 한 개) + 복원 줄(`M02_San_Est.parse_block`) +
총괄표 성분 want {T, L, M, S} → {로직, 원본} · 성분을 낸 줄만 합(나머지 = 합밖)."""
des = ev.designate(lines, want)
mine = {k: c for c, k in des.items() if isinstance(k, int)}
o = Out(block.get("이름"), block.get("단위"), "EST", block.get("번"))
rows = block.get("글행") or []
for c, k in des.items():
if k is None:
o.marks.append(f"성분 {COMP[c]} {want[c]} 을 낸 줄 없음")
val = ev.run_est(lines)
ref: dict[int, str] = {} # 차례 → 엔진 부름 글(앞줄 · 숨은 합)
for L in lines:
k, form, kind = L["차례"], L["꼴"], L.get("종류")
at, glyph = rows[k] if k < len(rows) else k, L["원글"]
comp = mine.get(k)
if form == "빈":
continue
if form == "변수" and L.get("값") is not None:
m = _VAR.search(L["원글"])
eng = o.names.line(m.group(1) if m else "변수")
o.middle(eng, num(L["값"]), None, at, glyph) # 얼린 값(금액엔 보인 값이 박힘)
continue
if form not in ("돈", "값"):
o.glyph(at, glyph)
continue
head = {} if comp else {"합밖": True}
try:
place = _place(L.get("규칙"))
if k not in val:
raise EmitError("보인 값이 되는 식 · 자리 못 찾음 — 보인 값으로 얼림")
if kind == "단가줄":
i, role = next((i, r) for i, r in L["역할"].items() if r["갈래"] == "단가")
price = next(n[1] for n in ex.numbers(L["식"]) if n[2] == i)
res = _est_res(role, price, comp)
link = _m01(res["갈래"], res) if keys else None
qty = text(L["식"], {}, {i: "1"})
one = resource(o.names.item(L.get("라벨") or ""), None, qty, res, link)
one |= {**head, **({"자리": place} if place else {})}
o.add_line(one, True, at, glyph, L["값"])
ref[k] = f"줄.'{one['이름']}'"
continue
if kind == "값줄":
parts = [ref.get(j) or num(lines[j]["값"]) for j in L["숨은"]["줄"]]
formula = " + ".join(parts)
else:
swap = {i: ref[r["줄"]] for i, r in L["역할"].items() if r.get("줄") in ref}
formula = text(L["식"], {}, swap)
except EmitError as error:
o.glyph(at, glyph, mark=str(error))
formula, place = num(L["값"]), None
name = o.names.line(L.get("라벨") or "줄")
one = {"이름": name, "식": formula, "비목": COMP[comp or L.get("비목") or "S"]}
o.add_line(one | head | ({"자리": place} if place else {}), False, at, glyph, L["값"])
if k in val:
ref[k] = name
check = _money(want) | {"계": want["T"]}
return o.done("비목버림", None, check, "단가산출", file)
@@ -33,6 +33,8 @@ export interface SheetItem {
input?: LogicInput; input?: LogicInput;
/** M01 과 다름(덮개 · 더한줄) */ /** M01 과 다름(덮개 · 더한줄) */
changed: boolean; changed: boolean;
/** 790-2 자리(바탕 cid · 중간 `mid:이름` · `add:#k`) · 없으면 "" */
자리: string;
} }
export interface SheetBase { export interface SheetBase {
@@ -89,10 +91,12 @@ export function sheetItems(o: {
ans: SkAnswer | null; ans: SkAnswer | null;
rows: CoverRow[]; rows: CoverRow[];
text: TextAnswer | null; text: TextAnswer | null;
/** 790-3 자리에 새 덮개(상수 · 표값 · 뺌 …)가 있나 — 캐시 문서 기준 */
covered?: (spot: string) => boolean;
}): SheetItem[] { }): SheetItem[] {
const out: SheetItem[] = []; const out: SheetItem[] = [];
const item = (x: Partial<SheetItem> & { kind: SheetKind; 이름: string }): void => const item = (x: Partial<SheetItem> & { kind: SheetKind; 이름: string }): void =>
void out.push({ 식: "", 값: null, 비목: "", 표: [], changed: false, ...x }); void out.push({ 식: "", 값: null, 비목: "", 표: [], changed: false, 자리: "", ...x });
const b = o.base; const b = o.base;
if (b?.출처) item({ kind: "근거", 이름: b.원문번호 || "", 식: b.출처 }); if (b?.출처) item({ kind: "근거", 이름: b.원문번호 || "", 식: b.출처 });
for (const line of (b?.비고 ?? "").split(/\r?\n/).filter((x) => x.trim())) for (const line of (b?.비고 ?? "").split(/\r?\n/).filter((x) => x.trim()))
@@ -107,9 +111,11 @@ export function sheetItems(o: {
const raw = String(f.식 ?? "").trim(); const raw = String(f.식 ?? "").trim();
const v = mid[f.이름]; const v = mid[f.이름];
const 값 = typeof v === "number" || typeof v === "string" ? v : null; const 값 = typeof v === "number" || typeof v === "string" ? v : null;
if (NUM_RE.test(raw)) item({ kind: "상수", 이름: f.이름, 값: 값 ?? Number(raw) }); const 자리 = `mid:${f.이름}`;
else if (LOOKUP_RE.test(raw)) item({ kind: "표", 이름: f.이름, 값, 표: tablesOf(raw) }); const one = { 이름: f.이름, 값, 자리, changed: !!o.covered?.(자리) };
else item({ kind: "계산", 이름: f.이름, 식: readable(raw, aliases), 값, 표: tablesOf(raw) }); if (NUM_RE.test(raw)) item({ ...one, kind: "상수", 값: 값 ?? Number(raw) });
else if (LOOKUP_RE.test(raw)) item({ ...one, kind: "표", 표: tablesOf(raw) });
else item({ ...one, kind: "계산", 식: readable(raw, aliases), 표: tablesOf(raw) });
} }
const lines = textLines(o.text); const lines = textLines(o.text);
for (const pct of [false, true]) for (const pct of [false, true])
@@ -124,7 +130,8 @@ export function sheetItems(o: {
비목: partTag(r), 비목: partTag(r),
표: tablesOf(line?.이름글 ?? ""), 표: tablesOf(line?.이름글 ?? ""),
row: r, row: r,
changed: r.covered.qty || r.covered.price || r.added !== null, changed: r.covered.qty || r.covered.price || r.added !== null || !!o.covered?.(r.cid),
자리: r.cid,
}); });
} }
const sums = o.ans?.기준 ?? null; const sums = o.ans?.기준 ?? null;
@@ -139,19 +146,28 @@ export const num = (v: number | null | undefined): string => (v == null ? "—"
/** 산출서 한 줄 글 — 경쟁사 꼴 「형틀목공 : 275,790 × 5 / 45 = 30,643.3」 · 소계 · 계 */ /** 산출서 한 줄 글 — 경쟁사 꼴 「형틀목공 : 275,790 × 5 / 45 = 30,643.3」 · 소계 · 계 */
export interface PaperLine { export interface PaperLine {
kind: "줄" | "소계" | "합계" | "머리"; kind: "줄" | "소계" | "합계" | "머리" | "글";
글: string; 글: string;
} }
export function paperLines( export function paperLines(
text: TextAnswer | null, text: TextAnswer | null,
rows: CoverRow[], rows: CoverRow[],
ans: SkAnswer | null, ans: SkAnswer | null,
notes: NoteLine[] = [],
): PaperLine[] { ): PaperLine[] {
if (!ans) return []; if (!ans) return [];
const out: PaperLine[] = []; const out: PaperLine[] = [];
const by = new Map(rows.map((r) => [r.cid, r])); const by = new Map(rows.map((r) => [r.cid, r]));
const sums = ans.기준 ?? {}; const sums = ans.기준 ?? {};
const groups = text?.ok ? text.groups : []; const groups = text?.ok ? text.groups : [];
const shown = new Set(groups.flatMap((g) => g.줄.flatMap((l) => (l.옵션꺼짐 ? [] : [l.cid]))));
const note = (spot: string): PaperLine[] =>
notes.filter((x) => x.글 && x.자리 === spot).map((x) => ({ kind: "글", 글: x.글 }));
out.push(
...notes
.filter((x) => x.글 && !shown.has(x.자리))
.map((x) => ({ kind: "글" as const, 글: x.글 })),
);
for (const g of groups) { for (const g of groups) {
out.push({ kind: "머리", 글: g.비목 }); out.push({ kind: "머리", 글: g.비목 });
for (const l of g.줄) { for (const l of g.줄) {
@@ -162,7 +178,7 @@ export function paperLines(
: r : r
? `${r.pct ? `${num(r.수량)} %` : `${num(r.단가)} × ${num(r.수량)}`} = ${num(r.금액)}` ? `${r.pct ? `${num(r.수량)} %` : `${num(r.단가)} × ${num(r.수량)}`} = ${num(r.금액)}`
: num(l.금액); : num(l.금액);
out.push({ kind: "줄", 글: `${l.이름} : ${body}` }); out.push({ kind: "줄", 글: `${l.이름} : ${body}` }, ...(l.cid ? note(l.cid) : []));
} }
const sub = sums[g.비목] ?? g.소계; const sub = sums[g.비목] ?? g.소계;
out.push({ kind: "소계", 글: `${g.비목} 소계 = ${num(sub)}${g.끝수 ? ` (${g.끝수})` : ""}` }); out.push({ kind: "소계", 글: `${g.비목} 소계 = ${num(sub)}${g.끝수 ? ` (${g.끝수})` : ""}` });
@@ -195,3 +211,118 @@ export const diffText = (now: number | null, was: number | null): string =>
now == null || was == null || now === was now == null || was == null || now === was
? "" ? ""
: `${now > was ? "+" : "−"}${NUM.format(Math.abs(now - was))}`; : `${now > was ? "+" : "−"}${NUM.format(Math.abs(now - was))}`;
// ── 790-3 숫자 넓힘 화면 이음(790-2 셈 답 `시트` · 문서 `글줄`) ──────────────
/** 글 줄(셈 없음) — 자리 뒤 · "" = 맨 위 */
export interface NoteLine {
자리: string;
글: string;
}
/** 셈 답 `시트.자리[자리]`(790-2) */
export interface SpotInfo {
갈래: string;
이름: string;
식?: string;
상수?: { n: number; 값: number | null; 원값: number | null }[];
표값?: { n: number; 글: string; 값: unknown; 원값: unknown }[];
원래?: number | null;
지금?: unknown;
덮개?: string[];
까닭?: string | null;
M01바뀜?: boolean;
/** 792-1 — 지금 식 · M01 식(이름 꼴) · 식 덮개 있음 */
식글?: string;
원식글?: string;
식덮개?: boolean;
}
export interface SheetSpots {
자리: Record<string, SpotInfo>;
글줄: NoteLine[];
짝잃음: { cid: string; 까닭: string }[];
/** 792-1 식 칸에 쓸 수 있는 이름 */
이름표?: { 글: string; 갈래: string }[];
}
export const spotsOf = (ans: SkAnswer | null): SheetSpots | null =>
((ans as { 시트?: SheetSpots } | null)?.시트 ?? null) || null;
export type FormulaPart =
{ t: "글"; 글: string } | { t: "수"; n: number } | { t: "표"; n: number; 표: string[] };
const IDENT = /^[\p{L}_][\p{L}\p{N}_]*/u;
const DIGITS = /^\d+(\.\d+)?/;
/** 식 글 → 숫자 자리(왼쪽부터 · 따옴표 글 · 이름 안 숫자 뺌) — 서버 `scan` 숫자 차례와 같음 */
export function numSpots(s: string): [number, number][] {
const out: [number, number][] = [];
let i = 0;
while (i < s.length) {
const c = s[i];
if (c === "'" || c === '"') {
const j = s.indexOf(c, i + 1);
i = j < 0 ? s.length : j + 1;
continue;
}
const rest = s.slice(i);
const id = IDENT.exec(rest);
const d = id ? null : DIGITS.exec(rest);
if (d) out.push([i, i + d[0].length]);
i += id ? id[0].length : d ? d[0].length : 1;
}
return out;
}
/** 엔진 식을 조각으로 — 글 · 숫자(n) · 표 찾기(n) · 서버 차례와 안 맞으면 null(숫자 칸 안 그림) */
export function formulaParts(info: SpotInfo | undefined): FormulaPart[] | null {
const s = info?.식 ?? "";
const nums = info?.상수 ?? [];
const finds = info?.표값 ?? [];
if (!s || (!nums.length && !finds.length)) return null;
const at = numSpots(s);
if (at.length !== nums.length) return null;
if (at.some(([a, b], i) => Number(s.slice(a, b)) !== Number(nums[i].원값))) return null;
const cuts: { a: number; b: number; p: FormulaPart }[] = [];
let from = 0;
for (const f of finds) {
const a = s.indexOf(f.글, from);
if (a < 0) return null;
from = a + f.글.length;
cuts.push({ a, b: from, p: { t: "표", n: f.n, 표: tablesOf(f.글) } });
}
at.forEach(([a, b], n) => {
if (!cuts.some((c) => c.a <= a && b <= c.b)) cuts.push({ a, b, p: { t: "수", n } });
});
cuts.sort((x, y) => x.a - y.a);
const out: FormulaPart[] = [];
const text = (x: string): void => {
const 글 = x.replace(/\s*\*\s*/g, " × ");
if (글) out.push({ t: "글", 글 });
};
let pos = 0;
for (const c of cuts) {
text(s.slice(pos, c.a));
out.push(c.p);
pos = c.b;
}
text(s.slice(pos));
return out;
}
/** 뺀 더한줄 되살림(차례 k 에 다시 끼움) 뒤 — 까닭 · 글줄 열쇠 `add:#j`(j ≥ k) 한 칸 밂 · `dropAddKeys` 거꾸로 */
export function liftAddKeys<T extends { 까닭?: Record<string, string>; 글줄?: NoteLine[] }>(
doc: T,
k: number,
): T {
const move = (key: string): string =>
key.replace(/^add:#(\d+)/, (all, j: string) =>
Number(j) >= k ? `add:#${Number(j) + 1}` : all,
);
const out = { ...doc };
if (doc.까닭)
out.까닭 = Object.fromEntries(Object.entries(doc.까닭).map(([x, v]) => [move(x), v]));
if (doc.글줄) out.글줄 = doc.글줄.map((x) => ({ ...x, 자리: move(x.자리) }));
return out;
}
@@ -178,3 +178,133 @@ def test_줄_갈래_읽기_쉬운_식_산출서_상태줄():
assert got["status"] == {"원래": 51000, "지금": 51419, "전": 50000} # 원래 = 바탕 셈 계 assert got["status"] == {"원래": 51000, "지금": 51419, "전": 50000} # 원래 = 바탕 셈 계
assert got["origin"]["원래"] == 49000 # 790-2 `원래` 있으면 그 값 assert got["origin"]["원래"] == 49000 # 790-2 `원래` 있으면 그 값
assert got["diff"] == ["+383", "−419", ""] assert got["diff"] == ["+383", "−419", ""]
# ── 790-3 E2 화면 이음(식 속 상수 · 표값 · 글 줄 · 까닭 · M01 원래로 · M01 바뀜) ──────────
EDIT = M02 / "M02_MasterTemplete_Sangeun_SheetEdit.ts"
def test_790_3_숫자_넓힘_칸_정적():
s = _text(EDIT)
assert "<button" not in s and "<input" not in s and "document.createElement" not in s
assert "fetch(" not in s and "sessionStorage." not in s and len(s.splitlines()) < 700
for call in (
"setConst(c.doc, spot, p.n, x)", # 식 속 숫자 = 상수 덮개
"setTable(c.doc, spot, p.n, x)", # 표 꼬리표 값 = 표값 덮개
'createBadge(t("Cut"), "warning")', # 표 연결 끊김
"addText(c.doc, spot,", # 글 줄 더함 · 고침 · 지움
"editText(c.doc, i, v)",
"dropText(c.doc, i)",
"setReason(c.doc, key, x)", # 까닭
"restoreM01(c.doc, spot)", # 줄마다 M01 원래로
"restoreM01(c.doc)", # 전체
'createBadge(t("Changed"), "danger")', # M01 바뀜
"c.spots?.짝잃음",
):
assert call in s, call
sheet = _text(SHEET)
for call in (
"formulaEditor(ctx, it.자리)",
"reasonCell(ctx, it.자리, it.changed)",
"spotTail(ctx, it.자리,",
"restoreAll(ctx)",
"lostBanner(ctx)",
"removedReasons(ctx, d.removed)",
"dropAddKeys(d.doc as ConstDoc, r.added)", # 뺀 더한줄 = 까닭 · 글줄 열쇠 당김
"liftAddKeys(d.doc as ConstDoc, at)", # 되살림 = 거꾸로 밂
"paperLines(d.text, d.rows, d.ans, (d.doc as ConstDoc).글줄)", # 글 줄 산출서에
"spots: spotsOf(d.ans)",
):
assert call in sheet, call
assert "Object.assign(doc, next);" in sheet # 상세가 쥔 같은 문서 객체 · 자동저장 없음
assert len(sheet.splitlines()) < 700
SPOT = {
"식": "찾기(QC000431, 유형=유형).시공량 * (1 - (높이감률 + 만약(지붕슬래브 == '아님', 0, 17)) / 100)",
"상수": [{"n": 0, "값": 1, "원값": 1}, {"n": 1, "값": 0, "원값": 0},
{"n": 2, "값": 17, "원값": 17}, {"n": 3, "값": 100, "원값": 100}],
"표값": [{"n": 0, "글": "찾기(QC000431, 유형=유형).시공량", "값": 45, "원값": 45}],
} # fmt: skip
def _run_e2():
src = IMPORTS.sub("", _text(MODEL))
bad = {**SPOT, "상수": SPOT["상수"][:3]}
body = (
f"\nconst S = {json.dumps(SPOT, ensure_ascii=False)};"
f"\nconst B = {json.dumps(bad, ensure_ascii=False)};"
f"\nconst F = {json.dumps(FIX, ensure_ascii=False)};"
"\nconst show = (p) => p && p.map((x) => x.t === '글' ? x.글 : x.t === '수' ? `[#${x.n}]` : `[표${x.n} ${x.표}]`).join('');"
"\nconst notes = [{ 자리: '', 글: '맨 위 글' }, { 자리: 'labor:LB000007#0', 글: '형틀 뒤 글' },"
" { 자리: 'material:폼타이#0', 글: '꺼진 줄 뒤' }, { 자리: 'labor:LB000007#0', 글: '' }];"
"\nconsole.log(JSON.stringify({"
"\n parts: show(formulaParts(S)),"
"\n bad: formulaParts(B),"
"\n none: formulaParts({ 식: '형틀목공', 상수: [], 표값: [] }),"
"\n spots: numSpots(\"만약(a == '3회', LB000007 * 2.5, 줄.'합판 12㎜' / 10)\"),"
"\n paper: paperLines(F.text, F.rows, F.ans, notes).map((p) => p.kind + ':' + p.글),"
"\n lift: liftAddKeys({ 까닭: { 'add:#0': 'a', 'add:#1/x#0': 'b', 'mid:q': 'c' },"
" 글줄: [{ 자리: 'add:#2', 글: 'g' }] }, 1),"
"\n spotsNone: spotsOf(null),"
"\n}));"
) # fmt: skip
result = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings",
"--input-type=module-typescript", "-"],
input=src + body, capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert result.returncode == 0, result.stderr
return json.loads(result.stdout)
def test_790_3_식_조각_글줄_산출서_열쇠_밂():
got = _run_e2()
assert got["parts"] == (
"[표0 QC000431] × ([#0] - (높이감률 + 만약(지붕슬래브 == '아님', [#1], [#2])) / [#3])"
) # 서버 scan 차례 그대로 · 표 찾기 = 한 칸
assert got["bad"] is None and got["none"] is None # 차례가 안 맞으면 숫자 칸 안 그림
assert len(got["spots"]) == 2 # 2.5 · 10 만 — 따옴표 글(3회 · 12㎜) · 이름(LB000007) 안 숫자 뺌
paper = got["paper"]
assert paper[0] == "글:맨 위 글" and paper[1] == "글:꺼진 줄 뒤" # 맨 위 · 안 그린 줄 = 위로
i = paper.index("줄:형틀목공 : 275,790 × 0.1111 = 30,643.3")
assert paper[i + 1] == "글:형틀 뒤 글" and "글:" not in paper[i + 2] # 빈 글 안 찍음
assert got["lift"]["까닭"] == {"add:#0": "a", "add:#2/x#0": "b", "mid:q": "c"}
assert got["lift"]["글줄"] == [{"자리": "add:#3", "글": "g"}]
assert got["spotsNone"] is None
# ── 792 식 칸 화면(줄 식 고침 · 칠 때마다 검사 · 변수 줄) ────────────────────────────
FX = M02 / "M02_MasterTemplete_Sangeun_SheetFormula.ts"
def test_792_식_칸_정적():
s = _text(FX)
assert "<button" not in s and "<input" not in s and "document.createElement" not in s
assert "sessionStorage." not in s and len(s.splitlines()) < 700
for call in (
"/sangeun/formula-check", # 검사 길(셈 · 저장 없음)
"ctl?.abort();", # 칠 때마다 · 옛 요청 끊기
"signal,",
'box.input.addEventListener("input"',
'box.setError(got.되나 ? undefined : got.까닭.join(" · "))', # 빨간 칸 + 까닭
"if (!got?.되나) return", # 틀린 식은 문서에 안 넣음
"setFormula(doc, spot, engine)", # 엔진 식을 문서에
"clearNums(c.doc as FormulaDoc, spot)", # 같은 자리 상수 · 표값 · 품 덮개 걷음
"addVar(c.doc as FormulaDoc,",
"editVar(c.doc as FormulaDoc, v.이름, { 이름: to })",
"dropVar(c.doc as FormulaDoc, v.이름)",
):
assert call in s, call
sheet = _text(SHEET)
for call in (
"rowFormula(ctx, it.자리,",
"formulaToggle(ctx, it.자리,",
"varRows(ctx, (v) => v.자리 === it.자리)",
"addVarButton(ctx)",
"scope: opt.scope,",
'coverKinds(d.doc as ConstDoc, r.cid).includes("식")', # 식 덮개 = 품 칸 안 둠
):
assert call in sheet, call
assert "addRate: () => rateButton.click(), scope }" in _text(DETAIL) # 탭 등록 줄에 층만
assert "fetch(" not in s # 길 = Api_Fetch.call(로그인 쿠키)
+288
View File
@@ -0,0 +1,288 @@
"""793-2a 지킴 — 경쟁사 산근 → 우리 엔진 로직 줄 꼴(`M02_San_Emit`) · 엔진 셈 = 엑셀.
여덟 파일 STmate 233 · EST 222 블록을 로직 JSON 으로 내보내 `master_formula.run` →
`master_cut.engine` 으로 셈 — 노 · 재 · 경 · 계 = 엑셀(STmate 계 꼬리 · EST 총괄표) =
참조 평가기(`M02_San_Eval`) · 줄마다(STmate 금액 줄 · EST 돈 · 값 줄) · 계표시(단계) 같음.
단가 전파 견본 = `_분석_649-G2` 4 장(보통인부 → 180,000) · 계약단가(낙찰률)는 B09 몫 — 엔진 계 ×
낙찰률 원 버림 = 엑셀 머리만 확인. 엑셀 = `helper_m02_hopyo_golden.FILES`(읽기만 · 없으면 skip).
"""
from __future__ import annotations
import importlib
import math
from functools import lru_cache
import helper_m02_hopyo_golden as golden
import pytest
from M01_MasterData import M01_MasterData_Store as m01 # noqa: F401 — scripts 길을 둠
from M02_MasterTemplete import M02_San_Book as book
from M02_MasterTemplete import M02_San_Emit as em
from M02_MasterTemplete import M02_San_Est as est
from M02_MasterTemplete import M02_San_Eval as ev
from M02_MasterTemplete import M02_San_Stmate as stm
mf = importlib.import_module("master_formula")
mcut = importlib.import_module("master_cut")
ms = importlib.import_module("master_formula_sum")
STMATE = { # 파일: (블록, 금액 줄, 계표시 줄, 끝전)
"봉화": (62, 349, 184, "비목버림"),
"동부청영월": (69, 411, 230, "합버림_큰나머지"),
"울진대흥": (47, 261, 128, "비목버림"),
"영덕계류": (15, 86, 41, "비목버림"),
"울진소광": (40, 208, 112, "비목버림"),
}
EST = {"북부청임도": (94, 1252), "북부청사방": (67, 849), "거창": (61, 802)} # (블록, 돈 · 값 줄)
# 엑셀 글 식이 보인 값과 안 맞는 줄(793-1 틀린 줄 2) — 보인 값으로 얼림 · 블록 금액은 같음
EST_FROZEN = {"북부청임도": {27: 1, 33: 1}}
K = ("노무비", "재료비", "경비", "계")
LABOR = {"종류": "노임", "이름": "보통인부", "규격": "", "열쇠비목": "L"}
def engine(out: dict) -> dict:
"""내보낸 로직 → 엔진 셈 — 단가 줄은 SK 서버처럼 값을 얹음(요소 대신 값묶음 {비목: 단가})."""
logic = {**out["로직"], "키": "GX990001"}
items = []
for item in logic["호표"]:
one = {k: v for k, v in item.items() if k not in ("이음", "자원", "단가", "규격")}
if "단가" in item:
money = dict.fromkeys(K[:3], 0) | {item["비목"]: item["단가"]}
one |= {"종류": "로직", "값묶음": money}
del one["비목"]
items.append(one | {"요소": None})
logic["호표"] = items
master = mf.Master({"로직.json": {"그룹": "로직", "줄": [logic]}})
return mcut.engine(master, logic["키"], mf.run(master, logic["키"], {}))
def money(got: dict) -> tuple:
return tuple(int(got[k]) for k in K)
@lru_cache(maxsize=8)
def _load(tag: str) -> dict:
path = golden.FILES[tag]
if not path.exists():
pytest.skip(f"실무 엑셀 없음 {path.name}")
return book.load(path)
def _stmate(tag: str, blk: dict) -> tuple[list, dict]:
nodes = stm.parse_block(blk["몸"])
return nodes, em.stmate(blk, nodes, _load(tag)["단가"], STMATE[tag][3])
def _lines_equal(out: dict, got: dict) -> tuple[list, int, int]:
"""원본 글줄마다 엔진 줄 금액(계) = 엑셀 · 단계 = 계표시 줄 → (틀린 것, 줄 수, 단계 수)."""
lines = {x["이름"]: x for x in got["줄"]}
steps = {x["이름"]: x for x in got.get("단계", [])}
bad, n_line, n_step = [], 0, 0
for g in out["원본"]["글줄"]:
if g.get("값") is None or not g.get("자리") or g["자리"].startswith("mid:"):
continue
if g["자리"].startswith("단계:"):
n_step += 1
have = float(steps[g["자리"][3:]]["계"])
tol = 0.15
else:
n_line += 1
have = sum(float(lines[n]["금액"] or 0) for n in g.get("자리들") or [g["자리"]])
tol = 0.05
if abs(have - g["값"]) >= tol:
bad.append((g["행"], g["글"].strip(), have, g["값"]))
return bad, n_line, n_step
@pytest.mark.parametrize("tag", list(STMATE))
def test_stmate_engine_equals_excel(tag: str) -> None:
data = _load(tag)
blocks, n_lines, n_steps, rule = STMATE[tag]
n, wrong, lines, steps = 0, [], 0, 0
for blk in data["블록"]:
if not any(ln["G"].strip() for ln in blk["몸"]):
continue
n += 1
nodes, out = _stmate(tag, blk)
got = engine(out)
ref = ev.stmate_block(blk, nodes, data["단가"], rule)["계"]
want = out["원본"]["엑셀금액"]
same = money(got) == tuple(round(want[k]) for k in K)
same &= money(got) == (ref["L"], ref["M"], ref["S"], ref["T"])
bad, a, b = _lines_equal(out, got)
bad += ms.check(out["로직"], blk["코드"]) + out["원본"]["표지"]
lines, steps = lines + a, steps + b
if deal := out["원본"].get("계약"): # B09 몫 — 엔진 계 × 낙찰률 원 버림 = 엑셀 머리
cut = [math.floor(round(int(got[k]) * deal["낙찰률"] / 100, 6)) for k in K[:3]]
same &= cut == [deal["금액"][k] for k in K[:3]]
if not same or bad:
wrong.append((blk["코드"], money(got), bad[:3]))
assert (n, wrong) == (blocks, [])
assert (lines, steps) == (n_lines, n_steps)
@pytest.mark.parametrize("tag", list(EST))
def test_est_engine_equals_excel(tag: str) -> None:
data = _load(tag)
n, wrong, lines = 0, [], 0
for blk in data["블록"]:
want = data["총괄"].get(blk["번"])
if not want or not (want["T"] or any(want[c] for c in "LMS")):
continue
n += 1
parsed = est.parse_block(blk["글"], data["단가"])
out = em.est(blk, parsed, want)
got = engine(out)
ref = ev.est_block(parsed, ev.designate(parsed, want))["금액"]
same = money(got) == (want["L"], want["M"], want["S"], want["T"])
same &= money(got) == (ref["L"], ref["M"], ref["S"], ref["T"])
bad, a, _ = _lines_equal(out, got)
bad += ms.check(out["로직"], str(blk["번"]))
lines += a
frozen = len(out["원본"]["표지"])
if not same or bad or frozen != EST_FROZEN.get(tag, {}).get(blk["번"], 0):
wrong.append((blk["번"], money(got), bad[:3], out["원본"]["표지"]))
assert (n, wrong, lines) == (EST[tag][0], [], EST[tag][1])
@pytest.mark.parametrize(
"tag, code, labor, before, after",
[
("봉화", "D00102", "L00007", (34119, 11192, 341), (34697, 11192, 346)),
("동부청영월", "D00003", "L00002", (4764, 310, 397), (5097, 309, 397)),
("울진소광", "D01489", "L00016", (182223, 230359, 8152), (185067, 230359, 8209)),
],
)
def test_stmate_price_propagates(tag, code, labor, before, after) -> None:
blk = next(b for b in _load(tag)["블록"] if b["코드"] == code)
_, out = _stmate(tag, blk)
assert money(engine(out))[:3] == before
assert em.reprice(out["로직"], {"코드": labor}, {"L": 180000}) >= 1
assert money(engine(out))[:3] == after
@pytest.mark.parametrize(
"tag, no, after",
[
("북부청사방", 31, (14877, 5491, 0)),
("북부청임도", 2, (22774, 3235, 3830)),
("거창", 1, (3485, 832, 1049)),
("북부청사방", 3, (20147, 391, 0)),
],
)
def test_est_price_propagates(tag, no, after) -> None:
data = _load(tag)
blk = next(b for b in data["블록"] if b["번"] == no)
out = em.est(blk, est.parse_block(blk["글"], data["단가"]), data["총괄"][no])
assert em.reprice(out["로직"], LABOR, {"L": 180000.0}) >= 1
assert money(engine(out))[:3] == after
def test_link_keys_m01() -> None:
"""호표 줄 = 793-2 꼴(종류 · 단가 · 이음 = 1차 줄 판정 M01 키) · 덧칸 자원 · 원본."""
tag = "봉화"
blk = next(b for b in _load(tag)["블록"] if b["코드"] == "D00102")
out = em.stmate(blk, stm.parse_block(blk["몸"]), _load(tag)["단가"], "비목버림", True)
labor = next(x for x in out["로직"]["호표"] if x["이름"] == "보통인부")
assert {k: v for k, v in labor.items() if k != "자원"} == {
"이름": "보통인부",
"규격": "",
"단위": "인",
"수량": "(2 / 50)",
"종류": "인력",
"비목": "노무비",
"단가": 165545,
"이음": "LB000002",
"번지": "A02",
}
assert labor["자원"] == {
"꼴": "STmate",
"갈래": "인력",
"코드": "L00007",
"이름": "보통인부",
"규격": None,
"단위": "인",
"단가": {"L": 165545, "M": 0, "S": 0},
"비목": "L",
"옮김": None,
}
src = out["원본"]
assert {k: src[k] for k in ("시트", "갈래", "코드", "행")} == {
"시트": "단가산출근거",
"갈래": "STmate",
"코드": "D00102",
"행": [2912, 2937],
}
assert src["엑셀금액"] == {"노무비": 34119, "재료비": 11192, "경비": 341, "계": 45652}
assert next(g for g in src["글줄"] if g.get("자리") == "보통인부")["값"] == 6621.8
# ── 엑셀 없이 도는 문법 견본(793-1 `test_stmate_grammar_sample` + 다시 대입 · 뒤 줄) ──
def test_stmate_grammar_sample_engine() -> None:
prices = {
"M00081": {"T": 11468, "L": 0, "M": 11468, "S": 0, "meta": {}, "이름": "합판(내수)"},
"L00007": {"T": 165545, "L": 165545, "M": 0, "S": 0, "meta": {}, "이름": "보통인부"},
"L00099": {"T": 2, "L": 2, "M": 0, "S": 0, "meta": {"UNT": "L%"}, "이름": "공구"},
}
text = [
"'합판(내수) :'&M00081& * 1.03 * 0.327 :=A01",
";;Q = 2.5 / 3 =",
"'보통인부' &L00007& * [Q] / 4 :=A02",
"&PA01M:M& * 11'%' =",
"&PA02L:S& * 1'%' = // 공구손료",
"(=)",
"&L00099& * 2 =",
"(-=)",
"'별도' &M00081& * 2 =",
"Q = 1 / 3 =",
"&L00099& * [Q] =",
"'뒤 인부' &L00007& * 0.01 =",
]
rows = [{"행": i, "G": g} for i, g in enumerate(text)]
nodes = stm.parse_block(rows)
block = {"코드": "D00001", "이름": "견본", "꼬리": [], "몸": rows}
block["머리"] = {"A": "", "B": "", "C": 0, "D": 0, "E": 0, "F": 0}
out = em.stmate(block, nodes, prices, "비목버림")
logic = out["로직"]
assert logic["중간"] == [
{"이름": "q", "식": "(2.5 / 3)", "표시": 2},
{"이름": "q_2", "식": "(1 / 3)", "표시": 2}, # 다시 대입 = 새 이름
]
assert ms.check(logic, "견본") == [] and out["원본"]["표지"] == []
got = engine(out)
lines, tot = ev.run_stmate(nodes, prices)
want = ev.block_total(tot, "비목버림")
assert money(got) == (want["L"], want["M"], want["S"], want["T"])
by = {x["이름"]: x for x in got["줄"]}
assert by["보통인부"]["비목"]["노무비"] == 34350.5 # 165,545 × 0.83 / 4
assert by["공구"]["비목"]["노무비"] == 687 # 노무 누계 × 2 % · (-=) 로 합에서 뺌
assert logic["덧줄"][-1]["식"] == "(노무비 - 줄.'뒤 인부') * ((q_2)) / 100" # 뒤 줄을 뺀 누계
assert (
float(by["공구_2"]["비목"]["노무비"]) == lines[10]["값"]["L"] == 113.3
) # 34,350.5 × 0.33 %
def test_est_grammar_sample_engine() -> None:
prices = [{"종류": "노임", "이름": "보통인부", "규격": "", "비목": "L", "값": 172068.0}]
text = [
"보통인부 : 172,068 × 0.09 인 = 15,486.1",
"공구손료 : 15,486.1 × 3 % = 464.5",
"소 계 : 15,950.6",
"노무비 : 15,950.6 × 1 = 15,950",
]
lines = est.parse_block(text, prices)
want = {"T": 15950.0, "L": 15950.0, "M": 0.0, "S": 0.0}
out = em.est({"번": 1, "이름": "견본", "단위": "㎡"}, lines, want)
logic = out["로직"]
assert [x["식"] for x in logic["덧줄"]] == [
"((줄.'보통인부' * 3) / 100)",
"줄.'보통인부' + 공구손료",
"소계",
]
assert logic["호표"][0]["종류"] == "인력" and logic["호표"][0]["단가"] == 172068
assert logic["덧줄"][2]["자리"] == {"방법": "버림", "자리": 0}
assert [x.get("합밖") for x in (*logic["호표"], *logic["덧줄"])] == [True, True, True, None]
assert money(engine(out)) == (15950, 0, 0, 15950)
em.reprice(logic, LABOR, {"L": 180000.0})
assert money(engine(out)) == (16686, 0, 0, 16686) # 793-1 run_est 전파와 같음