Files
Aislo/M02_MasterTemplete/M02_Sangeun_Cover_Model.ts
T
eomsangdonandClaude Opus 5.5 c818dcae5e feat(717-3): 일위대가 줄 [단가산출 보기] 모달 — 인력·재료·경비 · 단가산출 상세 고침 = 그 일위대가 줄 덮개 · [단가산출로 저장]
- 줄 단추 = 로직 줄 · SK 참조 줄(Labor_Rows `sangeun`) · 프로젝트 복사본(셈 주입)엔 없음
- 모달(Labor_SangeunModal) = 일위대가 머리 「1 ㎡ 당」 · 이 줄 수량 · 1 단위 금액 · 보기 고르기(기본 인력·재료·경비 = 공용 계산서 calc_sheet · 상세 = 품 · 단가 칸 고침)
- 고친 줄 = 표 부품 「바뀜」 + 원래 값 곁 + 「이 일위대가에서 고침」 꼬리표 · [적용] = 줄 칸 패치(화면 캐시 · 저장은 [저장]) · 적용 안 하고 닫으면 물음
- [단가산출로 저장] = 내 개인 층 새 SK(from-line) · 자기 개인 층 일위대가면 그 줄을 새 SK 참조 줄로
- 셈 = 서버 /sangeun/calc(저장 안 함 · 최신 응답만) · 화면 문서 = 서버 as_doc 꼴(Sangeun_Cover_Model · 고름 · 자재표시 cid)
- 고침 표 · 셈 길 부름 = Sangeun_Cover(717-2b 도 씀) · normalizeLabor 가 줄 패치를 지킴(떨구면 [저장]에서 사라짐)
- 시험 test_717_3 5 통과 · 고친 대상 166 통과(1 실패 = test_175 B09 Store_Combo._PER · 전부터 · 719-2 몫) · typecheck 0

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

167 lines
6.6 KiB
TypeScript

