feat(b09): 설계내역서 자유 편집 셈 · 저장 — user.bill v2 덮개 · 차례 · 부모 · gone · 새 1단 공종 · 블록 키 · 옛 꼴 읽음(184-2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
This commit is contained in:
2026-10-10 13:07:26 +09:00
co-authored by Claude Opus 5.5
parent 0970a2665c
commit d7df6dec14
9 changed files with 928 additions and 265 deletions
+239 -112
View File
@@ -43,8 +43,11 @@ export const SECTIONS: Array<[string, string, string | null]> = [
["4", "자재대", null],
["G", "관급자재대", null],
];
const DIRECT = ["1", "2", "3", "4"];
const PARTS = ["labor", "material", "expense"];
/** 184 덮개로 고칠 칸 · 묶음 깊이 한도 */
const FIELDS = ["name", "spec", "unit", "qty", "ref"];
const DEPTH = 5;
const NEW_AUTO = "새 자동 줄";
function amounts(qty: Num, prices: Row | null): Row {
const unit: Record<string, Num> = {};
@@ -60,30 +63,27 @@ function amounts(qty: Num, prices: Row | null): Row {
return out;
}
function itemRow(item: Row, prices: Row | null, gaps: Gap[], qtyOf: Row | null = null): Row {
let qty = num(item.qty);
const over: Row = {};
// 166-2 수량 덮어쓰기
if (qtyOf && item.key in qtyOf && !item.hand) {
over.qty_auto = qty;
qty = num(qtyOf[item.key]);
}
/** 항목 줄 — 「연결 없음」은 _gaps 에 담아 둠(보이는 줄만 표 차례로 모음) */
function itemRow(item: Row, prices: Row | null): Row {
const qty = num(item.qty);
const row: Row = {
kind: "item",
key: item.key,
section: item.section,
section: item.section ?? null,
name: item.name || "",
spec: item.spec || "",
unit: item.unit || "",
qty,
note: item.note || "",
ref: item.ref ?? null,
...over,
...(item.hand ? { hand: true } : {}),
...Object.fromEntries(Object.entries(item).filter(([k]) => k.startsWith("auto_"))),
...(item.hand ? { hand: true } : { auto: true }),
...amounts(qty, prices),
};
if (qty === null) gaps.push(gap("bill", item.key, row.name, "수량 연결 없음"));
if (prices === null) gaps.push(gap("bill", item.key, row.name, item.why || "단가 연결 없음"));
row._gaps = [];
if (qty === null) row._gaps.push(gap("bill", item.key, row.name, "수량 연결 없음"));
if (prices === null)
row._gaps.push(gap("bill", item.key, row.name, item.why || "단가 연결 없음"));
return row;
}
@@ -133,10 +133,11 @@ export function refPrice(ref: Row | null, books: Row, hopyo: Row, rowKey = ""):
return key ? (hopyo[key] ?? null) : null;
}
/** 손 줄 → [항목, 단가, 까닭] — 연결 = 고른 호표(로직 = 새 산근 키로 풀어 둠) */
/** 새 줄(hand:) → [항목, 단가, 까닭] — 연결 = 고른 호표(로직 = 새 산근 키로 풀어 둠) */
export function handItems(user: Row, books: Row, hopyo: Row): Array<[Row, Row | null, string]> {
const out: Array<[Row, Row | null, string]> = [];
for (const hand of (user.bill || {}).rows || []) {
if (!String(hand.key || "").startsWith("hand:")) continue;
const price = refPrice(hand.ref ?? null, books, hopyo, hand.key);
const ref = price?.tab ? { tab: price.tab, key: price.key } : null;
const why = hand.ref?.tab === "logic" ? WHY_LOGIC : WHY_HAND;
@@ -145,12 +146,51 @@ export function handItems(user: Row, books: Row, hopyo: Row): Array<[Row, Row |
return out;
}
/** 묶음 손 줄(182) — 이름만 · 금액 = 자손 항목 합(`nest`) */
/** 자동 줄 덮개(184) — 고친 칸 = 그 값(null = 빈칸) · auto_{칸} = 자동값 · 연결 덮개 = 그 단가(자재 줄 = 연결 안 받음) */
function covered(
item: Row,
prices: Row | null,
cover: Row | undefined,
books: Row,
hopyo: Row,
): [Row, Row | null] {
if (!cover) return [item, prices];
const out: Row = { ...item };
for (const field of FIELDS) {
if (!(field in cover) || (field === "ref" && item.key.startsWith("mat:"))) continue;
if (field === "qty") out.auto_qty = num(item.qty);
else if (field === "ref") out.auto_ref = item.ref ?? null;
else out[`auto_${field}`] = item[field] || "";
out[field] = cover[field];
}
if ("auto_ref" in out) {
prices = refPrice(cover.ref, books, hopyo, item.key);
const tab = prices?.tab;
out.ref = tab ? { tab, key: prices!.key } : null;
out.note = prices?.no || "";
if (cover.ref?.tab === "logic") out.why = WHY_LOGIC;
}
return [out, prices];
}
/** 기본 공종 제목(자동 묶음) — 이름 덮개만 · 번호는 no(차례 자동) */
function titleRow(code: string, name: string, parent: string | null, cover: Row | undefined): Row {
const row: Row = { kind: "title", key: `sec:${code}`, section: code, name, auto: true };
if (cover && "name" in cover) {
row.auto_name = name;
row.name = cover.name || "";
}
row.parent = parent ? `sec:${parent}` : null;
row._home = row.parent;
return row;
}
/** 새 묶음 줄(182 · 184) — 이름만 · 금액 = 자손 항목 합 */
function groupRow(item: Row): Row {
return {
kind: "group",
key: item.key,
section: item.section,
section: item.section ?? null,
name: item.name || "",
spec: item.spec || "",
unit: item.unit || "",
@@ -158,50 +198,14 @@ function groupRow(item: Row): Row {
note: "",
ref: null,
hand: true,
sum: 0,
labor: 0,
material: 0,
expense: 0,
blank: 0,
};
}
/** 182 레벨 — 부모 = 같은 공종 묶음 손 줄(못 찾거나 돌면 공종 바로 아래) · level · 묶음 금액 */
function nest(rows: Row[]): void {
const groups = new Map<string, Row>();
for (const row of rows) if (row.kind === "group") groups.set(row.key, row);
for (const row of rows) {
if (!row.hand) continue;
const up = groups.get(row.parent ?? "");
if (!up || up.section !== row.section) {
row.parent = null;
continue;
}
let at: Row | undefined = up;
for (let n = 0; at && n <= groups.size; n += 1) {
if (at.key === row.key) {
row.parent = null;
break;
}
at = groups.get(at.parent ?? "");
}
}
for (const row of rows) {
const chain: Row[] = [];
for (let at = groups.get(row.parent ?? ""); at; at = groups.get(at.parent ?? ""))
chain.push(at);
const depth = SECTIONS.find(([c]) => c === row.section)![2] ? 2 : 1;
row.level = row.kind === "title" ? depth : depth + 1 + chain.length;
if (row.kind !== "item") continue;
for (const g of chain) {
for (const p of ["sum", ...PARTS]) g[p] += row[p] || 0;
if (row.sum === null) g.blank += 1;
}
}
for (const g of groups.values()) g.note = g.blank ? `빈 줄 ${g.blank}` : "";
function handRow(item: Row, prices: Row | null, why: string): Row {
return item.kind === "group" ? groupRow(item) : itemRow({ ...item, why }, prices);
}
/** 손 줄 끼우기 — after 줄 바로 뒤 · 제목(sec:) = 그 공종 첫 자리 · 못 찾으면 끝 */
/** 옛 꼴 손 줄 끼우기 — after 줄 바로 뒤 · 제목(sec:) = 그 공종 첫 자리 · 못 찾으면 끝 */
function place(items: Row[], hand: Row, after: string): void {
let at = items.findIndex((row) => row.key === after);
if (at >= 0) at += 1;
@@ -209,12 +213,13 @@ function place(items: Row[], hand: Row, after: string): void {
items.splice(at < 0 ? items.length : at, 0, hand);
}
/** 구조물 열 → 대가 블록마다 한 줄(수량 = 그 블록 주인 값 합 ROUND 3 · 대가 없는 주인 = 한 줄) */
/** 구조물 열 → 대가 블록마다 한 줄(수량 = 그 블록 주인 값 합 ROUND 3 · 대가 없는 주인 = 한 줄) ·
* 줄 키 꼬리 = 대가 블록 키(184 — 대가 번호가 밀려도 덮개가 같은 줄) */
export function splitByBlock(item: Row, priceOf: PriceOf): Array<[Row, Row | null]> {
const groups = new Map<string, Row>();
for (const [owner, value] of Object.entries(item.owners || {})) {
const price = priceOf(owner);
const tag = price ? String(price.no || "") : "";
const tag = price ? String(price.key || price.no || "") : "";
if (!groups.has(tag)) groups.set(tag, { qty: 0, price, owners: [] });
const group = groups.get(tag)!;
group.qty += num(value) || 0;
@@ -222,11 +227,12 @@ export function splitByBlock(item: Row, priceOf: PriceOf): Array<[Row, Row | nul
}
const out: Array<[Row, Row | null]> = [];
for (const [tag, group] of groups) {
const no = String(group.price?.no || "");
const part: Row = {
...item,
key: tag ? `${item.key}#${tag}` : item.key,
qty: roundHalf(group.qty, 3),
note: [tag, item.note || ""].filter((t) => t).join(" · "),
note: [no, item.note || ""].filter((t) => t).join(" · "),
owners: Object.fromEntries(group.owners.map((o: string) => [o, item.owners[o]])),
};
// 대가 블록(145-2) — 이동 · 집계표 역전개
@@ -236,6 +242,121 @@ export function splitByBlock(item: Row, priceOf: PriceOf): Array<[Row, Row | nul
return out;
}
type Hands = Array<[Row, Row | null, string]>;
/** 옛 꼴(166-2 · 182) — 손 줄 = after 뒤 · 부모 = 같은 공종 손 묶음(아니면 공종 제목) */
function orderOld(_bill: Row, titles: Row[], items: Row[], hands: Hands): Row[] {
const groups = new Map<string, Row>();
for (const [item] of hands) if (item.kind === "group") groups.set(item.key, item);
for (const [item, prices, why] of hands) {
const row = handRow(item, prices, why);
const up = groups.get(item.parent ?? "");
row.parent = up && (up.section ?? null) === (item.section ?? null) ? up.key : null;
place(items, row, item.after || "");
}
const out: Row[] = [];
for (const title of titles) {
out.push(title);
for (const row of items) {
if (row.section !== title.section) continue;
row._home = title.key;
row.parent = row.parent || title.key;
out.push(row);
}
}
return out;
}
/** 184 v2 — 저장 차례 · 부모 · gone 뺌 · 저장에 없는 자동 줄 = 기본 부모 끝 「새 자동 줄」 */
function orderV2(bill: Row, titles: Row[], items: Row[], hands: Hands): Row[] {
const auto = new Map<string, Row>();
for (const row of titles) auto.set(row.key, row);
for (const row of items) {
row._home = `sec:${row.section}`;
auto.set(row.key, row);
}
const gone = new Set<string>(
(bill.gone || []).filter((k: string) => k !== "sec:G" && !k.startsWith("mat:")),
);
const fresh = new Map<string, [Row, Row | null, string]>();
for (const hand of hands) fresh.set(hand[0].key, hand);
const out: Row[] = [];
const seen = new Set<string>();
for (const saved of bill.rows || []) {
const key = saved.key;
if (seen.has(key)) continue;
let row: Row;
if (fresh.has(key)) {
row = handRow(...fresh.get(key)!);
row._home = null;
} else if (auto.has(key) && !gone.has(key)) row = auto.get(key)!;
else continue;
seen.add(key);
row.parent = saved.parent ?? null;
out.push(row);
}
for (const row of [...titles, ...items]) {
if (seen.has(row.key) || gone.has(row.key)) continue;
row.parent = row._home;
if (row.kind === "item") row.note = [row.note, NEW_AUTO].filter((t) => t).join(" · ");
out.push(row);
}
return out;
}
/** 부모 바로잡기(없음 · 항목 · 돎 = 기본 부모 / 맨 위 · sec:G 맨 위 · 관급 자재 줄 = sec:G 안) →
* 표 차례(부모 먼저 · 형제 = 저장 차례) · level · cost · section(깊이 5 넘음 = 레벨 4 조상 아래) */
function tree(order: Row[]): Row[] {
const byKey = new Map<string, Row>();
for (const row of order) byKey.set(row.key, row);
const home = (row: Row) => (row._home != null && byKey.has(row._home) ? row._home : null);
const loops = (row: Row): boolean => {
let at = byKey.get(row.parent ?? "");
for (let n = 0; at && n <= order.length; n += 1) {
if (at === row) return true;
at = byKey.get(at.parent ?? "");
}
return false;
};
const root = (row: Row): Row => {
while (row.parent) row = byKey.get(row.parent)!;
return row;
};
for (const row of order) {
const up = byKey.get(row.parent ?? "");
if (row.key === "sec:G") row.parent = null;
else if (!up || up.kind === "item" || up === row) row.parent = home(row);
}
for (const row of order) {
if (!loops(row)) continue;
row.parent = home(row);
if (loops(row)) row.parent = null;
}
for (const row of order)
if (row.key.startsWith("mat:") && row._home === "sec:G" && root(row).key !== "sec:G")
row.parent = "sec:G";
const kids = new Map<string | null, Row[]>();
for (const row of order) {
if (!kids.has(row.parent)) kids.set(row.parent, []);
kids.get(row.parent)!.push(row);
}
const rows: Row[] = [];
const stack: Array<[Row, Row[]]> = [...(kids.get(null) || [])].reverse().map((r) => [r, []]);
while (stack.length) {
let [row, chain] = stack.pop()!;
if (chain.length >= DEPTH) {
chain = chain.slice(0, DEPTH - 1);
row.parent = chain[chain.length - 1].key;
}
row.level = chain.length + 1;
row.cost = (chain.length ? chain[0] : row).key === "sec:G" ? "supply" : "direct";
if (row.kind !== "title") row.section = chain.length ? chain[chain.length - 1].section : null;
rows.push(row);
for (const kid of [...(kids.get(row.key) || [])].reverse()) stack.push([kid, [...chain, row]]);
}
return rows;
}
export function computeBill(
inputs: Row,
user: Row,
@@ -243,31 +364,32 @@ export function computeBill(
priceOf: PriceOf | null = null,
books: Row | null = null,
): Row {
const gaps: Gap[] = [];
const hopyo: Row = inputs.hopyo || {};
const book: Row = books || {};
const qtyOf: Row = (user.bill || {}).qty || {};
const bill: Row = user.bill || {};
const v2 = bill.v === 2;
const cover: Record<string, Row> = {};
if (v2) {
for (const r of bill.rows || []) if (!r.key.startsWith("hand:")) cover[r.key] = r;
} else for (const [k, v] of Object.entries(bill.qty || {})) cover[k] = { qty: v };
const items: Row[] = [];
for (const item of (inputs.bill || {}).items || []) {
if (priceOf !== null && item.owners && Object.keys(item.owners).length) {
for (const [part, prices] of splitByBlock(item, priceOf))
items.push(itemRow(part, prices, gaps, qtyOf));
continue;
}
const prices = refPrice(item.ref ?? null, book, hopyo);
items.push(itemRow(item, prices, gaps, qtyOf));
const parts: Array<[Row, Row | null]> =
priceOf !== null && item.owners && Object.keys(item.owners).length
? splitByBlock(item, priceOf)
: [[item, refPrice(item.ref ?? null, book, hopyo)]];
for (const [part, prices] of parts)
items.push(itemRow(...covered(part, prices, cover[part.key], book, hopyo)));
}
for (const item of materialItems(inputs, base.compare)) {
const price: Num = item._price;
const prices = price === null ? null : { labor: 0, material: price, expense: 0 };
items.push(itemRow(item, prices, gaps, qtyOf));
items.push(itemRow(...covered(item, prices, cover[item.key], book, hopyo)));
}
for (const [item, prices, why] of handItems(user, book, hopyo)) {
const row = item.kind === "group" ? groupRow(item) : itemRow({ ...item, why }, prices, gaps);
row.parent = item.parent ?? null;
place(items, row, item.after || "");
}
const owner = items.filter((row) => row.section === "G" && row.kind === "item");
const titles = SECTIONS.map(([c, n, p]) => titleRow(c, n, p, cover[`sec:${c}`]));
const hands = handItems(user, book, hopyo);
const rows = tree((v2 ? orderV2 : orderOld)(bill, titles, items, hands));
const owner = rows.filter((row) => row.kind === "item" && row.cost === "supply");
let net = 0;
for (const row of owner) net += row.material || 0;
const pct = num((user.estimate || {}).procurement_fee_pct);
@@ -282,6 +404,7 @@ export function computeBill(
qty: pct,
note: "관급 순자재대 밑수",
ref: null,
auto: true,
price_labor: 0,
price_material: net,
price_expense: 0,
@@ -290,48 +413,52 @@ export function computeBill(
material: fee,
expense: pct !== null ? 0 : null,
sum: fee,
parent: "sec:G",
level: 2,
cost: "supply",
_gaps: pct === null ? [gap("bill", "fee", "조달수수료", "조달수수료율 입력 없음")] : [],
};
if (owner.length) {
items.push(feeRow);
if (pct === null) gaps.push(gap("bill", "fee", "조달수수료", "조달수수료율 입력 없음"));
// 조달수수료 = sec:G 끝 고정
let at = rows.findIndex((row) => row.key === "sec:G") + 1;
while (at < rows.length && rows[at].level > 1) at += 1;
rows.splice(at, 0, feeRow);
}
const sums: Record<string, Row> = {};
for (const [code] of SECTIONS)
sums[code] = { sum: 0, labor: 0, material: 0, expense: 0, blank: 0 };
for (const row of items) {
if (row.kind !== "item") continue;
const code = row.section;
const chain = [code];
const parent = SECTIONS.find(([c]) => c === code)![2];
if (parent) chain.push(parent);
for (const c of chain) {
for (const p of ["sum", ...PARTS]) sums[c][p] += row[p] || 0;
if (row.sum === null) sums[c].blank += 1;
}
}
const rows: Row[] = [];
for (const [code, name] of SECTIONS) {
const s = sums[code];
rows.push({
kind: "title",
key: `sec:${code}`,
section: code,
name: code !== "G" ? `${code} ${name}` : name,
sum: s.sum,
labor: s.labor,
material: s.material,
expense: s.expense,
blank: s.blank,
note: (code === "G" ? "(제외금액)" : "") + (s.blank ? ` 빈 줄 ${s.blank}` : ""),
});
rows.push(...items.filter((row) => row.section === code));
}
nest(rows);
const byKey = new Map<string, Row>();
for (const row of rows) byKey.set(row.key, row);
for (const row of rows)
if (row.kind !== "item")
Object.assign(row, { sum: 0, labor: 0, material: 0, expense: 0, blank: 0 });
const direct: Row = { sum: 0, labor: 0, material: 0, expense: 0 };
let blank = 0;
for (const code of DIRECT) {
for (const p of Object.keys(direct)) direct[p] += sums[code][p];
blank += sums[code].blank;
for (const row of rows) {
if (row.kind !== "item") continue;
for (let at = byKey.get(row.parent ?? ""); at; at = byKey.get(at.parent ?? "")) {
for (const p of ["sum", ...PARTS]) at[p] += row[p] || 0;
if (row.sum === null) at.blank += 1;
}
if (row.cost === "direct") {
for (const p of Object.keys(direct)) direct[p] += row[p] || 0;
if (row.sum === null) blank += 1;
}
}
const gaps: Gap[] = [];
const seq = new Map<string | null, number>();
for (const row of rows) {
gaps.push(...(row._gaps || []));
delete row._gaps;
delete row._home;
row.no = "";
if (row.kind === "title")
row.note =
(row.key === "sec:G" ? "(제외금액)" : "") + (row.blank ? ` 빈 줄 ${row.blank}` : "");
else if (row.kind === "group") row.note = row.blank ? `빈 줄 ${row.blank}` : "";
if (row.kind !== "item" && row.cost === "direct" && row.level <= 2) {
// 번호 = 레벨 1 · 2 묶음 차례(1 · 1.1 · 관급 없음)
const up = row.parent;
seq.set(up, (seq.get(up) || 0) + 1);
row.no = up ? `${byKey.get(up)!.no}.${seq.get(up)}` : String(seq.get(up));
}
}
const totals = {
direct: { ...direct, blank },