Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1

This commit is contained in:
2026-11-01 22:30:06 +09:00
8 changed files with 980 additions and 58 deletions
+5 -1
View File
@@ -111,8 +111,12 @@ def rows(keys: list[str] | None = None, doc: dict[str, Any] | None = None) -> di
def _total(key: str) -> float | None:
"""1 단위 계 — 수량 = 불러온 때 엑셀 수량(`본뜬.실무수량` · 723-1 · 저장 수량 없음)."""
from M02_MasterTemplete.M02_Hopyo_Build import excel_given
at = refs.here()
got = refs.Expand(m01.loaded()[1])({"층": at[0], "주인": at[1], "키": key})
ref = {"층": at[0], "주인": at[1], "키": key}
got = refs.Expand(m01.loaded()[1], given=excel_given(ref))(ref)
return _num((got.get("기준") or {}).get("계"))
@@ -10,8 +10,7 @@
* · 자재목록 = 로직 자재 줄(103-1 · `Labor_LogicMats` · [바꾸기] → `담은로직[].고름` · 입력이 바뀌면 걸린 고름 풂) +
* 사용자 자재 줄([삭제]) · 로직 자재와 같은 품목은 사용자 자재로 못 담음
* · 오른쪽 「원문」 접는 패널(47-4 · 상세만 · 공용 `ui_template_side_panels`) = 고른 줄 로직의 출처 원문
* · 서고 모드(50-3 · `mode`) = 사전조합 — 이름 칸 · 단가산출 로직 표 · 자재목록(175-1) · 원문(면적 · 다이어그램 없음) ·
* 정보 칸 구분 · 상세구분(51-12 · 고름) · 일위대가 상세와 같은 [로직 추가] · 표 · 동작(51-11) · 수량 손 입력(기본 1)
* · 서고 모드(50-3 · `mode`) = 일위대가표만 → `Labor_Table`(723-2 · 수량 = 휘발 임시 입력)
* · 로직 줄 구분 드롭다운(134-1 · 단가산출 | 단가승격 · 서고 모드엔 없음)
* · 맨 위 엑셀 꼴 격자(704-3 · `Labor_Grid`) · 읽기만 카드는 격자만 · 규격 표(704-4 · `Labor_Spec`)
* ========================================================================== */
@@ -67,7 +66,7 @@ import { insideKeys, releaseTied } from "./M02_Labor_LogicMats_Model";
import { keepKnown } from "./M02_Labor_Values_Model";
import { addMaterialButton } from "./M02_Material_Pick";
import { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick";
import { libMeta } from "./M02_MasterTemplete_Labor_LibMeta";
import { mountLaborTable } from "./M02_MasterTemplete_Labor_Table";
import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css";
import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css";
import "@ui/ui_template_general_layout.css";
@@ -105,10 +104,10 @@ export function mountLaborDetail(
doc: unknown,
opt: LaborDetailOptions,
): LaborDetailHandle {
if (opt.mode === "precombo") return mountLaborTable(host, doc, opt); // 723-2 — 서고 = 일위대가표만
const cur = normalizeLabor(doc, opt.column);
const saved = JSON.stringify(cur);
const edit = !opt.readOnly;
const lib = opt.mode ?? null;
const logics = new Map<string, ItemLogic>();
let calc: LaborCalc | null = null;
/** 마지막으로 받은 calc — 다시 세는 동안 로직 자재 줄을 그대로 둠(줄이 사라졌다 나타나지 않게) */
@@ -168,7 +167,7 @@ export function mountLaborDetail(
const matOf = (code: string) => calc?.자재.find((m) => m.코드 === code);
const drawDg = (): void => {
if (opt.card || lib || !dgBox.open) return; // 카드 갈래 · 서고 모드엔 다이어그램 없음
if (opt.card || !dgBox.open) return; // 카드 갈래엔 다이어그램 없음
const data = buildLaborDiagram({
rows: cur.담은로직.filter((r) => !r.참조), // ponytail: 참조 줄은 다이어그램에 안 그림 — 그릴 때 카드 꼴 더함
logics: [...logics.values()],
@@ -205,8 +204,7 @@ export function mountLaborDetail(
move: reorder,
remove: removeRow,
alias: setAlias,
// 134-1 — 구조물 일위대가 로직 줄만(사전조합 안 로직 줄엔 없음)
...(lib ? {} : { promote: setPromote }),
promote: setPromote, // 134-1 — 구조물 일위대가 로직 줄
...sk,
}
: { open: pick, option: openRow, ref: openRef, ...sk },
@@ -214,7 +212,7 @@ export function mountLaborDetail(
),
);
const draw = (): void => {
gridHost.replaceChildren(laborGrid(cur, calc ?? last, logics, edit && changed, !lib));
gridHost.replaceChildren(laborGrid(cur, calc ?? last, logics, edit && changed));
drawLogic();
matHost.replaceChildren(
matTable(
@@ -326,7 +324,6 @@ export function mountLaborDetail(
unit,
readOnly: !edit,
sheet: { doc: area?.doc ?? null, layer: opt.layer, column: opt.column, pill },
...(lib ? { handOnly: true } : {}),
done: (r) => {
if (r.kind === "keep") return;
if (r.kind === "hand") target.수량 = { 손: r.손 };
@@ -515,8 +512,7 @@ export function mountLaborDetail(
const id = lineId({ 참조: ref });
if (cur.담은로직.some((r) => lineId(r) === id))
return showToast(tc("Labor_AlreadyRef").replace("{value}", name), "error");
const 수량 = lib ? { 손: 1 } : null; // 사전조합 줄 기본 1(51-6)
cur.담은로직.push({ 로직: "", 참조: ref, 별칭, 메모: null, 입력값: {}, 수량 });
cur.담은로직.push({ 로직: "", 참조: ref, 별칭, 메모: null, 입력값: {}, 수량: null });
changed();
},
};
@@ -528,9 +524,7 @@ export function mountLaborDetail(
(x) => {
if (cur.담은로직.some((r) => r.로직 === x.키))
return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error");
// 사전조합 = 면적산출 없음 → 손 1(51-11)
const 수량 = lib ? { 손: 1 } : null;
cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량 });
cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량: null });
void loadLogics().then(() => changed());
},
// 사전조합도 같은 창 · 같은 목록(51-11 · 자기 자신 · 순환은 [저장] 때 서버 422)
@@ -587,16 +581,6 @@ export function mountLaborDetail(
],
});
// 서고 모드 이름 칸(문서 `이름` · 목록에 보이는 이름)
const nameField = createInputField({ type: "text", label: tc("Lib_Name") });
nameField.root.dataset.laborField = "이름";
nameField.input.value = String((cur as { 이름?: unknown }).이름 ?? "");
nameField.input.readOnly = !edit;
nameField.input.addEventListener("input", () => {
(cur as { 이름?: string }).이름 = nameField.input.value.trim();
opt.onChanged(dirty());
});
const editor = (body: HTMLElement): HTMLElement =>
side ? el("div", { className: "ui-side-panels-row", children: [body, side.root] }) : body;
host.replaceChildren(
@@ -613,13 +597,7 @@ export function mountLaborDetail(
children: [
el("div", {
className: "m01lab__info",
children: [
...(lib
? [nameField.root, ...libMeta(cur, edit, () => opt.onChanged(dirty()))]
: []),
unit.root,
origin.root,
],
children: [unit.root, origin.root],
}),
],
}),
@@ -630,7 +608,7 @@ export function mountLaborDetail(
section(tc("Logics_Title"), opt.card ? null : addLogic, logicHost),
section(tc("Labor_Mats"), opt.card ? null : addMat, matHost),
].filter(() => edit || !opt.card), // 읽기만 카드 = 격자만
...(opt.card || lib ? [] : [dgBox]),
...(opt.card ? [] : [dgBox]),
],
}),
),
@@ -639,30 +617,22 @@ export function mountLaborDetail(
// 로직 입력 목록 · 면적산출 이은 칸(없으면 빈 것 — 새 구조물)을 받은 뒤 첫 계산
void Promise.all([
loadLogics(),
lib // 서고 모드엔 면적산출 없음
? undefined
: Promise.all([
opt.readArea ? opt.readArea() : readTemplate(opt.layer, "area", opt.column, null),
import("../A00_Common/spreadsheet/spreadsheet_recalc"),
]).then(
([got, { recalcWorkbook }]) => {
area = { doc: got.문서 as Workbook, 판: got.판 ?? "", recalc: recalcWorkbook };
links = areaLinks(got.문서);
},
() => undefined,
),
Promise.all([
opt.readArea ? opt.readArea() : readTemplate(opt.layer, "area", opt.column, null),
import("../A00_Common/spreadsheet/spreadsheet_recalc"),
]).then(
([got, { recalcWorkbook }]) => {
area = { doc: got.문서 as Workbook, 판: got.판 ?? "", recalc: recalcWorkbook };
links = areaLinks(got.문서);
},
() => undefined,
),
]).then(() => {
if (alive) recalc();
});
return {
// 서고 모드 = 문서 종류 그대로 · 열 없음
getDoc: () => {
if (!lib) return cur;
const out: Record<string, unknown> = { ...cur, 종류: "사전조합" };
delete out.열;
return out as unknown as LaborDoc;
},
getDoc: () => cur,
saveArea: async () => {
if (!area || !areaDirty) return false;
const info = await saveTemplate(opt.layer, "area", opt.column, null, area.판, area.doc);
@@ -0,0 +1,249 @@
/* =============================================================================
* M02_MasterTemplete_Labor_RowModal.ts
* 일위대가표 줄 누름 모달(PLAN 723-2 · 723-3 · 요구서_내역16 2장 4 · 5) — 일위대가표에서 빠진 줄 동작을 모달 안으로.
* · 로직 줄 · 단가산출(SK) 참조 줄 = 머리(이름 · 「1 ㎡ 당」) · [옵션](메모 · 켜쌓기율 · `Labor_Input`) ·
* 단가산출 상세 줄 모드(717-2b 보기 넷 · `Labor_RowSk` · 717-3 두 보기 갈음) ·
* 로직 자재(바꾸기 · 기본 · 세 칸 · `Labor_LogicMats`)
* · [적용] = 그 줄 덮개(M02 변경 항목) + 옵션(시험 입력 → 그 줄 입력값 · 규격 칸) · [단가산출로도 저장](기본 꺼짐)
* · 사용자 자재 줄 = 자재 모달(세 칸 · `Labor_MatTable` 한 줄)
* · 고침 = 그 줄 화면 캐시(저장은 [저장]) · 금액은 표가 다시 셈 → `watch` 로 받아 다시 그림
* ========================================================================== */
import { createCheckbox } from "@ui/ui_template_checkbox";
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { askConfirm, openModal } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
import type { ItemLogic } from "./M02_MasterTemplete_Api_Fetch";
import type { LaborCalc, LaborMat, LaborRow, MatMarkKey } from "./M02_MasterTemplete_Labor_Api";
import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats";
import { setLogicMark, setMark } from "./M02_MasterTemplete_Labor_MatMarks";
import { matTable } from "./M02_MasterTemplete_Labor_MatTable";
import { mountRowSk, saveAsSk, type RowSkHandle } from "./M02_MasterTemplete_Labor_RowSk";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import type { SkScope } from "./M02_MasterTemplete_Sangeun_Cover";
import { releaseTied } from "./M02_Labor_LogicMats_Model";
import { keepKnown, valueLine } from "./M02_Labor_Values_Model";
/** 표가 다시 센 답을 받음 — 돌려준 함수 = 그만 받음 */
export type CalcWatch = (fn: (calc: LaborCalc | null) => void) => () => void;
export interface RowModalOptions {
row: LaborRow;
logic: ItemLogic | null;
title: string;
/** 일위대가 머리(이름 · 「1 ㎡ 당」) */
head: string;
/** 줄 분모(SK 단위 기본) */
unit: string;
scope: SkScope;
readOnly: boolean;
/** 셈 주입(프로젝트 복사본) — 단가산출 상세 없음 */
inject: boolean;
/** 사용자 자재 코드 — 로직 자재 [바꾸기]로 같은 품목 막음 */
userMats: string[];
watch: CalcWatch;
/** 고침 뒤 — `again` = 서버 다시 셈(입력 · 고름 · 별도계상) · 아니면 화면만 */
done: (again: boolean) => void;
}
const TEXT = {
Apply: ["적용", "Apply"],
AlsoSk: ["단가산출로도 저장", "Also save as build-up"],
Saved: ["{k} 저장함(내 개인 층)", "{k} saved (my layer)"],
Discard: ["적용 안 한 고침이 있음 — 버리고 닫을까?", "Discard unapplied edits?"],
} as const;
const t = (k: keyof typeof TEXT): string => TEXT[k][0];
export function openRowModal(o: RowModalOptions): void {
const row = o.row;
const edit = !o.readOnly;
const info = el("div", { attrs: { "data-row-head": "" } });
const mats = el("div", { attrs: { "data-row-mats": "" } });
let calc: LaborCalc | null = null;
const line = () => calc?.줄.find((l) => !row.참조 && l.로직 === row.로직);
const draw = (): void => {
const values = valueLine(o.logic?.입력 ?? [], row.입력값);
info.replaceChildren(
createText(o.head, "muted", 14),
...(values.text ? [createText(values.text)] : []),
...(values.empty
? [createText(tc("Labor_Empty").replace("{n}", String(values.empty)), "error")]
: []),
...(row.켜쌓기율 != null
? [createText(tc("Labor_Layer").replace("{n}", String(row.켜쌓기율)), "muted")]
: []),
...(row.메모 ? [createText(row.메모, "muted")] : []),
);
const lead = row.참조
? []
: logicMatRows([row], calc, !calc, {
open: () => undefined,
...(edit
? {
swap: (_r: number, at: number, key: string) => {
if (o.userMats.includes(key))
return showToast(tc("Labor_AlreadyMat").replace("{value}", key), "error");
row.고름 = { ...row.고름, [String(at)]: key };
o.done(true);
},
reset: (_r: number, at: number) => {
if (!row.고름) return;
delete row.고름[String(at)];
if (!Object.keys(row.고름).length) delete row.고름;
o.done(true);
},
mark: (_r: number, at: number, key: MatMarkKey, v: string) => {
setLogicMark(row, at, key, v);
o.done(key === "별도계상");
draw();
},
}
: {}),
});
mats.replaceChildren(
...(lead.length
? [el("h4", { text: tc("Labor_Mats") }), matTable({ mats: [], calc, lead })]
: []),
);
};
const option = createButton({
label: tc("Labor_Option"),
variant: "ghost",
onClick: () =>
openLaborInput({
title: o.title,
logic: o.logic,
values: row.입력값,
memo: row.메모,
rate: row.켜쌓기율 ?? null,
readOnly: o.readOnly,
done: (next) => {
const same = setValues(next.입력값) && next.켜쌓기율 === (row.켜쌓기율 ?? null);
row.메모 = next.메모;
if (next.켜쌓기율 === null) delete row.켜쌓기율;
else row.켜쌓기율 = next.켜쌓기율;
o.done(!same);
draw();
if (!same) skMount(); // 바뀐 옵션으로 상세 다시
},
}),
});
option.dataset.rowOption = "";
/** 옵션 → 그 줄 입력값(로직 줄만 · 아는 입력만) · 같으면 참 */
function setValues(next: Record<string, string | number>): boolean {
if (row.참조) return true;
const values = o.logic ? keepKnown(o.logic.입력, next).kept : next;
if (JSON.stringify(values) === JSON.stringify(row.입력값)) return true;
const before = row.입력값;
row.입력값 = values;
releaseTied(row, before, line()?.재료 ?? []); // 바뀐 입력에 걸린 고름 = 기본(103-1)
return false;
}
// 단가산출 상세 줄 모드(723-3) — 셈 주입(프로젝트 복사본)엔 없음
const skHost = el("div", { attrs: { "data-row-sk-host": "" } });
let sk: RowSkHandle | null = null;
const skMount = (): void => {
if (o.inject) return;
sk?.destroy();
sk = mountRowSk(skHost, { row, scope: o.scope, title: o.title, unit: o.unit, readOnly: !edit });
};
let close = (): void => {};
const finish = (): void => {
stop();
sk?.destroy();
};
const also = createCheckbox(t("AlsoSk"), { checked: false });
also.input.dataset.rowAlsoSk = "";
const apply = createButton({
label: t("Apply"),
variant: "filled",
onClick: async () => {
const got = sk?.take() ?? null;
if (got) {
if (got.patch) row.패치 = got.patch;
else delete row.패치;
setValues(got.values);
o.done(true);
draw();
}
if (also.input.checked) {
try {
const name = await saveAsSk(row, got?.patch ?? row.패치 ?? null, o.title, o.unit);
showToast(t("Saved").replace("{k}", name), "success");
} catch (error) {
return showToast(error instanceof Error ? error.message : String(error), "error");
}
}
finish(); // [적용] = 그 줄에 남기고 닫음(저장은 페이지 [저장]) · 코드 닫기는 beforeClose 안 거침
close();
},
});
apply.dataset.rowApply = "";
const stop = o.watch((c) => {
calc = c;
draw();
});
const tools = edit && !o.inject ? [also.root, apply] : [];
({ close } = openModal({
title: o.title,
closeLabel: tc("In_Close"),
width: "1100px",
wide: true,
actions: [...(row.참조 || !edit ? [] : [option]), ...tools],
beforeClose: async () => {
if (sk?.take() && !(await askConfirm({ message: t("Discard"), danger: true }))) return false;
finish();
return true;
},
mount: (body) => body.append(info, skHost, mats),
}));
draw();
skMount();
}
export interface MatModalOptions {
mat: LaborMat;
watch: CalcWatch;
readOnly: boolean;
/** 세 칸 고침 뒤 — `money` = 별도계상(금액에 닿음) */
done: (money: boolean) => void;
}
/** 사용자 자재 줄 — 세 칸(148-4 · 222-1) · 가격 다섯 보기 */
export function openMatModal(o: MatModalOptions): void {
const host = el("div", { attrs: { "data-row-mat": o.mat.코드 } });
let calc: LaborCalc | null = null;
const draw = (): void =>
host.replaceChildren(
matTable(
{ mats: [o.mat], calc },
o.readOnly
? { open: () => undefined }
: {
open: () => undefined,
mark: (_i, key, v) => {
setMark(o.mat, key, v);
o.done(key === "별도계상");
draw();
},
},
),
);
const stop = o.watch((c) => {
calc = c;
draw();
});
openModal({
title: `${o.mat.코드} ${o.mat.이름 ?? ""}`.trim(),
closeLabel: tc("In_Close"),
width: "1100px",
beforeClose: () => (stop(), true),
mount: (body) => body.append(host),
});
draw();
}
@@ -0,0 +1,108 @@
/* =============================================================================
* M02_MasterTemplete_Labor_RowSk.ts
* 일위대가 줄 모달 안 단가산출 상세 = 717-2b 단가산출(SK) 상세 「줄 모드」(PLAN 723-3 · 정함 4 · 5).
* · 줄 → 이름 없는 SK 문서(`lineDoc` · 고름 · 자재표시 = 바탕 줄 정체 cid) → `mountSangeunDetail` 그대로 얹음
* (보기 넷 · 품 · 단가 덮개 · 줄 빼기 · 더하기 · 시험 입력 칩)
* · `take()` = [적용] 때 그 줄에 남길 것 — 패치 `{덮개, 뺀줄, 더한줄}`(M02 변경 항목 · M01 안 바뀜 · 내역 17-2) +
* 옵션(시험 입력 → 그 줄 `입력값` · 정함 5)
* · [단가산출로도 저장](기본 꺼짐) = `saveAsSk` — 개인 층 새 SK(`from-line`)
* 공용 부품 갈아 끼움은 724(sub2) — 지금은 717-2b 상세를 그대로 씀.
* ========================================================================== */
import { el } from "@ui/ui_template_elements";
import { createText } from "@ui/ui_template_text";
import { fetchAuto } from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
import type { LaborRow } from "./M02_MasterTemplete_Labor_Api";
import { calcSangeun, sangeunFromLine, type SkScope } from "./M02_MasterTemplete_Sangeun_Cover";
import { mountSangeunDetail } from "./M02_MasterTemplete_Sangeun_Detail";
import {
cleanPatch,
lineDoc,
needsIds,
type LinePatch,
type SkCalc,
} from "./M02_Sangeun_Cover_Model";
export interface RowSkHandle {
/** 고친 것(패치 · 옵션) — 없으면 null */
take: () => { patch: LinePatch | null; values: Record<string, string> } | null;
destroy: () => void;
}
const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
/** 줄 모드 SK 상세를 `host` 에 — 바탕 줄 정체가 필요하면 먼저 셈 */
export function mountRowSk(
host: HTMLElement,
o: { row: LaborRow; scope: SkScope; title: string; unit: string; readOnly: boolean },
): RowSkHandle {
let handle: ReturnType<typeof mountSangeunDetail> | null = null;
let alive = true;
/** 자동값(상세가 빈 입력에 채움) — 손대지 않은 자동값은 줄에 안 남김(빈 값 = 자동값 그대로) */
let auto: Record<string, unknown> = {};
const before = cleanPatch(o.row.패치);
const values0 = Object.fromEntries(
Object.entries(o.row.입력값 ?? {}).map(([k, v]) => [k, String(v)]),
);
host.replaceChildren(createText("…", "muted"));
void (async () => {
let ids: string[] | null = null;
try {
if (needsIds(o.row))
ids = (await calcSangeun<SkCalc>(o.scope, lineDoc(o.row, null, null))).정체 ?? null;
} catch {
ids = null; // 정체 못 받음 = 고름 · 자재표시 없이(셈 까닭은 상세가 보임)
}
if (!alive) return;
const doc = { ...lineDoc(o.row, ids, before), 이름: o.title, 단위: o.unit || "식" };
const box = el("div", { attrs: { "data-row-sk": "" } });
host.replaceChildren(box);
handle = mountSangeunDetail(box, doc, {
layer: o.scope.layer,
owner: o.scope.owner,
readOnly: o.readOnly,
onChanged: () => undefined,
});
if (o.row.로직 && !o.row.참조) auto = await fetchAuto(o.row.로직).catch(() => ({}));
})();
return {
take: () => {
if (!handle) return null;
const d = handle.getDoc();
const patch = cleanPatch({
덮개: d.덮개 as LinePatch["덮개"],
뺀줄: d.뺀줄,
더한줄: d.더한줄,
});
const values = o.row.참조
? values0
: Object.fromEntries(
Object.entries(handle.getValues()).filter(
([k, v]) =>
String(v).trim() !== "" &&
(k in values0 || auto[k] == null || String(auto[k]) !== String(v)),
),
);
return same(patch, before) && same(values, values0) ? null : { patch, values };
},
destroy: () => {
alive = false;
handle?.destroy();
},
};
}
/** [단가산출로도 저장] — 그 줄(고친 패치 포함)을 개인 층 새 SK 로 · 새 이름 돌려줌 */
export async function saveAsSk(
row: LaborRow,
patch: LinePatch | null,
name: string,
unit: string,
): Promise<string> {
const line: Record<string, unknown> = { ...row };
delete line.패치;
delete line.수량;
if (patch) line.패치 = patch;
const got = await sangeunFromLine({ layer: "personal", owner: null }, line, name, unit || "식");
return got.이름;
}
@@ -0,0 +1,484 @@
/* =============================================================================
* M02_MasterTemplete_Labor_Table.ts
* M02 일위대가(서고 · 사전조합) 상세 = 일위대가표만(PLAN 723-2 · 내역 16 · 17 · 요구서_내역16 2장 3~5).
* · 차례 = 정보(이름 · 구분 · 단위 · 본뜬) → 일위대가표(엑셀 꼴 격자 · `Labor_Grid.laborBlock`) · 오른쪽 원문 패널
* · 옵션 값 = 규격 칸 · 「빈 값 n」 = 비고 칸 붉게(입력 빈 로직 줄)
* · 수량 칸 = 확인용 임시 입력(723-1 · 휘발 · 문서 · 저장 · dirty 안 건드림) → 그 줄 금액 · 합계 미리 보기
* (짝 셈 `Labor_Calc.settle` · 서버 안 부름) · 문서 안 줄 수량은 늘 null(쓰는 쪽 알약으로 이음)
* · 머리 단추 = [로직 추가] [자재 추가] · 줄 우클릭 = 위로 · 아래로 · 별칭 · 삭제
* · 줄 누름 = 줄 모달(`Labor_RowModal` — 옵션 · 메모 · 켜쌓기율 · 로직 자재 바꾸기 · 세 칸 · 단가산출) ·
* 사전조합 참조 줄 = 서고 원본 모달(`Labor_RefEdit`) · 사용자 자재 줄 = 자재 모달(세 칸)
* · 금액 = 서버 calc(최신 응답만 · 저장 안 함) · [저장]은 UI_Main(서버가 수량 걷고 다시 셈)
* 구조물 조합 일위대가(카드 · 열 상세)는 그대로 `Labor_Detail`(면적 잇기 · 수량산출).
* ========================================================================== */
import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements";
import { createSidePanels } from "@ui/ui_template_side_panels";
import { createHopyoGrid, hopyoCellClass, hopyoGrid } from "@ui/ui_template_hopyo_grid";
import { openTreeMenu, type TreeMenuItem } from "@ui/ui_template_tree";
import { askText, showConfirmDialog } from "@ui/ui_template_modal";
import { createText } from "@ui/ui_template_text";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source";
import { getActiveLayer, getActiveOwner, type ItemLogic } from "./M02_MasterTemplete_Api_Fetch";
import { lineId, settle, type LaborFixed, type LaborRef } from "./M02_MasterTemplete_Labor_Calc";
import {
calcLabor,
fetchLogicInputs,
normalizeLabor,
type LaborCalc,
type LaborDoc,
type LaborRow,
} from "./M02_MasterTemplete_Labor_Api";
import type { LaborDetailHandle, LaborDetailOptions } from "./M02_MasterTemplete_Labor_Detail";
import { laborBlock } from "./M02_MasterTemplete_Labor_Grid";
import { pasteInto } from "./M02_MasterTemplete_Labor_Paste";
import { openLogicPicker, withHopyo } from "./M02_MasterTemplete_Labor_Pick";
import { openRefEdit } from "./M02_MasterTemplete_Labor_RefEdit";
import { openMatModal, openRowModal } from "./M02_MasterTemplete_Labor_RowModal";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { insideKeys } from "./M02_Labor_LogicMats_Model";
import { valueLine } from "./M02_Labor_Values_Model";
import { addMaterialButton } from "./M02_Material_Pick";
import { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick";
import { libMeta } from "./M02_MasterTemplete_Labor_LibMeta";
import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css";
import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css";
import "@ui/ui_template_general_layout.css";
import "./M02_MasterTemplete_Labor.css";
const TEXT = {
TempHint: [
"수량 칸 = 확인용 임시 입력(저장 안 함) — 실제 수량은 구조물 조합 · 프로젝트에서 이음",
"Quantity = temporary check only (not saved) — real quantities link in structures · projects",
],
Alias: ["별칭", "Alias"],
AliasAsk: ["별칭(비우면 지움)", "Alias (empty = clear)"],
RemoveAsk: ["「{value}」 줄을 지울까?", "Delete line “{value}”?"],
Empty: ["빈 값 {n}", "{n} empty"],
NotNumber: ["수가 아님", "Not a number"],
} as const satisfies Record<string, readonly [string, string]>;
const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
/** 비율 고정 줄(705-3) — 수량 = 율(문서에 남음 · 임시 입력 아님) */
const isRate = (row: LaborRow): boolean => !!row.고정?.비율;
const matKey = (code: string): string => `자재|${code}`;
export function mountLaborTable(
host: HTMLElement,
doc: unknown,
opt: LaborDetailOptions,
): LaborDetailHandle {
const cur = normalizeLabor(doc, opt.column);
// 723-1 — 옛 문서 손 수량은 안 씀(서버도 저장 때 걷음) · 비율 줄 율만
for (const r of cur.담은로직) if (!isRate(r)) r.수량 = null;
for (const m of cur.자재) m.수량 = null;
const saved = JSON.stringify(cur);
const edit = !opt.readOnly;
const logics = new Map<string, ItemLogic>();
/** 임시 수량(휘발) — 줄 id · 「자재|코드」 → 수 */
const temp = new Map<string, number>();
let calc: LaborCalc | null = null;
let last: LaborCalc | null = null;
let fail: string | null = null;
let seq = 0;
let timer = 0;
let alive = true;
let picked: string | null = null;
const dirty = (): boolean => JSON.stringify(cur) !== saved;
/** 줄 모달이 받는 셈 답(다시 셀 때마다) */
const watchers = new Set<(c: LaborCalc | null) => void>();
const watch = (fn: (c: LaborCalc | null) => void): (() => void) => {
watchers.add(fn);
fn(calc ?? last);
return () => watchers.delete(fn);
};
/** 셈 몸 = 문서 + 임시 수량(저장 안 함) */
const withTemp = (): LaborDoc => ({
...cur,
담은로직: cur.담은로직.map((r) => {
const v = temp.get(lineId(r) ?? "");
return v === undefined ? r : { ...r, 수량: { 손: v } };
}),
자재: cur.자재.map((m) => {
const v = temp.get(matKey(m.코드));
return v === undefined ? m : { ...m, 수량: { 손: v } };
}),
});
const gridHost = el("div", { attrs: { "data-labor-grid": "", "data-labor-table": "" } });
const source = createSourceView({
path: "/m02/logic-lib/source",
pdf: "/m02/logic-lib/source-pdf",
});
const sourceHost = el("div", { attrs: { "data-labor-source": "" } });
const side = createSidePanels({
prefKey: "m02-labor-table-side", // 일위대가표는 넓게 — 원문은 처음 접힘(누르면 엶)
defaultOpen: null,
follow: false,
panels: [{ id: "source", title: tc("Labor_Source"), body: sourceHost }],
onChange: () => showSource(),
});
function showSource(): void {
if (side.current() !== "source") return;
if (!picked) return void sourceHost.replaceChildren(createText(tc("Labor_SourceHint")));
source.show(logics.get(picked)?.출처 ?? "");
if (sourceHost.firstChild !== source.root) sourceHost.replaceChildren(source.root);
}
/** 일위대가표 — 줄 누름 · 우클릭 · 수량 칸(임시) · 빈 값 붉게 */
const draw = (): void => {
const body = withTemp();
const block = laborBlock(body, calc ?? last, logics);
const empties = new Set<number>();
block.lines.forEach((ln, n) => {
const row = body.담은로직[n];
if (!row) return void (ln.note = ln.note === tc("Grid_NoQty") ? "" : ln.note); // 자재 줄
const empty = row.로직 ? valueLine(logics.get(row.로직)?.입력 ?? [], row.입력값).empty : 0;
if (empty) empties.add(n);
if (empty) ln.note = t("Empty").replace("{n}", String(empty));
else if (ln.note === tc("Grid_NoQty")) ln.note = ""; // 수량 없음 = 서고에선 보통(723-1)
});
const have = calc ?? last;
// 빈 줄 = 단가 모름(까닭) · 빈 값만 — 임시 수량 없는 줄은 안 셈
block.blank = have ? block.lines.filter((l) => l.prices.sum == null).length + empties.size : 0;
if (fail && block.lines[0]) block.lines[0].note = fail;
const g = hopyoGrid([block]);
const at = (i: number): number | null => g.at.get(i)?.[1] ?? null;
const grid = createHopyoGrid(
[block],
{
onRowClick: (i) => {
const n = at(i);
if (n !== null) openAt(n);
},
editable: (i, key) => {
const n = at(i);
if (key !== "qty" || n === null) return false;
const row = cur.담은로직[n];
return !row || !isRate(row); // 임시 입력이라 읽기만 층도 됨
},
onEdit: (i, _key, text) => {
const n = at(i);
if (n !== null) setTemp(n, text);
},
cellClass: (i, key) => {
const n = at(i);
if (key === "note" && n !== null && empties.has(n)) return "ui-hopyo-grid__blank";
return hopyoCellClass(g.rows, i, key);
},
},
edit ? (text) => void pasteInto(cur, text, afterPaste) : undefined,
);
grid.root.addEventListener("contextmenu", (ev) => {
const tr = (ev.target as Element).closest<HTMLElement>("tr[data-i]");
const n = tr ? at(Number(tr.dataset.i)) : null;
if (n === null || !edit) return;
ev.preventDefault();
// 칸 초점이 표를 밀면(scroll) 메뉴가 바로 닫힘 — 밀림이 끝난 다음 그림에 엶
requestAnimationFrame(() => requestAnimationFrame(() => openTreeMenu(ev, menuAt(n))));
});
gridHost.replaceChildren(grid.root);
showSource();
for (const fn of watchers) fn(calc ?? last);
};
/** 서버 다시 셈 — 늦게 온 답은 버림 */
const recalc = (): void => {
window.clearTimeout(timer);
timer = window.setTimeout(async () => {
const mine = ++seq;
calc = null;
fail = null;
draw();
try {
const body = withTemp();
const got = opt.calc
? await opt.calc(body)
: await calcLabor(opt.layer, opt.column, body, undefined, opt.owner);
if (!alive || mine !== seq) return;
calc = last = got;
} catch (error) {
if (!alive || mine !== seq) return;
fail = error instanceof Error ? error.message : tc("Labor_Calc_Fail");
}
draw();
}, 150);
};
/** `qtyOnly` = 수량만(임시) — 짝 셈으로 바로 */
const changed = (qtyOnly = false): void => {
opt.onChanged(dirty());
if (qtyOnly && calc) {
calc = settle(calc, withTemp(), {});
draw();
} else recalc();
};
/** 임시 수량(휘발) — 빈 글 = 지움 · 수 아님 = 알림 */
function setTemp(n: number, text: string): void {
const row = cur.담은로직[n];
const key = row ? (lineId(row) ?? "") : matKey(cur.자재[n - cur.담은로직.length]?.코드 ?? "");
const raw = text.replace(/,/g, "").trim();
if (!raw) temp.delete(key);
else if (Number.isFinite(Number(raw))) temp.set(key, Number(raw));
else return showToast(t("NotNumber"), "error");
calc = calc && settle(calc, withTemp(), {});
draw();
}
/** 붙여넣기(705-2) 뒤 — 엑셀 수량은 임시 칸으로(문서엔 안 남김) */
function afterPaste(): void {
for (const r of cur.담은로직) {
const v = r.수량?.손;
if (isRate(r) || v == null) continue;
temp.set(lineId(r) ?? "", v);
r.수량 = null;
}
void loadLogics().then(() => changed());
}
const loadLogics = async (): Promise<void> => {
const want = cur.담은로직.map((r) => r.로직).filter((k) => k && !logics.has(k));
if (!want.length) return;
try {
const got = await fetchLogicInputs(want);
for (const l of got.로직) logics.set(l.로직, l);
} catch (error) {
showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error");
}
};
const lineOf = (row: LaborRow) => (calc ?? last)?.줄.find((l) => lineId(l) === lineId(row));
const nameOf = (row: LaborRow): string =>
row.별칭 ||
row.고정?.이름 ||
(row.참조 ? lineOf(row)?.이름 : logics.get(row.로직)?.이름) ||
row.참조?.키 ||
row.로직;
/** 줄 누름 — 줄 갈래마다 */
function openAt(n: number): void {
const row = cur.담은로직[n];
if (!row) return openMat(n - cur.담은로직.length);
picked = row.로직 || null;
showSource();
if (row.참조 && row.참조.키.startsWith("SC-"))
return void openRefEdit({
ref: row.참조,
title: nameOf(row),
readOnly: !edit || row.참조.층 !== opt.layer,
closed: () => alive && recalc(),
});
if (row.고정) return; // 고정 줄 = 숫자 그대로(이름 · 별칭은 우클릭)
openRowModal({
row,
logic: logics.get(row.로직) ?? null,
title: nameOf(row),
head: [String((cur as { 이름?: string }).이름 ?? ""), cur.단위 ? `1 ${cur.단위} 당` : ""]
.filter(Boolean)
.join(" · "),
unit: lineOf(row)?.분모 ?? "",
watch,
scope: { layer: opt.layer, owner: library.owner },
readOnly: !edit,
inject: !!opt.calc,
userMats: cur.자재.map((m) => m.코드),
done: (again) => changed(!again),
});
}
function openMat(i: number): void {
const mat = cur.자재[i];
if (!mat) return;
openMatModal({
mat,
watch,
readOnly: !edit,
done: (money) => changed(!money),
});
}
/** 우클릭 — 위로 · 아래로 · 별칭 · 삭제 */
function menuAt(n: number): TreeMenuItem[] {
const rows = cur.담은로직.length;
const isMat = n >= rows;
const name = isMat ? (cur.자재[n - rows]?.이름 ?? "") : nameOf(cur.담은로직[n]);
const items: TreeMenuItem[] = [];
if (!isMat) {
if (n > 0) items.push({ label: tc("Up"), run: () => move(n, n - 1) });
if (n < rows - 1) items.push({ label: tc("Down"), run: () => move(n, n + 1) });
items.push({ label: t("Alias"), run: () => void alias(n) });
}
items.push({
label: tc("Remove"),
run: () =>
void showConfirmDialog(t("RemoveAsk").replace("{value}", name), tc("Remove")).then(
(ok) => ok && remove(n),
),
});
return items;
}
function move(from: number, to: number): void {
cur.담은로직.splice(to, 0, ...cur.담은로직.splice(from, 1));
changed();
}
async function alias(n: number): Promise<void> {
const row = cur.담은로직[n];
const got = await askText({ message: t("AliasAsk"), value: row.별칭 ?? "" });
if (got === null || got === undefined) return;
row.별칭 = got.trim() || null;
opt.onChanged(dirty());
draw();
}
function remove(n: number): void {
const rows = cur.담은로직.length;
if (n < rows) {
temp.delete(lineId(cur.담은로직[n]) ?? "");
cur.담은로직.splice(n, 1);
} else {
temp.delete(matKey(cur.자재[n - rows]?.코드 ?? ""));
cur.자재.splice(n - rows, 1);
}
changed();
}
/** 서고 고르기 — 참조 줄 · 고정 줄(721-2 새 고르기 창 · 인력 · 재료) */
const library = {
layer: opt.layer,
owner:
opt.owner !== undefined
? opt.owner
: opt.layer === getActiveLayer()
? getActiveOwner()
: null,
pick: (ref: LaborRef, name: string, 별칭: string | null = null) => {
const id = lineId({ 참조: ref });
if (cur.담은로직.some((r) => lineId(r) === id))
return showToast(tc("Labor_AlreadyRef").replace("{value}", name), "error");
cur.담은로직.push({ 로직: "", 참조: ref, 별칭, 메모: null, 입력값: {}, 수량: null });
changed();
},
fixed: (fx: LaborFixed, name: string) => {
const id = lineId({ 고정: fx });
if (cur.담은로직.some((r) => lineId(r) === id))
return showToast(tc("Labor_AlreadyRef").replace("{value}", name), "error");
cur.담은로직.push({ 로직: "", 고정: fx, 별칭: null, 메모: null, 입력값: {}, 수량: null });
changed();
},
};
const addLogic = createButton({
label: tc("AddLogic"),
variant: "filled",
onClick: () =>
openLogicPicker((x) => {
if (cur.담은로직.some((r) => r.로직 === x.키))
return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error");
cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량: null });
void loadLogics().then(() => changed());
}, withHopyo(library)),
});
const addMat = addMaterialButton(
tc("Labor_AddMat"),
(m) => {
const why = insideKeys((calc ?? last)?.줄).has(m.코드)
? "Labor_InLogicMat"
: cur.자재.some((x) => x.코드 === m.코드) && "Labor_AlreadyMat";
if (why) return showToast(tc(why).replace("{value}", `${m.코드} ${m.이름 ?? ""}`), "error");
cur.자재.push({ 코드: m.코드, 이름: m.이름, 규격: m.규격, 단위: m.단위, 수량: null });
changed();
},
opt.layer === "personal" ? { owner: library.owner } : undefined,
);
// 정보 칸 — 이름 · 구분 · 상세구분 · 단위 · 본뜬
const nameField = createInputField({ type: "text", label: tc("Lib_Name") });
nameField.root.dataset.laborField = "이름";
nameField.input.value = String((cur as { 이름?: unknown }).이름 ?? "");
nameField.input.readOnly = !edit;
nameField.input.addEventListener("input", () => {
(cur as { 이름?: string }).이름 = nameField.input.value.trim();
opt.onChanged(dirty());
});
const unit = el("div", { className: "ui-field", attrs: { "data-labor-field": "단위" } });
const unit0 = cur.단위;
const drawUnit = (): void =>
unit.replaceChildren(
el("label", { className: "ui-field__label", text: tc("Labor_Unit") }),
createUnitPick({
value: cur.단위 || NO_UNIT,
extra: [unit0, cur.단위].filter((u): u is string => !!u),
readOnly: !edit,
ariaLabel: tc("Labor_Unit"),
onPick: (u) => {
cur.단위 = u === NO_UNIT ? null : u;
opt.onChanged(dirty());
drawUnit();
draw();
},
}),
);
drawUnit();
const origin = createInputField({ type: "text", label: tc("Labor_Origin") });
origin.input.value = cur.본뜬?.키 ? `${cur.본뜬.층} ${cur.본뜬.키}` : tc("Origin_Self");
origin.input.readOnly = true;
host.replaceChildren(
el("div", {
className: "ui-side-panels-row",
children: [
el("div", {
className: "m01-logic__editor m01-combo m02-labor__edit",
attrs: { "data-labor": opt.column },
children: [
el("section", {
className: "m01lab__box m01-logic__section",
children: [
el("div", {
className: "m01lab__info",
children: [
nameField.root,
...libMeta(cur, edit, () => opt.onChanged(dirty())),
unit,
origin.root,
],
}),
],
}),
el("section", {
className: "m01lab__box m01-logic__section",
children: [
el("div", {
className: "m01-logic__section-head",
children: [
el("h3", { text: tc("Grid_Title") }),
...(edit
? [el("div", { className: "ui-btn-group", children: [addLogic, addMat] })]
: []),
],
}),
createText(t("TempHint"), "hint"),
gridHost,
],
}),
],
}),
side.root,
],
}),
);
draw();
void loadLogics().then(() => alive && recalc());
return {
getDoc: () => {
const out: Record<string, unknown> = { ...cur, 종류: "사전조합" };
delete out.열;
return out as unknown as LaborDoc;
},
saveArea: async () => false,
destroy: () => {
alive = false;
window.clearTimeout(timer);
host.replaceChildren();
},
};
}
@@ -58,7 +58,7 @@ export function mountSangeunDetail(
host: HTMLElement,
doc0: unknown,
opt: SangeunDetailOptions,
): { getDoc: () => SkDoc; destroy: () => void } {
): { getDoc: () => SkDoc; getValues: () => Record<string, string>; destroy: () => void } {
setLogicApiPaths(LIB); // 상세 부품 길 = M02 로그인 길(717-1)
const doc = clone((doc0 ?? {}) as SkDoc);
let saved = JSON.stringify(doc);
@@ -323,6 +323,7 @@ export function mountSangeunDetail(
saved = JSON.stringify(doc);
return clone(doc);
},
getValues: () => ({ ...values }), // 723-3 — 일위대가 줄 모달 [적용] = 옵션을 그 줄에 남김
destroy: () => {
alive = false;
host.replaceChildren();
+11 -5
View File
@@ -35,11 +35,17 @@ def _save(folder, doc):
def test_화면_서고_모드도_자재목록_세_칸_자재_저장(world): # noqa: F811
detail = open(os.path.join(SRC, "M02_MasterTemplete_Labor_Detail.ts"), encoding="utf-8").read()
assert 'section(tc("Labor_Mats"), opt.card ? null : addMat, matHost),' in detail
assert 'lib ? [] : [section(tc("Labor_Mats")' not in detail
assert "mark: markLogicMat," in detail and "lib ? undefined" not in detail
assert "delete out.자재" not in detail and "delete out.열" in detail
"""723-2 — 서고 = 일위대가표만(`Labor_Table`) · [자재 추가] 머리 · 세 칸 = 줄 모달(`Labor_RowModal`)."""
read = lambda name: open(os.path.join(SRC, name), encoding="utf-8").read() # noqa: E731
detail, lib = (
read("M02_MasterTemplete_Labor_Detail.ts"),
read("M02_MasterTemplete_Labor_Table.ts"),
)
modal = read("M02_MasterTemplete_Labor_RowModal.ts")
assert 'if (opt.mode === "precombo") return mountLaborTable(host, doc, opt);' in detail
assert 'className: "ui-btn-group", children: [addLogic, addMat]' in lib
assert "setLogicMark(row, at, key, v);" in modal and "setMark(o.mat, key, v);" in modal
assert "delete out.자재" not in lib and "delete out.열" in lib
def test_저장_왕복_고름_자재표시_자재_남음(world): # noqa: F811
+100
View File
@@ -0,0 +1,100 @@
"""PLAN 723 — M02 일위대가(사전조합) 수량 = 저장 안 함 · 일위대가표만 · 줄 모달(내역 16 · 17).
723-1 서버 — 저장 때 안 줄 · 자재 수량 null(비율 줄 율만) · 펼침 = 쓰는 쪽 알약 값 · 안 이으면 「수량 없음」 ·
구조물 조합 알약에 안 줄 알약 · 불러오기 엑셀 수량 = `본뜬.실무수량`(셈 안 씀).
723-2 · 723-3 화면 — 서고 상세 = `Labor_Table`(수량 칸 휘발 임시 입력 · 우클릭) · 줄 모달 = 717-2b 상세 줄 모드.
"""
import os
import pytest
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_Labor_Ref as refs
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 임시 층 fixture
SRC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "M02_MasterTemplete")
SYS = {"층": "system", "주인": None}
STONE = "GF000219" # 원/㎡
RATE = {"이름": "공구손료", "단위": "%", "단가": {"노무비": 0, "재료비": 0, "경비": 0}}
def _src(name: str) -> str:
return open(os.path.join(SRC, name), encoding="utf-8").read()
def test_저장은_안_줄_수량을_걷고_비율_줄_율만_남김(world): # noqa: F811
rate = {"고정": {**RATE, "비율": {"기준": "노무비", "비목": "경비"}}, "수량": {"손": 3}}
lines = [{"로직": STONE, "수량": {"손": 2.5}}, rate]
with store.at(world["system"]):
key = refs.create("precombo", {"이름": "수량 없음", "단위": "m", "담은로직": lines})["이름"]
doc = store.read("precombo", key)["문서"]
assert doc["담은로직"][0]["수량"] is None
assert doc["담은로직"][1]["수량"] == {"손": 3} # 비율 = 율(기준단위 당 수량 아님)
frozen = refs.with_pills(doc) # 프로젝트 굳힘도 수량 없이
assert frozen["담은로직"][0]["수량"] is None and frozen["알약"]
def test_펼침은_이은_알약만_쓰고_층_원본_옛_손은_안_씀(world): # noqa: F811
with store.at(world["system"]):
key = refs.create("precombo", {"이름": "옛", "단위": "m", "담은로직": [{"로직": STONE}]})[
"이름"
]
ref = {**SYS, "키": key}
path = world["system"] / "precombo" / f"{key}.json"
import json
old = json.loads(path.read_bytes())
old["담은로직"][0]["수량"] = {"손": 9} # 옛 문서(723-1 앞) — 파일은 그대로 둠
path.write_text(json.dumps(old, ensure_ascii=False), encoding="utf-8")
whole = m01.loaded()[1]
with store.at(world["system"]):
bare = refs.Expand(whole)(ref)
got = refs.Expand(whole, given={f"{refs.ref_id(ref)}#0": 2})(ref)
frozen = refs.Expand(whole, read=lambda r: old)(ref) # 굳힌 사본 = 손 그대로
stone = labor.run_line(whole, STONE, {})["기준"]["계"]
assert bare["기준"] is None and "수량 없음" in bare["까닭"]
assert got["기준"]["계"] == pytest.approx(stone * 2)
assert frozen["기준"]["계"] == pytest.approx(stone * 9)
def test_구조물_조합_알약에_안_줄_알약(world): # noqa: F811
with store.at(world["system"]):
key = refs.create(
"precombo", {"이름": "옹벽", "단위": "m", "담은로직": [{"로직": STONE, "별칭": "돌"}]}
)["이름"]
doc = labor.clean({"열": "rv", "담은로직": [{"참조": {**SYS, "키": key}}]})
pills = labor.pills(doc)
rid = refs.ref_id({**SYS, "키": key})
assert [p["키"] for p in pills] == [rid, f"{rid}#0"]
assert pills[1]["이름"] == "옹벽 · 돌" and pills[1]["단위"] == "㎡"
def test_화면_서고_상세는_일위대가표만_임시_수량_휘발():
table, detail = (
_src("M02_MasterTemplete_Labor_Table.ts"),
_src("M02_MasterTemplete_Labor_Detail.ts"),
)
assert 'if (opt.mode === "precombo") return mountLaborTable(host, doc, opt);' in detail
assert "const temp = new Map<string, number>();" in table # 휘발 — 문서 밖
assert "calc = calc && settle(calc, withTemp(), {});" in table # 짝 셈 미리 보기
assert (
'const out: Record<string, unknown> = { ...cur, 종류: "사전조합" };' in table
) # temp 안 실음
for label in ('tc("Up")', 'tc("Down")', 't("Alias")', 'tc("Remove")'):
assert label in table # 우클릭 메뉴
assert "fixed: (fx: LaborFixed, name: string)" in table # 721-2 고정 줄 이음
def test_화면_줄_모달은_SK_상세_줄_모드_적용은_덮개와_옵션():
modal, sk = (
_src("M02_MasterTemplete_Labor_RowModal.ts"),
_src("M02_MasterTemplete_Labor_RowSk.ts"),
)
detail = _src("M02_MasterTemplete_Sangeun_Detail.ts")
assert "mountSangeunDetail(box, doc," in sk and "getValues: () => ({ ...values })" in detail
assert "if (got.patch) row.패치 = got.patch;" in modal and "setValues(got.values);" in modal
assert 'createCheckbox(t("AlsoSk"), { checked: false })' in modal # 기본 꺼짐
assert "openRowSangeun" not in modal # 717-3 두 보기 갈음