feat(M02): 359-3 실무자료 확인 창 표 = 일위대가 상세 꼴 · 금액 미리 셈 · 드롭다운 추가 · 로직없음 · 사용안함

- 줄 표 = 로직 표(차례 · 구분 · 이름 · 별칭 · 수량산출 · 결과 단위 · 노무비 · 재료비 · 경비 · 합계) + 자재 표(세 칸 · 가격 다섯) + 품 비교 · 단추
- 금액 칸 = 위 엑셀 · 아래 우리(줄마다 서버 calc 미리 셈 · 저장 안 함 · 최신 응답만)
- 미리 셈 문서 꼴 = logic/M02_MasterTemplete_Hopyo_Draft.ts(정본 = M02_Hopyo_Build · 수량 = 엑셀 × 배 · 별도계상)
- 드롭다운 글 추가 · 로직없음 · 사용안함(저장 값 그대로) · [엑셀에서 보기] → [엑셀 보기] · [사전조합 생성]
- Check.ts 쪼갬(표 그리기 = Hopyo_CheckRows.ts) · 쓰지 않는 [← 호표 목록] 지움
- 시험 test_m02_hopyo_draft_calc(북부청 실물 줄 → calc 200 · 반영합 · 저장 안 함)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
This commit is contained in:
2026-10-16 20:52:22 +09:00
co-authored by Claude Opus 5.5
parent 9920981e42
commit dd49b554b4
7 changed files with 667 additions and 223 deletions
+28 -13
View File
@@ -55,34 +55,49 @@
overflow-wrap: break-word;
}
/* 확인 창 줄 표(338 · 339) — 체크 · 갈래 · 수량 · 단위 · 상태 = 줄바꿈 없음(폭 = 글 폭) · 단추 = 세로로 쌓아 좁게 ·
품명 · 규격 · 우리 로직 · 옵션 · 품 비교가 남는 폭 · 칸 여백 좁힘(공용 표보다 앞서게 두 갈래 이름) */
/* 확인 창 줄 표(338 · 339 · 359-3) — 일위대가 상세 꼴(m02-labrow) · 체크 칸 · 수량 · 단위 = 줄바꿈 없음 ·
단추 = 세로로 쌓아 좁게 · 이름 · 별칭 · 품 비교가 남는 폭 · 칸 여백 좁힘(공용 표보다 앞서게 두 갈래 이름) */
.m02-hopyo__table.m02-hopyo__lines :is(th, td) {
padding: var(--spacing-8);
}
.m02-hopyo__table.m02-hopyo__lines
:is(th, td):is(:nth-child(1), :nth-child(2), :nth-child(5), :nth-child(6), :nth-child(7)) {
.m02-hopyo__table.m02-hopyo__lines td:first-child,
.m02-hopyo__lines :is(.m02-labrow__qty, .m02-labrow__unit) {
white-space: nowrap;
}
/* 품명 · 규격 — 낱말 두셋은 한 줄에 */
.m02-hopyo__table.m02-hopyo__lines td:nth-child(3),
/* 이름 칸 대상 줄 — 키 · 이름 · 배 칸 · 몫 한 줄(상세 이름 칸의 한 줄 말줄임을 풂) */
.m02-hopyo__lines .m02-labrow__name > .m02-hopyo__part {
display: flex;
max-width: none;
overflow: visible;
}
.m02-hopyo__lines .m02-labrow__name > .m02-hopyo__state,
.m02-hopyo__lines .m02-labrow__kind > .m02-hopyo__state {
display: inline-block;
margin-left: var(--spacing-4);
}
/* 품 비교 — 항목마다 한 줄(글의 줄바꿈 그대로) */
.m02-hopyo__table.m02-hopyo__lines td.m02-hopyo__compare {
min-width: 9em;
white-space: pre-line;
}
.m02-hopyo__subhead {
margin: var(--spacing-8) 0 0;
font-size: var(--text-body-sm);
}
.m02-hopyo-ledger__table td:nth-child(1) {
min-width: 7em;
}
.m02-hopyo__table.m02-hopyo__lines td:nth-child(4),
.m02-hopyo-ledger__table td:nth-child(2) {
min-width: 6em;
}
/* 옵션 · 품 비교 — 항목마다 한 줄(글의 줄바꿈 그대로) */
.m02-hopyo__table.m02-hopyo__lines td:is(:nth-child(9), :nth-child(10)) {
min-width: 9em;
white-space: pre-line;
}
/* 단추 칸 — 단추 · 넣음/뺌을 세로로 쌓아 칸 폭 = 가장 긴 단추 */
.m02-hopyo__table.m02-hopyo__lines td.m02-hopyo__acts {
width: 1%;
@@ -1,15 +1,15 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_Check.ts
* 일위대가 실무자료 확인 창(PLAN 338-3 · 설계 4-1) — 호표 하나의 줄마다 서버 맞춤(상태 · 체크 제안 ·
* 우리 로직 · 옵션 · 품 비교)을 보이고, 사용자가 체크한 줄만 [확인하고 사전조합 만들기]로 서버에 보냄.
* · 자리 = 실무자료 본 칸(호표 누름) · [로직 추가] 「실무 호표」 모달 · 엑셀 보기 창 펼침 [확인 창 열기]
* 일위대가 실무자료 확인 창(PLAN 338-3 · 설계 4-1 · 359-3) — 호표 하나의 줄마다 서버 맞춤(상태 · 체크 제안 ·
* 우리 로직 · 옵션 · 품 비교)을 보이고, 사용자가 체크한 줄만 [사전조합 생성]으로 서버에 보냄.
* · 자리 = 엑셀 보기 창 호표 [수정] · [로직 추가] 「실무 호표」 모달
* · 머리 = 호표 · 「제안 n / 줄 m」 · 숫자 판정 셈 중이면 3 초마다 다시 읽음(끝나면 멈춤 · 만진 줄은 그대로)
* · 줄 = [✓] · 갈래 · 품명 · 규격 · 수량 · 단위 · 상태(맞음 · 별칭 · 다른 점 · 후보) · 우리 로직(여럿 · 몫 · 배) ·
* 옵션(칸=값 · 출처 · 못 가름) · 품 비교(엑셀 → 우리 ✓ / ≠) · [고르기] [원문 보기] [엑셀에서 보기] · 뺌 까닭
* · 줄 표 = 구조물 조합 일위대가 상세 꼴(로직 표 · 자재 표 · 품 비교 · 단추 — `M02_MasterTemplete_Hopyo_CheckRows`)
* · 금액 = 줄마다 서버 calc 미리 셈(`logic/M02_MasterTemplete_Hopyo_Draft` 문서 · 저장 안 함 · 줄마다 최신 응답만)
* · 대상 · 옵션을 바꾸면 서버 `guess`(저장 안 함 · 줄마다 최신 응답만)가 우리 품을 다시 셈
* · 캐시 = sessionStorage `m02-hopyo-check:{층}:{주인}:{키}:{번}`(조작 즉시 · 만들기 성공하면 지움) · 자동 저장 없음
* · 화면 잠금은 [확인하고 사전조합 만들기] 때만 · 쓰기 단추 = 만들 층 쓰기 권한만.
* 계산은 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build`) — 화면은 보이기 · 체크 · 고름만.
* · 화면 잠금은 [사전조합 생성] 때만 · 쓰기 단추 = 만들 층 쓰기 권한만.
* 계산은 서버(`M02_Hopyo_Match` · `M02_Hopyo_Build` · `/labor/{열}/calc`) — 화면은 보이기 · 체크 · 고름만.
* ========================================================================== */
import {
@@ -36,22 +36,23 @@ import {
type HopyoPart,
type HopyoScope,
} from "./M02_MasterTemplete_Hopyo_Api";
import { builtWord, openHopyoChoose, targetText } from "./M02_MasterTemplete_Hopyo_Choose";
import {
builtWord,
inputsText,
openHopyoChoose,
sourceText,
targetText,
} from "./M02_MasterTemplete_Hopyo_Choose";
checkTables,
type LinePrice,
type RowsContext,
} from "./M02_MasterTemplete_Hopyo_CheckRows";
import type { HopyoSheetOptions } from "./M02_MasterTemplete_Hopyo_Sheet";
import { fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api";
import { calcLabor, fetchLogicInputs } from "./M02_MasterTemplete_Labor_Api";
import { openLaborInput } from "./M02_MasterTemplete_Labor_Input";
import { draftDoc } from "./logic/M02_MasterTemplete_Hopyo_Draft";
import "./M02_MasterTemplete_Hopyo.css";
export const POLL_MS = 3000;
/** 사전조합을 만들었음(document 알림) — 좌측 「일위대가 사전조합」 목록 다시 */
export const HOPYO_BUILT = "m02-hopyo-built";
const OUT = ["우리에 없음", "안 씀"] as const;
/** 미리 셈 문서 열 이름(서버 calc 는 보낸 문서로 셈 · 저장 안 함) */
const DRAFT_COLUMN = "hopyo-draft";
/** 확인 창 캐시 — 사용자가 만진 것만(서버 제안 위에 덮어 보임) */
export interface CheckDraft {
@@ -204,13 +205,11 @@ export interface CheckOptions {
target?: HopyoScope;
/** 만들 층 쓰기 권한 — 없으면 체크 · 만들기 없음(보기만) */
canWrite: boolean;
/** [← 호표 목록](본 칸) */
onBack?: () => void;
/** 만들었음 — [로직 추가] 참조 줄 · 모달 닫기 */
onBuilt?: (got: HopyoBuilt, hopyo: HopyoOne["호표"]) => void;
}
/** 확인 창 몸 — 본 칸 · 모달이 같이 씀 */
/** 확인 창 몸 — 모달(`openHopyoCheck`)에 담김 */
export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
const root = el("div", {
className: "m02-hopyo m02-hopyo-check",
@@ -224,6 +223,9 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
let seq = 0;
let timer = 0;
const ask = newest(); // 줄마다 최신 미리 셈만
const askPrice = newest(); // 줄마다 최신 금액 미리 셈만
const priced = new Map<number, { sig: string; got: LinePrice }>();
let pending = false;
const keep = (): void => saveDraft(k, d);
async function load(quiet = false): Promise<void> {
@@ -313,161 +315,76 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
});
}
function partCell(line: HopyoLine): HTMLElement {
const parts = partsOf(line, d);
return el("td", {
className: "m02-hopyo__parts",
children: parts.map((p, at) => {
const times = el("input", {
className: "ui-input ui-input--sm m02-hopyo__times",
attrs: { type: "number", step: "any", min: "0", value: p.배 == null ? "" : String(p.배) },
}) as HTMLInputElement;
times.title = "배 — 우리 수량 = 엑셀 수량 × 배";
times.disabled = !opt.canWrite;
times.addEventListener("change", () => {
const v = times.value === "" ? null : Number(times.value);
d.대상[line.차례] = parts.map((x, i) => (i === at ? { ...x, 배: v } : x));
keep();
draw();
});
const share = p.몫 != null ? ` · 몫 ${p.몫}` : "";
return el("div", {
className: "m02-hopyo__part",
children: [
el("span", {
text: `${targetText(p)} ${p.로직 ?? p.자재 ?? ""}${share}`.trim(),
attrs: { title: p.어디 ?? "" },
}),
times,
...(p.로직
? [
createButton({
label: "옵션",
variant: "ghost",
size: "sm",
onClick: () => editOptions(line, at),
}),
]
: []),
],
});
}),
/** 줄마다 금액 미리 셈(359-3) — 서버 calc(저장 안 함 · 줄마다 최신 응답만 · 문서가 같으면 다시 안 셈) */
function price(line: HopyoLine): LinePrice | null {
const doc = draftDoc(line, partsOf(line, d));
if (!doc) return null;
const sig = JSON.stringify(doc);
const have = priced.get(line.차례);
if (have?.sig === sig) return have.got;
const slot = { sig, got: {} as LinePrice };
priced.set(line.차례, slot);
const mine = askPrice(line.차례);
calcLabor(target.layer, DRAFT_COLUMN, doc, {}, target.owner).then(
(calc) => mine() && ((slot.got = { calc }), soon()),
(error: unknown) => mine() && ((slot.got = { fail: why(error) }), soon()),
);
return slot.got;
}
/** 응답 여럿 = 한 번 그림 */
function soon(): void {
if (pending) return;
pending = true;
requestAnimationFrame(() => {
pending = false;
if (root.isConnected) draw();
});
}
function lineRow(line: HopyoLine): HTMLElement {
const m = line.맞춤;
const parts = partsOf(line, d);
const box = el("input", { attrs: { type: "checkbox", "data-check": String(line.차례) } });
const input = box as HTMLInputElement;
input.checked = isChecked(line, d) && checkable(line, d);
input.disabled = !opt.canWrite || !checkable(line, d);
input.addEventListener("change", () => {
d.체크[line.차례] = input.checked;
const ctx = (): RowsContext => ({
canWrite: opt.canWrite,
d,
price,
check: (line, on) => {
d.체크[line.차례] = on;
keep();
draw();
});
const out = el("select", {
className: "ui-input ui-input--sm",
attrs: { title: "뺌 — 사전조합에 안 넣고 장부에 남김" },
children: [
el("option", { text: "넣음", attrs: { value: "" } }),
...OUT.map((x) => el("option", { text: `뺌 — ${x}`, attrs: { value: x } })),
],
}) as HTMLSelectElement;
out.value = d.뺌[line.차례] ?? "";
out.disabled = !opt.canWrite || m.상태 === "%";
out.addEventListener("change", () => {
if (out.value) d.뺌[line.차례] = out.value;
},
out: (line, value) => {
if (value) d.뺌[line.차례] = value;
else delete d.뺌[line.차례];
d.체크[line.차례] = !!out.value || d.체크[line.차례];
d.체크[line.차례] = !!value || d.체크[line.차례];
keep();
draw();
});
const sheet = (행: [number, number]): void =>
openSheet({
scope: opt.scope,
key: opt.key,
파일: got?.파일 ?? "",
canWrite: opt.canWrite,
target,
단산행: 행,
});
const acts = [
...(opt.canWrite && m.상태 !== "%"
? [
createButton({
label: "고르기",
variant: "ghost",
size: "sm",
onClick: () =>
openHopyoChoose({
line,
openSheet: sheet,
done: (ts) =>
setParts(
line,
ts.map((t) => ({ ...t, 입력값: t.입력값 ?? {}, 몫: t.몫 ?? null })),
),
}),
}),
]
: []),
createButton({
label: "원문 보기",
variant: "ghost",
size: "sm",
onClick: () => void showSource(line),
},
times: (line, at, value) => {
d.대상[line.차례] = partsOf(line, d).map((x, i) => (i === at ? { ...x, 배: value } : x));
keep();
draw();
},
choose: (line) =>
openHopyoChoose({
line,
openSheet: sheetAt,
done: (ts) =>
setParts(
line,
ts.map((t) => ({ ...t, 입력값: t.입력값 ?? {}, 몫: t.몫 ?? null })),
),
}),
...(line.근거
? [
createButton({
label: "엑셀에서 보기",
variant: "ghost",
size: "sm",
onClick: () => sheet(line.근거!.행),
}),
]
: []),
out,
];
const tags = [m.글, ...(m.꼬리표 ?? []), sourceText(line.근거)].filter(Boolean).join("\n");
const opts = parts
.filter((p) => p.로직)
.flatMap((p) => inputsText(p.입력값 ?? {}, p.못읽음 ?? [], p.출처).split(/, (?=[^,=]+=)| · /))
.filter(Boolean);
if (m.갈리는칸?.length) opts.push(`⚠ 못 가름: ${m.갈리는칸.join(", ")}`);
const alias = m.별칭?.규격다름 ? " · 규격 다름" : m.별칭?.근거다름 ? " · 근거 다름" : "";
return el("tr", {
className: `m02-hopyo__line is-${input.checked ? "in" : "out"}`,
attrs: { "data-line": String(line.차례), "data-state": m.상태 },
children: [
el("td", { children: [box] }),
td(line.갈래),
td(line.이름),
td(line.규격),
td(line.수량 === null ? "" : String(line.수량), "m02-hopyo__num"),
td(line.단위),
el("td", {
attrs: { title: tags },
children: [
el("span", {
className: `m02-hopyo__state is-${m.상태.replace(" ", "")}`,
text: `${m.상태}${alias}`,
}),
],
}),
partCell(line),
td(opts.join("\n")), // 옵션 · 품 비교 = 항목마다 한 줄
td(
compareText(d.품[line.차례] ?? m.품)
.split(" · ")
.join("\n"),
),
el("td", { className: "m02-hopyo__acts", children: acts }),
],
source: (line) => void showSource(line),
sheet: sheetAt,
options: editOptions,
});
const sheetAt = (행: [number, number]): void =>
openSheet({
scope: opt.scope,
key: opt.key,
파일: got?.파일 ?? "",
canWrite: opt.canWrite,
target,
단산행: 행,
});
}
async function make(h: HopyoOne["호표"]): Promise<void> {
const whole = !!h.통째 && (d.통째 ?? h.통째.체크);
@@ -483,8 +400,8 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
.map(([a, b]) => `${a} ${b}`)
.join(" · ")})`
: "") +
" — 확인하고 사전조합을 만듦";
if (!(await showConfirmDialog(ask, "확인하고 만들기"))) return;
" — 사전조합 생성";
if (!(await showConfirmDialog(ask, "생성"))) return;
const from = { 층: opt.scope.layer, 주인: opt.scope.owner, 키: opt.key, 번: opt.no };
showLoadingOverlay();
try {
@@ -575,19 +492,15 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
draw();
});
const whole = !!h.통째 && (d.통째 ?? h.통째.체크);
const go = createButton({ label: "확인하고 사전조합 만들기", variant: "filled", size: "sm" });
const go = createButton({ label: "사전조합 생성", variant: "filled", size: "sm" });
go.disabled = !whole && checked < 1;
go.setAttribute("data-hopyo-make", "");
go.addEventListener("click", () => void make(h));
tools.push(all, go);
}
const back = opt.onBack
? [createButton({ label: "← 호표 목록", variant: "ghost", size: "sm", onClick: opt.onBack })]
: [];
const head = el("div", {
className: "m02-hopyo__bar",
children: [
...back,
el("strong", { text: `제${h.번}호표 ${h.이름}` }),
note(h.규격),
note(h.단위),
@@ -598,7 +511,7 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
: []),
...(h.만든 ? [note(`만든 사전조합 ${h.만든.키}${h.만든.확인 ? " · 확인함" : ""}`)] : []),
createButton({
label: "엑셀에서 보기",
label: "엑셀 보기",
size: "sm",
onClick: () =>
openSheet({
@@ -638,38 +551,10 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
];
})()
: [];
const heads = [
"",
"갈래",
"품명",
"규격",
"수량",
"단위",
"상태",
"우리 로직 · 배",
"옵션",
"품 비교",
"",
];
root.replaceChildren(
head,
...whole,
h.줄.length
? el("div", {
className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head",
children: [
el("table", {
className: "ui-general-block__table m02-hopyo__table m02-hopyo__lines",
children: [
el("thead", {
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
}),
el("tbody", { children: h.줄.map(lineRow) }),
],
}),
],
})
: note("구성 줄 없음"),
...(h.줄.length ? checkTables(h.줄, ctx()) : [note("구성 줄 없음")]),
...(built ? [builtBlock(built)] : []),
);
}
@@ -678,7 +563,7 @@ export function buildHopyoCheck(opt: CheckOptions): HTMLElement {
return root;
}
/** 확인 창 모달 — [로직 추가] 「실무 호표」 · 엑셀 보기 창 [확인 창 열기] · 만들면 닫음 */
/** 확인 창 모달 — [로직 추가] 「실무 호표」 · 엑셀 보기 창 호표 [수정] · 만들면 닫음 */
export function openHopyoCheck(opt: CheckOptions & { title?: string }): void {
openModal({
title: opt.title ?? `확인 — 제${opt.no}호표`,
@@ -0,0 +1,343 @@
/* =============================================================================
* M02_MasterTemplete_Hopyo_CheckRows.ts
* 확인 창 줄 표(PLAN 359-3) — 구조물 조합 일위대가 상세 꼴(`Labor_Rows` logicTable · matTable)에 품 비교 · 단추만 더함.
* · 로직 표 = [✓] · 차례 · 구분(엑셀 갈래 + 상태) · 이름(우리 로직 · 배 · 옵션 글) · 별칭(엑셀 품명 = 만들 때 별칭) ·
* 수량산출(위 엑셀 / 아래 우리 = 엑셀 × 배) · 결과 단위 · 노무비 · 재료비 · 경비 · 합계 · 품 비교 · 단추
* · 자재 표 = [✓] · 차례 · 코드 · 이름 · 규격 · 수량산출 · 단위 · 세 칸(자재구분 · 별도계상 · 운반비 · 글만) ·
* 가격 다섯 · 엑셀 / 우리 금액 · 단추
* · 금액 칸 = 위 작게 엑셀 · 아래 우리(서버 calc 미리 셈 · 저장 안 함) · 셈 중 「…」
* · 단추 = [고르기] · [원문 보기] · [엑셀 보기] · [옵션](로직 대상마다) · 드롭다운 추가 · 로직없음 · 사용안함
* (화면 글만 · 저장 값 = 빈 값 · 「우리에 없음」 · 「안 씀」 그대로)
* 줄 표 = `tableOf`(대상이 모두 자재 · 대상 없는 자재 · 별도계상 줄 = 자재 표).
* ========================================================================== */
import { createButton, el } from "@ui/ui_template_elements";
import type { HopyoLine, HopyoPart } from "./M02_MasterTemplete_Hopyo_Api";
import {
checkable,
compareText,
isChecked,
partsOf,
type CheckDraft,
} from "./M02_MasterTemplete_Hopyo_Check";
import { inputsText, sourceText } from "./M02_MasterTemplete_Hopyo_Choose";
import type { LaborCalc } from "./M02_MasterTemplete_Labor_Api";
import { markCells, markHeads } from "./M02_MasterTemplete_Labor_MatMarks";
import { moneyCell, PRICE_SLOTS } from "./M02_MasterTemplete_Labor_Rows";
import { tc } from "./M02_MasterTemplete_Labor_Text";
import { tableOf } from "./logic/M02_MasterTemplete_Hopyo_Draft";
/** 드롭다운 — 저장 값(서버 뺌 까닭) → 화면 글 */
export const OUT_TEXT: [string, string][] = [
["", "추가"],
["우리에 없음", "로직없음"],
["안 씀", "사용안함"],
];
const COSTS = ["노무비", "재료비", "경비", "계"] as const;
const COST_HEAD = {
노무비: "Prev_Labor",
재료비: "Prev_Material",
경비: "Prev_Expense",
계: "Prev_Sum",
} as const;
/** 줄 하나 미리 셈 — 셈 중 = 둘 다 없음 */
export interface LinePrice {
calc?: LaborCalc;
fail?: string;
}
export interface RowsContext {
canWrite: boolean;
d: CheckDraft;
price: (line: HopyoLine) => LinePrice | null;
check: (line: HopyoLine, on: boolean) => void;
out: (line: HopyoLine, value: string) => void;
times: (line: HopyoLine, at: number, value: number | null) => void;
choose: (line: HopyoLine) => void;
source: (line: HopyoLine) => void;
sheet: (행: [number, number]) => void;
options: (line: HopyoLine, at: number) => void;
}
const num = (v: number): string =>
Number(v.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
const small = (text: string, cls = "m02-labrow__note"): HTMLElement =>
el("small", { className: cls, text, attrs: { title: text } });
/** 금액 칸 — 위 엑셀(작게) · 아래 우리 */
function pairCell(
excel: number | null | undefined,
ours: number | null | undefined,
wait: boolean,
label: string,
): HTMLElement {
const td = moneyCell(excel, ours, wait, label);
td.querySelector(".m02-labrow__base")?.setAttribute("title", "엑셀");
td.querySelector(".m02-labrow__applied")?.setAttribute("title", "우리(미리 셈)");
return td;
}
/** 수량산출 — 위 엑셀 수량 · 아래 우리(엑셀 × 배 · 대상마다) */
function qtyCell(line: HopyoLine, parts: HopyoPart[]): HTMLElement {
const ours = parts
.map((p) => (p.배 === null || line.수량 === null ? "—" : num(line.수량 * p.배)))
.join(" · ");
return el("td", {
className: "m02-labrow__qty",
attrs: { "data-label": tc("Labor_QtyCol") },
children: [
el("small", {
className: "m02-labrow__base",
text: line.수량 === null ? "—" : `${num(line.수량)} ${line.단위}`.trim(),
attrs: { title: "엑셀" },
}),
el("span", { className: "m02-labrow__applied", text: ours || "—", attrs: { title: "우리" } }),
],
});
}
const stateChip = (line: HopyoLine): HTMLElement => {
const m = line.맞춤;
const alias = m.별칭?.규격다름 ? " · 규격 다름" : m.별칭?.근거다름 ? " · 근거 다름" : "";
const tags = [m.글, ...(m.꼬리표 ?? []), sourceText(line.근거)].filter(Boolean).join("\n");
return el("span", {
className: `m02-hopyo__state is-${m.상태.replace(" ", "")}`,
text: `${m.상태}${alias}`,
attrs: { title: tags },
});
};
/** 대상마다 한 줄 — 키 · 이름 · × 배 칸 · 몫 */
function partLines(line: HopyoLine, parts: HopyoPart[], ctx: RowsContext): HTMLElement[] {
return parts.map((p, at) => {
const times = el("input", {
className: "ui-input ui-input--sm m02-hopyo__times",
attrs: { type: "number", step: "any", min: "0", value: p.배 == null ? "" : String(p.배) },
}) as HTMLInputElement;
times.title = "배 — 우리 수량 = 엑셀 수량 × 배";
times.disabled = !ctx.canWrite;
times.addEventListener("change", () =>
ctx.times(line, at, times.value === "" ? null : Number(times.value)),
);
const key = p.로직 ?? p.자재 ?? (p.호표 !== undefined ? `제${p.호표}호표` : "");
return el("div", {
className: "m02-hopyo__part",
attrs: { title: p.어디 ?? "" },
children: [
el("span", { className: "m02-labrow__key", text: key }),
el("strong", { text: p.이름 ?? "" }),
times,
...(p.몫 != null ? [small(`몫 ${p.몫}`)] : []),
],
});
});
}
/** 이름 칸 덧글 — 옵션(칸=값 · 출처) · 못 가름 · 셈 못 함 까닭 */
function nameNotes(line: HopyoLine, parts: HopyoPart[], price: LinePrice | null): HTMLElement[] {
const opts = parts
.filter((p) => p.로직)
.map((p) => inputsText(p.입력값 ?? {}, p.못읽음 ?? [], p.출처))
.filter(Boolean);
if (line.맞춤.갈리는칸?.length) opts.push(`⚠ 못 가름: ${line.맞춤.갈리는칸.join(", ")}`);
const why = [
price?.fail,
...(price?.calc?.줄 ?? []).map((l) => l.까닭),
...(price?.calc?.자재 ?? []).map((m) => m.까닭),
].filter(Boolean) as string[];
return [
...opts.map((t) => small(t, "m02-labrow__note m02-hopyo__opts")),
...why.map((t) => small(`셈 못 함: ${t}`, "m02-labrow__empty")),
...(parts.some((p) => p.호표 !== undefined) ? [small("하위 호표 = 만든 뒤 셈")] : []),
];
}
function checkCell(line: HopyoLine, ctx: RowsContext): HTMLElement {
const box = el("input", {
attrs: { type: "checkbox", "data-check": String(line.차례) },
}) as HTMLInputElement;
box.checked = isChecked(line, ctx.d) && checkable(line, ctx.d);
box.disabled = !ctx.canWrite || !checkable(line, ctx.d);
box.addEventListener("change", () => ctx.check(line, box.checked));
return el("td", { children: [box] });
}
function actsCell(line: HopyoLine, parts: HopyoPart[], ctx: RowsContext): HTMLElement {
const m = line.맞춤;
const ghost = (label: string, run: () => void): HTMLElement =>
createButton({ label, variant: "ghost", size: "sm", onClick: run });
const out = el("select", {
className: "ui-input ui-input--sm",
attrs: { title: "로직없음 · 사용안함 — 사전조합에 안 넣고 장부에 남김", "data-out": "" },
children: OUT_TEXT.map(([value, text]) => el("option", { text, attrs: { value } })),
}) as HTMLSelectElement;
out.value = ctx.d.뺌[line.차례] ?? "";
out.disabled = !ctx.canWrite || m.상태 === "%";
out.addEventListener("change", () => ctx.out(line, out.value));
const options = parts.flatMap((p, at) => {
if (!p.로직) return [];
const b = ghost("옵션", () => ctx.options(line, at));
b.title = `${p.로직} 옵션`;
return [b];
});
return el("td", {
className: "m02-hopyo__acts",
children: [
...(ctx.canWrite && m.상태 !== "%" ? [ghost("고르기", () => ctx.choose(line))] : []),
ghost("원문 보기", () => ctx.source(line)),
...(line.근거 ? [ghost("엑셀 보기", () => ctx.sheet(line.근거!.행))] : []),
...options,
out,
],
});
}
const rowOf = (line: HopyoLine, ctx: RowsContext, cells: HTMLElement[]): HTMLElement => {
const on = isChecked(line, ctx.d) && checkable(line, ctx.d);
return el("tr", {
className: `m02-labrow__row m02-hopyo__line is-${on ? "in" : "out"}`,
attrs: { "data-line": String(line.차례), "data-state": line.맞춤.상태 },
children: [checkCell(line, ctx), ...cells, actsCell(line, partsOf(line, ctx.d), ctx)],
});
};
function logicRow(line: HopyoLine, ctx: RowsContext): HTMLElement {
const parts = partsOf(line, ctx.d);
const price = ctx.price(line);
const wait = !!price && !price.calc && !price.fail;
const ours = price?.calc?.반영합;
const unit = price?.calc?.줄[0]?.분모 ?? parts[0]?.단위 ?? line.단위;
const compare = compareText(ctx.d.품[line.차례] ?? line.맞춤.품);
return rowOf(line, ctx, [
el("td", { className: "m02-labrow__no", text: String(line.차례 + 1) }),
el("td", { className: "m02-labrow__kind", children: [line.갈래, stateChip(line)] }),
el("td", {
className: "m02-labrow__name m02-hopyo__parts",
children: [
...(parts.length ? partLines(line, parts, ctx) : [small("—", "m02-labrow__muted")]),
...nameNotes(line, parts, price),
],
}),
el("td", {
className: "m02-labrow__alias",
attrs: { title: `${line.이름} ${line.규격}`.trim() },
children: [line.이름, ...(line.규격 ? [small(line.규격)] : [])],
}),
qtyCell(line, parts),
el("td", { className: "m02-labrow__unit", text: unit ?? "" }),
...COSTS.map((c) =>
pairCell(line.금액[c], price?.calc ? (ours?.[c] ?? null) : null, wait, tc(COST_HEAD[c])),
),
el("td", { className: "m02-hopyo__compare", text: compare.split(" · ").join("\n") }),
]);
}
function matRow(line: HopyoLine, ctx: RowsContext): HTMLElement {
const parts = partsOf(line, ctx.d);
const price = ctx.price(line);
const wait = !!price && !price.calc && !price.fail;
const mats = price?.calc?.자재 ?? [];
const first = mats[0];
const ours = mats.length ? mats.reduce((s, m) => s + (m.반영 ?? 0), 0) : null;
const show = (v: number | null | undefined): string =>
wait ? "…" : v === null || v === undefined ? "—" : v.toLocaleString("ko-KR");
const p = parts[0];
return rowOf(line, ctx, [
el("td", { className: "m02-labrow__no", text: String(line.차례 + 1) }),
el("td", {
className: "m02-labrow__code",
text: parts.map((x) => x.자재 ?? "").join(" · ") || "—",
}),
el("td", {
className: "m02-labrow__name m02-hopyo__parts",
children: [
stateChip(line),
...(parts.length ? partLines(line, parts, ctx) : []),
small(`엑셀 ${line.이름}`.trim()),
...nameNotes(line, parts, price),
],
}),
el("td", {
className: "m02-labrow__spec",
text: p?.규격 ?? line.규격,
attrs: { title: p?.규격 ?? line.규격 },
}),
qtyCell(line, parts),
el("td", { className: "m02-labrow__unit", text: p?.단위 ?? line.단위 }),
...markCells({ 별도계상: line.갈래 === "별도계상" ? "별도계상" : "직접계산" }),
...PRICE_SLOTS.map((slot) =>
el("td", {
className: `m02-labrow__num m02-labrow__price${first?.기준칸 === slot ? " is-base-price" : ""}`,
text: show(first?.가격?.[slot]),
attrs: { "data-label": slot, "data-price-slot": slot },
}),
),
pairCell(line.금액.계, price?.calc ? ours : null, wait, tc("Labor_AppliedSum")),
]);
}
const grid = (heads: string[], rows: HTMLElement[], kind: string): HTMLElement =>
el("div", {
className: "ui-general-block__table-wrap ui-general-block__table-wrap--sticky-head",
children: [
el("table", {
className: "ui-general-block__table m02-labrow m02-hopyo__table m02-hopyo__lines",
attrs: { "data-hopyo-table": kind },
children: [
el("thead", {
children: [el("tr", { children: heads.map((t) => el("th", { text: t })) })],
}),
el("tbody", { children: rows }),
],
}),
],
});
/** 줄 표 둘 — 로직 표 · 자재 표(줄 없는 표는 안 그림) */
export function checkTables(lines: HopyoLine[], ctx: RowsContext): HTMLElement[] {
const logic = lines.filter((l) => tableOf(l, partsOf(l, ctx.d)) === "logic");
const mat = lines.filter((l) => !logic.includes(l));
const out: HTMLElement[] = [];
if (logic.length)
out.push(
grid(
[
"",
tc("Col_Order"),
tc("Sub"),
tc("Col_Name"),
tc("Col_Alias"),
tc("Labor_QtyCol"),
tc("Col_Unit"),
...COSTS.map((c) => tc(COST_HEAD[c])),
"품 비교",
"",
],
logic.map((l) => logicRow(l, ctx)),
"logic",
),
);
if (mat.length)
out.push(
el("h4", { className: "m02-hopyo__subhead", text: "자재" }),
grid(
[
"",
tc("Col_Order"),
tc("Labor_Code"),
tc("Col_Name"),
tc("Labor_Spec"),
tc("Labor_QtyCol"),
tc("Labor_Unit"),
...markHeads(),
...PRICE_SLOTS,
`엑셀 / 우리 ${tc("Labor_AppliedSum")}`,
"",
],
mat.map((l) => matRow(l, ctx)),
"mat",
),
);
return out;
}
@@ -0,0 +1,56 @@
/* =============================================================================
* M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Draft.ts
* 확인 창 금액 미리 보기 꼴(PLAN 359-3) — 엑셀 줄 하나 + 지금 대상 → 일위대가 문서 한 벌(서버 `/labor/{열}/calc` 에
* 보냄 · 저장 안 함 · 금액은 서버가 셈) · 줄이 들어갈 표(로직 표 · 자재 표).
* 정본 = 서버 `M02_Hopyo_Build.Builder.build` 의 `put`([사전조합 생성] 때 다시 셈) — 같은 규칙:
* 우리 수량 = 엑셀 수량 × 배 · 로직 = {로직, 입력값, 수량 {손}} · 자재 = {코드, 수량 {손}, 별도계상 =
* 갈래 별도계상이면 「별도계상」 아니면 「직접계산」} · 같은 자재 = 수량 더함.
* 미리 보기만 다른 것: 호표 대상(하위 사전조합 — 만든 뒤 셈) · 배 없는 대상은 뺌 ·
* 같은 로직 둘째는 뺌(Build = 한 줄 사전조합으로 감쌈 · 한 문서에 같은 로직 둘 = 서버 400).
* 화면 · fetch · sessionStorage 없음(`CLAUDE.md` 4장).
* ========================================================================== */
import type { HopyoLine, HopyoPart } from "../M02_MasterTemplete_Hopyo_Api";
import type { LaborDoc, LaborMat, LaborRow } from "../M02_MasterTemplete_Labor_Api";
export type CheckTable = "logic" | "mat";
/** 엑셀 갈래 중 자재 표로 가는 것(대상 없을 때) */
const MAT_KINDS = ["자재", "별도계상"];
/** 줄이 들어갈 표 — 대상이 모두 자재면 자재 표 · 대상 없으면 갈래(자재 · 별도계상) · 그 밖 로직 표 */
export function tableOf(line: Pick<HopyoLine, "갈래">, parts: HopyoPart[]): CheckTable {
if (parts.length) return parts.every((p) => !!p.자재) ? "mat" : "logic";
return MAT_KINDS.includes(line.갈래) ? "mat" : "logic";
}
/** 엑셀 줄 + 대상 → 미리 셈 문서 · 셀 것이 없으면 null(호표 대상뿐 · 배 없음 · 대상 없음) */
export function draftDoc(
line: Pick<HopyoLine, "수량" | "갈래">,
parts: HopyoPart[],
): LaborDoc | null {
const rows: LaborRow[] = [];
const mats = new Map<string, LaborMat>();
const apart = line.갈래 === "별도계상" ? "별도계상" : "직접계산";
for (const p of parts) {
if (p.배 === null || p.호표 !== undefined) continue;
const qty = (line.수량 ?? 0) * p.배;
if (p.자재) {
const have = mats.get(p.자재)?.수량;
const hand = (have ? have.손 : 0) + qty;
mats.set(p.자재, {
코드: p.자재,
이름: null,
규격: null,
단위: null,
수량: { 손: hand },
별도계상: apart,
});
} else if (p.로직 && !rows.some((r) => r.로직 === p.로직)) {
const values = (p.입력값 ?? {}) as LaborRow["입력값"];
rows.push({ 로직: p.로직, 별칭: null, 메모: null, 입력값: values, 수량: { 손: qty } });
}
}
if (!rows.length && !mats.size) return null;
return { 종류: "일위대가", 열: "", 단위: null, 담은로직: rows, 자재: [...mats.values()] };
}
@@ -14,6 +14,7 @@ const STUB_FILES = [
"M02_MasterTemplete_Labor_Api",
"M02_MasterTemplete_Labor_Input",
"M01_MasterData_UI_LogicLab_Source",
"M02_MasterTemplete_Hopyo_CheckRows", // 표 그리기(DOM · Labor_Rows) — 359-3
];
const opened = [];
const stub = new Proxy(
@@ -74,6 +75,17 @@ let out0 = null;
const sheet = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Sheet.ts"));
const api = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Api.ts"));
const pick = loadTs(path.join(M02, "M02_MasterTemplete_Hopyo_Pick.ts"));
const draft = loadTs(path.join(M02, "logic", "M02_MasterTemplete_Hopyo_Draft.ts"));
if (input.drafts) {
// 359-3 — 실물 줄 → 미리 셈 문서 · 표(서버 calc 에 그대로 보냄)
const d = check.loadDraft("drafts");
out0 = input.drafts.map((l) => {
const parts = check.partsOf(l, d);
return { 차례: l.차례, table: draft.tableOf(l, parts), doc: draft.draftDoc(l, parts) };
});
process.stdout.write(JSON.stringify(out0));
return;
}
if (input.real) {
// 실물 맞춤 응답(338-4) — 서버 줄을 그대로 먹여 체크 · 몸 · 빠질 줄 · 품 비교 · 글
const lines = input.real;
@@ -177,6 +189,32 @@ let out0 = null;
entry: ledger.entryOf({ ...rows[0], 갈래: "우리에 없음" }),
};
})(),
draft: (() => {
// 359-3 — 미리 셈 문서(우리 수량 = 엑셀 × 배 · 별도계상 · 같은 자재 더함 · 같은 로직 둘째 · 호표 · 배 없음 뺌)
const L = { 로직: "GC000198", 입력값: { 소량감률: 50 }, 배: 2 };
const M = { 자재: "MT1", 입력값: {}, 배: 1 };
return {
logic: draft.draftDoc({ 수량: 0.5, 갈래: "단가산출" }, [
L,
{ ...L, 배: 3 },
{ 호표: 46, 입력값: {}, 배: 1 },
{ 로직: "GF000101", 입력값: {}, 배: null },
]),
mat: draft.draftDoc({ 수량: 2, 갈래: "별도계상" }, [M, { ...M, 배: 0.5 }]),
direct: draft.draftDoc({ 수량: null, 갈래: "자재" }, [M]).자재[0],
none: [
draft.draftDoc({ 수량: 1, 갈래: "단가산출" }, []),
draft.draftDoc({ 수량: 1, 갈래: "호표" }, [{ 호표: 3, 입력값: {}, 배: 1 }]),
],
table: [
draft.tableOf({ 갈래: "단가산출" }, [M]),
draft.tableOf({ 갈래: "자재" }, [L]),
draft.tableOf({ 갈래: "자재" }, [L, M]),
draft.tableOf({ 갈래: "별도계상" }, []),
draft.tableOf({ 갈래: "%" }, []),
],
};
})(),
sheet: {
order: sheet.buildOrder([
{ 번: 45, 아래: [46] },
@@ -0,0 +1,65 @@
"""확인 창 금액 미리 셈 이음매(PLAN 359-3) — 북부청 실물 호표 줄 → 화면 문서 꼴
(`logic/M02_MasterTemplete_Hopyo_Draft.ts` · Node) → 서버 `/labor/{열}/calc`(저장 안 함).
체크 제안 로직 줄(제1~10호표) = 200 · 반영합 있음 · 우리 수량 = 엑셀 수량 × 배 ·
자재 줄 = 자재 표 · 셈 뒤 층 폴더에 문서 안 생김.
"""
import json
import os
import subprocess
from M02_MasterTemplete import M02_Hopyo_Fit as fit
from M02_MasterTemplete import M02_Template_Layers as layers
from resources.tester import test_m02_hopyo_excel as base
from resources.tester.test_m02_hopyo_excel import _post
HERE = os.path.dirname(os.path.abspath(__file__))
client = base.client # 공용 고정물(임시 층)
CALC = "/api/m02/layers/personal/labor/hopyo-draft/calc"
def _drafts(lines: list[dict]) -> list[dict]:
proc = subprocess.run(
["node", os.path.join(HERE, "helper_m02_hopyo_check.cjs")],
input=json.dumps({"drafts": lines}, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=False,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_실물_줄_미리_셈(client):
assert _post(client).status_code == 200
folder = layers.personal_dir(7, 42)
fit.run_job(folder, "HP-000001") # 셈 끝(338 숫자 판정)
before = sorted(p.name for p in folder.rglob("*") if p.is_file())
lines = [
{**line, "차례": no * 1000 + line["차례"]} # 호표 여럿 줄 한 번에(차례 = 열쇠만)
for no in range(1, 11)
for line in client.get(f"/api/m02/layers/personal/hopyo/HP-000001/{no}").json()["호표"][
"줄"
]
]
drafts = {x["차례"]: x for x in _drafts(lines)}
sure = [x for x in lines if x["맞춤"]["체크"] and any("로직" in p for p in x["맞춤"]["대상"])]
assert len(sure) >= 5, [x["맞춤"]["상태"] for x in lines]
for line in sure:
draft = drafts[line["차례"]]
assert draft["doc"] is not None, line["이름"]
res = client.post(CALC, json={"doc": draft["doc"], "수량": {}})
assert res.status_code == 200, (line["이름"], res.text)
got = res.json()
if draft["table"] == "logic":
row = draft["doc"]["담은로직"][0]
part = line["맞춤"]["대상"][0]
assert row["수량"]["손"] == line["수량"] * part["배"]
assert got["반영합"]["계"] > 0, line["이름"]
for line in lines: # 자재 · 별도계상 줄(대상 없음) = 자재 표
if line["갈래"] in ("자재", "별도계상") and not line["맞춤"]["대상"]:
assert drafts[line["차례"]]["table"] == "mat"
after = sorted(p.name for p in folder.rglob("*") if p.is_file())
assert after == before # 미리 셈 = 저장 안 함
+51 -9
View File
@@ -26,6 +26,7 @@ NEW = (
"Hopyo_Side",
"Hopyo_Sheet",
"Hopyo_Check",
"Hopyo_CheckRows",
"Hopyo_Choose",
"Hopyo_Ledger",
)
@@ -282,7 +283,8 @@ def test_옛_즉시_확정과_바로_만들기_없음():
users = sorted(name for name, text in texts.items() if "buildHopyo(" in text)
assert users == ["Hopyo_Check", "Hopyo_Sheet"] # 확인 창 · 엑셀 보기 [저장] 물음 뒤
check = texts["Hopyo_Check"]
assert '"확인하고 사전조합 만들기"' in check and "맞음 · 별칭 모두 체크" in check
assert '"사전조합 생성"' in check and "맞음 · 별칭 모두 체크" in check
assert "확인하고" not in check # 359-4 명사형
assert "showLoadingOverlay()" in check and "dropDraft(k)" in check
assert "m02-hopyo-check:" in check and "POLL_MS" in check
sheet = texts["Hopyo_Sheet"]
@@ -352,7 +354,8 @@ def test_엑셀_보기_창_여는_자리와_늦게_불러옴():
assert "await showSheet(got);" in side # 올린 직후 자동
assert lazy in side.split("async function showSheet(")[1].split("async function showLedger(")[0]
check = _text("M02_MasterTemplete_Hopyo_Check.ts")
assert lazy in check and '"엑셀에서 보기"' in check and "호표: opt.no" in check
assert lazy in check and 'label: "엑셀 보기"' in check and "호표: opt.no" in check
assert "엑셀에서 보기" not in check
assert "단산행: 행" in check
pick = _text("M02_MasterTemplete_Hopyo_Pick.ts")
assert lazy in pick
@@ -398,16 +401,14 @@ def test_확인_창_손질_339():
② 만들기 뒤 좌측 사전조합 목록 다시 ③ 엑셀 보기 창 목록 셈 끝까지 다시 읽음 · 펼침 표 숫자 판정 칸."""
css = _text("M02_MasterTemplete_Hopyo.css")
assert "word-break: keep-all;" in css and "white-space: pre-line;" in css
assert (
".m02-hopyo__lines .m02-hopyo__acts > *" in css
and "text-overflow"
not in css.split(".m02-hopyo__lines td:is(:nth-child(9)")[-1].split("}")[0]
)
assert ".m02-hopyo__lines .m02-hopyo__acts > *" in css
assert "nth-child(9)" not in css # 359-3 — 칸 차례 대신 칸 이름
assert ".m02-hopyo-ledger__table td:nth-child(1)" in css
assert "m02-hopyo-ledger__table" in _text("M02_MasterTemplete_Hopyo_Ledger.ts")
rows = _text("M02_MasterTemplete_Hopyo_CheckRows.ts")
assert r'text: compare.split(" · ").join("\n")' in rows # 품 비교 = 항목마다 한 줄
assert "m02-hopyo__opts" in rows # 옵션 = 항목마다 한 줄
check = _text("M02_MasterTemplete_Hopyo_Check.ts")
assert r'td(opts.join("\n"))' in check and r'.split(" · ")' in check
assert r'.join("\n"),' in check # 품 비교 = 항목마다 한 줄(판정 글 함수는 그대로)
make = check.split("async function make(")[1].split("function builtBlock(")[0]
assert make.index("document.dispatchEvent(new Event(HOPYO_BUILT));") < make.index("opt.onBuilt")
side = _text("M02_MasterTemplete_UI_Side.ts")
@@ -431,3 +432,44 @@ def test_엑셀_보기_머리_막대_셈_끝까지_다시_읽음_359():
assert drop.index("deleteHopyo(") < drop.index("modal.close();") < drop.index("opt.onDeleted")
poll = sheet.split("function poll(")[1].split("async function load(")[0]
assert "paintHead(next);" in poll and "paintList();" in poll and "poll(next);" in poll
def test_확인_창_표_일위대가_상세_꼴_359():
"""359-3 — 로직 표 · 자재 표(일위대가 상세 꼴) + 품 비교 · 단추 · 드롭다운 글(저장 값 그대로) ·
금액 = 줄마다 서버 calc 미리 셈(`logic/` 문서 꼴 · Build 와 같은 수량 규칙)."""
got = _check()["draft"]
assert got["logic"] == {
"종류": "일위대가",
"열": "",
"단위": None,
"담은로직": [ # 같은 로직 둘째 · 호표 · 배 없음 = 뺌
{"로직": "GC000198", "별칭": None, "메모": None, "입력값": {"소량감률": 50},
"수량": {"손": 1}},
],
"자재": [],
} # fmt: skip
assert got["mat"]["자재"] == [ # 같은 자재 = 2 × 1 + 2 × 0.5
{"코드": "MT1", "이름": None, "규격": None, "단위": None, "수량": {"손": 3},
"별도계상": "별도계상"},
] # fmt: skip
assert got["direct"]["별도계상"] == "직접계산" and got["direct"]["수량"] == {"손": 0}
assert got["none"] == [None, None]
assert got["table"] == ["mat", "logic", "logic", "mat", "logic"]
rows = _text("M02_MasterTemplete_Hopyo_CheckRows.ts")
assert '["", "추가"],' in rows and '["우리에 없음", "로직없음"],' in rows
assert '["안 씀", "사용안함"],' in rows and "뺌 — " not in rows
for word in ('"고르기"', '"원문 보기"', '"엑셀 보기"', '"옵션"', '"품 비교"'):
assert word in rows, word
assert 'import { moneyCell, PRICE_SLOTS } from "./M02_MasterTemplete_Labor_Rows";' in rows
assert "...markCells({ 별도계상:" in rows and "...markHeads()," in rows
for head in ("Col_Order", "Sub", "Col_Name", "Col_Alias", "Labor_QtyCol", "Col_Unit"):
assert f'tc("{head}")' in rows, head
check = _text("M02_MasterTemplete_Hopyo_Check.ts")
assert "calcLabor(target.layer, DRAFT_COLUMN, doc, {}, target.owner)" in check
assert "checkTables(h.줄, ctx())" in check and "draftDoc(line, partsOf(line, d))" in check
assert "function lineRow(" not in check and "function partCell(" not in check
draft = _text("logic/M02_MasterTemplete_Hopyo_Draft.ts")
assert "M02_Hopyo_Build" in draft # 정본 짝 표시
code = draft.split("*/", 1)[1] # 머리 글 밖
for gone in ("document", "fetch(", "sessionStorage", "@ui/"):
assert gone not in code, gone