Merge remote-tracking branch 'origin/dev' into sub_laptop_2
This commit is contained in:
@@ -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];
|
||||||
|
}
|
||||||
@@ -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(로그인 쿠키)
|
||||||
|
|||||||
@@ -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 전파와 같음
|
||||||
Reference in New Issue
Block a user