feat(b09): 준공 단계 — 계약금액 | 준공금액(기성 마지막 회차 누계를 옮기기만)
- 준공 별도 계산 규칙 미확인(35번 §3) — 규칙을 짓지 않고 기성 누계를 옮김 · 기성 한 장 불변 - 기성 회차가 없으면 준공금액 비움(0 원으로 안 채움) - 기성 계산 조각(progress_for)을 떼어 준공이 같은 길로 받음 - 기성 간접재료비 확인 대기 닫음 — 예정가격작성기준 제17조·제39조② 근거(브레인 판정) - 준공 탭 파일 · 사전 키(등록은 서브) · 시험 2건 · 전체 1730 통과 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
This commit is contained in:
@@ -17,7 +17,8 @@
|
|||||||
③ 제잡비 줄 = 간접노무비 ~ 부가세 직전(11번 §10) — 간접재료비·관급·분리발주 폐기물은 밖
|
③ 제잡비 줄 = 간접노무비 ~ 부가세 직전(11번 §10) — 간접재료비·관급·분리발주 폐기물은 밖
|
||||||
원문 대조: 예정가격작성기준 제39조②(표준시장단가 장) 간접공사비 「1. 간접노무비」~「10.」 —
|
원문 대조: 예정가격작성기준 제39조②(표준시장단가 장) 간접공사비 「1. 간접노무비」~「10.」 —
|
||||||
간접재료비 없음 · 제17조(원가계산 장)는 간접재료비를 재료비 안에 둠. 원가계산 장에는
|
간접재료비 없음 · 제17조(원가계산 장)는 간접재료비를 재료비 안에 둠. 원가계산 장에는
|
||||||
「간접공사비」 묶음이 없어 기성 범위를 직접 정한 글은 아님.
|
「간접공사비」 묶음이 없어 기성 범위를 직접 정한 글은 아님 — 간접재료비 자리는 두 조문이
|
||||||
|
한 방향이라 ③ 확인 대기 닫음(2026-09-14 브레인 판정).
|
||||||
①′ 계약잡비율 직접입력(사유 필수)이 역산값을 이김 — 계약서에 제잡비율이 명시됨(브레인 판정).
|
①′ 계약잡비율 직접입력(사유 필수)이 역산값을 이김 — 계약서에 제잡비율이 명시됨(브레인 판정).
|
||||||
④ 사정 — 「기성내역서(사정)」 열 이름뿐이라 칸만 받고 계산에 안 씀.
|
④ 사정 — 「기성내역서(사정)」 열 이름뿐이라 칸만 받고 계산에 안 씀.
|
||||||
"""
|
"""
|
||||||
@@ -403,7 +404,7 @@ def progress_sheet(
|
|||||||
if cost_data.indirect_material_krw:
|
if cost_data.indirect_material_krw:
|
||||||
notes.append(
|
notes.append(
|
||||||
f"계약 간접재료비 {cost_data.indirect_material_krw:,.0f}원은 기성 제잡비 줄 밖"
|
f"계약 간접재료비 {cost_data.indirect_material_krw:,.0f}원은 기성 제잡비 줄 밖"
|
||||||
"(원자료 「간접노무비부터 부가세 직전」) — 확인 대기"
|
"(예정가격작성기준 제17조 재료비 안 · 제39조② 간접공사비 목록 밖)"
|
||||||
)
|
)
|
||||||
unknown = sorted(set(overrides) - set(ratios))
|
unknown = sorted(set(overrides) - set(ratios))
|
||||||
if unknown:
|
if unknown:
|
||||||
@@ -419,3 +420,44 @@ def progress_sheet(
|
|||||||
"vat": rounds[current - 1] if current else None,
|
"vat": rounds[current - 1] if current else None,
|
||||||
"notes": notes,
|
"notes": notes,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def completion_sheet(progress: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""준공 — 「계약금액 | 준공금액」 두 열(27번 §5 · 35번 §3 `준공조서(을/병)`).
|
||||||
|
|
||||||
|
⚠ 준공 별도 계산 규칙은 **미확인** — 지어내지 않고 기성 **마지막 회차 누계**를 옮기기만 함.
|
||||||
|
기성 한 장(`progress_sheet`, 마지막 회차)을 읽기만 하고 고치지 않음. 회차가 없으면 옮길
|
||||||
|
누계가 없어 준공금액을 비워 둠(0 원으로 안 채움).
|
||||||
|
"""
|
||||||
|
has_rounds = bool(progress.get("round"))
|
||||||
|
|
||||||
|
def moved(value: Any) -> str | None:
|
||||||
|
return value if has_rounds and value is not None else None
|
||||||
|
|
||||||
|
rows = [
|
||||||
|
{
|
||||||
|
**{key: row.get(key) for key in ("item_no", "name", "spec", "unit", "is_group")},
|
||||||
|
"contract_amount_krw": row.get("contract_amount_krw"),
|
||||||
|
"completion_amount_krw": moved(row.get("progress_cumulative_amount_krw")),
|
||||||
|
}
|
||||||
|
for row in progress["rows"]
|
||||||
|
if row.get("in_bill", True)
|
||||||
|
]
|
||||||
|
lines = [
|
||||||
|
{
|
||||||
|
"key": line["key"],
|
||||||
|
"name": line["name"],
|
||||||
|
"total": total,
|
||||||
|
"contract_krw": line["contract_krw"],
|
||||||
|
"completion_krw": moved(line.get("cumulative_krw")),
|
||||||
|
"completion_pct": line.get("cumulative_pct") if has_rounds else None,
|
||||||
|
}
|
||||||
|
for lines_of, total in ((progress["items"], False), (progress["summary"], True))
|
||||||
|
for line in lines_of
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"rows": rows,
|
||||||
|
"lines": lines,
|
||||||
|
"from_round": progress.get("round") or 0,
|
||||||
|
"notes": [] if has_rounds else ["기성 회차 없음 — 옮길 누계가 없어 준공금액 비움"],
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from B09_Estimation.B09_Estimation_Progress import (
|
|||||||
SETTINGS_KEY,
|
SETTINGS_KEY,
|
||||||
VAT_CHOICES,
|
VAT_CHOICES,
|
||||||
clean_settings,
|
clean_settings,
|
||||||
|
completion_sheet,
|
||||||
progress_sheet,
|
progress_sheet,
|
||||||
)
|
)
|
||||||
from B09_Estimation.B09_Estimation_Rates import RateLookupError
|
from B09_Estimation.B09_Estimation_Rates import RateLookupError
|
||||||
@@ -34,9 +35,10 @@ def _cut_options(first: str) -> list[dict[str, str]]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}/estimation/progress")
|
async def progress_for(
|
||||||
async def get_progress(project_id: UUID, round: int | None = None) -> JSONResponse:
|
project_id: UUID, round: int | None
|
||||||
"""기성 한 장 — `round` 회차(1부터)를 금회로 · 없으면 마지막 회차."""
|
) -> tuple[dict[str, Any], dict[str, Any]] | JSONResponse:
|
||||||
|
"""(기성 저장값, 기성 한 장) — 못 서면 그 까닭 응답. 준공도 이 길로 기성본을 받음."""
|
||||||
from B09_Estimation.B09_Estimation_Router_Contract import contract_for
|
from B09_Estimation.B09_Estimation_Router_Contract import contract_for
|
||||||
from B09_Estimation.B09_Estimation_Router_CostSheet import cost_from_bill
|
from B09_Estimation.B09_Estimation_Router_CostSheet import cost_from_bill
|
||||||
from common_util.common_util_project_settings import estimation_settings
|
from common_util.common_util_project_settings import estimation_settings
|
||||||
@@ -52,7 +54,34 @@ async def get_progress(project_id: UUID, round: int | None = None) -> JSONRespon
|
|||||||
except RateLookupError as error:
|
except RateLookupError as error:
|
||||||
return JSONResponse(status_code=422, content={"status": "error", "message": str(error)})
|
return JSONResponse(status_code=422, content={"status": "error", "message": str(error)})
|
||||||
stored, _ = clean_settings(dict(estimation_settings(root).get(SETTINGS_KEY) or {}))
|
stored, _ = clean_settings(dict(estimation_settings(root).get(SETTINGS_KEY) or {}))
|
||||||
sheet = progress_sheet(contract["rows"], cost_data, cost_result, stored, round)
|
return stored, progress_sheet(contract["rows"], cost_data, cost_result, stored, round)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/estimation/completion")
|
||||||
|
async def get_completion(project_id: UUID) -> JSONResponse:
|
||||||
|
"""준공 한 장 — 계약금액 | 준공금액(기성 마지막 회차 누계를 옮김)."""
|
||||||
|
found = await progress_for(project_id, None)
|
||||||
|
if isinstance(found, JSONResponse):
|
||||||
|
return found
|
||||||
|
return JSONResponse(
|
||||||
|
content={
|
||||||
|
"status": "success",
|
||||||
|
**completion_sheet(found[1]),
|
||||||
|
"limit_note": (
|
||||||
|
"준공 별도 계산 규칙 미확인(35번 §3) — 기성 마지막 회차 누계를 옮기기만 함 · "
|
||||||
|
"준공 표본 0건"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/estimation/progress")
|
||||||
|
async def get_progress(project_id: UUID, round: int | None = None) -> JSONResponse:
|
||||||
|
"""기성 한 장 — `round` 회차(1부터)를 금회로 · 없으면 마지막 회차."""
|
||||||
|
found = await progress_for(project_id, round)
|
||||||
|
if isinstance(found, JSONResponse):
|
||||||
|
return found
|
||||||
|
stored, sheet = found
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
content={
|
content={
|
||||||
"status": "success",
|
"status": "success",
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
* B09_Estimation_UI_Tab_Completion.ts
|
||||||
|
* 준공 탭 — STmate 「준공조서(을/병)」의 「계약금액 | 준공금액」 두 열을 본뜸 (PLAN 12장 · 랩탑 메인).
|
||||||
|
*
|
||||||
|
* - 위 = 제잡비 줄 + 합계 줄(직접공사비 · 제잡비 계 · 공급가액 · 부가세 · 기성금액).
|
||||||
|
* - 아래 = 공종별 계약금액 | 준공금액.
|
||||||
|
* - ⚠ 준공 별도 계산 규칙은 미확인 — 서버(`/estimation/completion`)가 기성 마지막 회차 누계를
|
||||||
|
* 옮기기만 함. 입력 칸·[저장] 없음(기성 탭에서 고침).
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||||
|
import { API_BASE_URL } from "@config/config_frontend";
|
||||||
|
import type { B09Tab, B09TabContext } from "./B09_Estimation_UI_Shell_Types";
|
||||||
|
|
||||||
|
function L(key: keyof typeof ui_locales): string {
|
||||||
|
return ui_locales[key][currentLanguageIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompletionRow {
|
||||||
|
item_no: string;
|
||||||
|
name: string;
|
||||||
|
spec: string | null;
|
||||||
|
unit: string | null;
|
||||||
|
is_group: boolean;
|
||||||
|
contract_amount_krw: string | null;
|
||||||
|
completion_amount_krw: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompletionLine {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
total: boolean;
|
||||||
|
contract_krw: string;
|
||||||
|
completion_krw: string | null;
|
||||||
|
completion_pct: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CompletionDto {
|
||||||
|
status: string;
|
||||||
|
message?: string;
|
||||||
|
rows: CompletionRow[];
|
||||||
|
lines: CompletionLine[];
|
||||||
|
from_round: number;
|
||||||
|
notes: string[];
|
||||||
|
limit_note: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STYLE_ID = "b09-completion-styles";
|
||||||
|
function injectStyles(): void {
|
||||||
|
if (document.getElementById(STYLE_ID)) return;
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.id = STYLE_ID;
|
||||||
|
style.textContent = `
|
||||||
|
.b09cp { display: flex; flex-direction: column; gap: 8px; height: 100%; min-height: 0; }
|
||||||
|
.b09cp__meta { font-size: 12px; color: var(--color-text-secondary); }
|
||||||
|
.b09cp__warn { font-size: 12px; color: var(--color-warning-text, #8a5a00); }
|
||||||
|
.b09cp__scroll { flex: 1; overflow: auto; min-height: 0; display: flex; flex-direction: column; gap: 12px; }
|
||||||
|
.b09cp__table { border-collapse: collapse; font-size: 12px; white-space: nowrap; }
|
||||||
|
.b09cp__table th, .b09cp__table td { border: 1px solid var(--color-border); padding: 2px 6px; }
|
||||||
|
.b09cp__table td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.b09cp__table tr.is-total td, .b09cp__table tr.is-group td { font-weight: 600; }
|
||||||
|
`;
|
||||||
|
document.head.append(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
function el<K extends keyof HTMLElementTagNameMap>(
|
||||||
|
tag: K,
|
||||||
|
className = "",
|
||||||
|
text = "",
|
||||||
|
): HTMLElementTagNameMap[K] {
|
||||||
|
const node = document.createElement(tag);
|
||||||
|
if (className) node.className = className;
|
||||||
|
if (text) node.textContent = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function won(value: string | null | undefined): string {
|
||||||
|
if (value === null || value === undefined || value === "") return "";
|
||||||
|
const n = Number(value);
|
||||||
|
return Number.isFinite(n) ? n.toLocaleString("ko-KR") : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCompletion(projectId: string): Promise<CompletionDto> {
|
||||||
|
const response = await fetch(
|
||||||
|
`${API_BASE_URL}/projects/${encodeURIComponent(projectId)}/estimation/completion`,
|
||||||
|
{ credentials: "include" },
|
||||||
|
);
|
||||||
|
const body = (await response.json()) as CompletionDto;
|
||||||
|
if (!response.ok) throw new Error(body.message ?? `HTTP ${response.status}`);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function table(headers: string[]): HTMLTableElement {
|
||||||
|
const node = el("table", "b09cp__table");
|
||||||
|
const head = el("tr");
|
||||||
|
for (const label of headers) head.append(el("th", "", label));
|
||||||
|
node.append(head);
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function draw(ctx: B09TabContext, data: CompletionDto): void {
|
||||||
|
const wrap = el("div", "b09cp");
|
||||||
|
wrap.append(el("div", "b09cp__warn", `⚠ ${data.limit_note}`));
|
||||||
|
for (const note of data.notes) wrap.append(el("div", "b09cp__warn", `⚠ ${note}`));
|
||||||
|
wrap.append(
|
||||||
|
el(
|
||||||
|
"div",
|
||||||
|
"b09cp__meta",
|
||||||
|
data.from_round
|
||||||
|
? `준공금액 = 기성 ${data.from_round}회(마지막 회차) 누계 — 고칠 곳은 기성 탭`
|
||||||
|
: "기성 탭에서 회차를 넣으면 그 누계가 준공금액으로 옮겨짐",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const scroll = el("div", "b09cp__scroll");
|
||||||
|
|
||||||
|
const summary = table(["명칭", "계약금액", "준공금액", "준공(%)"]);
|
||||||
|
for (const line of data.lines) {
|
||||||
|
const tr = el("tr", line.total ? "is-total" : "");
|
||||||
|
tr.append(
|
||||||
|
el("td", "", line.name),
|
||||||
|
el("td", "num", won(line.contract_krw)),
|
||||||
|
el("td", "num", won(line.completion_krw)),
|
||||||
|
el("td", "num", line.completion_pct ? `${line.completion_pct}%` : ""),
|
||||||
|
);
|
||||||
|
summary.append(tr);
|
||||||
|
}
|
||||||
|
const bill = table(["공종번호", "명칭", "규격", "단위", "계약금액", "준공금액"]);
|
||||||
|
for (const row of data.rows) {
|
||||||
|
const tr = el("tr", row.is_group ? "is-group" : "");
|
||||||
|
tr.append(
|
||||||
|
el("td", "", row.item_no),
|
||||||
|
el("td", "", row.name),
|
||||||
|
el("td", "", row.spec ?? ""),
|
||||||
|
el("td", "", row.unit ?? ""),
|
||||||
|
el("td", "num", won(row.contract_amount_krw)),
|
||||||
|
el("td", "num", won(row.completion_amount_krw)),
|
||||||
|
);
|
||||||
|
bill.append(tr);
|
||||||
|
}
|
||||||
|
const top = el("div");
|
||||||
|
top.append(el("strong", "", "준공조서 — 계약금액 | 준공금액"), summary);
|
||||||
|
const bottom = el("div");
|
||||||
|
bottom.append(el("strong", "", "공종별"), bill);
|
||||||
|
scroll.append(top, bottom);
|
||||||
|
wrap.append(scroll);
|
||||||
|
ctx.body.append(wrap);
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(ctx: B09TabContext): void {
|
||||||
|
injectStyles();
|
||||||
|
if (!ctx.projectId) {
|
||||||
|
ctx.body.append(el("div", "b09cp__meta", "프로젝트를 고르세요"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.body.replaceChildren(
|
||||||
|
el("div", "b09cp__meta", `${L("B09_Estimation_Tab_Completion")} 계산 중…`),
|
||||||
|
);
|
||||||
|
fetchCompletion(ctx.projectId)
|
||||||
|
.then((data) => {
|
||||||
|
ctx.body.replaceChildren();
|
||||||
|
draw(ctx, data);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
ctx.body.replaceChildren(
|
||||||
|
el(
|
||||||
|
"div",
|
||||||
|
"b09cp__warn",
|
||||||
|
`준공을 세우지 못함 — ${error instanceof Error ? error.message : ""}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const completionTab: B09Tab = {
|
||||||
|
key: "completion",
|
||||||
|
label: () => L("B09_Estimation_Tab_Completion"),
|
||||||
|
render,
|
||||||
|
};
|
||||||
@@ -20,7 +20,11 @@ ROOT = Path(__file__).resolve().parents[2]
|
|||||||
sys.path.insert(0, str(ROOT))
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
from B09_Estimation.B09_Estimation_Engine_Cost import CostLine, CostResult # noqa: E402
|
from B09_Estimation.B09_Estimation_Engine_Cost import CostLine, CostResult # noqa: E402
|
||||||
from B09_Estimation.B09_Estimation_Progress import clean_settings, progress_sheet # noqa: E402
|
from B09_Estimation.B09_Estimation_Progress import ( # noqa: E402
|
||||||
|
clean_settings,
|
||||||
|
completion_sheet,
|
||||||
|
progress_sheet,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _contract_row(item_no: str, qty: str, unit: tuple[int, int, int]) -> dict:
|
def _contract_row(item_no: str, qty: str, unit: tuple[int, int, int]) -> dict:
|
||||||
@@ -201,6 +205,25 @@ def test_이윤금액_직접입력과_사정_칸() -> None:
|
|||||||
assert row["progress_current_amount_krw"] == "140000"
|
assert row["progress_current_amount_krw"] == "140000"
|
||||||
|
|
||||||
|
|
||||||
|
def test_준공은_마지막_회차_누계를_옮기기만() -> None:
|
||||||
|
progress = _sheet()
|
||||||
|
before = copy.deepcopy(progress)
|
||||||
|
done = completion_sheet(progress)
|
||||||
|
assert progress == before # 기성 한 장 불변
|
||||||
|
row = {r["item_no"]: r for r in done["rows"]}["1.1"]
|
||||||
|
assert (row["contract_amount_krw"], row["completion_amount_krw"]) == ("350000", "245000")
|
||||||
|
lines = {line["key"]: line for line in done["lines"]}
|
||||||
|
summary = {s["key"]: s for s in progress["summary"]}
|
||||||
|
assert lines["total"]["completion_krw"] == summary["total"]["cumulative_krw"]
|
||||||
|
assert lines["indirect_labor_cost"]["completion_krw"] == "12750" and done["from_round"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_기성_회차가_없으면_준공금액을_비운다() -> None:
|
||||||
|
done = completion_sheet(_sheet({"rounds": []}))
|
||||||
|
assert all(line["completion_krw"] is None for line in done["lines"])
|
||||||
|
assert done["notes"] and done["from_round"] == 0
|
||||||
|
|
||||||
|
|
||||||
def test_틀린_칸은_거른다() -> None:
|
def test_틀린_칸은_거른다() -> None:
|
||||||
_, errors = clean_settings(
|
_, errors = clean_settings(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,4 +11,5 @@ export const ui_locales_b4 = {
|
|||||||
B09_Estimation_Tab_Contract: ["계약내역", "Contract Bill"],
|
B09_Estimation_Tab_Contract: ["계약내역", "Contract Bill"],
|
||||||
B09_Estimation_Tab_Execution: ["실행예산", "Execution Budget"],
|
B09_Estimation_Tab_Execution: ["실행예산", "Execution Budget"],
|
||||||
B09_Estimation_Tab_Progress: ["기성", "Progress Payment"],
|
B09_Estimation_Tab_Progress: ["기성", "Progress Payment"],
|
||||||
|
B09_Estimation_Tab_Completion: ["준공", "Completion"],
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
Reference in New Issue
Block a user