Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_2' and 'origin/sub_laptop_3' into main_laptop_1
This commit is contained in:
@@ -17,7 +17,8 @@ const LABEL: Record<(typeof COSTS)[number], ComboTextKey> = {
|
||||
};
|
||||
|
||||
const num = (v: string): number | string => (v.trim() !== "" && !isNaN(Number(v)) ? Number(v) : v);
|
||||
const fmt = (n: number): string => n.toLocaleString("ko-KR", { maximumFractionDigits: 4 });
|
||||
/** 원 단위 소수점 0 — 조합 미리 보기는 모두 돈 자리(노무비·재료비·경비·계) */
|
||||
const fmt = (n: number): string => n.toLocaleString("ko-KR", { maximumFractionDigits: 0 });
|
||||
|
||||
/** 로직 입력 정의 → 처음 값(고르기 첫째 · 범위 아래끝) */
|
||||
const initial = (i: LogicInput): string => String(i.고르기?.[0] ?? i.범위?.[0] ?? "");
|
||||
|
||||
@@ -19,9 +19,53 @@ import { buildEditor, formatNumber, qtyKind } from "./M01_MasterData_UI_Logic_Ed
|
||||
import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
|
||||
import { tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { buildFormula } from "./M01_MasterData_UI_LogicLab_Formula";
|
||||
import { openMaterialModal } from "./M01_MasterData_UI_LogicLab_Modal";
|
||||
import { openMaterialModal, openTableModal } from "./M01_MasterData_UI_LogicLab_Modal";
|
||||
import { fragmentRow, type Fragment } from "./M01_MasterData_UI_LogicLab_Pill";
|
||||
import { tl, type LabTextKey } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
import "./M01_MasterData_UI_LogicLab_Style.css";
|
||||
import "./M01_MasterData_UI_LogicLab_Pill.css";
|
||||
|
||||
/** 원 단위 소수점 0 — 돈 자리(단가·금액·소계·계)만. 수량·비율은 `formatNumber` 그대로 둠. */
|
||||
const formatMoney = (value: unknown): string =>
|
||||
typeof value === "number"
|
||||
? value.toLocaleString("ko-KR", { maximumFractionDigits: 0 })
|
||||
: formatNumber(value);
|
||||
|
||||
/** 조각 계약(`ref/_분석_알약_상세줄.md` ㉮ 표) — 수량 식 안 찾기(...) 표 호출만 알약,
|
||||
* 그 밖(숫자·연산기호·중간값·설계입력 이름)은 글자. 서버가 조각 목록을 아직 안 주므로
|
||||
* 여기서 임시로 쪼갬(`Logic_Note.ts` 찾기() 정규식과 같은 규칙) — 서버가 붙으면 이 파싱을 걷어냄. */
|
||||
const FIND_RE = /찾기\(\s*([A-Za-z0-9_]+)\s*,([^)]*)\)\s*\.\s*([A-Za-z0-9_가-힣]+)/g;
|
||||
|
||||
function splitFindConds(raw: string): [string, string][] {
|
||||
return raw
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean)
|
||||
.map((p): [string, string] => {
|
||||
const i = p.indexOf("=");
|
||||
return i < 0 ? [p, p] : [p.slice(0, i).trim(), p.slice(i + 1).trim()];
|
||||
});
|
||||
}
|
||||
|
||||
function qtyFragments(expr: string, values: Record<string, string>): Fragment[] {
|
||||
const out: Fragment[] = [];
|
||||
let last = 0;
|
||||
for (const m of expr.matchAll(FIND_RE)) {
|
||||
const at = m.index ?? 0;
|
||||
if (at > last) out.push({ text: expr.slice(last, at) });
|
||||
const table = m[1];
|
||||
const label = `${tl("Pill_Table")} ${table}`;
|
||||
out.push({
|
||||
pill: {
|
||||
label,
|
||||
onClick: () => openTableModal({ title: label, table, conds: splitFindConds(m[2]), values }),
|
||||
},
|
||||
});
|
||||
last = at + m[0].length;
|
||||
}
|
||||
if (last < expr.length) out.push({ text: expr.slice(last) });
|
||||
return out.length ? out : [{ text: expr }];
|
||||
}
|
||||
|
||||
export interface DetailContext {
|
||||
row: LogicRow;
|
||||
@@ -56,6 +100,7 @@ interface Entry {
|
||||
unit: string;
|
||||
qty: string;
|
||||
qtyTag: string;
|
||||
qtyFrag: Fragment[];
|
||||
price: string;
|
||||
amount: number | null;
|
||||
/** 덧줄이면 true */
|
||||
@@ -168,7 +213,8 @@ function entries(ctx: DetailContext): Entry[] {
|
||||
unit: item.단위 ?? "",
|
||||
qty: item.수량,
|
||||
qtyTag: qtyKind(item.수량) === tx("Ho_QtyTable") ? tl("Tag_Table") : qtyKind(item.수량),
|
||||
price: price === undefined || price === null ? "" : formatNumber(price),
|
||||
qtyFrag: qtyFragments(item.수량, ctx.values),
|
||||
price: price === undefined || price === null ? "" : formatMoney(price),
|
||||
amount: line ? line.금액 : null,
|
||||
extra: false,
|
||||
open: () =>
|
||||
@@ -192,6 +238,7 @@ function entries(ctx: DetailContext): Entry[] {
|
||||
unit: "",
|
||||
qty: extra.식,
|
||||
qtyTag: tl("Extra"),
|
||||
qtyFrag: qtyFragments(extra.식, ctx.values),
|
||||
price: "",
|
||||
amount: line ? line.금액 : null,
|
||||
extra: true,
|
||||
@@ -224,13 +271,14 @@ function lineRow(e: Entry): HTMLElement {
|
||||
}),
|
||||
cellOf(e.unit),
|
||||
el("td", {
|
||||
className: "m01lab__qty-cell",
|
||||
children: [
|
||||
el("span", { className: "m01-logic__tag", text: e.qtyTag }),
|
||||
el("span", { className: "m01lab__qty", text: ` ${e.qty}` }),
|
||||
fragmentRow(e.qtyFrag),
|
||||
],
|
||||
}),
|
||||
cellOf(e.price, "m01-logic__money"),
|
||||
cellOf(e.amount === null ? "" : formatNumber(e.amount), "m01-logic__money"),
|
||||
cellOf(e.amount === null ? "" : formatMoney(e.amount), "m01-logic__money"),
|
||||
],
|
||||
});
|
||||
tr.addEventListener("click", e.open);
|
||||
@@ -254,7 +302,7 @@ function groupTable(group: Group, mine: Entry[], calculated: boolean): HTMLEleme
|
||||
attrs: { "data-group": group, "data-subtotal": calculated ? String(sub) : "" },
|
||||
children: [
|
||||
el("td", { attrs: { colspan: "4" }, text: `${groupName(group)} ${tl("Subtotal")}` }),
|
||||
cellOf(calculated ? formatNumber(sub) : "—", "m01-logic__money"),
|
||||
cellOf(calculated ? formatMoney(sub) : "—", "m01-logic__money"),
|
||||
],
|
||||
});
|
||||
const headers = [
|
||||
@@ -326,7 +374,7 @@ function hoBox(ctx: DetailContext): HTMLElement {
|
||||
const totalLine = el("p", {
|
||||
className: "m01lab__total",
|
||||
attrs: { "data-sum": calculated ? String(total) : "" },
|
||||
text: `${tl("Total")} ${calculated ? formatNumber(total) : "—"}`,
|
||||
text: `${tl("Total")} ${calculated ? formatMoney(total) : "—"}`,
|
||||
});
|
||||
// 자체 로직 — 고치는 자리는 편집기 하나 · 읽기용 묶음 표는 접어 둠(펼치면 소계가 보임)
|
||||
const fold = el("details", {
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_Modal.ts
|
||||
* 호표 줄 자료 모달 — 그 줄이 쓰는 자료를 보임
|
||||
* 표(소요량·계수)는 표 그대로 + 지금 넣은 값으로 걸린 줄 강조 · 단일 값(단가)은 값과 출처
|
||||
* 자료 모달 둘 —
|
||||
* ① 줄 모달(`openMaterialModal`) — 줄을 누르면 그 줄의 단가(재료·인력·기계 또는 다른 로직)를
|
||||
* 바로 보임(알약 목록 없이) · 그 아래 한 줄 풀이 + 원문 수량 식
|
||||
* ② 표 모달(`openTableModal`) — 수량 칸 안 알약(찾기(...) 표 참조)을 누르면 그 표만 보임
|
||||
* 표는 표 그대로 + 지금 넣은 값으로 걸린 줄 강조 · 단일 값(단가)은 값과 출처
|
||||
* 표 읽기 = `/elements` → `/table`(`Logic_Note.loadTable`) · 단가 = 로직 화면이 이미 받은 `prices`
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import type { ElementBrief, NamedFormula } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
|
||||
import {
|
||||
explain,
|
||||
loadTable,
|
||||
matchRow,
|
||||
relatedFinds,
|
||||
type RelatedFind,
|
||||
type TableRow,
|
||||
} from "./M01_MasterData_UI_Logic_Note";
|
||||
import { explain, loadTable, matchRow, type TableRow } from "./M01_MasterData_UI_Logic_Note";
|
||||
import { jumpToMaster, type MasterJump } from "./M01_MasterData_UI_Side";
|
||||
import { pillBar, type Pill } from "./M01_MasterData_UI_LogicLab_Pill";
|
||||
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
import "./M01_MasterData_UI_LogicLab_Pill.css";
|
||||
|
||||
/** 원 단위 소수점 0 — 돈 자리만(수량·비율은 `formatNumber` 그대로) */
|
||||
const formatMoney = (value: unknown): string =>
|
||||
typeof value === "number"
|
||||
? value.toLocaleString("ko-KR", { maximumFractionDigits: 0 })
|
||||
: formatNumber(value);
|
||||
|
||||
export interface ModalTarget {
|
||||
title: string;
|
||||
@@ -33,19 +33,25 @@ export interface ModalTarget {
|
||||
values: Record<string, string>;
|
||||
}
|
||||
|
||||
/** 수량 칸 표 알약 하나가 여는 자료 — 조각 계약의 「표 찾기」 자리 */
|
||||
export interface TableTarget {
|
||||
title: string;
|
||||
table: string;
|
||||
conds: [string, string][];
|
||||
values: Record<string, string>;
|
||||
}
|
||||
|
||||
function tableView(
|
||||
find: RelatedFind,
|
||||
table: TableRow,
|
||||
conds: [string, string][],
|
||||
values: Record<string, string>,
|
||||
): HTMLElement {
|
||||
// 표 줄의 빈 칸은 「무엇이든」 — 서버 찾기()와 같게 그 칸은 조건에서 뺌
|
||||
// 표 줄의 빈 칸은 「무엇이든」 — 서버 찾기()와 같게 그 칸은 조건에서 뺌(줄마다 따로 봄)
|
||||
const hit =
|
||||
(table.줄 ?? []).find((line) =>
|
||||
matchRow(
|
||||
{ ...table, 줄: [line] },
|
||||
find.find.conds.filter(
|
||||
([col]) => line[col] !== undefined && line[col] !== null && line[col] !== "",
|
||||
),
|
||||
conds.filter(([col]) => line[col] !== undefined && line[col] !== null && line[col] !== ""),
|
||||
{ values, middle: {} },
|
||||
),
|
||||
) ?? null;
|
||||
@@ -71,7 +77,7 @@ function tableView(
|
||||
}),
|
||||
el("p", {
|
||||
className: "m01-logic__muted",
|
||||
text: `${find.source} → ${find.find.col} · ${hit ? tl("Modal_Hit") : tl("Modal_NoHit")}`,
|
||||
text: hit ? tl("Modal_Hit") : tl("Modal_NoHit"),
|
||||
}),
|
||||
goButton({ kind: "table", key: table.키 }),
|
||||
el("div", {
|
||||
@@ -117,7 +123,7 @@ function priceView(ref: string, brief: ElementBrief): HTMLElement {
|
||||
? [el("dt", { text: tl("Modal_Spec") }), el("dd", { text: brief.규격 })]
|
||||
: []),
|
||||
el("dt", { text: tl("Modal_Value") }),
|
||||
el("dd", { className: "m01-logic__money", text: formatNumber(brief.값) }),
|
||||
el("dd", { className: "m01-logic__money", text: formatMoney(brief.값) }),
|
||||
...(brief.file
|
||||
? [el("dt", { text: tl("Modal_Source") }), el("dd", { text: brief.file })]
|
||||
: []),
|
||||
@@ -129,18 +135,18 @@ function priceView(ref: string, brief: ElementBrief): HTMLElement {
|
||||
});
|
||||
}
|
||||
|
||||
/** 모달을 엶 */
|
||||
export function openMaterialModal(target: ModalTarget): void {
|
||||
/** 모달 겉틀(배경·닫기·포커스) — 몸은 `mount` 이 채움 */
|
||||
function openDialog(title: string, mount: (body: HTMLElement) => void): void {
|
||||
const body = el("div", { className: "m01lab__modal-body" });
|
||||
const close = (): void => backdrop.remove();
|
||||
const dialog = el("div", {
|
||||
className: "m01-logic__pick m01lab__modal",
|
||||
attrs: { role: "dialog", "aria-label": target.title },
|
||||
attrs: { role: "dialog", "aria-label": title },
|
||||
children: [
|
||||
el("div", {
|
||||
className: "m01lab__modal-top",
|
||||
children: [
|
||||
el("h3", { text: target.title }),
|
||||
el("h3", { text: title }),
|
||||
createButton({ label: tl("Modal_Close"), variant: "ghost", onClick: close }),
|
||||
],
|
||||
}),
|
||||
@@ -150,64 +156,62 @@ export function openMaterialModal(target: ModalTarget): void {
|
||||
const backdrop = el("div", { className: "m01-logic__backdrop", children: [dialog] });
|
||||
backdrop.addEventListener("click", (ev) => ev.target === backdrop && close());
|
||||
backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close());
|
||||
|
||||
const finds = relatedFinds(target.expr, target.middles);
|
||||
const base = target.element?.split(".")[0] ?? "";
|
||||
const price = target.element ? (target.prices[target.element] ?? target.prices[base]) : undefined;
|
||||
const logicRef = target.element?.startsWith("로직(") ? target.element : "";
|
||||
const pills: Pill[] = [];
|
||||
if (price && target.element) {
|
||||
pills.push({
|
||||
label: `${groupOf(base)} ${price.이름 ?? base}`.trim(),
|
||||
view: () => priceView(target.element as string, price),
|
||||
});
|
||||
} else if (target.element && !logicRef) {
|
||||
pills.push({
|
||||
label: `${groupOf(base)} ${target.title}`.trim(),
|
||||
view: () =>
|
||||
el("p", {
|
||||
className: "m01-logic__muted",
|
||||
text: `${target.element} — ${tl(target.element?.includes("{") ? "Pill_NoPick" : "Pill_NoValue")}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (logicRef) {
|
||||
pills.push({
|
||||
label: `${tl("Pill_Logic")} ${logicRef.slice(3).split(",")[0].replace(/\)$/, "")}`,
|
||||
view: () => el("p", { text: `${tl("Modal_Logic")} · ${logicRef}` }),
|
||||
});
|
||||
}
|
||||
for (const f of finds) {
|
||||
pills.push({
|
||||
label: `${tl("Pill_Table")} ${f.find.table}`,
|
||||
view: async () => {
|
||||
const table = await loadTable(f.find.table);
|
||||
return table
|
||||
? tableView(f, table, target.values)
|
||||
: el("p", { className: "m01-logic__bad", text: `${f.find.table} ?` });
|
||||
},
|
||||
});
|
||||
}
|
||||
const unit = price ? `${tl("Modal_UnitPrice")} ${formatNumber(price.값)} × ` : "";
|
||||
const parts: HTMLElement[] = [
|
||||
el("p", { className: "m01-logic__muted", text: tl("Modal_Sum") }),
|
||||
el("p", {
|
||||
className: "m01lab__sum",
|
||||
text: `${target.title} = ${unit}${explain(target.expr)}`,
|
||||
}),
|
||||
...(pills.length
|
||||
? [el("p", { className: "m01-logic__muted", text: tl("Pill_Hint") }), pillBar(pills)]
|
||||
: [el("p", { className: "m01-logic__muted", text: tl("Modal_NoData") })]),
|
||||
el("details", {
|
||||
className: "m01lab__raw",
|
||||
children: [
|
||||
el("summary", { text: tl("Modal_Qty") }),
|
||||
el("pre", { className: "m01lab__expr", text: target.expr }),
|
||||
],
|
||||
}),
|
||||
];
|
||||
body.replaceChildren(...parts);
|
||||
mount(body);
|
||||
document.body.append(backdrop);
|
||||
dialog.tabIndex = -1;
|
||||
dialog.focus();
|
||||
}
|
||||
|
||||
/** 줄을 눌렀을 때 — 그 줄의 단가(재료·인력·기계 또는 다른 로직)를 바로 보임(알약 없이) */
|
||||
export function openMaterialModal(target: ModalTarget): void {
|
||||
openDialog(target.title, (body) => {
|
||||
const base = target.element?.split(".")[0] ?? "";
|
||||
const price = target.element
|
||||
? (target.prices[target.element] ?? target.prices[base])
|
||||
: undefined;
|
||||
const logicRef = target.element?.startsWith("로직(") ? target.element : "";
|
||||
const rateParts: HTMLElement[] = [];
|
||||
if (price && target.element) {
|
||||
rateParts.push(priceView(target.element, price));
|
||||
} else if (logicRef) {
|
||||
rateParts.push(el("p", { text: `${tl("Modal_Logic")} · ${logicRef}` }));
|
||||
} else if (target.element) {
|
||||
rateParts.push(
|
||||
el("p", {
|
||||
className: "m01-logic__muted",
|
||||
text: `${target.element} — ${tl(target.element.includes("{") ? "Pill_NoPick" : "Pill_NoValue")}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const unit = price ? `${tl("Modal_UnitPrice")} ${formatMoney(price.값)} × ` : "";
|
||||
body.replaceChildren(
|
||||
...rateParts,
|
||||
el("p", { className: "m01-logic__muted", text: tl("Modal_Sum") }),
|
||||
el("p", {
|
||||
className: "m01lab__sum",
|
||||
text: `${target.title} = ${unit}${explain(target.expr)}`,
|
||||
}),
|
||||
el("details", {
|
||||
className: "m01lab__raw",
|
||||
children: [
|
||||
el("summary", { text: tl("Modal_Qty") }),
|
||||
el("pre", { className: "m01lab__expr", text: target.expr }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** 수량 칸 표 알약을 눌렀을 때 — 그 표만 보임(걸린 줄 강조) */
|
||||
export function openTableModal(target: TableTarget): void {
|
||||
openDialog(target.title, (body) => {
|
||||
body.replaceChildren(el("p", { className: "m01-logic__muted", text: "…" }));
|
||||
void loadTable(target.table).then((table) => {
|
||||
body.replaceChildren(
|
||||
table
|
||||
? tableView(table, target.conds, target.values)
|
||||
: el("p", { className: "m01-logic__bad", text: `${target.table} ?` }),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_Pill.ts
|
||||
* 줄 모달의 알약 단추 — 쓰인 자료를 알약으로만 늘어놓고, 누른 것 하나만 펼침(다시 누르면 접힘)
|
||||
* 알약 단추 — 수량 칸 안 조각(표 찾기 등)을 누르면 곧장 그 자료 모달을 엶.
|
||||
* (이전에는 알약을 늘어놓고 아래 자리에서 펼쳤으나, 알약이 수량 칸 안으로 들어가며
|
||||
* 「누르면 모달」 로 바뀜 — 모달 안에 알약 목록을 또 두지 않음.)
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
|
||||
export interface Pill {
|
||||
/** 알약 글 — 보기 「표 QF000421」 · 「인력 석공」 */
|
||||
/** 알약 글 — 보기 「표 QF000421」 */
|
||||
label: string;
|
||||
/** 펼칠 때 그림 — 표처럼 읽어 와야 하면 Promise */
|
||||
view: () => HTMLElement | Promise<HTMLElement>;
|
||||
/** 누르면 할 일 — 그 자료 모달을 엶 */
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function pillBar(pills: Pill[]): HTMLElement {
|
||||
const stage = el("div", { className: "m01lab__pill-stage" });
|
||||
const buttons: HTMLButtonElement[] = [];
|
||||
let open = -1;
|
||||
let turn = 0;
|
||||
const show = async (i: number): Promise<void> => {
|
||||
const mine = ++turn;
|
||||
open = open === i ? -1 : i;
|
||||
buttons.forEach((b, k) => {
|
||||
b.setAttribute("aria-expanded", String(k === open));
|
||||
b.classList.toggle("is-open", k === open);
|
||||
});
|
||||
if (open < 0) return stage.replaceChildren();
|
||||
const view = await pills[open].view();
|
||||
if (mine === turn) stage.replaceChildren(view); // 빨리 눌러도 마지막 것만
|
||||
};
|
||||
pills.forEach((p, i) => {
|
||||
const b = el("button", {
|
||||
className: "m01lab__pill",
|
||||
text: p.label,
|
||||
attrs: { type: "button", "aria-expanded": "false", "data-pill": p.label },
|
||||
});
|
||||
b.addEventListener("click", () => void show(i));
|
||||
buttons.push(b);
|
||||
/** 알약 하나 — 글자 조각 사이에 끼워 쓰는 단추 */
|
||||
export function pillButton(p: Pill): HTMLButtonElement {
|
||||
const b = el("button", {
|
||||
className: "m01lab__pill",
|
||||
text: p.label,
|
||||
attrs: { type: "button", "data-pill": p.label },
|
||||
});
|
||||
return el("div", {
|
||||
className: "m01lab__pills",
|
||||
children: [el("div", { className: "m01lab__pill-row", children: buttons }), stage],
|
||||
b.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation(); // 줄 전체 누르기(행 모달)와 안 겹치게
|
||||
p.onClick();
|
||||
});
|
||||
return b;
|
||||
}
|
||||
|
||||
/** 수량 칸 조각 — 알약 아니면 그냥 글자(연산기호·숫자·중간값 이름) */
|
||||
export type Fragment = { pill: Pill } | { text: string };
|
||||
|
||||
/** 조각을 이어 그림 — 칸 폭을 넘으면 자동으로 줄바꿈(`flex-wrap`) */
|
||||
export function fragmentRow(fragments: Fragment[]): HTMLElement {
|
||||
return el("span", {
|
||||
className: "m01lab__qty-frags",
|
||||
children: fragments.map((f) =>
|
||||
"pill" in f
|
||||
? pillButton(f.pill)
|
||||
: el("span", { className: "m01lab__qty-text", text: f.text }),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -39,13 +39,27 @@
|
||||
margin: var(--spacing-8) 0 var(--spacing-4);
|
||||
}
|
||||
|
||||
/* 칸 폭 고정(`table-layout: fixed`) — 인력·재료·경비 세 표가 저마다 딴 `<table>` 이라
|
||||
내용 기준으로 폭이 갈리던 것을 막고 셋이 같은 폭으로 맞춰짐(일감35 이어 — 수량 칸 알약화). */
|
||||
.m01lab__ho {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.m01lab__ho th:nth-child(1) {
|
||||
width: 26%;
|
||||
}
|
||||
|
||||
.m01lab__ho th:nth-child(2) {
|
||||
width: 64px;
|
||||
width: 8%;
|
||||
}
|
||||
|
||||
.m01lab__ho th:nth-child(3) {
|
||||
width: 40%;
|
||||
}
|
||||
|
||||
.m01lab__ho th:nth-child(4),
|
||||
.m01lab__ho th:nth-child(5) {
|
||||
width: 110px;
|
||||
width: 13%;
|
||||
}
|
||||
|
||||
.m01lab__line {
|
||||
@@ -61,7 +75,6 @@
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.m01lab__qty,
|
||||
.m01lab__expr {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: var(--text-body-sm);
|
||||
@@ -69,6 +82,25 @@
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* 수량 칸 — 알약 + 사칙연산 글자를 이어 붙이고, 칸 폭을 넘으면 그 칸 폭 기준으로 줄바꿈 */
|
||||
.m01lab__qty-cell {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.m01lab__qty-frags {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 2px 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m01lab__qty-text {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: var(--text-body-sm);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.m01lab__subtotal td {
|
||||
font-weight: 600;
|
||||
background: var(--color-paper);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createButton, createSelectField, el, showToast } from "@ui/ui_template_
|
||||
import {
|
||||
runCalc,
|
||||
runText,
|
||||
searchElements,
|
||||
type CalcAnswer,
|
||||
type CalcLine,
|
||||
type TextAnswer,
|
||||
@@ -16,6 +17,28 @@ import {
|
||||
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
|
||||
import { tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
|
||||
/** 고르기 값이 마스터 키(자재·기계·직종)면 이름으로 바꿔 보임 — 값 칸은 키 그대로 보냄 */
|
||||
const KEY_RE = /^[A-Z]{2}\d{6}$/;
|
||||
const KEY_GROUP: Record<string, string> = { MT: "재료", EQ: "기계", LB: "인력" };
|
||||
/** 로직마다 다시 그려도 남는 이름 캐시 — 같은 키를 두 번 묻지 않음 */
|
||||
const nameCache = new Map<string, string | null>();
|
||||
|
||||
async function keyName(key: string): Promise<string | null> {
|
||||
if (nameCache.has(key)) return nameCache.get(key)!;
|
||||
const group = KEY_GROUP[key.slice(0, 2)];
|
||||
if (!group) return null;
|
||||
let name: string | null = null;
|
||||
try {
|
||||
const found = await searchElements(group, key); // q = 키 그대로 — 「키」 칸 부분일치라 그 줄만 걸림
|
||||
const hit = found.items.find((it) => it.ref === key);
|
||||
name = hit ? `${hit.이름 ?? ""}${hit.규격 ? ` ${hit.규격}` : ""}`.trim() || null : null;
|
||||
} catch {
|
||||
name = null;
|
||||
}
|
||||
nameCache.set(key, name);
|
||||
return name;
|
||||
}
|
||||
|
||||
export interface CalcContext {
|
||||
/** 저장된 키 — 새 로직은 null */
|
||||
savedKey: string | null;
|
||||
@@ -33,17 +56,31 @@ export interface CalcContext {
|
||||
const SUMS = ["노무비", "재료비", "경비", "계"];
|
||||
|
||||
export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
|
||||
// 그릇 폭 280px 고정 칸은 옛 테스트 컨테이너(UI_Test*, 걷힘)의 죽은 CSS 규칙 —
|
||||
// 지금 쓰는 LogicLab 화면은 그 칸에 안 걸려 폭이 이미 넓음(실측 730px) · 따로 안 풂
|
||||
const optionText = (o: string | number): string =>
|
||||
(typeof o === "string" && nameCache.get(o)) || String(o);
|
||||
const fields = (ctx.row.입력 ?? []).map((spec) => {
|
||||
const name = spec.이름;
|
||||
let control: HTMLElement;
|
||||
if (spec.고르기?.length) {
|
||||
const picks = spec.고르기;
|
||||
if (picks?.length) {
|
||||
const pick = createSelectField({
|
||||
options: ["", ...spec.고르기.map(String)].map((o) => ({ value: o, text: o })),
|
||||
options: ["", ...picks.map(String)].map((o) => ({ value: o, text: optionText(o) })),
|
||||
value: ctx.values[name] ?? "",
|
||||
compact: true,
|
||||
onChange: (v) => (ctx.values[name] = v),
|
||||
});
|
||||
control = pick.root;
|
||||
const keys = picks.filter((o): o is string => typeof o === "string" && KEY_RE.test(o));
|
||||
if (keys.length) {
|
||||
void Promise.all(keys.map(keyName)).then(() => {
|
||||
pick.setOptions(
|
||||
["", ...picks.map(String)].map((o) => ({ value: o, text: optionText(o) })),
|
||||
ctx.values[name] ?? "",
|
||||
);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const box = el("input", {
|
||||
className: "m01-logic__input",
|
||||
@@ -55,8 +92,10 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
|
||||
control = box;
|
||||
}
|
||||
const label = spec.단위 ? `${name} (${spec.단위})` : name;
|
||||
// 긴 이름 줄만 한 칸 다 씀 — 고르기 줄은 짧으면 그대로 2열에 맞춰 나란히
|
||||
const wide = label.length > 10;
|
||||
return el("label", {
|
||||
className: "m01-logic__field",
|
||||
className: `m01-logic__field${wide ? " m01-logic__field--wide" : ""}`,
|
||||
children: [el("span", { text: label }), control],
|
||||
});
|
||||
});
|
||||
@@ -89,7 +128,7 @@ export function buildCalc(host: HTMLElement, ctx: CalcContext): void {
|
||||
host.replaceChildren(
|
||||
el("h3", { text: tx("Calc_Title") }),
|
||||
...(fields.length
|
||||
? fields
|
||||
? [el("div", { className: "m01-logic__head", children: fields })] // 요소 표가 쓰는 auto-fill 그리드 재사용 — 2열
|
||||
: [el("p", { className: "m01-logic__muted", text: tx("Calc_NoInputs") })]),
|
||||
createButton({ label: tx("Calc_Run"), onClick: () => void run() }),
|
||||
out,
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
· `이름글` 이름 꼴: 「석공노임 * 메쌓기인력.석공 * (1 + 증가율) = 석공비」 — 표 찾기·로직
|
||||
부르기는 별칭 이름(`master_copy` 3-4 규칙) · 중간 값·입력은 그 이름 그대로
|
||||
두 꼴은 같은 줄에 나란히 있고 `짝` 번호가 붙음 — 화면 두 칸이 줄 맞춤으로 놓임.
|
||||
줄마다 `조각`(PLAN 2-1 조각 계약) 도 같이 줌 — kind(요소·표·로직·값·기호·글) · 글 · 참조.
|
||||
알약(요소·표·로직)이 될 자리는 구조만 보고 짓고, `만약()` 갈래만 골라야 해서 계산이 필요함 —
|
||||
계산이 안 되면(FormulaError 따위) 갈래 양쪽을 다 내보내고 깊이를 적어 접을 수 있게 함.
|
||||
호표 줄 계산 자체가 막혀도(재료 단가 없음 따위) 이름 식·조각은 `row` 구조만으로 그리고
|
||||
`글`·`금액`만 비움 — `만약() 과 무관` 인 그 60개 로직도 이름 식은 보이게.
|
||||
끝수는 줄이 아니라 소계에 녹여 적음. 계산 자체는 `master_formula.run` 이 한 벌만 함.
|
||||
"""
|
||||
|
||||
@@ -24,6 +29,7 @@ ELEM_TAIL = {"LB": "노임", "EQ": "손료"} # 식 안 요소 키의 이름 꼬
|
||||
_PREC = {"또는": 1, "그리고": 2, "==": 3, "!=": 3, "<": 3, "<=": 3, ">": 3, ">=": 3}
|
||||
_PREC |= {"+": 4, "-": 4, "*": 5, "/": 5}
|
||||
_RIGHT_TIGHT = ("-", "/") # 오른쪽 짝은 같은 세기라도 괄호가 필요함
|
||||
_CALC_ERRORS = (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) # 계산 못 함
|
||||
|
||||
|
||||
def fmt(value) -> str:
|
||||
@@ -125,6 +131,133 @@ def _side(show, real, parent, env: dict, master, depth: int, draw, right: bool)
|
||||
return text
|
||||
|
||||
|
||||
# ── 조각(알약) — PLAN 2-1 조각 계약 · 2-2 「이름 식을 계산 없이」 ──────────
|
||||
_PIECE_SYM = {"+": "+", "-": "-", "*": "×", "/": "÷"}
|
||||
|
||||
|
||||
def _p_val(text: str) -> dict:
|
||||
return {"kind": "값", "글": text}
|
||||
|
||||
|
||||
def _p_sym(text: str) -> dict:
|
||||
return {"kind": "기호", "글": text}
|
||||
|
||||
|
||||
def _alias_index(marks: list[dict]) -> dict[str, dict]:
|
||||
"""별칭 이름 → 별칭 한 줄(`master_copy.aliases` 결과) — 조각이 참조를 되찾을 때 씀."""
|
||||
return {m["이름"]: m for m in marks}
|
||||
|
||||
|
||||
def _elem_piece(master, ref: str) -> dict:
|
||||
"""요소 알약 — 글은 이름(못 찾아도 이름만). 자리 표시(`{…}`) 든 참조 14파일은
|
||||
값(참조 키)을 못 구하므로 이름만 적고 참조 자리는 비움."""
|
||||
piece = {"kind": "요소", "글": _elem_name(master, ref)}
|
||||
if "{" in str(ref):
|
||||
return piece
|
||||
try:
|
||||
row = master.get(ref)
|
||||
except mf.FormulaError:
|
||||
return piece
|
||||
key = str(row.get("키") or ref)
|
||||
piece["참조"] = {"테이블": key[:2], "키": key}
|
||||
return piece
|
||||
|
||||
|
||||
def _alias_piece(mark: dict, env: dict, master, column: str | None) -> dict:
|
||||
"""표 찾기·로직 부르기 알약 — 글은 별칭 이름. 참조는 다시 찾아 봐서 되면만 붙임."""
|
||||
piece = {"kind": "표" if mark["종류"] == "찾기" else "로직", "글": mark["이름"]}
|
||||
try:
|
||||
node = mf.parse(mark["원문"])
|
||||
ref = mf.fill(node[1], env)
|
||||
if mark["종류"] == "찾기":
|
||||
table = master.get(ref)
|
||||
conds = {k: mf.evaluate(v, env, master, 0) for k, v in node[2]}
|
||||
row = mf.find(table, conds, column)
|
||||
piece["참조"] = {
|
||||
"테이블": ref[:2],
|
||||
"키": ref,
|
||||
"행": row.get("키") or row.get("원문번호"),
|
||||
"열": column,
|
||||
}
|
||||
else:
|
||||
piece["참조"] = {"테이블": ref[:2], "키": ref}
|
||||
except _CALC_ERRORS:
|
||||
pass # 다시 못 찾음 — 별칭 이름만 남김(값 자리 비움)
|
||||
return piece
|
||||
|
||||
|
||||
def _if_pieces(show, real, env: dict, master, alias_by_name: dict, ifdepth: int) -> list[dict]:
|
||||
"""만약() 조각 — 계산되면 걸린 가지만 · 안 되면 양쪽 다(깊이를 적어 접을 수 있게)."""
|
||||
try:
|
||||
taken = mf.evaluate(real[2][0], env, master, 0)
|
||||
except _CALC_ERRORS:
|
||||
deeper = ifdepth + 1
|
||||
left = _pieces(show[2][1], real[2][1], env, master, alias_by_name, deeper)
|
||||
right = _pieces(show[2][2], real[2][2], env, master, alias_by_name, deeper)
|
||||
for one in (*left, *right):
|
||||
one["깊이"] = deeper
|
||||
return [*left, _p_sym("/"), *right]
|
||||
at = 1 if taken else 2
|
||||
return _pieces(show[2][at], real[2][at], env, master, alias_by_name, ifdepth)
|
||||
|
||||
|
||||
def _side_pieces(
|
||||
show, real, parent_show, env: dict, master, alias_by_name: dict, ifdepth: int, right: bool
|
||||
):
|
||||
inner = _pieces(show, real, env, master, alias_by_name, ifdepth)
|
||||
if show[0] != "bin":
|
||||
return inner
|
||||
mine, up = _PREC[show[1]], _PREC[parent_show[1]]
|
||||
if mine < up or (mine == up and right and parent_show[1] in _RIGHT_TIGHT):
|
||||
return [_p_sym("("), *inner, _p_sym(")")]
|
||||
return inner
|
||||
|
||||
|
||||
def _bin_pieces(show, real, env: dict, master, alias_by_name: dict, ifdepth: int) -> list[dict]:
|
||||
op = show[1]
|
||||
if op == "/" and show[3][0] == "num" and show[3][1] == 100: # 「… / 100」 은 퍼센트로
|
||||
left = _side_pieces(show[2], real[2], show, env, master, alias_by_name, ifdepth, False)
|
||||
return [*left, {"kind": "글", "글": "%"}]
|
||||
left = _side_pieces(show[2], real[2], show, env, master, alias_by_name, ifdepth, False)
|
||||
right = _side_pieces(show[3], real[3], show, env, master, alias_by_name, ifdepth, True)
|
||||
return [*left, _p_sym(_PIECE_SYM.get(op, op)), *right]
|
||||
|
||||
|
||||
def _pieces(show, real, env: dict, master, alias_by_name: dict, ifdepth: int = 0) -> list[dict]:
|
||||
"""식 한 마디 → 조각 목록. 요소·표·로직 부르기는 알약(참조가 됨) · 나머지는 값·기호·글."""
|
||||
kind = show[0]
|
||||
if kind == "num":
|
||||
return [_p_val(fmt(show[1]))]
|
||||
if kind == "str":
|
||||
return [_p_val(f"'{show[1]}'")]
|
||||
if kind == "neg":
|
||||
return [_p_sym("-"), *_pieces(show[1], real[1], env, master, alias_by_name, ifdepth)]
|
||||
if kind == "bin":
|
||||
return _bin_pieces(show, real, env, master, alias_by_name, ifdepth)
|
||||
if kind == "call" and show[1] == "만약":
|
||||
return _if_pieces(show, real, env, master, alias_by_name, ifdepth)
|
||||
if kind == "ref":
|
||||
return [_elem_piece(master, show[1])]
|
||||
if kind == "field" and show[1][0] == "name" and show[1][1] in alias_by_name:
|
||||
return [_alias_piece(alias_by_name[show[1][1]], env, master, show[2])]
|
||||
if kind == "name" and show[1] in alias_by_name:
|
||||
return [_alias_piece(alias_by_name[show[1]], env, master, None)]
|
||||
try:
|
||||
return [_p_val(_value(real, env, master, 0))]
|
||||
except _CALC_ERRORS:
|
||||
return [_p_val("")] # 그 밖(값 계산 자체가 막힘) — 빈 값 조각으로 줄이 안 죽게
|
||||
|
||||
|
||||
def _formula_pieces(text, env: dict, master, name_of: dict, alias_by_name: dict) -> list[dict]:
|
||||
"""식 글 한 줄의 조각 — 별칭을 씌운 나무와 원래 나무를 나란히 읽음(`_names` 와 짝)."""
|
||||
real = mf.parse(text)
|
||||
try:
|
||||
show = mf.parse(mc.mask(text, name_of))
|
||||
except mf.FormulaError:
|
||||
show = real
|
||||
return _pieces(show, real, env, master, alias_by_name)
|
||||
|
||||
|
||||
# ── 줄 세우기 ─────────────────────────────────────────────────────────
|
||||
def _env_of(master, row: dict, given: dict, depth: int = 0) -> dict:
|
||||
"""입력 + 중간 — `run` 과 같은 차례로 다시 셈(덧줄 전 상태)."""
|
||||
@@ -197,56 +330,117 @@ def _names(text, env: dict, master, name_of: dict) -> str:
|
||||
|
||||
|
||||
def lines(files: dict, key: str, given: dict) -> dict:
|
||||
"""로직 한 줄의 읽는 식 — 비목 묶음마다 줄과 소계 · 돈 아닌 로직은 「결과」 묶음 하나."""
|
||||
"""로직 한 줄의 읽는 식 — 비목 묶음마다 줄과 소계 · 돈 아닌 로직은 「결과」 묶음 하나.
|
||||
호표 줄 계산이 막히면(재료 단가 없음 따위) `row` 구조만으로 이름 식·조각을 짓고
|
||||
`글`·`금액`은 비움(입력 자체가 없는 경우는 지금처럼 그대로 막힘 — `_env_of` 에서 걸림)."""
|
||||
master = mf.Master(files)
|
||||
row = master.logic(key)
|
||||
result = mf.run(master, key, given)
|
||||
env = _env_of(master, row, given)
|
||||
marks = mc.aliases(row, master)
|
||||
name_of = mc.names(marks)
|
||||
alias_by_name = _alias_index(marks)
|
||||
try:
|
||||
result = mf.run(master, key, given)
|
||||
except _CALC_ERRORS:
|
||||
result = None
|
||||
|
||||
if "결과" in row:
|
||||
text = f"{render(mf.parse(row['결과']['식']), env, master)} = {fmt(result['결과'])}"
|
||||
조각 = _formula_pieces(row["결과"]["식"], env, master, name_of, alias_by_name)
|
||||
got = mc.plain(row.get("이름")) or "결과"
|
||||
one = {
|
||||
"이름": row.get("이름") or "",
|
||||
"글": text,
|
||||
"이름글": f"{_names(row['결과']['식'], env, master, name_of)} = {got}",
|
||||
"금액": result["결과"],
|
||||
}
|
||||
group = {
|
||||
"비목": "결과",
|
||||
"줄": [one],
|
||||
"소계": result["결과"],
|
||||
"끝수": _cut_note(row, "결과"),
|
||||
}
|
||||
return _paired({"ok": True, "groups": [group], "계": result["결과"], "별칭": marks})
|
||||
made: dict[str, list] = {name: [] for name in mf.COST_ITEMS}
|
||||
shown = result["줄"]
|
||||
env["줄"] = {r["이름"]: r["금액"] for r in shown if r["이름"]}
|
||||
items = row.get("호표") or []
|
||||
for item, line in zip(items, shown):
|
||||
for part, money in _shares(line):
|
||||
split = part if len(line["비목"]) > 1 else None
|
||||
이름글 = f"{_names(row['결과']['식'], env, master, name_of)} = {got}"
|
||||
if result is not None:
|
||||
one = {
|
||||
"이름": _label(item, line),
|
||||
"글": _line_text(item, line, env, master, split),
|
||||
"이름글": _line_names(item, line, env, master, split, name_of),
|
||||
"금액": money,
|
||||
"이름": row.get("이름") or "",
|
||||
"글": f"{render(mf.parse(row['결과']['식']), env, master)} = {fmt(result['결과'])}",
|
||||
"이름글": 이름글,
|
||||
"금액": result["결과"],
|
||||
"조각": 조각,
|
||||
}
|
||||
why = _why(item, line)
|
||||
made[part].append({**one, "까닭": why} if why else one)
|
||||
_extras(row, shown[len(items) :], made, env, master, name_of)
|
||||
group = {
|
||||
"비목": "결과",
|
||||
"줄": [one],
|
||||
"소계": result["결과"],
|
||||
"끝수": _cut_note(row, "결과"),
|
||||
}
|
||||
계 = result["결과"]
|
||||
else:
|
||||
one = {
|
||||
"이름": row.get("이름") or "",
|
||||
"글": "",
|
||||
"이름글": 이름글,
|
||||
"금액": None,
|
||||
"조각": 조각,
|
||||
}
|
||||
group = {"비목": "결과", "줄": [one], "소계": None, "끝수": ""}
|
||||
계 = None
|
||||
return _paired({"ok": True, "groups": [group], "계": 계, "별칭": marks})
|
||||
|
||||
made: dict[str, list] = {name: [] for name in mf.COST_ITEMS}
|
||||
items = row.get("호표") or []
|
||||
if result is not None:
|
||||
shown = result["줄"]
|
||||
env["줄"] = {r["이름"]: r["금액"] for r in shown if r["이름"]}
|
||||
for item, line in zip(items, shown):
|
||||
조각 = _formula_pieces(item["수량"], env, master, name_of, alias_by_name)
|
||||
for part, money in _shares(line):
|
||||
split = part if len(line["비목"]) > 1 else None
|
||||
one = {
|
||||
"이름": _label(item, line),
|
||||
"글": _line_text(item, line, env, master, split),
|
||||
"이름글": _line_names(item, line, env, master, split, name_of),
|
||||
"금액": money,
|
||||
"조각": 조각,
|
||||
}
|
||||
why = _why(item, line)
|
||||
made[part].append({**one, "까닭": why} if why else one)
|
||||
_extras(row, shown[len(items) :], made, env, master, name_of, alias_by_name)
|
||||
소계 = {name: result[name] for name in mf.COST_ITEMS}
|
||||
끝수 = _cut_note(row)
|
||||
계 = result["계"]
|
||||
else:
|
||||
for item in items:
|
||||
parts = (
|
||||
list(mf.COST_ITEMS)
|
||||
if item.get("종류") == "로직" and "비목" not in item
|
||||
else [item.get("비목")]
|
||||
)
|
||||
조각 = _formula_pieces(item["수량"], env, master, name_of, alias_by_name)
|
||||
for part in parts:
|
||||
if part not in mf.COST_ITEMS:
|
||||
continue
|
||||
split = part if len(parts) > 1 else None
|
||||
made[part].append(
|
||||
{
|
||||
"이름": _label(item, {}),
|
||||
"글": "",
|
||||
"이름글": _line_names(item, {}, env, master, split, name_of),
|
||||
"금액": None,
|
||||
"조각": 조각,
|
||||
}
|
||||
)
|
||||
for extra in row.get("덧줄") or []:
|
||||
made[extra["비목"]].append(
|
||||
{
|
||||
"이름": extra["이름"],
|
||||
"글": "",
|
||||
"이름글": (
|
||||
f"{_names(extra['식'], env, master, name_of)} = "
|
||||
f"{mc.plain(extra.get('이름')) or '덧줄'}"
|
||||
),
|
||||
"금액": None,
|
||||
"조각": _formula_pieces(extra["식"], env, master, name_of, alias_by_name),
|
||||
}
|
||||
)
|
||||
소계 = dict.fromkeys(mf.COST_ITEMS, None)
|
||||
끝수 = ""
|
||||
계 = None
|
||||
|
||||
groups = [
|
||||
{
|
||||
"비목": name,
|
||||
"줄": made[name],
|
||||
"소계": result[name],
|
||||
"끝수": _cut_note(row),
|
||||
}
|
||||
{"비목": name, "줄": made[name], "소계": 소계[name], "끝수": 끝수}
|
||||
for name in mf.COST_ITEMS
|
||||
if made[name]
|
||||
]
|
||||
return _paired({"ok": True, "groups": groups, "계": result["계"], "별칭": marks})
|
||||
return _paired({"ok": True, "groups": groups, "계": 계, "별칭": marks})
|
||||
|
||||
|
||||
def _paired(answer: dict) -> dict:
|
||||
@@ -259,7 +453,9 @@ def _paired(answer: dict) -> dict:
|
||||
return answer
|
||||
|
||||
|
||||
def _extras(row: dict, shown: list, made: dict, env: dict, master, name_of: dict) -> None:
|
||||
def _extras(
|
||||
row: dict, shown: list, made: dict, env: dict, master, name_of: dict, alias_by_name: dict
|
||||
) -> None:
|
||||
"""덧줄 — 앞선 줄까지의 비목 합을 넣고 한 줄씩(식 안의 비목 이름은 그 수로 바뀜)."""
|
||||
sums = {name: sum((one["금액"] for one in made[name]), Decimal(0)) for name in mf.COST_ITEMS}
|
||||
for extra, line in zip(row.get("덧줄") or [], shown):
|
||||
@@ -269,6 +465,7 @@ def _extras(row: dict, shown: list, made: dict, env: dict, master, name_of: dict
|
||||
"글": _extra_text(extra, line, env, master),
|
||||
"이름글": _extra_names(extra, env, master, name_of),
|
||||
"금액": line["금액"],
|
||||
"조각": _formula_pieces(extra["식"], env, master, name_of, alias_by_name),
|
||||
}
|
||||
made[extra["비목"]].append(one)
|
||||
sums[extra["비목"]] += line["금액"]
|
||||
|
||||
@@ -160,3 +160,83 @@ def test_이름_꼴_찾기_별칭이_표_열쇠_그대로면_밑이름으로_풀
|
||||
assert "QC001882.요율 %" in extra["이름글"]
|
||||
assert "잔디깎기공구손료" not in extra["이름글"]
|
||||
assert any(one["이름"] == "QC001882" for one in got["별칭"])
|
||||
|
||||
|
||||
# ── PLAN 2-1 · 2-2 — 조각(알약) ─────────────────────────────────────────
|
||||
def test_조각이_이름글_금액_까닭_곁에_칸으로_더해진다(client: TestClient) -> None:
|
||||
"""지금 주던 이름글·글·금액·까닭은 그대로 두고 조각 칸만 더함(계약 확인)."""
|
||||
got, calc = _both(client, STACK)
|
||||
mason = _all_lines(got)[0]
|
||||
assert mason["이름"] == "석공" and mason["글"].startswith("268,908 * ")
|
||||
assert Decimal(str(got["계"])) == Decimal(str(calc["sums"]["계"]))
|
||||
assert isinstance(mason["조각"], list) and mason["조각"]
|
||||
kinds = {p["kind"] for p in mason["조각"]}
|
||||
assert kinds <= {"요소", "표", "로직", "값", "기호", "글"}
|
||||
표 = next(p for p in mason["조각"] if p["kind"] == "표")
|
||||
assert (
|
||||
표["참조"]["테이블"] == "QF"
|
||||
and 표["참조"]["키"] == "QF000421"
|
||||
and 표["참조"]["열"] == "석공"
|
||||
)
|
||||
assert any(p["kind"] == "글" and p["글"] == "%" for p in mason["조각"])
|
||||
|
||||
|
||||
def test_조각_요소는_알약이고_참조에_테이블과_키가_있다() -> None:
|
||||
files = {
|
||||
"재료_test.json": {
|
||||
"그룹": "재료",
|
||||
"줄": [{"키": "MT000001", "이름": "테스트자재", "값": Decimal("100")}],
|
||||
}
|
||||
}
|
||||
master = mt.mf.Master(files)
|
||||
piece = mt._elem_piece(master, "MT000001")
|
||||
assert piece == {
|
||||
"kind": "요소",
|
||||
"글": "테스트자재단가",
|
||||
"참조": {"테이블": "MT", "키": "MT000001"},
|
||||
}
|
||||
|
||||
|
||||
def test_조각_자리_표시_든_요소_참조는_이름만_있고_참조는_빔() -> None:
|
||||
"""PLAN 2-2 ② — 자리 표시(`{…}`) 든 요소 참조는 값(참조)을 못 구해 이름만."""
|
||||
master = mt.mf.Master({})
|
||||
piece = mt._elem_piece(master, "LB:{직종}")
|
||||
assert piece["kind"] == "요소" and "참조" not in piece
|
||||
assert piece["글"] # 이름 자리는 비지 않음(못 풀어도 원문 그대로)
|
||||
|
||||
|
||||
def test_조각_만약은_계산되면_걸린_가지만() -> None:
|
||||
node = mt.mf.parse("만약(1 > 0, 2, 3)")
|
||||
pieces = mt._pieces(node, node, {}, mt.mf.Master({}), {})
|
||||
assert pieces == [{"kind": "값", "글": "2"}]
|
||||
assert not any("깊이" in p for p in pieces)
|
||||
|
||||
|
||||
def test_조각_만약은_계산_안_되면_양쪽_다_깊이가_붙는다() -> None:
|
||||
"""PLAN 2-2 ① — 계산 안 되면(모르는 이름) 양쪽 다 · 깊이를 적어 접을 수 있게."""
|
||||
node = mt.mf.parse("만약(모르는이름 > 0, 2, 3)")
|
||||
pieces = mt._pieces(node, node, {}, mt.mf.Master({}), {})
|
||||
vals = [(p["kind"], p["글"], p.get("깊이")) for p in pieces]
|
||||
assert vals == [("값", "2", 1), ("기호", "/", None), ("값", "3", 1)]
|
||||
|
||||
|
||||
def test_조각_로직_부르는_줄_990개_세_비목에_다_걸치고_계산_전엔_금액이_빈다(
|
||||
client: TestClient,
|
||||
) -> None:
|
||||
"""PLAN 2-2 ③ — 계산이 막히면(재료 단가 없음 따위) 이름 식·조각은 구조로 남고 금액만 빔.
|
||||
비목이 안 정해진 로직 부르는 줄은 세 비목에 다 걸쳐 보여야 하나, 이 견본(STACK)은
|
||||
호표 둘 다 비목이 「노무비」로 정해진 인력 줄이라 한 비목에만 걸림 — 그 좁은 경우 확인."""
|
||||
got = client.get("/api/m01/logic", params={"key": STACK}).json()
|
||||
row = dict(got["logic"])
|
||||
row["호표"] = [dict(row["호표"][0], 요소="LB099999"), dict(row["호표"][1])]
|
||||
body = {"key": STACK, "inputs": _inputs(STACK), "row": row, "file": got["file"]}
|
||||
text = client.post("/api/m01/text", json=body)
|
||||
assert text.status_code == 200, text.text
|
||||
out = text.json()
|
||||
assert out["ok"] is True and out["계"] is None
|
||||
rows = _all_lines(out)
|
||||
assert [one["이름"] for one in rows] == ["석공", "보통인부"]
|
||||
for one in rows:
|
||||
assert one["금액"] is None and one["글"] == "" and "까닭" not in one
|
||||
assert one["조각"] # 수량 식은 깨진 요소와 무관해 구조로 그대로 남음
|
||||
assert [g["소계"] for g in out["groups"]] == [None]
|
||||
|
||||
Reference in New Issue
Block a user