Merge branch 'sub_laptop_3' of C:/Aislo_wt/sub_laptop_3 into main_laptop_1

This commit is contained in:
2026-11-15 14:07:41 +09:00
12 changed files with 508 additions and 253 deletions
+14
View File
@@ -29,6 +29,20 @@ export function nameMachines(list: string[], redraw: () => void): void {
});
}
let asked = false;
/** 글 값이 기계 원문번호면 「이름 규격 (원문번호)」(838-3 · 요구 859) — 이름이 아직 없으면 한 번 받아 `redraw` · 아니면 null */
export function machineText(x: unknown, redraw: () => void): string | null {
if (typeof x !== "string" || !MACHINE.test(x)) return null;
if (!(x in names) && !asked) {
asked = true;
void cfMachineNames().then((got) => {
names = { ...names, ...got };
redraw();
});
}
return optionText(x);
}
export function buildOptions(
doc: FormulaDoc | null,
given: Record<string, unknown>,
+34
View File
@@ -473,3 +473,37 @@
grid-template-columns: minmax(0, 1fr);
}
}
/* 836-1 기계 제어 구역 — 기계 줄 아래 접는 구역(값 칸 · 조종원 인력 줄 · 잡품 재료 줄) · 줄 꼴 = 단가산출 줄과 닮음 */
.lstk-mctl {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin-block-start: var(--spacing-4);
padding: var(--spacing-8);
border-radius: var(--radius-sm);
background: var(--color-surface-sunken);
}
.lstk-mctl > .lstk-frow {
display: grid;
grid-template-columns: 7em auto 11em auto auto;
justify-content: start;
}
.lstk-mctl > .lstk-frow > .lstk-sym {
max-width: none;
}
.lstk-mctl .lstk-f-s {
width: auto;
}
.lstk-mctl .lstk-line.k-ctl > .lstk-f-s {
flex: 0 1 9em;
min-width: 6em;
}
.lstk-mctl .lstk-line.k-ctl > :last-child {
margin-inline-start: auto;
}
+35 -15
View File
@@ -20,7 +20,7 @@ import { varId, type FormulaDoc, type MoneyLine } from "../common_util/common_ut
import { isNum, type DocAnswer } from "../common_util/common_util_formula_doc_calc";
import { lineMap, offLine, type Values } from "../common_util/common_util_formula_doc_view";
import type { DocTable } from "../common_util/common_util_formula_doc_table";
import type { DocRecord } from "../common_util/common_util_formula_doc_cache";
import { coverOf } from "../common_util/common_util_formula_doc_cover";
import {
dropSyms,
docOf,
@@ -38,12 +38,7 @@ import {
} from "../common_util/common_util_line_stack";
import { placeNames } from "../common_util/common_util_line_stack_copy";
import { askFileWide, materialMark, matEntry } from "./line_stack_material";
import {
draftsOf,
machineNow,
pruneDrafts,
putDraft,
} from "../common_util/common_util_line_stack_draft";
import { draftsOf, machineNow, pruneDrafts } from "../common_util/common_util_line_stack_draft";
import {
addPicked,
type Picked,
@@ -57,6 +52,7 @@ import { createUnitPick, NO_UNIT } from "../M02_MasterTemplete/DataFlow/M02_DF_U
import { reach } from "./formula_doc_render";
import { attachDrag } from "./line_stack_drag";
import { openMachineWindow, type MachineCtx } from "./line_stack_machine";
import { buildMachineCtl, machineOfLine } from "./line_stack_machine_ctl";
import { openStackPick, type StackTake } from "./line_stack_pick";
import { buildBlock, sourceTag } from "./line_stack_rows";
import { lt, type LsKey } from "./line_stack_text";
@@ -78,10 +74,10 @@ export interface EditHooks {
take?: StackTake;
/** 저장본 식 줄 {기호: 식} — 만약 식을 고쳐 굳은 줄 표지(요구 696) */
before?: ReadonlyMap<string, unknown>;
/** 827-1 기계 줄 = 기계 창(조회 · 바꾸기 · 고쳐 쓰기) — 층 · 권한 · 사본 줄 */
/** 827-1 기계 줄 = 기계 창(찾기 · 바꾸기 · 836-1) — 층 · 사본 줄 */
machine?: MachineCtx;
/** 827-1 초안 기계 셈 기록 → 계산기 캐시(브라우저 즉시 셈 · 서버 미리 셈과 같은 값) */
prime?: (record: DocRecord) => void;
/** 836-1 기계 제어 구역 — 덮음 없이 그 기계 식 셈(원본 = 기본값) */
calc?: (doc: FormulaDoc, given: Record<string, unknown>) => DocAnswer;
}
const field = (
@@ -192,10 +188,6 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
now: machineNow(d, link as { 갈래?: string; 입력?: Record<string, string> }),
query,
pick: swap,
draft: (item, p, record) => {
h.prime?.(record);
h.change((doc) => putDraft(doc, i, own, item, p));
},
})
: openStackPick(kind, swap, undefined, query),
});
@@ -203,6 +195,24 @@ function moneyBody(h: EditHooks, i: number): HTMLElement[] {
pick.title = `${lt("Repick")}${res ? ` — ${res}` : ""}`;
pick.dataset.repick = String(i);
head.push(pick);
if (kind === "기계" && h.calc) {
// 836-1 기계 제어 구역 접기 · 고친 기계 표지
const on = h.open.has(`mctl:${i}`);
const t = createIconButton({
icon: on ? "chevronDown" : "chevronRight",
title: lt("Ctl_Open"),
size: 24,
onClick: () => {
if (on) h.open.delete(`mctl:${i}`);
else h.open.add(`mctl:${i}`);
h.change(() => undefined);
},
});
t.dataset.mctlToggle = String(i);
head.push(t);
const m = machineOfLine(h.answer, link);
if (m && coverOf(d)[m]) head.push(createBadge(lt("Ctl_Changed"), "info", { size: 18 }));
}
const mark = machineMark(found, link);
if (mark) head.push(mark);
const matMark = kind === "재료" ? materialMark(matEntry(d, own), link) : null; // 827-3 판정 · 단서
@@ -310,7 +320,9 @@ function qtyBox(
);
f.dataset.qty = String(i);
const val = v ? (h.answer.값 ?? {})[text] : undefined;
const tag = v ? sourceTag(h, text, v.연결) : null;
// 838-3 공식 · 표 값 묶음에 이미 보이는 기호 = 줄 쪽 표 칩 걷음(묶음에서 고름) · 묶음에 없으면 줄에서 고름
const inBlock = h.items.some((it) => it.줄.some((l) => l.꼴 === "공식" && l.기호.includes(text)));
const tag = v && !inBlock ? sourceTag(h, text, v.연결) : null;
if (tag && isNum(val))
tag.title = `${eqText(val)}${shownUnit(v?.단위) ? ` ${shownUnit(v?.단위)}` : ""} — ${tag.title || tag.textContent}`;
return el("span", { className: "lstk-qty", children: tag ? [f, tag] : [f] });
@@ -490,6 +502,14 @@ export function buildEdit(h: EditHooks): HTMLElement {
: [lineGrip, ...body, del],
});
card.dataset.loc = `${i}.${j}`;
if (line.꼴 === "금액" && h.calc && h.open.has(`mctl:${line.i}`)) {
const own = ownedOf(h.doc).get(line.i);
const zone = buildMachineCtl(
{ ...h, calc: h.calc, change: (fn) => h.change((d) => fn(d)), readOnly: !h.machine?.scope }, // prettier-ignore
h.doc.변수?.find((x) => varId(x) === own)?.연결,
);
if (zone) card.append(zone);
}
card.addEventListener("pointerdown", () => h.pair(`${i}.${j}`));
attachDrag(lineGrip, {
what: "line",
+36 -220
View File
@@ -1,48 +1,38 @@
/* =============================================================================
* line_stack_machine.ts
* 줄 쌓기 기계 창(PLAN 827-1 · 요구 810 · 811) — 기계 줄 이름 단추 · 옵션 가로 [조회]가 띄움.
* 왼쪽 = 기계 찾기 — 맨 위 「지금 이 줄」 · 이 단가산출 초안 · 개인 층 내 기계 · M01 기계(줄마다 시간당)
* 오른쪽 = 공통 기계 양식(`ui_template_machine_form` · 읽기 전용) — 지금 줄 = 이 단가산출 사본에 박힌 값
* (원본 지금 값과 다르면 「새 값 있음」) · 고른 줄 = 그 기계 지금 값
* [이 기계로 바꾸기] = `pick`(부른 쪽 `repick` · 가져온 식 823-1 규칙 그대로)
* [고쳐 쓰기](개인 층 · 쓰기 권한 · `ctx.canEdit`) = 양식 편집 → [확인] = 서버 확인(쓰기 없음) → `draft`
* (부른 쪽이 문서 칸 `내기계초안` 에 · 디스크는 단가산출 [저장] 때 서버) · 그 밖 = 까닭 한 줄
* M01 기계에서 시작 = 새 내 기계(본뜬 = 그 키) · 내 기계 = 그 기계 고침 · 초안 = 그 초안 고침 · 즉시 저장 없음
* 줄 쌓기 기계 창(PLAN 827-1 · 836-1 · 요구 810 · 846 · 853) — 기계 줄 이름 단추 · 옵션 가로 [조회]가 띄움 = 찾기 · 바꾸기만
* 왼쪽 = 기계 찾기 — 맨 위 「지금 이 줄」 · 이 단가산출 옛 초안 · 개인 층 내 기계 · M01 기계(줄마다 시간당)
* 오른쪽 = 고른 기계 이름 · 시간당 · 사본 표지(이 단가산출에 복사된 값 · 원본 새 값) · [이 기계로 바꾸기] = `pick`
* 기계 내부 값 고침 = 단가산출 왼쪽 기계 제어 구역(`line_stack_machine_ctl` · 836-1) — 이 창의 양식 · [고쳐 쓰기] 걷음
* (단가산출 안 고침은 내 기계를 만들지 않음 · 내 기계 목록 · 개인관리 화면은 그대로)
* ========================================================================== */
import { createButton } from "@ui/ui_template_button";
import { createDataTable } from "@ui/ui_template_data_table";
import { createInputField, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
import { createMachineForm, HOURLY_KEY, type MachineValues } from "@ui/ui_template_machine_form";
import { createInputField, el, showToast } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
import { isDraftNo, newTag, type DraftItem } from "../common_util/common_util_line_stack_draft";
import type { DocRecord } from "../common_util/common_util_formula_doc_cache";
import { isDraftNo, type DraftItem } from "../common_util/common_util_line_stack_draft";
import type { Picked } from "../common_util/common_util_line_stack_pick";
import { call, type Layer } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import {
checkDraft,
fetchMyMachineLists,
listMyMachines,
machineArg,
machineRows,
readMyMachine,
type MachineBrief,
type MyHourly,
type MyMachineRow,
} from "../M02_MasterTemplete/M02_MyMachine_Api";
import { bodyOfForm, hourlyText, valuesOf } from "../M02_MasterTemplete/M02_MyMachine_Form";
import { hourlyText } from "../M02_MasterTemplete/M02_MyMachine_Form";
import { mt } from "../M02_MasterTemplete/M02_MyMachine_Text";
import { lt } from "./line_stack_text";
/** 사본 줄(서버 `copy_row`) — 키 · 값 · 방식(내 기계) · 시간당(직접) */
/** 사본 줄(서버 `copy_row`) — 키 · 이름 · 규격 */
interface CopyRowData {
키: string;
원문번호?: string;
이름?: string;
규격?: string;
값?: MachineValues;
방식?: string;
시간당?: Record<string, number>;
}
export interface CopyAnswer {
사본: CopyRowData | null;
@@ -53,11 +43,7 @@ export interface CopyAnswer {
export interface MachineCtx {
/** 층 · 주인 — null = M01 단가산출 기초(M01 기계만) */
scope: { layer: Layer; owner: string | null } | null;
/** [고쳐 쓰기] 됨(개인 층 SK · 쓰기 권한) */
canEdit: boolean;
/** 못 고칠 까닭(M01 · 회사 · 마스터 층) */
why: string;
/** 이 SK 키(쓰는 곳 셈에서 뺌) */
/** 이 SK 키 */
me?: string;
/** 이 SK 사본에 박힌 기계 줄(키 있는 M02 SK) */
copyRow?: (no: string) => Promise<CopyAnswer>;
@@ -71,8 +57,6 @@ export interface MachineWindowOptions {
query: string;
/** 없으면 보기만(옵션 가로 조회) */
pick?: (p: Picked) => void;
/** 고쳐 쓰기 [확인] — 초안 · 줄 연결 · 그 식 셈 기록(계산기 캐시 · 즉시 셈) */
draft?: (d: DraftItem, p: Picked, record: DocRecord) => void;
}
type Kind = "now" | "draft" | "mine" | "m01";
@@ -90,45 +74,15 @@ interface Cand {
key?: string;
}
interface Shown {
values: MachineValues;
name: string;
spec: string;
lines: HTMLElement[];
logic?: string | null;
/** [고쳐 쓰기] 대상 */
target: { 표지?: string; 키?: string; 본뜬?: string };
/** 내 기계 = 쓰는 곳 셈 키 */
mineKey?: string;
}
const kindOf = (no: string): Kind =>
isDraftNo(no) ? "draft" : no.startsWith("개인 ") ? "mine" : "m01";
/** 사본 · 원본 줄 → 양식 값 — 직접 = 시간당 · 바로(셈 꼴) = 계수 셋 · 빈 연료 · 조종원 0 뺌 */
function formValues(row: CopyRowData): MachineValues {
if (row.방식 === "직접") {
const v = row.시간당 ?? row.값 ?? {};
return { [HOURLY_KEY]: { 노무비: v.노무비, 재료비: v.재료비, 경비: v.경비 } };
}
const v: MachineValues = { ...(row.값 ?? {}) };
if (row.방식 === "바로") {
for (const k of ["상각비계수", "정비비계수", "관리비계수"]) delete v[k];
for (const k of ["주연료", "잡재료율", "조종원"]) if (v[k] === 0) delete v[k];
for (const k of ["주연료종류", "조종원직종"]) if (v[k] === "") delete v[k];
}
return v;
}
/** 내 기계 목록 줄 → 양식 값(직접 = 서버 시간당 노 · 재 · 경) */
const mineValues = (m: MyMachineRow): MachineValues =>
valuesOf({
방식: m.방식,
값: m.값,
시간당: {
노무비: m.시간당.노무비 ?? 0,
재료비: m.시간당.재료비 ?? 0,
경비: m.시간당.경비 ?? 0,
},
});
const hourOf = (h: MyHourly | undefined): number | null => (h?.ok ? (h.계 ?? null) : null);
const same = (a: Cand | undefined, b: Cand | null): boolean =>
!!a && !!b && a.kind === b.kind && a.no === b.no && a.key === b.key;
@@ -142,8 +96,6 @@ export function openMachineWindow(o: MachineWindowOptions): void {
const briefs = new Map<string, MachineBrief>();
let cands: Cand[] = [];
let sel: Cand | null = null;
let editing = false;
let formDirty = (): boolean => false;
let seq = 0;
let showSeq = 0;
let timer = 0;
@@ -264,7 +216,7 @@ export function openMachineWindow(o: MachineWindowOptions): void {
return got;
};
/** 고른 줄 → 양식 값 · 글 · 바꿀 연결 · 고칠 대상 */
/** 고른 줄 → 이름 · 글(시간당 · 사본 표지) · 바꿀 식 */
const resolve = async (c: Cand): Promise<Shown> => {
const k = c.kind === "now" ? kindOf(c.no) : c.kind;
const lines: HTMLElement[] = [];
@@ -274,24 +226,7 @@ export function openMachineWindow(o: MachineWindowOptions): void {
lines.push(createText(mt("Win_Draft"), "muted"));
if (d.시간당)
lines.push(createText(`${mt("Server_Hourly")} ${hourlyText(d.시간당 as MyHourly)}`));
const target = {
표지: d.표지,
...(d.키 ? { 키: d.키 } : {}),
...(d.본뜬 ? { 본뜬: d.본뜬 } : {}),
};
return {
values: valuesOf({
방식: d.줄.방식 as never,
값: (d.줄.값 ?? {}) as MachineValues,
시간당: d.줄.시간당 as never,
}),
name: d.줄.이름,
spec: d.줄.규격,
lines,
logic: d.시간당?.로직,
target,
mineKey: d.키,
};
return { name: d.줄.이름, spec: d.줄.규격, lines, logic: d.시간당?.로직 };
}
// 지금 줄 = 이 단가산출 사본 값(있으면)
const copy = c.kind === "now" && ctx.copyRow ? await ctx.copyRow(c.no).catch(() => null) : null;
@@ -302,29 +237,22 @@ export function openMachineWindow(o: MachineWindowOptions): void {
}
if (k === "mine") {
const m = c.mine ?? mines.find((r) => r.원문번호 === c.no);
const key = m?.키 ?? c.no.replace(/^개인 /, "");
const was = o.drafts.find((d) => d.키 === key); // 이미 고친 초안 = 그 초안을 다시 고침
if (!row && m) lines.push(createText(`${mt("Server_Hourly")} ${hourlyText(m.시간당)}`));
if (m) lines.push(createText(`${mt("Server_Hourly")} ${hourlyText(m.시간당)}`));
return {
values: row ? formValues(row) : m ? mineValues(m) : {},
name: row?.이름 ?? m?.이름 ?? c.이름,
spec: row?.규격 ?? m?.규격 ?? c.규격,
lines,
logic: m?.시간당.로직,
target: { 키: key, ...(was ? { 표지: was.표지 } : {}) },
mineKey: key,
};
}
const b = await briefOf(c.no, c.key);
if (!row && b) lines.push(createText(`${mt("Win_Live")} · ${hourlyText(b.시간당)}`));
if (b) lines.push(createText(`${mt("Win_Live")} · ${hourlyText(b.시간당)}`));
if (!row && !b) throw new Error(mt("Hourly_None"));
return {
values: row ? formValues(row) : { ...(b?.값 ?? {}) },
name: row?.이름 ?? b?.이름 ?? c.이름,
spec: row?.규격 ?? b?.규격 ?? c.규격,
lines,
logic: b?.시간당.ok ? b.시간당.로직 : null,
target: { 본뜬: row?.키 ?? b?.키 },
};
};
@@ -335,19 +263,17 @@ export function openMachineWindow(o: MachineWindowOptions): void {
};
const choose = async (c: Cand): Promise<void> => {
if (editing && formDirty() && !(await showConfirmDialog(mt("Leave_Ask")))) return;
editing = false;
sel = c;
table.setRows(cands.map((x) => ({ 품명: x.이름, 규격: x.규격, 금액: x.계, 구분: x.구분 })));
await show(c);
};
async function show(c: Cand, edit = false, blank = false): Promise<void> {
async function show(c: Cand): Promise<void> {
const mine = ++showSeq;
right.replaceChildren(createText(mt("Server_Hourly"), "muted"));
let s: Shown;
try {
s = blank ? { values: {}, name: "", spec: "", lines: [], target: {} } : await resolve(c);
s = await resolve(c);
} catch (error) {
if (mine === showSeq)
right.replaceChildren(
@@ -355,151 +281,41 @@ export function openMachineWindow(o: MachineWindowOptions): void {
);
return;
}
const lists = await fetchMyMachineLists();
if (mine !== showSeq) return;
editing = edit;
const notice = el("div", { className: "lstk-mwin__notice" });
const form = createMachineForm({
name: s.name,
spec: s.spec,
values: s.values,
coefMode: "direct",
allowHourly: true,
fuels: lists.fuels,
jobs: lists.jobs,
power: lists.power,
hour: lists.hour,
readOnly: !edit,
onChange: () => notice.replaceChildren(),
});
formDirty = () => edit && form.state().changed;
const bar: HTMLElement[] = [];
if (!edit) {
const p = pickedOf(c, s);
if (o.pick && c.kind !== "now") {
const b = createButton({
label: mt("Win_Swap"),
variant: "filled",
size: 28,
disabled: !p,
onClick: () => {
if (!p) return;
close();
o.pick!(p);
},
});
b.dataset.mwinSwap = "";
if (!p) b.title = mt("Pick_Direct");
bar.push(b);
}
if (o.draft && ctx.canEdit) {
const b = createButton({
label: mt("Win_Edit"),
variant: c.kind === "now" ? "filled" : "ghost",
size: 28,
iconStart: "pencil",
onClick: () => void show(c, true),
});
b.dataset.mwinEdit = "";
bar.push(b);
} else if (o.draft && ctx.why) bar.push(createText(ctx.why, "muted"));
} else {
const ok = createButton({ label: mt("Win_Ok"), variant: "filled", size: 28 });
ok.dataset.mwinOk = "";
ok.addEventListener("click", () => void confirm(form.state(), s, notice, ok));
bar.push(
ok,
createButton({
label: mt("Win_Cancel"),
variant: "ghost",
size: 28,
onClick: () => void (blank ? choose(c) : show(c)),
}),
);
}
const extra: HTMLElement[] = [];
if (edit) extra.push(createText(mt("Win_Edit_Note"), "muted"));
if (edit && s.target.키) {
const uses = el("div");
extra.push(uses);
void readMyMachine(owner, s.target.키)
.then((one) => {
const n = (one.쓰는곳 ?? []).filter((u) => u.키 !== ctx.me).length;
uses.replaceChildren(createText(mt("Win_Uses").replace("{value}", String(n)), "warning"));
})
.catch(() => undefined);
const p = pickedOf(c, s);
if (o.pick && c.kind !== "now") {
const b = createButton({
label: mt("Win_Swap"),
variant: "filled",
size: 28,
disabled: !p,
onClick: () => {
if (!p) return;
close();
o.pick!(p);
},
});
b.dataset.mwinSwap = "";
if (!p) b.title = mt("Pick_Direct");
bar.push(b);
}
right.replaceChildren(
el("div", {
className: "ui-toolbar",
children: [el("strong", { text: `${s.name} ${s.spec}`.trim() || mt("New") }), ...bar],
children: [el("strong", { text: `${s.name} ${s.spec}`.trim() }), ...bar],
}),
...s.lines,
...extra,
notice,
form.root,
createText(lt("Win_Ctl_Note"), "muted"),
);
right.dataset.mwinMode = edit ? "edit" : "view";
right.dataset.mwinMode = "view";
}
/** [확인] — 서버 확인(쓰기 없음) → 초안 · 줄 연결 */
async function confirm(
state: ReturnType<ReturnType<typeof createMachineForm>["state"]>,
s: Shown,
notice: HTMLElement,
btn: HTMLButtonElement,
): Promise<void> {
const { body, errors } = bodyOfForm(state);
notice.replaceChildren(...errors.map((e) => createText(e, "error")));
if (!body) return;
btn.disabled = true;
try {
const tag = s.target.표지 ?? newTag();
const got = await checkDraft(owner, body, { ...s.target, 표지: tag });
if (!got.시간당.ok || !got.시간당.로직)
throw new Error(hourlyText(got.시간당) || mt("Pick_Direct"));
const item: DraftItem = {
표지: tag,
...(s.target.키 ? { 키: s.target.키 } : {}),
...(!s.target.키 && s.target.본뜬 ? { 본뜬: s.target.본뜬 } : {}),
줄: body,
시간당: got.시간당,
};
const p: Picked = {
키: got.시간당.로직,
이름: body.이름,
입력: { 기계: `'개인 ${tag}'` },
...(body.규격 ? { 규격: body.규격 } : {}),
};
formDirty = () => false;
close();
o.draft!(item, p, got.연결);
} catch (error) {
notice.replaceChildren(
createText(error instanceof Error ? error.message : mt("Failed"), "error"),
);
} finally {
btn.disabled = false;
}
}
const blankBtn = createButton({
label: mt("Win_Blank"),
variant: "ghost",
onClick: () => {
const c: Cand = { kind: "draft", no: "", 이름: "", 규격: "", 계: null, 구분: "" };
sel = null;
void show(c, true, true);
},
});
blankBtn.dataset.mwinBlank = "";
({ close } = openModal({
title: mt("Win_Title"),
closeLabel: mt("Close"),
width: "1100px",
wide: true,
...(o.draft && ctx.canEdit ? { actions: [blankBtn] } : {}),
beforeClose: async () => !formDirty() || showConfirmDialog(mt("Leave_Ask")),
mount: (body) =>
body.append(
el("div", {
+317
View File
@@ -0,0 +1,317 @@
/* =============================================================================
* line_stack_machine_ctl.ts
* 줄 쌓기 기계 제어 구역(PLAN 836-1 · 요구 846 · 849 · 850 · 853) — 왼쪽 기계 줄 아래 접는 구역
* ㉠ 값 칸 = 그 기계 식이 읽는 기계 칸(단위 · 기본 = 사본 값 · 고친 칸 = 「고침」 + [원래 값])
* ㉡ 조종원 = 인력 줄(노임 고르기 · 인/일 · 더하기 · 빼기 · 기본 = 기계 값 조종원 · 직종)
* ㉢ 잡품 = 재료 줄(재료 · 유가 고르기 · 수량 · 율 % · 기본 = 밑 재료 줄 · 품셈 잡재료율)
* 고침 = 문서 칸 `기계덮음`(캐시 · [저장] 때 서버가 정본으로 다시 셈) · 원본과 같으면 지움(`putCover`)
* 기본값 = 덮음 없이 셈한 그 기계 식 답(`defaultsOf`) · 읽기(M01 기초) = 칸 잠김 · 단추 없음
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createButton, createIconButton } from "@ui/ui_template_button";
import { eqText } from "@ui/ui_template_equation";
import { el, showToast } from "@ui/ui_template_elements";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { varId, type FormulaDoc, type Link } from "../common_util/common_util_formula_doc";
import type { DocAnswer } from "../common_util/common_util_formula_doc_calc";
import {
coverOf,
defaultsOf,
machineKey,
partsOf,
putCover,
type CrewRow,
type MachineCover,
type MiscRow,
} from "../common_util/common_util_formula_doc_cover";
import { subInputs, type StackDoc } from "../common_util/common_util_line_stack";
import type { Picked } from "../common_util/common_util_line_stack_pick";
import { openFindPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Find_Pick";
import { openStackPick } from "./line_stack_pick";
import { lt } from "./line_stack_text";
export interface CtlHooks {
doc: StackDoc;
answer: DocAnswer;
subDoc: (key: string) => FormulaDoc | null;
/** 덮음 없이 그 기계 식 셈(원본 = 기본값) */
calc: (doc: FormulaDoc, given: Record<string, unknown>) => DocAnswer;
change: (fn: (d: StackDoc) => void) => void;
name: (key: string) => string;
/** 읽기(M01 기초 · 칸 잠김) */
readOnly: boolean;
}
/** 기계 칸 단위(기계 표 `단위` 와 같음) */
const UNIT: Record<string, string> = {
가격: "천원", 상각비계수: "10⁻⁷/hr", 정비비계수: "10⁻⁷/hr", 관리비계수: "10⁻⁷/hr", 손료계수: "10⁻⁷/hr",
주연료: "ℓ/hr", 전력: "kWh/hr", 노무비: "원/hr", 재료비: "원/hr", 경비: "원/hr",
}; // prettier-ignore
const FUELS = ["경유", "휘발유", "실내등유"];
/** 그 줄 기계 열쇠 — 부를 입력을 지금 값으로 셈(옵션 · 만약 식 기호도) · 기계 줄 아님 = "" */
export const machineOfLine = (answer: DocAnswer, link: Link | undefined): string =>
link?.갈래 === "단가산출"
? machineKey(subInputs(link, (answer.값 ?? {}) as Record<string, unknown>))
: "";
/** 그 기계 줄의 기계 열쇠 · 식 · 원본 기본값 — 기계 식이 아니면 null */
export function ctlOf(h: CtlHooks, link: Link | undefined) {
if (link?.갈래 !== "단가산출" || !link.키) return null;
const given = subInputs(link, (h.answer.값 ?? {}) as Record<string, unknown>);
const machine = machineKey(given);
const sub = h.subDoc(link.키);
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 num = (t: string): number | null => {
const n = Number(t.trim().replace(/,/g, ""));
return t.trim() !== "" && Number.isFinite(n) ? n : null;
};
/** 수 칸 — 빈 글 = 원래대로(null) · 음수 · 글 = 알림 */
function numBox(
label: string,
value: unknown,
unit: string,
ro: boolean,
set: (n: number | null) => void,
hint = "",
): HTMLElement {
const f = createInputField({
value: value === undefined || value === null ? "" : String(value),
placeholder: hint,
compact: true,
size: 24,
...(unit ? { unit } : {}),
});
f.root.classList.add("lstk-f-s");
f.input.setAttribute("aria-label", label);
f.input.title = label;
f.input.disabled = ro;
f.input.addEventListener("change", () => {
const t = f.input.value;
const n = num(t);
if (t.trim() === "") return set(null);
if (n === null || n < 0) {
f.input.value = value === undefined || value === null ? "" : String(value);
return void showToast(lt("Ctl_Bad"), "warning");
}
set(n);
});
return f.root;
}
const kind = (text: string): HTMLElement => el("span", { className: "lstk-kind", text });
/** 기계 제어 구역 — `link` = 그 기계 줄 단가 기호 연결 */
export function buildMachineCtl(h: CtlHooks, link: Link | undefined): HTMLElement | null {
const got = ctlOf(h, link);
if (!got) return null;
const { machine, sub, vals, base } = got;
const entry: MachineCover = got.entry ?? {};
const ro = h.readOnly;
const put = (next: MachineCover): void => h.change((d) => putCover(d, machine, next, base));
const parts = partsOf(sub);
const lines = sub.금액 ?? [];
// ㉠ 값 칸
const values = parts.fields.map(({ 칸: col }) => {
const was = base.값[col];
const mine = entry.값?.[col];
const now = mine ?? was;
const setVal = (v: number | string | null): void => {
const 값 = { ...(entry.값 ?? {}) };
if (v === null || v === "") delete 값[col];
else 값[col] = v;
put({ ...entry, 값 });
};
const box =
col === "주연료종류"
? createSelectField({
options: [...new Set([...FUELS, String(now ?? "")])]
.filter(Boolean)
.map((x) => ({ value: x, text: x })),
value: String(now ?? ""),
compact: true,
size: 24,
disabled: ro,
ariaLabel: col,
onChange: (x) => setVal(x),
}).root
: numBox(col, now, UNIT[col] ?? "", ro, setVal);
const row = el("div", {
className: "lstk-frow",
children: [el("b", { className: "lstk-sym", text: col }), "=", box],
});
if (mine !== undefined) {
const tag = createBadge(lt("Ctl_Changed"), "info", { size: 18 });
tag.title = `${lt("Ctl_Was")} ${was === undefined ? "—" : eqText(was)}`;
row.append(tag);
if (!ro)
row.append(
createButton({
label: lt("Ctl_Back"),
variant: "ghost",
size: 24,
iconStart: "undo",
onClick: () => setVal(null),
}),
);
}
row.dataset.mctlField = col;
return row;
});
// ㉡ 조종원 — 인력 줄
const crew: CrewRow[] = entry.조종원 ?? base.조종원;
const setCrew = (rows: CrewRow[]): void => put({ ...entry, 조종원: rows });
const crewName = (r: CrewRow, i: number): string => {
if (r.이름) return r.이름;
const at = parts.crew[i] ?? parts.crew[0];
const sym = at === undefined ? "" : String(lines[at]?.단가 ?? "");
const v = (sub.변수 ?? []).find((x) => varId(x) === sym);
return (v?.이름 ?? "").replace(/노임$/, "") || h.name(r.키) || r.키;
};
const crewRows = crew.map((r, i) =>
lineRow(
lt("K_인력"),
crewName(r, i),
ro,
() => openStackPick("인력", (p) => setCrew(swapAt(crew, i, { ...r, 키: p.키, 이름: p.이름 })), undefined, crewName(r, i)), // prettier-ignore
[numBox(lt("Ctl_Crew_Qty"), r.인, lt("Ctl_Per_Day"), ro, (n) => setCrew(swapAt(crew, i, { ...r, 인: n ?? 0 })))], // prettier-ignore
"노무비",
() => setCrew(crew.filter((_, k) => k !== i)),
),
);
// ㉢ 잡품 — 재료 줄
const misc: MiscRow[] = entry.잡품 ?? base.잡품;
const setMisc = (rows: MiscRow[]): void => put({ ...entry, 잡품: rows });
const baseQty = (r: MiscRow): number | null => {
const b = lines.find((l) => String(l.이름 ?? "") === r.줄);
const q = b?.수량 ? (vals[String(b.수량)] ?? Number(b.수량)) : null;
return typeof q === "number" && Number.isFinite(q) ? q : null;
};
const miscName = (r: MiscRow): string => {
if (r.이름) return r.이름;
if (r.키) return h.name(r.키) || r.키;
const fuel = r.줄 === "주연료" ? vals.S_연료 : undefined;
return [r.줄, fuel].filter(Boolean).join(" · ");
};
const pickMat = (done: (p: Picked) => void, query: string): void =>
openFindPicker({
layer: "system",
owner: null,
title: `${lt("Find_Title")} — ${lt("Ctl_Misc")}`,
kinds: ["material", "oil"],
query: query || undefined,
element: (e) => done({ 키: e.코드, 이름: e.이름, 단위: e.단위, ...(e.규격 ? { 규격: e.규격 } : {}) }), // prettier-ignore
oil: (e) => done({ 키: e.코드, 이름: e.이름, 단위: e.단위 }),
logic: () => undefined,
});
const asMat = (r: MiscRow, p: Picked): MiscRow => ({
키: p.키,
이름: [p.이름, p.규격].filter(Boolean).join(" "),
...(p.단위 ? { 단위: p.단위 } : {}),
수량: r.수량 ?? baseQty(r) ?? 1,
율: r.율,
});
const miscRows = misc.map((r, i) =>
lineRow(
lt("K_재료"),
miscName(r),
ro,
() => pickMat((p) => setMisc(swapAt(misc, i, asMat(r, p))), r.키 ? miscName(r) : ""),
[
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 }))), // prettier-ignore
],
"재료비",
() => setMisc(misc.filter((_, k) => k !== i)),
),
);
const head = (label: string, covered: boolean, add: () => void, reset: () => void) =>
el("div", {
className: "lstk-fhead",
children: [
el("span", { className: "lstk-kind", text: label }),
...(covered ? [createBadge(lt("Ctl_Changed"), "info", { size: 18 })] : []),
...(ro
? []
: [
createIconButton({ icon: "plus", title: `${label} ${lt("Ctl_Add")}`, size: 24, onClick: add }), // prettier-ignore
...(covered
? [createButton({ label: lt("Ctl_Back"), variant: "ghost", size: 24, iconStart: "undo", onClick: reset })] // prettier-ignore
: []),
]),
],
});
const box = el("div", {
className: "lstk-mctl lstk-wide",
attrs: { "data-mctl": machine },
children: [
...(values.length
? [el("div", { className: "lstk-fhead", children: [kind(lt("Ctl_Values"))] }), ...values]
: []),
head(
lt("Ctl_Crew"),
entry.조종원 !== undefined,
() => 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,
() => pickMat((p) => setMisc([...misc, asMat({ 율: 100 }, p)]), ""),
() => put({ ...entry, 잡품: undefined }),
),
...(miscRows.length ? miscRows : [el("div", { className: "lstk-empty", text: lt("Ctl_None") })]), // prettier-ignore
],
});
return box;
}
const swapAt = <T>(rows: T[], i: number, one: T): T[] => rows.map((r, k) => (k === i ? one : r));
/** 줄 꼴(단가산출 줄과 닮음) — [종류][이름 단추][칸들][비목][X] */
function lineRow(
label: string,
name: string,
ro: boolean,
pick: () => void,
boxes: HTMLElement[],
cost: string,
drop: () => void,
): HTMLElement {
const b = createButton({
label: name || "—",
variant: "ghost",
tone: "link",
size: 24,
iconStart: "search",
disabled: ro,
onClick: pick,
});
b.classList.add("lstk-res");
b.title = `${lt("Repick")} — ${name}`;
return el("div", {
className: "lstk-line k-ctl",
children: [
kind(label),
b,
...boxes,
el("span", { className: "lstk-hint", text: cost }),
...(ro ? [] : [createIconButton({ icon: "x", title: lt("Del"), size: 24, onClick: drop })]),
],
});
}
+20 -6
View File
@@ -15,12 +15,14 @@ import { tableRow, tableShell, type Column } from "@ui/ui_template_table";
import { COSTS, varId, type FormulaDoc } from "../common_util/common_util_formula_doc";
import { calcDoc, isNum, type DocAnswer } from "../common_util/common_util_formula_doc_calc";
import type { DocCache } from "../common_util/common_util_formula_doc_cache";
import { coverDoc, coverOf, type Cover } from "../common_util/common_util_formula_doc_cover";
import {
lineMap,
moneyScope,
offLine,
type Values,
} from "../common_util/common_util_formula_doc_view";
import { machineText } from "./formula_doc_options";
import { reach } from "./formula_doc_render";
import {
kindOf,
@@ -56,6 +58,10 @@ export interface OutHooks {
toggle: (at: string) => void;
/** 캐시에 없는 연결 — 뒤에서 서버에 물음 */
miss: () => void;
/** 836-1 기계 덮음(맨 위 문서 칸) — 펼친 기계 식에 얹음(계산기와 같음) */
cover?: Cover;
/** 838-3 기계 이름을 받은 뒤 다시 그림 */
redraw?: () => void;
}
const COST_SHOW: Record<string, string> = { 노무비: "노무비", 재료비: "재료비", 경비: "경 비" };
@@ -145,9 +151,11 @@ function rows(
};
const expand = (id: string): void => {
const path = `${at}/${id}`;
const sub = subOf(id);
if (!h.open.has(path) || !sub || depth >= MAX_DEPTH) return;
const got = calcDoc(sub, h.cache.links, subInputs(vars.get(id)?.연결, vals));
const raw = subOf(id);
if (!h.open.has(path) || !raw || depth >= MAX_DEPTH) return;
const given = subInputs(vars.get(id)?.연결, vals);
const sub = coverDoc(raw, given, h.cover);
const got = calcDoc(sub, h.cache.links, given, [], new Map(), h.cover);
if (h.cache.takeMissing()) h.miss();
out.push({
depth: depth + 1,
@@ -175,7 +183,9 @@ function rows(
flush();
const res = isNum(v)
? katexNode(`${valTex(v)}\\ ${unit(id) ? `\\text{${unit(id)}}` : ""}`)
: missing(a, id);
: typeof v === "string" // 글 값(기계 원문번호 등 · 838-3) = 그 글 · 기계면 이름
? el("span", { text: machineText(v, () => h.redraw?.()) ?? v })
: missing(a, id);
// 기호식 = 값식 = 결과 — 같은 꼴이 이어지면 한 번만(만약이 수 하나를 탄 줄)
const n = nodeOf(t)!;
const sym = tex(n, { side: "sym", vals, alias });
@@ -217,7 +227,11 @@ function rows(
head,
...expr,
" = ",
isNum(v) ? `${eqText(v)} ${unit(id)}` : typeof v === "string" ? v : missing(a, id),
isNum(v)
? `${eqText(v)} ${unit(id)}`
: typeof v === "string"
? (machineText(v, () => h.redraw?.()) ?? v)
: missing(a, id),
],
});
pack.push(cell);
@@ -408,7 +422,7 @@ export function buildOut(doc: StackDoc, a: DocAnswer, h: OutHooks): HTMLElement
depth: 0,
lv: 0,
};
for (const r of [head, ...rows(doc, a, h, 0, "")]) {
for (const r of [head, ...rows(doc, a, { ...h, cover: coverOf(doc) }, 0, "")]) {
const cell = (k: "sum" | (typeof COSTS)[number]): string => {
const v = r.cols?.[k];
if (v === undefined || (r.won && !v && k !== "sum")) return "";
+3
View File
@@ -24,6 +24,7 @@ import {
} from "../common_util/common_util_formula_doc";
import { isNum } from "../common_util/common_util_formula_doc_calc";
import { decide, hasIf, tryParse } from "../common_util/common_util_formula_doc_view";
import { machineText } from "./formula_doc_options";
import {
dropSyms,
freshId,
@@ -157,6 +158,8 @@ export function buildBlock(
const realText = (t: string): string => aliasText(t, alias, true);
const valText = (id: string): string => {
const v = vals[id];
const mach = machineText(v, () => h.change(() => undefined)); // 838-3 기계 원문번호 = 이름
if (mach) return mach;
if (isNum(v) || typeof v === "string")
return `${eqText(v)} ${shownUnit(vars.get(id)?.단위)}`.trim();
if (v && typeof v === "object")
+11 -8
View File
@@ -11,7 +11,8 @@
* 셈 = 브라우저 계산기(`calcDoc`) 즉시 · 캐시에 없는 연결만 뒤에서 `POST {길}/preview`(옛 요청 끊음)
* 고침 = 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST {길}/save`(서버가 정본으로 다시 셈) · 자동저장 없음
* M02 사본(`copy`) — 저장 몸 {문서, 끊음(받은 그대로), 판} · 판 바뀜(409)은 문서가 그대로면 새 판으로 한 번 더
* 기계 줄 · 옵션 가로 기계 = 기계 창(827-1 · `line_stack_machine`) — 초안은 문서 칸(캐시) · [저장] 때 서버가 내 기계로
* 기계 줄 · 옵션 가로 기계 = 기계 창(827-1 · 836-1 찾기 · 바꾸기만) · 기계 내부 값 = 왼쪽 기계 제어 구역(836-1 ·
* `line_stack_machine_ctl` — 문서 칸 `기계덮음` · 캐시 · [저장] 때 서버가 다시 셈)
* [단가 갱신] · [재생성] = 미리 셈 → 알림 → 저장(`formula_doc_copy.ts` · 고친 것 먼저 저장)
* 저장 전 문서(`memory` · 836-5 · 불러오기 미리 보기) = 빈 키 + 서버에 문서째(`본`) 읽음 · 왼쪽 줄 쌓기는 보기만(inert) · 디스크 쓰기 0
* ========================================================================== */
@@ -57,7 +58,6 @@ import { addTaken, type Taken } from "../common_util/common_util_line_stack_copy
import type { Layer } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import { buildEdit } from "./line_stack_edit";
import { openMachineWindow, type CopyAnswer, type MachineCtx } from "./line_stack_machine";
import { mt } from "../M02_MasterTemplete/M02_MyMachine_Text";
import { buildOut } from "./line_stack_out";
import { lt } from "./line_stack_text";
import "./line_stack.css";
@@ -283,18 +283,20 @@ export function createStackScreen(opt: StackScreenOptions) {
// ── 그림 ──
const edit = (): boolean => opt.admin && (!!key || !cp);
/** 827-1 기계 창 — 개인 층 M02 SK 만 [고쳐 쓰기] · M01 기초 = M01 기계만 · 사본 줄 = 수식 길 `/machine` */
/** 827-1 기계 창(찾기 · 바꾸기) — M01 기초 = M01 기계만 · 사본 줄 = 수식 길 `/machine` */
const machineCtx = (): MachineCtx => ({
scope: cp && opt.scope ? opt.scope : null,
canEdit: !!cp && !!key && opt.scope?.layer === "personal" && edit(),
why: !cp ? mt("Why_M01") : opt.scope?.layer === "personal" ? "" : mt("Why_Company"),
...(key ? { me: key } : {}),
...(cp && key
? { copyRow: (no: string) => call<CopyAnswer>(`/machine?no=${encodeURIComponent(no)}`) }
: {}),
});
/** 초안 기계 셈 기록(서버 확인 길 · 요소 · 표 · 식 문서) → 계산기 캐시 — [확인] 직후 브라우저가 바로 셈 */
const prime = (record: DocRecord): void => cache.merge(record);
/** 836-1 기계 제어 구역 기본값 — 덮음 없이 그 기계 식 셈(캐시에 없는 연결 = 뒤에서 서버) */
const calcSub = (d: FormulaDoc, g: Record<string, unknown>): DocAnswer => {
const got = calcDoc(d, cache.links, g, [], new Map(), {});
if (cache.takeMissing()) ask();
return got;
};
const draw = (): void => {
if (!doc || !answer) {
[head, left, right, foot].forEach((x) => x.replaceChildren());
@@ -372,7 +374,7 @@ export function createStackScreen(opt: StackScreenOptions) {
pair: (loc) => pair(loc, "left"),
take: takeCtx(),
machine: machineCtx(),
prime,
calc: calcSub,
before: saved ? lineMap(JSON.parse(saved) as StackDoc) : undefined,
}),
]
@@ -391,6 +393,7 @@ export function createStackScreen(opt: StackScreenOptions) {
},
miss: ask,
pair: (loc) => pair(loc, "right"),
redraw: () => draw(),
})
: el("p", { className: "ui-text ui-text--muted", text: lt("Empty") }),
);
+18
View File
@@ -90,6 +90,24 @@ const TEXT = {
"이 값의 줄은 위 옵션이 고름(다른 줄은 옵션에서 바꿈 · 여기선 칸만)",
"Row follows the option above (change it there; pick a column here)",
],
Ctl_Open: ["기계 값 · 조종원 · 잡품 고치기", "Edit machine values · operator · sundries"],
Ctl_Values: ["기계 값", "Machine values"],
Ctl_Crew: ["조종원", "Operator"],
Ctl_Misc: ["잡품", "Sundries"],
Ctl_Add: ["더함", "add"],
Ctl_Changed: ["고침", "Edited"],
Ctl_Was: ["원래 값", "Original"],
Ctl_Back: ["원래 값", "Original"],
Ctl_Bad: ["0 이상 수만 넣을 수 있음", "Enter a number 0 or more"],
Ctl_Crew_Qty: ["인원", "Persons"],
Ctl_Per_Day: ["인/일", "per day"],
Ctl_Rate: ["율", "Rate"],
Ctl_Follow: ["(따름)", "(follows)"],
Ctl_None: ["없음", "None"],
Win_Ctl_Note: [
"기계 내부 값(가격 · 연료 · 조종원 · 잡품)은 단가산출 왼쪽 기계 줄의 ▸ 에서 고침",
"Edit machine internals from the ▸ on the left machine line",
],
} as const;
export type LsKey = keyof typeof TEXT;
+6 -1
View File
@@ -41,6 +41,8 @@ const HOUR_LINE = { 기호: HOUR, 식: "Q_분자^표 ÷ Q_분모^표" };
export interface CrewRow {
키: string;
인: number;
/** 화면 이름(직종) — 셈 · 원본 대조 안 봄 */
이름?: string;
}
export interface MiscRow {
줄?: string;
@@ -166,12 +168,14 @@ export function defaultsOf(doc: FormulaDoc, vals: Vals): Required<MachineCover>
return { 값, 조종원, 잡품 };
}
/** 원본 대조 꼴 — 이름 · 단위(화면 글) · 빈 칸 뺌 · 수 12 자리 */
const LABEL = ["이름", "단위"];
const canon = (v: unknown): string =>
JSON.stringify(v, (_, x: unknown) =>
x && typeof x === "object" && !Array.isArray(x)
? Object.fromEntries(
Object.entries(x as Record<string, unknown>)
.filter(([, y]) => y !== undefined && y !== null && y !== "")
.filter(([k, y]) => y !== undefined && y !== null && y !== "" && !LABEL.includes(k))
.sort(([a], [b]) => (a < b ? -1 : 1)),
)
: typeof x === "number"
@@ -238,6 +242,7 @@ function crewLines(doc: FormulaDoc, rows: CrewRow[]): MoneyLine[] {
}
return rows.map((r) => ({
이름: "조종원",
...(r.이름 ? { 규격: r.이름 } : {}),
단위: "인",
비목: "노무비",
수량: `${plain(r.인)} × ${HOUR}`,
@@ -145,10 +145,15 @@ def defaults_of(doc: dict, vals: dict) -> dict:
return {"값": values, "조종원": crew, "잡품": misc}
_LABEL = ("이름", "단위") # 화면 글 — 원본 대조 안 봄
def _canon(v) -> str:
def norm(x):
if isinstance(x, dict):
return {k: norm(y) for k, y in sorted(x.items()) if y not in (None, "")}
return {
k: norm(y) for k, y in sorted(x.items()) if y not in (None, "") and k not in _LABEL
}
if isinstance(x, list):
return [norm(y) for y in x]
if _num(x) is not None:
@@ -209,7 +214,8 @@ def _crew_lines(doc: dict, rows: list) -> list[dict]:
)
doc.setdefault("식", []).append(dict(_HOUR_LINE))
return [
{"이름": "조종원", "단위": "인", "비목": "노무비", "수량": f"{_plain(r['인'])} × {HOUR}",
{"이름": "조종원", **({"규격": r["이름"]} if r.get("이름") else {}), "단위": "인",
"비목": "노무비", "수량": f"{_plain(r['인'])} × {HOUR}",
"단가": _add_var(doc, "w", "조종원", "조종원 노임", r["키"])}
for r in rows
] # fmt: skip
+6 -1
View File
@@ -229,7 +229,7 @@ def _same(a, b, where=""):
COVERS = [
None,
{"값": {"가격": 150000, "주연료종류": "휘발유"}},
{"조종원": [{"키": "LB:1048", "인": 2}, {"키": "LB:1002", "인": 0.5}]},
{"조종원": [{"키": "LB:1048", "인": 2, "이름": "운전사"}, {"키": "LB:1002", "인": 0.5}]},
{"조종원": []},
{"잡품": [{"줄": "주연료", "율": 30}, {"키": "LB:1048", "이름": "시험", "수량": 2, "율": 5}]},
{"잡품": [{"줄": "주연료", "수량": 10, "율": 22}]},
@@ -334,3 +334,8 @@ def test_저장_다시_열기_되돌리면_덮음_없음(world):
doc[mcv.COVER] = {NO: {"값": {"가격": -5}}}
with pytest.raises(store.StoreError):
at(path.save, key, {"문서": doc, "판": back["판"]})
def test_이름_단위는_원본_대조_안_봄():
base = {"값": {}, "조종원": [{"키": "LB:1048", "인": 1}], "잡품": []}
assert mcv.prune({"조종원": [{"키": "LB:1048", "인": 1, "이름": "운전사"}]}, base) is None