Files
Aislo/A00_Common/line_stack_machine_sheet.ts
T
eomsangdonandClaude Opus 5.5 c8cd98dec1 feat(846-2): 기계 줄 시간당 작업량 화면 — 산출근거 「시간당 작업량」 줄 먼저 · 비목 ÷ 시간당 작업량 · 제어 구역 일당 / 시간당 식 고르기 · 변수 칸 · 기계 보기 작업량 식
- 산출근거: 일당 「시간당 작업량 = 시공량 560 ㎥/일 ÷ (1 대 × 8 시간) = 70 ㎥/시간」 · 시간당 식 = q · K · f · E · cm 값 줄 + 한 층 분수 Q 식 · 비목 줄 「59,020 원/시간 ÷ 70 ㎥/시간 = 843.1 원」(금액 그대로)
- 제어 구역: 「시간당 작업량」 묶음 맨 위 — 방식 고르기(식 없는 기계 = 잠김 + 까닭) · 시간당 = 작업량 틀 변수 칸(고침 · 원래 값) · 덮음 = 기계덮음.<기계>.작업량
- 기계 보기: 왼쪽 작업량 카드(기계 정본 변수) · 오른쪽 풀이 · 서버가 작업량 틀 연결을 실음(M02_MyMachine_Sheet._work)
- 시험: test_846_2_work_view 새로 · test_845_2 GC000088 비목 줄 기대값을 새 꼴로 고침(846-2 표시 변경) · 헬퍼 가짜 DOM 이 진짜 DOM 처럼 요소를 옮김(같은 요소 두 줄 재사용 흠을 잡게)

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

592 lines
22 KiB
TypeScript

