feat(B09): 원가계산서 = 아는 금액까지 + 「빈 줄 n · 미완성」 띠 · 인쇄 · 확정은 빈 줄 0 일 때만(PLAN 701-3)
- 설계내역서 sums = 빈 줄이 있어도 아는 비목 합 + blank(파이썬 · TS 짝 · 관급은 그대로) - 총괄 · 원가계산서 표 위 공용 띠(경고) — 탭 표 banner 칸 - [인쇄 미리보기] · [>] = 빈 줄 있으면 알림 뒤 머묾(DRAFT.blocked) · 서버 확정(complete) = 정본으로 다시 셈 · 빈 줄 있으면 409 - 설계내역서 안내 글 줄 고침 · 낡은 시험(sums None · 패널 줄 · [>] 흉내) 갱신 · 시험 test_701_3 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -48,7 +48,7 @@
|
|||||||
묶음 금액 = 자손 항목 합 + 「빈 줄 n」 ·
|
묶음 금액 = 자손 항목 합 + 「빈 줄 n」 ·
|
||||||
구조물 블록 줄 키 꼬리 = 대가 블록 키(대가 번호 밀림에 안 흔들림).
|
구조물 블록 줄 키 꼬리 = 대가 블록 키(대가 번호 밀림에 안 흔들림).
|
||||||
원가계산서로 넘길 합 = `sums`(cost direct 항목의 직접재료비 · 직접노무비 · 산출경비 ·
|
원가계산서로 넘길 합 = `sums`(cost direct 항목의 직접재료비 · 직접노무비 · 산출경비 ·
|
||||||
supply 항목의 관급 순자재대 — 145-4 이음 꼴 · 빈 줄이 있으면 None) ·
|
supply 항목의 관급 순자재대 — 145-4 이음 꼴 · 직접공사 = 아는 금액까지 + `blank`(701-3) · 관급 = 빈 줄이 있으면 None) ·
|
||||||
화면 표시용 = `totals`(아는 금액 합 · 빈 줄 수 · 조달수수료).
|
화면 표시용 = `totals`(아는 금액 합 · 빈 줄 수 · 조달수수료).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -502,14 +502,15 @@ def compute_bill(
|
|||||||
"direct": {**direct, "blank": blank},
|
"direct": {**direct, "blank": blank},
|
||||||
"owner": {"net_material": net, "fee": fee, "total": net + (fee or 0)},
|
"owner": {"net_material": net, "fee": fee, "total": net + (fee or 0)},
|
||||||
}
|
}
|
||||||
# 원가계산서로 넘김(145-4 이음 꼴) — 빈 줄이 있으면 모르는 값 = None
|
# 원가계산서로 넘김(145-4 이음 꼴) — 빈 줄이 있어도 아는 금액까지(701-3 · 띠 「빈 줄 n · 미완성」 ·
|
||||||
known = blank == 0
|
# 인쇄 · 확정은 빈 줄 0 일 때만) · 관급 = 모르는 줄이 있으면 None(그대로)
|
||||||
owner_known = all(row["sum"] is not None for row in owner)
|
owner_known = all(row["sum"] is not None for row in owner)
|
||||||
sums_out = {
|
sums_out = {
|
||||||
"material": direct["material"] if known else None,
|
"material": direct["material"],
|
||||||
"labor": direct["labor"] if known else None,
|
"labor": direct["labor"],
|
||||||
"expense": direct["expense"] if known else None,
|
"expense": direct["expense"],
|
||||||
"supply_net": net if owner_known else None,
|
"supply_net": net if owner_known else None,
|
||||||
|
"blank": blank,
|
||||||
}
|
}
|
||||||
return {"rows": rows, "totals": totals, "sums": sums_out, "gaps": gaps}
|
return {"rows": rows, "totals": totals, "sums": sums_out, "gaps": gaps}
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
* 공종 나무 = 엑셀 꼴 고정(1 토공사 · 2 구조물공 · 3 운반공 · 4 자재대 · 관급자재대(제외금액)) · 공구 안 나눔
|
* 공종 나무 = 엑셀 꼴 고정(1 토공사 · 2 구조물공 · 3 운반공 · 4 자재대 · 관급자재대(제외금액)) · 공구 안 나눔
|
||||||
* 항목 = 서버가 B08 수량으로 짠 줄 + 대비표 「별도」(4 자재대) · 「관급」(관급자재대) 자재 + 조달수수료
|
* 항목 = 서버가 B08 수량으로 짠 줄 + 대비표 「별도」(4 자재대) · 「관급」(관급자재대) 자재 + 조달수수료
|
||||||
* 금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 조달수수료 = ROUNDDOWN(순자재대 × 율 ÷ 100, 0)
|
* 금액 = ROUNDDOWN(단가 × 수량, 0) 비목마다 · 조달수수료 = ROUNDDOWN(순자재대 × 율 ÷ 100, 0)
|
||||||
* 묶음 금액 = 자손 항목 합(빈 줄은 「빈 줄 n」) · 원가계산서로 넘길 합 = sums(빈 줄 있으면 null)
|
* 묶음 금액 = 자손 항목 합(빈 줄은 「빈 줄 n」) · 원가계산서로 넘길 합 = sums(아는 금액까지 + blank · 701-3 · 관급만 빈 줄 있으면 null)
|
||||||
* 단가 = `refPrice(ref)` 하나(dg 대가 목록 · sg 산근 목록 · logic = 서버 sg_hand 산근 · 그 밖 hopyo)
|
* 단가 = `refPrice(ref)` 하나(dg 대가 목록 · sg 산근 목록 · logic = 서버 sg_hand 산근 · 그 밖 hopyo)
|
||||||
* 자유 편집(184) = `user.bill` v2 덮개 {v: 2, rows: [표 차례 · 고친 칸 · 새 줄], gone: [지운 자동 줄]} ·
|
* 자유 편집(184) = `user.bill` v2 덮개 {v: 2, rows: [표 차례 · 고친 칸 · 새 줄], gone: [지운 자동 줄]} ·
|
||||||
* 옛 꼴(qty · 손 줄 after · parent) 그대로 읽음 · 저장 꼴 · 출력 줄 칸(계약) = 짝 .py 머리
|
* 옛 꼴(qty · 손 줄 after · parent) 그대로 읽음 · 저장 꼴 · 출력 줄 칸(계약) = 짝 .py 머리
|
||||||
@@ -472,14 +472,15 @@ export function computeBill(
|
|||||||
direct: { ...direct, blank },
|
direct: { ...direct, blank },
|
||||||
owner: { net_material: net, fee, total: net + (fee || 0) },
|
owner: { net_material: net, fee, total: net + (fee || 0) },
|
||||||
};
|
};
|
||||||
// 원가계산서로 넘김(145-4 이음 꼴) — 빈 줄이 있으면 모르는 값 = null
|
// 원가계산서로 넘김(145-4 이음 꼴) — 빈 줄이 있어도 아는 금액까지(701-3 · 띠 「빈 줄 n · 미완성」 ·
|
||||||
const known = blank === 0;
|
// 인쇄 · 확정은 빈 줄 0 일 때만) · 관급 = 모르는 줄이 있으면 null(그대로)
|
||||||
const ownerKnown = owner.every((row) => row.sum !== null);
|
const ownerKnown = owner.every((row) => row.sum !== null);
|
||||||
const sumsOut = {
|
const sumsOut = {
|
||||||
material: known ? direct.material : null,
|
material: direct.material,
|
||||||
labor: known ? direct.labor : null,
|
labor: direct.labor,
|
||||||
expense: known ? direct.expense : null,
|
expense: direct.expense,
|
||||||
supply_net: ownerKnown ? net : null,
|
supply_net: ownerKnown ? net : null,
|
||||||
|
blank,
|
||||||
};
|
};
|
||||||
return { rows, totals, sums: sumsOut, gaps };
|
return { rows, totals, sums: sumsOut, gaps };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,8 +230,12 @@ async def save_estimation(project_id: UUID, body: dict[str, Any] = Body(...)) ->
|
|||||||
|
|
||||||
@router.post("/{project_id}/estimation/complete")
|
@router.post("/{project_id}/estimation/complete")
|
||||||
async def estimation_complete(project_id: UUID) -> dict[str, Any]:
|
async def estimation_complete(project_id: UUID) -> dict[str, Any]:
|
||||||
"""B09 [>] — 단계 6 완료(요구 300 B1)."""
|
"""B09 [>] — 단계 6 완료(요구 300 B1) · 빈 줄 있으면 409(701-3 확정 = 빈 줄 0 · 서버 정본으로 셈)."""
|
||||||
await _project_root(project_id) # 없는 프로젝트는 404
|
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")
|
await mark_stage(str(project_id), 6, "next")
|
||||||
return {"status": "success", "project_id": str(project_id)}
|
return {"status": "success", "project_id": str(project_id)}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import "./B09_Estimation_UI_Page.css";
|
|||||||
import { API_BASE_URL } from "@config/config_frontend";
|
import { API_BASE_URL } from "@config/config_frontend";
|
||||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||||
import { createText } from "@ui/ui_template_text";
|
import { createText } from "@ui/ui_template_text";
|
||||||
|
import { createBanner } from "@ui/ui_template_banner";
|
||||||
import {
|
import {
|
||||||
createButton,
|
createButton,
|
||||||
hideLoadingOverlay,
|
hideLoadingOverlay,
|
||||||
@@ -38,7 +39,14 @@ import { createCheckPanel } from "./B09_Estimation_UI_Check";
|
|||||||
import { tableOptionsOf, tabTableOf } from "./B09_Estimation_UI_Builders";
|
import { tableOptionsOf, tabTableOf } from "./B09_Estimation_UI_Builders";
|
||||||
import { createDock, linkMode } from "./B09_Estimation_UI_Dock";
|
import { createDock, linkMode } from "./B09_Estimation_UI_Dock";
|
||||||
import { closePopups, openRowPopup } from "./B09_Estimation_UI_Popup";
|
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 { attachRowMenu, openMenuAt, type BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit";
|
||||||
import { jumpRange, jumpTo, levelBar, pickedClass } from "./B09_Estimation_UI_Tabs_Bill_Level";
|
import { jumpRange, jumpTo, levelBar, pickedClass } from "./B09_Estimation_UI_Tabs_Bill_Level";
|
||||||
import {
|
import {
|
||||||
@@ -257,7 +265,9 @@ export function renderB09Estimation(root: HTMLElement): void {
|
|||||||
// 203 안내 글 영역 하나 — 설계내역서 레벨 안내 + 탭 안내 이어 붙임
|
// 203 안내 글 영역 하나 — 설계내역서 레벨 안내 + 탭 안내 이어 붙임
|
||||||
if (level && hint) hint.textContent = `${level.guide.textContent} · ${hint.textContent}`;
|
if (level && hint) hint.textContent = `${level.guide.textContent} · ${hint.textContent}`;
|
||||||
const foot = hint ?? level?.guide;
|
const foot = hint ?? level?.guide;
|
||||||
panel.replaceChildren(table.root, ...(foot ? [foot] : []));
|
// 701-3 띠(원가계산서 「빈 줄 n · 미완성」) = 표 위
|
||||||
|
const banner = spec.banner ? [createBanner({ tone: "warning", text: spec.banner })] : [];
|
||||||
|
panel.replaceChildren(...banner, table.root, ...(foot ? [foot] : []));
|
||||||
if (at >= 0) requestAnimationFrame(() => table.focusRow(at, false)); // 스크롤만(표시 = 빨강)
|
if (at >= 0) requestAnimationFrame(() => table.focusRow(at, false)); // 스크롤만(표시 = 빨강)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -302,6 +312,7 @@ export function renderB09Estimation(root: HTMLElement): void {
|
|||||||
? run("/estimation", "PUT", "원가 사용자 값을 저장했습니다.", { user })
|
? run("/estimation", "PUT", "원가 사용자 값을 저장했습니다.", { user })
|
||||||
: Promise.resolve(true);
|
: Promise.resolve(true);
|
||||||
DRAFT.save = saveChanged; // 초안 [인쇄 미리보기] = 저장 뒤 이동(506-3)
|
DRAFT.save = saveChanged; // 초안 [인쇄 미리보기] = 저장 뒤 이동(506-3)
|
||||||
|
DRAFT.blocked = () => (payload ? unfinished(sheets) : null); // 701-3 인쇄 · 확정 = 빈 줄 0
|
||||||
const refresh = createButton({
|
const refresh = createButton({
|
||||||
label: "업데이트",
|
label: "업데이트",
|
||||||
variant: "ghost",
|
variant: "ghost",
|
||||||
@@ -361,6 +372,8 @@ export function renderB09Estimation(root: HTMLElement): void {
|
|||||||
left.addEventListener("ui-step-nav", (event) => {
|
left.addEventListener("ui-step-nav", (event) => {
|
||||||
if ((event as CustomEvent<number>).detail !== 1 || !goNext || !projectId) return;
|
if ((event as CustomEvent<number>).detail !== 1 || !goNext || !projectId) return;
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
const why = DRAFT.blocked?.();
|
||||||
|
if (why) return void showToast(why, "warning"); // 701-3 확정 = 빈 줄 0 일 때만
|
||||||
void moveStage(projectId, goNext, [
|
void moveStage(projectId, goNext, [
|
||||||
{ name: "원가 저장", run: saveChanged },
|
{ name: "원가 저장", run: saveChanged },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -62,6 +62,12 @@ export const blankRows = (sheets: Row): Row[] =>
|
|||||||
(r) => r.kind === "item" && r.cost === "direct" && r.sum === null,
|
(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) — [위 탭 이름, 아래 탭들] · 아래 탭 하나뿐 = 위 탭 단독 · 첫 아래 탭 = 위 탭 키 */
|
/** 위 탭(181-1) — [위 탭 이름, 아래 탭들] · 아래 탭 하나뿐 = 위 탭 단독 · 첫 아래 탭 = 위 탭 키 */
|
||||||
export const TAB_GROUPS: Array<[string, TabId[]]> = [
|
export const TAB_GROUPS: Array<[string, TabId[]]> = [
|
||||||
["공사원가계산서", ["cost"]],
|
["공사원가계산서", ["cost"]],
|
||||||
@@ -101,6 +107,8 @@ export interface TabTable {
|
|||||||
frozen?: number;
|
frozen?: number;
|
||||||
/** 표 위 안내 글(근거 · 끝수) */
|
/** 표 위 안내 글(근거 · 끝수) */
|
||||||
hint?: string;
|
hint?: string;
|
||||||
|
/** 표 위 띠 알림(공용 띠 · 경고) — 예 원가계산서 「빈 줄 n · 미완성」(701-3) */
|
||||||
|
banner?: string;
|
||||||
/** 칸 누름 → 갈 줄(호표 칸) */
|
/** 칸 누름 → 갈 줄(호표 칸) */
|
||||||
link?: (index: number, key: string) => Link | null;
|
link?: (index: number, key: string) => Link | null;
|
||||||
editable?: (index: number, key: string) => boolean;
|
editable?: (index: number, key: string) => boolean;
|
||||||
@@ -140,6 +148,8 @@ export const DRAFT: {
|
|||||||
next?: () => void;
|
next?: () => void;
|
||||||
/** 고친 값 저장(셸 `saveChanged`) — 성공이면 참 · 초안 단추가 이동 전에 부름. */
|
/** 고친 값 저장(셸 `saveChanged`) — 성공이면 참 · 초안 단추가 이동 전에 부름. */
|
||||||
save?: () => Promise<boolean>;
|
save?: () => Promise<boolean>;
|
||||||
|
/** 인쇄 · 확정 막는 까닭(셸 · 701-3 빈 줄 있으면 글) — 없으면 null */
|
||||||
|
blocked?: () => string | null;
|
||||||
} = { builders: {}, extend: [], panels: [], actions: [] };
|
} = { builders: {}, extend: [], panels: [], actions: [] };
|
||||||
|
|
||||||
/** 201-1 호표 묶음 레벨(보기 전용) — 머리 · 합계(제목 줄) = 1 · 그 밖 = 2 · key 열을 들여씀 */
|
/** 201-1 호표 묶음 레벨(보기 전용) — 머리 · 합계(제목 줄) = 1 · 그 밖 = 2 · key 열을 들여씀 */
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export const buildBill: TabBuild = (ctx): BillTable => {
|
|||||||
hint:
|
hint:
|
||||||
"줄을 우클릭하면 항목 추가 · 줄 삭제 · 옮김 · 단가연결 칸 램프를 누르면 일위대가 · 단가산출 연결" +
|
"줄을 우클릭하면 항목 추가 · 줄 삭제 · 옮김 · 단가연결 칸 램프를 누르면 일위대가 · 단가산출 연결" +
|
||||||
(direct.blank
|
(direct.blank
|
||||||
? ` · 빈 줄 ${direct.blank} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음`
|
? ` · 빈 줄 ${direct.blank} — 계는 아는 금액만 · 원가계산서도 아는 금액까지 · 인쇄 · 확정은 빈 줄 0 일 때만`
|
||||||
: ""),
|
: ""),
|
||||||
editOnDouble: true,
|
editOnDouble: true,
|
||||||
editable: (i, key) => {
|
editable: (i, key) => {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
type TabBuild,
|
type TabBuild,
|
||||||
type TabContext,
|
type TabContext,
|
||||||
type TabTable,
|
type TabTable,
|
||||||
|
unfinished,
|
||||||
} from "./B09_Estimation_UI_Tabs";
|
} from "./B09_Estimation_UI_Tabs";
|
||||||
|
|
||||||
type Num = number | null;
|
type Num = number | null;
|
||||||
@@ -251,6 +252,7 @@ export const buildTotal: TabBuild = (ctx): TabTable => {
|
|||||||
rowClass: (i) => levelClass(rows[i]),
|
rowClass: (i) => levelClass(rows[i]),
|
||||||
level: nameLevel(rows),
|
level: nameLevel(rows),
|
||||||
mergeSame: ["part"],
|
mergeSame: ["part"],
|
||||||
|
banner: unfinished(ctx.sheets) ?? undefined,
|
||||||
hint: "맨 위 내역 줄을 누르면 설계내역서 그 줄로 · 요율 칸에 적으면 사용자 값 · 지우거나 출처 칸을 누르면 자동 값",
|
hint: "맨 위 내역 줄을 누르면 설계내역서 그 줄로 · 요율 칸에 적으면 사용자 값 · 지우거나 출처 칸을 누르면 자동 값",
|
||||||
editable: (i, key) =>
|
editable: (i, key) =>
|
||||||
(key === "rate" && RATE_KEYS.has(String(rows[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]),
|
rowClass: (i) => levelClass(rows[i]),
|
||||||
level: nameLevel(rows),
|
level: nameLevel(rows),
|
||||||
mergeSame: ["group", "part"],
|
mergeSame: ["group", "part"],
|
||||||
|
banner: unfinished(ctx.sheets) ?? undefined,
|
||||||
hint: "고칠 칸은 간접재료비 · 줄을 누르면 총괄 그 줄로 이동",
|
hint: "고칠 칸은 간접재료비 · 줄을 누르면 총괄 그 줄로 이동",
|
||||||
editable: (i, key) => key === "amount" && String(rows[i]?.key) in HAND,
|
editable: (i, key) => key === "amount" && String(rows[i]?.key) in HAND,
|
||||||
onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value),
|
onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value),
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
* B09_Draft_Print_UI.ts
|
* B09_Draft_Print_UI.ts
|
||||||
* 인쇄 미리보기 등록 입구(PLAN 302-1 · 초안) — B09 셸이 `import.meta.glob("./draft/*_UI.ts")` 로 불러올 때
|
* 인쇄 미리보기 등록 입구(PLAN 302-1 · 초안) — B09 셸이 `import.meta.glob("./draft/*_UI.ts")` 로 불러올 때
|
||||||
* 좌측 하단 고정 줄(`DRAFT.actions`)에 [인쇄 미리보기] 단추 → 저장(`DRAFT.save`) 뒤 #/b09-print(`…_Print_Route`)
|
* 좌측 하단 고정 줄(`DRAFT.actions`)에 [인쇄 미리보기] 단추 → 저장(`DRAFT.save`) 뒤 #/b09-print(`…_Print_Route`)
|
||||||
* · 단계 그대로 · 저장 실패면 머묾 ·
|
* · 단계 그대로 · 저장 실패면 머묾 · 빈 줄 있으면 막음(`DRAFT.blocked` · 701-3) ·
|
||||||
* [>] 다음 길(`DRAFT.next` · 저장 · 단계 6 완료 뒤 · 요구 298-2)
|
* [>] 다음 길(`DRAFT.next` · 저장 · 단계 6 완료 뒤 · 요구 298-2)
|
||||||
* draft 폴더째 지우면 단추 · 길 · 서버 길이 함께 사라짐
|
* draft 폴더째 지우면 단추 · 길 · 서버 길이 함께 사라짐
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import type { RoutePath } from "@config/config_frontend";
|
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 { navigateTo } from "../../A00_Common/router";
|
||||||
import { DRAFT } from "../B09_Estimation_UI_Tabs";
|
import { DRAFT } from "../B09_Estimation_UI_Tabs";
|
||||||
import { ROUTE } from "./B09_Draft_Print_Route";
|
import { ROUTE } from "./B09_Draft_Print_Route";
|
||||||
@@ -19,10 +19,13 @@ DRAFT.actions.push(() => {
|
|||||||
const button = createButton({
|
const button = createButton({
|
||||||
label: "인쇄 미리보기",
|
label: "인쇄 미리보기",
|
||||||
variant: "ghost",
|
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(
|
void (DRAFT.save?.() ?? Promise.resolve(true)).then(
|
||||||
(ok) => ok && navigateTo(ROUTE.path as RoutePath),
|
(ok) => ok && navigateTo(ROUTE.path as RoutePath),
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
button.dataset.b09Print = "";
|
button.dataset.b09Print = "";
|
||||||
return button;
|
return button;
|
||||||
|
|||||||
@@ -235,6 +235,8 @@ async function b09(withNext) {
|
|||||||
return { ok: true, status: 200, json: async () => any() };
|
return { ok: true, status: 200, json: async () => any() };
|
||||||
};
|
};
|
||||||
const tabs = any();
|
const tabs = any();
|
||||||
|
let unfinished = null; // 701-3 빈 줄 띠 글 — 있으면 [>] 막힘
|
||||||
|
tabs.unfinished = () => unfinished;
|
||||||
tabs.DRAFT = { builders: {}, extend: [], panels: [], actions: [] };
|
tabs.DRAFT = { builders: {}, extend: [], panels: [], actions: [] };
|
||||||
if (withNext) tabs.DRAFT.next = () => calls.push("next");
|
if (withNext) tabs.DRAFT.next = () => calls.push("next");
|
||||||
const nextBtn = Object.assign(new Node("button"), { disabled: true });
|
const nextBtn = Object.assign(new Node("button"), { disabled: true });
|
||||||
@@ -290,6 +292,9 @@ async function b09(withNext) {
|
|||||||
out.completeFail = await nav(1);
|
out.completeFail = await nav(1);
|
||||||
failOn = null;
|
failOn = null;
|
||||||
out.prev = await nav(-1);
|
out.prev = await nav(-1);
|
||||||
|
unfinished = "빈 줄 2 · 미완성";
|
||||||
|
out.blocked = await nav(1);
|
||||||
|
unfinished = null;
|
||||||
// [초기화]
|
// [초기화]
|
||||||
const reset = dock.children[0];
|
const reset = dock.children[0];
|
||||||
const press = async () => {
|
const press = async () => {
|
||||||
|
|||||||
@@ -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.1"]["sum"] == 361494 + 1119272 and rows["sec:1.1"]["blank"] == 0
|
||||||
assert rows["sec:1"]["sum"] == 361494 + 1119272 + 11003825 # 빈 줄은 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 비고 빈 줄 없음
|
assert rows["sec:1"]["blank"] == 1 and "빈 줄" not in rows["sec:1"]["note"] # 257-2 비고 빈 줄 없음
|
||||||
# 145-4 이음 = {material, labor, expense, supply_net} · 빈 줄 있으면 None
|
# 145-4 이음 = {material, labor, expense, supply_net, blank} · 701-3 빈 줄 있어도 아는 금액까지
|
||||||
assert sheets["bill"]["sums"] == {
|
known = {"material": 2771766, "labor": 7565300, "expense": 2627525, "supply_net": 69474220}
|
||||||
"material": None,
|
assert sheets["bill"]["sums"] == {**known, "blank": 2} # 절토(연암) · 막자갈(단가 없음)
|
||||||
"labor": None,
|
|
||||||
"expense": None,
|
|
||||||
"supply_net": 69474220,
|
|
||||||
}
|
|
||||||
full = {**INPUTS, "bill": {"items": ITEMS[:3]}, "materials": MATERIALS[:-1]}
|
full = {**INPUTS, "bill": {"items": ITEMS[:3]}, "materials": MATERIALS[:-1]}
|
||||||
sums = compute_estimation(full, USER)["bill"]["sums"]
|
sums = compute_estimation(full, USER)["bill"]["sums"]
|
||||||
|
assert sums == {**known, "blank": 0}
|
||||||
assert sums["labor"] == 307104 + 924896 + 6333300 # I9 + I10 + I14
|
assert sums["labor"] == 307104 + 924896 + 6333300 # I9 + I10 + I14
|
||||||
assert sums["material"] == 54390 + 194376 + 2043000 + 480000
|
assert sums["material"] == 54390 + 194376 + 2043000 + 480000
|
||||||
assert sums["expense"] == 2627525
|
assert sums["expense"] == 2627525
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ def test_묶음_탭_단추가_보조_줄에_안내는_아래에():
|
|||||||
def test_잇기_쉘_안내_줄_아래_도구_비움():
|
def test_잇기_쉘_안내_줄_아래_도구_비움():
|
||||||
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
|
||||||
assert (
|
assert (
|
||||||
"panel.replaceChildren(table.root, ...(foot ? [foot] : []))" in shell
|
"panel.replaceChildren(...banner, table.root, ...(foot ? [foot] : []))" in shell # 701-3 띠 = 표 위
|
||||||
) # 229-1 레벨 단추 = 보조 탭 줄 · 안내 글 = 표 아래 · 203 레벨 안내 + 탭 안내 = 한 영역(맨 아래)
|
) # 229-1 레벨 단추 = 보조 탭 줄 · 안내 글 = 표 아래 · 203 레벨 안내 + 탭 안내 = 한 영역(맨 아래)
|
||||||
assert "...(hint ? [hint] : []), table.root" not in shell # 위 자리 없음
|
assert "...(hint ? [hint] : []), table.root" not in shell # 위 자리 없음
|
||||||
assert re.search(r"bars\.draw\(tab\);\s*bars\.setTools\(\[\]\);", shell)
|
assert re.search(r"bars\.draw\(tab\);\s*bars\.setTools\(\[\]\);", shell)
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ def test_B09_다음_저장_완료_인쇄(r):
|
|||||||
"toast:error",
|
"toast:error",
|
||||||
]
|
]
|
||||||
assert b["prev"] == [False, []] # [<] = 공용 틀 몫
|
assert b["prev"] == [False, []] # [<] = 공용 틀 몫
|
||||||
|
assert b["blocked"] == [True, ["toast:warning"]] # 701-3 빈 줄 있으면 저장 · 완료 없이 머묾
|
||||||
|
|
||||||
|
|
||||||
def test_B09_초기화(r):
|
def test_B09_초기화(r):
|
||||||
|
|||||||
@@ -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<string, any>;\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
|
||||||
Reference in New Issue
Block a user