feat(804-5): 줄 쌓기 표 값 이름 · 산출근거 고정 · 그룹 글 · 짝 강조 채우기 · 틀로 저장 걷음(요구 683~685)

- 표 값 기호 화면 이름 = 고른 줄 이름(Q_수량2^표 → Q_특별인부 · 겹치면 2 · 3) — 왼쪽 · 산출근거 같음 · 칸 글은 화면 이름 ↔ 기호로 오감(문서 기호 그대로)
- 단위 「줄 따름」 안 보임 · 표 값만 든 묶음 머리 = 「표 값」 · [식으로 풀기] → 「식 고치기」
- 산출근거 = 넓을 때 화면 고정 + 자기 스크롤(높이 = 스크롤 영역) · 나란히 놓는 탭 폭 1240 → 1040
- 글 「항목」 → 「그룹」(그룹 이름 · + 그룹 · 그룹 지움)
- 짝 강조 = 왼쪽 카드 · 그룹 머리 · 오른쪽 줄 모두 같은 바탕 채우기(테두리 걷음)
- [틀로 저장] · 내 틀 목록(브라우저 저장) 걷음 · 공식 틀 고르기 = M01 단가산출 결과만
- 시험 test_804_4 표 값 화면 이름 1,518 전수(겹침 없음 · 식 글 왕복 같음) 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-08 08:42:41 +09:00
co-authored by Claude Opus 5.5
parent af576e5a7a
commit 6f678fb91b
10 changed files with 219 additions and 114 deletions
+18 -8
View File
@@ -38,11 +38,25 @@
display: none;
}
/* 탭 폭이 좁으면(1366 화면 등) 편집 위 · 산출근거 아래 */
@container (max-width: 1240px) {
/* 산출근거(요구 683) — 왼쪽을 내려도 화면에 고정 · 길면 자기 스크롤(높이 = 스크롤 영역 · fit()) */
.lstk[data-edit="1"] .lstk__right {
position: sticky;
inset-block-start: 0;
max-height: var(--lstk-view, 100vh);
overflow: auto;
}
/* 탭 폭이 좁으면(1366 화면 등) 편집 위 · 산출근거 아래(고정 없음) */
@container (max-width: 1040px) {
.lstk__areas {
grid-template-columns: minmax(0, 1fr);
}
.lstk[data-edit="1"] .lstk__right {
position: static;
max-height: none;
overflow: visible;
}
}
/* ── 왼쪽 편집 ── */
@@ -269,13 +283,9 @@
font-size: 1.05em;
}
/* 짝 강조(679) — 왼쪽 카드 · 오른쪽 줄 같은 자리 */
/* 짝 강조(679 · 684) — 왼쪽 카드 · 그룹 머리 · 오른쪽 줄 = 같은 바탕 채우기 */
.lstk-line.is-pair,
.lstk-item.is-pair {
outline: 2px solid var(--color-accent);
outline-offset: -2px;
}
.lstk-item.is-pair > .lstk-ihead,
.lstk-out tr.is-pair > td {
background: var(--color-accent-soft);
}
+16 -16
View File
@@ -32,10 +32,15 @@ import {
type StackItem,
} from "../common_util/common_util_line_stack";
import { placeNames } from "../common_util/common_util_line_stack_copy";
import { addPicked, repick } from "../common_util/common_util_line_stack_pick";
import {
addPicked,
aliasText,
repick,
tableAlias,
} from "../common_util/common_util_line_stack_pick";
import { attachDrag } from "./line_stack_drag";
import { openStackPick, type StackTake } from "./line_stack_pick";
import { buildBlock, insertTpl, type Tpl } from "./line_stack_rows";
import { buildBlock } from "./line_stack_rows";
import { lt, type LsKey } from "./line_stack_text";
export interface EditHooks {
@@ -49,10 +54,8 @@ export interface EditHooks {
name: (key: string) => string;
/** 공식 묶음 펼친 자리(`항목.줄`) — 화면 상태만 */
open: Set<string>;
templates: () => Tpl[];
/** 줄 · 항목 누름 — 오른쪽 짝 강조(679) */
pair: (loc: string) => void;
saveTpl: (tpl: Tpl) => void;
/** M02 단가산출(804-4) — 다른 단가산출 · 일위대가 = 복사해 펼침 · M01 = 없음(M01 전체만) */
take?: StackTake;
}
@@ -112,10 +115,14 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
]),
],
});
// 표 값 기호 = 화면 이름(요구 683) — 칸 글은 화면 이름 ↔ 기호로 오감
const alias = tableAlias(d);
const shown = (t: unknown): string => aliasText(String(t ?? ""), alias);
const real = (t: string): string => aliasText(t.trim(), alias, true);
const qty = field(
lt("Qty"),
line.수량 ?? "",
(t) => put((l) => (t.trim() ? (l.수량 = t.trim()) : delete l.수량)),
shown(line.수량),
(t) => put((l) => (t.trim() ? (l.수량 = real(t)) : delete l.수량)),
"l",
);
const price: HTMLElement[] = [];
@@ -145,12 +152,13 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
pick.title = lt("Repick");
pick.dataset.repick = String(i);
price.push(pick);
} else price.push(field(lt("Price"), line.단가, (t) => put((l) => (l.단가 = t.trim())), "l"));
} else
price.push(field(lt("Price"), shown(line.단가), (t) => put((l) => (l.단가 = real(t))), "l"));
const why = (h.answer.줄?.[i] as { 까닭?: string } | undefined)?.까닭;
// 율 밑 번지(복사해 온 조각) — 칸 아래 읽는 이름(804-5)
const places = /번지\./.test(`${line.단가} ${line.수량 ?? ""}`) ? placeNames(d, h.items) : null;
const read = (t: unknown): string =>
String(t ?? "").replace(/번지\.([^.\s()]+)\./g, (all, p: string) =>
shown(t).replace(/번지\.([^.\s()]+)\./g, (all, p: string) =>
places?.[p] ? `${places[p]} ` : all,
);
return [
@@ -180,11 +188,6 @@ function addLine(h: EditHooks, at: number, kind: string): void {
(p) => h.change((d, items) => void addPicked(d, items[at], kind as MoneyKind, p)),
h.take,
);
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)));
return;
}
h.change((d, items) => {
const it = items[at];
const n = (d.금액 ??= []).length;
@@ -228,9 +231,6 @@ export function buildEdit(h: EditHooks): HTMLElement {
})),
{ value: "공식", text: lt("K_새공식") },
{ value: "틀", text: lt("K_틀") },
...h
.templates()
.map((t) => ({ value: `tpl:${t.id}`, text: `${lt("K_공식")} — ${t.name}` })),
{ value: "글", text: lt("K_글") },
],
value: "",
+21 -13
View File
@@ -30,8 +30,8 @@ import {
type StackDoc,
} from "../common_util/common_util_line_stack";
import { placeNames } from "../common_util/common_util_line_stack_copy";
import { stackLevels } from "../common_util/common_util_line_stack_pick";
import { katexNode, symTex, tex, valTex } from "./line_stack_tex";
import { shownUnit, stackLevels, tableAlias } from "../common_util/common_util_line_stack_pick";
import { katexNode, symTex, tex, valTex, type TexCtx } from "./line_stack_tex";
import { lt } from "./line_stack_text";
type Cols = Partial<Record<"sum" | (typeof COSTS)[number], number>>;
@@ -71,11 +71,11 @@ function exprNode(
vals: Values,
inline = false,
cost?: string,
place?: Record<string, string>,
more: Partial<TexCtx> = {},
) {
const n = nodeOf(text);
return n
? katexNode(tex(n, { side, vals, inline, cost, place }))
? katexNode(tex(n, { side, vals, inline, cost, ...more }))
: el("span", { text: String(text ?? "") });
}
const hasVar = (text: unknown): boolean => {
@@ -103,7 +103,8 @@ function rows(
const shown = new Set(reach(doc, vals)); // 지금 탄 갈래로 닿는 기호만
const steps = lineMap(doc);
const place = placeNames(doc, stackOf(doc)); // 번지 읽는 이름(804-5)
const unit = (id: string): string => vars.get(id)?.단위 ?? "";
const unit = (id: string): string => shownUnit(vars.get(id)?.단위);
const alias = tableAlias(doc); // 표 값 기호 화면 이름(요구 683)
const subOf = (id: string): FormulaDoc | null => {
const k = vars.get(id)?.연결;
if (k?.갈래 !== "단가산출" || !k.키 || k.키.includes("{")) return null;
@@ -153,7 +154,7 @@ function rows(
for (const id of ids.filter((x) => shown.has(x))) {
const v = vals[id];
const t = lines.get(id);
const head = katexNode(symTex(id));
const head = katexNode(symTex(alias[id] ?? id));
if (t !== undefined && hasVar(t)) {
flush();
const res = isNum(v)
@@ -161,8 +162,8 @@ function rows(
: missing(a, id);
// 기호식 = 값식 = 결과 — 같은 꼴이 이어지면 한 번만(만약이 수 하나를 탄 줄)
const n = nodeOf(t)!;
const sym = tex(n, { side: "sym", vals });
const val = tex(n, { side: "val", vals });
const sym = tex(n, { side: "sym", vals, alias });
const val = tex(n, { side: "val", vals, alias });
const same = isNum(v) && val === valTex(v);
push({
h: [
@@ -190,7 +191,10 @@ function rows(
expand(id);
continue;
}
const expr = t !== undefined && !isNum(Number(t)) ? [" = ", exprNode(t, "sym", vals)] : [];
const expr =
t !== undefined && !isNum(Number(t))
? [" = ", exprNode(t, "sym", vals, false, undefined, { alias })]
: [];
const cell = el("span", {
className: "lstk-pack",
children: [
@@ -234,7 +238,11 @@ function rows(
const qtyNode = (cost?: string): (string | HTMLElement)[] =>
qty === null
? []
: [" × ", exprNode(qty, "val", scope, true, cost), line.단위 ? ` ${line.단위}` : ""];
: [
" × ",
exprNode(qty, "val", scope, true, cost, { alias }),
line.단위 ? ` ${line.단위}` : "",
];
if (r.까닭 && r.금액 === null) {
push({ h: [`${name} : — `, el("span", { className: "lstk-miss", text: r.까닭 })] });
return { sub: false, cols: {} };
@@ -264,11 +272,11 @@ function rows(
const price =
hasVar(line.단가) && kindOf(doc, i) === "율"
? [
exprNode(line.단가, "sym", scope, true, undefined, place),
exprNode(line.단가, "sym", scope, true, undefined, { place, alias }),
" = ",
exprNode(line.단가, "val", scope, true),
exprNode(line.단가, "val", scope, true, undefined, { alias }),
]
: [exprNode(line.단가, "val", scope, true)];
: [exprNode(line.단가, "val", scope, true, undefined, { alias })];
const c = String(line.비목);
push({
h: [`${name} : `, ...price, ...qtyNode(), ` = ${one(amt)}`],
+35 -51
View File
@@ -3,14 +3,14 @@
* 줄 쌓기 공식 묶음(PLAN 804-2 · 요구 675) — 「이름 = 값」 · 「이름 = 식」 줄 묶음 · 틀 이름표 · [식으로 풀기] · [틀로 저장]
* 접힘 = 값 줄은 값 칸(연결 값을 고쳐 적으면 숫자 = 고정값 · 요구 664) · 식 줄은 「이름 = 식 = 값」 글 · 단가산출 결과 기호 = [식으로 풀기]
* 풀림 = 줄마다 이름 · 식 · 단위 칸 + 지움 · [+ 줄] — 이름 바꿈 = 문서 전부(`rename`) · 식 = 식 줄 · 수 = 숫자 연결
* 틀 = 단가산출 결과 문서(굴착기 Q 등)를 이 문서 식으로 옮김(`unfold`) · 내 틀 = 이 브라우저 저장(localStorage) — 꼴이 바뀌면
* 「내 틀 (틀 이름 에서 변경)」 · 빈 틀 = [찾아 넣기](돈 아닌 단가산출 결과 — 작업량 등)
* 틀 = 단가산출 결과 문서(굴착기 Q 등)를 이 문서 식으로 옮김(`unfold`) — 꼴이 바뀌면 「내 틀 (틀 이름 에서 변경)」 ·
* 빈 틀 = [찾아 넣기](M01 단가산출 결과 — 작업량 등) · [틀로 저장] · 내 틀 목록 걷음(요구 685) · 표 값만 = 머리 「표 값」
* 표 값(804-3) = [표 · 칸] 단추 → 표 고르기 창(`line_stack_pick.ts`) · 값 칸 누름 = 그 줄 · 칸(`pickCell`)
* ========================================================================== */
import { createButton } from "@ui/ui_template_button";
import { eqText } from "@ui/ui_template_equation";
import { askText, el, showToast } from "@ui/ui_template_elements";
import { el, showToast } from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import {
DocError,
@@ -27,32 +27,25 @@ import {
setRow,
shapeOf,
unfold,
type StackDoc,
type StackItem,
type StackLine,
} from "../common_util/common_util_line_stack";
import { boundConds, currentRow, pickCell } from "../common_util/common_util_line_stack_pick";
import {
aliasText,
boundConds,
currentRow,
pickCell,
shownUnit,
tableAlias,
} 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";
/** 내 틀 — 기호 · 식 조각(이 브라우저) */
export interface Tpl {
id: string;
name: string;
문서: FormulaDoc;
}
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
type Block = Extract<StackLine, { 꼴: "공식" }>;
/** 틀 넣기 — 새 공식 묶음 줄 */
export function insertTpl(d: StackDoc, t: Tpl): Block {
const moved = unfold(d, t.문서);
return { 꼴: "공식", 기호: moved, 틀: `my:${t.id}`, 틀꼴: shapeOf(d, moved) };
}
const LINK_TAG: Record<string, string> = {
표: "표",
요소: "요소",
@@ -81,9 +74,15 @@ export function buildBlock(h: EditHooks, i: number, j: number, line: Block): HTM
const vars = new Map((d.변수 ?? []).map((v) => [varId(v), v]));
const lines = new Map((d.식 ?? []).map((l) => [String(l.기호 ?? ""), String(l.식 ?? "")]));
const blockOf = (items: StackItem[]): Block => items[i].줄[j] as Block;
// 표 값 기호 = 화면 이름(요구 683 · 문서 기호 그대로) — 칸 글은 화면 이름 ↔ 기호로 오감
const alias = tableAlias(d);
const show = (id: string): string => alias[id] ?? id;
const shownText = (t: string): string => aliasText(t, alias);
const realText = (t: string): string => aliasText(t, alias, true);
const valText = (id: string): string => {
const v = vals[id];
if (isNum(v) || typeof v === "string") return `${eqText(v)} ${vars.get(id)?.단위 ?? ""}`.trim();
if (isNum(v) || typeof v === "string")
return `${eqText(v)} ${shownUnit(vars.get(id)?.단위)}`.trim();
if (v && typeof v === "object")
return ["노무비", "재료비", "경비"]
.map((c) => `${c} ${eqText((v as Record<string, unknown>)[c])}`)
@@ -93,12 +92,11 @@ export function buildBlock(h: EditHooks, i: number, j: number, line: Block): HTM
// ── 이름표 ──
const tplName = (k: string): string =>
k.startsWith("my:")
? (h.templates().find((t) => `my:${t.id}` === k)?.name ?? lt("My_Tpl"))
: (h.subDoc(k)?.이름 ?? (h.name(k) || k));
k.startsWith("my:") ? lt("My_Tpl") : (h.subDoc(k)?.이름 ?? (h.name(k) || k)); // my: = 옛 내 틀(걷음 · 요구 685)
const changed = !!line.틀 && !!line.틀꼴 && shapeOf(d, line.기호) !== line.틀꼴;
const tableOnly = line.기호.every((id) => vars.get(id)?.연결?.갈래 === "표" && !lines.has(id));
const label = !line.틀
? lt("K_공식")
? lt(tableOnly && line.기호.length ? "K_표값" : "K_공식")
: changed
? `${lt("My_Tpl")} (${tplName(line.틀)} ${lt("Changed_From")})`
: tplName(line.틀);
@@ -116,28 +114,6 @@ export function buildBlock(h: EditHooks, i: number, j: number, line: Block): HTM
h.change(() => undefined);
},
}),
createButton({
label: lt("Save_Tpl"),
variant: "ghost",
size: 24,
onClick: () =>
void askText({ message: lt("Tpl_Name"), value: label }).then((name) => {
if (!name?.trim()) return;
const ids = new Set(line.기호);
const id = `t${Date.now()}`;
h.saveTpl({
id,
name: name.trim(),
문서: {
변수: clone((d.변수 ?? []).filter((v) => ids.has(varId(v)))),
식: clone((d.식 ?? []).filter((l) => ids.has(String(l.기호 ?? "")))),
},
});
h.change((doc, items) =>
Object.assign(blockOf(items), { 틀: `my:${id}`, 틀꼴: shapeOf(doc, line.기호) }),
);
}),
}),
],
});
head.dataset.tpl = changed ? "changed" : line.틀 ? "tpl" : "";
@@ -246,16 +222,20 @@ export function buildBlock(h: EditHooks, i: number, j: number, line: Block): HTM
}),
}),
);
const name = el("b", { className: "lstk-sym", text: id });
const name = el("b", { className: "lstk-sym", text: show(id) });
const valueOf =
expr ??
(link?.갈래 === "숫자" ? String(link.값 ?? "") : isNum(vals[id]) ? String(vals[id]) : "");
let body: (HTMLElement | string)[];
if (open)
body = [
input(id, lt("Row_Name"), "lstk-f-s", (t) => renameRow(id, t)),
input(show(id), lt("Row_Name"), "lstk-f-s", (t) =>
renameRow(id, t.trim() === show(id) ? id : t),
),
"=",
input(valueOf, lt("Row_Expr"), "lstk-f-l", (t) => h.change((doc) => setRow(doc, id, t))),
input(shownText(valueOf), lt("Row_Expr"), "lstk-f-l", (t) =>
h.change((doc) => setRow(doc, id, realText(t))),
),
input(v?.단위 ?? "", lt("Unit"), "lstk-f-s", (t) =>
h.change((doc) => {
let x = doc.변수?.find((y) => varId(y) === id);
@@ -274,15 +254,19 @@ export function buildBlock(h: EditHooks, i: number, j: number, line: Block): HTM
else if (expr !== undefined || sub || link?.갈래 === "단가산출")
body = [
name,
`= ${expr ?? ""}${expr ? " =" : ""}`,
`= ${shownText(expr ?? "")}${expr ? " =" : ""}`,
el("span", { className: "lstk-val", text: valText(id) }),
];
else
body = [
name,
"=",
input(valueOf, valText(id), "lstk-f-m", (t) => h.change((doc) => setRow(doc, id, t))),
el("span", { className: "lstk-unit", text: v?.단위 ?? "" }),
input(shownText(valueOf), valText(id), "lstk-f-m", (t) =>
h.change((doc) => setRow(doc, id, realText(t))),
),
...(shownUnit(v?.단위)
? [el("span", { className: "lstk-unit", text: shownUnit(v?.단위) })]
: []),
];
const row = el("div", {
className: `lstk-frow ${open ? "is-open" : ""}`,
+8 -11
View File
@@ -39,7 +39,6 @@ import { addTaken, type Taken } from "../common_util/common_util_line_stack_copy
import type { Layer } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import { buildEdit } from "./line_stack_edit";
import { buildOut } from "./line_stack_out";
import type { Tpl } from "./line_stack_rows";
import { lt } from "./line_stack_text";
import "./formula_doc.css";
import "./line_stack.css";
@@ -66,7 +65,6 @@ interface Loaded {
판?: string;
}
const TPL_KEY = "lineStack.myTemplates";
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
export function createStackScreen(opt: StackScreenOptions) {
@@ -80,6 +78,13 @@ export function createStackScreen(opt: StackScreenOptions) {
attrs: { "data-line-stack": "" },
children: [head, notes, el("div", { className: "lstk__areas", children: [left, right] }), foot],
});
// 산출근거 고정 높이(요구 683) — 스크롤 조상(없으면 창) 높이 · 넓을 때만 고정(line_stack.css)
const fit = (): void => {
let p = root.parentElement;
while (p && !/(auto|scroll)/.test(getComputedStyle(p).overflowY)) p = p.parentElement;
root.style.setProperty("--lstk-view", `${p?.clientHeight ?? innerHeight}px`);
};
window.addEventListener("resize", fit);
const cp = opt.copy;
let key = "";
let shown: string | null = null; // 연 키(같은 키 다시 그림 = 안 읽음 · 탭 바꿈 = force)
@@ -136,13 +141,6 @@ export function createStackScreen(opt: StackScreenOptions) {
/* 캐시가 막혀도 화면 안 고침은 남음 */
}
};
const templates = (): Tpl[] => {
try {
return JSON.parse(localStorage.getItem(TPL_KEY) ?? "[]") as Tpl[];
} catch {
return [];
}
};
// ── 셈 ──
const usable = (): Record<string, unknown> => {
@@ -323,9 +321,7 @@ export function createStackScreen(opt: StackScreenOptions) {
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])),
take: takeCtx(),
}),
]
@@ -348,6 +344,7 @@ export function createStackScreen(opt: StackScreenOptions) {
);
foot.replaceChildren(...(doc.결과 ? [] : [docFoot(answer)]));
pair(paired);
fit();
};
const take = (got: Loaded): void => {
+5 -4
View File
@@ -21,6 +21,8 @@ export interface TexCtx {
cost?: string;
/** 번지 읽는 이름(804-5) — 「번지.복사N.노무비」 → 「항목 이름 노무비」 */
place?: Record<string, string>;
/** 표 값 기호 화면 이름(요구 683 — 「Q_수량2^표」 → 「Q_작업반장」) */
alias?: Record<string, string>;
}
const HIDE = new Set(["반올림", "올림", "버림"]);
@@ -90,12 +92,11 @@ export function tex(n: Node, c: TexCtx): string {
case "str":
return txt(`'${n[1]}'`);
case "var": {
if (c.side === "sym") return symTex(n[1]);
const id = c.alias?.[n[1]] ?? n[1];
if (c.side === "sym") return symTex(id);
let v = c.vals[n[1]];
if (v && typeof v === "object" && c.cost) v = (v as Values)[c.cost];
return v === null || v === undefined || typeof v === "object"
? miss(symTex(n[1]))
: valTex(v);
return v === null || v === undefined || typeof v === "object" ? miss(symTex(id)) : valTex(v);
}
case "field": {
const [, of, key] = n;
+9 -10
View File
@@ -11,18 +11,18 @@ const TEXT = {
Total: ["합 계", "Total"],
Sum: ["계", "Sum"],
Sub_Sum: ["소 계", "Subtotal"],
Add_Item: ["+ 항목", "+ Item"],
Add_Item: ["+ 그룹", "+ Group"],
Add_Line: ["+ 줄", "+ Line"],
Del_Item: ["항목 지움", "Delete item"],
Del_Item: ["그룹 지움", "Delete group"],
Del: ["지움", "Delete"],
Item: ["항목", "Item"],
Item: ["그룹", "Group"],
K_줄: ["줄", "Line"],
Drag: ["끌어 옮기기", "Drag to move"],
Fold_Item: ["항목 접기", "Collapse item"],
Unfold_Item: ["항목 펼치기", "Expand item"],
Fold_Item: ["그룹 접기", "Collapse group"],
Unfold_Item: ["그룹 펼치기", "Expand group"],
Dropped: ["지움 — 눌러 되돌리기", "deleted — click to undo"],
Undo_Late: ["그 뒤 고친 것이 있어 못 되돌림", "Changed since — cannot undo"],
Title: ["항목 이름(비우면 번호 없음)", "Item title"],
Title: ["그룹 이름(비우면 번호 없음)", "Group title"],
Gana: ["가나다", "Sub"],
K_인력: ["인력", "Labor"],
K_재료: ["재료", "Material"],
@@ -33,6 +33,7 @@ const TEXT = {
Take_Failed: ["복사 못 함", "Copy failed"],
K_율: ["율 · 식", "Rate"],
K_공식: ["공식", "Formula"],
K_표값: ["표 값", "Table values"],
K_새공식: ["공식 — 새 공식(빈 묶음)", "Formula — new"],
K_틀: ["공식 — 틀 찾기(단가산출 결과)", "Formula — template"],
K_글: ["글 · 근거", "Text"],
@@ -45,10 +46,8 @@ const TEXT = {
Find: ["찾기", "Find"],
Pick: ["고름 ▾", "Pick ▾"],
None_Found: ["찾은 것 없음", "Nothing found"],
Unfold: ["식으로 풀기", "Unfold"],
Unfold: ["식 고치기", "Edit formula"],
Fold: ["접기", "Fold"],
Save_Tpl: ["틀로 저장", "Save as template"],
Tpl_Name: ["틀 이름", "Template name"],
New_Formula: ["새 공식", "New formula"],
My_Formula: ["내 공식", "My formula"],
My_Tpl: ["내 틀", "My template"],
@@ -58,7 +57,7 @@ const TEXT = {
Row_Expr: ["값 또는 식", "Value or expr"],
Bad_Name: ["못 쓰는 이름 · 같은 이름 있음", "Bad or duplicate name"],
Empty_Item: ["줄 없음 — [+ 줄]", "No lines — [+ Line]"],
Empty: ["빈 단가산출 — [+ 항목] 으로 시작", "Empty — start with [+ Item]"],
Empty: ["빈 단가산출 — [+ 그룹] 으로 시작", "Empty — start with [+ Group]"],
Text_Ph: ["< 품셈 근거 > · ※ 메모 · 별산", "Note"],
Notes: [
"줄 쌓기에서 못 고치는 칸(보존됨 · 수식 탭에서 고침)",
+55 -1
View File
@@ -6,7 +6,15 @@
* 산출근거 수준 = 항목(1. 2.) 1 · 가나다 2 · 줄 = 항목 + 1 · 제목 없는 항목 줄 = 1
* ========================================================================== */
import { names, varId, type FormulaDoc, type Link } from "./common_util_formula_doc";
import {
names,
renameText,
splitId,
symId,
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 {
@@ -14,6 +22,7 @@ import {
isOption,
newVar,
nodeOf,
symbols,
type MoneyKind,
type StackItem,
} from "./common_util_line_stack";
@@ -144,3 +153,48 @@ export function stackLevels(items: StackItem[]): { item: number; line: number }[
return { item, line: item + 1 };
});
}
/** 표 값 기호 화면 이름(804-5 · 요구 683) — 「Q_수량2^표」 → 「Q_작업반장」(고른 줄 글 조건 첫 낱말) · 글 조건 없으면 「^표」만 뗌
* · 「^표」 기호만 · 겹치면 2 · 3 · 문서 기호는 그대로(화면만 · 칸 글은 `aliasText` 로 오감) */
export function tableAlias(doc: FormulaDoc): Record<string, string> {
const taken = new Set([
...symbols(doc),
...(doc.금액 ?? []).map((l) => String(l.기호 ?? "")),
"노무비",
"재료비",
"경비",
]);
const out: Record<string, string> = {};
for (const v of doc.변수 ?? []) {
const link = v.연결;
if (link?.갈래 !== "표") continue;
const id = varId(v);
const [s, lo, up] = splitId(id);
const said = Object.values(link.조건 ?? {})
.map(String)
.find((t) => /^'.+'$/.test(t));
const label = said?.slice(1, -1).split(/[\s(,·/]/)[0] ?? "";
if (up !== "표") continue; // 「^표」 꼬리 기호만(r_인력 등 손 이름은 그대로)
const base = label || lo.replace(/\d+$/, "") || lo; // 번호 꼬리 뗌(겹치면 다시 2 · 3)
for (let n = 1; n < 100; n++) {
const now = symId(s, n > 1 ? `${base}${n}` : base);
const node = nodeOf(now);
if (taken.has(now) || node?.[0] !== "var" || node[1] !== now) continue;
taken.add(now);
out[id] = now;
break;
}
}
return out;
}
/** 식 글의 기호를 화면 이름으로(또는 `back` = 화면 이름을 기호로) */
export function aliasText(text: string, alias: Record<string, string>, back = false): string {
let t = text;
for (const [id, now] of Object.entries(alias))
t = String(back ? renameText(t, now, id) : renameText(t, id, now));
return t;
}
/** 표 칸 단위 「줄 따름」(줄마다 다른 단위) = 화면에 안 보임(요구 683) */
export const shownUnit = (u: unknown): string => (u === "줄 따름" ? "" : String(u ?? ""));
@@ -213,6 +213,32 @@ function moveAll({ 문서, 옮김 }) {
}
const placeOf = ({ 문서 }) => cp.placeNames(문서, ls.stackOf(문서));
/* 683 표 값 기호 화면 이름 — 별칭 · 글 왕복(화면 이름 → 기호 = 처음 식과 같은 나무) */
const pk2 = util("common_util_line_stack_pick");
const fd = util("common_util_formula_doc");
function aliasOf({ 문서 }) {
const alias = pk2.tableAlias(문서);
const texts = [
...(문서.식 || []).map((l) => l.식),
...(문서.금액 || []).flatMap((l) => [l.수량, l.단가]),
].filter((t) => typeof t === "string" && t);
const bad = [];
for (const t of texts) {
let want;
try {
want = fd.textOf(fd.parse(t));
} catch {
continue;
}
const shown = pk2.aliasText(t, alias);
const back = pk2.aliasText(shown, alias, true);
if (fd.textOf(fd.parse(back)) !== want) bad.push(t);
if (Object.keys(alias).some((id) => fd.names(fd.parse(shown)).includes(id)))
bad.push(`남음:${t}`);
}
return { 별칭: alias, 틀림: bad };
}
const input = JSON.parse(fs.readFileSync(0, "utf8"));
const job = {
왕복: roundTrip,
@@ -224,5 +250,6 @@ const job = {
복사: takeIn,
옮김: moveAll,
번지: placeOf,
별칭: aliasOf,
}[input.할일];
process.stdout.write(JSON.stringify(input.문서들.map(job)));
@@ -230,3 +230,28 @@ def test_끌어옮김_번지이름(world):
places = {m["번지"] for m in g["문서"]["금액"] if m.get("번지")}
assert places and set(names) == places
assert all(v and not v.startswith("복사") for v in names.values()), names
def test_표값_화면이름_전수():
"""요구 683 — 표 값 기호(「Q_수량2^표」) = 화면 이름(「Q_작업반장」) · 겹침 없음 · 식 글 왕복 = 같은 식(1,518 전수)."""
docs = dc.folder()
keys = sorted(docs)
got = _ts("별칭", [{"문서": docs[k]} for k in keys])
named = 0
for k, g in zip(keys, got):
assert not g["틀림"], (k, g["틀림"][:3])
alias = g["별칭"]
assert len(set(alias.values())) == len(alias), k # 겹치면 2 · 3
assert all("^표" not in v and k.endswith("^표") for k, v in alias.items()), k
named += len(alias)
assert named > 500
gc = dict(zip(keys, got))[FORM]["별칭"]
assert gc and "Q_보통인부22" not in gc.values() # 번호 꼬리 뗀 뒤 2 · 3
# 문서 기호는 그대로(화면만) — 원본 문서 변수에 별칭 이름 없음
names = {
v["기호"]
+ (f"_{v['아래']}" if v.get("아래") else "")
+ (f"^{v['위']}" if v.get("위") else "")
for v in docs[FORM]["변수"]
}
assert not set(gc.values()) & names