/* =============================================================================
* line_stack_machine_sheet.ts
* 기계 보기(PLAN 840-2 · 요구 853 · 862 「기계 보기 공용」) — 기계 한 대를 단가산출 수식 보기와 닮은 꼴로:
* 왼쪽 = 기계 제어 구역(836-1 `line_stack_machine_ctl` 그대로 — 값 칸 · 인력 줄 · 잡품 재료 줄 여럿 · 842-3) +
* 「기본 정보 · 손료 근거」 접이 카드(이름 · 규격 · 손료 방식 · 품셈 비율 · 식이 안 읽는 값 칸) + 비슷한 기계에서 시작(새 기계)
* 오른쪽 = 그 기계 시간당 경비 산출근거(`line_stack_out` · 노 · 재 · 경 · 계 합계 상자) — 값을 바꾸면 브라우저 계산기로 즉시
* 서버(`POST /api/m02/machine-sheet` · `M02_MyMachine_Sheet`) = 식 고름 + 처음 줄 값을 박은 식 문서 + 연결 기록 ·
* 고친 값은 덮음(`기계덮음` 꼴)으로 얹어 셈 · 캐시에 없는 연결 · 식이 바뀔 칸만 뒤에서 다시 물음(옛 요청 끊음)
* 시간당 작업량(846-2) = 왼쪽 접이 카드(작업량 식 변수 = 기계 값) · 오른쪽 풀이(`line_stack_machine_sheet_work`)
* 줄 = 저장 꼴(M01 기계 줄 · 내 기계 줄) — 덮음 ↔ 줄은 `common_util_machine_sheet` · 저장은 부르는 쪽(M01 상세 · 내 기계)
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
import { createInputField, createSelectField, el } from "@ui/ui_template_elements";
import { createFindList } from "@ui/ui_template_find_list";
import { t as L } from "@ui/ui_template_locale";
import { attachPickSearch } from "@ui/ui_template_pick_search";
import { createSegment } from "@ui/ui_template_segment";
import { createText } from "@ui/ui_template_text";
import type { FormulaDoc, Link } from "../common_util/common_util_formula_doc";
import { calcDoc, type DocAnswer } from "../common_util/common_util_formula_doc_calc";
import { createDocCache, type DocRecord } from "../common_util/common_util_formula_doc_cache";
import {
COVER,
coverDoc,
coverOf,
defaultsOf,
partsOf,
putCover,
rowEntry,
type CoverParts,
type MachineCover,
} from "../common_util/common_util_formula_doc_cover";
import type { StackDoc } from "../common_util/common_util_line_stack";
import {
MACHINE_COEFS,
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,
modeOf,
ownedCols,
rangeWarnings,
sameVal,
slotOf,
type MachineMode,
type MachinePick,
type MachineRow,
type MachineValues,
} from "../common_util/common_util_machine_sheet";
import {
MACHINE_UNIT,
machineUnit,
unitText,
varUnit,
} from "../common_util/common_util_line_stack_unit";
import { machineCellName } from "../common_util/common_util_machine_symbol_name";
import { docFoot } from "./formula_doc_foot";
import { buildMachineCtl } from "./line_stack_machine_ctl";
import { sheetWorkCard, sheetWorkOut, type SheetWorkOpts } from "./line_stack_machine_sheet_work";
import { buildOut } from "./line_stack_out";
import "./line_stack.css";
import "./line_stack_machine_sheet.css";
export interface MachineSimilar {
key: string;
name: string;
spec: string;
/** 흐린 꼬리표(구분 · 내 기계 등) */
tag?: string;
row: MachineRow;
}
export interface MachineSheetOptions {
/** 처음 줄(저장본 · 새 기계 = 빈 줄) — 고친 칸 표지 · 되돌림 기준 */
orig: MachineRow;
/** 지금 줄(초안) · 없으면 처음 줄 */
cur?: MachineRow;
/** 내 기계(방식 바로 · 품셈 · 직접 · 그 사람 개인 층 기계 표) */
mine: boolean;
readOnly: boolean;
lists: { fuels: MachinePick[]; jobs: MachinePick[] };
findSimilar?: (q: string) => Promise<MachineSimilar[]>;
onSimilar?: (item: MachineSimilar) => void;
onChange?: () => void;
}
export interface MachineSheetState {
name: string;
spec: string;
/** 지금 줄(이름 · 규격 넣음) */
row: MachineRow;
mode: MachineMode;
/** 숫자 아닌 칸 — 있으면 저장 막음 */
errors: string[];
changed: boolean;
}
interface SheetInfo {
키: string | null;
이름?: string;
까닭?: string;
문서: FormulaDoc | null;
/** 처음 줄 셈 꼴 값(식에 박을 값) */
값?: MachineValues;
연결?: DocRecord;
직종: Record<string, string>;
}
/** 늘 보이는 기계 줄 칸(값이 없어도) — 그 밖 칸은 줄에 있으면 */
const BASE_KEYS = ["가격", "손료계수", "주연료", "주연료종류", "전력"];
const PICKS = ["주연료종류"];
/** 식이 바뀔 수 있는 칸(서버 식 고름) */
const PICKY = ["주연료", "전력", "손료계수", ...MACHINE_COEFS];
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v ?? null)) as T;
/** 값 박은 식(그림용) — 박기 전 기계 칸 단위를 남김(박으면 칸 연결이 수로 바뀌어 단위를 못 읽음 · 845-2) */
const boundView = (d: FormulaDoc, raw: MachineValues): FormulaDoc => {
const b = bindDoc(d, raw);
b.변수?.forEach((v, i) => {
const u = varUnit(d.변수?.[i]);
if (u && !v.단위) v.단위 = u;
});
return b;
};
const text = (v: unknown): string => (v === null || v === undefined ? "" : String(v));
const label = (k: string): string => machineCellName(k);
export function createMachineSheet(o: MachineSheetOptions) {
const mine = o.mine;
const ro = o.readOnly;
let orig: MachineRow = clone(o.orig);
let cur: MachineRow = clone(o.cur ?? o.orig);
let name = text(cur.이름);
let spec = text(cur.규격);
const start = JSON.stringify([name, spec, cur]);
const no = text(cur.원문번호) || text(cur.키) || "새 기계";
const given = { 기계: no };
const link: Link = { 갈래: "단가산출", 입력: { 기계: `'${no.replace(/'/g, "")}'` } };
const holder = { 꼴: "수식문서", 변수: [], 식: [], 금액: [] } as unknown as StackDoc;
const cache = createDocCache();
const opened = new Set<string>();
const errors = new Map<string, string>();
let info: SheetInfo | null = null;
let jobs: Record<string, string> = {};
let parts: CoverParts | null = null;
let base: Required<MachineCover> = { 값: {}, 조종원: [], 잡품: [] };
const left = el("div", { className: "lstk__left" });
const right = el("div", { className: "lstk__right" });
const foot = el("div", { className: "lstk__foot" });
const notice = el("div");
const warn = el("div");
const ctlBox = el("div");
const baseBox = el("div");
const workBox = el("div");
const root = el("div", {
className: "lstk",
attrs: { "data-edit": "1", "data-machine-sheet": no },
children: [el("div", { className: "lstk__areas", children: [left, right] }), foot],
});
attachCollapsible(baseBox);
const sub = (): FormulaDoc | null => info?.문서 ?? null;
const entry = (): MachineCover => coverOf(holder)[no] ?? {};
/** 값 박은 셈(덮음 얹은 뒤 · 제어 구역 기본값) */
const plain = (d: FormulaDoc, g: Record<string, unknown>): DocAnswer =>
raw(bindDoc(d, info?.값 ?? {}), g);
const raw = (d: FormulaDoc, g: Record<string, unknown>): DocAnswer =>
calcDoc(d, cache.links, g, [], new Map(), {});
/** 식이 바뀌거나 처음 줄 · 방식이 바뀐 뒤 — 덮음을 처음 줄 대 지금 줄로 다시 지음 */
const rebuild = (): void => {
delete (holder as { [COVER]?: unknown })[COVER];
const d = sub();
parts = d ? partsOf(d) : null;
if (!d || !parts) return;
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);
};
// ── 서버(뒤에서) ──
let seq = 0;
let timer: number | undefined;
let ctl: AbortController | null = null;
const ask = (now = false): void => {
window.clearTimeout(timer);
timer = window.setTimeout(() => void run(), now ? 0 : 300);
};
const run = async (): Promise<void> => {
ctl?.abort();
ctl = new AbortController();
const mineSeq = ++seq;
const body = { 줄: rowOut(), 본: orig, 덮음: coverOf(holder)[no] ?? null, 개인: mine };
try {
const r = await fetch(`${API_BASE_URL}/m02/machine-sheet`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: ctl.signal,
});
const got = (await r.json().catch(() => ({}))) as SheetInfo & { detail?: unknown };
if (mineSeq !== seq) return;
if (!r.ok) throw new Error(text(got.detail) || r.statusText);
cache.merge(got.연결);
jobs = got.직종 ?? jobs;
if (!info || got.키 !== info.키) {
info = got;
rebuild();
drawAll();
} else {
info.까닭 = got.까닭; // 식은 그대로 — 까닭만 지금 줄로
drawLive();
}
} catch (e) {
if (mineSeq !== seq || (e as Error).name === "AbortError") return;
info = { 키: null, 까닭: e instanceof Error ? e.message : "", 문서: null, 직종: jobs };
parts = null;
drawAll();
}
};
const rowOut = (): MachineRow => ({ ...cur, 이름: name.trim(), 규격: spec.trim() });
const changed = (refit = false): void => {
o.onChange?.();
drawLive();
ask(refit);
};
// ── 품셈 비율 → 계수(같으면 처음 값 · 제어 구역 칸은 덮음으로) ──
const recoef = (): void => {
if (modeOf(cur, mine) !== "품셈") return;
const v = (cur.값 ??= {});
const same = MACHINE_INPUTS.every((k) => sameVal(v[k], orig.값?.[k]));
const got = same ? null : machineCoefOf(v);
if (!same && !got) return;
const owned = parts ? ownedCols(parts) : [];
const e = clone(entry());
for (const k of MACHINE_COEFS) {
const want = same ? orig.값?.[k] : got![k];
if (owned.includes(k)) {
e.값 = { ...(e.값 ?? {}) };
if (want === undefined || same) delete e.값[k];
else e.값[k] = want as number;
} else if (want === undefined) delete v[k];
else if (!sameVal(v[k], want)) v[k] = want;
}
if (parts) {
putCover(holder, no, e, base);
cur = applyCover(parts, orig, cur, entry(), mine, jobs);
}
};
// ── 왼쪽 ──
const drawCtl = (): void => {
const d = sub();
if (!d || !info?.키) return void ctlBox.replaceChildren();
const mode = modeOf(cur, mine);
const lock = mode === "품셈" ? [...MACHINE_COEFS] : mine && mode === "바로" ? ["정비비계수", "관리비계수"] : []; // prettier-ignore
const zone = buildMachineCtl(
{
doc: holder,
answer: { 값: {} } as unknown as DocAnswer,
subDoc: () => d,
calc: plain,
change: (fn) => {
fn(holder);
const e = entry();
if (e.조종원) {
e.조종원 = e.조종원.map((r) => ({ ...r, 키: crewKey(r.키, jobs) }));
putCover(holder, no, e, base);
}
if (parts) cur = applyCover(parts, orig, cur, entry(), mine, jobs);
changed();
},
name: (k) => cache.names.get(k) ?? "",
readOnly: ro,
lists: () => engineVals(orig, mine),
lock,
},
{ ...link, 키: info.키 },
);
if (zone && lock.length && mode === "품셈") zone.title = L("MS_Locked");
ctlBox.replaceChildren(...(zone ? [zone] : []));
};
/** 칸 단위 — 줄 `단위` 글(일 단위 손료 등 · 기계 표 기본과 다를 때) › 기계 표 칸 단위(845-2 한 벌) */
const unitOf = (k: string): string => {
const u = text((cur.단위 as MachineValues | undefined)?.[k]);
return u && u !== MACHINE_UNIT[k] ? unitText(u) : machineUnit(k);
};
const numField = (k: string, read = false): HTMLElement => {
const v = cur.값?.[k];
const unit = unitOf(k);
const f = createInputField({ label: label(k), value: text(v), size: 28, ...(unit ? { unit } : {}) }); // prettier-ignore
f.input.inputMode = "decimal";
f.input.dataset.key = k;
f.input.readOnly = ro || read;
f.root.classList.toggle("is-changed", !sameVal(v, orig.값?.[k]));
f.input.addEventListener("change", () => {
const t = f.input.value.replace(/,/g, "").trim();
const box = (cur.값 ??= {});
const n = Number(t);
if (t && !Number.isFinite(n)) {
errors.set(k, `${label(k)} · ${L("MF_NumberOnly")}`);
f.setError(L("MF_NumberOnly"));
return o.onChange?.();
}
errors.delete(k);
f.setError();
if (!t) {
if (k in (orig.값 ?? {})) box[k] = null;
else delete box[k];
} else box[k] = n;
if ((MACHINE_INPUTS as readonly string[]).includes(k)) recoef();
f.root.classList.toggle("is-changed", !sameVal(box[k], orig.값?.[k]));
changed(PICKY.includes(k));
if ((MACHINE_INPUTS as readonly string[]).includes(k)) drawBase();
});
return f.root;
};
const pickField = (k: string): HTMLElement => {
const list = k === "주연료종류" ? o.lists.fuels : o.lists.jobs;
const now = text(cur.값?.[k]);
const options = [
{ value: "", text: L("MF_None") },
...list.map((p) => ({ value: p.value, text: p.text })),
];
if (now && !list.some((p) => p.value === now)) options.push({ value: now, text: `${now} (${L("MF_NoPrice")})` }); // prettier-ignore
const f = createSelectField({ label: label(k), options, value: now, size: 28 });
f.select.dataset.key = k;
f.select.disabled = ro;
attachPickSearch(f.select, { placeholder: L("MF_PickFind") });
f.root.classList.toggle("is-changed", !sameVal(now, orig.값?.[k]));
f.select.addEventListener("change", () => {
const box = (cur.값 ??= {});
if (f.select.value) box[k] = f.select.value;
else if (k in (orig.값 ?? {})) box[k] = orig.값![k] === "" ? "" : null;
else delete box[k];
f.root.classList.toggle("is-changed", !sameVal(box[k], orig.값?.[k]));
changed(true);
});
return f.root;
};
const setMode = (m: MachineMode): void => {
const v = (cur.값 ??= {});
const was = modeOf(cur, mine);
if (m === was) return;
if (m === "품셈") for (const k of MACHINE_INPUTS) if (orig.값?.[k] !== undefined) v[k] = clone(orig.값[k]); // prettier-ignore
if (m === "바로" && !mine) for (const k of MACHINE_INPUTS) delete v[k];
if (mine) {
if (m === "바로" && blankNum(v.손료계수)) {
const e = engineVals(cur, true);
v.손료계수 = ["상각비계수", "정비비계수", "관리비계수"].reduce(
(s, k) => s + (Number(e[k]) || 0),
0,
);
}
if (m === "직접" && !cur.시간당) cur.시간당 = Object.fromEntries((["노무비", "재료비", "경비"] as const).map((k) => [k, Math.round(lastAnswer?.[k] ?? 0)])); // prettier-ignore
cur.방식 = m;
}
recoef();
rebuild();
drawAll();
changed(true);
};
const blankNum = (x: unknown): boolean => typeof x !== "number" || !x;
const drawBase = (): void => {
const keep = baseBox.firstElementChild?.classList.contains("is-collapsed") ?? false;
const mode = modeOf(cur, mine);
const owned = new Set(
(parts && info?.키 ? ownedCols(parts) : [])
.map((c) => slotOf(c, mode, mine))
.flatMap((s) => (s && s[0] === "값" ? [s[1]] : [])),
);
const pumsem = mode === "품셈";
const keys =
mode === "직접"
? []
: [...new Set([...BASE_KEYS, ...Object.keys(cur.값 ?? {})])].filter(
(k) => !owned.has(k) && !(MACHINE_INPUTS as readonly string[]).includes(k) && !(pumsem && (MACHINE_COEFS as readonly string[]).includes(k)), // prettier-ignore
);
const textBox = (
lab: string,
get: string,
was: string,
set: (v: string) => void,
): HTMLElement => {
const f = createInputField({ label: lab, value: get, size: 28 });
f.input.readOnly = ro;
f.root.classList.toggle("is-changed", get !== was);
f.input.addEventListener("input", () => {
set(f.input.value);
f.root.classList.toggle("is-changed", f.input.value !== was);
o.onChange?.();
});
return f.root;
};
const seg = createSegment({
options: [
{ value: "바로", label: L("MF_CoefDirect") },
{ value: "품셈", label: L("MF_CoefPumsem") },
...(mine ? [{ value: "직접", label: L("MF_HourlyDirect") }] : []),
],
value: mode,
size: 24,
ariaLabel: L("MS_Mode"),
onChange: (v) => setMode(v as MachineMode),
});
if (ro) seg.root.querySelectorAll("button").forEach((b) => (b.disabled = true));
const row = (children: HTMLElement[]): HTMLElement => el("div", { className: "lstk-frow", children }); // prettier-ignore
const card = createPanelCard(L("MS_Base"), keep);
card.append(
row([
textBox(L("MF_Name"), name, text(orig.이름), (v) => (name = v)),
textBox(L("MF_Spec"), spec, text(orig.규격), (v) => (spec = v)),
]),
row([el("span", { className: "lstk-kind", text: L("MS_Mode") }), seg.root]),
...(pumsem
? [
row(MACHINE_INPUTS.map((k) => numField(k))),
row([
el("span", { className: "lstk-kind", text: L("MS_Coefs") }),
...MACHINE_COEFS.filter((k) => !owned.has(k)).map((k) => numField(k, true)),
]),
]
: []),
...(keys.length
? [row(keys.map((k) => (PICKS.includes(k) ? pickField(k) : numField(k))))]
: []),
);
baseBox.replaceChildren(card);
};
const similarBox = (): HTMLElement[] => {
if (!o.findSimilar || ro) return [];
const find = createFindList({ placeholder: L("MF_SimilarFind") });
const card = createPanelCard(L("MF_Similar"), !!(orig.이름 || orig.값 && Object.keys(orig.값).length)); // prettier-ignore
card.classList.add("lstk-msheet-similar");
card.append(find.root);
attachCollapsible(card);
let n = 0;
let wait: number | undefined;
const look = async (): Promise<void> => {
const me = ++n;
const got = await o.findSimilar!(find.search.value.trim()).catch(() => []);
if (me !== n) return;
find.hint.textContent = got.length ? "" : L("MF_SimilarNone");
find.setRows(
got.map((m) => ({
key: m.key,
name: `${m.name} ${m.spec}`.trim(),
tag: m.tag,
sub: m.key,
onPick: () => {
orig = clone(m.row);
cur = clone(m.row);
name = m.name;
spec = m.spec;
errors.clear();
info = null;
parts = null;
delete (holder as { [COVER]?: unknown })[COVER];
card.classList.add("is-collapsed");
setNotice(L("MF_SimilarCopied").replace("{value}", `${m.name} ${m.spec}`.trim()));
o.onSimilar?.(m);
drawAll();
changed(true);
},
})),
);
};
find.search.addEventListener("input", () => {
window.clearTimeout(wait);
wait = window.setTimeout(() => void look(), 250);
});
void look();
return [card];
};
// ── 오른쪽 · 경고 ──
let lastAnswer: DocAnswer | null = null;
const drawRight = (): void => {
const d = sub();
const head = el("div", {
className: "lstk__bar",
children: [
el("strong", { text: L("MS_Out") }),
...(info?.키 ? [el("span", { className: "ui-text ui-text--muted", text: `${info.키} ${info.이름 ?? ""}`.trim() })] : []), // prettier-ignore
],
});
if (!info) {
lastAnswer = null;
foot.replaceChildren();
return void right.replaceChildren(head, createText(L("MS_Loading"), "muted"));
}
if (!d) {
lastAnswer = null;
foot.replaceChildren();
const why = info.까닭 ? ` — ${info.까닭}` : "";
return void right.replaceChildren(head, createText(`${L("MS_NoFormula")}${why}`, "warning"));
}
const shaped = coverDoc(d, given, coverOf(holder), rowEntry(d, engineVals(orig, mine)));
const a = plain(shaped, given);
if (cache.takeMissing()) ask();
lastAnswer = a;
right.replaceChildren(
head,
...(info.까닭 ? [createText(info.까닭, "warning")] : []),
buildOut(boundView(shaped, info.값 ?? {}) as StackDoc, a, {
cache,
open: opened,
toggle: (at) => {
if (opened.has(at)) opened.delete(at);
else opened.add(at);
drawRight();
},
miss: () => ask(),
redraw: () => drawRight(),
}),
);
right.append(sheetWorkOut(work()));
if (cache.takeMissing()) ask();
foot.replaceChildren(docFoot(a));
};
// 846-2 시간당 작업량 — 지금 줄 `값.작업량`(고침 = 지금 줄 · 저장은 부르는 쪽)
const work = (): SheetWorkOpts => ({
cur,
orig,
ro,
links: cache.links,
miss: () => undefined,
changed: () => changed(),
});
const drawWork = (): void => {
const keep = workBox.firstElementChild?.classList.contains("is-collapsed") ?? false;
workBox.replaceChildren(sheetWorkCard(work(), keep));
if (cache.takeMissing()) ask();
};
const drawWarn = (): void => {
const vals: MachineValues = crewView({ ...engineVals(cur, mine), ...(cur.값 ?? {}) });
if (unitOf("손료계수") !== machineUnit("손료계수")) delete vals.손료계수; // 일 단위 손료(임업) = 시간당 범위 아님
warn.replaceChildren(
...rangeWarnings(vals).map(([k, r]) =>
createText(L("MS_Range").replace("{name}", label(k)).replace("{value}", r), "warning"),
),
);
};
const drawLive = (): void => {
drawCtl();
drawWork();
drawRight();
drawWarn();
};
const drawAll = (): void => {
drawBase();
drawLive();
};
const setNotice = (t: string): void => {
notice.replaceChildren(...(t ? [createText(t, "warning")] : []));
};
left.append(...similarBox(), notice, warn, ctlBox, workBox, baseBox);
drawAll();
ask(true);
return {
root,
setNotice,
state: (): MachineSheetState => ({
name: name.trim(),
spec: spec.trim(),
row: rowOut(),
mode: modeOf(cur, mine),
errors: [...errors.values()],
changed: JSON.stringify([name, spec, cur]) !== start,
}),
};
}