From 0eb7822772c728e2bc4ff144aea56fe5baab7f07 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 8 Nov 2026 01:27:20 +0900 Subject: [PATCH] =?UTF-8?q?feat(804-3):=20=EC=A4=84=20=EC=8C=93=EA=B8=B0?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=201=EC=B0=A8=20=E2=80=94=20=ED=91=9C=20?= =?UTF-8?q?=EA=B0=92=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=C2=B7=20=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=EA=B7=BC=EA=B1=B0=20=EB=93=A4=EC=97=AC=EC=93=B0?= =?UTF-8?q?=EA=B8=B0=20=C2=B7=20=EC=B0=BE=EC=95=84=20=EB=84=A3=EA=B8=B0=20?= =?UTF-8?q?=EC=B0=BD=20=C2=B7=20=EC=A7=9D=20=EA=B0=95=EC=A1=B0=20=C2=B7=20?= =?UTF-8?q?=EA=B8=B0=EA=B3=84=20=EC=9D=B4=EB=A6=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 표 값(요구 678-1): 「표」 꼬리표가 글뿐이라 눌러도 안 열림 → [표 · 고른 줄 · 칸 ▾] 단추 = 품셈 표 창(값 칸 누름 = 그 줄 · 칸) 옵션에 매인 조건(폼타이규격 등) = 그 줄만 고름 · 다른 줄 막힘 + 「위 옵션에서 고름」 안내 · M02 길 표 값 글 수도 고름 - 산출근거 들여쓰기(678-2): 머리 0 · 항목 1 · 가나다 2 · 줄 = 항목 + 1 · 펼친 블록 = 그 줄 + 1(수준 = stackLevels) - 찾아 넣기(678-3): [+ 줄] 인력 · 재료 · 기계 · 다른 단가산출 = M02 [항목 추가] 창 그대로(그 종류만) · 고르면 줄 생김(닫으면 없음) 있는 줄 = [찾아 바꾸기](같은 로직 기계 = 지역 · 보정작업 넘김 그대로) · 옛 찾기 칸 + 고름 목록 · 기종 코드 고르개 걷음 빈 틀 = [찾아 넣기](돈 아닌 단가산출 결과만 — Find_Pick results) - 짝 강조(679-1): 오른쪽 줄 누름 = 왼쪽 카드 강조 + 보이는 자리로 · 왼쪽 누름 = 오른쪽 같은 줄 강조 - 기계 이름(679-2): 기계 줄 = 기종 이름 · 규격 · machine-hourly 답에 로직 키 · 원문번호 더함(기존 칸 그대로) - 모델 common_util_line_stack_pick.ts(boundConds · currentRow · pickCell · addPicked · repick · stackLevels) - 시험 test_804_2 넓힘 3(표 고름 = 값 + 출처 · 서버 셈 같음 · 찾아 넣기 넷 = 요소 키 · 서버 금액 · 다시 고름 · 수준) Co-Authored-By: Claude Opus 5.5 --- A00_Common/line_stack.css | 23 ++- A00_Common/line_stack_edit.ts | 177 +++++------------- A00_Common/line_stack_out.ts | 47 ++++- A00_Common/line_stack_pick.ts | 128 +++++++++++++ A00_Common/line_stack_rows.ts | 73 +++++++- A00_Common/line_stack_screen.ts | 42 ++++- A00_Common/line_stack_text.ts | 10 + M02_MasterTemplete/M02_Machine_Hourly.py | 12 +- .../M02_MasterTemplete_Find_Pick.ts | 17 +- common_util/common_util_line_stack_pick.ts | 146 +++++++++++++++ resources/tester/helper_804_2_line_stack.cjs | 63 ++++++- resources/tester/test_804_2_line_stack.py | 112 +++++++++++ 12 files changed, 690 insertions(+), 160 deletions(-) create mode 100644 A00_Common/line_stack_pick.ts create mode 100644 common_util/common_util_line_stack_pick.ts diff --git a/A00_Common/line_stack.css b/A00_Common/line_stack.css index aa1af24c2..e5ff8fa15 100644 --- a/A00_Common/line_stack.css +++ b/A00_Common/line_stack.css @@ -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); +} diff --git a/A00_Common/line_stack_edit.ts b/A00_Common/line_stack_edit.ts index e40a4a2f1..3b3e7ed54 100644 --- a/A00_Common/line_stack_edit.ts +++ b/A00_Common/line_stack_edit.ts @@ -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; subDoc: (key: string) => FormulaDoc | null; name: (key: string) => string; /** 공식 묶음 펼친 자리(`항목.줄`) — 화면 상태만 */ open: Set; 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 = { 인력: "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["연결"] & 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 | 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)[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)[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): 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", diff --git a/A00_Common/line_stack_out.ts b/A00_Common/line_stack_out.ts index 77e0b1fd5..6bb9b8428 100644 --- a/A00_Common/line_stack_out.ts +++ b/A00_Common/line_stack_out.ts @@ -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; @@ -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): void => - void out.push({ ...r, depth, ...(depth ? { cols: undefined } : {}) }); + const push = (r: Omit): 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 = { 노무비: [], 재료비: [], 경비: [] }; 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 = { 노무비: 0, 재료비: 0, 경비: 0 }; const own: Record = { 노무비: [], 재료비: [], 경비: [] }; 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"); diff --git a/A00_Common/line_stack_pick.ts b/A00_Common/line_stack_pick.ts new file mode 100644 index 000000000..4a68d8449 --- /dev/null +++ b/A00_Common/line_stack_pick.ts @@ -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> = { + 인력: "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; + done: (row: Record, 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], + }), + ); + }, + }); +} diff --git a/A00_Common/line_stack_rows.ts b/A00_Common/line_stack_rows.ts index 8964c9dd9..f660738ca 100644 --- a/A00_Common/line_stack_rows.ts +++ b/A00_Common/line_stack_rows.ts @@ -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[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 ?? diff --git a/A00_Common/line_stack_screen.ts b/A00_Common/line_stack_screen.ts index 2e83eb888..70ec29ea6 100644 --- a/A00_Common/line_stack_screen.ts +++ b/A00_Common/line_stack_screen.ts @@ -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>(); // 표 고르기(문서 열 때 비움) const opened = new Set(); // 산출근거 펼친 자리 + let paired = ""; // 짝 강조 자리(679 — 항목 `k` · 줄 `k.j`) const blocks = new Set(); // 풀린 공식 묶음 const call = async (path: string, body?: unknown, signal?: AbortSignal): Promise => { @@ -187,6 +189,22 @@ export function createStackScreen(opt: StackScreenOptions) { } }; + /** 표 하나 — 수식 탭과 같은 길(`/table`) · 받은 표는 계산기 캐시에도 */ + const tableOf = (k: string): Promise => { + if (!tables.has(k)) + tables.set( + k, + call(`/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(sel); + const r = [...right.querySelectorAll(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) { diff --git a/A00_Common/line_stack_text.ts b/A00_Common/line_stack_text.ts index 4b48c13ee..c63557a88 100644 --- a/A00_Common/line_stack_text.ts +++ b/A00_Common/line_stack_text.ts @@ -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; diff --git a/M02_MasterTemplete/M02_Machine_Hourly.py b/M02_MasterTemplete/M02_Machine_Hourly.py index 71b6955b1..dbf5ef6a0 100644 --- a/M02_MasterTemplete/M02_Machine_Hourly.py +++ b/M02_MasterTemplete/M02_Machine_Hourly.py @@ -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, + } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts index 5f0423ce7..d8fcf7964 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Find_Pick.ts @@ -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 => { try { - const got = await call<{ ok: boolean; reason?: string } & Record>( - `/logic-lib/machine-hourly?key=${encodeURIComponent(e.코드)}`, - ); + const got = await call< + { ok: boolean; reason?: string; 로직?: string; 원문번호?: string } & Record + >(`/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; diff --git a/common_util/common_util_line_stack_pick.ts b/common_util/common_util_line_stack_pick.ts new file mode 100644 index 000000000..02cc7ad4a --- /dev/null +++ b/common_util/common_util_line_stack_pick.ts @@ -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; + +/** 표 조건 가운데 옵션(선택 · 입력 열쇠)에 매인 것 — {조건 칸: 옵션 열쇠들} */ +export function boundConds(doc: FormulaDoc, link: Link | undefined): Record { + const opts = new Map( + (doc.변수 ?? []).filter(isOption).map((v) => [varId(v), String(v.열쇠)] as const), + ); + const out: Record = {}; + 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 = {}; + 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; +} + +const SIGN: Record = { 인력: "w", 재료: "P", 기계: "P", 단가산출: "B" }; +const COST: Record = { 인력: "노무비", 재료: "재료비" }; + +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 }; + }); +} diff --git a/resources/tester/helper_804_2_line_stack.cjs b/resources/tester/helper_804_2_line_stack.cjs index 3ff0173a4..ed6d86bbc 100644 --- a/resources/tester/helper_804_2_line_stack.cjs +++ b/resources/tester/helper_804_2_line_stack.cjs @@ -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))); diff --git a/resources/tester/test_804_2_line_stack.py b/resources/tester/test_804_2_line_stack.py index fdf6b3da3..b625e292c 100644 --- a/resources/tester/test_804_2_line_stack.py +++ b/resources/tester/test_804_2_line_stack.py @@ -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