feat(717-2b): M02 단가산출 상세 · 고침 — M01 상세 부품(보기 넷)을 M02 길로 · 품 · 단가 칸 덮개 · 줄 더하기 · 빼기 · 바탕 M01 로직 고름
- 새 Sangeun_Detail(mountSangeunDetail) · Sangeun_Cover(고침 표 · 셈 길 최신 응답만) · logic/M02_Sangeun_Cover_Model(순수 꼴) - 고친 칸 = 칸 색 + 바탕 값 곁 글 + 「단가산출에서 고침」 · M01 개정 배지(바탕판 · 원값) · 짝잃음 띠 · 별도계상 재료 = 계 밖 표기 - M01 buildLabDetail 덧칸 cover(단가산출 상세 · 인력·재료·경비 덩이 갈음) · 마스터 이동 단추 = 등록 없으면 숨김(hasMasterJump) - Sangeun_View 자리표 · 옛 글 열쇠(준비 중 · 덮개 · 더한 줄 · 열린 입력) 걷음 - 시험 test_717_2b_sangeun_detail 3 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete/logic/M02_Sangeun_Cover_Model.ts
|
||||
* 단가산출(SK) 고침 꼴(PLAN 717-2b · 717-3 같이 씀) — 서버 셈 답(`/layers/{층}/sangeun/calc`) → 표 줄 ·
|
||||
* 고친 칸 → SK 문서 덮개 · 더한줄 · 뺀줄(꼴 = 서버 `M02_Sangeun_Doc`). 금액은 늘 서버가 셈 —
|
||||
* 여기는 줄 모양 · 문서 칸 옮김만(값 셈 없음 · 로직 줄 단가를 노 · 재 · 경 몫 비로 나누는 것만).
|
||||
* 줄 정체 cid = `kind:키#n`(바탕 줄) · `add:#k`(더한줄 k 째) · 덮개 원값 = 저장 때 서버가 다시 적음
|
||||
* (화면은 고친 첫때 바탕 값을 적어 둠 — 곁 글용) · 열린입력 있는 SK = 품은 배율만
|
||||
* 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { SheetGroup } from "../../A00_Common/calc_sheet";
|
||||
|
||||
export type CoverPart = "labor" | "material" | "expense";
|
||||
export const PART_OF: Record<string, CoverPart> = {
|
||||
노무비: "labor",
|
||||
재료비: "material",
|
||||
경비: "expense",
|
||||
};
|
||||
const PARTS: CoverPart[] = ["labor", "material", "expense"];
|
||||
|
||||
export interface SkCover {
|
||||
qty?: number;
|
||||
배율?: number;
|
||||
labor?: number;
|
||||
material?: number;
|
||||
expense?: number;
|
||||
원값?: { qty?: number; 단가?: number };
|
||||
}
|
||||
|
||||
export interface SkAdded {
|
||||
종류?: string;
|
||||
수량: number;
|
||||
단가?: number;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
/** SK 문서 중 고침이 만지는 칸 */
|
||||
export interface SkDoc {
|
||||
키?: string;
|
||||
이름?: string;
|
||||
단위?: string;
|
||||
바탕?: { 로직?: string; 참조?: { 층?: string; 주인?: string | null; 키?: string } } | null;
|
||||
입력값?: Record<string, unknown>;
|
||||
열린입력?: string[];
|
||||
덮개?: Record<string, SkCover>;
|
||||
더한줄?: SkAdded[];
|
||||
뺀줄?: string[];
|
||||
고름?: Record<string, string>;
|
||||
자재표시?: Record<string, unknown>;
|
||||
바탕판?: string;
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SkLine {
|
||||
이름: string;
|
||||
단위?: string;
|
||||
수량: number | null;
|
||||
단가: number | null;
|
||||
금액: number | null;
|
||||
비목?: Record<string, number | null>;
|
||||
묶음?: string | null;
|
||||
cid: string;
|
||||
율?: number;
|
||||
}
|
||||
|
||||
export interface SkMat {
|
||||
차례: number;
|
||||
이름: string;
|
||||
단위?: string;
|
||||
수량: number | null;
|
||||
단가: number | null;
|
||||
금액: number | null;
|
||||
}
|
||||
|
||||
export interface SkAnswer {
|
||||
기준: Record<string, number> | null;
|
||||
줄: SkLine[];
|
||||
재료?: SkMat[];
|
||||
까닭?: string | null;
|
||||
정체: string[];
|
||||
짝잃음: { cid: string; 까닭: string; 바탕?: string }[];
|
||||
바탕판: string;
|
||||
머리?: string;
|
||||
}
|
||||
|
||||
export interface CoverRow {
|
||||
cid: string;
|
||||
묶음: string;
|
||||
이름: string;
|
||||
단위: string;
|
||||
수량: number | null;
|
||||
단가: number | null;
|
||||
금액: number | null;
|
||||
/** 비목 몫 금액 */
|
||||
parts: Record<string, number>;
|
||||
/** % 덧줄 — 수량 = 율 · 단가 못 고침 */
|
||||
pct: boolean;
|
||||
/** 더한줄 차례 · 바탕 줄 = null */
|
||||
added: number | null;
|
||||
/** 기준 밖 재료(일위대가 자재목록으로 셈) — 금액이 계에 안 듦 */
|
||||
apart: boolean;
|
||||
covered: { qty: boolean; price: boolean };
|
||||
/** 덮개 배율(열린입력 SK) */
|
||||
배율: number | null;
|
||||
/** 바탕 값(덮개 · 더한줄 · 뺀줄 없이 셈) · 없으면 null */
|
||||
was: { qty: number | null; 단가: number | null } | null;
|
||||
/** 저장 때 적힌 원값이 지금 바탕 값과 다름(M01 개정) */
|
||||
revised: boolean;
|
||||
priceEdit: boolean;
|
||||
}
|
||||
|
||||
/* ── 순수 셈 ── */
|
||||
const BUNDLE_OF: Record<string, string> = { 노무비: "인력", 재료비: "재료", 경비: "경비" };
|
||||
const OWN_PART: Record<string, string> = { 인력: "노무비", 재료: "재료비" };
|
||||
const ADD_RE = /^add:#(\d+)$/;
|
||||
const COVER_KEYS = ["qty", "배율", ...PARTS] as const;
|
||||
|
||||
const partsOf = (l: { 비목?: Record<string, number | null> }): Record<string, number> =>
|
||||
Object.fromEntries(Object.entries(l.비목 ?? {}).map(([k, v]) => [k, v ?? 0]));
|
||||
|
||||
/** 바탕 값 — 바탕 셈(덮개 · 더한줄 · 뺀줄 없이)의 줄마다 품(덧줄 = 율) · 단가 */
|
||||
export function baseValues(
|
||||
bare: SkAnswer | null,
|
||||
): Map<string, { qty: number | null; 단가: number | null }> {
|
||||
const out = new Map<string, { qty: number | null; 단가: number | null }>();
|
||||
if (!bare) return out;
|
||||
for (const l of bare.줄) out.set(l.cid, { qty: l.율 ?? l.수량, 단가: l.단가 });
|
||||
for (const m of bare.재료 ?? []) {
|
||||
const cid = bare.정체[m.차례];
|
||||
if (cid && !out.has(cid)) out.set(cid, { qty: m.수량, 단가: m.단가 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const near = (a: number | null | undefined, b: number | null | undefined): boolean =>
|
||||
a != null && b != null && Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(a), Math.abs(b));
|
||||
|
||||
/** 셈 답 → 표 줄(엔진 줄 + 기준 밖 재료) */
|
||||
export function rowsOf(ans: SkAnswer, bare: SkAnswer | null, doc: SkDoc): CoverRow[] {
|
||||
const was = baseValues(bare);
|
||||
const rows: CoverRow[] = [];
|
||||
const row = (
|
||||
cid: string,
|
||||
l: {
|
||||
이름: string;
|
||||
단위?: string;
|
||||
수량: number | null;
|
||||
단가: number | null;
|
||||
금액: number | null;
|
||||
},
|
||||
parts: Record<string, number>,
|
||||
extra: { 묶음?: string | null; 율?: number; apart?: boolean },
|
||||
): CoverRow => {
|
||||
const hit = ADD_RE.exec(cid);
|
||||
const added = hit ? Number(hit[1]) : null;
|
||||
const one = added === null ? undefined : doc.더한줄?.[added];
|
||||
const cover = added === null ? doc.덮개?.[cid] : undefined;
|
||||
const pct = cid.startsWith("pct:");
|
||||
const keys = Object.keys(parts);
|
||||
const base = added === null ? (was.get(cid) ?? null) : null;
|
||||
const old = cover?.원값;
|
||||
return {
|
||||
cid,
|
||||
묶음: extra.묶음 || BUNDLE_OF[keys[0] ?? ""] || "하위",
|
||||
이름: l.이름,
|
||||
단위: pct ? "%" : (l.단위 ?? ""),
|
||||
수량: pct ? (extra.율 ?? l.수량) : l.수량,
|
||||
단가: pct ? null : l.단가,
|
||||
금액: l.금액,
|
||||
parts,
|
||||
pct,
|
||||
added,
|
||||
apart: !!extra.apart,
|
||||
covered: {
|
||||
qty: !!cover && ("qty" in cover || "배율" in cover),
|
||||
price: added === null ? PARTS.some((k) => cover?.[k] != null) : one?.단가 != null,
|
||||
},
|
||||
배율: cover?.배율 ?? null,
|
||||
was: base,
|
||||
revised:
|
||||
!!old &&
|
||||
!!base &&
|
||||
((old.qty != null && !near(old.qty, base.qty)) ||
|
||||
(old.단가 != null && !near(old.단가, base.단가))),
|
||||
priceEdit: !pct && (added === null || ["인력", "재료", "기계"].includes(String(one?.종류))),
|
||||
};
|
||||
};
|
||||
for (const l of ans.줄) rows.push(row(l.cid, l, partsOf(l), l));
|
||||
const seen = new Set(rows.map((r) => r.cid));
|
||||
for (const m of ans.재료 ?? []) {
|
||||
const cid = ans.정체[m.차례];
|
||||
if (!cid || seen.has(cid)) continue;
|
||||
rows.push(row(cid, m, { 재료비: m.금액 ?? 0 }, { 묶음: "재료", apart: true }));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const memo = (base: CoverRow["was"]): SkCover["원값"] | undefined => {
|
||||
if (!base) return undefined;
|
||||
const out: { qty?: number; 단가?: number } = {};
|
||||
if (base.qty != null) out.qty = base.qty;
|
||||
if (base.단가 != null) out.단가 = base.단가;
|
||||
return Object.keys(out).length ? out : undefined;
|
||||
};
|
||||
|
||||
function putCover(doc: SkDoc, r: CoverRow, cover: SkCover): void {
|
||||
const covers = { ...(doc.덮개 ?? {}) };
|
||||
if (COVER_KEYS.some((k) => cover[k] != null)) {
|
||||
const old = cover.원값 ?? memo(r.was);
|
||||
covers[r.cid] = { ...cover, ...(old ? { 원값: old } : {}) };
|
||||
} else delete covers[r.cid];
|
||||
doc.덮개 = covers;
|
||||
}
|
||||
|
||||
/** 품 고침 — 더한줄 = 그 줄 수량 · 바탕 줄 = 덮개 qty(열린입력 SK = 배율 · 1 = 지움) · 바탕 값과 같거나 빈 값 = 덮개 지움 */
|
||||
export function setQty(doc: SkDoc, r: CoverRow, v: number | null, opened: boolean): void {
|
||||
if (r.added !== null) {
|
||||
const one = doc.더한줄?.[r.added];
|
||||
if (one && v !== null) one.수량 = v;
|
||||
return;
|
||||
}
|
||||
const cover: SkCover = { ...(doc.덮개?.[r.cid] ?? {}) };
|
||||
delete cover.qty;
|
||||
delete cover.배율;
|
||||
if (v !== null && opened && v !== 1) cover.배율 = v;
|
||||
if (v !== null && !opened && !near(v, r.was?.qty)) cover.qty = v;
|
||||
putCover(doc, r, cover);
|
||||
}
|
||||
|
||||
/** 단가 고침 — 요소 줄 = 그 비목 칸 · 로직 줄 = 노 · 재 · 경 몫 비로 나눔 · 더한 요소 줄 = 그 줄 단가 */
|
||||
export function setPrice(doc: SkDoc, r: CoverRow, v: number | null): void {
|
||||
if (!r.priceEdit) return;
|
||||
if (r.added !== null) {
|
||||
const one = doc.더한줄?.[r.added];
|
||||
if (!one) return;
|
||||
if (v === null) delete one.단가;
|
||||
else one.단가 = v;
|
||||
return;
|
||||
}
|
||||
const cover: SkCover = { ...(doc.덮개?.[r.cid] ?? {}) };
|
||||
for (const k of PARTS) delete cover[k];
|
||||
if (v !== null && !near(v, r.was?.단가)) {
|
||||
const keys = Object.keys(r.parts).filter((k) => PART_OF[k]);
|
||||
const sum = keys.reduce((s, k) => s + r.parts[k], 0);
|
||||
const own = PART_OF[keys[0] ?? ""] ?? PART_OF[OWN_PART[r.묶음] ?? "경비"];
|
||||
if (keys.length === 1 || !sum) cover[own] = v;
|
||||
else for (const k of keys) cover[PART_OF[k]] = Math.round(((v * r.parts[k]) / sum) * 1e4) / 1e4;
|
||||
}
|
||||
putCover(doc, r, cover);
|
||||
}
|
||||
|
||||
/** 줄 빼기 — 더한줄 = 그 줄 지움 · 바탕 줄 = 뺀줄에 cid(덮개 · 고름 · 자재표시도 지움) */
|
||||
export function dropLine(doc: SkDoc, r: CoverRow): void {
|
||||
if (r.added !== null) {
|
||||
doc.더한줄 = (doc.더한줄 ?? []).filter((_, i) => i !== r.added);
|
||||
return;
|
||||
}
|
||||
doc.뺀줄 = [...(doc.뺀줄 ?? []).filter((c) => c !== r.cid), r.cid];
|
||||
for (const k of ["덮개", "고름", "자재표시"] as const) {
|
||||
const map = { ...((doc[k] as Record<string, unknown> | undefined) ?? {}) };
|
||||
delete map[r.cid];
|
||||
(doc as Record<string, unknown>)[k] = map;
|
||||
}
|
||||
}
|
||||
|
||||
/** 뺀 줄 되살림 */
|
||||
export function restoreLine(doc: SkDoc, cid: string): void {
|
||||
doc.뺀줄 = (doc.뺀줄 ?? []).filter((c) => c !== cid);
|
||||
}
|
||||
|
||||
const NUM = new Intl.NumberFormat("ko-KR", { maximumFractionDigits: 4 });
|
||||
const num = (v: number | null): string => (v == null ? "—" : NUM.format(v));
|
||||
|
||||
/** 표 줄 → 공용 계산서(`calc_sheet`) 비목 묶음 — 줄 글 「단가 * 품 = 금액」 · 기준 밖 재료 = 금액 없음 */
|
||||
export function sheetGroups(rows: CoverRow[]): SheetGroup[] {
|
||||
const groups = new Map<string, SheetGroup>();
|
||||
const take = (k: string): SheetGroup => {
|
||||
let g = groups.get(k);
|
||||
if (!g) groups.set(k, (g = { 비목: k, 소계: 0, 줄: [] }));
|
||||
return g;
|
||||
};
|
||||
for (const r of rows) {
|
||||
const entries = Object.entries(r.parts);
|
||||
for (const [k, v] of entries.length ? entries : [["경비", 0] as [string, number]]) {
|
||||
const share = entries.length > 1 && r.금액 ? (r.단가 ?? 0) * (v / r.금액) : r.단가;
|
||||
const amount = r.apart ? null : v;
|
||||
const g = take(k);
|
||||
g.소계 = (g.소계 ?? 0) + (amount ?? 0);
|
||||
g.줄.push({
|
||||
이름: r.이름,
|
||||
글: r.pct ? `${num(r.수량)} %` : `${num(share)} * ${num(r.수량)} = ${num(amount)}`,
|
||||
금액: amount,
|
||||
묶음: r.묶음,
|
||||
장비: entries.length > 1 && r.cid.startsWith("logic:"),
|
||||
});
|
||||
}
|
||||
}
|
||||
const order = Object.keys(PART_OF);
|
||||
return [...groups.values()].sort((a, b) => order.indexOf(a.비목) - order.indexOf(b.비목));
|
||||
}
|
||||
/* ── 순수 셈 끝 ── */
|
||||
Reference in New Issue
Block a user