diff --git a/B09_Estimation/B09_Estimation_Calc_Total.py b/B09_Estimation/B09_Estimation_Calc_Total.py index b54e624a1..de6deb648 100644 --- a/B09_Estimation/B09_Estimation_Calc_Total.py +++ b/B09_Estimation/B09_Estimation_Calc_Total.py @@ -19,7 +19,14 @@ 산업안전보건관리비 = A(관급 순자재대 ÷ 1.1 포함) · B(관급 빼고 × 1.2) 중 작은 쪽 · 이윤 = ROUNDDOWN((다 − 재료비) × 율, 0) − 보정 · 부가세 = ROUNDDOWN(공급가액 × 율, 0) + 조정. 빈칸 규칙 = 요율(사용자 · 자동 둘 다 없음) · 비목 합이 빈칸이면 그 줄 빈칸(0 을 채우지 않음) · 그 줄을 쓰는 소계도 빈칸 · -손입력(기초액 · 보정 · 조정 · 간접재료비 · 작업설) 빈칸 = 0 · 엑셀에 없는 현행 비목 = 줄만(늘 빈칸). +손입력(기초액 · 보정 · 조정 · 간접재료비 · 작업설) 빈칸 = 0. +현행 비목 다섯(PLAN 708-2 · 엑셀 총괄에 없음 · 경쟁사 둘 다 셈) = STmate 일반형식 식 · 자리 + (지식DB `STmate 분석/20_분석/12_원가계산_4형식과_RATE표_복원.md` 일반 · 수공 경비 차례 · + `35_형식과_단계의_공통과_차이.md` 밑수 표) — 경비 묶음 안 · 나.소계(순공사원가)에 듦 · + 퇴직공제부금비 = 직접노무비 × 율 · 임금채권 · 석면 = (직접노무비 + 간접노무비) × 율 · + 하도급대금지급보증수수료 = 순공사비 × 율 · 공사이행보증수수료 = 금액 손입력(`performance_fee` · + STmate 식 = 공종 · 기간 표라 뒤로) · 자동 요율 없음 · 요율 빈칸 = 해당 없음(0 · 빈칸 아님). +요율 출처 = 사용자 값 중 `rates_imported` 키 = 「import」(707-2 불러옴). """ from __future__ import annotations @@ -29,7 +36,8 @@ from typing import Any from B09_Estimation.B09_Estimation_Calc_Base import num, rounddown, roundup, total from common_util.common_util_time import kst_today -#: 제비율 줄 (키, 이름, M01 요율 후보 키, 산식 글) — 엑셀 총괄 49~58 차례 · 나.소계에 듦 +#: 제비율 줄 (키, 이름, M01 요율 후보 키, 산식 글) — 엑셀 총괄 49~58 차례 + +#: 현행 비목(STmate 경비 차례) · 나.소계에 듦 RATE_LINES: tuple[tuple[str, str, str, str], ...] = ( ("indirect_labor", "간접노무비", "RT000008", "직접노무비 × 율"), ("accident", "산재보험료", "RT000001", "(직접노무비 + 간접노무비) × 율"), @@ -37,19 +45,18 @@ RATE_LINES: tuple[tuple[str, str, str, str], ...] = ( ("health", "건강보험료", "RT000003", "직접노무비 × 율"), ("care", "노인장기요양보험료", "RT000005", "건강보험료 × 율"), ("pension", "연금보험료", "RT000004", "직접노무비 × 율"), + ("retirement", "퇴직공제부금비", "RT000011", "직접노무비 × 율"), ("safety", "산업안전보건관리비", "RT000006", "A · B 중 작은 쪽 + 기초액"), ("other", "기타경비", "RT000009", "(직접노무비 + 간접노무비 + 재료비) × 율"), + ("performance", "공사이행보증수수료", "RT000016", "금액 직접 입력"), + ("subcontract", "하도급대금지급보증수수료", "RT000014", "순공사비 × 율"), ("environment", "환경보전비", "RT000010", "순공사비 × 율"), ("machine", "건설기계대여금지급보증", "RT000015", "순공사비 × 율"), + ("asbestos", "석면피해구제분담금", "RT000013", "(직접노무비 + 간접노무비) × 율"), + ("wage_claim", "임금채권보장분담금", "RT000012", "(직접노무비 + 간접노무비) × 율"), ) -#: 엑셀에 없는 현행 비목 — 줄만 두고 빈칸(밑수 · 고르는 값 미정 · 나.소계에 안 듦) -CURRENT_ONLY: tuple[tuple[str, str, str], ...] = ( - ("retirement", "퇴직공제부금비", "RT000011"), - ("wage_claim", "임금채권보장분담금", "RT000012"), - ("asbestos", "석면피해구제분담금", "RT000013"), - ("subcontract", "하도급대금지급보증수수료", "RT000014"), - ("performance", "공사이행보증수수료", "RT000016"), -) +#: 현행 비목 — 요율 빈칸 = 해당 없음(0) · 공사이행보증 = 금액 손입력(율 칸 없음) +CURRENT_ONLY = ("retirement", "subcontract", "asbestos", "wage_claim", "performance") #: 소계 아래 줄 (키, 이름, 후보 키, 산식 글) TAIL_LINES: tuple[tuple[str, str, str, str], ...] = ( ("overhead", "일반관리비", "RT000017", "나.소계 × 율"), @@ -135,6 +142,7 @@ def compute_total( sums = sums if isinstance(sums, dict) else {} est = estimate if isinstance(estimate, dict) else {} rates_in = est.get("rates") if isinstance(est.get("rates"), dict) else {} + imported = est.get("rates_imported") if isinstance(est.get("rates_imported"), list) else [] def hand(key: str) -> float: return num(est.get(key)) or 0.0 @@ -164,7 +172,7 @@ def compute_total( mine, got = num(rates_in.get(key)), auto.get(key) or {} rates[key] = { "rate": got.get("rate") if mine is None else mine, - "from": "user" + "from": ("import" if key in imported else "user") if mine is not None else ("auto" if got.get("rate") is not None else None), "auto": got.get("rate"), @@ -193,6 +201,14 @@ def compute_total( line["other"] = _pct(total([labor, line["indirect_labor"], material]), rate["other"]) line["environment"] = _pct(direct, rate["environment"]) line["machine"] = _pct(direct, rate["machine"]) + line["retirement"] = _pct(labor, rate["retirement"]) + line["subcontract"] = _pct(direct, rate["subcontract"]) + line["asbestos"] = _pct(total_labor, rate["asbestos"]) + line["wage_claim"] = _pct(total_labor, rate["wage_claim"]) + for key in CURRENT_ONLY: # 요율 빈칸 = 해당 없음 + if rate[key] is None: + line[key] = 0.0 + line["performance"] = hand("performance_fee") net_cost = total([direct, *(line[key] for key, *_ in RATE_LINES)]) # 나. 순공사원가 line["overhead"] = _pct(net_cost, rate["overhead"]) da = total([net_cost, line["overhead"]]) # 다 @@ -220,8 +236,7 @@ def compute_total( "indirect_labor": line["indirect_labor"], "labor": total_labor, "expense": expense, - **{key: line[key] for key in ("accident", "employment", "health", "care", "pension")}, - **{key: line[key] for key in ("safety", "other", "environment", "machine")}, + **{key: line[key] for key, *_ in RATE_LINES[1:]}, "expense_sum": total( [expense, *(line[key] for key, *_ in RATE_LINES if key != "indirect_labor")] ), diff --git a/B09_Estimation/B09_Estimation_Calc_Total.ts b/B09_Estimation/B09_Estimation_Calc_Total.ts index 564c05429..dfe5bad3b 100644 --- a/B09_Estimation/B09_Estimation_Calc_Total.ts +++ b/B09_Estimation/B09_Estimation_Calc_Total.ts @@ -9,13 +9,14 @@ * 입력 sums = 설계내역서 비목 합(145-3 `sheets.bill.sums`) · estimate = 사용자 값(`user.estimate`) · * table = M01 요율 압축 표(payload `rate_table`) · year = 연금 해 — 자동 고르기(166-1) = 파이썬 짝 머리 글 * 식 · 끝수 = 엑셀 총괄 49~67 그대로 · 빈칸 규칙 = 파이썬 짝 머리 글 그대로. + * 현행 비목 다섯(708-2) = STmate 일반형식 식 · 자리(경비 묶음 안 · 나.소계에 듦) — 근거 = 파이썬 짝 머리 글. * ========================================================================== */ import { num, rounddown, roundup, total, type Row } from "./B09_Estimation_Calc_Base"; type Num = number | null; -/** 제비율 줄 [키, 이름, M01 요율 후보 키, 산식 글] — 엑셀 총괄 49~58 차례 · 나.소계에 듦 */ +/** 제비율 줄 [키, 이름, M01 요율 후보 키, 산식 글] — 엑셀 총괄 49~58 차례 + 현행 비목 · 나.소계에 듦 */ export const RATE_LINES: Array<[string, string, string, string]> = [ ["indirect_labor", "간접노무비", "RT000008", "직접노무비 × 율"], ["accident", "산재보험료", "RT000001", "(직접노무비 + 간접노무비) × 율"], @@ -23,19 +24,18 @@ export const RATE_LINES: Array<[string, string, string, string]> = [ ["health", "건강보험료", "RT000003", "직접노무비 × 율"], ["care", "노인장기요양보험료", "RT000005", "건강보험료 × 율"], ["pension", "연금보험료", "RT000004", "직접노무비 × 율"], + ["retirement", "퇴직공제부금비", "RT000011", "직접노무비 × 율"], ["safety", "산업안전보건관리비", "RT000006", "A · B 중 작은 쪽 + 기초액"], ["other", "기타경비", "RT000009", "(직접노무비 + 간접노무비 + 재료비) × 율"], + ["performance", "공사이행보증수수료", "RT000016", "금액 직접 입력"], + ["subcontract", "하도급대금지급보증수수료", "RT000014", "순공사비 × 율"], ["environment", "환경보전비", "RT000010", "순공사비 × 율"], ["machine", "건설기계대여금지급보증", "RT000015", "순공사비 × 율"], + ["asbestos", "석면피해구제분담금", "RT000013", "(직접노무비 + 간접노무비) × 율"], + ["wage_claim", "임금채권보장분담금", "RT000012", "(직접노무비 + 간접노무비) × 율"], ]; -/** 엑셀에 없는 현행 비목 — 줄만 두고 빈칸 */ -export const CURRENT_ONLY: Array<[string, string, string]> = [ - ["retirement", "퇴직공제부금비", "RT000011"], - ["wage_claim", "임금채권보장분담금", "RT000012"], - ["asbestos", "석면피해구제분담금", "RT000013"], - ["subcontract", "하도급대금지급보증수수료", "RT000014"], - ["performance", "공사이행보증수수료", "RT000016"], -]; +/** 현행 비목 — 요율 빈칸 = 해당 없음(0) · 공사이행보증 = 금액 손입력(율 칸 없음) */ +export const CURRENT_ONLY = ["retirement", "subcontract", "asbestos", "wage_claim", "performance"]; /** 소계 아래 줄 */ export const TAIL_LINES: Array<[string, string, string, string]> = [ ["overhead", "일반관리비", "RT000017", "나.소계 × 율"], @@ -113,6 +113,7 @@ export function computeTotal( const s: Row = sums && typeof sums === "object" ? sums : {}; const est: Row = estimate && typeof estimate === "object" ? estimate : {}; const ratesIn: Row = est.rates && typeof est.rates === "object" ? est.rates : {}; + const imported: unknown[] = Array.isArray(est.rates_imported) ? est.rates_imported : []; const hand = (key: string): number => num(est[key]) || 0; const material = num(s.material); @@ -138,7 +139,14 @@ export function computeTotal( const autoRate: Num = got.rate ?? null; rates[key] = { rate: mine === null ? autoRate : mine, - from: mine !== null ? "user" : autoRate !== null ? "auto" : null, + from: + mine !== null + ? imported.includes(key) + ? "import" + : "user" + : autoRate !== null + ? "auto" + : null, auto: autoRate, why: got.why || "", }; @@ -168,6 +176,12 @@ export function computeTotal( line.other = pct(total([labor, line.indirect_labor, material]), rate.other); line.environment = pct(direct, rate.environment); line.machine = pct(direct, rate.machine); + line.retirement = pct(labor, rate.retirement); + line.subcontract = pct(direct, rate.subcontract); + line.asbestos = pct(totalLabor, rate.asbestos); + line.wage_claim = pct(totalLabor, rate.wage_claim); + for (const key of CURRENT_ONLY) if (rate[key] === null) line[key] = 0; // 요율 빈칸 = 해당 없음 + line.performance = hand("performance_fee"); const netCost = total([direct, ...RATE_LINES.map(([key]) => line[key])]); line.overhead = pct(netCost, rate.overhead); const da = total([netCost, line.overhead]); @@ -193,15 +207,7 @@ export function computeTotal( indirect_labor: line.indirect_labor, labor: totalLabor, expense, - accident: line.accident, - employment: line.employment, - health: line.health, - care: line.care, - pension: line.pension, - safety: line.safety, - other: line.other, - environment: line.environment, - machine: line.machine, + ...Object.fromEntries(RATE_LINES.slice(1).map(([key]) => [key, line[key]])), expense_sum: total([ expense, ...RATE_LINES.filter(([key]) => key !== "indirect_labor").map(([key]) => line[key]), diff --git a/B09_Estimation/B09_Estimation_Import_Rates.py b/B09_Estimation/B09_Estimation_Import_Rates.py index 014687a04..829b2bf92 100644 --- a/B09_Estimation/B09_Estimation_Import_Rates.py +++ b/B09_Estimation/B09_Estimation_Import_Rates.py @@ -12,11 +12,11 @@ indirect_material · scrap = 그 줄 금액 · profit_adjust = ROUNDDOWN((순공사원가 − 재료비 + 일반관리비) × 율) − 엑셀 이윤 · vat_adjust = 엑셀 부가세 − ROUNDDOWN(총원가 × 율) · - procurement_fee_pct = 관급 조달수수료 ÷ 순자재대(소수 2~4 자리 중 수수료를 그대로 내는 첫 값). + procurement_fee_pct = 관급 조달수수료 ÷ 순자재대(소수 2~4 자리 중 수수료를 그대로 내는 첫 값) · + performance_fee = 공사이행보증수수료 줄 금액(708-2 · 율 셈 없음). 대조(`check`) = 엑셀 비목 합 + 실은 값으로 우리 식(`compute_total`)을 돌려 줄마다 엑셀 금액과 견줌 — 1 원 넘게 다른 줄 = 「식 다름」(노인장기요양 · 산업안전보건관리비 A/B 등 식 꼴이 다른 비목) · - 우리 표에 없는 행(폐기물처리 · 국가지점번호판 …) · 셈 없는 현행 비목(퇴직공제부금비 …) - = 「짝 없음」 · 「셈 없음」. + 우리 표에 없는 행(폐기물처리 · 국가지점번호판 …) = 「짝 없음」. """ from __future__ import annotations @@ -27,7 +27,6 @@ from typing import Any from B09_Estimation.B09_Estimation_Calc_Base import num, rounddown from B09_Estimation.B09_Estimation_Calc_Total import ( - CURRENT_ONLY, RATE_LINES, TAIL_LINES, compute_total, @@ -68,7 +67,6 @@ ROWS: tuple[tuple[str, str], ...] = ( ("소계", "subtotal"), ) RATE_KEYS = {key: (name, rt) for key, name, rt, _ in (*RATE_LINES, *TAIL_LINES)} -NO_CALC = {key: (name, rt) for key, name, rt in CURRENT_ONLY} _PCT = re.compile(r"[x×*]\s*([\d.]+)\s*%") _BASE = re.compile(r"%\s*\+\s*([\d,]+)") _HANGUL = re.compile(r"[가-힣]") @@ -204,16 +202,14 @@ def read_rates(name: str, data: bytes) -> dict[str, Any]: excel[key] = min(excel[key], amount or excel[key]) continue excel[key] = amount if amount is not None else excel.get(key, 0.0) - if key in RATE_KEYS or key in NO_CALC: + if key == "performance": # 공사이행보증 = 금액 손입력(율 셈 없음) + if amount: + estimate["performance_fee"] = amount + elif key in RATE_KEYS: if row["rate"] is not None: rates[key] = row["rate"] elif amount: notes.append(f"율 없음 — 「{row['name']}」 산출근거 빈칸 · 지금 율 그대로") - if key in NO_CALC and amount: - notes.append( - f"셈 없음 — 「{row['name']}」 {amount:,.0f} 원 · 율 {row['rate']}% 만 실음" - "(우리 원가계산서 줄만 · 총액에 안 듦)" - ) if key == "safety" and row["text"]: got = _BASE.search(row["text"]) estimate["safety_base"] = float(got.group(1).replace(",", "")) if got else 0.0 diff --git a/B09_Estimation/B09_Estimation_Router_Import.py b/B09_Estimation/B09_Estimation_Router_Import.py index 1bce2e96e..943bf58f9 100644 --- a/B09_Estimation/B09_Estimation_Router_Import.py +++ b/B09_Estimation/B09_Estimation_Router_Import.py @@ -350,6 +350,7 @@ def _rate_patch(name: str, data: bytes, mine: dict[str, Any]) -> dict[str, Any]: raise HTTPException(400, str(error)) from error check = import_rates.check(read, mine, rate_table()) estimate = {**mine, **read["estimate"], "rates": {**(mine.get("rates") or {}), **read["rates"]}} + estimate["rates_imported"] = sorted({*(mine.get("rates_imported") or []), *read["rates"]}) keep = ("kind", "sheet", "rows", "rates", "notes") return {**{k: read[k] for k in keep}, "check": check, "patch": {"estimate": estimate}} diff --git a/B09_Estimation/B09_Estimation_Store.py b/B09_Estimation/B09_Estimation_Store.py index 12026ad0b..88f1a2b34 100644 --- a/B09_Estimation/B09_Estimation_Store.py +++ b/B09_Estimation/B09_Estimation_Store.py @@ -62,6 +62,7 @@ ESTIMATE_FIELDS = ( "indirect_material", # 간접재료비(원) "scrap", # 작업설 부산물(원 · 빼는 값) "procurement_fee_pct", # 조달수수료율(%) + "performance_fee", # 공사이행보증수수료(원 · 708-2) ) TEXT_FIELDS = ("work_type", "environment_kind") @@ -345,6 +346,9 @@ def clean_user(raw: Any) -> dict[str, Any]: rates = given.get("rates") if isinstance(rates, dict): estimate["rates"] = {str(k): _num(v) for k, v in rates.items()} + imported = given.get("rates_imported") # 707-2 불러온 요율 줄 키(출처 「불러옴」) + if isinstance(imported, list): + estimate["rates_imported"] = [str(k) for k in imported] bill = clean_bill(raw.get("bill")) out = {"materials": materials, "estimate": estimate, **({"bill": bill} if bill else {})} if cover := clean_cover(raw.get("daega_cover")): diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts index 0c761f055..e84107e8b 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Total.ts @@ -7,7 +7,8 @@ * 맨 위 = 요율 고르는 값(추정가격 · 공사기간 · 환경보전비 공종 — 누르면 도로 ↔ 그 밖)(166-1) * 요율 = 자동(M01 조달청 제비율 · 짝 셈이 고름) · 사용자가 적으면 앞섬 · 출처 칸 누름 = 자동으로 * 고칠 칸 = 요율 · 손입력(기초액 · 이윤 보정 · 부가세 조정 · 조달수수료율) · - * 엑셀에 없는 현행 비목 = 줄만(빈칸) + * 현행 비목(708-2) = 경비 묶음 안 · 요율 빈칸 = 해당 없음(0) · 공사이행보증 = 금액 손입력 · + * 요율 출처 = 사용자 · 불러옴(707-2 `rates_imported`) — 요율을 고치면 사용자 * 원가계산서 = 총괄 값 거울 + 구성비 · 고칠 칸 = 간접재료비 · 작업설 · 줄 누름 = 총괄 그 줄로 * 레벨(185-1 · 201-1) = 줄 `level`(화면이 정함 · 줄 짜임이 이 파일에 있음 · 셈 짝은 값만) → * 공용 표 level 옵션(명칭 칸 들여쓰기 · 보기 전용) · 1단 = 굵게(is-title) @@ -42,8 +43,22 @@ const HAND: Record = { supply: ["procurement_fee_pct", "조달수수료율(%)"], indirect_material: ["indirect_material", "간접재료비(원)"], scrap: ["scrap", "작업설 부산물(원)"], + performance: ["performance_fee", "공사이행보증수수료(원)"], }; -const RATE_KEYS = new Set([...RATE_LINES, ...TAIL_LINES].map(([key]) => key)); +/** 요율 칸을 고치는 줄 — 공사이행보증 = 금액 손입력(율 칸 없음) */ +const RATE_KEYS = new Set( + [...RATE_LINES, ...TAIL_LINES].map(([key]) => key).filter((key) => key !== "performance"), +); + +/** 요율 줄 사용자 값 고침 · 지움 — 불러옴 표시도 뗌 */ +function setRate(estimate: Row, key: string, v: number | null): void { + const rates: Row = { ...(estimate.rates || {}) }; + if (v === null) delete rates[key]; + else rates[key] = v; + estimate.rates = rates; + if (Array.isArray(estimate.rates_imported)) + estimate.rates_imported = estimate.rates_imported.filter((k: string) => k !== key); +} const ENV_NAME: Record = { road: "도로", other: "그 밖의 토목" }; @@ -52,12 +67,8 @@ function editEstimate(ctx: TabContext, rowKey: string, column: string, value: st const v = parseNumber(value); if (v === undefined) return; const estimate: Row = { ...(ctx.user.estimate || {}) }; - if (column === "rate" && RATE_KEYS.has(rowKey)) { - const rates: Row = { ...(estimate.rates || {}) }; - if (v === null) delete rates[rowKey]; - else rates[rowKey] = v; - estimate.rates = rates; - } else if (HAND[rowKey]) { + if (column === "rate" && RATE_KEYS.has(rowKey)) setRate(estimate, rowKey, v); + else if (HAND[rowKey]) { const field = HAND[rowKey][0]; if (v === null) delete estimate[field]; else estimate[field] = v; @@ -71,11 +82,9 @@ function clickEstimate(ctx: TabContext, rowKey: string, column: string, from: un if (rowKey === "in_env") { if (estimate.environment_kind === "road") delete estimate.environment_kind; else estimate.environment_kind = "road"; - } else if (column === "candidate" && from === "user") { - const rates: Row = { ...(estimate.rates || {}) }; - delete rates[rowKey]; - estimate.rates = rates; - } else return; + } else if (column === "candidate" && (from === "user" || from === "import")) + setRate(estimate, rowKey, null); + else return; ctx.setUser({ ...ctx.user, estimate }); } @@ -122,8 +131,13 @@ function billRows(bill: Row | undefined): DataRow[] { ]; } -function rateText(formula: string, rate: Num): string { - return rate === null ? `${formula}(요율 빈칸)` : formula.replace("율", `${rate}%`); +function rateText(formula: string, rate: Num, key = ""): string { + if (key === "performance") return formula; + if (rate === null) + return CURRENT_ONLY.includes(key) + ? `해당 없음(요율 빈칸) — ${formula}` + : `${formula}(요율 빈칸)`; + return formula.replace("율", `${rate}%`); } export const buildTotal: TabBuild = (ctx): TabTable => { @@ -137,8 +151,10 @@ export const buildTotal: TabBuild = (ctx): TabTable => { /** 출처 칸 글 — 사용자 · 자동 까닭 · 자동 없음 */ const source = (key: string): string => { const r: Row = picked[key] || {}; - if (r.from === "user") - return `사용자 값 · 누르면 자동${r.auto === null || r.auto === undefined ? "(없음)" : ` ${r.auto}%`}으로`; + if (key === "performance") return "금액 직접 입력(율 셈 없음)"; + if (r.from === "user" || r.from === "import") + return `${r.from === "user" ? "사용자" : "불러옴"} 값 · 누르면 자동${r.auto === null || r.auto === undefined ? "(없음)" : ` ${r.auto}%`}으로`; + if (CURRENT_ONLY.includes(key)) return "사용자 · 불러옴만(자동 없음)"; return r.from === "auto" ? `자동 — ${r.why}` : `자동 없음${r.why ? ` — ${r.why}` : ""}`; }; const hand = (key: string): Num => { @@ -165,7 +181,7 @@ export const buildTotal: TabBuild = (ctx): TabTable => { ...extra, }); const rateRow = ([key, name, rt, formula]: [string, string, string, string], no: number) => - row(key, "", `${no}. ${name}`, line[key] ?? null, rateText(formula, rate(key)), { + row(key, "", `${no}. ${name}`, line[key] ?? null, rateText(formula, rate(key), key), { rate: rate(key), candidate: source(key), from: picked[key]?.from ?? null, @@ -204,11 +220,6 @@ export const buildTotal: TabBuild = (ctx): TabTable => { expense: sums.expense ?? null, }), ...RATE_LINES.map((spec, i) => rateRow(spec, i + 1)), - ...CURRENT_ONLY.map(([key, name, rt]) => - row(key, "", name, null, "현행 비목 · 엑셀에 없음 — 밑수 · 고르는 값 미정(줄만)", { - candidate: cand[rt] ?? rt, - }), - ), row("net_cost", "나.", "소계(순공사원가)", sub.net_cost ?? null), rateRow(TAIL_LINES[0], RATE_LINES.length + 1), row("da", "다.", "소계", sub.da ?? null), @@ -302,7 +313,7 @@ export const buildCost: TabBuild = (ctx): TabTable => { const formula: Record = Object.fromEntries( [...RATE_LINES, ...TAIL_LINES].map(([key, , , f]) => [ key, - rateText(f, picked[key]?.rate ?? null), + rateText(f, picked[key]?.rate ?? null, key), ]), ); const rows: DataRow[] = COST_ROWS.map(([key, group, part, name, link]) => ({ @@ -339,7 +350,7 @@ export const buildCost: TabBuild = (ctx): TabTable => { level: nameLevel(rows), mergeSame: ["group", "part"], banner: unfinished(ctx.sheets) ?? undefined, - hint: "고칠 칸은 간접재료비 · 줄을 누르면 총괄 그 줄로 이동", + hint: "고칠 칸은 간접재료비 · 작업설 · 공사이행보증수수료 · 줄을 누르면 총괄 그 줄로 이동", editable: (i, key) => key === "amount" && String(rows[i]?.key) in HAND, onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value), link: (i) => { diff --git a/resources/tester/test_145_4_b09_total_calc.py b/resources/tester/test_145_4_b09_total_calc.py index 42b6e9ac2..0a04a8d18 100644 --- a/resources/tester/test_145_4_b09_total_calc.py +++ b/resources/tester/test_145_4_b09_total_calc.py @@ -17,7 +17,7 @@ ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -from B09_Estimation.B09_Estimation_Calc_Total import compute_total # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Total import CURRENT_ONLY, compute_total # noqa: E402 from B09_Estimation.B09_Estimation_Rates import rate_candidates # noqa: E402 IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) @@ -60,6 +60,7 @@ EXCEL_LINES = { # 총괄 D49~D58 · D60 · D62 · D64 · D66 "profit": 108109955, "vat": 102911727, "supply": 69850000, + **{key: 0 for key in CURRENT_ONLY}, # 708-2 현행 비목 — 엑셀(기번3)에 없음 = 요율 빈칸 = 0 } EXCEL_SUBTOTALS = { # 총괄 D59 · D61 · D63 · D65 · D67 "net_cost": 868874829, @@ -103,7 +104,8 @@ def test_간접재료비_작업설은_직접재료비만_바꿈(): def test_빈칸_규칙(): blank = compute_total(SUMS, {}) - assert all(v is None for k, v in blank["lines"].items() if k != "supply") + assert all(v is None for k, v in blank["lines"].items() if k not in ("supply", *CURRENT_ONLY)) + assert all(blank["lines"][k] == 0 for k in CURRENT_ONLY) # 708-2 요율 빈칸 = 해당 없음 assert blank["subtotals"]["net_cost"] is None and blank["subtotals"]["grand"] is None assert blank["lines"]["supply"] is None # 조달수수료율 빈칸 # 요율 하나만 빈칸 — 그 줄 · 소계 빈칸 · 다른 줄은 셈 @@ -157,6 +159,8 @@ def test_거울_TS_가_같은_값(): [{**SUMS, "supply_net": 2_000_000_000}, ESTIMATE], [{**SUMS, "labor": None}, ESTIMATE], [SUMS, {**ESTIMATE, "rates": {**RATES, "care": None, "profit": 12.5}}], + [SUMS, CURRENT], + [{**SUMS, "labor": None}, CURRENT], ] assert _run_ts(cases) == [compute_total(s, e) for s, e in cases] @@ -208,8 +212,53 @@ console.log(JSON.stringify(out)); ) # fmt: skip assert result.returncode == 0, result.stderr out = json.loads(result.stdout) - assert out["editable"] == [True, False, True, False, False] + assert out["editable"] == [True, False, True, True, False] # 708-2 퇴직공제 요율 칸 assert out["care"] == 13.14 and out["careGone"] is False assert out["grandBlank"] is None # 요율 지우면 그 줄 · 소계 빈칸 assert out["base"] == 1000 and out["scrap"] == 300 assert out["link"] == {"tab": "total", "key": "safety"} + + +CURRENT = { # 708-2 현행 비목 — 율 넷 · 공사이행보증 금액 · 불러온 율 표시 + **ESTIMATE, + "rates": { + **RATES, + "retirement": 2.3, + "wage_claim": 0.09, + "asbestos": 0.006, + "subcontract": 0.1, + }, + "rates_imported": ["retirement", "safety"], + "performance_fee": 1234567, +} + + +def test_708_2_현행_비목_다섯(): + out = compute_total(SUMS, CURRENT) + ln, total_labor, direct = out["lines"], 346800554 + 50286080, 710359136 + assert ln["retirement"] == int(346800554 * 2.3 / 100) # 직접노무비 × 율 + assert ln["wage_claim"] == int(total_labor * 0.09 / 100) # (직노 + 간노) × 율 + assert ln["asbestos"] == int(total_labor * 0.006 / 100) + assert ln["subcontract"] == int(direct * 0.1 / 100) # (재료 + 직노 + 산출경비) × 율 + assert ln["performance"] == 1234567 # 금액 손입력 + added = sum(ln[k] for k in CURRENT_ONLY) + assert out["subtotals"]["net_cost"] == EXCEL_SUBTOTALS["net_cost"] + added # 나.소계에 듦 + assert out["cost"]["expense_sum"] == 277553778 + added # 경비 묶음 안 + assert out["cost"]["retirement"] == ln["retirement"] + assert ( + out["rates"]["retirement"]["from"] == "import" + and out["rates"]["safety"]["from"] == "import" + ) + assert out["rates"]["wage_claim"]["from"] == "user" + # 요율 빈칸 = 해당 없음(0 · 소계 빈칸 아님) — 옛 공사 금액 그대로 + old = compute_total(SUMS, ESTIMATE) + assert all(old["lines"][k] == 0 for k in CURRENT_ONLY) + assert old["subtotals"] == EXCEL_SUBTOTALS + + +def test_708_2_저장_칸(): + from B09_Estimation import B09_Estimation_Store as store + + raw = {"estimate": {"performance_fee": 1234567, "rates_imported": ["retirement", 3]}} + got = store.clean_user(raw)["estimate"] + assert got["performance_fee"] == 1234567 and got["rates_imported"] == ["retirement", "3"] diff --git a/resources/tester/test_166_1_b09_rate_auto.py b/resources/tester/test_166_1_b09_rate_auto.py index a00b15292..639781824 100644 --- a/resources/tester/test_166_1_b09_rate_auto.py +++ b/resources/tester/test_166_1_b09_rate_auto.py @@ -15,7 +15,7 @@ ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -from B09_Estimation.B09_Estimation_Calc_Total import compute_total # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Total import CURRENT_ONLY, compute_total # noqa: E402 from B09_Estimation.B09_Estimation_Rates import rate_table # noqa: E402 IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) @@ -142,7 +142,7 @@ def test_엑셀_검산_손값이면_그대로(): out = total(SUMS, EXCEL, 2026) assert out["subtotals"]["grand"] == 1201879000 assert out["lines"]["safety"] == 16586996 and out["lines"]["profit"] == 108109955 - assert all(r["from"] == "user" for r in out["rates"].values()) + assert all(r["from"] == "user" for k, r in out["rates"].items() if k not in CURRENT_ONLY) def _node(script: str) -> str: diff --git a/resources/tester/test_707_b09_import_rates_prices.py b/resources/tester/test_707_b09_import_rates_prices.py index 5b328bc48..bbf1eefe0 100644 --- a/resources/tester/test_707_b09_import_rates_prices.py +++ b/resources/tester/test_707_b09_import_rates_prices.py @@ -3,7 +3,7 @@ 총액 대조 = 엑셀 비목 합 + 실은 요율 · 손입력으로 우리 식(`compute_total`) → 엑셀 총공사비: 대흥 = 같음 · 영월 = 「국가지점번호판검증수수료」 762,000 원만 다름(우리 표에 줄 없음) · - 거창 = 퇴직공제부금비(셈 없는 현행 비목)만큼 · 소광 = 산출근거 빈칸 율 넷 · 폐기물처리 줄. + 거창 = 같음(708-2 퇴직공제부금비 셈 뒤) · 소광 = 산출근거 빈칸 율 넷 · 폐기물처리 줄. """ import sys @@ -61,7 +61,7 @@ def test_707_2_요율_짝(rates): 2.3, ) assert gc["estimate"]["safety_base"] == 0 # 글에 + 기초액 없음 - assert any("퇴직공제부금비" in n and "셈 없음" in n for n in gc["notes"]) + assert gc["notes"] == [] # 708-2 퇴직공제부금비 = 셈(「셈 없음」 없음) sk = rates["소광"] assert sum("율 없음" in n for n in sk["notes"]) == 4 # 산출근거 빈칸 — 지금 율 그대로 assert any("페기물처리" in n for n in sk["notes"]) @@ -73,11 +73,11 @@ def test_707_2_총액_대조(rates): assert got["대흥"]["differ"] == [] assert got["영월"]["excel_grand"] - got["영월"]["grand"] == 762000 # 국가지점번호판 줄 몫 assert set(got["영월"]["differ"]) == {"도급공사비", "총공사비"} + # 708-2 — 퇴직공제부금비 5,603,907(직노 × 2.3 %)가 순공사원가에 듦 → 총공사비 = 엑셀 + assert got["거창"]["grand"] == got["거창"]["excel_grand"] == 744653000 + assert got["거창"]["differ"] == [] lines = {ln["key"]: ln for ln in got["거창"]["lines"]} - rate_keys = ("indirect_labor", "accident", "employment", "health", "care", "pension") - assert all(lines[k]["same"] for k in (*rate_keys, "safety", "other", "environment")) - assert not lines["overhead"]["same"] # 퇴직공제부금비 5,603,907 이 순공사원가에 없음 - assert lines["overhead"]["excel"] - lines["overhead"]["ours"] == 448312 # = 5,603,907 × 8 % + assert lines["retirement"]["ours"] == lines["retirement"]["excel"] == 5603907 def test_707_3_단가표_읽기():