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>
This commit is contained in:
2026-11-15 18:30:54 +09:00
co-authored by Claude Opus 5.5
parent e8d5534c11
commit b93f7e8acc
12 changed files with 354 additions and 99 deletions
+2
View File
@@ -78,6 +78,8 @@ export interface EditHooks {
machine?: MachineCtx;
/** 836-1 기계 제어 구역 — 덮음 없이 그 기계 식 셈(원본 = 기본값) */
calc?: (doc: FormulaDoc, given: Record<string, unknown>) => DocAnswer;
/** 842-3 그 기계 줄 인력 · 잡품 목록(기본값 바탕) */
lists?: (given: Record<string, unknown>) => Record<string, unknown> | null;
}
const field = (
+17 -29
View File
@@ -5,8 +5,9 @@
* ㉡ 조종원 = 인력 줄(노임 고르기 · 인/일 · 더하기 · 빼기 · 기본 = 기계 값 조종원 · 직종)
* ㉢ 잡품 = 재료 줄(재료 · 유가 고르기 · 수량 · 율 % · 기본 = 밑 재료 줄 · 품셈 잡재료율)
* 고침 = 문서 칸 `기계덮음`(캐시 · [저장] 때 서버가 정본으로 다시 셈) · 원본과 같으면 지움(`putCover`)
* 기본값 = 덮음 없이 셈한 그 기계 식 답(`defaultsOf`) · 읽기(M01 기초) = 칸 잠김 · 단추 없음
* 기계 표 꼴(`sheet` · 840-2 기계 보기 `line_stack_machine_sheet`) = 조종원 한 줄까지 · 잡품 = 밑 줄 고정(율 하나) ·
* 기본값 = 덮음 없이 셈한 그 기계 식 답 + 기계 줄 인력 · 잡품 목록(`defaultsOf` · `rowEntry` · 842-3) ·
* 읽기(M01 기초) = 칸 잠김 · 단추 없음
* 기계 보기(840-2 `line_stack_machine_sheet` · M01 기계 상세 · 내 기계) = 같은 구역(인력 · 잡품 여러 줄 · 842-3) ·
* `lock` = 잠긴 값 칸(품셈 방식 계수 등)
* ========================================================================== */
@@ -23,6 +24,7 @@ import {
machineKey,
partsOf,
putCover,
rowEntry,
type CrewRow,
type MachineCover,
type MiscRow,
@@ -44,8 +46,8 @@ export interface CtlHooks {
name: (key: string) => string;
/** 읽기(M01 기초 · 칸 잠김) */
readOnly: boolean;
/** 기계 표 꼴(840-2) — 기계 줄 칸에 담기는 만큼만(조종원 한 줄 · 잡품 율 하나 · 재료 고르기 없음) */
sheet?: boolean;
/** 그 기계 줄 값(인력 · 잡품 목록 — 기본값 바탕 · 842-3) */
lists?: (given: Record<string, unknown>) => Record<string, unknown> | null;
/** 잠긴 값 칸 */
lock?: string[];
}
@@ -72,7 +74,8 @@ export function ctlOf(h: CtlHooks, link: Link | undefined) {
if (!machine || !sub) return null;
const orig = h.calc(sub, given);
const vals = (orig.값 ?? {}) as Record<string, unknown>;
return { machine, sub, vals, base: defaultsOf(sub, vals), entry: coverOf(h.doc)[machine] };
const base = defaultsOf(sub, vals, rowEntry(sub, h.lists?.(given) ?? null));
return { machine, sub, vals, base, entry: coverOf(h.doc)[machine] };
}
const num = (t: string): number | null => {
@@ -122,7 +125,6 @@ export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElemen
const { machine, sub, vals, base } = got;
const entry: MachineCover = got.entry ?? {};
const ro = h.readOnly;
const table = !!h.sheet;
const put = (next: MachineCover): void => h.change((d) => putCover(d, machine, next, base));
const parts = partsOf(sub);
const lines = sub.금액 ?? [];
@@ -238,22 +240,15 @@ export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElemen
lineRow(
lt("K_재료"),
miscName(r),
ro || table,
ro,
() => pickMat((p) => setMisc(swapAt(misc, i, asMat(r, p))), r.키 ? miscName(r) : ""),
[
...(table
? []
: [numBox(lt("Qty"), r.수량, r.단위 ?? "", ro, (n) => {
const { 수량: _, ...rest } = r;
setMisc(swapAt(misc, i, n === null ? rest : { ...r, 수량: n }));
}, r.줄 && r.수량 === undefined ? `${baseQty(r) ?? ""} ${lt("Ctl_Follow")}` : "")]), // prettier-ignore
numBox(
lt("Ctl_Rate"),
r.율,
"%",
ro,
(n) =>
setMisc(table ? misc.map((x) => ({ ...x, 율: n ?? 0 })) : swapAt(misc, i, { ...r, 율: n ?? 0 })), // prettier-ignore
numBox(lt("Qty"), r.수량, r.단위 ?? "", ro, (n) => {
const { 수량: _, ...rest } = r;
setMisc(swapAt(misc, i, n === null ? rest : { ...r, 수량: n }));
}, r.줄 && r.수량 === undefined ? `${baseQty(r) ?? ""} ${lt("Ctl_Follow")}` : ""), // prettier-ignore
numBox(lt("Ctl_Rate"), r.율, "%", ro, (n) =>
setMisc(swapAt(misc, i, { ...r, 율: n ?? 0 })),
),
],
"재료비",
@@ -287,21 +282,14 @@ export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElemen
head(
lt("Ctl_Crew"),
entry.조종원 !== undefined,
table && crew.length
? null
: () =>
openStackPick("인력", (p) => setCrew([...crew, { 키: p.키, 이름: p.이름, 인: 1 }])),
() => openStackPick("인력", (p) => setCrew([...crew, { 키: p.키, 이름: p.이름, 인: 1 }])),
() => put({ ...entry, 조종원: undefined }),
),
...(crewRows.length ? crewRows : [el("div", { className: "lstk-empty", text: lt("Ctl_None") })]), // prettier-ignore
head(
lt("Ctl_Misc"),
entry.잡품 !== undefined,
!table
? () => pickMat((p) => setMisc([...misc, asMat({ 율: 100 }, p)]), "")
: parts.base.length && !misc.length
? () => setMisc(parts.base.map((k) => ({ 줄: String(lines[k].이름 ?? ""), 율: 0 })))
: null,
() => pickMat((p) => setMisc([...misc, asMat({ 율: 100 }, p)]), ""),
() => put({ ...entry, 잡품: undefined }),
),
...(miscRows.length ? miscRows : [el("div", { className: "lstk-empty", text: lt("Ctl_None") })]), // prettier-ignore
+15 -22
View File
@@ -1,7 +1,7 @@
/* =============================================================================
* line_stack_machine_sheet.ts
* 기계 보기(PLAN 840-2 · 요구 853 · 862 「기계 보기 공용」) — 기계 한 대를 단가산출 수식 보기와 닮은 꼴로:
* 왼쪽 = 기계 제어 구역(836-1 `line_stack_machine_ctl` 그대로 · 기계 표 꼴 `sheet` — 값 칸 · 조종원 인력 줄 · 잡품 줄) +
* 왼쪽 = 기계 제어 구역(836-1 `line_stack_machine_ctl` 그대로 — 값 칸 · 인력 줄 · 잡품 재료 줄 여럿 · 842-3) +
* 「기본 정보 · 손료 근거」 접이 카드(이름 · 규격 · 손료 방식 · 품셈 비율 · 식이 안 읽는 값 칸) + 비슷한 기계에서 시작(새 기계)
* 오른쪽 = 그 기계 시간당 경비 산출근거(`line_stack_out` · 노 · 재 · 경 · 계 합계 상자) — 값을 바꾸면 브라우저 계산기로 즉시
* 서버(`POST /api/m02/machine-sheet` · `M02_MyMachine_Sheet`) = 식 고름 + 처음 줄 값을 박은 식 문서 + 연결 기록 ·
@@ -27,6 +27,7 @@ import {
defaultsOf,
partsOf,
putCover,
rowEntry,
type CoverParts,
type MachineCover,
} from "../common_util/common_util_formula_doc_cover";
@@ -36,12 +37,13 @@ import {
MACHINE_INPUTS,
machineCoefOf,
} from "../common_util/common_util_machine_coef";
import { crewView } from "../common_util/common_util_machine_crew";
import {
applyCover,
bindDoc,
coverFrom,
crewKey,
engineVals,
jobCode,
modeOf,
ownedCols,
rangeWarnings,
@@ -105,23 +107,13 @@ interface SheetInfo {
}
/** 늘 보이는 기계 줄 칸(값이 없어도) — 그 밖 칸은 줄에 있으면 */
const BASE_KEYS = [
"가격",
"손료계수",
"주연료",
"주연료종류",
"잡재료율",
"조종원",
"조종원직종",
"전력",
];
const BASE_KEYS = ["가격", "손료계수", "주연료", "주연료종류", "전력"];
const UNIT: Record<string, string> = {
가격: "천원", 손료계수: "10⁻⁷/hr", 내용시간: "시간", 연간표준가동시간: "시간", 주연료: "ℓ/hr",
잡재료율: "%", 조종원: "인", 전력: "kWh",
가격: "천원", 손료계수: "10⁻⁷/hr", 내용시간: "시간", 연간표준가동시간: "시간", 주연료: "ℓ/hr", 전력: "kWh",
}; // prettier-ignore
const PICKS = ["주연료종류", "조종원직종"];
const PICKS = ["주연료종류"];
/** 식이 바뀔 수 있는 칸(서버 식 고름) */
const PICKY = ["주연료", "조종원", "조종원직종", "전력", "손료계수", ...MACHINE_COEFS];
const PICKY = ["주연료", "전력", "손료계수", ...MACHINE_COEFS];
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v ?? null)) as T;
const text = (v: unknown): string => (v === null || v === undefined ? "" : String(v));
@@ -175,8 +167,9 @@ export function createMachineSheet(o: MachineSheetOptions) {
const d = sub();
parts = d ? partsOf(d) : null;
if (!d || !parts) return;
base = defaultsOf(d, (plain(d, given).값 ?? {}) as Record<string, unknown>);
putCover(holder, no, coverFrom(parts, d.금액 ?? [], orig, cur, mine), base);
const lists = engineVals(orig, mine);
base = defaultsOf(d, (plain(d, given).값 ?? {}) as Record<string, unknown>, rowEntry(d, lists));
putCover(holder, no, coverFrom(parts, orig, cur, mine), base);
};
// ── 서버(뒤에서) ──
@@ -268,7 +261,7 @@ export function createMachineSheet(o: MachineSheetOptions) {
fn(holder);
const e = entry();
if (e.조종원) {
e.조종원 = e.조종원.map((r) => ({ ...r, 키: `LB:${jobCode(r.키, jobs)}` }));
e.조종원 = e.조종원.map((r) => ({ ...r, 키: crewKey(r.키, jobs) }));
putCover(holder, no, e, base);
}
if (parts) cur = applyCover(parts, orig, cur, entry(), mine, jobs);
@@ -276,7 +269,7 @@ export function createMachineSheet(o: MachineSheetOptions) {
},
name: (k) => cache.names.get(k) ?? "",
readOnly: ro,
sheet: true,
lists: () => engineVals(orig, mine),
lock,
},
{ ...link, 키: info.키 },
@@ -504,7 +497,7 @@ export function createMachineSheet(o: MachineSheetOptions) {
const why = info.까닭 ? ` — ${info.까닭}` : "";
return void right.replaceChildren(head, createText(`${L("MS_NoFormula")}${why}`, "warning"));
}
const shaped = coverDoc(d, given, coverOf(holder));
const shaped = coverDoc(d, given, coverOf(holder), rowEntry(d, engineVals(orig, mine)));
const a = plain(shaped, given);
if (cache.takeMissing()) ask();
lastAnswer = a;
@@ -526,7 +519,7 @@ export function createMachineSheet(o: MachineSheetOptions) {
foot.replaceChildren(docFoot(a));
};
const drawWarn = (): void => {
const vals: MachineValues = { ...engineVals(cur, mine), ...(cur.값 ?? {}) };
const vals: MachineValues = crewView({ ...engineVals(cur, mine), ...(cur.값 ?? {}) });
if (unitOf("손료계수") !== UNIT.손료계수) delete vals.손료계수; // 일 단위 손료(임업) = 시간당 범위 아님
warn.replaceChildren(
...rangeWarnings(vals).map(([k, r]) =>
+2
View File
@@ -29,6 +29,7 @@ import {
} from "@ui/ui_template_elements";
import { blankDoc, type DocVar, type FormulaDoc } from "../common_util/common_util_formula_doc";
import { calcDoc, type DocAnswer } from "../common_util/common_util_formula_doc_calc";
import { listsOf } from "../common_util/common_util_formula_doc_cover";
import { importView, type Basis } from "../common_util/common_util_formula_doc_import";
import { createSegment } from "@ui/ui_template_segment";
import type { DocTable } from "../common_util/common_util_formula_doc_table";
@@ -375,6 +376,7 @@ export function createStackScreen(opt: StackScreenOptions) {
take: takeCtx(),
machine: machineCtx(),
calc: calcSub,
lists: (g) => listsOf((q) => cache.links.element(q), g),
before: saved ? lineMap(JSON.parse(saved) as StackDoc) : undefined,
}),
]
+6 -3
View File
@@ -1,7 +1,7 @@
/* =============================================================================
* M01_MasterData_UI_Machine.ts
* M01 기계 목록(PLAN 821-2 · 요구 790-2) — 표는 목록용 열만(이름 · 규격 · 구분 · 가격 · 손료계수 ·
* 주연료 · 조종원 · 시간당 경비(서버 값) · 별칭 · 비고) · 줄을 누르면 상세 페이지 · [새 기계] = 양식.
* 주연료 · 인력 줄 요약 · 시간당 경비(서버 값) · 별칭 · 비고) · 줄을 누르면 상세 페이지 · [새 기계] = 양식.
* 단위 · 출처 열 없음 · 고친 것 · 새 줄 · 지울 줄은 초안(노랑 · 새 · 지움 표시) — 저장은 페이지 [저장].
* ========================================================================== */
@@ -9,6 +9,7 @@ import { createButton, el, showToast } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
import { createEmpty } from "@ui/ui_template_empty";
import { guessRows, pageKeepFirst, watchFit } from "@ui/ui_template_pager";
import { code, crewOf } from "../common_util/common_util_machine_crew";
import { fetchRows, type Row, type RowsPage } from "./M01_MasterData_Api_Fetch";
import { onDraftChange, peek, same } from "./M01_MasterData_Draft";
import { fetchMachineHourly } from "./M01_MasterData_Machine_Api";
@@ -32,9 +33,11 @@ const COLS: [string, (r: Row) => string][] = [
val(r)["주연료"] ? `${val(r)["주연료종류"] ?? ""} ${num(val(r)["주연료"])}ℓ`.trim() : "",
],
[
"조종원",
"인력",
(r) =>
val(r)["조종원"] ? `${num(val(r)["조종원"])}인 ${val(r)["조종원직종"] ?? ""}`.trim() : "",
(crewOf(val(r)) ?? []) // 인력 줄 요약(842-3)
.map((x) => `${num(x.인)}인 ${code(x.키) || "직종 고르기"}`)
.join(", "),
],
];
const val = (r: Row): Record<string, unknown> => (r["값"] ?? {}) as Record<string, unknown>;
+9 -3
View File
@@ -6,7 +6,9 @@
836-1) 얹음 → 값 박음(`bind` — 남은 요소 `EQ:{S_기계}` 칸 → 숫자 · 저장 전 새 기계 · 내 기계도 같은 계산기로 셈)
- 파이썬 · TS 짝 — `bind` ↔ `common_util/common_util_machine_sheet.ts` `bindDoc` · 거울 시험 test_840_2_machine_sheet.py
- 연결 = 덮음 얹은 문서 셈이 쓴 요소 · 표 · 문서(`master_doc_record`) — 화면이 같은 계산기로 즉시 다시 셈
- 직종 = 노임 요소 코드 → 인력 원문번호(조종원 줄 → 기계 줄 `조종원직종`)
- 바탕 = 처음 줄 인력 · 잡품 목록(842-3 · `master_doc_cover.row_entry`) — 덮음(지금 줄 목록)이 그 위에 · 값 박음은
목록 → 조종원 수 · 직종 · 잡재료율(`master_machine_crew.view`)
- 직종 = 노임 요소 코드 → 인력 원문번호(인력 줄 키 `LB:원문번호`)
"""
from __future__ import annotations
@@ -27,8 +29,11 @@ def _plain(v: Any) -> Any:
def bind(doc: dict, vals: dict) -> dict:
"""식 문서 → 기계 값 칸을 숫자로 박은 새 문서(그 칸이 없는 줄은 요소 그대로)."""
"""식 문서 → 기계 값 칸을 숫자로 박은 새 문서(그 칸이 없는 줄은 요소 그대로 · 목록 = `view`)."""
import master_machine_crew as mcrew
out = copy.deepcopy(doc)
vals = mcrew.view(vals)
for v in out.get("변수") or []:
link = v.get("연결") or {}
key = str(link.get("키") or "")
@@ -77,7 +82,8 @@ def sheet(row: Any, base: Any = None, cover: Any = None, mine: bool = False) ->
given = {"기계": no}
entry = cover if isinstance(cover, dict) and cover else None
try:
covered = mcv.cover_doc(doc, given, mcv.clean({no: m01._dec(entry)})) if entry else doc
cover = mcv.clean({no: m01._dec(entry)}) if entry else None
covered = mcv.cover_doc(doc, given, cover, mcv.row_entry(doc, vals))
except ValueError as e:
raise m01.StoreError(400, str(e)) from None
shaped = bind(covered, vals)
+27 -30
View File
@@ -2,18 +2,20 @@
* common_util_machine_sheet.ts
* 기계 보기 셈 꼴(PLAN 840-2 · 요구 853 · 862) — 기계 줄(저장 꼴) ↔ 식이 읽는 값 · 덮음(836-1 `기계덮음` 꼴) ↔ 줄
* 셈 꼴(`engineVals`) = M01 = 값 그대로 · 내 기계 = 바로 → 상각비계수 ← 손료계수 · 정비 · 관리 0 ·
* 직접 → 노무비 · 재료비 · 경비 · 빈 연료 · 조종원 칸 = 0(서버 `M02_MyMachine.engine` 과 같은 꼴)
* 직접 → 노무비 · 재료비 · 경비 · 빈 연료 = 0 · 인력 · 잡품 = 빈 목록 · 옛 꼴 = 목록으로(서버 `M02_MyMachine.engine` 과 같은 꼴)
* 덮음 ← 줄 차이(`coverFrom` — 처음 줄 대 지금 줄) · 지금 줄 ← 덮음(`applyCover` — 제어 구역이 맡은 칸만 ·
* 덮음이 없는 칸 = 처음 줄 값 그대로 = 안 고치고 닫으면 줄 바이트 같음)
* 제어 구역이 맡은 칸(`ownedCols`) = 식이 읽는 값 칸 + 조종원 · 조종원직종 + (잡품 줄 있는 식) 잡재료율
* 값 박음(`bindDoc`) = 식 문서 요소 `EQ:{S_기계}` 칸 → 숫자(처음 줄 값) — 셈 차례 = 덮음 얹음(`coverDoc`) → 값 박음
* 제어 구역이 맡은 칸(`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, MoneyLine } from "./common_util_formula_doc";
import type { CoverParts, MachineCover } from "./common_util_formula_doc_cover";
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>;
/** 고르기 목록 한 칸 — 연료(값 = 종류) · 직종(값 = 인력 원문번호) */
@@ -34,8 +36,6 @@ export interface MachineRow {
}
export const HOURLY = ["노무비", "재료비", "경비"] as const;
const CREW = ["조종원", "조종원직종"];
const MISC = "잡재료율";
/** 보통 범위(옛 기계 양식과 같음) — 밖이면 경고만 */
export const RANGE: Record<string, [number, number]> = {
@@ -48,8 +48,9 @@ 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 {
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.키 ?? "");
@@ -69,6 +70,7 @@ export function bindDoc(doc: FormulaDoc, vals: MachineValues): FormulaDoc {
/** 같은 값 — 수는 작은 차이 무시 · 글은 글로 */
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 ?? "");
@@ -87,7 +89,7 @@ export function engineVals(row: MachineRow, mine: boolean): MachineValues {
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 };
return { 주연료: 0, 주연료종류: "", [CREW]: [], [MISC]: [], ...fromOld(v) };
}
/** 셈 꼴 칸 → 저장 꼴 자리 · null = 저장 안 함(내 기계 바로의 정비 · 관리 = 늘 0) */
@@ -106,14 +108,15 @@ export function slotOf(
/** 제어 구역이 맡은 칸(셈 꼴 이름) */
export const ownedCols = (parts: CoverParts): string[] => [
...parts.fields.map((f) => f.칸),
...CREW,
...(parts.misc !== null ? [MISC] : []),
CREW,
MISC,
];
/** 처음 줄 대 지금 줄 → 덮음(조종원 = 「LB:직종」 한 줄 · 잡품 = 밑 줄마다 같은 율) — 정리는 `putCover` 몫 */
const listText = (v: unknown): string => JSON.stringify(v ?? null);
/** 처음 줄 대 지금 줄 → 덮음(값 = 고친 칸 · 조종원 · 잡품 = 지금 줄 목록 통째) — 정리는 `putCover` 몫 */
export function coverFrom(
parts: CoverParts,
lines: MoneyLine[],
orig: MachineRow,
cur: MachineRow,
mine: boolean,
@@ -125,20 +128,17 @@ export function coverFrom(
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 })) : [];
}
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` → 직종 표) */
/** 노임 요소 키 → 인력 원문번호(`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(
@@ -163,14 +163,11 @@ export function applyCover(
} 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));
put(
CREW,
entry.조종원?.map((r) => ({ ...r, 키: crewKey(r.키, jobs) })),
);
put(MISC, entry.잡품 && clone(entry.잡품));
return next;
}
+10 -1
View File
@@ -5,6 +5,7 @@
* 화면 글만 바꿈 — 문서 자료 · 식 글 · 열쇠 · 계산 불변 · 식 글 고침 칸은 `tableAlias` 길이 이름 ↔ 기호로 오감
* 기계경비식 밖 문서 · 사용자가 손으로 바꾼 기호(사전에 없는 꼴)는 그대로 · 이름이 겹치면 그 기호만 그대로
* 화면 없음(시험이 Node 로 돌림) · 기계 제어 구역(836-1)도 `machineAlias` 로 같은 이름
* 인력 · 잡품 줄 여럿(842-3 — 덮음이 더한 `w_조종원N` · `P_잡품N`) = 줄마다 「인력N」 · 「잡품N」
* ========================================================================== */
import type { FormulaDoc } from "./common_util_formula_doc";
@@ -88,6 +89,8 @@ const CELL: Record<string, string> = {
전력: "전력",
조종원: "조종원",
잡재료율: "잡품",
인력: "인력",
잡품: "잡품",
노무비: "시간노무",
재료비: "시간재료",
경비: "시간경비",
@@ -105,8 +108,14 @@ export function machineAlias(doc: FormulaDoc): Record<string, string> {
if (doc.부품?.갈래 !== "기계경비식") return {};
const ids = symbols(doc);
const slot = SLOTS[ids.filter((x) => x.startsWith("P_EQ기계")).length] ?? [];
const row = (id: string): string | undefined => {
const m = /^(?:w_조종원|P_잡품)(\d+)$/.exec(id); // 842-3 인력 · 잡품 줄마다
return m ? `${id.startsWith("w_") ? "인력" : "잡품"}${m[1]}` : undefined;
};
const want = (id: string): string | undefined =>
NAME[id] ?? (id.startsWith("P_EQ기계") ? slot[Number(id.slice(6) || 1) - 1] : undefined);
NAME[id] ??
row(id) ??
(id.startsWith("P_EQ기계") ? slot[Number(id.slice(6) || 1) - 1] : undefined);
const count = new Map<string, number>();
const plain = new Set(ids);
for (const id of ids) {
@@ -27,7 +27,7 @@ const out = ask.대상.map((t) => {
const doc = ask.문서들[t.식];
const parts = cover.partsOf(doc);
const cur = t.지금 ?? t.처음;
const made = sheet.coverFrom(parts, doc.금액 ?? [], t.처음, cur, t.개인);
const made = sheet.coverFrom(parts, t.처음, cur, t.개인);
const use = t.덮음 ?? made;
const row = sheet.applyCover(parts, t.처음, cur, use, t.개인, t.직종 ?? {});
return { 덮음: made, 줄: row, ...(t.박음 ? { 박음: sheet.bindDoc(doc, t.박음) } : {}) };
@@ -0,0 +1,36 @@
/* 842-3 기계 인력 · 잡품 줄 헬퍼 — common_util_machine_crew.ts · common_util_formula_doc_cover.ts 를 트랜스파일해 Node 로 돌림.
* stdin = {값들: [기계 값], 문서들: {키: 식 문서}, 대상: [{식, 값, 덮음?, 기계?}]}
* stdout = {읽기: [crewView], 옛: [fromOld], 바탕: [rowEntry], 얹음: [coverDoc(식, {기계}, 덮음, rowEntry)]} */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const cache = new Map();
function load(at) {
if (cache.has(at)) return cache.get(at);
const js = ts.transpileModule(fs.readFileSync(at, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(at, box.exports);
const need = (p) =>
p.startsWith(".") ? load(path.resolve(path.dirname(at), `${p}.ts`)) : require(p);
new Function("exports", "module", "require", js)(box.exports, box, need);
return box.exports;
}
const root = path.join(__dirname, "..", "..", "common_util");
const crew = load(path.join(root, "common_util_machine_crew.ts"));
const cover = load(path.join(root, "common_util_formula_doc_cover.ts"));
const ask = JSON.parse(fs.readFileSync(0, "utf8"));
const out = {
읽기: ask.값들.map((v) => crew.crewView(v)),
옛: ask.값들.map((v) => crew.fromOld(v)),
바탕: ask.대상.map((t) => cover.rowEntry(ask.문서들[t.식], t.값)),
얹음: ask.대상.map((t) => {
const doc = ask.문서들[t.식];
const given = { 기계: t.기계 ?? "M" };
return cover.coverDoc(doc, given, t.덮음 ? { [given.기계]: t.덮음 } : null, cover.rowEntry(doc, t.값));
}),
};
process.stdout.write(JSON.stringify(out));
+22 -10
View File
@@ -4,7 +4,8 @@
(+ 덮음 ↔ 줄 `coverFrom` · `applyCover` — 화면 몫 · Node 로 돌림).
① M01 기계 655 대 전수 — 식 고름 → 값 박은 식 셈 = 기계 표 요소로 읽는 셈(노 · 재 · 경 · 계 같음) · 식 없음 = 까닭.
② 655 대 전수(TS) — 고침 없음 = 덮음 없음 · 줄 JSON 같음(열고 닫아도 M01 기계 파일 그대로) · 박음 거울.
③ 굴착기 1.0㎥ — 가격 · 조종원(2 인 · 직종 · 뺌) · 잡품 율 덮음 → 줄 칸 · 덮음 얹고 박은 셈 = 줄 고친 셈.
③ 굴착기 1.0㎥ — 가격 · 인력 줄(2 인 · 직종 · 뺌 · 둘) · 잡품 줄(율 · 재료 더함) 덮음 → 줄 목록 ·
덮음 얹고 박은 셈 = 줄 고친 셈(842-3 — 덮음 조종원 · 잡품 = 줄 `인력` · `잡품` 목록 그대로).
④ 내 기계 꼴 — 바로(상각비계수 → 손료계수) · 직접(시간당) · 처음 값으로 되돌리면 줄 같음.
⑤ M01 과 같은 이름 · 규격 = 409 · 다른 이름 = 통과(검사만 · 쓰기 없음).
"""
@@ -28,6 +29,7 @@ from M02_MasterTemplete import M02_MyMachine_Sheet as ms
mc = importlib.import_module("master_doc_calc")
ml = importlib.import_module("master_doc_link")
mcv = importlib.import_module("master_doc_cover")
mcrew = importlib.import_module("master_machine_crew")
HERE = os.path.dirname(os.path.abspath(__file__))
COSTS = ("노무비", "재료비", "경비", "계")
@@ -117,18 +119,23 @@ def test_655대_고침_없음_줄_같음_박음_거울(world):
# ── ③ 굴착기 덮음 → 줄 · 셈 ────────────────────────────────────────────
def test_굴착기_덮음_줄_칸_셈(world):
row = _plain(next(r for r in world["rows"] if r["키"] == DIG))
row = _plain(mcrew.from_old_row(next(r for r in world["rows"] if r["키"] == DIG)))
doc = world["docs"]["GC000267"]
given = {"기계": row["원문번호"]}
jobs = ms._jobs()
lb = next(k for k, v in jobs.items() if v == "1002")
cases = [
({"값": {"가격": 150000}}, {"가격": 150000}),
({"조종원": [{"키": "LB:1048", "인": 2}]}, {"조종원": 2}),
({"조종원": [{"키": lb, "인": 1}]}, {"조종원직종": "1002"}),
({"조종원": []}, {"조종원": 0}),
({"잡품": [{"줄": "주연료", "율": 30}]}, {"잡재료율": 30}),
]
({"조종원": [{"키": "LB:1048", "인": 2}]}, {"인력": [{"키": "LB:1048", "인": 2}]}),
({"조종원": [{"키": lb, "인": 1}]}, {"인력": [{"키": "LB:1002", "인": 1}]}),
({"조종원": []}, {"인력": []}),
({"잡품": [{"줄": "주연료", "율": 30}]}, {"잡품": [{"줄": "주연료", "율": 30}]}),
(two := {"조종원": [{"키": "LB:1048", "인": 1}, {"키": "LB:1002", "인": 2}]},
{"인력": two["조종원"]}),
(mat := {"잡품": [{"줄": "주연료", "율": 22},
{"키": "MO:휘발유:전국평균", "이름": "휘발유", "수량": 2, "율": 50}]},
{"잡품": mat["잡품"]}),
] # fmt: skip
targets = [
{"식": "GC000267", "처음": row, "덮음": c, "개인": False, "직종": jobs} for c, _ in cases
]
@@ -137,15 +144,20 @@ def test_굴착기_덮음_줄_칸_셈(world):
want = copy.deepcopy(row)
want["값"].update(change)
assert g["줄"] == want, cover
first, now = m01._dec(row["값"]), m01._dec(g["줄"]["값"])
shaped = ms.bind(
mcv.cover_doc(doc, given, {row["원문번호"]: m01._dec(cover)}), m01._dec(row["값"])
mcv.cover_doc(
doc, given, {row["원문번호"]: m01._dec(cover)}, mcv.row_entry(doc, first)
),
first,
)
edited = ms.bind(doc, m01._dec(g["줄"]["값"]))
edited = ms.bind(mcv.cover_doc(doc, given, None, mcv.row_entry(doc, now)), now)
a, b = mc.calc(shaped, world["links"], given), mc.calc(edited, world["links"], given)
assert a["셈됨"] and _money(a) == _money(b), cover
# 지금 줄(초안) → 덮음 — 처음 줄 대비 고친 칸만
cur = copy.deepcopy(row)
cur["값"].update({"가격": 150000, "조종원": 2, "잡재료율": 30})
cur["값"].update({"가격": 150000, "인력": [{"키": "LB:1048", "인": 2}],
"잡품": [{"줄": "주연료", "율": 30}]}) # fmt: skip
made = _node({"문서들": {"GC000267": _plain(doc)},
"대상": [{"식": "GC000267", "처음": row, "지금": cur, "개인": False}]})[0] # fmt: skip
assert made["덮음"] == {
+207
View File
@@ -0,0 +1,207 @@
"""842-3 기계 인력 · 잡품 줄 지킴 시험 — 두 길 같음 · 자료 꼴 · 손셈 · 거울 · 저장 꼴 · 쓰기 없음.
짝 = resources/master_data/scripts/master_machine_crew.py ↔ common_util/common_util_machine_crew.ts ·
`master_doc_cover` row_entry · merge · cover_doc(바탕) ↔ common_util_formula_doc_cover.ts rowEntry · mergeCover · coverDoc.
① 655 대 두 길 — 로직 엔진(목록 → 조종원 수 · 직종 · 잡재료율 읽기) = 문서 엔진(식 문서 + 목록 바탕 덮음) 노 · 재 · 경 · 계.
② M01 기계 자료 — 옛 칸(조종원 · 조종원직종 · 잡재료율) 0 · 목록 꼴 틀 0 · 선원류 칸 그대로.
③ 굴착기 1.0㎥ — 인력 둘(직종 다름) = 한 줄씩 셈 합 · 잡품 재료 줄 = 단가 × 수량 × 율 · 단가산출 덮음이 위에 덮임.
④ 거울 — 읽기 · 옛 꼴 바꿈 · 바탕 덮음 · 얹은 문서 파이썬 = TS.
⑤ 내 기계 저장 꼴 — 목록 받음 · 옛 칸 → 목록 · 틀린 줄 400.
"""
import copy
import importlib
import json
import os
import subprocess
from decimal import Decimal as D
import pytest
from M01_MasterData import M01_MasterData_Doc as m01doc
from M01_MasterData import M01_MasterData_Store as m01 # scripts 길을 둠
from M02_MasterTemplete import M02_Machine_Hourly as mh
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from M02_MasterTemplete import M02_MyMachine as mym
from M02_MasterTemplete import M02_Sangeun_Copy as skc
mcrew = importlib.import_module("master_machine_crew")
mcv = importlib.import_module("master_doc_cover")
mc = importlib.import_module("master_doc_calc")
ml = importlib.import_module("master_doc_link")
mcut = importlib.import_module("master_cut")
HERE = os.path.dirname(os.path.abspath(__file__))
COSTS = ("노무비", "재료비", "경비", "계")
DIG = "EQ000022" # 굴착기(무한궤도) 1.0㎥ 0201-0100 · GC000267
SEA = ("선원", "준설선선장", "준설선기관사", "고급선원")
def _plain(v):
return json.loads(json.dumps(v, ensure_ascii=False, default=float))
@pytest.fixture(scope="module")
def world():
with store.at(store.FOLDER):
index = m01.loaded()[1]
yield {"index": index, "docs": m01doc.docs(), "rows": list(index.index["EQ"].values())}
def _logic(index, row, logic):
given = {i["이름"]: (i.get("고르기") or [""])[0] for i in logic.get("입력") or []}
if mh.takes_machine(logic):
given["기계"] = str(row.get("원문번호"))
mf = m01.cm.mf
got = mcut.engine(index, logic["키"], mf.run(index, logic["키"], mf.fill_empty(logic, given)))
return {k: round(float(got[k]), 4) for k in COSTS}
# ── ① 두 길 ────────────────────────────────────────────────────────────
def test_655대_로직_엔진_문서_엔진_같음(world):
index, same = world["index"], 0
with store.at(store.FOLDER):
for row in world["rows"]:
logic = mh.pick(row)
if logic is None or mh.why(row, logic):
continue
doc = mh.doc_sums(row, logic, index)
if doc is None or not doc["ok"]:
continue
assert {k: round(v, 4) for k, v in doc["합"].items()} == _logic(index, row, logic), row[
"키"
]
same += 1
assert len(world["rows"]) == 655 and same >= 600
# ── ② 자료 꼴 ───────────────────────────────────────────────────────────
def test_M01_기계_옛_칸_없음_목록_꼴(world):
crew = misc = 0
for row in world["rows"]:
vals = row.get("값") or {}
assert not any(k in vals for k in mcrew.OLD), row["키"]
assert mcrew.check(vals) == [], row["키"]
for part in ("단위", "출처"):
assert set(row.get(part) or {}) == set(vals), (row["키"], part)
crew += bool(vals.get("인력"))
misc += bool(vals.get("잡품"))
assert crew >= 295 and misc >= 320
assert sum(any(k in (r.get("값") or {}) for k in SEA) for r in world["rows"]) == 79
# ── ③ 손셈 ─────────────────────────────────────────────────────────────
def _hour(world, vals, cover=None):
"""굴착기 줄 값을 바꾼 사본 색인으로 시간당(문서 엔진 · 단가산출 덮음 얹음)."""
row = copy.deepcopy(world["index"].get(DIG))
row["값"] = {**mcrew.from_old(row["값"]), **vals}
index = skc.Copy(world["index"], [row], ("시험", id(vals)))
doc, given = world["docs"]["GC000267"], {"기계": "0201-0100"}
shaped = mcv.cover_doc(doc, given, cover, mcv.row_entry(doc, row["값"]))
got = mc.calc(shaped, ml.Links(index, world["docs"]), given)
assert got["셈됨"], got["표지"]
return got
def test_굴착기_인력_둘_잡품_재료_손셈(world):
with store.at(store.FOLDER):
one = _hour(world, {"인력": [{"키": "LB:1048", "인": D(1)}]})
two = _hour(world, {"인력": [{"키": "LB:1002", "인": D(2)}]})
both = _hour(
world, {"인력": [{"키": "LB:1048", "인": D(1)}, {"키": "LB:1002", "인": D(2)}]}
)
assert abs(both["노무비"] - one["노무비"] - two["노무비"]) <= 1
assert both["재료비"] == one["재료비"] and both["경비"] == one["경비"]
crew = [x for x in both["줄"] if x.get("이름") == "조종원"]
assert len(crew) == 2
none = _hour(world, {"인력": []})
assert none["노무비"] == 0
# 잡품 재료 줄 — 주연료 22 % + 재료 2 × 50 %
base = _hour(world, {})
mat = _hour(world, {"잡품": [{"줄": "주연료", "율": D(22)},
{"키": "MO:휘발유:전국평균", "수량": D(2), "율": D(50)}]}) # fmt: skip
price = world["index"].get("MO:휘발유:전국평균")["값"]
assert abs(mat["재료비"] - base["재료비"] - D(str(price)) * 2 * D("0.5")) <= 1
# 단가산출 덮음 = 목록 통째 위에(정본 2 줄 → 덮음 1 줄)
top = _hour(world, {"인력": [{"키": "LB:1048", "인": D(1)}, {"키": "LB:1002", "인": D(2)}]},
{"0201-0100": {"조종원": [{"키": "LB:1048", "인": D(1)}]}}) # fmt: skip
assert top["노무비"] == one["노무비"]
def test_빈_직종_줄은_식_입력_직종(world):
doc = world["docs"]["GC000275"]
parts = mcv.parts_of(doc)
assert mcv.row_entry(doc, {"인력": [{"키": "", "인": 1}]}) is None # 한 줄 = 식 그대로
got = mcv.cover_doc(doc, {"기계": "M"}, None, {"조종원": [{"키": "", "인": 2}]})
crew = [x for x in got["금액"] if x.get("비목") == "노무비"]
assert len(crew) == 1 and crew[0]["단가"] == doc["금액"][parts["crew"][0]]["단가"]
# ── ④ 거울 ─────────────────────────────────────────────────────────────
VALUES = [
{"가격": 1, "주연료": 19.5, "조종원": 1, "조종원직종": "1048", "잡재료율": 22},
{"가격": 1, "조종원": 1, "조종원직종": None, "잡재료율": 0, "주연료": 3},
{"가격": 1, "전력": 5, "주연료": 2, "잡재료율": 7, "조종원": 0},
{"가격": 1, "인력": [{"키": "LB:1048", "인": 1}, {"키": "LB:1002", "인": 2}], "잡품": []},
{"가격": 1, "인력": [{"키": "", "인": 1}], "잡품": [{"키": "MA:1", "수량": 2, "율": 5}]},
{"가격": 1},
]
TARGETS = [
("GC000268", {"인력": [{"키": "LB:1048", "인": 1}], "잡품": [{"줄": "주연료", "율": 10}]}, None),
("GC000268", {"인력": [{"키": "LB:1048", "인": 1}, {"키": "LB:1002", "인": 1}]}, None),
("GC000268", {"인력": [], "잡품": [{"줄": "주연료", "율": 10}, {"키": "MA:1", "율": 5}]}, None),
("GC000272", {"인력": [{"키": "LB:1048", "인": 1}], "잡품": [{"줄": "주연료", "율": 3, "수량": 1}]},
{"값": {"가격": 9}, "잡품": [{"줄": "주연료", "율": 4}]}),
("GC000273", {"인력": [{"키": "LB:1048", "인": 1}], "잡품": [{"키": "MA:1", "율": 5}]}, None),
("GC000275", {"인력": [{"키": "", "인": 2}, {"키": "LB:1050", "인": 1}]}, None),
("GC001200", {"잡품": [{"줄": "주연료", "율": 7}, {"줄": "전력", "율": 7}]}, None),
] # fmt: skip
def test_거울_파이썬_TS_같은_답(world):
docs = {k: _plain(world["docs"][k]) for k, _, _ in TARGETS}
ask = {"값들": VALUES, "문서들": docs,
"대상": [{"식": k, "값": v, **({"덮음": c} if c else {})} for k, v, c in TARGETS]} # fmt: skip
proc = subprocess.run(
["node", os.path.join(HERE, "helper_842_3_machine_crew.cjs")],
input=json.dumps(ask, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
got = json.loads(proc.stdout)
assert got["읽기"] == _plain([mcrew.view(v) for v in VALUES])
assert got["옛"] == _plain([mcrew.from_old(v) for v in VALUES])
base = [mcv.row_entry(docs[k], v) for k, v, _ in TARGETS]
assert got["바탕"] == _plain(base)
assert [b is None for b in base] == [True, False, False, False, False, False, True]
want = [
mcv.cover_doc(docs[k], {"기계": "M"}, {"M": c} if c else None, b)
for (k, _, c), b in zip(TARGETS, base)
]
assert got["얹음"] == _plain(want)
# ── ⑤ 내 기계 저장 꼴 ─────────────────────────────────────────────────
def test_내기계_저장_목록_받음(world):
with store.at(store.FOLDER):
got = mym.clean({"이름": "sub3임시", "방식": "바로", "값": {
"가격": 1000, "손료계수": 2000, "주연료": 5, "주연료종류": "경유",
"인력": [{"키": "LB:1048", "인": 1}, {"키": "LB:1002", "인": 2}],
"잡품": [{"줄": "주연료", "율": 10}, {"키": "MO:휘발유:전국평균", "수량": 1, "율": 50}]}}) # fmt: skip
assert len(got["값"]["인력"]) == 2 and len(got["값"]["잡품"]) == 2
assert got["단위"]["인력"] == "인/일" and got["출처"]["잡품"] == "개인"
old = mym.clean({"이름": "sub3임시", "방식": "바로", "값": {
"가격": 1000, "손료계수": 2000, "주연료": 5, "주연료종류": "경유",
"조종원": 1, "조종원직종": "1048", "잡재료율": 10}}) # fmt: skip
assert old["값"]["인력"] == [{"키": "LB:1048", "인": 1}]
assert old["값"]["잡품"] == [{"줄": "주연료", "율": 10}]
assert not any(k in old["값"] for k in mcrew.OLD)
for bad in ({"인력": [{"키": "LB:없음", "인": 1}]}, {"인력": [{"키": "LB:1048", "인": -1}]},
{"잡품": [{"줄": "없는줄", "율": 1}]}, {"잡품": [{"키": "MA:없음", "율": 1}]}): # fmt: skip
with pytest.raises(store.StoreError) as e:
mym.clean(
{"이름": "sub3임시", "방식": "바로", "값": {"가격": 1, "손료계수": 1, **bad}}
)
assert e.value.status == 400, bad