Merge branch 'sub_laptop_3' of C:/Aislo_wt/sub_laptop_3 into main_laptop_1

This commit is contained in:
2026-11-08 01:27:57 +09:00
12 changed files with 690 additions and 160 deletions
+22 -1
View File
@@ -158,7 +158,7 @@
flex-wrap: wrap;
align-items: center;
gap: 0 var(--spacing-4);
padding-inline-start: calc(var(--lstk-depth, 0) * var(--spacing-24));
padding-inline-start: calc(var(--lstk-depth, 0) * var(--spacing-16));
white-space: normal;
}
@@ -195,3 +195,24 @@
.lstk-tex .katex {
font-size: 1.05em;
}
/* 짝 강조(679) — 왼쪽 카드 · 오른쪽 줄 같은 자리 */
.lstk-line.is-pair,
.lstk-item.is-pair {
outline: 2px solid var(--color-accent);
outline-offset: -2px;
}
.lstk-out tr.is-pair > td {
background: var(--color-accent-soft);
}
.lstk-out tr[data-loc] {
cursor: pointer;
}
.lstk-tpick {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
+45 -132
View File
@@ -4,7 +4,8 @@
* 줄 = 인력 · 재료 · 기계 · 다른 단가산출 · 율 · 식(금액 줄 — 이름 · 단위 · 비목 · 수량 식 · 단가 고르기 또는 식)
* · 공식 묶음(`line_stack_rows.ts`) · 글 줄
* 고침 = 부른 쪽 `change(fn)` — fn 이 문서 · 차림을 고치면 부른 쪽이 `docOf` 로 굳히고 미리 셈 · 다시 그림(입력은 change 때)
* 자원 찾기 = 수식 탭과 같은 길(`/links` 요소 인력 · 재료 · 단가산출) · 기계 = 시간당 기계경비 단가산출 + 기종(그 문서 기계 목록)
* 자원 고르기(804-3) = 찾아 넣기 창(`line_stack_pick.ts` — M02 [항목 추가] 창 그대로) · [+ 줄] 인력 · 재료 · 기계 · 다른 단가산출 =
* 고르면 줄이 생김(닫으면 안 생김) · 있는 줄 = [찾아 바꾸기]
* ========================================================================== */
import { createButton } from "@ui/ui_template_button";
@@ -13,6 +14,7 @@ import { el } from "@ui/ui_template_elements";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { varId, type FormulaDoc, type MoneyLine } from "../common_util/common_util_formula_doc";
import type { DocAnswer } from "../common_util/common_util_formula_doc_calc";
import type { DocTable } from "../common_util/common_util_formula_doc_table";
import {
dropSyms,
freshId,
@@ -23,7 +25,8 @@ import {
type StackDoc,
type StackItem,
} from "../common_util/common_util_line_stack";
import type { Pick } from "./formula_doc_defs";
import { addPicked, repick } from "../common_util/common_util_line_stack_pick";
import { openStackPick } from "./line_stack_pick";
import { buildBlock, insertTpl, type Tpl } from "./line_stack_rows";
import { lt, type LsKey } from "./line_stack_text";
@@ -32,12 +35,15 @@ export interface EditHooks {
answer: DocAnswer;
items: StackItem[];
change: (fn: (d: StackDoc, items: StackItem[]) => void) => void;
ask: (kind: string, words: string, group?: string) => Promise<{ 후보: Pick[] }>;
/** 품셈 표 하나(표 고르기 창) */
table: (key: string) => Promise<DocTable | null>;
subDoc: (key: string) => FormulaDoc | null;
name: (key: string) => string;
/** 공식 묶음 펼친 자리(`항목.줄`) — 화면 상태만 */
open: Set<string>;
templates: () => Tpl[];
/** 줄 · 항목 누름 — 오른쪽 짝 강조(679) */
pair: (loc: string) => void;
saveTpl: (tpl: Tpl) => void;
}
@@ -60,47 +66,6 @@ const field = (
const btn = (label: string, onClick: () => void, variant: "ghost" | "danger-ghost" = "ghost") =>
createButton({ label, variant, size: 24, onClick });
/** 자원 찾기 — 찾는 말 → 후보 고르기 */
function finder(
h: EditHooks,
kind: string,
group: string | undefined,
seed: string,
onPick: (p: Pick) => void,
): HTMLElement {
const words = createInputField({
placeholder: lt("Find"),
value: seed,
compact: true,
size: 24,
type: "search",
});
const pick = createSelectField({
options: [{ value: "", text: lt("Pick") }],
compact: true,
size: 24,
ariaLabel: lt("Pick"),
});
let found: Pick[] = [];
const run = (): void =>
void h.ask(kind, words.input.value, group).then((got) => {
found = got.후보 ?? [];
pick.setOptions([
{ value: "", text: found.length ? lt("Pick") : lt("None_Found") },
...found.map((p, k) => ({
value: String(k),
text: [p.이름, p.규격].filter(Boolean).join(" "),
})),
]);
});
words.input.addEventListener("change", run);
pick.select.addEventListener("change", () => {
const p = found[Number(pick.select.value)];
if (pick.select.value !== "" && p) onPick(p);
});
return el("div", { className: "lstk-find", children: [words.root, pick.root] });
}
const KIND_KEY: Record<MoneyKind, LsKey> = {
인력: "K_인력",
재료: "K_재료",
@@ -118,19 +83,6 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
const v = own ? d.변수?.find((x) => varId(x) === own) : undefined;
const put = (fn: (l: MoneyLine & { 규격?: string }) => void): void =>
h.change((doc) => fn(doc.금액![i]));
const setVar = (
fn: (link: NonNullable<typeof v>["연결"] & object, l: MoneyLine & { 규격?: string }) => void,
): void =>
h.change((doc) => {
const x = doc.변수!.find((y) => varId(y) === own)!;
x.연결 ??= {};
fn(x.연결, doc.금액![i]);
});
const named = (p: Pick, l: MoneyLine & { 규격?: string }): void => {
l.이름 = p.이름 ?? l.이름;
if (p.규격) l.규격 = p.규격;
else delete l.규격;
};
const head = el("div", {
className: "lstk-row",
children: [
@@ -159,54 +111,23 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
const price: HTMLElement[] = [];
if (own && v) {
const link = v.연결 ?? {};
const mat = link.재료 as Record<string, unknown> | undefined;
const spec = [line.이름, (line as { 규격?: string }).규격].filter(Boolean).join(" ");
const label = link.키
? link.갈래 === "단가산출"
? (h.subDoc(link.키)?.이름 ?? h.name(link.키))
: h.name(link.키)
: "";
price.push(el("span", { className: "lstk-link", text: label || "—" }));
if (link.갈래 === "요소")
price.push(
finder(h, "요소", kind === "인력" ? "인력" : "재료", "", (p) =>
setVar((k, l) => {
for (const x of Object.keys(k)) delete (k as Record<string, unknown>)[x];
Object.assign(k, { 갈래: "요소", 키: p.키 });
named(p, l);
}),
),
);
else {
const sub = link.키 ? h.subDoc(link.키) : null;
const codes = sub?.변수?.find((x) => x.열쇠 === "기계")?.연결;
if (kind === "기계" && codes?.목록) {
const now = String(link.입력?.기계 ?? `'${codes.값 ?? ""}'`).replace(/^'|'$/g, "");
price.push(
createSelectField({
label: lt("Machine"),
options: codes.목록.map((x) => ({ value: String(x), text: String(x) })),
value: now,
compact: true,
size: 24,
onChange: (x) => setVar((k) => (k.입력 = { ...(k.입력 ?? {}), 기계: `'${x}'` })),
}).root,
);
}
price.push(
finder(
h,
"단가산출",
undefined,
!link.키 && own.startsWith("P_") ? "시간당 기계경비" : "",
(p) =>
setVar((k, l) => {
const keep = k.키 === p.키 ? (k.입력 ?? {}) : {};
for (const x of Object.keys(k)) delete (k as Record<string, unknown>)[x];
Object.assign(k, { 갈래: "단가산출", 키: p.키, 입력: keep });
if (!l.이름) named(p, l);
}),
),
);
}
? kind === "기계" && spec
? spec // 기계 = 기종 이름 · 규격(679 — 코드 번호 아님)
: link.갈래 === "단가산출"
? (h.subDoc(link.키)?.이름 ?? h.name(link.키))
: h.name(link.키)
: mat
? String(mat.검색어 ?? mat.이름 ?? mat.상세구분 ?? "")
: "";
price.push(
el("span", { className: "lstk-link", text: label || "—" }),
btn(lt("Repick"), () =>
openStackPick(kind, (p) => h.change((doc) => repick(doc, i, own, kind, p))),
),
);
} else price.push(field(lt("Price"), line.단가, (t) => put((l) => (l.단가 = t.trim())), "l"));
const why = (h.answer.줄?.[i] as { 까닭?: string } | undefined)?.까닭;
return [
@@ -219,6 +140,10 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
/** [+ 줄] 고르개 값 → 새 줄 */
function addLine(h: EditHooks, at: number, kind: string): void {
if (["인력", "재료", "기계", "단가산출"].includes(kind))
return openStackPick(kind as MoneyKind, (p) =>
h.change((d, items) => void addPicked(d, items[at], kind as MoneyKind, p)),
);
if (kind.startsWith("tpl:")) {
const t = h.templates().find((x) => x.id === kind.slice(4));
if (t) h.change((d, items) => items[at].줄.push(insertTpl(d, t)));
@@ -227,32 +152,11 @@ function addLine(h: EditHooks, at: number, kind: string): void {
h.change((d, items) => {
const it = items[at];
const n = (d.금액 ??= []).length;
const money = (line: MoneyLine, id?: string, link?: Record<string, unknown>): void => {
if (id) (d.변수 ??= []).push(newVar(id, link as never));
const money = (line: MoneyLine): void => {
d.금액!.push(line);
it.줄.push({ 꼴: "금액", i: n });
};
if (kind === "인력" || kind === "재료") {
const id = freshId(d, kind === "인력" ? "w" : "P", "새");
money(
{
이름: "",
단위: kind === "인력" ? "인" : "",
비목: kind === "인력" ? "노무비" : "재료비",
수량: "1",
단가: id,
},
id,
{ 갈래: "요소" },
);
} else if (kind === "기계" || kind === "단가산출") {
const id = freshId(d, kind === "기계" ? "P" : "B", "새");
money(
{ 이름: "", 단위: kind === "기계" ? "hr" : "", 비목: "묶음", 수량: "1", 단가: id },
id,
{ 갈래: "단가산출", 입력: {} },
);
} else if (kind === "율")
if (kind === "율")
money({ 이름: "공구손료 및 경장비", 단위: "식", 비목: "경비", 단가: "노무비 × 3 %" });
else if (kind === "공식") {
const id = freshId(d, "x");
@@ -287,8 +191,8 @@ function dropLine(d: StackDoc, items: StackItem[], i: number, j: number): void {
}
export function buildEdit(h: EditHooks): HTMLElement {
const add = (at: number): HTMLElement =>
createSelectField({
const add = (at: number): HTMLElement => {
const pick = createSelectField({
options: [
{ value: "", text: `${lt("Add_Line")} ▾` },
...(["인력", "재료", "기계", "단가산출", "율"] as MoneyKind[]).map((k) => ({
@@ -306,8 +210,13 @@ export function buildEdit(h: EditHooks): HTMLElement {
compact: true,
size: 24,
ariaLabel: lt("Add_Line"),
onChange: (x) => x && addLine(h, at, x),
}).root;
onChange: (x) => {
pick.select.value = ""; // 창을 닫아도 [+ 줄 ▾] 로 돌아옴
if (x) addLine(h, at, x);
},
});
return pick.root;
};
const items = h.items.map((it, i) => {
const head = el("div", {
className: "lstk-ihead",
@@ -355,10 +264,12 @@ export function buildEdit(h: EditHooks): HTMLElement {
: buildBlock(h, i, j, line);
const card = el("div", { className: `lstk-line k-${line.꼴}`, children: [tools, ...body] });
card.dataset.loc = `${i}.${j}`;
card.addEventListener("pointerdown", () => h.pair(`${i}.${j}`));
return card;
});
return el("div", {
const box = el("div", {
className: "lstk-item",
attrs: { "data-loc": String(i) },
children: [
head,
...(lines.length
@@ -366,6 +277,8 @@ export function buildEdit(h: EditHooks): HTMLElement {
: [el("div", { className: "lstk-empty", text: lt("Empty_Item") })]),
],
});
head.addEventListener("pointerdown", () => h.pair(String(i)));
return box;
});
return el("div", {
className: "lstk-edit",
+40 -7
View File
@@ -29,6 +29,7 @@ import {
subInputs,
type StackDoc,
} from "../common_util/common_util_line_stack";
import { stackLevels } from "../common_util/common_util_line_stack_pick";
import { katexNode, symTex, tex, valTex } from "./line_stack_tex";
import { lt } from "./line_stack_text";
@@ -39,8 +40,14 @@ interface Row {
cls?: string;
won?: boolean;
depth: number;
/** 들여쓰기 수준(804-3) — 머리 0 · 항목 1 · 가나다 2 · 줄 = 항목 + 1 · 펼친 블록 = 그 줄 + 1 */
lv: number;
/** 왼쪽 짝 자리(679) — 항목 `k` · 줄 `k.j`(펼친 블록 = 그 줄) */
loc?: string;
}
export interface OutHooks {
/** 줄 누름 — 왼쪽 짝 자리(679) */
pair?: (loc: string) => void;
cache: DocCache;
/** 펼친 자리(`부모/기호`) */
open: Set<string>;
@@ -67,14 +74,23 @@ const hasVar = (text: unknown): boolean => {
return !!n && JSON.stringify(n).includes('["var"');
};
function rows(doc: StackDoc, a: DocAnswer, h: OutHooks, depth: number, at: string): Row[] {
function rows(
doc: StackDoc,
a: DocAnswer,
h: OutHooks,
depth: number,
at: string,
base = 0,
): Row[] {
const out: Row[] = [];
let lv = base + 1; // 지금 쌓는 줄 수준
let loc: string | undefined; // 지금 쌓는 왼쪽 자리(맨 위 문서만)
const vals = (a.값 ?? {}) as Values;
const vars = new Map((doc.변수 ?? []).map((v) => [varId(v), v]));
const lines = new Map((doc.식 ?? []).map((l) => [String(l.기호 ?? ""), l.식]));
// 펼친 문서 줄 = 금액 칸 없이(부른 쪽 칸과 섞이지 않게)
const push = (r: Omit<Row, "depth">): void =>
void out.push({ ...r, depth, ...(depth ? { cols: undefined } : {}) });
const push = (r: Omit<Row, "depth" | "lv">): void =>
void out.push({ ...r, depth, lv, loc, ...(depth ? { cols: undefined } : {}) });
const shown = new Set(reach(doc, vals)); // 지금 탄 갈래로 닿는 기호만
const steps = lineMap(doc);
const unit = (id: string): string => vars.get(id)?.단위 ?? "";
@@ -108,10 +124,12 @@ function rows(doc: StackDoc, a: DocAnswer, h: OutHooks, depth: number, at: strin
if (h.cache.takeMissing()) h.miss();
out.push({
depth: depth + 1,
lv: lv + 1,
loc,
h: [`${sub.이름 ?? ""}${sub.결과단위 ? ` [${sub.결과단위}]` : ""}`],
cls: "subhead",
});
out.push(...rows(sub, got, h, depth + 1, path));
out.push(...rows(sub, got, h, depth + 1, path, lv + 1).map((r) => ({ ...r, loc })));
};
/** 공식 묶음 — 값만인 줄은 넷까지 한 줄 */
@@ -240,7 +258,10 @@ function rows(doc: StackDoc, a: DocAnswer, h: OutHooks, depth: number, at: strin
const terms: Record<string, number[]> = { 노무비: [], 재료비: [], 경비: [] };
let n = 0;
let sub = 0;
for (const it of items) {
const levels = stackLevels(items);
for (const [k, it] of items.entries()) {
lv = base + levels[k].item;
loc = depth ? undefined : String(k);
if (it.제목.trim()) {
const label = it.가나다 ? `${"가나다라마바사아자차카타파하"[sub++] ?? "?"} . ` : `${++n}. `;
if (!it.가나다) sub = 0;
@@ -249,7 +270,9 @@ function rows(doc: StackDoc, a: DocAnswer, h: OutHooks, depth: number, at: strin
const sum: Record<string, number> = { 노무비: 0, 재료비: 0, 경비: 0 };
const own: Record<string, number[]> = { 노무비: [], 재료비: [], 경비: [] };
let hasSub = false;
for (const l of it.줄) {
lv = base + levels[k].line;
for (const [j, l] of it.줄.entries()) {
loc = depth ? undefined : `${k}.${j}`;
if (l.꼴 === "글") push({ h: [l.글] });
else if (l.꼴 === "공식") block(l.기호);
else {
@@ -263,12 +286,15 @@ function rows(doc: StackDoc, a: DocAnswer, h: OutHooks, depth: number, at: strin
}
}
}
loc = depth ? undefined : String(k);
if (hasSub && COSTS.some((c) => sum[c])) {
const s = Math.round((sum.노무비 + sum.재료비 + sum.경비) * 10) / 10;
push({ h: [`${lt("Sub_Sum")} : ${one(s)}`], cols: { sum: s, ...sum }, cls: "subsum" });
}
for (const c of COSTS) terms[c].push(...(hasSub ? (sum[c] ? [sum[c]] : []) : own[c]));
}
lv = base + 1;
loc = undefined;
if (doc.결과) {
const r = a.결과;
push({
@@ -282,6 +308,7 @@ function rows(doc: StackDoc, a: DocAnswer, h: OutHooks, depth: number, at: strin
}
if (!money.length) return out;
push({ h: [`${++n}. ${lt("Total")}`], cls: "item" });
lv = base + 2;
for (const c of COSTS) {
const t = terms[c];
const v = a[c];
@@ -326,6 +353,7 @@ export function buildOut(doc: StackDoc, a: DocAnswer, h: OutHooks): HTMLElement
cls: "head",
won: true,
depth: 0,
lv: 0,
};
for (const r of [head, ...rows(doc, a, h, 0, "")]) {
const cell = (k: "sum" | (typeof COSTS)[number]): string => {
@@ -334,9 +362,14 @@ export function buildOut(doc: StackDoc, a: DocAnswer, h: OutHooks): HTMLElement
return r.won ? won(v) : one(v);
};
const first = el("div", { className: "lstk-h", children: r.h });
first.style.setProperty("--lstk-depth", String(r.depth));
first.style.setProperty("--lstk-depth", String(r.lv));
first.dataset.lv = String(r.lv);
const tr = tableRow(columns, [first, cell("sum"), ...COSTS.map(cell)]);
tr.className = `lstk-orow ${r.cls ?? ""} ${r.depth ? "is-sub" : ""}`;
if (r.loc !== undefined) {
tr.dataset.loc = r.loc;
tr.addEventListener("click", () => h.pair?.(r.loc!));
}
tbody.append(tr);
}
root.classList.add("lstk-out");
+128
View File
@@ -0,0 +1,128 @@
/* =============================================================================
* line_stack_pick.ts
* 줄 쌓기 고르기 창(PLAN 804-3 · 요구 678-1 · 678-3)
* 찾아 넣기 = M02 [항목 추가] 창 그대로(`openFindPicker` · 탭 M01 전체 · 찾기 칸 · 표) — 줄 갈래마다 그 종류만
* 인력(노임 LB) · 재료(자재 MT) · 기계(EQ → 시간당 경비 로직 + 기계='원문번호') · 다른 단가산출(M01 단가산출 로직)
* M02 단가산출 · 일위대가 탭은 안 보임 — 수식 문서 연결은 M01 키만 셈
* 표 고르기 = 품셈 표(조건 칸 + 값 칸) · 값 칸 누름 = 그 줄 · 칸 · 지금 닿는 칸 강조 ·
* 옵션에 매인 조건 = 그 줄만 고를 수 있음(다른 줄은 막힘 + 「위 옵션에서 고름」 안내)
* ========================================================================== */
import { createButton } from "@ui/ui_template_button";
import { eqText } from "@ui/ui_template_equation";
import { el } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { tableRow, tableShell, type Column } from "@ui/ui_template_table";
import { createText } from "@ui/ui_template_text";
import type { DocTable } from "../common_util/common_util_formula_doc_table";
import type { MoneyKind } from "../common_util/common_util_line_stack";
import type { Picked } from "../common_util/common_util_line_stack_pick";
import { openFindPicker, type FindKind } from "../M02_MasterTemplete/M02_MasterTemplete_Find_Pick";
import { lt } from "./line_stack_text";
const KINDS: Partial<Record<MoneyKind, FindKind>> = {
인력: "labor",
재료: "material",
기계: "machine",
단가산출: "logic",
};
/** 찾아 넣기 — 고르면 `done(고른 것)` · 닫으면 아무것도 안 함 */
export function openStackPick(kind: MoneyKind, done: (p: Picked) => void): void {
const want = KINDS[kind];
if (!want) return;
const spec = (e: { 규격: string }) => (e.규격 ? { 규격: e.규격 } : {});
openFindPicker({
layer: "system",
owner: null,
title: `${lt("Find_Title")} — ${lt(`K_${kind}` as never)}`,
kinds: [want],
logic: (x) => done({ 키: x.키, 이름: x.이름, 단위: x.결과단위.replace(/^원\/?/, "") }),
element: (e) => done({ 키: e.코드, 이름: e.이름, 단위: e.단위, ...spec(e) }),
machine: (e) =>
e.로직 && e.원문번호
? done({ 키: e.로직, 이름: e.이름, 입력: { 기계: `'${e.원문번호}'` }, ...spec(e) })
: undefined,
});
}
const isNum = (v: unknown): boolean =>
typeof v === "number" || (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v)));
const cellText = (v: unknown): string =>
Array.isArray(v)
? `${v[0] ?? ""} ~ ${v[1] ?? ""}`
: isNum(v)
? eqText(Number(v))
: String(v ?? "");
export interface TablePick {
table: DocTable;
/** 지금 닿는 줄(-1 = 없음) · 칸 */
now: number;
col: string;
/** 옵션에 매인 조건 — {조건 칸: 옵션 열쇠들} */
bound: Record<string, string[]>;
done: (row: Record<string, unknown>, col: string) => void;
}
/** 표 고르기 창 — 값 칸 누름 = 고름 */
export function openTablePick(o: TablePick): void {
const t = o.table;
const conds = Object.keys(t.조건 ?? {});
const valueCols = [
...new Set([...Object.keys(t.값칸 ?? {}), ...t.줄.flatMap((r) => Object.keys(r))]),
].filter((k) => !conds.includes(k));
const locked = Object.keys(o.bound).length > 0;
const columns: Column[] = [
...conds.map((c) => ({ label: c })),
...valueCols.map((c) => {
const unit = (t.값칸 ?? {})[c];
return { label: unit ? `${c} (${String(unit)})` : c, align: "end" as const };
}),
];
openModal({
title: [t.원문번호, (t as { 이름?: string }).이름].filter(Boolean).join(" "),
closeLabel: lt("Close"),
width: "960px",
mount: (body, close) => {
const { root, tbody } = tableShell(columns, { density: "data" });
t.줄.forEach((row, k) => {
const off = locked && k !== o.now;
const tr = tableRow(columns, [
...conds.map((c) => cellText(row[c])),
...valueCols.map((c) =>
isNum(row[c]) // 수만 고름(글 칸 = 단위 등은 보기만 · M02 길은 수를 글로 줌)
? createButton({
label: cellText(row[c]),
variant: k === o.now && c === o.col ? "filled" : "ghost",
size: 24,
disabled: off,
onClick: () => {
close();
o.done(row, c);
},
})
: cellText(row[c]),
),
]);
tr.dataset.row = String(k);
tbody.append(tr);
});
const note = locked
? [
createText(
`${lt("Tbl_Bound")} — ${[...new Set(Object.values(o.bound).flat())].join(" · ")}`,
),
]
: [];
body.append(
el("div", {
className: "lstk-tpick",
attrs: { "data-table-pick": t.키 ?? "" },
children: [...note, createText(lt("Tbl_Hint")), root],
}),
);
},
});
}
+65 -8
View File
@@ -4,7 +4,8 @@
* 접힘 = 값 줄은 값 칸(연결 값을 고쳐 적으면 숫자 = 고정값 · 요구 664) · 식 줄은 「이름 = 식 = 값」 글 · 단가산출 결과 기호 = [식으로 풀기]
* 풀림 = 줄마다 이름 · 식 · 단위 칸 + 지움 · [+ 줄] — 이름 바꿈 = 문서 전부(`rename`) · 식 = 식 줄 · 수 = 숫자 연결
* 틀 = 단가산출 결과 문서(굴착기 Q 등)를 이 문서 식으로 옮김(`unfold`) · 내 틀 = 이 브라우저 저장(localStorage) — 꼴이 바뀌면
* 「내 틀 (틀 이름 에서 변경)」
* 「내 틀 (틀 이름 에서 변경)」 · 빈 틀 = [찾아 넣기](돈 아닌 단가산출 결과 — 작업량 등)
* 표 값(804-3) = [표 · 칸] 단추 → 표 고르기 창(`line_stack_pick.ts`) · 값 칸 누름 = 그 줄 · 칸(`pickCell`)
* ========================================================================== */
import { createButton } from "@ui/ui_template_button";
@@ -30,7 +31,10 @@ import {
type StackItem,
type StackLine,
} from "../common_util/common_util_line_stack";
import { boundConds, currentRow, pickCell } from "../common_util/common_util_line_stack_pick";
import { openFindPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Find_Pick";
import type { EditHooks } from "./line_stack_edit";
import { openTablePick } from "./line_stack_pick";
import { lt } from "./line_stack_text";
/** 내 틀 — 기호 · 식 조각(이 브라우저) */
@@ -138,6 +142,38 @@ export function buildBlock(h: EditHooks, i: number, j: number, line: Block): HTM
});
head.dataset.tpl = changed ? "changed" : line.틀 ? "tpl" : "";
// ── 표 값 — [표 · 칸] → 표 고르기 ──
const tableBtn = (id: string, link: NonNullable<FormulaDoc["변수"]>[number]["연결"] & object) => {
const b = createButton({
label:
[
lt("Tbl"),
...Object.values(link.조건 ?? {}).flatMap((t) =>
/^'.*'$/.test(String(t)) ? [String(t).slice(1, -1)] : [],
),
link.칸,
]
.filter(Boolean)
.join(" · ") + " ▾", // 고른 줄(글 조건) · 칸
variant: "ghost",
size: 24,
onClick: () =>
void h.table(String(link.표 ?? "")).then((t) => {
if (!t) return void showToast(lt("Tbl_Missing"), "warning");
openTablePick({
table: t,
now: currentRow(t, link, vals),
col: String(link.칸 ?? ""),
bound: boundConds(d, link),
done: (row, col) => h.change((doc) => pickCell(doc, id, t, row, col)),
});
}),
});
b.dataset.tablePick = id;
b.title = h.name(String(link.표 ?? "")) || String(link.표 ?? "");
return b;
};
// ── 줄 ──
const renameRow = (old: string, now: string): void => {
const t = now.trim();
@@ -171,12 +207,14 @@ export function buildBlock(h: EditHooks, i: number, j: number, line: Block): HTM
const expr = lines.get(id);
const sub = link?.갈래 === "단가산출" && link.키 ? h.subDoc(link.키) : null;
const tag =
link && LINK_TAG[link.갈래 ?? ""] && !expr
? el("span", {
className: "lstk-tag",
text: `${LINK_TAG[link.갈래!]}${link.키 && !link.키.includes("{") ? ` · ${sub?.이름 ?? h.name(link.키) ?? ""}` : ""}`,
})
: null;
link?.갈래 === "표" && !expr
? tableBtn(id, link)
: link && LINK_TAG[link.갈래 ?? ""] && !expr
? el("span", {
className: "lstk-tag",
text: `${LINK_TAG[link.갈래!]}${link.키 && !link.키.includes("{") ? ` · ${sub?.이름 ?? h.name(link.키) ?? ""}` : ""}`,
})
: null;
const tools: HTMLElement[] = [];
if (sub?.결과 && link?.키)
tools.push(
@@ -188,7 +226,26 @@ export function buildBlock(h: EditHooks, i: number, j: number, line: Block): HTM
}),
);
if (link?.갈래 === "단가산출" && !link.키)
tools.push(el("span", { className: "lstk-hint", text: lt("K_틀") }));
tools.push(
createButton({
label: lt("Find_Title"),
variant: "ghost",
size: 24,
onClick: () =>
openFindPicker({
layer: "system",
owner: null,
title: lt("K_틀"),
kinds: ["logic"],
results: true,
logic: (x) =>
h.change((doc) => {
const y = doc.변수?.find((z) => varId(z) === id);
if (y) y.연결 = { 갈래: "단가산출", 키: x.키, 입력: {} };
}),
}),
}),
);
const name = el("b", { className: "lstk-sym", text: id });
const valueOf =
expr ??
+39 -3
View File
@@ -22,6 +22,7 @@ import {
} from "@ui/ui_template_elements";
import { blankDoc, type FormulaDoc } from "../common_util/common_util_formula_doc";
import { calcDoc, type DocAnswer } from "../common_util/common_util_formula_doc_calc";
import type { DocTable } from "../common_util/common_util_formula_doc_table";
import { createDocCache, type DocRecord } from "../common_util/common_util_formula_doc_cache";
import {
docOf,
@@ -31,7 +32,6 @@ import {
type StackItem,
} from "../common_util/common_util_line_stack";
import { copyBadges, type CopyMark, type CopyOptions } from "./formula_doc_copy";
import type { Pick } from "./formula_doc_defs";
import { docFoot } from "./formula_doc_foot";
import { buildOptions } from "./formula_doc_options";
import { ft } from "./formula_doc_text";
@@ -90,7 +90,9 @@ export function createStackScreen(opt: StackScreenOptions) {
let seq = 0;
let asked = "";
let ctl: AbortController | null = null;
const tables = new Map<string, Promise<DocTable | null>>(); // 표 고르기(문서 열 때 비움)
const opened = new Set<string>(); // 산출근거 펼친 자리
let paired = ""; // 짝 강조 자리(679 — 항목 `k` · 줄 `k.j`)
const blocks = new Set<string>(); // 풀린 공식 묶음
const call = async <T>(path: string, body?: unknown, signal?: AbortSignal): Promise<T> => {
@@ -187,6 +189,22 @@ export function createStackScreen(opt: StackScreenOptions) {
}
};
/** 표 하나 — 수식 탭과 같은 길(`/table`) · 받은 표는 계산기 캐시에도 */
const tableOf = (k: string): Promise<DocTable | null> => {
if (!tables.has(k))
tables.set(
k,
call<DocTable & { 끊김?: string }>(`/table?key=${encodeURIComponent(k)}`)
.then((t) => {
if (t.끊김) return null;
cache.putTable(k, t);
return t;
})
.catch(() => null),
);
return tables.get(k)!;
};
/** 고침 — 문서 · 차림을 고친 뒤 문서로 굳힘 */
const change = (fn: (d: StackDoc, items: StackItem[]) => void): void => {
if (!doc) return;
@@ -198,6 +216,20 @@ export function createStackScreen(opt: StackScreenOptions) {
draw();
};
/** 짝 강조 — 왼쪽 카드 · 오른쪽 줄(같은 자리) · from = 누른 쪽(반대쪽을 보이는 자리로) */
const pair = (loc: string, from?: "left" | "right"): void => {
paired = loc;
for (const x of root.querySelectorAll(".is-pair")) x.classList.remove("is-pair");
if (!loc) return;
const sel = `[data-loc="${CSS.escape(loc)}"]`;
const l = left.querySelector<HTMLElement>(sel);
const r = [...right.querySelectorAll<HTMLElement>(sel)];
l?.classList.add("is-pair");
r.forEach((x) => x.classList.add("is-pair"));
if (from === "right") l?.scrollIntoView({ block: "nearest", behavior: "smooth" });
if (from === "left") r[0]?.scrollIntoView({ block: "nearest", behavior: "smooth" });
};
// ── 그림 ──
const edit = (): boolean => opt.admin && (!!key || !cp);
const draw = (): void => {
@@ -269,12 +301,12 @@ export function createStackScreen(opt: StackScreenOptions) {
answer,
items,
change,
ask: (kind, words, group) =>
call<{ 후보: Pick[] }>("/links", { 갈래: kind, 검색: words, 분류: group }),
table: tableOf,
subDoc,
name: (k) => cache.names.get(k) ?? "",
open: blocks,
templates,
pair: (loc) => pair(loc, "left"),
saveTpl: (t) => localStorage.setItem(TPL_KEY, JSON.stringify([...templates(), t])),
}),
]
@@ -291,10 +323,12 @@ export function createStackScreen(opt: StackScreenOptions) {
draw();
},
miss: ask,
pair: (loc) => pair(loc, "right"),
})
: el("p", { className: "ui-text ui-text--muted", text: lt("Empty") }),
);
foot.replaceChildren(...(doc.결과 ? [] : [docFoot(answer)]));
pair(paired);
};
const take = (got: Loaded): void => {
@@ -342,6 +376,7 @@ export function createStackScreen(opt: StackScreenOptions) {
const was = key;
shown = next;
if (next !== key) {
paired = "";
opened.clear();
blocks.clear();
given = {};
@@ -350,6 +385,7 @@ export function createStackScreen(opt: StackScreenOptions) {
doc = answer = null;
asked = "";
cache = createDocCache();
tables.clear();
[cut, mark, version] = [{}, undefined, ""];
draw();
if (!next) {
+10
View File
@@ -57,6 +57,16 @@ const TEXT = {
"Kept fields (edit in Equation tab)",
],
Unsaved_New: ["저장한 단가산출만 — 페이지 [저장] 뒤", "Saved unit prices only"],
Find_Title: ["찾아 넣기", "Find and add"],
Repick: ["찾아 바꾸기", "Find and change"],
Close: ["닫기", "Close"],
Tbl: ["표", "Table"],
Tbl_Missing: ["표를 못 읽음", "Table not found"],
Tbl_Hint: ["값 칸을 누르면 그 값이 들어감", "Click a value cell to use it"],
Tbl_Bound: [
"이 값의 줄은 위 옵션이 고름(다른 줄은 옵션에서 바꿈 · 여기선 칸만)",
"Row follows the option above (change it there; pick a column here)",
],
} as const;
export type LsKey = keyof typeof TEXT;
+10 -2
View File
@@ -42,7 +42,7 @@ def _rank(row: dict) -> tuple:
def hourly(key: str) -> dict[str, Any]:
"""`{ok, 이름, 규격, 노무비, 재료비, 경비, 계}` · 못 셈 = `{ok: False, reason}`."""
"""`{ok, 이름, 규격, 로직, 원문번호, 노무비, 재료비, 경비, 계}` · 못 셈 = `{ok: False, reason}`."""
if not key.startswith("EQ"):
return {"ok": False, "reason": f"「{key}」 는 기계 아님"}
try:
@@ -69,4 +69,12 @@ def hourly(key: str) -> dict[str, Any]:
if not got.get("ok") or "sums" not in got:
return {"ok": False, "reason": got.get("reason") or "셈 못 함"}
sums = {k: float(got["sums"][k]) for k in (*cm.mf.COST_ITEMS, "계")}
return {"ok": True, "이름": row.get("이름"), "규격": row.get("규격"), **sums}
# 804-3 줄 쌓기 기계 줄 = 이 로직 + 기계='원문번호'
return {
"ok": True,
"이름": row.get("이름"),
"규격": row.get("규격"),
"로직": logic["키"],
"원문번호": code,
**sums,
}
@@ -53,6 +53,9 @@ export interface M01Element {
값: number | null;
/** 기계(736-3) — 한 시간 노 · 재 · 경(서버 `/logic-lib/machine-hourly` · 고르는 순간 받음) */
단가?: Record<"노무비" | "재료비" | "경비", number>;
/** 기계(804-3) — 시간당 경비 로직 키 · 기종 원문번호(줄 쌓기 기계 줄 연결) */
로직?: string;
원문번호?: string;
}
/** 고를 수 있는 종류 — logic = M01 단가산출 · 기초 로직 · labor = 인력 · material = 재료 · machine = 기계 · sk = M02 단가산출 · pre = 일위대가 */
@@ -70,6 +73,8 @@ export interface FindPickOptions {
material?: (e: M01Element) => void;
/** M01 단가산출 로직 고름 */
logic: (x: LogicSummary) => void;
/** 804-3 돈 아닌 기초(작업량 등 결과)만 — 줄 쌓기 빈 틀 고름 */
results?: boolean;
/** M02 단가산출 · 일위대가 고름(참조) */
ref?: (ref: LaborRef, name: string) => void;
/** M01 인력 · 재료 고름 — 없으면 그 줄 회색 막힘 */
@@ -210,7 +215,7 @@ export function openFindPicker(opt: FindPickOptions): void {
const hit = matchLogic(q, { ...x, 이름: `${x.이름} ${x.규격}` }); // 공용 · 208-1
if (!hit.ok) return [];
const logic = logics.find((y) => y.키 === x.키);
if (logic && !logic.결과단위.trim().startsWith("원")) return []; // 돈 아닌 기초(작업량 · 728-2) 뺌
if (logic && !logic.결과단위.trim().startsWith("원") !== !!opt.results) return []; // 돈 아닌 기초(작업량 · 728-2) 뺌 · results = 그것만
return [
{
품명: join(x.이름, aliasNote(hit.alias)),
@@ -231,12 +236,12 @@ export function openFindPicker(opt: FindPickOptions): void {
/** 기계 고름 — 한 시간 값을 받아 단가로(못 셈 = 까닭 알림 · 창 그대로) */
const hourly = async (e: M01Element): Promise<M01Element | null> => {
try {
const got = await call<{ ok: boolean; reason?: string } & Record<string, number>>(
`/logic-lib/machine-hourly?key=${encodeURIComponent(e.코드)}`,
);
const got = await call<
{ ok: boolean; reason?: string; 로직?: string; 원문번호?: string } & Record<string, number>
>(`/logic-lib/machine-hourly?key=${encodeURIComponent(e.코드)}`);
if (!got.ok) throw new Error(got.reason ?? tc("Load_Failed"));
const { 노무비, 재료비, 경비, 계 } = got;
return { ...e, 값: 계, 단가: { 노무비, 재료비, 경비 } };
const { 노무비, 재료비, 경비, 계, 로직, 원문번호 } = got;
return { ...e, 값: 계, 단가: { 노무비, 재료비, 경비 }, 로직, 원문번호 };
} catch (error) {
fail(error);
return null;
+146
View File
@@ -0,0 +1,146 @@
/* =============================================================================
* common_util_line_stack_pick.ts
* 줄 쌓기 고르기 · 수준(PLAN 804-3 · 요구 678) — 화면 · DOM · fetch 없음(시험이 Node 로 돌림)
* 표 칸 고름 = 그 기호 연결을 표 · 칸 + 옵션에 안 매인 조건은 고른 줄 값으로(옵션에 매인 조건은 그대로 = 옵션이 줄을 고름)
* 찾아 넣기 고름(인력 · 재료 · 기계 · 다른 단가산출) = 새 금액 줄 + 단가 기호 연결(요소 키 · 단가산출 키 + 넘길 입력)
* 산출근거 수준 = 항목(1. 2.) 1 · 가나다 2 · 줄 = 항목 + 1 · 제목 없는 항목 줄 = 1
* ========================================================================== */
import { names, varId, type FormulaDoc, type Link } from "./common_util_formula_doc";
import { findRow, type DocTable } from "./common_util_formula_doc_table";
import { evalNode } from "./common_util_formula_doc_view";
import {
freshId,
isOption,
newVar,
nodeOf,
type MoneyKind,
type StackItem,
} from "./common_util_line_stack";
type Row = Record<string, unknown>;
/** 표 조건 가운데 옵션(선택 · 입력 열쇠)에 매인 것 — {조건 칸: 옵션 열쇠들} */
export function boundConds(doc: FormulaDoc, link: Link | undefined): Record<string, string[]> {
const opts = new Map(
(doc.변수 ?? []).filter(isOption).map((v) => [varId(v), String(v.열쇠)] as const),
);
const out: Record<string, string[]> = {};
for (const [c, t] of Object.entries(link?.조건 ?? {})) {
const n = nodeOf(t);
const keys = n ? names(n).flatMap((x) => (opts.has(x) ? [opts.get(x)!] : [])) : [];
if (keys.length) out[c] = keys;
}
return out;
}
/** 지금 조건으로 닿는 표 줄(못 찾으면 -1) */
export function currentRow(table: DocTable, link: Link | undefined, vals: Row): number {
const conds: Row = {};
for (const [c, t] of Object.entries(link?.조건 ?? {})) {
const n = nodeOf(t);
if (n) conds[c] = evalNode(n, vals);
}
try {
return table.줄.indexOf(findRow(table, conds, link?.칸));
} catch {
return -1;
}
}
/** 표 줄 조건 값 → 식 글(글 = '…' · 범위 = 안쪽 수) */
function literal(v: unknown): string | null {
if (typeof v === "number") return String(v);
if (Array.isArray(v)) {
const [lo, hi] = v as [number | null, number | null];
if (lo !== null && hi !== null) return String((lo + hi) / 2);
return lo !== null ? String(lo) : hi !== null ? String(hi) : null;
}
return v === null || v === undefined ? null : `'${String(v).replace(/'/g, "")}'`;
}
/** 표 칸 고름 — 기호 연결 = 표 · 칸 · 옵션에 안 매인 조건은 고른 줄 값 · 그 기호 식 줄은 걷음 */
export function pickCell(
doc: FormulaDoc,
id: string,
table: DocTable,
row: Row,
col: string,
): void {
let v = (doc.변수 ?? []).find((x) => varId(x) === id);
if (!v) (doc.변수 ??= []).push((v = newVar(id, { 갈래: "표" })));
const was = v.연결?.갈래 === "표" ? v.연결 : undefined;
const bound = boundConds(doc, was);
const cond: Record<string, string> = {};
for (const c of Object.keys(table.조건 ?? {})) {
if (bound[c] || (table.조건?.[c] === "범위" && was?.조건?.[c])) cond[c] = was!.조건![c];
else {
const t = literal(row[c]);
if (t !== null) cond[c] = t;
}
}
v.연결 = { 갈래: "표", 표: table.키 ?? String(was?.표 ?? ""), 조건: cond, 칸: col };
doc.식 = (doc.식 ?? []).filter((l) => l.기호 !== id);
}
/** 찾아 넣기에서 고른 것 — 기계 = 시간당 경비 로직 키 + 입력 {기계: '원문번호'} */
export interface Picked {
키: string;
이름: string;
규격?: string;
단위?: string;
입력?: Record<string, string>;
}
const SIGN: Record<string, string> = { 인력: "w", 재료: "P", 기계: "P", 단가산출: "B" };
const COST: Record<string, string> = { 인력: "노무비", 재료: "재료비" };
const linkOf = (kind: MoneyKind, p: Picked): Link =>
kind === "인력" || kind === "재료"
? { 갈래: "요소", 키: p.키 }
: { 갈래: "단가산출", 키: p.키, 입력: { ...(p.입력 ?? {}) } };
const unitOf = (kind: MoneyKind, p: Picked): string =>
kind === "기계" ? "hr" : kind === "인력" ? "인" : (p.단위 ?? "");
/** 고른 것으로 새 금액 줄(수량 1) — 항목 끝에 */
export function addPicked(doc: FormulaDoc, item: StackItem, kind: MoneyKind, p: Picked): string {
const id = freshId(doc, SIGN[kind] ?? "B", "새");
(doc.변수 ??= []).push(newVar(id, linkOf(kind, p), p.이름));
const n = (doc.금액 ??= []).length;
doc.금액.push({
이름: p.이름,
...(p.규격 ? { 규격: p.규격 } : {}),
단위: unitOf(kind, p),
비목: COST[kind] ?? "묶음",
수량: "1",
단가: id,
} as never);
item.줄.push({ 꼴: "금액", i: n });
return id;
}
/** 있는 줄 바꿔 고름 — 그 줄 단가 기호 연결 · 줄 이름 · 규격(단위 · 수량은 그대로 · 같은 로직이면 넘길 입력 그대로) */
export function repick(doc: FormulaDoc, i: number, id: string, kind: MoneyKind, p: Picked): void {
const v = (doc.변수 ?? []).find((x) => varId(x) === id);
const line = (doc.금액 ?? [])[i] as { 이름?: string; 규격?: string } | undefined;
if (!v || !line) return;
const was = v.연결 ?? {};
const now = linkOf(kind, p);
if (now.갈래 === "요소" && was.지역) now.지역 = was.지역; // 지역 옵션 이음 그대로
if (now.갈래 === "단가산출" && was.키 === now.키) now.입력 = { ...was.입력, ...now.입력 }; // 같은 로직 = 다른 넘길 입력 그대로
v.연결 = now;
v.이름 = p.이름;
line.이름 = p.이름;
if (p.규격) line.규격 = p.규격;
else delete line.규격;
}
/** 산출근거 수준 — 항목 머리 · 그 항목 줄(제목 없는 항목 = 머리 없음 · 줄 1) */
export function stackLevels(items: StackItem[]): { item: number; line: number }[] {
return items.map((it) => {
if (!it.제목.trim()) return { item: 0, line: 1 };
const item = it.가나다 ? 2 : 1;
return { item, line: item + 1 };
});
}
+62 -1
View File
@@ -108,6 +108,67 @@ function unfold({ 문서, 연결, 기호, 고침, 지움 }) {
});
}
/* 804-3 고르기 — common_util_line_stack_pick.ts */
const pk = util("common_util_line_stack_pick");
const idOf = (v) => ls.symbols({ 변수: [v] })[0];
const varOf = (doc, id) => (doc.변수 || []).find((v) => idOf(v) === id);
/** 표 고름 — 앞 = [{기호, 조건}](먼저 조건을 바꿔 둠) · 기호의 표에서 줄 · 칸 고름 → 연결 · 값 · 금액 */
function tablePick({ 문서, 연결, 기호, 줄, 칸, 앞 }) {
const doc = JSON.parse(JSON.stringify(문서));
for (const f of 앞 || []) varOf(doc, f.기호).연결.조건 = f.조건;
const c = dc.createDocCache();
c.merge(연결);
const link = varOf(doc, 기호).연결;
const t = 연결.표[link.표];
const before = calc.calcDoc(doc, c.links, {});
const now = pk.currentRow(t, link, before.값 || {});
const bound = pk.boundConds(doc, link);
pk.pickCell(doc, 기호, t, t.줄[줄], 칸);
const after = calc.calcDoc(doc, c.links, {});
return {
전줄: now,
묶임: bound,
연결: varOf(doc, 기호).연결,
값: (after.값 || {})[기호],
후줄: pk.currentRow(t, varOf(doc, 기호).연결, after.값 || {}),
...money(after),
문서: doc,
};
}
/** 찾아 넣기 — 새 항목에 고른 것 차례로 · 바꿈 = [{차례, 고른}](넣은 줄 다시 고름) */
function putPicked({ 문서, 넣을, 바꿈 }) {
const doc = JSON.parse(JSON.stringify(문서));
const items = ls.stackOf(doc);
items.push({ 제목: "시험 넣기", 가나다: true, 줄: [] });
const base = (doc.금액 || []).length;
const ids = 넣을.map((x) => pk.addPicked(doc, items[items.length - 1], x.갈래, x.고른));
for (const r of 바꿈 || []) pk.repick(doc, base + r.차례, ids[r.차례], 넣을[r.차례].갈래, r.고른);
const laid = ls.docOf(doc, items);
return {
문서: laid,
기호: ids,
처음: base,
갈래: ids.map((_, k) => ls.kindOf(laid, base + k)),
수준: pk.stackLevels(ls.stackOf(laid)),
};
}
/** 있는 줄 다시 고름 — {문서, 줄, 기호, 갈래, 고른} → 문서 */
function repickOne({ 문서, 줄, 기호, 갈래, 고른 }) {
const doc = JSON.parse(JSON.stringify(문서));
pk.repick(doc, 줄, 기호, 갈래, 고른);
return doc;
}
const input = JSON.parse(fs.readFileSync(0, "utf8"));
const job = { 왕복: roundTrip, 셈: money_, 펼침: unfold }[input.할일];
const job = {
왕복: roundTrip,
셈: money_,
펼침: unfold,
표고름: tablePick,
넣기: putPicked,
바꿈: repickOne,
}[input.할일];
process.stdout.write(JSON.stringify(input.문서들.map(job)));
+112
View File
@@ -227,3 +227,115 @@ def test_화면_이음():
assert lic == "MIT"
for f in (ROOT / "A00_Common").glob("line_stack*.ts"):
assert len(f.read_text(encoding="utf-8").splitlines()) <= 700, f.name
# ── 804-3 줄 쌓기 수정 1차(요구 678) ──
def _tbl(world, key, **kw):
return _ts("표고름", [{**world["cases"][key], **kw}])[0]
def _calc(world, doc):
return dc.mc.calc(
json.loads(json.dumps(doc), parse_float=Decimal, parse_int=Decimal), world["links"]
)
def test_표고름_값_출처(world):
# 옵션에 매인 조건(규격 = S_폼타이규격) — 줄은 옵션 그대로 · 칸만 바뀜
a = _tbl(world, "GC000211", 기호="Q_3t^표", 줄=1, 칸="4t/㎡")
assert a["묶임"] == {"규격": ["폼타이규격"]} and a["전줄"] == 2
assert a["연결"] == {
"갈래": "표",
"표": "QC000429",
"조건": {"규격": "S_폼타이규격"},
"칸": "4t/㎡",
}
assert a["값"] == 0.72 and a["후줄"] == 2 # 12.7㎜ 줄 · 4t/㎡
# 옵션에 안 매인 조건 — 고른 줄 값이 조건으로 · 값 = 표 칸 · 서버 셈 같음
b = _tbl(
world,
"GC000211",
기호="Q_시공^표",
줄=4,
칸="시공량",
앞=[{"기호": "Q_시공^표", "조건": {"유형": "'보통'"}}],
)
assert b["묶임"] == {} and b["전줄"] == 3
assert b["연결"]["조건"] == {"유형": "'간단'"} and b["연결"]["표"] == "QC000431"
assert b["값"] == 50 and b["후줄"] == 4
py = _calc(world, b["문서"])
assert [_num(py.get(f)) for f in COSTS] == [b[f] for f in COSTS]
assert b["계"] != world["py"]["GC000211"]["계"] # 시공량 20 → 50 = 금액 바뀜
# 조건 없는 표(한 줄) — 칸만
c = _tbl(world, "GC000211", 기호="Q_합판2^표", 줄=0, 칸="각재")
assert (
c["연결"] == {"갈래": "표", "표": "QC000427", "조건": {}, "칸": "각재"} and c["값"] == 0.038
)
def test_찾아넣기_요소키_금액(world):
h = mh.hourly("EQ000016")
assert h["ok"] and h["로직"] and h["원문번호"] == "0201-0012"
picks = [
{"갈래": "인력", "고른": {"키": "LB000002", "이름": "보통인부", "단위": "인"}},
{"갈래": "재료", "고른": {"키": "MT000018", "이름": "합판(내수), 서울", "규격": "12t×1220×2440mm", "단위": "㎡"}},
{"갈래": "기계", "고른": {"키": h["로직"], "이름": "굴착기(무한궤도)", "규격": "0.12㎥", "입력": {"기계": "'0201-0012'"}}},
{"갈래": "단가산출", "고른": {"키": "GF000099", "이름": "측구터파기 토사", "단위": "㎥"}},
] # fmt: skip
got = _ts("넣기", [{"문서": world["docs"]["GC000211"], "넣을": picks}])[0]
assert got["갈래"] == ["인력", "재료", "기계", "단가산출"]
doc = got["문서"]
links = {v["기호"] + ("_" + v["아래"] if v.get("아래") else ""): v["연결"] for v in doc["변수"]}
assert [links[i] for i in got["기호"]] == [
{"갈래": "요소", "키": "LB000002"},
{"갈래": "요소", "키": "MT000018"},
{"갈래": "단가산출", "키": h["로직"], "입력": {"기계": "'0201-0012'"}},
{"갈래": "단가산출", "키": "GF000099", "입력": {}},
]
new = doc["금액"][got["처음"] :]
assert [(m["단위"], m["비목"], m["수량"]) for m in new] == [
("인", "노무비", "1"),
("㎡", "재료비", "1"),
("hr", "묶음", "1"),
("㎥", "묶음", "1"),
]
# 금액 = 서버 정본 셈 — 넣은 넷 = 노임 + 자재 + 기계 한 시간(hourly) + 단가산출 계
before, after = world["py"]["GC000211"], _calc(world, doc)
want = 172068 + 11354 + float(h["계"]) + float(world["py"]["GF000099"]["계"])
assert float(after["계"]) - float(before["계"]) == pytest.approx(want, abs=2)
ts = _ts("셈", [{"문서": doc, "연결": _record(world, doc)}])[0]
assert not ts["누락"], ts
assert [ts[f] for f in COSTS] == [_num(after.get(f)) for f in COSTS]
# 다시 고름 — 키 · 이름만 바뀜
again = _ts(
"넣기",
[{"문서": world["docs"]["GC000211"], "넣을": picks[:1], "바꿈": [{"차례": 0, "고른": {"키": "LB000007", "이름": "형틀목공"}}]}],
)[0] # fmt: skip
m = again["문서"]["금액"][again["처음"]]
assert m["이름"] == "형틀목공" and m["수량"] == "1"
assert any(v["연결"] == {"갈래": "요소", "키": "LB000007"} for v in again["문서"]["변수"])
# 기계 다시 고름(같은 로직) — 기종만 바뀌고 지역 · 보정작업 넘김 그대로
gf = world["docs"]["GF000099"]
i = next(k for k, m in enumerate(gf["금액"]) if m["단가"] == "P_굴착기무한궤")
one = _ts("바꿈", [{"문서": gf, "줄": i, "기호": "P_굴착기무한궤", "갈래": "기계", "고른": picks[2]["고른"]}])[0] # fmt: skip
link = next(v["연결"] for v in one["변수"] if v.get("아래") == "굴착기무한궤")
assert link["입력"] == {"기계": "'0201-0012'", "지역": "S_지역", "보정작업": "S_보정작업"}
assert one["금액"][i]["이름"] == "굴착기(무한궤도)" and one["금액"][i]["규격"] == "0.12㎥"
def _record(world, doc):
rec = mr.Rec(world["links"])
d = json.loads(json.dumps(doc), parse_float=Decimal, parse_int=Decimal)
rec.prefetch(d)
dc.mc.calc(d, rec) # 요소 값은 셈하며 기록됨(화면 미리 셈과 같은 길)
return rec.out()
def test_들여쓰기_수준():
got = _ts("넣기", [{"문서": {"금액": [], "변수": []}, "넣을": []}])[0]
assert got["수준"] == [{"item": 2, "line": 3}] # 가나다 항목 = 2 · 줄 3
out = _src("A00_Common/line_stack_out.ts")
assert "stackLevels(items)" in out and '"--lstk-depth", String(r.lv)' in out
assert "rows(sub, got, h, depth + 1, path, lv + 1)" in out # 펼친 블록 = 그 줄 + 1
edit = _src("A00_Common/line_stack_edit.ts")
assert "openStackPick" in edit and "/links" not in edit and "Machine" not in edit