merge(717-2b): origin/main_laptop_1 합침 — Sangeun_Cover.ts 같은 이름 새 파일 충돌(AA) 풂 · 두 표 한 파일
- 셈 길 calcSangeun 한 벌 — 답 꼴 제네릭(기본 717-2b SkAnswer · 717-3 모달은 SkCalc) · 시험 입력 칸(입력값) 그대로 · owner 는 시스템 층 밖만 - 717-3 일위대가 모달 표 = coverTable · sangeunFromLine 그대로 · 717-2b SK 상세 표 = skCoverTable(옛 coverTable 이름 바꿈 · 옵션 = SkCoverOptions) - 모달 부르는 자리 셋에 <SkCalc> 만 더함 - 시험 test_717_2b · test_717_3 · 715 · 719 통과 · typecheck 0 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import {
|
||||
type SubBrief,
|
||||
} from "../M01_MasterData/M01_MasterData_UI_Logic_Api";
|
||||
import type { LaborCalc, LaborFixed, LaborRef, LaborSpec } from "./M02_MasterTemplete_Labor_Calc";
|
||||
import type { LinePatch } from "./M02_Sangeun_Cover_Model";
|
||||
import {
|
||||
call,
|
||||
getActiveLayer,
|
||||
@@ -184,6 +185,8 @@ export interface LaborRow {
|
||||
자재표시?: Record<string, MatMarks>;
|
||||
/** 고정 줄(705-1 · 705-2 붙여넣기) — 숫자 그대로 · 로직 · 참조 없음 */
|
||||
고정?: LaborFixed;
|
||||
/** 그 줄 덮개(715-3 · 717-3 단가산출 모달) — 로직 줄 · SK 참조 줄 · 서버가 검사 */
|
||||
패치?: LinePatch;
|
||||
}
|
||||
/** 자재 세 칸(148-4 · 서버 `M02_Labor_Doc.MARKS` 와 같음) — 빈 칸 = 읽을 때 기본값 · 금액 무관 */
|
||||
export const MAT_MARKS = {
|
||||
@@ -264,6 +267,7 @@ export function normalizeLabor(doc: unknown, column: string): LaborDoc {
|
||||
...(r.승격 === true && !r.참조 ? { 승격: true } : {}),
|
||||
...(r.참조 || r.고정 ? {} : cleanShown(r.자재표시)),
|
||||
...(r.고정 ? { 고정: r.고정 } : {}), // 705-1 — 읽을 때 떨구면 [저장]에서 줄이 사라짐
|
||||
...(r.패치 ? { 패치: r.패치 } : {}), // 717-3 — 떨구면 [저장]에서 줄 덮개가 사라짐
|
||||
})),
|
||||
자재: (d.자재 ?? []).map((m) => ({
|
||||
코드: m.코드,
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
|
||||
import { openQtyModal } from "./M02_MasterTemplete_Labor_QtyModal";
|
||||
import { openRefEdit } from "./M02_MasterTemplete_Labor_RefEdit";
|
||||
import { openRowSangeun } from "./M02_MasterTemplete_Labor_SangeunModal";
|
||||
import { afterRowDrag } from "./M02_MasterTemplete_Labor_RowDrag";
|
||||
import { openLogicPicker, withHopyo } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import { logicMatRows } from "./M02_MasterTemplete_Labor_LogicMats";
|
||||
@@ -206,8 +207,9 @@ export function mountLaborDetail(
|
||||
alias: setAlias,
|
||||
// 134-1 — 구조물 일위대가 로직 줄만(사전조합 안 로직 줄엔 없음)
|
||||
...(lib ? {} : { promote: setPromote }),
|
||||
...sk,
|
||||
}
|
||||
: { open: pick, option: openRow, ref: openRef },
|
||||
: { open: pick, option: openRow, ref: openRef, ...sk },
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -455,6 +457,33 @@ export function mountLaborDetail(
|
||||
});
|
||||
}
|
||||
|
||||
/** [단가산출 보기](717-3) — 고침 = 그 줄 패치(화면 캐시) · 프로젝트 복사본(셈 주입)엔 없음 */
|
||||
const sk = opt.calc ? {} : { sangeun: openSk };
|
||||
function openSk(i: number): void {
|
||||
const row = cur.담은로직[i];
|
||||
const line = lineOf(lineId(row) ?? "");
|
||||
const name = (cur as { 이름?: string }).이름 || opt.column;
|
||||
openRowSangeun({
|
||||
scope: { layer: opt.layer, owner: library.owner },
|
||||
row,
|
||||
title:
|
||||
`${row.참조?.키 ?? row.로직} ${(row.참조 ? line?.이름 : logics.get(row.로직)?.이름) ?? ""}`.trim(),
|
||||
head: [name, cur.단위 ? `1 ${cur.단위} 당` : ""].filter(Boolean).join(" · "),
|
||||
qty: qtyShort(line, line?.분모 ?? "", cells()),
|
||||
unit: line?.분모 ?? "",
|
||||
readOnly: !edit,
|
||||
apply: (p) => {
|
||||
if (p) row.패치 = p;
|
||||
else delete row.패치;
|
||||
changed();
|
||||
},
|
||||
// 새 SK 참조 줄로 = 자기 개인 층 일위대가만(위 층 일위대가는 개인 SK 를 못 가리킴)
|
||||
...(edit && opt.layer === "personal" && !library.owner
|
||||
? { replace: (next: LaborRow) => ((cur.담은로직[i] = next), changed()) }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** 수량산출 칸 누름 — 로직 줄 */
|
||||
function openQty(i: number): void {
|
||||
const row = cur.담은로직[i];
|
||||
|
||||
@@ -215,6 +215,8 @@ export interface RowHandlers {
|
||||
alias?: (i: number, value: string | null) => void;
|
||||
/** 구분 드롭다운(134-1 · 로직 줄 단가산출 ↔ 단가승격) — 없으면 글만 */
|
||||
promote?: (i: number, on: boolean) => void;
|
||||
/** [단가산출 보기](717-3 · 로직 줄 · SK 참조 줄 모달) — 없으면 단추 없음 */
|
||||
sangeun?: (i: number) => void;
|
||||
/** 자재 세 칸(148-4) — 없으면 글만 */
|
||||
mark?: (i: number, key: MatMarkKey, v: string) => void;
|
||||
}
|
||||
@@ -330,9 +332,9 @@ function subRows(line: LaborCalcLine | undefined, wait: boolean, tools: boolean)
|
||||
/** 담은 로직 표 — 보기 갈래는 `on` 없이 */
|
||||
export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement {
|
||||
const wait = !data.calc && !data.fail;
|
||||
const { move, remove, option, ref } = on ?? {};
|
||||
const { move, remove, option, ref, sangeun } = on ?? {};
|
||||
// 옵션 · 삭제만(구조물 조합 카드 · 43-4)이어도 끝 칸 — 순서 단추는 `move` 가 있을 때만
|
||||
const tools = !!(move || remove || option || ref);
|
||||
const tools = !!(move || remove || option || ref || sangeun);
|
||||
const group = (children: HTMLElement[]): HTMLElement =>
|
||||
el("div", { className: "ui-btn-group", children });
|
||||
const lineOf = (key: string): LaborCalcLine | undefined =>
|
||||
@@ -457,6 +459,9 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
]
|
||||
: []),
|
||||
group([
|
||||
...(sangeun && !fx && (!row.참조 || row.참조.키.startsWith("SK-"))
|
||||
? [small(tc("Labor_Sangeun"), () => sangeun(i))]
|
||||
: []),
|
||||
...(row.참조
|
||||
? ref && !row.참조.키.startsWith("SP-")
|
||||
? [small(tc("Labor_Edit"), () => ref(i))]
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Labor_SangeunModal.ts
|
||||
* 일위대가 줄 [단가산출 보기] 모달(PLAN 717-3 · 요구서 내역12 4장 4 · 16 · 24 · 4-1 확정 5) —
|
||||
* 로직 줄 · 단가산출(SK) 참조 줄 하나의 단가산출을 보고 그 일위대가에서만 고침.
|
||||
* · 위 = 일위대가 머리(「1 ㎡ 당」) · 이 줄 수량 · 1 단위 금액 · 짝 잃은 덮개 알림
|
||||
* · 보기 고르기 = 인력·재료·경비(기본 · 공용 계산서 `calc_sheet`) | 단가산출 상세(품 · 단가 칸 고침 · `Sangeun_Cover`)
|
||||
* · 고침 = 그 일위대가 줄 칸 `패치 {덮개, 뺀줄, 더한줄}` · [적용] = 화면 캐시(저장은 페이지 [저장] ·
|
||||
* 원값은 저장 때 서버가 셈) · 적용 안 하고 닫으면 물음
|
||||
* · [단가산출로 저장] = 개인 층 새 SK(이름 받음 · `from-line`) · 일위대가가 자기 개인 층이면 그 줄을 새 SK 참조 줄로
|
||||
* · 셈 = 서버 셈 길(로그인 · 저장 안 함 · 최신 응답만 · CLAUDE.md 5장)
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, el, showToast } from "@ui/ui_template_elements";
|
||||
import { askConfirm, askText, openModal } from "@ui/ui_template_modal";
|
||||
import { createSegment } from "@ui/ui_template_segment";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { calcSheetRows } from "../A00_Common/calc_sheet";
|
||||
import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money";
|
||||
import type { LaborRow } from "./M02_MasterTemplete_Labor_Api";
|
||||
import {
|
||||
calcSangeun,
|
||||
coverTable,
|
||||
sangeunFromLine,
|
||||
type SkScope,
|
||||
} from "./M02_MasterTemplete_Sangeun_Cover";
|
||||
import {
|
||||
cleanPatch,
|
||||
lineDoc,
|
||||
needsIds,
|
||||
setCover,
|
||||
sheetGroups,
|
||||
type LinePatch,
|
||||
type SkCalc,
|
||||
type SkLine,
|
||||
} from "./M02_Sangeun_Cover_Model";
|
||||
|
||||
const TEXT = {
|
||||
Title: ["단가산출", "Unit-price build-up"],
|
||||
Sheet: ["인력·재료·경비", "Labor · material · expense"],
|
||||
Detail: ["단가산출 상세", "Detail"],
|
||||
Qty: ["이 줄 수량", "Line quantity"],
|
||||
Per: ["1 {u} 당", "per 1 {u}"],
|
||||
Sum: ["계", "Total"],
|
||||
Name: ["이름", "Name"],
|
||||
Poom: ["품 · 수량", "Qty"],
|
||||
Unit: ["단위", "Unit"],
|
||||
Price: ["단가", "Unit price"],
|
||||
Amount: ["금액", "Amount"],
|
||||
Was: ["원래", "was"],
|
||||
Tag: ["이 일위대가에서 고침", "Edited in this unit price"],
|
||||
Empty: ["줄 없음", "No lines"],
|
||||
Loading: ["셈하는 중…", "Calculating…"],
|
||||
Lost: ["덮개 짝 잃음", "Override lost"],
|
||||
Apply: ["적용", "Apply"],
|
||||
ToSk: ["단가산출로 저장", "Save as unit-price build-up"],
|
||||
AskName: ["새 단가산출 이름(내 개인 층)", "New build-up name (my layer)"],
|
||||
Saved: ["{k} 저장함(내 개인 층)", "{k} saved (my layer)"],
|
||||
Replaced: ["이 줄을 {k} 참조로 바꿈 — [저장]으로 남김", "Line now refers to {k} — press [Save]"],
|
||||
Discard: ["적용 안 한 고침이 있음 — 버리고 닫을까?", "Discard unapplied edits?"],
|
||||
Close: ["닫기", "Close"],
|
||||
ReadOnly: ["읽기만 — 고친 값은 [단가산출로 저장]으로만 남김", "Read only — use [Save as]"],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
|
||||
|
||||
export interface RowSangeunOptions {
|
||||
/** 그 일위대가 층(셈 길 · 참조 위 층 검사) */
|
||||
scope: SkScope;
|
||||
row: LaborRow;
|
||||
/** 줄 이름(로직 키 · 이름) */
|
||||
title: string;
|
||||
/** 일위대가 머리(이름 · 「1 ㎡ 당」) */
|
||||
head: string;
|
||||
/** 이 줄 지금 수량 글(「수기 5 ㎥」) */
|
||||
qty: string;
|
||||
/** 줄 결과 단위(분모 · SK 단위 기본) */
|
||||
unit: string;
|
||||
readOnly: boolean;
|
||||
/** [적용] — 그 줄 패치(null = 고침 없음) */
|
||||
apply: (patch: LinePatch | null) => void;
|
||||
/** [단가산출로 저장] 뒤 그 줄을 새 SK 참조 줄로(없으면 안 바꿈) */
|
||||
replace?: (row: LaborRow) => void;
|
||||
}
|
||||
|
||||
const same = (a: LinePatch | null, b: LinePatch | null): boolean =>
|
||||
JSON.stringify(a) === JSON.stringify(b);
|
||||
|
||||
export function openRowSangeun(o: RowSangeunOptions): void {
|
||||
let applied = cleanPatch(o.row.패치);
|
||||
let patch = applied;
|
||||
let ids: string[] | null = null;
|
||||
let orig = new Map<string, SkLine>();
|
||||
let calc: SkCalc | null = null;
|
||||
let fail: string | null = null;
|
||||
let view = "sheet";
|
||||
let seq = 0;
|
||||
let timer = 0;
|
||||
const info = el("div", { className: "m02-std-sheet__body", attrs: { "data-sk-head": "" } });
|
||||
const main = el("div", { className: "m02-std-sheet__body", attrs: { "data-sk-body": "" } });
|
||||
const seg = createSegment({
|
||||
options: [
|
||||
{ value: "sheet", label: t("Sheet") },
|
||||
{ value: "detail", label: t("Detail") },
|
||||
],
|
||||
value: view,
|
||||
ariaLabel: t("Title"),
|
||||
onChange: (v) => {
|
||||
view = v;
|
||||
draw();
|
||||
},
|
||||
});
|
||||
|
||||
const draw = (): void => {
|
||||
const unit = o.unit || "식";
|
||||
const one = calc?.["1단위"]?.계;
|
||||
info.replaceChildren(
|
||||
createText(o.head, "muted", 14),
|
||||
createText(`${o.title} · ${t("Qty")} ${o.qty}`, "muted", 14),
|
||||
...(one != null ? [createText(`${t("Per").replace("{u}", unit)} ${formatMoney(one)}`)] : []),
|
||||
...(o.readOnly ? [createText(t("ReadOnly"), "hint")] : []),
|
||||
...(calc?.짝잃음 ?? []).map((x) =>
|
||||
createText(`${t("Lost")} ${x.cid} · ${x.까닭}`, "warning"),
|
||||
),
|
||||
);
|
||||
if (!calc)
|
||||
return void main.replaceChildren(createText(fail ?? t("Loading"), fail ? "error" : "muted"));
|
||||
const base = calc.배 && calc.배 !== 1 ? `${calc.배} ${unit}` : `1 ${unit}`;
|
||||
main.replaceChildren(
|
||||
...(fail ? [createText(fail, "error")] : []),
|
||||
...(view === "sheet"
|
||||
? calcSheetRows({
|
||||
묶음: sheetGroups(calc),
|
||||
계: calc.기준?.계 ?? null,
|
||||
sumLabel: `${t("Sum")} (${base})`,
|
||||
})
|
||||
: [
|
||||
coverTable(calc, orig, patch, {
|
||||
edit: true,
|
||||
text: {
|
||||
name: t("Name"),
|
||||
qty: t("Poom"),
|
||||
unit: t("Unit"),
|
||||
price: t("Price"),
|
||||
amount: t("Amount"),
|
||||
was: t("Was"),
|
||||
tag: t("Tag"),
|
||||
empty: t("Empty"),
|
||||
},
|
||||
onCover: (cid, key, value) => {
|
||||
patch = setCover(patch, cid, key, value);
|
||||
recalc();
|
||||
},
|
||||
}),
|
||||
]),
|
||||
);
|
||||
applyBtn.disabled = o.readOnly || same(patch, applied);
|
||||
};
|
||||
|
||||
/** 서버 셈 — 늦게 온 답은 버림 */
|
||||
const recalc = (): void => {
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(async () => {
|
||||
const mine = ++seq;
|
||||
try {
|
||||
const got = await calcSangeun<SkCalc>(o.scope, lineDoc(o.row, ids, patch));
|
||||
if (mine !== seq) return;
|
||||
calc = got;
|
||||
fail = null;
|
||||
} catch (error) {
|
||||
if (mine !== seq) return;
|
||||
fail = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
draw();
|
||||
}, 150);
|
||||
};
|
||||
|
||||
let close = (): void => {};
|
||||
const applyBtn = createButton({
|
||||
label: t("Apply"),
|
||||
variant: "filled",
|
||||
onClick: () => {
|
||||
o.apply(patch);
|
||||
applied = patch;
|
||||
close();
|
||||
},
|
||||
});
|
||||
applyBtn.dataset.skApply = "";
|
||||
const saveBtn = createButton({
|
||||
label: t("ToSk"),
|
||||
variant: "ghost",
|
||||
onClick: async () => {
|
||||
const name = (await askText({ message: t("AskName"), value: o.title }))?.trim();
|
||||
if (!name) return;
|
||||
const line: Record<string, unknown> = { ...o.row };
|
||||
delete line.패치;
|
||||
if (patch) line.패치 = patch;
|
||||
try {
|
||||
const got = await sangeunFromLine(
|
||||
{ layer: "personal", owner: null },
|
||||
line,
|
||||
name,
|
||||
o.unit || "식",
|
||||
);
|
||||
showToast(t("Saved").replace("{k}", got.이름), "success");
|
||||
if (!o.replace) return;
|
||||
o.replace({ 로직: "", 입력값: {}, 메모: null, 별칭: null, 수량: o.row.수량, ...got.줄 });
|
||||
showToast(t("Replaced").replace("{k}", got.이름), "info");
|
||||
applied = patch;
|
||||
close();
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : String(error), "error");
|
||||
}
|
||||
},
|
||||
});
|
||||
saveBtn.dataset.skSave = "";
|
||||
({ close } = openModal({
|
||||
title: `${t("Title")} — ${o.title}`,
|
||||
closeLabel: t("Close"),
|
||||
width: "1100px",
|
||||
wide: true,
|
||||
actions: [seg.root, saveBtn, applyBtn],
|
||||
beforeClose: () => same(patch, applied) || askConfirm({ message: t("Discard"), danger: true }),
|
||||
mount: (body) => body.append(info, main),
|
||||
}));
|
||||
draw();
|
||||
|
||||
// 원래 값(그 일위대가 고침 없음) 먼저 — 고름 · 자재표시가 있으면 바탕 줄 정체부터
|
||||
void (async () => {
|
||||
try {
|
||||
if (needsIds(o.row))
|
||||
ids = (await calcSangeun<SkCalc>(o.scope, lineDoc(o.row, null, null))).정체 ?? null;
|
||||
const base = await calcSangeun<SkCalc>(o.scope, lineDoc(o.row, ids, null));
|
||||
orig = new Map(base.줄.map((x) => [x.cid, x]));
|
||||
if (patch) return recalc();
|
||||
calc = base;
|
||||
} catch (error) {
|
||||
fail = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
draw();
|
||||
})();
|
||||
}
|
||||
@@ -37,6 +37,7 @@ const TEXT = {
|
||||
Remove: ["삭제", "Delete"],
|
||||
Labor_Option: ["옵션", "Options"],
|
||||
Labor_Edit: ["수정", "Edit"],
|
||||
Labor_Sangeun: ["단가산출 보기", "Build-up"],
|
||||
Ref_Shared: [
|
||||
"서고 원본을 고침 — 이 문서를 쓰는 모든 구조물에 따라감",
|
||||
"Edits the library original — every structure using it follows",
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
/* =============================================================================
|
||||
* M02_MasterTemplete_Sangeun_Cover.ts
|
||||
* 단가산출(SK) 고침 표(PLAN 717-2b · 717-3 일위대가 모달도 씀) — M01 「단가산출 상세」 꼴(인력 · 재료 · 경비 ·
|
||||
* 하위 묶음 + 소계 · 계 · 열 = 이름 · 수량 · 단위 · 로직 · 단가 · 금액)에 품 · 단가 칸 고침 · 줄 빼기.
|
||||
* 고친 칸 = 칸 색(`is-edited`) + 바탕 값 곁 글 + 「단가산출에서 고침」 꼬리표 · 값 = 서버 셈 답 그대로
|
||||
* 셈 길 = `calcSangeun`(`POST /layers/{층}/sangeun/calc` · 저장 안 함) — 부르는 쪽이 최신 응답만 받음
|
||||
* 단가산출(SK) 고침 표 · 셈 길 부름(PLAN 717-2b · 717-3 · 요구서 내역12 4장 4 · 24) — 두 표가 셈 길 한 벌을 씀.
|
||||
* · 셈 = `calcSangeun`(`POST /layers/{층}/sangeun/calc` · 저장 안 함 · 부르는 쪽이 최신 응답만 받음) ·
|
||||
* [단가산출로 저장] = `sangeunFromLine`(`/sangeun/from-line`)
|
||||
* · `coverTable`(717-3 일위대가 모달) = 이름 · 품 · 단위 · 단가 · 금액 한 표 · 고친 줄 = 표 부품 「바뀜」
|
||||
* (tr.is-changed) + 원래 값 곁 + 꼬리표 · 단가 칸은 한 비목 줄만 · 덧줄(%) = 율만(서버 `_covered` 규칙)
|
||||
* · `skCoverTable`(717-2b SK 상세) = M01 「단가산출 상세」 꼴(인력 · 재료 · 경비 · 하위 묶음 + 소계 · 계 ·
|
||||
* 열 = 이름 · 수량 · 단위 · 로직 · 단가 · 금액) · 품 · 단가 칸 고침 · 줄 빼기 · 고친 칸 = `is-edited`
|
||||
* + 바탕 값 곁 글 + 꼬리표 · 값 = 서버 셈 답 그대로
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
@@ -16,25 +20,154 @@ import { createText } from "@ui/ui_template_text";
|
||||
import { formatNumber } from "../M01_MasterData/M01_MasterData_UI_Logic_Edit";
|
||||
import { formatMoney, formatPrice } from "../M01_MasterData/M01_MasterData_UI_Logic_Money";
|
||||
import { call, type Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import type { CoverRow, SkAnswer, SkDoc } from "./logic/M02_Sangeun_Cover_Model";
|
||||
import type { CoverRow, SkAnswer } from "./logic/M02_Sangeun_Cover_Model";
|
||||
import {
|
||||
priceKey,
|
||||
qtyOf,
|
||||
type CoverKey,
|
||||
type LinePatch,
|
||||
type PatchRow,
|
||||
type SkCalc,
|
||||
type SkLine,
|
||||
} from "./M02_Sangeun_Cover_Model";
|
||||
import "./M02_MasterTemplete_Sangeun.css";
|
||||
|
||||
/** SK 셈(저장 안 함) — 문서 = 화면 문서 · 입력값 = 시험 입력 */
|
||||
export function calcSangeun(
|
||||
scope: { layer: Layer; owner: string | null },
|
||||
doc: SkDoc,
|
||||
export interface SkScope {
|
||||
layer: Layer;
|
||||
owner: string | null;
|
||||
}
|
||||
const q = (scope: SkScope): string =>
|
||||
scope.owner && scope.layer !== "system" ? `?owner=${encodeURIComponent(scope.owner)}` : "";
|
||||
|
||||
/** 셈(저장 안 함) — 문서 = 화면 문서 · `given` = 시험 입력(빈 칸 뺌) · 서버가 M01 엔진 한 벌로 셈 ·
|
||||
* 답 꼴 = 717-2b `SkAnswer`(기본) · 717-3 `SkCalc` */
|
||||
export function calcSangeun<T = SkAnswer>(
|
||||
scope: SkScope,
|
||||
doc: Record<string, unknown>,
|
||||
given: Record<string, string> = {},
|
||||
): Promise<SkAnswer> {
|
||||
const q =
|
||||
scope.owner && scope.layer !== "system" ? `?owner=${encodeURIComponent(scope.owner)}` : "";
|
||||
): Promise<T> {
|
||||
const 입력값 = Object.fromEntries(Object.entries(given).filter(([, v]) => String(v).trim()));
|
||||
return call<SkAnswer>(`/layers/${scope.layer}/sangeun/calc${q}`, {
|
||||
return call<T>(`/layers/${scope.layer}/sangeun/calc${q(scope)}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ 문서: doc, 입력값 }),
|
||||
});
|
||||
}
|
||||
|
||||
/** [단가산출로 저장] — 그 층 새 SK · 답 = 새 키 + 그 SK 참조 줄 */
|
||||
export const sangeunFromLine = (
|
||||
scope: SkScope,
|
||||
line: PatchRow & Record<string, unknown>,
|
||||
이름: string,
|
||||
단위: string,
|
||||
): Promise<{ 이름: string; 줄: Record<string, unknown> }> =>
|
||||
call(`/layers/${scope.layer}/sangeun/from-line${q(scope)}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ 줄: line, 이름, 단위 }),
|
||||
});
|
||||
|
||||
const num = (v: number | null | undefined): string =>
|
||||
v == null ? "—" : Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
|
||||
|
||||
export interface CoverTableText {
|
||||
name: string;
|
||||
qty: string;
|
||||
unit: string;
|
||||
price: string;
|
||||
amount: string;
|
||||
/** 원래 값 곁 글 머리(「원래」) */
|
||||
was: string;
|
||||
/** 고친 줄 꼬리표(「이 일위대가에서 고침」) */
|
||||
tag: string;
|
||||
empty: string;
|
||||
}
|
||||
|
||||
export interface CoverTableOptions {
|
||||
edit: boolean;
|
||||
text: CoverTableText;
|
||||
/** 칸 고침 — 빈 글 = null(그 칸 원래 값) */
|
||||
onCover: (cid: string, key: CoverKey, value: number | null) => void;
|
||||
}
|
||||
|
||||
/** 고칠 칸 — 글 칸(Enter · 떠남 때 넘김 · 숫자 아니면 그대로 되돌림) · 곁에 원래 값 */
|
||||
function cell(
|
||||
shown: number | null | undefined,
|
||||
was: number | null | undefined,
|
||||
covered: boolean,
|
||||
o: CoverTableOptions,
|
||||
set: ((v: number | null) => void) | null,
|
||||
): HTMLElement {
|
||||
const note =
|
||||
covered && was != null && was !== shown ? [createText(`${o.text.was} ${num(was)}`)] : [];
|
||||
if (!o.edit || !set) return el("div", { children: [el("span", { text: num(shown) }), ...note] });
|
||||
const box = createInputField({ value: shown == null ? "" : String(shown), compact: true });
|
||||
box.input.inputMode = "decimal";
|
||||
box.input.dataset.coverCell = "";
|
||||
box.input.addEventListener("keydown", (e) => e.key === "Enter" && box.input.blur());
|
||||
box.input.addEventListener("change", () => {
|
||||
const raw = box.input.value.replace(/,/g, "").trim();
|
||||
const v = raw === "" ? null : Number(raw);
|
||||
if (v !== null && (!Number.isFinite(v) || v < 0)) {
|
||||
box.input.value = shown == null ? "" : String(shown);
|
||||
return;
|
||||
}
|
||||
set(v);
|
||||
});
|
||||
return el("div", { children: [box.root, ...note] });
|
||||
}
|
||||
|
||||
/** 고침 표 — `calc` = 지금 셈 답 · `orig` = 고치기 전 셈 줄(cid → 줄) · `patch` = 지금 줄 패치 */
|
||||
export function coverTable(
|
||||
calc: SkCalc,
|
||||
orig: ReadonlyMap<string, SkLine>,
|
||||
patch: LinePatch | null,
|
||||
o: CoverTableOptions,
|
||||
): HTMLElement {
|
||||
const cols: Column[] = [
|
||||
{ label: o.text.name },
|
||||
{ label: o.text.qty, align: "end", width: "10rem" },
|
||||
{ label: o.text.unit, width: "5rem" },
|
||||
{ label: o.text.price, align: "end", width: "10rem" },
|
||||
{ label: o.text.amount, align: "end", width: "9rem" },
|
||||
];
|
||||
const { root, tbody } = tableShell(cols, { density: "data" });
|
||||
for (const line of calc.줄) {
|
||||
const mine = patch?.덮개?.[line.cid] ?? {};
|
||||
const was = orig.get(line.cid);
|
||||
const pk = priceKey(line);
|
||||
const covered = Object.keys(mine).length > 0 || (line.덮개?.length ?? 0) > 0;
|
||||
const name = el("div", {
|
||||
children: [
|
||||
el("span", { text: line.이름, attrs: { title: line.출처 ?? line.이름 } }),
|
||||
...(Object.keys(mine).length ? [createBadge(o.text.tag, "warning", { size: 18 })] : []),
|
||||
],
|
||||
});
|
||||
const tr = tableRow(cols, [
|
||||
name,
|
||||
// 고친 칸 = 적은 값(서버가 아직 안 받는 칸도 보임) · 그 밖 = 서버 셈 값
|
||||
cell(mine.qty ?? qtyOf(line), was && qtyOf(was), "qty" in mine, o, (v) =>
|
||||
o.onCover(line.cid, "qty", v),
|
||||
),
|
||||
line.율 != null ? "%" : (line.단위 ?? ""),
|
||||
cell(
|
||||
(pk && mine[pk]) ?? line.단가,
|
||||
was?.단가,
|
||||
!!pk && pk in mine,
|
||||
o,
|
||||
pk ? (v) => o.onCover(line.cid, pk, v) : null,
|
||||
),
|
||||
line.금액 == null ? "—" : formatMoney(line.금액),
|
||||
]);
|
||||
tr.dataset.cid = line.cid;
|
||||
if (covered) tr.classList.add("is-changed");
|
||||
tbody.append(tr);
|
||||
}
|
||||
if (!calc.줄.length)
|
||||
tbody.append(tableRow(cols, [{ content: calc.까닭 ?? o.text.empty, colSpan: cols.length }]));
|
||||
return root;
|
||||
}
|
||||
|
||||
/* ── 717-2b SK 상세 고침 표 ── */
|
||||
export interface SkCoverOptions {
|
||||
/** 고칠 수 있음(쓰기 층 · 읽기만 아님) */
|
||||
edit: boolean;
|
||||
/** 열린입력 있는 SK — 품 칸 = 배율 */
|
||||
@@ -85,7 +218,7 @@ function editCell(
|
||||
});
|
||||
}
|
||||
|
||||
function rowCells(r: CoverRow, o: CoverTableOptions): (string | Node)[] {
|
||||
function rowCells(r: CoverRow, o: SkCoverOptions): (string | Node)[] {
|
||||
const can = o.edit;
|
||||
const qtyText = r.수량 == null ? "—" : formatNumber(r.수량);
|
||||
const wasQty = r.was?.qty == null ? null : formatNumber(r.was.qty);
|
||||
@@ -135,7 +268,7 @@ function rowCells(r: CoverRow, o: CoverTableOptions): (string | Node)[] {
|
||||
}
|
||||
|
||||
/** 묶음 표 넷 + 계 + 뺀 줄 */
|
||||
export function coverTable(rows: CoverRow[], o: CoverTableOptions): HTMLElement {
|
||||
export function skCoverTable(rows: CoverRow[], o: SkCoverOptions): HTMLElement {
|
||||
const cols: Column[] = [
|
||||
{ label: L("M02_SangeunColName") },
|
||||
{ label: L("M02_SangeunColQty"), align: "end" },
|
||||
|
||||
@@ -28,7 +28,7 @@ import { buildLabDetail } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Det
|
||||
import { createViewPicker } from "../M01_MasterData/M01_MasterData_UI_LogicLab_View";
|
||||
import type { Layer } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick";
|
||||
import { calcSangeun, coverTable } from "./M02_MasterTemplete_Sangeun_Cover";
|
||||
import { calcSangeun, skCoverTable } from "./M02_MasterTemplete_Sangeun_Cover";
|
||||
import { openSangeunLinePicker } from "./M02_MasterTemplete_Sangeun_Pick";
|
||||
import {
|
||||
dropLine,
|
||||
@@ -221,7 +221,7 @@ export function mountSangeunDetail(
|
||||
baseHint.hidden = !base;
|
||||
const rows = ans ? rowsOf(ans, bare, doc) : [];
|
||||
const names = new Map((bare ? rowsOf(bare, null, {}) : []).map((r) => [r.cid, r.이름]));
|
||||
const ho = coverTable(rows, {
|
||||
const ho = skCoverTable(rows, {
|
||||
edit,
|
||||
opened: opened(),
|
||||
tag: L("M02_SangeunEditedHere"),
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/* =============================================================================
|
||||
* M02_Sangeun_Cover_Model.ts
|
||||
* 단가산출 고친 칸(덮개) 순수 함수(PLAN 717-3 · 요구서 내역12 4장 4 · 16 · 24) — 화면 · fetch 없음.
|
||||
* · `lineDoc` = 일위대가 줄 → 이름 없는 단가산출(SK) 문서(서버 `M02_Labor_Patch.as_doc` 꼴 · 셈 길 몸) ·
|
||||
* 줄 `고름` · `자재표시`(호표 차례 열쇠)는 바탕 줄 정체(`ids` · 셈 답 `정체`)로 cid 열쇠로 바꿈
|
||||
* · `setCover` · `cleanPatch` = 줄 칸 `패치 {덮개, 뺀줄, 더한줄}` 고침(숫자만 · `원값`은 서버가 저장 때 셈)
|
||||
* · `sheetGroups` = 셈 답 줄 → 공용 계산서(`A00_Common/calc_sheet`) 묶음(값 = 서버 셈 그대로 · 다시 안 셈)
|
||||
* 계산 자리 = 서버(`M02_Sangeun_Calc`) 단독 — 여기는 문서 모양만.
|
||||
* ========================================================================== */
|
||||
|
||||
export const PARTS = ["노무비", "재료비", "경비"] as const;
|
||||
export type Part = (typeof PARTS)[number];
|
||||
/** 덮개 칸(서버 `M02_Sangeun_Doc.COVER_KEYS`) — qty = 품 · labor · material · expense = 줄 단가 */
|
||||
export const PART_KEY = { 노무비: "labor", 재료비: "material", 경비: "expense" } as const;
|
||||
export type CoverKey = "qty" | "배율" | (typeof PART_KEY)[Part];
|
||||
export type Cover = Partial<Record<CoverKey, number>>;
|
||||
export interface LinePatch {
|
||||
덮개?: Record<string, Cover>;
|
||||
뺀줄?: string[];
|
||||
더한줄?: unknown[];
|
||||
}
|
||||
export type Money = Record<Part | "계", number>;
|
||||
|
||||
/** 셈 답 한 줄(M01 시험 계산 줄 + cid · 고친 칸 이름) */
|
||||
export interface SkLine {
|
||||
cid: string;
|
||||
이름: string;
|
||||
단위?: string;
|
||||
수량: number;
|
||||
단가?: number;
|
||||
금액?: number;
|
||||
비목?: Partial<Record<Part, number>>;
|
||||
율?: number | null;
|
||||
출처?: string;
|
||||
덮개?: string[];
|
||||
}
|
||||
export interface SkCalc {
|
||||
기준: Money | null;
|
||||
줄: SkLine[];
|
||||
까닭?: string | null;
|
||||
"1단위"?: Money | null;
|
||||
배?: number;
|
||||
정체?: string[];
|
||||
짝잃음?: { cid: string; 까닭: string }[];
|
||||
머리?: string | null;
|
||||
}
|
||||
|
||||
/** 줄 꼴(일위대가 `담은로직[]` 한 줄 · 쓰는 칸만) */
|
||||
export interface PatchRow {
|
||||
로직?: string;
|
||||
참조?: { 층: string; 주인?: string | null; 키: string };
|
||||
입력값?: Record<string, unknown>;
|
||||
켜쌓기율?: number | null;
|
||||
고름?: Record<string, unknown>;
|
||||
자재표시?: Record<string, unknown>;
|
||||
패치?: LinePatch;
|
||||
}
|
||||
|
||||
const keyed = (value: Record<string, unknown> | undefined, ids: string[]) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(value ?? {}).flatMap(([n, v]) =>
|
||||
/^\d+$/.test(n) && Number(n) < ids.length ? [[ids[Number(n)], v]] : [],
|
||||
),
|
||||
);
|
||||
|
||||
/** 고름 · 자재표시가 있는 로직 줄 = 바탕 줄 정체가 먼저 있어야 함 */
|
||||
export const needsIds = (row: PatchRow): boolean =>
|
||||
!row.참조 &&
|
||||
(Object.keys(row.고름 ?? {}).length > 0 || Object.keys(row.자재표시 ?? {}).length > 0);
|
||||
|
||||
/** 줄 → 이름 없는 SK 문서 — `patch` null = 그 일위대가 고침 없는 원래 값 */
|
||||
export function lineDoc(
|
||||
row: PatchRow,
|
||||
ids: string[] | null,
|
||||
patch: LinePatch | null,
|
||||
): Record<string, unknown> {
|
||||
const head = {
|
||||
키: "SK-000000",
|
||||
이름: "줄",
|
||||
단위: "식",
|
||||
덮개: patch?.덮개 ?? {},
|
||||
뺀줄: patch?.뺀줄 ?? [],
|
||||
더한줄: patch?.더한줄 ?? [],
|
||||
};
|
||||
if (row.참조) return { ...head, 바탕: { 참조: row.참조 } };
|
||||
return {
|
||||
...head,
|
||||
...(row.켜쌓기율 != null ? { 켜쌓기율: row.켜쌓기율 } : {}),
|
||||
바탕: { 로직: row.로직 ?? "" },
|
||||
입력값: { ...(row.입력값 ?? {}) },
|
||||
고름: ids ? keyed(row.고름, ids) : {},
|
||||
자재표시: ids ? keyed(row.자재표시, ids) : {},
|
||||
};
|
||||
}
|
||||
|
||||
/** 단가를 고칠 수 있는 비목 — 금액이 한 비목에만 있는 줄만(덧줄 % 줄 = 율만 · 없음 = null) */
|
||||
export function priceKey(line: SkLine): CoverKey | null {
|
||||
if (line.율 != null || line.cid.startsWith("pct:")) return null;
|
||||
const parts = PARTS.filter((p) => (line.비목?.[p] ?? 0) !== 0);
|
||||
return parts.length === 1 ? PART_KEY[parts[0]] : null;
|
||||
}
|
||||
|
||||
/** 품 칸 값 — 덧줄 = 율 · 그 밖 = 수량 */
|
||||
export const qtyOf = (line: SkLine): number => line.율 ?? line.수량;
|
||||
|
||||
/** 빈 칸 · 원값 뺀 패치 — 다 비면 null(줄 칸 안 둠) */
|
||||
export function cleanPatch(patch: LinePatch | null | undefined): LinePatch | null {
|
||||
const covers = Object.fromEntries(
|
||||
Object.entries(patch?.덮개 ?? {}).flatMap(([cid, c]) => {
|
||||
const keep = Object.fromEntries(
|
||||
Object.entries(c ?? {}).filter(([k, v]) => k !== "원값" && typeof v === "number"),
|
||||
);
|
||||
return Object.keys(keep).length ? [[cid, keep]] : [];
|
||||
}),
|
||||
);
|
||||
const out: LinePatch = {
|
||||
...(Object.keys(covers).length ? { 덮개: covers } : {}),
|
||||
...(patch?.뺀줄?.length ? { 뺀줄: [...patch.뺀줄] } : {}),
|
||||
...(patch?.더한줄?.length ? { 더한줄: [...patch.더한줄] } : {}),
|
||||
};
|
||||
return Object.keys(out).length ? out : null;
|
||||
}
|
||||
|
||||
/** 칸 하나 고침 — `value` null = 그 칸 덮개 풂(원래 값) · 같은 값도 덮개로 둠(사용자가 적은 값) */
|
||||
export function setCover(
|
||||
patch: LinePatch | null,
|
||||
cid: string,
|
||||
key: CoverKey,
|
||||
value: number | null,
|
||||
): LinePatch | null {
|
||||
const covers = { ...(patch?.덮개 ?? {}) };
|
||||
const one: Cover = { ...(covers[cid] ?? {}) };
|
||||
if (value === null) delete one[key];
|
||||
else one[key] = value;
|
||||
if (key === "qty") delete one.배율; // 품은 qty · 배율 중 하나(서버 400)
|
||||
covers[cid] = one;
|
||||
return cleanPatch({ ...patch, 덮개: covers });
|
||||
}
|
||||
|
||||
const fmt = (v: number | null | undefined): string =>
|
||||
v == null ? "-" : Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
|
||||
|
||||
/** 계산서 묶음 — 비목마다 그 비목 금액이 있는 줄 · 식 = 「단가 * 품 = 금액」(한 비목 줄) · 소계 = 서버 기준 */
|
||||
export function sheetGroups(calc: SkCalc): {
|
||||
비목: string;
|
||||
소계: number | null;
|
||||
줄: { 이름: string; 글: string; 금액: number | null }[];
|
||||
}[] {
|
||||
return PARTS.flatMap((p) => {
|
||||
const lines = calc.줄.filter((x) => (x.비목?.[p] ?? 0) !== 0);
|
||||
if (!lines.length) return [];
|
||||
return [
|
||||
{
|
||||
비목: p,
|
||||
소계: calc.기준?.[p] ?? null,
|
||||
줄: lines.map((x) => {
|
||||
const amount = x.비목?.[p] ?? null;
|
||||
const single = priceKey(x) !== null && x.단가 != null;
|
||||
const pct = x.율 != null ? " %" : ""; // 덧줄 = 율 %
|
||||
const text = single ? `${fmt(x.단가)} * ${fmt(qtyOf(x))}` : `${fmt(qtyOf(x))}${pct}`;
|
||||
return { 이름: x.이름, 글: `${text} = ${fmt(amount)}`, 금액: amount };
|
||||
}),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀
|
||||
"""일위대가 줄 [단가산출 보기] 모달(PLAN 717-3) — 임시 storage · 순수 함수는 Node 로.
|
||||
|
||||
화면 문서(`M02_Sangeun_Cover_Model.lineDoc`) = 서버 `M02_Labor_Patch.as_doc` 꼴(같은 고름 · 자재표시 cid) ·
|
||||
모달 셈 길(`/sangeun/calc` = `calc_doc`) 값 = 일위대가 셈의 그 줄 값 · 고친 칸 = 덮개 칸 이름 ·
|
||||
덮개 고침 순수 함수 · 계산서 묶음 · `normalizeLabor` 가 줄 `패치` 를 지킴 · 화면 이음.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
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_Patch as patch
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete import M02_Sangeun_Calc as calc
|
||||
from M02_MasterTemplete import M02_Sangeun_Doc as sd
|
||||
from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
M02 = ROOT / "M02_MasterTemplete"
|
||||
PCT = "GC001109" # 원/㎡ · 인력 둘 + 「노무비 * 3 / 100」
|
||||
TWO_MATS = "GC000565" # 원/m · 재료 줄 둘
|
||||
COVER = {"덮개": {"labor:LB000036#0": {"qty": 0.5}}}
|
||||
|
||||
MODEL = r"""
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const root = process.argv[1];
|
||||
const ts = require(path.join(root, "config", "node_modules", "typescript"));
|
||||
const f = path.join(root, "M02_MasterTemplete", "M02_Sangeun_Cover_Model.ts");
|
||||
const js = ts.transpileModule(fs.readFileSync(f, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, () => ({}));
|
||||
const m = box.exports;
|
||||
const job = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const out = {};
|
||||
for (const [k, [fn, ...args]] of Object.entries(job)) out[k] = m[fn](...args);
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def _node(job: dict) -> dict:
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("node 없음")
|
||||
got = subprocess.run(
|
||||
[node, "-e", MODEL, str(ROOT)],
|
||||
input=json.dumps(job, ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert got.returncode == 0, got.stderr
|
||||
return json.loads(got.stdout)
|
||||
|
||||
|
||||
def _whole():
|
||||
return m01.loaded()[1]
|
||||
|
||||
|
||||
def test_화면_문서_서버_as_doc_꼴():
|
||||
"""고름 · 자재표시(호표 차례) → cid · 패치 · 참조 줄 = 서버 `as_doc` 와 같은 몸."""
|
||||
import master_pick as mp
|
||||
|
||||
whole = _whole()
|
||||
row = whole.logic(TWO_MATS)
|
||||
item = row["호표"][2]
|
||||
want = mp.search(whole, item["요소"], {}, item.get("단위"))[1]
|
||||
drop = {"뺀줄": ["material:" + row["호표"][1]["이름"] + "#0"]}
|
||||
one = {
|
||||
"로직": TWO_MATS,
|
||||
"입력값": {},
|
||||
"고름": {"2": want},
|
||||
"자재표시": {"2": {"별도계상": "직접계산"}},
|
||||
}
|
||||
ref = {"참조": {"층": "personal", "주인": None, "키": "SK-000001"}, "입력값": {}}
|
||||
ids = sd.line_ids(row)
|
||||
got = _node(
|
||||
{
|
||||
"logic": ["lineDoc", one, ids, drop],
|
||||
"ref": ["lineDoc", ref, None, COVER],
|
||||
"bare": ["lineDoc", one, None, None],
|
||||
"needs": ["needsIds", one],
|
||||
"needsRef": ["needsIds", ref],
|
||||
}
|
||||
)
|
||||
assert got["logic"] == patch.as_doc({**one, "패치": drop}, whole)
|
||||
assert got["ref"] == patch.as_doc({**ref, "패치": COVER}, whole)
|
||||
assert got["bare"]["고름"] == {} and got["bare"]["덮개"] == {}
|
||||
assert got["needs"] is True and got["needsRef"] is False
|
||||
|
||||
|
||||
def test_모달_셈_길_값_일위대가_줄_값(world):
|
||||
"""모달 셈(`calc_doc` · 저장 안 함) 기준 = 일위대가 셈 그 줄 기준 · 고친 줄 덮개 칸 = qty · 정체 = 바탕 줄."""
|
||||
whole = _whole()
|
||||
line = {"로직": PCT, "입력값": {}, "수량": {"손": 1}}
|
||||
doc = _node({"d": ["lineDoc", line, None, COVER]})["d"]
|
||||
bare = _node({"d": ["lineDoc", line, None, None]})["d"]
|
||||
with store.at(world["personal"]):
|
||||
got = calc.calc_doc(doc)
|
||||
was = calc.calc_doc(bare)
|
||||
mine = labor.calc(labor.clean({"담은로직": [{**line, "패치": COVER}]}), {})["줄"][0]
|
||||
plain = labor.calc(labor.clean({"담은로직": [line]}), {})["줄"][0]
|
||||
assert got["기준"] == pytest.approx(mine["기준"])
|
||||
assert was["기준"] == pytest.approx(plain["기준"])
|
||||
assert got["기준"]["계"] != was["기준"]["계"] # 품 0.5 로 바뀜
|
||||
assert was["정체"] == sd.line_ids(whole.logic(PCT))
|
||||
cover = [x for x in got["줄"] if x["cid"] == "labor:LB000036#0"][0]
|
||||
assert cover["덮개"] == ["qty"] and cover["수량"] == pytest.approx(0.5)
|
||||
|
||||
|
||||
def test_덮개_순수_함수_계산서_묶음():
|
||||
"""칸 고침 · 풂 · 원값 뺌 · 단가 칸 비목 · 묶음 = 비목마다 서버 값 그대로."""
|
||||
lines = [
|
||||
{"cid": "labor:LB1#0", "이름": "보통인부", "수량": 0.2, "단가": 100, "금액": 20,
|
||||
"비목": {"노무비": 20, "재료비": 0, "경비": 0}},
|
||||
{"cid": "logic:GC1#0", "이름": "하위", "수량": 1, "금액": 30,
|
||||
"비목": {"노무비": 10, "재료비": 20, "경비": 0}},
|
||||
{"cid": "pct:공구#0", "이름": "공구손료", "수량": 0.6, "율": 3, "금액": 0.6,
|
||||
"비목": {"노무비": 0, "재료비": 0, "경비": 0.6}},
|
||||
] # fmt: skip
|
||||
calc_ = {"기준": {"노무비": 30, "재료비": 20, "경비": 0.6, "계": 50.6}, "줄": lines}
|
||||
one = {"덮개": {"labor:LB1#0": {"qty": 0.5, "원값": {"qty": 0.2}}}}
|
||||
got = _node(
|
||||
{
|
||||
"set": ["setCover", None, "labor:LB1#0", "labor", 120],
|
||||
"qty": ["setCover", {"덮개": {"x:a#0": {"배율": 2}}}, "x:a#0", "qty", 1],
|
||||
"off": ["setCover", {"덮개": {"labor:LB1#0": {"qty": 1}}}, "labor:LB1#0", "qty", None],
|
||||
"clean": ["cleanPatch", one],
|
||||
"empty": ["cleanPatch", {"덮개": {}, "뺀줄": [], "더한줄": []}],
|
||||
"keep": ["cleanPatch", {"뺀줄": ["a:b#0"]}],
|
||||
"p0": ["priceKey", lines[0]],
|
||||
"p1": ["priceKey", lines[1]],
|
||||
"p2": ["priceKey", lines[2]],
|
||||
"q2": ["qtyOf", lines[2]],
|
||||
"groups": ["sheetGroups", calc_],
|
||||
}
|
||||
)
|
||||
assert got["set"] == {"덮개": {"labor:LB1#0": {"labor": 120}}}
|
||||
assert got["qty"] == {"덮개": {"x:a#0": {"qty": 1}}} # 품은 qty · 배율 중 하나
|
||||
assert got["off"] is None and got["empty"] is None
|
||||
assert got["clean"] == {"덮개": {"labor:LB1#0": {"qty": 0.5}}} # 원값 = 서버 몫
|
||||
assert got["keep"] == {"뺀줄": ["a:b#0"]}
|
||||
assert (got["p0"], got["p1"], got["p2"], got["q2"]) == ("labor", None, None, 3)
|
||||
groups = got["groups"]
|
||||
assert [g["비목"] for g in groups] == ["노무비", "재료비", "경비"]
|
||||
assert [g["소계"] for g in groups] == [30, 20, 0.6]
|
||||
assert groups[0]["줄"][0] == {"이름": "보통인부", "글": "100 * 0.2 = 20", "금액": 20}
|
||||
assert [x["이름"] for x in groups[0]["줄"]] == ["보통인부", "하위"]
|
||||
assert groups[2]["줄"][0]["글"] == "3 % = 0.6"
|
||||
|
||||
|
||||
def test_normalizeLabor_줄_패치_지킴():
|
||||
node = shutil.which("node")
|
||||
if not node:
|
||||
pytest.skip("node 없음")
|
||||
doc = {"담은로직": [{"로직": PCT, "패치": COVER}, {"로직": TWO_MATS}]}
|
||||
got = subprocess.run(
|
||||
[node, str(ROOT / "resources/tester/helper_m02_labor_normalize.cjs")],
|
||||
input=json.dumps({"doc": doc, "column": "c"}, ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
assert got.returncode == 0, got.stderr
|
||||
rows = json.loads(got.stdout)["담은로직"]
|
||||
assert rows[0]["패치"] == COVER and "패치" not in rows[1]
|
||||
|
||||
|
||||
def test_화면_이음():
|
||||
"""줄 단추 = 로직 줄 · SK 참조 줄만 · 셈 주입(프로젝트 복사본)엔 없음 · 적용 안 한 고침 물음 · 700 줄."""
|
||||
rows = (M02 / "M02_MasterTemplete_Labor_Rows.ts").read_text("utf-8")
|
||||
assert 'sangeun && !fx && (!row.참조 || row.참조.키.startsWith("SK-"))' in rows
|
||||
detail = (M02 / "M02_MasterTemplete_Labor_Detail.ts").read_text("utf-8")
|
||||
assert "const sk = opt.calc ? {} : { sangeun: openSk };" in detail
|
||||
assert 'edit && opt.layer === "personal" && !library.owner' in detail
|
||||
modal = (M02 / "M02_MasterTemplete_Labor_SangeunModal.ts").read_text("utf-8")
|
||||
assert "same(patch, applied) || askConfirm(" in modal
|
||||
assert '{ layer: "personal", owner: null }' in modal # 새 SK = 내 개인 층
|
||||
assert "if (mine !== seq) return;" in modal # 최신 응답만
|
||||
cover = (M02 / "M02_MasterTemplete_Sangeun_Cover.ts").read_text("utf-8")
|
||||
assert "/sangeun/calc" in cover and "/sangeun/from-line" in cover
|
||||
assert 'tr.classList.add("is-changed")' in cover # 표 부품 「바뀜」 상태
|
||||
for name in (
|
||||
"M02_MasterTemplete_Labor_Rows.ts",
|
||||
"M02_MasterTemplete_Labor_Detail.ts",
|
||||
"M02_MasterTemplete_Labor_SangeunModal.ts",
|
||||
"M02_MasterTemplete_Sangeun_Cover.ts",
|
||||
"M02_Sangeun_Cover_Model.ts",
|
||||
):
|
||||
assert len((M02 / name).read_text("utf-8").splitlines()) <= 700, name
|
||||
Reference in New Issue
Block a user