Merge remote-tracking branch 'origin/dev' into sub_laptop_6

This commit is contained in:
2026-10-31 13:49:19 +09:00
22 changed files with 285 additions and 34 deletions
+7 -6
View File
@@ -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}
+7 -6
View File
@@ -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 };
}
+6 -2
View File
@@ -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)}
+7 -1
View File
@@ -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))
+15 -2
View File
@@ -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<number>).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 },
{
+10
View File
@@ -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<boolean>;
/** 인쇄 · 확정 막는 까닭(셸 · 701-3 빈 줄 있으면 글) — 없으면 null */
blocked?: () => string | null;
} = { builders: {}, extend: [], panels: [], actions: [] };
/** 201-1 호표 묶음 레벨(보기 전용) — 머리 · 합계(제목 줄) = 1 · 그 밖 = 2 · key 열을 들여씀 */
@@ -69,7 +69,7 @@ export const buildBill: TabBuild = (ctx): BillTable => {
hint:
"줄을 우클릭하면 항목 추가 · 줄 삭제 · 옮김 · 단가연결 칸 램프를 누르면 일위대가 · 단가산출 연결" +
(direct.blank
? ` · 빈 줄 ${direct.blank} — 계는 아는 금액만 · 원가계산서는 빈 줄이 없을 때만 받음`
? ` · 빈 줄 ${direct.blank} — 계는 아는 금액만 · 원가계산서도 아는 금액까지 · 인쇄 · 확정은 빈 줄 0 일 때만`
: ""),
editOnDouble: true,
editable: (i, key) => {
@@ -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),
+7 -4
View File
@@ -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;