Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts
T
eomsangdonandClaude Opus 5.5 fd9cce0b12 feat(778-4): 율 기준 줄 목록 열쇠@비목 — 그 줄 그 비목 몫(절 노무비 · 기계 노무 포함)
- 밑 공용 함수 rate_base ↔ rateBase · 검사 · 짝잃음 · B09 rows:n@labor(파이썬 · TS) · 굳힘 · 화면 열쇠 옮김이 비목 꼬리 그대로
- 여덟 파일 율 줄 152 중 기준 찾은 147 재현(인력 82 · 재료 35 · 절 소계 16 · 노무비 11 · 장비 2 · 자재+율 1)
- 시험 test_778_4_rate_line 29 · 전후 대조 148/148 같음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:15:13 +09:00

333 lines
14 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.
/* M02 일위대가 수량 · 반영 · 합(PLAN 39-1a · 설계_정함 1장) — 면적 칸 값이 바뀌면 화면이 바로 곱함.
*
* 짝(CLAUDE.md 5장 ①): `M02_Labor_Doc.py` `settle` 과 같은 규칙 —
* 거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`.
* 기준(결과단위 그대로)은 서버 calc 길이 셈 · 여기는 받은 기준에
* 수량(손이 칸보다 앞섬 · 칸 = 알약 키 값) · 반영 = 기준 × 수량 ÷ 배 · 자재 = 재료비만.
* 끝수(751-3 · 품셈 1-2-2) = B09 `rounddown` — 반영 · 자재 금액 비목마다 0.1 원 · 반영합 비목마다 1 원 버림.
* 별도계상 자재(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`).
* 율 줄 밑(778-4) `rateBase` = 짝 `M02_Labor_Fixed.rate_base` — 일위대가 비율 줄 · 단가산출(SK) 율 더한줄
* (서버 `M02_Sangeun_Calc._rated`) 한 규칙 · 거울 시험 `resources/tester/test_778_4_rate_line.py`. */
import type { CalcLine as LogicLine } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import type { LogicMat } from "./M02_Labor_LogicMats_Model";
import { rounddown } from "../B09_Estimation/B09_Estimation_Calc_Base";
export const MONEY_KEYS = ["노무비", "재료비", "경비", "계"] as const;
const COSTS = ["노무비", "재료비", "경비"] 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 · 엑셀 % 줄) — 수량 = 율 · 반영[비목] = 밑(`rateBase`) × 율 ÷ 100 */
비율?: { 기준: RateBasis; 비목: CostKey };
}
type CostKey = "노무비" | "재료비" | "경비";
/** 율 기준(778-4) — 비목 · 묶음(인력 · 재료 · 장비) · 줄 열쇠 목록 */
export type RateBasis = CostKey | RateGroup | string[];
export type RateGroup = "인력" | "재료" | "장비";
/** 율 밑 줄 — 열쇠(SC 줄 가름 · SK cid) · 묶음 · 비목 · 율 줄인가 */
export interface RateRow {
키: string | null;
묶음: string | null;
비목: Partial<Record<CostKey, number | null>> | null;
율?: boolean;
}
const GROUPS: Record<"노임" | "자재" | "기계", RateGroup> = {
노임: "인력",
자재: "재료",
기계: "장비",
};
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, "없음"];
}
/** 반영 = 기준 × 수량 ÷ 배 — 비목마다 0.1 원 버림 · 계 = 셋 합 · 수량이 없으면 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 COSTS) out[k] = rounddown((base[k] * qty) / times, 1);
out.계 = out.노무비 + out.재료비 + out.경비;
return out;
}
/** 반영합 끝수 — 비목마다 1 원 버림 · 계 = 셋 합(짝 `master_cut.sums`) */
function cutSums(sum: Money): Money {
for (const k of COSTS) sum[k] = rounddown(sum[k], 0);
sum.계 = sum.노무비 + sum.재료비 + sum.경비;
return sum;
}
/** 기준이 선 계산 답 + 문서 손 값 + 알약 값(`{키: 값}`) → 수량 · 반영 · 합을 새로 채운 답 ·
* `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 ? rounddown(price * qty, 1) : 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 };
});
const lines = ratioLines(
줄,
자재.filter((m) => !aside.has(m.코드)),
done,
base,
);
return { 줄: lines, 자재, 기준합: base, 반영합: cutSums(done) };
}
/** 고정 줄 묶음 — 첫 이음(노임 · 자재 · 기계 · 짝 `M02_Labor_Fixed.group_of`) */
export function groupOf(fx: LaborFixed | undefined): RateGroup | null {
const link = fx?.이음;
const hit = (Object.keys(GROUPS) as (keyof typeof GROUPS)[]).find((k) => link?.[k]);
return hit ? GROUPS[hit] : null;
}
const all = (m: RateRow["비목"]): number => COSTS.reduce((s, k) => s + (m?.[k] ?? 0), 0);
/** 목록 열쇠 → [줄 열쇠, 비목 | null] — `add:#3@노무비` = 그 줄 노무비 몫(짝 `M02_Labor_Fixed.split_key`) */
export function splitKey(one: string): [string, CostKey | null] {
const at = one.lastIndexOf("@");
const cost = one.slice(at + 1);
return at >= 0 && (COSTS as readonly string[]).includes(cost)
? [one.slice(0, at), cost as CostKey]
: [one, null];
}
/** 율 줄 밑(778-4 · 짝 `M02_Labor_Fixed.rate_base`) — 비목 = 율 줄 뺀 그 비목 합 ·
* 묶음 = 율 줄 뺀 그 묶음 줄 셋 합 · 목록 = 그 열쇠 줄 셋 합(앞 율 줄 됨 · `열쇠@비목` = 그 비목 몫) */
export function rateBase(basis: RateBasis, rows: RateRow[]): number {
if (Array.isArray(basis)) {
let sum = 0;
for (const one of new Set(basis)) {
const [key, cost] = splitKey(one);
for (const r of rows) if (r.키 === key) sum += cost ? (r.비목?.[cost] ?? 0) : all(r.비목);
}
return sum;
}
const plain = rows.filter((r) => !r.율);
if ((COSTS as readonly string[]).includes(basis))
return plain.reduce((s, r) => s + (r.비목?.[basis as CostKey] ?? 0), 0);
return plain.reduce((s, r) => s + (r.묶음 === basis ? all(r.비목) : 0), 0);
}
/** 비율 고정 줄(705-3 · 짝 `M02_Labor_Fixed.ratios`) — 밑 = `rateBase`(율 줄 뺀 반영 줄 · 자재 = 묶음 재료) ·
* 기준[비목] = 밑 · 배 100 · 반영 = 기준 × 율 ÷ 100 · `done` 에 더함 · 기준합(`base`)엔 안 셈 */
function ratioLines(
lines: CalcLine[],
mats: { 반영?: number | null }[],
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 rows: RateRow[] = [
...lines
.filter((l) => !l.고정?.비율 && l.반영)
.map((l) => ({ 키: lineId(l) ?? null, 묶음: groupOf(l.고정), 비목: l.반영 ?? null })),
...mats
.filter((m) => m.반영 != null)
.map((m) => ({ 키: null, 묶음: "재료", 비목: { 재료비: m.반영 } })),
];
return lines.map((line) => {
const rate = line.고정?.비율;
if (!rate) return line;
const own = zero();
own[rate.비목] = own.계 = rateBase(rate.기준, rows);
const got = reflect(own, line.수량 ?? null, 100);
if (got) {
for (const k of MONEY_KEYS) done[k] += got[k];
rows.push({ 키: lineId(line) ?? null, 묶음: groupOf(line.고정), 비목: got, 율: true });
}
return { ...line, 기준: own, 배: 100, 반영: got };
});
}