Files
Aislo/common_util/common_util_machine_sheet.ts
T
eomsangdonandClaude Opus 5.5 b93f7e8acc feat(842-3): 기계 보기 · 제어 구역 인력 · 잡품 줄 여럿 — 기계 줄 목록 ↔ 덮음 · 기본 정보 조종원 · 잡품 % 칸 걷음
- 제어 구역 기계 표 꼴(sheet) 한 줄 막음 걷음 — 인력 더하기 · 빼기 · 직종 고르기 · 잡품 재료 고르기 · 수량 · 율 줄마다
- coverFrom · applyCover = 줄 인력 · 잡품 목록 ↔ 덮음 조종원 · 잡품 통째 · bindDoc · bind = 목록 → 조종원 수 · 직종 · 잡재료율
- 기본값 바탕 = 기계 줄 목록(lists 고리 · 단가산출 안 = 연결 기록 · 기계 보기 = 처음 줄)
- M01 기계 목록 조종원 열 → 인력 줄 요약 · 기호 이름 = 인력N · 잡품N 줄마다
- 시험 test_842_3 새로(두 길 · 손셈 · 거울 · 저장 꼴) · test_840_2 ③ = 목록 꼴로 고침(옛 조종원 수 · 잡재료율 칸 기대 낡음)

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

183 lines
8.1 KiB
TypeScript

