Files
Aislo/M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts
T
eomsangdonandClaude Opus 5.5 f4be6782a5 feat(751-3): M01 · M02 셈 끝수 = 품셈 1-2-2 버림 — 줄 0.1 원 · 비목 소계 1 원 · 계 = 셋 합 · B09 와 같은 값
- 끝수 한 곳 `master_cut.py`(B09 `rounddown` 재사용) — 엔진 답에 입힘: 줄 비목마다 0.1 원 버림 · 하위 로직 줄 = 하위 끝수 계 × 수량 · 덧줄 = 버린 줄 합으로 다시 셈 · 비목 합 1 원 버림 · 중간 칸도 버린 값
- 쓰는 곳: M01 시험 계산(`check_master.calc`) · 읽는 식(`master_text.lines`) · 옛 조합 미리보기(`master_combo`) · M02 `run_line`(단가산출 · 일위대가 · 펼침) · 별도계상 뺀 기준 · 켜쌓기율 곱 뒤 · 일위대가 반영 · 반영합 · 비율 줄 · 사전조합 안 줄
- 일위대가 반영 TS 짝(`Labor_Calc.ts`) 같은 끝수 · 거울 헬퍼가 상대 경로 .ts 가져옴
- B09 = `run_line(raw=True)` 엔진 날 값(제 단가로 다시 버림 · % 덧줄 율 비 그대로) — 검증_sub3 B09 247 칸 전후 같음
- `master_text` 꼴 셋 = `common_util_money` 로 갈음(줄 금액 `fmt_line_money` · 식 안 금액 = 버린 0.1 원)
- 화면: 상세 줄 금액 · 시험 계산 줄 · 중간 돈 = `formatLineMoney` · 일위대가 반영 칸 0.1 원 · 상세 바닥 · 박스 · 인력재료경비 비목 소계 = 줄 합 1 원 버림
- 테스트1 네 보기 · 서고 목록 = 124,419(옛 124,420 / 124,419.85) · 재료 줄 11,694.6 + 23,425.6 + 1,404.8 = 소계 36,525
- 새 지킴 시험 test_751_3_money_cut
- 옛 날 금액(끝수 없음)을 대조하던 시험 24 파일 고침 — 까닭 = 금액 규칙이 줄 0.1 원 · 소계 1 원 버림으로 바뀜(고정 값 → 새 값 · 줄 합 = 계 → 줄 합 아래 비목마다 1 원 안 · 곱 비 = 끝수 폭 안) · 실무 xlsx 영월 엑셀 같음 22 → 23 · 이어 붙이는 TS 시험(704_3 · 717_2b)에 `rounddown` 같은 식

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

261 lines
11 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`). */
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 · 엑셀 % 줄) — 수량 = 율 · 반영[비목] = 다른 줄 반영합[기준] × 율 ÷ 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, "없음"];
}
/** 반영 = 기준 × 수량 ÷ 배 — 비목마다 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(줄, done, base);
return { 줄: lines, 자재, 기준합: base, 반영합: cutSums(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 };
});
}