fix(번호 노출): 복사 창 규격 · 재료 창고 규격 · B09 줄 비고 · M02 격자 머리 · 비고 = 사람 말(층 낱말 · 이름) · 번호는 풍선만 (PLAN 704 딸림 ⑤)

- 사전조합 대가 블록 규격 = 「내 자료집 · 회사 · 마스터」 · 키는 spec_raw(풍선) · 복사해 새 일위대가 창 규격은 빈 칸
- B09 설계내역서 구조물 줄 비고 = 「층 낱말 · 조합 이름」(옛 「personal|1/3|SP3-000015」)
- M02 일위대가 격자 머리 = 번호꼴 일위대가번이면 이름 · 「일위대가」 · 참조 줄 비고 = 층 낱말 · 참조 창 제목 · 이미 담음 알림에서 키 뺌
- 내 자료집 저장 알림 · 같은 이름 물음에서 키 뺌
- 시험: test_704_3 격자 · test_701_1 비고 고침

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 18:30:51 +09:00
co-authored by Claude Sonnet 5.5
parent 3c2c82c78e
commit 8c65a323cc
9 changed files with 59 additions and 14 deletions
+17 -1
View File
@@ -16,6 +16,7 @@ from typing import Any
from B08_Quantity.B08_Quantity_Haul import bill_key, haul_groups
from B08_Quantity.B08_Quantity_Material_Calc import _structure_row
from M02_MasterTemplete.M02_Item_Source import item_name
WHY_EARTH = "조합 밖 토공 산근 — 연결 없음"
WHY_READY = "준비공 대가 — 연결 없음"
@@ -47,6 +48,20 @@ EARTH: list[tuple[str, str, str, tuple[str, str, str | None] | None]] = [
#: 구조물 집계표 열 id 머리 → 공종(그 밖 = 2.2 배수공) · 커플링 = 자재라 뺌
STRUCTURE_SECTION = {"pipe:revet|": "2.1", "pipe:chute|": "2.3", "pipe:wall|": "2.3"}
SKIP_COLUMNS = ("pipe:cp|",)
#: 층 이름 — 화면 낱말(번호 · 영문 층 이름은 화면에 안 냄)
LAYER_WORD = {"system": "마스터", "company": "회사", "personal": "내 자료집", "project": "이 공사"}
def combo_text(combo: str, names: dict[str, str]) -> str:
"""조합 id 「{층}|{주인}|{키}」 → 「내 자료집 · 조합 이름」(이름을 못 읽으면 층 낱말만)."""
layer, _, rest = combo.partition("|")
owner, _, key = rest.partition("|")
if combo not in names:
try:
names[combo] = item_name({"layer": layer, "owner": owner or None, "key": key})
except (LookupError, KeyError, ValueError, OSError):
names[combo] = ""
return " · ".join(t for t in (LAYER_WORD.get(layer, ""), names[combo]) if t)
def _same(row: dict[str, Any], want: tuple[str, str, str | None]) -> bool:
@@ -80,13 +95,14 @@ def structure_items(summary: dict[str, Any] | None) -> list[dict[str, Any]]:
columns = (summary or {}).get("columns") or []
total = (summary or {}).get("total") or {}
out = []
names: dict[str, str] = {}
for column in columns:
cid = str(column.get("id") or "")
if cid.startswith(SKIP_COLUMNS):
continue
section = next((s for head, s in STRUCTURE_SECTION.items() if cid.startswith(head)), "2.2")
name, spec, unit = _structure_row(column)
combos = [c for c in column.get("combos") or [] if c]
combos = [combo_text(c, names) for c in column.get("combos") or [] if c]
out.append(
{
"key": f"st:{cid}",
@@ -33,6 +33,7 @@ from typing import Any
from B09_Estimation import B09_Estimation_Bill_Library as library
from B09_Estimation import B09_Estimation_Sangeun as sg
from B09_Estimation import B09_Estimation_Sangeun_Text as st
from B09_Estimation.B09_Estimation_Bill_Items import LAYER_WORD
from M02_MasterTemplete import M02_Item_Prices as item_prices
from M02_MasterTemplete import M02_Labor_Doc as labor_doc
from M02_MasterTemplete import M02_Labor_Fixed as fixed
@@ -266,7 +267,8 @@ class Builder:
{
"kind": "사전조합",
"name": doc.get("이름") or ref.get("키"),
"spec": str(ref.get("키") or ""),
"spec": LAYER_WORD.get(str(ref.get("층")), ""), # 번호(키)는 풍선 글로만
"spec_raw": str(ref.get("키") or ""),
"unit": doc.get("단위") or "",
"cite": "",
"origin": {"참조": refs.ref_id(ref)},
@@ -38,7 +38,7 @@ async function copyBlock(ctx: TabContext, raw: DataRow[], row: Row, block: Row):
note: `「${block.name}」 을 이 공사 안 새 호표로 — 고친 칸까지 복사`,
fields: [
{ key: "name", label: "이름", value: `${block.name} 복사`, required: true },
{ key: "spec", label: "규격", value: block.spec || "" },
{ key: "spec", label: "규격", value: block.kind === "사전조합" ? "" : block.spec || "" },
],
okLabel: "만들기",
closeLabel: "닫기",
@@ -106,7 +106,7 @@ async function savePersonal(ctx: TabContext, block: Row): Promise<void> {
const tail = left
? ` · 안 담은 고친 칸 ${left}(품 ${skip.품} · 단가 ${skip.단가}) — 품은 고정 줄(PLAN 705) 뒤 담김`
: "";
showToast(`내 자료집에 저장 「${got.이름}」(${got.키})${tail}`, "success", 6000);
showToast(`내 자료집에 저장 「${got.이름}」${tail}`, "success", 6000);
return;
}
if (status !== 409 || !same) {
@@ -116,7 +116,7 @@ async function savePersonal(ctx: TabContext, block: Row): Promise<void> {
}
const over = await askConfirm({
title: "같은 이름",
message: `내 자료집에 「${name.trim()}」(${same}) 이 있음 — 덮어쓰기 · 새 이름`,
message: `내 자료집에 「${name.trim()}」 이 있음 — 덮어쓰기 · 새 이름`,
okLabel: "덮어쓰기",
cancelLabel: "새 이름",
});
@@ -455,7 +455,7 @@ export function mountLaborDetail(
if (!ref) return;
void openRefEdit({
ref,
title: `${ref.키} ${lineOf(lineId({ 참조: ref }) ?? "")?.이름 ?? ""}`.trim(),
title: lineOf(lineId({ 참조: ref }) ?? "")?.이름 ?? "",
readOnly: !edit || ref.층 !== opt.layer,
closed: () => alive && recalc(),
});
@@ -466,7 +466,7 @@ export function mountLaborDetail(
const row = cur.담은로직[i];
const line = lineOf(lineId(row) ?? "");
openQtyFor(
`${row.참조?.키 ?? row.로직} ${(row.참조 ? line?.이름 : logics.get(row.로직)?.이름) ?? ""}`.trim(),
`${row.참조 ? "" : row.로직} ${(row.참조 ? line?.이름 : logics.get(row.로직)?.이름) ?? ""}`.trim(),
row,
line,
line?.분모 ?? "",
@@ -491,7 +491,7 @@ export function mountLaborDetail(
pick: (ref: LaborRef, name: string, 별칭: string | null = null) => {
const id = lineId({ 참조: ref });
if (cur.담은로직.some((r) => lineId(r) === id))
return showToast(tc("Labor_AlreadyRef").replace("{value}", `${ref.키} ${name}`), "error");
return showToast(tc("Labor_AlreadyRef").replace("{value}", name), "error");
const 수량 = lib ? { 손: 1 } : null; // 사전조합 줄 기본 1(51-6)
cur.담은로직.push({ 로직: "", 참조: ref, 별칭, 메모: null, 입력값: {}, 수량 });
changed();
@@ -34,6 +34,13 @@ const money = (m: LaborCost | null | undefined, times = 1): HopyoMoney =>
}
: {};
/** 층 낱말 — 번호(키)는 화면 글에 안 냄 */
const layerWord = (layer: string): string =>
layer === "personal" ? tc("Grid_Mine") : tc(layer === "company" ? "Lib_Company" : "Lib_System");
/** 일위대가번 — 번호꼴(SU-000011 · SU01010101)은 사람 글이 아니라 뺌 */
const humanNo = (no?: string): string => (no && !/^S[A-Z]\d?-?\d{6,8}$/.test(no) ? no : "");
/** 문서 + calc 답 → 호표 하나 — calc 없으면(계산 중) 금액 빈 칸 · 빈 줄 0 */
export function laborBlock(
doc: LaborDoc,
@@ -68,7 +75,9 @@ export function laborBlock(
unit: line?.분모 ?? (row.참조 ? "" : (logic?.결과단위 ?? "")),
prices: money(line?.기준, line?.배 || 1),
amounts: money(line?.반영),
note: line?.까닭 ?? (calc && qty === null ? tc("Grid_NoQty") : (row.참조?.키 ?? row.로직)),
note:
line?.까닭 ??
(calc && qty === null ? tc("Grid_NoQty") : row.참조 ? layerWord(row.참조.층) : row.로직),
tag: HOPYO_TAG.pum,
};
});
@@ -96,7 +105,7 @@ export function laborBlock(
const lines = [...logicLines, ...matLines];
const block: HopyoBlock = {
key: doc.열,
name: doc.일위대가번 || doc.열,
name: humanNo(doc.일위대가번) || (doc as { 이름?: string }).이름 || tc("Grid_Head"),
unit: doc.단위 ?? "",
total: money(calc?.반영합),
lines,
@@ -15,7 +15,7 @@ import { tc } from "./M02_MasterTemplete_Labor_Text";
export interface RefEditOptions {
ref: LaborRef;
/** 「SC-000001 돌붙임」 */
/** 「돌붙임」 */
title: string;
readOnly: boolean;
closed: () => void;
@@ -179,6 +179,8 @@ const TEXT = {
Grid_Apart: ["별도계상 · 합계 밖", "Separate · not in total"],
Grid_Linked: ["이 공사 단가에 이음", "Linked to project price"],
Grid_Hand: ["손 단가(붙인 값)", "Hand price (pasted)"],
Grid_Head: ["일위대가", "Unit price"],
Grid_Mine: ["내 자료집", "My library"],
} as const satisfies Record<string, readonly [string, string]>;
export type ComboTextKey = keyof typeof TEXT;
@@ -125,4 +125,4 @@ def test_B09_금액_길이_곱하기_대가(out):
inputs = {"bill": {"items": structure_items(out)}}
sheets = compute_estimation(inputs, {}, {"s-wall": price}.get)
row = next(r for r in sheets["bill"]["rows"] if r["key"].endswith("#blk:w"))
assert (row["qty"], row["sum"], row["note"]) == (12.0, 1495128, "대가1 · system||SS000201")
assert (row["qty"], row["sum"], row["note"]) == (12.0, 1495128, "대가1 · 마스터")
+18 -2
View File
@@ -58,7 +58,7 @@ def test_머리_구성_합계_줄_꼬리표_빈_줄():
STUBS = r"""
const tc = (k) => ({ Grid_NoQty: "수량 없음", Grid_Apart: "별도계상 · 합계 밖", Labor_Layer: "켜쌓기 {n}%" })[k] ?? k;
const tc = (k) => ({ Grid_NoQty: "수량 없음", Grid_Apart: "별도계상 · 합계 밖", Labor_Layer: "켜쌓기 {n}%", Grid_Head: "일위대가", Grid_Mine: "내 자료집", Lib_Company: "회사", Lib_System: "마스터" })[k] ?? k;
const valueLine = (inputs, values) => ({ text: Object.values(values ?? {}).join(" · "), empty: 0 });
"""
@@ -108,7 +108,7 @@ def test_M02_문서_calc_를_격자_줄로():
"\nconsole.log(JSON.stringify({ b, foot: hopyoGrid([b]).rows.at(-1), wait: hopyoGrid([wait]).rows.at(-1) }));"
), STUBS) # fmt: skip
b = out["b"]
assert b["name"] == "SU-000006" and b["unit"] == "m" and b["total"]["sum"] == 112559.55
assert b["name"] == "일위대가" and b["unit"] == "m" and b["total"]["sum"] == 112559.55
one, two, mt1, mt2 = b["lines"]
assert (
one["name"] == "돌쌓기 찰쌓기"
@@ -141,3 +141,19 @@ def test_세_곳_같은_부품():
assert '"@ui/ui_template_hopyo_grid"' in m02 and '"@ui/ui_template_hopyo_grid"' in b09
kit = (ROOT / "ui_template/ui_template_ui_kit_parts.ts").read_text("utf-8")
assert 'key: "hopyo-grid"' in kit # 부품 모음 견본
def test_번호는_글에_안_냄_사람_글은_그대로():
files = [GRID, "M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts",
"M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts",
"M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts"] # fmt: skip
ref = {"로직": "", "별칭": None, "입력값": {}, "수량": {"손": 1},
"참조": {"층": "personal", "주인": "1/3", "키": "SC-000002"}} # fmt: skip
named = {**DOC, "일위대가번": "제5호표", "담은로직": [ref]}
out = _node(files, (
f"\nconst a = laborBlock({json.dumps(named, ensure_ascii=False)}, null, new Map());"
f"\nconst c = laborBlock({json.dumps({**named, '일위대가번': 'SP3-000015'}, ensure_ascii=False)}, null, new Map());"
"\nconsole.log(JSON.stringify({ a, c }));"
), STUBS) # fmt: skip
assert out["a"]["name"] == "제5호표" and out["c"]["name"] == "일위대가"
assert out["a"]["lines"][0]["note"] == "내 자료집"