249 lines
10 KiB
TypeScript
249 lines
10 KiB
TypeScript
/* M02 일위대가 수량 · 반영 · 합(PLAN 39-1a · 설계_정함 1장) — 면적 칸 값이 바뀌면 화면이 바로 곱함.
|
||
*
|
||
* 짝(CLAUDE.md 5장 ①): `M02_Labor_Doc.py` `settle` 과 같은 규칙 —
|
||
* 거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`.
|
||
* 기준(결과단위 그대로)은 서버 calc 길이 셈 · 여기는 받은 기준에
|
||
* 수량(손이 칸보다 앞섬 · 칸 = 알약 키 값) · 반영 = 기준 × 수량 ÷ 배 · 자재 = 재료비만.
|
||
* 별도계상 자재(222-1 · 빈 칸 = 기본 별도계상) = 반영은 보이되 합에 안 더함(짝 `apart`).
|
||
* 참조 줄(단가승격 · 사전조합 · 50-3)은 `lineId` = 「참조|층|주인|키」(짝 `M02_Labor_Ref.ref_id`) ·
|
||
* 고정 줄(705-1)은 「고정|이름|규격|단위」(짝 `M02_Labor_Fixed.fixed_id`) · 기준 = 서버가 낸 줄 단가.
|
||
* 규격 열(704-4) = 문서 `규격` · 그 열 수량이 있는 줄 = `{손}` 자리(`atSpec` · 짝 `M02_Labor_Spec.at`). */
|
||
|
||
import type { CalcLine as LogicLine } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||
import type { LogicMat } from "./M02_Labor_LogicMats_Model";
|
||
|
||
export const MONEY_KEYS = ["노무비", "재료비", "경비", "계"] as const;
|
||
export type Money = Record<(typeof MONEY_KEYS)[number], number>;
|
||
export type QtySource = "손" | "칸" | "없음";
|
||
/** 문서의 수량 칸 — null = 면적산출 알약 · {손} = 손 입력 */
|
||
export type Qty = { 손: number } | null;
|
||
|
||
/** 참조 — 단가승격(SP) · 사전조합(SC) 문서(50-3) */
|
||
export interface LaborRef {
|
||
층: string;
|
||
주인: string | null;
|
||
키: string;
|
||
}
|
||
|
||
/** 고정 줄(705-1) — 숫자 그대로 · 이음 = 이 공사 노임 · 자재 · 기계 키(못 이음 = null) */
|
||
export interface LaborFixed {
|
||
이름: string;
|
||
규격?: string;
|
||
단위?: string;
|
||
단가: Record<"노무비" | "재료비" | "경비", number>;
|
||
이음?: Record<"노임" | "자재" | "기계", string | null>;
|
||
출처?: Record<string, string | number>;
|
||
/** 비율 줄(705-3 · 엑셀 % 줄) — 수량 = 율 · 반영[비목] = 다른 줄 반영합[기준] × 율 ÷ 100 */
|
||
비율?: { 기준: CostKey; 비목: CostKey };
|
||
}
|
||
type CostKey = "노무비" | "재료비" | "경비";
|
||
|
||
export interface CalcLine {
|
||
/** 참조 줄은 서버 답에 없음(빈 글로 다룸) */
|
||
로직: string;
|
||
참조?: LaborRef;
|
||
고정?: LaborFixed;
|
||
/** 고정 줄 — 단가 책 값을 쓴 비목 */
|
||
이은?: string[];
|
||
알약?: string | null;
|
||
분모?: string | null;
|
||
배: number;
|
||
기준: Money | null;
|
||
수량?: number | null;
|
||
수량출처?: QtySource;
|
||
반영?: Money | null;
|
||
까닭?: string | null;
|
||
결과?: unknown;
|
||
/** 그 로직 재료 · 노무 · 경비 줄(M01 시험계산 표와 같은 모양 · 50-4) */
|
||
줄?: LogicLine[];
|
||
/** 로직 자재(103-1) — 맨 위 층 재료 줄 · 금액은 `기준` 안(여기서 안 더함) */
|
||
재료?: LogicMat[];
|
||
/** 참조 줄 — 가리킨 문서 이름 · 단가승격의 원 로직 키 · 사전조합 안 줄(한 층 · 50-3 ④ ·
|
||
* 참조 줄 = `참조` · 로직 줄(51-11) = `로직` · 자재 줄(175-2) = `자재` 코드) */
|
||
이름?: string | null;
|
||
원로직?: string | null;
|
||
안?: {
|
||
참조?: LaborRef;
|
||
로직?: string;
|
||
자재?: string;
|
||
수량: number;
|
||
이름?: string | null;
|
||
단위?: string | null;
|
||
기준: Money | null;
|
||
까닭?: string | null;
|
||
}[];
|
||
}
|
||
|
||
export interface CalcMaterial {
|
||
코드: string;
|
||
알약?: string | null;
|
||
기준단가: number | null;
|
||
/** M01 자재품목 값 열 다섯(물가자료 · 유통물가 · 물가정보 · 거래가격 · 관급 · 빈 값 null · 43-3) */
|
||
가격?: Record<string, number | null>;
|
||
/** 기준단가가 된 열 이름 — 없으면 null */
|
||
기준칸?: string | null;
|
||
수량?: number | null;
|
||
수량출처?: QtySource;
|
||
반영?: number | null;
|
||
까닭?: string | null;
|
||
}
|
||
|
||
export interface LaborCalc {
|
||
줄: CalcLine[];
|
||
자재: CalcMaterial[];
|
||
기준합?: Money;
|
||
반영합?: Money;
|
||
}
|
||
|
||
/** 규격 열(704-4) — 이름(H · Φ) · 열(화면 차례) · 기본 · 줄 id 마다 열 수량 */
|
||
export interface LaborSpec {
|
||
이름: string;
|
||
열: string[];
|
||
기본: string;
|
||
수량: Record<string, Record<string, number>>;
|
||
}
|
||
|
||
/** 수량만 보는 문서 조각 — 담은 로직 · 자재의 손 값 · 규격 열 */
|
||
export interface LaborQtyDoc {
|
||
담은로직?: { 로직?: string; 참조?: LaborRef; 고정?: LaborFixed; 수량?: Qty }[];
|
||
자재?: { 코드: string; 수량?: Qty; 별도계상?: string }[];
|
||
규격?: LaborSpec | null;
|
||
}
|
||
|
||
function num(raw: unknown): number | null {
|
||
if (raw === null || raw === undefined || typeof raw === "boolean") return null;
|
||
const n = Number(String(raw).replace(/,/g, "").trim());
|
||
return String(raw).trim() !== "" && Number.isFinite(n) ? n : null;
|
||
}
|
||
|
||
/** 줄 가름 — 로직 키 · 참조 줄은 「참조|층|주인|키」 · 고정 줄은 「고정|이름|규격|단위」
|
||
* (짝 `M02_Labor_Doc.line_id`) */
|
||
export function lineId(one: {
|
||
로직?: string;
|
||
참조?: LaborRef;
|
||
고정?: LaborFixed;
|
||
}): string | undefined {
|
||
const fx = one.고정;
|
||
if (fx) return `고정|${fx.이름 ?? ""}|${fx.규격 ?? ""}|${fx.단위 ?? ""}`;
|
||
if (one.참조) return `참조|${one.참조.층}|${one.참조.주인 ?? ""}|${one.참조.키}`;
|
||
return one.로직;
|
||
}
|
||
|
||
/** 쓸 규격 열 — 고른 열이 있으면 그것 · 없으면 기본 · 규격 없음 = null(짝 `M02_Labor_Spec.column`) */
|
||
export function specColumn(doc: LaborQtyDoc, col?: string | null): string | null {
|
||
const cols = doc.규격?.열 ?? [];
|
||
if (!cols.length) return null;
|
||
return col && cols.includes(col) ? col : doc.규격?.기본 || cols[0];
|
||
}
|
||
|
||
/** 열 끼우기 — 그 열 규격 수량이 있는 줄 · 자재의 수량을 `{손}` 으로(짝 `M02_Labor_Spec.at`) */
|
||
export function atSpec<D extends LaborQtyDoc>(doc: D, col?: string | null): D {
|
||
const use = specColumn(doc, col);
|
||
if (use === null) return doc;
|
||
const qty = doc.규격?.수량 ?? {};
|
||
const put = <T extends { 수량?: Qty }>(one: T, id: string | undefined): T => {
|
||
const v = id !== undefined ? qty[id]?.[use] : undefined;
|
||
return v !== undefined && v !== null ? { ...one, 수량: { 손: v } } : one;
|
||
};
|
||
return {
|
||
...doc,
|
||
담은로직: (doc.담은로직 ?? []).map((one) => put(one, lineId(one))),
|
||
자재: (doc.자재 ?? []).map((one) => put(one, one.코드)),
|
||
};
|
||
}
|
||
|
||
/** 셈 답에서 칸 값 되살림 `{알약: 수량}` — 열마다 다시 셀 때(짝 `M02_Labor_Spec.given_of`) */
|
||
export function givenOf(result: LaborCalc): Record<string, number> {
|
||
const out: Record<string, number> = {};
|
||
for (const r of [...result.줄, ...result.자재])
|
||
if (r.수량출처 === "칸" && r.알약 && r.수량 != null) out[r.알약] = r.수량;
|
||
return out;
|
||
}
|
||
|
||
function zero(): Money {
|
||
return { 노무비: 0, 재료비: 0, 경비: 0, 계: 0 };
|
||
}
|
||
|
||
function pick(
|
||
hand: Qty | undefined,
|
||
key: string | null | undefined,
|
||
given: Record<string, number>,
|
||
): [number | null, QtySource] {
|
||
const own = hand ? num(hand.손) : null;
|
||
if (own !== null) return [own, "손"];
|
||
if (key && key in given) return [given[key], "칸"];
|
||
return [null, "없음"];
|
||
}
|
||
|
||
/** 반영 = 기준 × 수량 ÷ 배 — 수량이 없으면 null */
|
||
export function reflect(base: Money, qty: number | null, times: number): Money | null {
|
||
if (qty === null) return null;
|
||
const out = zero();
|
||
for (const k of MONEY_KEYS) out[k] = (base[k] * qty) / times;
|
||
return out;
|
||
}
|
||
|
||
/** 기준이 선 계산 답 + 문서 손 값 + 알약 값(`{키: 값}`) → 수량 · 반영 · 합을 새로 채운 답 ·
|
||
* `col` = 규격 열(없으면 기본 · 704-4) */
|
||
export function settle(
|
||
result: LaborCalc,
|
||
whole: LaborQtyDoc,
|
||
given: Record<string, unknown>,
|
||
col?: string | null,
|
||
): LaborCalc {
|
||
const doc = atSpec(whole, col);
|
||
const have: Record<string, number> = {};
|
||
for (const [k, raw] of Object.entries(given)) {
|
||
const v = num(raw);
|
||
if (v !== null) have[k] = v;
|
||
}
|
||
const hands = new Map((doc.담은로직 ?? []).map((one) => [lineId(one), one.수량 ?? null]));
|
||
const mhands = new Map((doc.자재 ?? []).map((one) => [one.코드, one.수량 ?? null]));
|
||
// 별도계상(222-1) — 「직접계산」 아니면(빈 · 옛 값 = 기본 별도계상) 합에서 뺌(짝 `M02_Labor_Doc.apart`)
|
||
const aside = new Set(
|
||
(doc.자재 ?? []).filter((m) => m.별도계상 !== "직접계산").map((m) => m.코드),
|
||
);
|
||
const base = zero();
|
||
const done = zero();
|
||
const add = (sum: Money, m: Money | null): void => {
|
||
if (m) for (const k of MONEY_KEYS) sum[k] += m[k];
|
||
};
|
||
const 줄 = result.줄.map((line) => {
|
||
const [qty, source] = pick(hands.get(lineId(line)), line.알약, have);
|
||
const own = line.기준 ? reflect(line.기준, qty, num(line.배) || 1) : null;
|
||
add(base, line.기준);
|
||
add(done, own);
|
||
return { ...line, 수량: qty, 수량출처: source, 반영: own };
|
||
});
|
||
const 자재 = result.자재.map((one) => {
|
||
const [qty, source] = pick(mhands.get(one.코드), one.알약, have);
|
||
const price = num(one.기준단가);
|
||
const cost = price !== null && qty !== null ? price * qty : null;
|
||
if (price !== null && !aside.has(one.코드)) add(base, { ...zero(), 재료비: price, 계: price });
|
||
if (cost !== null && !aside.has(one.코드)) add(done, { ...zero(), 재료비: cost, 계: cost });
|
||
return { ...one, 수량: qty, 수량출처: source, 반영: cost };
|
||
});
|
||
return { 줄: ratioLines(줄, done, base), 자재, 기준합: base, 반영합: done };
|
||
}
|
||
|
||
/** 비율 고정 줄(705-3 · 짝 `M02_Labor_Fixed.ratios`) — 밑 = 비율 줄 뺀 반영합 · 기준[비목] = 밑[기준] ·
|
||
* 배 100 · 반영 = 기준 × 율 ÷ 100 · `done` 에 더함 · 기준합(`base`)엔 안 셈 */
|
||
function ratioLines(lines: CalcLine[], done: Money, base: Money): CalcLine[] {
|
||
for (const l of lines.filter((x) => x.고정?.비율)) {
|
||
for (const k of MONEY_KEYS) {
|
||
done[k] -= l.반영?.[k] ?? 0;
|
||
base[k] -= l.기준?.[k] ?? 0;
|
||
}
|
||
}
|
||
const under = { ...done };
|
||
return lines.map((line) => {
|
||
const rate = line.고정?.비율;
|
||
if (!rate) return line;
|
||
const own = zero();
|
||
own[rate.비목] = own.계 = under[rate.기준];
|
||
const got = reflect(own, line.수량 ?? null, 100);
|
||
if (got) for (const k of MONEY_KEYS) done[k] += got[k];
|
||
return { ...line, 기준: own, 배: 100, 반영: got };
|
||
});
|
||
}
|