Files
Aislo/common_util/common_util_machine_sheet.ts
T
eomsangdonandClaude Opus 5.5 f822a4c36a feat(840-2): 기계 보기 공용 — M01 기계 상세 · 내 기계를 왼쪽 제어 구역 + 오른쪽 시간당 경비 산출근거로 · 「복사해 내 기계로」 걷음 · 기계 줄 풀기 안 내놓음
- 기계 보기 부품(A00_Common/line_stack_machine_sheet) — 836-1 제어 구역 그대로(기계 표 꼴 훅 sheet · 잠금 lock) + 기본 정보 · 손료 근거 접이 카드 + 비슷한 기계에서 시작 · 값 바꾸면 브라우저 계산기로 즉시
- 서버 POST /api/m02/machine-sheet(M02_MyMachine_Sheet) — 식 고름 + 식 문서 + 처음 줄 값 + 연결 기록 · 쓰기 없음 · 파이썬 bind ↔ TS bindDoc 짝
- 덮음 ↔ 기계 줄(common_util_machine_sheet) — 고침 없음 = 줄 바이트 같음 · 내 기계 바로 · 직접 자리 맞춤
- 내 기계 = M01 에 없는 기계만 — 화면 만들기 · 고치기에서 M01 과 같은 이름 · 규격 409(불러오기 자동 생성은 그대로) · [새 기계] · 고침 열림
- 옛 기계 양식 부품(ui_template_machine_form) 지움 — 쓰는 곳 0(부품 모음 견본 줄도 걷음)
- 낡은 시험 test_821_3 test_stack_line_copy — 836-2 자동 따라옴(내 기계 고치면 SK 금액 따라감 · 갱신 할 것 없음)에 맞춤
- 지킴 시험 test_840_2 — M01 기계 655 대 박은 식 셈 = 요소 셈 · 줄 그대로 · 굴착기 덮음 · 내 기계 꼴 · M01 같은 이름 막음

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

186 lines
8.0 KiB
TypeScript

/* =============================================================================
* common_util_machine_sheet.ts
* 기계 보기 셈 꼴(PLAN 840-2 · 요구 853 · 862) — 기계 줄(저장 꼴) ↔ 식이 읽는 값 · 덮음(836-1 `기계덮음` 꼴) ↔ 줄
* 셈 꼴(`engineVals`) = M01 = 값 그대로 · 내 기계 = 바로 → 상각비계수 ← 손료계수 · 정비 · 관리 0 ·
* 직접 → 노무비 · 재료비 · 경비 · 빈 연료 · 조종원 칸 = 0(서버 `M02_MyMachine.engine` 과 같은 꼴)
* 덮음 ← 줄 차이(`coverFrom` — 처음 줄 대 지금 줄) · 지금 줄 ← 덮음(`applyCover` — 제어 구역이 맡은 칸만 ·
* 덮음이 없는 칸 = 처음 줄 값 그대로 = 안 고치고 닫으면 줄 바이트 같음)
* 제어 구역이 맡은 칸(`ownedCols`) = 식이 읽는 값 칸 + 조종원 · 조종원직종 + (잡품 줄 있는 식) 잡재료율
* 값 박음(`bindDoc`) = 식 문서 요소 `EQ:{S_기계}` 칸 → 숫자(처음 줄 값) — 셈 차례 = 덮음 얹음(`coverDoc`) → 값 박음
* 파이썬 · TS 짝(`bindDoc`) — 짝 = M02_MasterTemplete/M02_MyMachine_Sheet.py `bind` · 거울 시험 test_840_2_machine_sheet.py
* · 화면 · DOM · fetch 없음
* ========================================================================== */
import type { FormulaDoc, MoneyLine } from "./common_util_formula_doc";
import type { CoverParts, MachineCover } from "./common_util_formula_doc_cover";
import { MACHINE_INPUTS } from "./common_util_machine_coef";
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;
const CREW = ["조종원", "조종원직종"];
const MISC = "잡재료율";
/** 보통 범위(옛 기계 양식과 같음) — 밖이면 경고만 */
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, vals: MachineValues): FormulaDoc {
const out = clone(doc);
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 (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, 잡재료율: 0, 조종원: 0, 주연료종류: "", 조종원직종: "", ...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,
...(parts.misc !== null ? [MISC] : []),
];
/** 처음 줄 대 지금 줄 → 덮음(조종원 = 「LB:직종」 한 줄 · 잡품 = 밑 줄마다 같은 율) — 정리는 `putCover` 몫 */
export function coverFrom(
parts: CoverParts,
lines: MoneyLine[],
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 (CREW.some((k) => !sameVal(c[k], o[k]))) {
const n = Number(c.조종원) || 0;
out.조종원 = n > 0 && !blank(c.조종원직종) ? [{ 키: `LB:${String(c.조종원직종)}`, 인: n }] : [];
}
if (parts.misc !== null && !sameVal(c[MISC], o[MISC])) {
const r = Number(c[MISC]) || 0;
out.잡품 = r ? parts.base.map((i) => ({ 줄: String(lines[i]?.이름 ?? ""), 율: r })) : [];
}
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);
/** 덮음 → 지금 줄(제어 구역이 맡은 칸만 · 덮음 없는 칸 = 처음 줄 저장 값으로) — 새 줄 */
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.값?.[칸]);
if (entry.조종원 === undefined) CREW.forEach((k) => put(k, undefined));
else {
const r = entry.조종원[0];
put("조종원", r ? r.인 : 0);
put("조종원직종", r ? jobCode(r.키, jobs) : undefined);
}
if (parts.misc !== null)
put(MISC, entry.잡품 === undefined ? undefined : (entry.잡품[0]?.율 ?? 0));
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]]
: [];
});
}