diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.py b/B09_Estimation/B09_Estimation_Calc_Bill.py index 6c7379b47..50a783f96 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.py +++ b/B09_Estimation/B09_Estimation_Calc_Bill.py @@ -48,7 +48,7 @@ 묶음 금액 = 자손 항목 합 + 「빈 줄 n」 · 구조물 블록 줄 키 꼬리 = 대가 블록 키(대가 번호 밀림에 안 흔들림). 원가계산서로 넘길 합 = `sums`(cost direct 항목의 직접재료비 · 직접노무비 · 산출경비 · -supply 항목의 관급 순자재대 — 145-4 이음 꼴 · 빈 줄이 있으면 None) · +supply 항목의 관급 순자재대 — 145-4 이음 꼴 · 직접공사 = 아는 금액까지 + `blank`(701-3) · 관급 = 빈 줄이 있으면 None) · 화면 표시용 = `totals`(아는 금액 합 · 빈 줄 수 · 조달수수료). """ @@ -502,14 +502,15 @@ def compute_bill( "direct": {**direct, "blank": blank}, "owner": {"net_material": net, "fee": fee, "total": net + (fee or 0)}, } - # 원가계산서로 넘김(145-4 이음 꼴) — 빈 줄이 있으면 모르는 값 = None - known = blank == 0 + # 원가계산서로 넘김(145-4 이음 꼴) — 빈 줄이 있어도 아는 금액까지(701-3 · 띠 「빈 줄 n · 미완성」 · + # 인쇄 · 확정은 빈 줄 0 일 때만) · 관급 = 모르는 줄이 있으면 None(그대로) owner_known = all(row["sum"] is not None for row in owner) sums_out = { - "material": direct["material"] if known else None, - "labor": direct["labor"] if known else None, - "expense": direct["expense"] if known else None, + "material": direct["material"], + "labor": direct["labor"], + "expense": direct["expense"], "supply_net": net if owner_known else None, + "blank": blank, } return {"rows": rows, "totals": totals, "sums": sums_out, "gaps": gaps} diff --git a/B09_Estimation/B09_Estimation_Calc_Bill.ts b/B09_Estimation/B09_Estimation_Calc_Bill.ts index 50ad3e3fd..3edec67e9 100644 --- a/B09_Estimation/B09_Estimation_Calc_Bill.ts +++ b/B09_Estimation/B09_Estimation_Calc_Bill.ts @@ -9,7 +9,7 @@ * 공종 나무 = 엑셀 꼴 고정(1 토공사 · 2 구조물공 · 3 운반공 · 4 자재대 · 관급자재대(제외금액)) · 공구 안 나눔 * 항목 = 서버가 B08 수량으로 짠 줄 + 대비표 「별도」(4 자재대) · 「관급」(관급자재대) 자재 + 조달수수료 * 금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 조달수수료 = ROUNDDOWN(순자재대 × 율 ÷ 100, 0) - * 묶음 금액 = 자손 항목 합(빈 줄은 「빈 줄 n」) · 원가계산서로 넘길 합 = sums(빈 줄 있으면 null) + * 묶음 금액 = 자손 항목 합(빈 줄은 「빈 줄 n」) · 원가계산서로 넘길 합 = sums(아는 금액까지 + blank · 701-3 · 관급만 빈 줄 있으면 null) * 단가 = `refPrice(ref)` 하나(dg 대가 목록 · sg 산근 목록 · logic = 서버 sg_hand 산근 · 그 밖 hopyo) * 자유 편집(184) = `user.bill` v2 덮개 {v: 2, rows: [표 차례 · 고친 칸 · 새 줄], gone: [지운 자동 줄]} · * 옛 꼴(qty · 손 줄 after · parent) 그대로 읽음 · 저장 꼴 · 출력 줄 칸(계약) = 짝 .py 머리 @@ -472,14 +472,15 @@ export function computeBill( direct: { ...direct, blank }, owner: { net_material: net, fee, total: net + (fee || 0) }, }; - // 원가계산서로 넘김(145-4 이음 꼴) — 빈 줄이 있으면 모르는 값 = null - const known = blank === 0; + // 원가계산서로 넘김(145-4 이음 꼴) — 빈 줄이 있어도 아는 금액까지(701-3 · 띠 「빈 줄 n · 미완성」 · + // 인쇄 · 확정은 빈 줄 0 일 때만) · 관급 = 모르는 줄이 있으면 null(그대로) const ownerKnown = owner.every((row) => row.sum !== null); const sumsOut = { - material: known ? direct.material : null, - labor: known ? direct.labor : null, - expense: known ? direct.expense : null, + material: direct.material, + labor: direct.labor, + expense: direct.expense, supply_net: ownerKnown ? net : null, + blank, }; return { rows, totals, sums: sumsOut, gaps }; } diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index 09d09379b..3f43b2ede 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -248,8 +248,12 @@ async def save_estimation(project_id: UUID, body: dict[str, Any] = Body(...)) -> @router.post("/{project_id}/estimation/complete") async def estimation_complete(project_id: UUID) -> dict[str, Any]: - """B09 [>] — 단계 6 완료(요구 300 B1).""" - await _project_root(project_id) # 없는 프로젝트는 404 + """B09 [>] — 단계 6 완료(요구 300 B1) · 빈 줄 있으면 409(701-3 확정 = 빈 줄 0 · 서버 정본으로 셈).""" + root = await _project_root(project_id) # 없는 프로젝트는 404 + sheets = (await payload(project_id, root))["sheets"] + blank = (((sheets.get("bill") or {}).get("totals") or {}).get("direct") or {}).get("blank") + if blank: + raise HTTPException(409, f"빈 줄 {blank} · 미완성 — 확정은 빈 줄 0 일 때만") await mark_stage(str(project_id), 6, "next") return {"status": "success", "project_id": str(project_id)} diff --git a/B09_Estimation/B09_Estimation_Sangeun.py b/B09_Estimation/B09_Estimation_Sangeun.py index 297f9de63..ac8be89fd 100644 --- a/B09_Estimation/B09_Estimation_Sangeun.py +++ b/B09_Estimation/B09_Estimation_Sangeun.py @@ -570,11 +570,17 @@ def collect( def build( project_root: str | Path, sheets: dict[str, Any], user: dict[str, Any] | None = None ) -> dict[str, Any]: - """연결된 사본 전부(구조물 id 차례)의 산근 + 설계내역서 손 줄 로직(166-2 · 뒤 번호).""" + """연결된 사본 전부(구조물 id 차례 → 설계내역서 서고 사본 · 702-3)의 산근 + + 설계내역서 손 줄 로직(166-2 · 뒤 번호) — 차례 = `Labor_Source.daega_inputs`.""" + from B09_Estimation.B09_Estimation_Bill_Library import copies + base = structures_dir(project_root) entries = [ (owner, base / linked["폴더"]) for owner, linked in sorted(linked_snapshots(project_root).items()) if "일위대가" in linked.get("부분", []) ] + entries += [ + (f"서고 {(m.get('원본') or {}).get('name') or f.name}", f) for f, m in copies(project_root) + ] return collect(entries, sheets, hand_sources(user)) diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index 608bdff34..71fd0d8f1 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -15,6 +15,7 @@ import "./B09_Estimation_UI_Page.css"; import { API_BASE_URL } from "@config/config_frontend"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { createText } from "@ui/ui_template_text"; +import { createBanner } from "@ui/ui_template_banner"; import { createButton, hideLoadingOverlay, @@ -38,7 +39,14 @@ import { createCheckPanel } from "./B09_Estimation_UI_Check"; import { tableOptionsOf, tabTableOf } from "./B09_Estimation_UI_Builders"; import { createDock, linkMode } from "./B09_Estimation_UI_Dock"; import { closePopups, openRowPopup } from "./B09_Estimation_UI_Popup"; -import { DRAFT, rowIndex, type Link, type TabContext, type TabId } from "./B09_Estimation_UI_Tabs"; +import { + DRAFT, + rowIndex, + unfinished, + type Link, + type TabContext, + type TabId, +} from "./B09_Estimation_UI_Tabs"; import { attachRowMenu, openMenuAt, type BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; import { jumpRange, jumpTo, levelBar, pickedClass } from "./B09_Estimation_UI_Tabs_Bill_Level"; import { @@ -259,7 +267,9 @@ export function renderB09Estimation(root: HTMLElement): void { if (level && hint) hint.textContent = `${level.guide.textContent} · ${hint.textContent}`; const foot = hint ?? level?.guide; const fold = bill?.fold?.(table.root); // 702-1 줄 펼침 = 표 아래 - panel.replaceChildren(table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : [])); + // 701-3 띠(원가계산서 「빈 줄 n · 미완성」) = 표 위 + const banner = spec.banner ? [createBanner({ tone: "warning", text: spec.banner })] : []; + panel.replaceChildren(...banner, table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : [])); if (at >= 0) requestAnimationFrame(() => table.focusRow(at, false)); // 스크롤만(표시 = 빨강) }; @@ -304,6 +314,7 @@ export function renderB09Estimation(root: HTMLElement): void { ? run("/estimation", "PUT", "원가 사용자 값을 저장했습니다.", { user }) : Promise.resolve(true); DRAFT.save = saveChanged; // 초안 [인쇄 미리보기] = 저장 뒤 이동(506-3) + DRAFT.blocked = () => (payload ? unfinished(sheets) : null); // 701-3 인쇄 · 확정 = 빈 줄 0 const refresh = createButton({ label: "업데이트", variant: "ghost", @@ -363,6 +374,8 @@ export function renderB09Estimation(root: HTMLElement): void { left.addEventListener("ui-step-nav", (event) => { if ((event as CustomEvent).detail !== 1 || !goNext || !projectId) return; event.stopPropagation(); + const why = DRAFT.blocked?.(); + if (why) return void showToast(why, "warning"); // 701-3 확정 = 빈 줄 0 일 때만 void moveStage(projectId, goNext, [ { name: "원가 저장", run: saveChanged }, { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs.ts b/B09_Estimation/B09_Estimation_UI_Tabs.ts index d37e2ff84..7a116957a 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs.ts @@ -62,6 +62,12 @@ export const blankRows = (sheets: Row): Row[] => (r) => r.kind === "item" && r.cost === "direct" && r.sum === null, ); +/** 「빈 줄 n · 미완성」 띠 글(701-3) — 빈 줄 0 = null */ +export const unfinished = (sheets: Row): string | null => { + const n = blankRows(sheets).length; + return n ? `빈 줄 ${n} · 미완성 — 금액은 아는 줄까지 · 인쇄 · 확정은 빈 줄 0 일 때만` : null; +}; + /** 위 탭(181-1) — [위 탭 이름, 아래 탭들] · 아래 탭 하나뿐 = 위 탭 단독 · 첫 아래 탭 = 위 탭 키 */ export const TAB_GROUPS: Array<[string, TabId[]]> = [ ["공사원가계산서", ["cost"]], @@ -101,6 +107,8 @@ export interface TabTable { frozen?: number; /** 표 위 안내 글(근거 · 끝수) */ hint?: string; + /** 표 위 띠 알림(공용 띠 · 경고) — 예 원가계산서 「빈 줄 n · 미완성」(701-3) */ + banner?: string; /** 칸 누름 → 갈 줄(호표 칸) */ link?: (index: number, key: string) => Link | null; editable?: (index: number, key: string) => boolean; @@ -140,6 +148,8 @@ export const DRAFT: { next?: () => void; /** 고친 값 저장(셸 `saveChanged`) — 성공이면 참 · 초안 단추가 이동 전에 부름. */ save?: () => Promise; + /** 인쇄 · 확정 막는 까닭(셸 · 701-3 빈 줄 있으면 글) — 없으면 null */ + blocked?: () => string | null; } = { builders: {}, extend: [], panels: [], actions: [] }; /** 201-1 호표 묶음 레벨(보기 전용) — 머리 · 합계(제목 줄) = 1 · 그 밖 = 2 · key 열을 들여씀 */ diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts index fac5b923f..e53db9eb4 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts @@ -69,7 +69,7 @@ export const buildBill: TabBuild = (ctx): BillTable => { hint: "줄을 우클릭하면 항목 추가 · 줄 삭제 · 옮김 · 단가연결 칸 램프를 누르면 일위대가 · 단가산출 연결" + (direct.blank - ? ` · 빈 줄 ${direct.blank} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음` + ? ` · 빈 줄 ${direct.blank} — 계는 아는 금액만 · 원가계산서도 아는 금액까지 · 인쇄 · 확정은 빈 줄 0 일 때만` : ""), editOnDouble: true, editable: (i, key) => { diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts index e41a21e1b..1fb329ba6 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts @@ -25,6 +25,7 @@ import { type TabBuild, type TabContext, type TabTable, + unfinished, } from "./B09_Estimation_UI_Tabs"; type Num = number | null; @@ -251,6 +252,7 @@ export const buildTotal: TabBuild = (ctx): TabTable => { rowClass: (i) => levelClass(rows[i]), level: nameLevel(rows), mergeSame: ["part"], + banner: unfinished(ctx.sheets) ?? undefined, hint: "맨 위 내역 줄을 누르면 설계내역서 그 줄로 · 요율 칸에 적으면 사용자 값 · 지우거나 출처 칸을 누르면 자동 값", editable: (i, key) => (key === "rate" && RATE_KEYS.has(String(rows[i]?.key))) || @@ -336,6 +338,7 @@ export const buildCost: TabBuild = (ctx): TabTable => { rowClass: (i) => levelClass(rows[i]), level: nameLevel(rows), mergeSame: ["group", "part"], + banner: unfinished(ctx.sheets) ?? undefined, hint: "고칠 칸은 간접재료비 · 줄을 누르면 총괄 그 줄로 이동", editable: (i, key) => key === "amount" && String(rows[i]?.key) in HAND, onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value), diff --git a/B09_Estimation/draft/B09_Draft_Print_UI.ts b/B09_Estimation/draft/B09_Draft_Print_UI.ts index 10508c02b..db8ee9f9c 100644 --- a/B09_Estimation/draft/B09_Draft_Print_UI.ts +++ b/B09_Estimation/draft/B09_Draft_Print_UI.ts @@ -2,13 +2,13 @@ * B09_Draft_Print_UI.ts * 인쇄 미리보기 등록 입구(PLAN 302-1 · 초안) — B09 셸이 `import.meta.glob("./draft/*_UI.ts")` 로 불러올 때 * 좌측 하단 고정 줄(`DRAFT.actions`)에 [인쇄 미리보기] 단추 → 저장(`DRAFT.save`) 뒤 #/b09-print(`…_Print_Route`) - * · 단계 그대로 · 저장 실패면 머묾 · + * · 단계 그대로 · 저장 실패면 머묾 · 빈 줄 있으면 막음(`DRAFT.blocked` · 701-3) · * [>] 다음 길(`DRAFT.next` · 저장 · 단계 6 완료 뒤 · 요구 298-2) * draft 폴더째 지우면 단추 · 길 · 서버 길이 함께 사라짐 * ========================================================================== */ import type { RoutePath } from "@config/config_frontend"; -import { createButton } from "@ui/ui_template_elements"; +import { createButton, showToast } from "@ui/ui_template_elements"; import { navigateTo } from "../../A00_Common/router"; import { DRAFT } from "../B09_Estimation_UI_Tabs"; import { ROUTE } from "./B09_Draft_Print_Route"; @@ -19,10 +19,13 @@ DRAFT.actions.push(() => { const button = createButton({ label: "인쇄 미리보기", variant: "ghost", - onClick: () => + onClick: () => { + const why = DRAFT.blocked?.(); // 701-3 인쇄 = 빈 줄 0 일 때만 + if (why) return void showToast(why, "warning"); void (DRAFT.save?.() ?? Promise.resolve(true)).then( (ok) => ok && navigateTo(ROUTE.path as RoutePath), - ), + ); + }, }); button.dataset.b09Print = ""; return button; diff --git a/M02_MasterTemplete/M02_Item_Paste.py b/M02_MasterTemplete/M02_Item_Paste.py index e906dffb6..081eb808f 100644 --- a/M02_MasterTemplete/M02_Item_Paste.py +++ b/M02_MasterTemplete/M02_Item_Paste.py @@ -1,7 +1,8 @@ """여러 항목 · 폴더를 층 사이 붙여넣기(PLAN 30-2) — 구조물 라이브러리 나무의 [복사] → [붙여넣기]. 붙일 폴더(`dest`) 아래 새 항목 — 대상 층 새 키(SC · SP) · 열 `본뜬` = 원본(층 · 주인 · 키) · -같은 이름은 「이름(1)」 · 폴더는 안의 항목 · 빈 폴더까지(폴더 이름이 겹치면 「폴더(1)」). +같은 이름은 「이름(1)」 · 폴더는 안의 항목 · 빈 폴더까지(폴더 이름이 겹치면 「폴더(1)」) · +복사 생성(대상 `복사이름`) = 새 이름 「원래 이름 복사」(701-4 · 층이 달라도 같은 이름 둘 안 생김). 파일(도면 · 면적 · 입력 연결)은 `Items.copy_item` 이 옮김. 권한은 부르는 쪽 몫. """ @@ -73,7 +74,8 @@ def paste( if found is None: raise store.StoreError(404, f"없는 항목 「{key}」") used = [*taken, *(p for _, p in plan)] - path = tree.unique_path([*dest, found[-1]], lambda p: p in used) + name = f"{found[-1]} 복사" if one.get("복사이름") else found[-1] + path = tree.unique_path([*dest, name], lambda p: p in used) renamed += [path[-1]] if path[-1] != found[-1] else [] plan.append((key, path)) continue diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 7c6f6e109..6da5621f7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -467,6 +467,8 @@ export const editTree = ( export interface TreeRef { 경로: string[]; 키?: string; + /** 복사 생성(701-4) — 새 이름 = 「원래 이름 복사」(같은 이름 둘 안 생김) */ + 복사이름?: boolean; } /** 복사해 둔 것 — 나무 종류(`kind` · 구조물 · 일위대가) · 복사한 층 · 고른 것들 */ diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor.css b/M02_MasterTemplete/M02_MasterTemplete_Labor.css index dd3399808..4dd499c3d 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor.css +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor.css @@ -85,6 +85,13 @@ border-top: 2px solid var(--color-border); } +/* 701-4 수량 안 넣은 줄 경고 — 합계 아래 제 줄 · 좁은 카드에서도 줄바꿈 */ +.m02-labrow__name > .m02-labrow__noqty { + display: block; + max-width: 9rem; + white-space: normal; +} + .m02-labrow__move { white-space: nowrap; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts index d7638b889..a48088c51 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Rows.ts @@ -493,6 +493,13 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement }); const sum = (part: Partial | undefined, c: (typeof COSTS)[number]): number | null => part?.[c] ?? null; + // 701-4 수량산출 빈 줄 = 반영 금액 없음 → 합계에서 빠짐 — 조용히 빠지지 않게 합계 줄에 경고 + const noQty = data.calc + ? data.rows.filter((row) => { + const line = lineOf(lineId(row) ?? ""); + return !!line && (line.수량 === null || line.수량 === undefined); + }).length + : 0; const foot = data.rows.length ? [ el("tr", { @@ -500,7 +507,21 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement children: [ el("td", { className: "m02-labrow__no", text: "" }), el("td", { className: "m02-labrow__kind", text: "" }), - el("td", { className: "m02-labrow__name", text: tc("Prev_Sum") }), + el("td", { + className: "m02-labrow__name", + children: [ + el("span", { text: tc("Prev_Sum") }), + ...(noQty + ? [ + el("small", { + className: "ui-text ui-text--error m02-labrow__noqty", + text: tc("Labor_NoQty").replace("{n}", String(noQty)), + attrs: { "data-labor-noqty": String(noQty) }, + }), + ] + : []), + ], + }), el("td", { className: "m02-labrow__alias", text: "" }), el("td", { className: "m02-labrow__qty", text: "" }), el("td", { className: "m02-labrow__unit", text: "" }), diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts index b379755f3..35792580c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Text.ts @@ -107,6 +107,10 @@ const TEXT = { Lib_Personal: ["개인", "Personal"], Labor_AlreadyRef: ["이미 담은 일위대가 조합 — {value}", "Already added — {value}"], Labor_Empty: ["빈 값 {n}", "{n} empty"], + Labor_NoQty: [ + "수량 안 넣은 줄 {n} — 합계에서 빠짐", + "{n} rows without quantity — left out of the sum", + ], Labor_Lost: [ "로직 입력이 바뀌어 저장 때 빠지는 값 — {value}", "Inputs no longer in the logic (dropped on save) — {value}", diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts index 259dc409c..384e588c5 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -673,7 +673,12 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { const run = api.paste; if (!run || !opt.canEdit || !(await ready())) return; await apply(() => - run(opt.layer, owner, { kind: clipKind, from, refs: [{ 경로: [], 키: key }] }, dest), + run( + opt.layer, + owner, + { kind: clipKind, from, refs: [{ 경로: [], 키: key, 복사이름: true }] }, // 701-4 「… 복사」 + dest, + ), ); }; diff --git a/resources/tester/helper_298_2_b08_b09_buttons.cjs b/resources/tester/helper_298_2_b08_b09_buttons.cjs index 63e5bee72..35a9518d4 100644 --- a/resources/tester/helper_298_2_b08_b09_buttons.cjs +++ b/resources/tester/helper_298_2_b08_b09_buttons.cjs @@ -235,6 +235,8 @@ async function b09(withNext) { return { ok: true, status: 200, json: async () => any() }; }; const tabs = any(); + let unfinished = null; // 701-3 빈 줄 띠 글 — 있으면 [>] 막힘 + tabs.unfinished = () => unfinished; tabs.DRAFT = { builders: {}, extend: [], panels: [], actions: [] }; if (withNext) tabs.DRAFT.next = () => calls.push("next"); const nextBtn = Object.assign(new Node("button"), { disabled: true }); @@ -290,6 +292,9 @@ async function b09(withNext) { out.completeFail = await nav(1); failOn = null; out.prev = await nav(-1); + unfinished = "빈 줄 2 · 미완성"; + out.blocked = await nav(1); + unfinished = null; // [초기화] const reset = dock.children[0]; const press = async () => { diff --git a/resources/tester/test_145_3_b09_bill.py b/resources/tester/test_145_3_b09_bill.py index 1113830dd..f18b74948 100644 --- a/resources/tester/test_145_3_b09_bill.py +++ b/resources/tester/test_145_3_b09_bill.py @@ -125,15 +125,12 @@ def test_제목_합_빈_줄과_원가계산서_이음(): assert rows["sec:1.1"]["sum"] == 361494 + 1119272 and rows["sec:1.1"]["blank"] == 0 assert rows["sec:1"]["sum"] == 361494 + 1119272 + 11003825 # 빈 줄은 0 이 아니라 빈 줄 수 assert rows["sec:1"]["blank"] == 1 and "빈 줄" not in rows["sec:1"]["note"] # 257-2 비고 빈 줄 없음 - # 145-4 이음 = {material, labor, expense, supply_net} · 빈 줄 있으면 None - assert sheets["bill"]["sums"] == { - "material": None, - "labor": None, - "expense": None, - "supply_net": 69474220, - } + # 145-4 이음 = {material, labor, expense, supply_net, blank} · 701-3 빈 줄 있어도 아는 금액까지 + known = {"material": 2771766, "labor": 7565300, "expense": 2627525, "supply_net": 69474220} + assert sheets["bill"]["sums"] == {**known, "blank": 2} # 절토(연암) · 막자갈(단가 없음) full = {**INPUTS, "bill": {"items": ITEMS[:3]}, "materials": MATERIALS[:-1]} sums = compute_estimation(full, USER)["bill"]["sums"] + assert sums == {**known, "blank": 0} assert sums["labor"] == 307104 + 924896 + 6333300 # I9 + I10 + I14 assert sums["material"] == 54390 + 194376 + 2043000 + 480000 assert sums["expense"] == 2627525 diff --git a/resources/tester/test_202_1_table_hint_bottom.py b/resources/tester/test_202_1_table_hint_bottom.py index 8b75244ec..7d33b35ce 100644 --- a/resources/tester/test_202_1_table_hint_bottom.py +++ b/resources/tester/test_202_1_table_hint_bottom.py @@ -106,7 +106,7 @@ def test_묶음_탭_단추가_보조_줄에_안내는_아래에(): def test_잇기_쉘_안내_줄_아래_도구_비움(): shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8") assert ( - "panel.replaceChildren(table.root, ...(foot ? [foot] : []))" in shell + "panel.replaceChildren(...banner, table.root, ...(foot ? [foot] : []))" in shell # 701-3 띠 = 표 위 ) # 229-1 레벨 단추 = 보조 탭 줄 · 안내 글 = 표 아래 · 203 레벨 안내 + 탭 안내 = 한 영역(맨 아래) assert "...(hint ? [hint] : []), table.root" not in shell # 위 자리 없음 assert re.search(r"bars\.draw\(tab\);\s*bars\.setTools\(\[\]\);", shell) diff --git a/resources/tester/test_298_2_b08_b09_buttons.py b/resources/tester/test_298_2_b08_b09_buttons.py index 64f2735fc..6d498e0e5 100644 --- a/resources/tester/test_298_2_b08_b09_buttons.py +++ b/resources/tester/test_298_2_b08_b09_buttons.py @@ -88,6 +88,7 @@ def test_B09_다음_저장_완료_인쇄(r): "toast:error", ] assert b["prev"] == [False, []] # [<] = 공용 틀 몫 + assert b["blocked"] == [True, ["toast:warning"]] # 701-3 빈 줄 있으면 저장 · 완료 없이 머묾 def test_B09_초기화(r): diff --git a/resources/tester/test_701_3_b09_cost_unfinished.py b/resources/tester/test_701_3_b09_cost_unfinished.py new file mode 100644 index 000000000..fdc18a385 --- /dev/null +++ b/resources/tester/test_701_3_b09_cost_unfinished.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +"""701-3 — 원가계산서 = 아는 금액까지 + 「빈 줄 n · 미완성」 띠 · 인쇄 · 확정은 빈 줄 0 일 때만. + +- 설계내역서 `sums` = 빈 줄이 있어도 아는 비목 합 + `blank`(파이썬 · TS 짝 같은 값) +- 총괄 · 원가계산서 = 그 합으로 셈(빈칸 아님) +- [>] 확정 = 서버가 정본으로 다시 셈 · 빈 줄 있으면 409 · 단계 완료 안 함 +""" + +from __future__ import annotations + +import asyncio +import json +import subprocess +import sys +from pathlib import Path + +import pytest +from fastapi import HTTPException + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(Path(__file__).parent)) + +from test_145_3_b09_bill import INPUTS, USER, _run_ts # noqa: E402 + +from B09_Estimation import B09_Estimation_Router as router # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Bill import compute_estimation # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Total import compute_total # noqa: E402 +from B09_Estimation.B09_Estimation_Rates import rate_table # noqa: E402 + + +def test_빈_줄_있어도_아는_금액_합_거울(): + py = compute_estimation(INPUTS, USER) + sums = py["bill"]["sums"] + assert sums["blank"] == 2 and sums["labor"] == 7565300 and sums["material"] == 2771766 + assert _run_ts(INPUTS, USER)["bill"]["sums"] == sums + + +def test_원가계산서_아는_금액으로_셈(): + sums = compute_estimation(INPUTS, USER)["bill"]["sums"] + estimate = {**USER["estimate"], "estimated_price": 5e8, "duration_days": 120} # 요율 구간 + total = compute_total(sums, estimate, rate_table()) + assert total["sums"]["direct"] == 7565300 + 2771766 + 2627525 + assert total["cost"]["grand"] is not None and total["cost"]["grand"] > total["sums"]["direct"] + + +def _complete(monkeypatch, blank: int) -> list[str]: + marks: list[str] = [] + + async def root(_pid): + return Path("x") + + async def payload(_pid, _root): + return {"sheets": {"bill": {"totals": {"direct": {"blank": blank}}}}} + + async def mark(pid, stage, how): + marks.append(f"{stage}:{how}") + + monkeypatch.setattr(router, "_project_root", root) + monkeypatch.setattr(router, "payload", payload) + monkeypatch.setattr(router, "mark_stage", mark) + asyncio.run(router.estimation_complete("P1")) + return marks + + +def test_확정_빈_줄_있으면_409_단계_그대로(monkeypatch): + with pytest.raises(HTTPException) as caught: + _complete(monkeypatch, 3) + assert caught.value.status_code == 409 and "빈 줄 3 · 미완성" in caught.value.detail + assert _complete(monkeypatch, 0) == ["6:next"] + + +def test_띠_글_빈_줄_0_이면_없음(): + source = (ROOT / "B09_Estimation/B09_Estimation_Calc_Base.ts").read_text(encoding="utf-8") + tabs = (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs.ts").read_text(encoding="utf-8") + start = tabs.index("export const blankRows") + end = tabs.index("\n};\n", tabs.index("export const unfinished")) + 4 + rows = [ + {"kind": "item", "cost": "direct", "sum": None}, + {"kind": "item", "cost": "direct", "sum": 5}, + ] + script = ( + "type Row = Record;\n" + + tabs[start:end] + + f"\nconsole.log(JSON.stringify([unfinished({{ bill: {{ rows: {json.dumps(rows)} }} }})," + " unfinished({ bill: { rows: [] } })]));" + ) + assert "export type Row" in source # 셈 짝 Row 꼴 그대로(흉내 타입) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + banner, none = json.loads(result.stdout) + assert banner.startswith("빈 줄 1 · 미완성") and "인쇄 · 확정은 빈 줄 0 일 때만" in banner + assert none is None diff --git a/resources/tester/test_702_3_b09_estimation_live.py b/resources/tester/test_702_3_b09_estimation_live.py new file mode 100644 index 000000000..26e235fcc --- /dev/null +++ b/resources/tester/test_702_3_b09_estimation_live.py @@ -0,0 +1,45 @@ +"""702-3 B09 단가연결 즉시 금액 · [저장] 단추 · 서고 사본 산근. + +- 서고 사본(단가연결로 들여온 사전조합)의 단가산출 줄 = 산근 목록에 듦 · 대가 블록 단가 채움 + (옛 흠 — 산근이 구조물 사본만 훑어 서고 사본 산근 0 · 대가 빈칸) +- 셈 미리보기 길(`POST …/estimation/calc`) = 화면 캐시로 서버 셈 · 저장 안 함 · 로직 연결 줄 금액 +- 화면 — 연결 즉시 뒤에서 셈(차례 번호 · 옛 요청 끊기) · [저장] 단추 · 옮길 때 물음 +임시 층(견본 마스터 · 임시 storage) · 실제 마스터 · 프로젝트는 안 씀. +""" + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B09_Estimation import B09_Estimation_Prices as prices # noqa: E402 +from B09_Estimation import B09_Estimation_Sangeun as sangeun # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Base import compute_base # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor # noqa: E402 +from resources.tester.test_204_2_b09_bill_library import ( # noqa: E402, F401 + daega_inputs, + library, + linked_resource_keys, + world, +) + +B09 = ROOT / "B09_Estimation" + + +def test_서고_사본_산근_목록에_듦_대가_단가_채움(world): + root = world["root"] + folder = library.import_copy(root, "precombo", "system", None, world["key"])["폴더"] + daega = daega_inputs(root) + block = next(b for b in daega["blocks"] if b["id"] == daega["libs"][folder]) + wanted = [ln["key"] for ln in block["lines"] if ln["kind"] == "sangeun"] + base = compute_base(prices.build_inputs(None, linked_resource_keys(root)), {}) + sg = sangeun.build(root, base) + assert [r["key"] for r in sg["sg_list"]] == wanted # 둘 다 산근 · 블록 줄 키 그대로 + assert all(r["sum"] for r in sg["sg_list"]) + assert sg["sg_list"][0]["uses"] == ["서고 서고 조합"] + by_key = {r["key"]: r for r in sg["sg_list"]} + labor = compute_labor(daega, {**base, "gaps": []}, by_key) + row = next(r for r in labor["daega"] if r["key"] == block["id"]) + assert row["sum"] and not row.get("blank") diff --git a/resources/tester/test_m02_tree_paste.py b/resources/tester/test_m02_tree_paste.py index 757936cb8..3c19c8dde 100644 --- a/resources/tester/test_m02_tree_paste.py +++ b/resources/tester/test_m02_tree_paste.py @@ -9,6 +9,7 @@ from __future__ import annotations import json +from pathlib import Path from typing import Any from resources.tester import test_m02_tree as base @@ -158,3 +159,27 @@ def test_항목_추가_창_카드_격자_표_접기(): assert "max-height: calc(100dvh - var(--app-header-h));\n overflow-y: auto;" in css main = (m02 / "M02_MasterTemplete_UI_Main.ts").read_text("utf-8") assert "host.hidden = !host.hidden;" in main and 'title.classList.toggle("is-folded"' in main + + +def test_복사_생성_이름_원래_이름_복사_같은_이름_둘_없음(world): + """701-4 — 복사 생성(대상 `복사이름`) = 「원래 이름 복사」 · 또 하면 「… 복사(1)」 · 그냥 붙여넣기는 그대로.""" + company = _company(world) + wall = company.item(["옹벽"])["key"] + client = _as(world) + body = { + "from_layer": "company", + "대상": [{"경로": [], "키": wall, "복사이름": True}], + "경로": [], + } + first = client.post(PASTE.format("personal"), json=body).json() + second = client.post(PASTE.format("personal"), json=body).json() + plain = client.post(PASTE.format("personal"), json={**body, "대상": [{"경로": [], "키": wall}]}) + names = [r["경로"][-1] for r in plain.json()["items"]] + assert sorted(names) == ["옹벽", "옹벽 복사", "옹벽 복사(1)"] + assert first["알림"]["바뀐이름"] == ["옹벽 복사"] and second["알림"]["바뀐이름"] == [ + "옹벽 복사(1)" + ] + assert len(set(names)) == len(names) # 같은 이름 둘 없음 + root = Path(__file__).resolve().parents[2] + ui = (root / "M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts").read_text("utf-8") + assert "refs: [{ 경로: [], 키: key, 복사이름: true }]" in ui # 복사 생성 길만