Files
Aislo/B09_Estimation/B09_Estimation_Sangeun_Cover.ts
T
eomsangdonandClaude Opus 5.5 51f07499f5 feat(719-4 · 719-3): B09 단가산출근거 탭 = 공용 계산서 카드 · 이 공사 산근 덮개
- 719-4 근거 탭 = 산근 블록마다 카드 · 공용 calcSheetRows 묶음 꼴(인력 · 재료 · 경비(장비 한 줄 + 중기사용료 펼침 넉 줄) · 하위 = 산근 n호표 한 줄) · 서버 줄 덧칸 sheet{쓰는입력 · 기준단위 · 환산근거}(master_sheet_line 한 벌) · 블록 기준단위 · 입력 · 중간값 글은 고치기 펼침 때 · 옛 풀이표는 곁 창에만
- 719-3 산근 덮개 sg_cover {산근 키: {「n:이름」: {qty · 노 · 재 · 경 단가}}} · 셈 짝 B09_Estimation_Sangeun_Cover.py ↔ .ts(서버 풀이 뒤 덧씌움 · 하위 산근 이어짐 · % 덧줄 밑수 비례 · 총계 · 목록표 · 빈칸 목록 · raw 로 두 번 덮어도 같음 · 짝 잃음 sg_orphans 알림)
- 고침 표(702 꼴) = 품 · 단가 두 번 눌러 고침 · 고친 칸 색 · 원래 값 칸 · 「이 공사에서 고침」 꼬리표 · 우클릭 · 카드 단추 [단가산출 값으로] · 고친 뒤 보던 자리 지킴
- 저장 다듬기 sg_cover · [업데이트] 알림 고친 칸 수에 산근 칸 · 셈 길(compute_sheets) · 화면 짝(computeSheets) 둘 다 덮개
- 금액 전후 같음 — 덮개 없음 = 서버 풀이 그대로 · 덧칸 뺀 옛 풀이와 13 블록 같음 · 일위대가 합 검증_랩탑메인 13,662,560 · 검증_sub5 10,149,562 그대로
- 시험 새로 test_719_3_sangeun_cover(8 · 거울 · 대가 · 원가계산서 · 업데이트 뒤 지킴) · test_719_4_sangeun_sheet(2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 17:28:11 +09:00

186 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* B09_Estimation_Sangeun_Cover.ts
* B09 산근 덮개(PLAN 719-3) — 이 공사 단가산출근거 고친 칸(품 · 단가)을 서버 풀이 뒤에 덧씌움.
*
* ⚠ 짝 = `B09_Estimation_Sangeun_Cover.py`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
* 거울 시험 `resources/tester/test_719_3_sangeun_cover.py`.
* 조작 중 = 이 파일이 바로 셈 · [저장] = 서버가 다시 풀이(`Sangeun.build`) 뒤 파이썬 짝으로 정본.
*
* 덮개 = 사용자 값 `sg_cover` {산근 키: {줄 열쇠 「n:이름」: {qty?, labor?, material?, expense?}}} —
* 있는 칸만 셈 값 대신 · 줄이 없거나 이름이 다르면 짝 잃음(`sg_orphans` · 알림만)
* 고칠 칸(`fields`) = 노무 · 자재 · 중기 · 빈 줄 = 품 + 쓰는 비목 단가 · 하위 산근 = 품 · % 덧줄(`of`) = 율
* 다시 셈 = 덮개가 닿은 블록과 그 블록을 부르는 블록만 — 줄 금액 ROUNDDOWN(단가 × 품, 1) ·
* 하위 산근 줄 단가 = 다시 센 그 블록 비목 합 · % 덧줄 = 서버 값 × 밑수 비 × 율 비(처음이 0 · 빈칸이면
* 밑수 × 율 ÷ 100) · 총계 = 비목마다 ROUNDDOWN(Σ, 0) · 목록표 · 빈칸 목록 같이
* · 율 식 밖 덧줄(`of` 없음) = 서버 값 그대로
* 고친 줄 = `base`(덮기 전 값) · `covered`(고친 칸) · 다시 센 블록 · 목록 줄 `raw` = 덮기 전 값
* (두 번 덮어도 같음 — 서버 답이 덮인 채 오면 벗기고 다시 덮음)
* ========================================================================== */
import { gap, num, rounddown, total, type Num, type Row } from "./B09_Estimation_Calc_Base";
const PARTS = ["labor", "material", "expense"] as const;
export const SG_COVER_KEYS = ["qty", ...PARTS] as const;
const DEPTH = 8;
/** 덮개 줄 열쇠 — 블록 안 차례 + 이름 */
export const lineId = (n: number, line: Row): string => `${n}:${line.name || ""}`;
/** 고칠 수 있는 칸 — 품(qty) + 단가 비목 */
export function fields(line: Row): string[] {
const kind = line.kind;
if (kind === "sangeun") return ["qty"];
if (kind === "extra") return line.of ? ["qty"] : [];
if (kind === "machine") return ["qty", ...PARTS];
if (kind === "labor") return ["qty", "labor"];
if (kind === "material") return ["qty", ...PARTS.filter((p) => p in (line.price || {}))];
if (kind === "blank") return ["qty", ...PARTS.filter((p) => (line[p] ?? null) === null)];
return [];
}
const money = (price: Num | undefined, qty: Num | undefined): Num =>
price === null || price === undefined || qty === null || qty === undefined
? null
: rounddown(price * qty, 1);
function baseOf(lines: Row[], n: number, of: string): Num {
if (of === "people")
return total(
lines
.slice(0, n)
.filter((l) => l.kind === "labor")
.map((l) => l.labor ?? null),
);
return total(lines.slice(0, n).map((l) => l[of] ?? null));
}
function settle(block: Row, mine: Row, by: Map<string, Row>): void {
const first: Row[] = JSON.parse(JSON.stringify(block.lines));
(block.lines as Row[]).forEach((ln, n) => {
const cell: Row = mine[lineId(n, ln)] || {};
const can = fields(ln);
const keys = can.filter((k) => num(cell[k]) !== null);
const qty0: Num = ln.qty ?? null;
const price0: Row = { ...(ln.price || {}) };
if (keys.length) {
ln.base = {
qty: qty0,
...Object.fromEntries(can.filter((p) => p !== "qty").map((p) => [p, price0[p] ?? null])),
};
ln.covered = keys;
}
const qty: Num = keys.includes("qty") ? num(cell.qty) : qty0;
if (ln.kind === "sangeun") {
const sub: Row = by.get((ln.ref || {}).key) || {};
const price = Object.fromEntries(PARTS.map((p) => [p, sub[p] ?? null]));
Object.assign(ln, { qty, price }, ...PARTS.map((p) => ({ [p]: money(price[p], qty) })));
ln.why = (sub.sum ?? null) !== null ? null : `하위 산근 ${sub.no ?? ""} 빈칸`;
} else if (ln.kind === "extra") {
const of: string | undefined = ln.of;
const part =
Object.keys(price0)[0] ?? PARTS.find((p) => (ln[p] ?? null) === null) ?? undefined;
if (!of || part === undefined) return;
const now = baseOf(block.lines, n, of);
const then = baseOf(first, n, of);
const value0: Num = price0[part] ?? null;
let value: Num;
if (now === null || qty === null) value = null;
else if (value0 !== null && then && qty0)
value = value0 * (now / then) * ((qty as number) / qty0);
else value = (now * (qty as number)) / 100;
Object.assign(ln, { qty, price: { [part]: value }, [part]: money(value, 1) });
ln.why = value !== null ? null : "밑수 빈칸";
} else {
const price: Row = {
...price0,
...Object.fromEntries(keys.filter((p) => p !== "qty").map((p) => [p, num(cell[p])])),
};
for (const p of can) if (p !== "qty") ln[p] = money(price[p] ?? null, qty);
Object.assign(ln, { qty, price });
if (PARTS.every((p) => (ln[p] ?? null) !== null)) ln.why = null;
}
});
for (const part of PARTS) {
const s = total((block.lines as Row[]).map((l) => l[part] ?? null));
block[part] = s === null ? null : rounddown(s, 0);
}
block.sum = total(PARTS.map((p) => block[p]));
block.why = (block.lines as Row[]).find((l) => l.why)?.why ?? null;
}
function orphansOf(by: Map<string, Row>, cover: Row): Row[] {
const out: Row[] = [];
for (const [key, lines] of Object.entries(cover)) {
const block = by.get(key);
const ids = lines && typeof lines === "object" ? Object.keys(lines) : [];
for (const lid of ids) {
const at = lid.indexOf(":");
const n = at < 0 ? lid : lid.slice(0, at);
const name = at < 0 ? "" : lid.slice(at + 1);
const ok = /^\d+$/.test(n) && block && Number(n) < block.lines.length;
const ln: Row | null = ok ? block!.lines[Number(n)] : null;
if (!ln || (ln.name || "") !== name || !fields(ln).length) out.push({ key, line: lid });
}
}
return out;
}
/** 산근 답(sg · sg_list · sg_gaps …) + 덮개 → 새 답(받은 답은 안 고침) · 덮개 없으면 받은 값 그대로 */
const bare = (row: Row): Row => {
if (!("raw" in row)) return row;
const { raw, covered: _covered, ...rest } = row;
return { ...rest, ...raw };
};
const gapsOf = (blocks: Row[]): Row[] =>
blocks
.filter((b) => b.sum === null)
.map((b) => gap("sg", b.key, `산근 ${b.no} ${b.name}`, b.why || "단가 빈칸"));
/** 이미 덮인 답(서버 [저장] · 셈 답) = 블록 · 목록 줄 `raw`(덮기 전 값)로 벗긴 뒤 다시 덮음 */
export function coverSangeun(sg: Row, cover: unknown): Row {
const covers: Row = cover && typeof cover === "object" ? (cover as Row) : {};
const worn: Row[] = sg.sg || [];
const blocks: Row[] = worn.map((b) => ("raw" in b ? b.raw : b));
const orphans = orphansOf(new Map(blocks.map((b) => [b.key, b])), covers);
if (!blocks.some((b) => covers[b.key] && Object.keys(covers[b.key]).length)) {
if (!worn.some((b) => "raw" in b)) return { ...sg, sg_orphans: orphans };
const rows = ((sg.sg_list || []) as Row[]).map(bare);
return { ...sg, sg: blocks, sg_list: rows, sg_gaps: gapsOf(blocks), sg_orphans: orphans };
}
const out: Row[] = JSON.parse(JSON.stringify(blocks));
const by = new Map(out.map((b) => [b.key as string, b]));
const hit = new Map<string, boolean>();
const touch = (key: string, depth: number): boolean => {
if (hit.has(key)) return hit.get(key)!;
const block = by.get(key);
if (!block || depth > DEPTH) return false;
hit.set(key, false); // 돎 막음
const subs = (block.lines as Row[])
.filter((l) => l.kind === "sangeun" && (l.ref || {}).key)
.map((l) => touch(l.ref.key, depth + 1));
const mine: Row = covers[key] || {};
const got = Object.keys(mine).length > 0 || subs.some(Boolean);
hit.set(key, got);
if (got) {
const raw = JSON.parse(JSON.stringify(block));
settle(block, mine, by);
block.raw = raw;
}
return got;
};
for (const b of out) touch(b.key, 0);
const listing = ((sg.sg_list || []) as Row[]).map(bare).map((row) => {
const b = by.get(row.key);
if (!b || !hit.get(row.key)) return row;
const n = (b.lines as Row[]).filter((l) => l.covered).length;
return {
...row,
...Object.fromEntries([...PARTS, "sum"].map((k) => [k, b[k]])),
...(n ? { covered: n } : {}),
raw: Object.fromEntries([...PARTS, "sum"].map((k) => [k, row[k] ?? null])),
};
});
return { ...sg, sg: out, sg_list: listing, sg_gaps: gapsOf(out), sg_orphans: orphans };
}