feat(804-3): 줄 쌓기 수정 1차 — 표 값 고르기 · 산출근거 들여쓰기 · 찾아 넣기 창 · 짝 강조 · 기계 이름
- 표 값(요구 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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],
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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)));
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user