diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css index 612aee372..5a6bc6ea6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo.css @@ -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%; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts index 05f10c7c9..4af902735 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_Check.ts @@ -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(); + let pending = false; const keep = (): void => saveDraft(k, d); async function load(quiet = false): Promise { @@ -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 { 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}호표`, diff --git a/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts new file mode 100644 index 000000000..e14bbd11d --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Hopyo_CheckRows.ts @@ -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; +} diff --git a/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Draft.ts b/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Draft.ts new file mode 100644 index 000000000..cb2051f00 --- /dev/null +++ b/M02_MasterTemplete/logic/M02_MasterTemplete_Hopyo_Draft.ts @@ -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, 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, + parts: HopyoPart[], +): LaborDoc | null { + const rows: LaborRow[] = []; + const mats = new Map(); + 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()] }; +} diff --git a/resources/tester/helper_m02_hopyo_check.cjs b/resources/tester/helper_m02_hopyo_check.cjs index 9e2f83b33..b3d3acdd3 100644 --- a/resources/tester/helper_m02_hopyo_check.cjs +++ b/resources/tester/helper_m02_hopyo_check.cjs @@ -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] }, diff --git a/resources/tester/test_m02_hopyo_draft_calc.py b/resources/tester/test_m02_hopyo_draft_calc.py new file mode 100644 index 000000000..a6769e95a --- /dev/null +++ b/resources/tester/test_m02_hopyo_draft_calc.py @@ -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 # 미리 셈 = 저장 안 함 diff --git a/resources/tester/test_m02_hopyo_screen.py b/resources/tester/test_m02_hopyo_screen.py index c3b7b89ff..ca913fc01 100644 --- a/resources/tester/test_m02_hopyo_screen.py +++ b/resources/tester/test_m02_hopyo_screen.py @@ -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