- 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>
186 lines
8.6 KiB
TypeScript
186 lines
8.6 KiB
TypeScript
/* =============================================================================
|
||
* 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 };
|
||
}
|