/* =============================================================================
* common_util_machine_sheet.ts
* 기계 보기 셈 꼴(PLAN 840-2 · 요구 853 · 862) — 기계 줄(저장 꼴) ↔ 식이 읽는 값 · 덮음(836-1 `기계덮음` 꼴) ↔ 줄
* 셈 꼴(`engineVals`) = M01 = 값 그대로 · 내 기계 = 바로 → 상각비계수 ← 손료계수 · 정비 · 관리 0 ·
* 직접 → 노무비 · 재료비 · 경비 · 빈 연료 = 0 · 인력 · 잡품 = 빈 목록 · 옛 꼴 = 목록으로(서버 `M02_MyMachine.engine` 과 같은 꼴)
* 덮음 ← 줄 차이(`coverFrom` — 처음 줄 대 지금 줄) · 지금 줄 ← 덮음(`applyCover` — 제어 구역이 맡은 칸만 ·
* 덮음이 없는 칸 = 처음 줄 값 그대로 = 안 고치고 닫으면 줄 바이트 같음)
* 제어 구역이 맡은 칸(`ownedCols`) = 식이 읽는 값 칸 + 인력 · 잡품 목록(842-3 — 덮음 조종원 · 잡품 = 줄 목록 그대로)
* 값 박음(`bindDoc`) = 식 문서 요소 `EQ:{S_기계}` 칸 → 숫자(처음 줄 값 · 목록 → 조종원 수 · 직종 · 잡재료율 `crewView`) —
* 셈 차례 = 덮음 얹음(`coverDoc` · 바탕 = 처음 줄 목록 `rowEntry`) → 값 박음
* 파이썬 · TS 짝(`bindDoc`) — 짝 = M02_MasterTemplete/M02_MyMachine_Sheet.py `bind` · 거울 시험 test_840_2_machine_sheet.py
* · 화면 · DOM · fetch 없음
* ========================================================================== */
import type { FormulaDoc } from "./common_util_formula_doc";
import type { CoverParts, CrewRow, MachineCover, MiscRow } from "./common_util_formula_doc_cover";
import { MACHINE_INPUTS } from "./common_util_machine_coef";
import { CREW, MISC, crewView, fromOld } from "./common_util_machine_crew";
export type MachineValues = Record<string, unknown>;
/** 고르기 목록 한 칸 — 연료(값 = 종류) · 직종(값 = 인력 원문번호) */
export interface MachinePick {
value: string;
text: string;
price: number | null;
}
export type MachineMode = "바로" | "품셈" | "직접";
/** 기계 줄(M01 기계 줄 · 내 기계 줄 꼴) — 이름 · 규격 · 값 · (내 기계) 방식 · 시간당 */
export interface MachineRow {
이름?: unknown;
규격?: unknown;
값?: MachineValues;
방식?: MachineMode;
시간당?: MachineValues;
[k: string]: unknown;
}
export const HOURLY = ["노무비", "재료비", "경비"] as const;
/** 보통 범위(옛 기계 양식과 같음) — 밖이면 경고만 */
export const RANGE: Record<string, [number, number]> = {
가격: [0, 5_000_000], 손료계수: [1, 100_000], 내용시간: [100, 100_000], 연간표준가동시간: [100, 8_760],
상각비율: [0, 1], 정비비율: [0, 3], 연간관리비율: [0, 1], 주연료: [0, 2_000], 잡재료율: [0, 100],
조종원: [0, 20], 전력: [0, 10_000],
}; // prettier-ignore
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v ?? null)) as T;
const blank = (v: unknown): boolean => v === undefined || v === null || v === "";
/** 식 문서 → 기계 값 칸을 숫자로 박은 새 문서(그 칸이 없는 줄은 요소 그대로) */
export function bindDoc(doc: FormulaDoc, raw: MachineValues): FormulaDoc {
const out = clone(doc);
const vals = crewView(raw);
for (const v of out.변수 ?? []) {
const l = v.연결 ?? {};
const key = String(l.키 ?? "");
const col = l.칸;
if (
l.갈래 === "요소" &&
key.startsWith("EQ:") &&
key.includes("{S_기계}") &&
col &&
col in vals
)
v.연결 = { 갈래: "숫자", 값: vals[col] as number | string };
}
return out;
}
/** 같은 값 — 수는 작은 차이 무시 · 글은 글로 */
export function sameVal(a: unknown, b: unknown): boolean {
if (blank(a) && blank(b)) return true;
if (Array.isArray(a) || Array.isArray(b)) return JSON.stringify(a) === JSON.stringify(b);
if (typeof a === "number" && typeof b === "number")
return Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(a), Math.abs(b));
return String(a ?? "") === String(b ?? "");
}
export function modeOf(row: MachineRow, mine: boolean): MachineMode {
if (mine) return row.방식 ?? "바로";
return MACHINE_INPUTS.some((k) => typeof row.값?.[k] === "number") ? "품셈" : "바로";
}
/** 식이 읽는 값 묶음(셈 꼴) */
export function engineVals(row: MachineRow, mine: boolean): MachineValues {
const v: MachineValues = { ...(row.값 ?? {}) };
if (!mine) return v;
const how = row.방식 ?? "바로";
if (how === "직접") return Object.fromEntries(HOURLY.map((k) => [k, row.시간당?.[k] ?? 0]));
if (how === "바로")
Object.assign(v, { 상각비계수: v.손료계수 || 0, 정비비계수: 0, 관리비계수: 0 });
return { 주연료: 0, 주연료종류: "", [CREW]: [], [MISC]: [], ...fromOld(v) };
}
/** 셈 꼴 칸 → 저장 꼴 자리 · null = 저장 안 함(내 기계 바로의 정비 · 관리 = 늘 0) */
export function slotOf(
col: string,
mode: MachineMode,
mine: boolean,
): ["값" | "시간당", string] | null {
if (!mine) return ["값", col];
if (mode === "직접") return (HOURLY as readonly string[]).includes(col) ? ["시간당", col] : null;
if (mode === "바로" && col === "상각비계수") return ["값", "손료계수"];
if (mode === "바로" && (col === "정비비계수" || col === "관리비계수")) return null;
return ["값", col];
}
/** 제어 구역이 맡은 칸(셈 꼴 이름) */
export const ownedCols = (parts: CoverParts): string[] => [
...parts.fields.map((f) => f.칸),
CREW,
MISC,
];
const listText = (v: unknown): string => JSON.stringify(v ?? null);
/** 처음 줄 대 지금 줄 → 덮음(값 = 고친 칸 · 조종원 · 잡품 = 지금 줄 목록 통째) — 정리는 `putCover` 몫 */
export function coverFrom(
parts: CoverParts,
orig: MachineRow,
cur: MachineRow,
mine: boolean,
): MachineCover {
const o = engineVals(orig, mine);
const c = engineVals(cur, mine);
const out: MachineCover = {};
const 값: Record<string, number | string> = {};
for (const { 칸 } of parts.fields)
if (!blank(c[칸]) && !sameVal(c[칸], o[칸])) 값[칸] = c[칸] as number | string;
if (Object.keys(값).length) out.값 = 값;
if (listText(c[CREW]) !== listText(o[CREW])) out.조종원 = clone((c[CREW] ?? []) as CrewRow[]);
if (listText(c[MISC]) !== listText(o[MISC])) out.잡품 = clone((c[MISC] ?? []) as MiscRow[]);
return out;
}
/** 노임 요소 키 → 인력 원문번호(`LB:1048` → 1048 · `LB000048` → 직종 표 · 빈 글 = 직종 고르기) */
export const jobCode = (key: string, jobs: Record<string, string>): string =>
key.startsWith("LB:") ? key.slice(3) : (jobs[key] ?? key);
/** 인력 줄 키 = `LB:원문번호`(빈 키 그대로) */
export const crewKey = (key: string, jobs: Record<string, string>): string =>
key ? `LB:${jobCode(key, jobs)}` : "";
/** 덮음 → 지금 줄(제어 구역이 맡은 칸만 · 덮음 없는 칸 = 처음 줄 저장 값으로) — 새 줄 */
export function applyCover(
parts: CoverParts,
orig: MachineRow,
cur: MachineRow,
entry: MachineCover,
mine: boolean,
jobs: Record<string, string>,
): MachineRow {
const mode = modeOf(cur, mine);
const next = clone(cur);
const put = (col: string, v: unknown): void => {
const at = slotOf(col, mode, mine);
if (!at) return;
const [part, key] = at;
const box = (next[part] ??= {}) as MachineValues;
if (v === undefined) {
const was = (orig[part] ?? {}) as MachineValues;
if (key in was) box[key] = clone(was[key]);
else delete box[key];
} else if (!(key in box) || !sameVal(box[key], v)) box[key] = v;
};
for (const { 칸 } of parts.fields) put(칸, entry.값?.[칸]);
put(
CREW,
entry.조종원?.map((r) => ({ ...r, 키: crewKey(r.키, jobs) })),
);
put(MISC, entry.잡품 && clone(entry.잡품));
return next;
}
/** 범위 밖 칸 — [칸, 범위 글] */
export function rangeWarnings(values: MachineValues): [string, string][] {
return Object.entries(RANGE).flatMap(([k, [lo, hi]]) => {
const v = values[k];
return typeof v === "number" && (v < lo || v > hi)
? [[k, `${lo}~${hi}`] as [string, string]]
: [];
});
}