/* =============================================================================
* M02_Sangeun_Cover_Model.ts
* 단가산출 고친 칸(덮개) 순수 함수(PLAN 717-3 · 요구서 내역12 4장 4 · 16 · 24) — 화면 · fetch 없음.
* · `lineDoc` = 일위대가 줄 → 이름 없는 단가산출(SK) 문서(서버 `M02_Labor_Patch.as_doc` 꼴 · 셈 길 몸) ·
* 줄 `고름` · `자재표시`(호표 차례 열쇠)는 바탕 줄 정체(`ids` · 셈 답 `정체`)로 cid 열쇠로 바꿈
* · `setCover` · `cleanPatch` = 줄 칸 `패치 {덮개, 뺀줄, 더한줄}` 고침(숫자만 · `원값`은 서버가 저장 때 셈)
* · `sheetGroups` = 셈 답 줄 → 공용 계산서(`A00_Common/calc_sheet`) 묶음(값 = 서버 셈 그대로 · 다시 안 셈)
* 계산 자리 = 서버(`M02_Sangeun_Calc`) 단독 — 여기는 문서 모양만.
* ========================================================================== */
export const PARTS = ["노무비", "재료비", "경비"] as const;
export type Part = (typeof PARTS)[number];
/** 덮개 칸(서버 `M02_Sangeun_Doc.COVER_KEYS`) — qty = 품 · labor · material · expense = 줄 단가 */
export const PART_KEY = { 노무비: "labor", 재료비: "material", 경비: "expense" } as const;
export type CoverKey = "qty" | "배율" | (typeof PART_KEY)[Part];
export type Cover = Partial<Record<CoverKey, number>>;
export interface LinePatch {
덮개?: Record<string, Cover>;
뺀줄?: string[];
더한줄?: unknown[];
}
export type Money = Record<Part | "계", number>;
/** 셈 답 한 줄(M01 시험 계산 줄 + cid · 고친 칸 이름) */
export interface SkLine {
cid: string;
이름: string;
단위?: string;
수량: number;
단가?: number;
금액?: number;
비목?: Partial<Record<Part, number>>;
율?: number | null;
출처?: string;
덮개?: string[];
}
export interface SkCalc {
기준: Money | null;
줄: SkLine[];
까닭?: string | null;
"1단위"?: Money | null;
배?: number;
정체?: string[];
짝잃음?: { cid: string; 까닭: string }[];
머리?: string | null;
}
/** 줄 꼴(일위대가 `담은로직[]` 한 줄 · 쓰는 칸만) */
export interface PatchRow {
로직?: string;
참조?: { 층: string; 주인?: string | null; 키: string };
입력값?: Record<string, unknown>;
켜쌓기율?: number | null;
고름?: Record<string, unknown>;
자재표시?: Record<string, unknown>;
패치?: LinePatch;
}
const keyed = (value: Record<string, unknown> | undefined, ids: string[]) =>
Object.fromEntries(
Object.entries(value ?? {}).flatMap(([n, v]) =>
/^\d+$/.test(n) && Number(n) < ids.length ? [[ids[Number(n)], v]] : [],
),
);
/** 고름 · 자재표시가 있는 로직 줄 = 바탕 줄 정체가 먼저 있어야 함 */
export const needsIds = (row: PatchRow): boolean =>
!row.참조 &&
(Object.keys(row.고름 ?? {}).length > 0 || Object.keys(row.자재표시 ?? {}).length > 0);
/** 줄 → 이름 없는 SK 문서 — `patch` null = 그 일위대가 고침 없는 원래 값 */
export function lineDoc(
row: PatchRow,
ids: string[] | null,
patch: LinePatch | null,
): Record<string, unknown> {
const head = {
키: "SK-000000",
이름: "줄",
단위: "식",
덮개: patch?.덮개 ?? {},
뺀줄: patch?.뺀줄 ?? [],
더한줄: patch?.더한줄 ?? [],
};
if (row.참조) return { ...head, 바탕: { 참조: row.참조 } };
return {
...head,
...(row.켜쌓기율 != null ? { 켜쌓기율: row.켜쌓기율 } : {}),
바탕: { 로직: row.로직 ?? "" },
입력값: { ...(row.입력값 ?? {}) },
고름: ids ? keyed(row.고름, ids) : {},
자재표시: ids ? keyed(row.자재표시, ids) : {},
};
}
/** 단가를 고칠 수 있는 비목 — 금액이 한 비목에만 있는 줄만(덧줄 % 줄 = 율만 · 없음 = null) */
export function priceKey(line: SkLine): CoverKey | null {
if (line.율 != null || line.cid.startsWith("pct:")) return null;
const parts = PARTS.filter((p) => (line.비목?.[p] ?? 0) !== 0);
return parts.length === 1 ? PART_KEY[parts[0]] : null;
}
/** 품 칸 값 — 덧줄 = 율 · 그 밖 = 수량 */
export const qtyOf = (line: SkLine): number => line.율 ?? line.수량;
/** 빈 칸 · 원값 뺀 패치 — 다 비면 null(줄 칸 안 둠) */
export function cleanPatch(patch: LinePatch | null | undefined): LinePatch | null {
const covers = Object.fromEntries(
Object.entries(patch?.덮개 ?? {}).flatMap(([cid, c]) => {
const keep = Object.fromEntries(
Object.entries(c ?? {}).filter(([k, v]) => k !== "원값" && typeof v === "number"),
);
return Object.keys(keep).length ? [[cid, keep]] : [];
}),
);
const out: LinePatch = {
...(Object.keys(covers).length ? { 덮개: covers } : {}),
...(patch?.뺀줄?.length ? { 뺀줄: [...patch.뺀줄] } : {}),
...(patch?.더한줄?.length ? { 더한줄: [...patch.더한줄] } : {}),
};
return Object.keys(out).length ? out : null;
}
/** 칸 하나 고침 — `value` null = 그 칸 덮개 풂(원래 값) · 같은 값도 덮개로 둠(사용자가 적은 값) */
export function setCover(
patch: LinePatch | null,
cid: string,
key: CoverKey,
value: number | null,
): LinePatch | null {
const covers = { ...(patch?.덮개 ?? {}) };
const one: Cover = { ...(covers[cid] ?? {}) };
if (value === null) delete one[key];
else one[key] = value;
if (key === "qty") delete one.배율; // 품은 qty · 배율 중 하나(서버 400)
covers[cid] = one;
return cleanPatch({ ...patch, 덮개: covers });
}
const fmt = (v: number | null | undefined): string =>
v == null ? "-" : Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
/** 계산서 묶음 — 비목마다 그 비목 금액이 있는 줄 · 식 = 「단가 * 품 = 금액」(한 비목 줄) · 소계 = 서버 기준 */
export function sheetGroups(calc: SkCalc): {
비목: string;
소계: number | null;
줄: { 이름: string; 글: string; 금액: number | null }[];
}[] {
return PARTS.flatMap((p) => {
const lines = calc.줄.filter((x) => (x.비목?.[p] ?? 0) !== 0);
if (!lines.length) return [];
return [
{
비목: p,
소계: calc.기준?.[p] ?? null,
줄: lines.map((x) => {
const amount = x.비목?.[p] ?? null;
const single = priceKey(x) !== null && x.단가 != null;
const pct = x.율 != null ? " %" : ""; // 덧줄 = 율 %
const text = single ? `${fmt(x.단가)} * ${fmt(qtyOf(x))}` : `${fmt(qtyOf(x))}${pct}`;
return { 이름: x.이름, 글: `${text} = ${fmt(amount)}`, 금액: amount };
}),
},
];
});
}