feat(B09 원가계산서 · 708-2): 현행 비목 다섯 셈 — 퇴직공제 · 임금채권 · 석면 · 하도급 요율 셈 · 공사이행보증 금액 손입력

- 셈 짝(Calc_Total py ↔ ts) — STmate 일반형식 식 · 자리(지식DB 12 · 35 분석) · 경비 묶음 안 · 나.소계에 듦
  퇴직공제부금비 = 직접노무비 × 율 · 임금채권 · 석면 = (직노 + 간노) × 율 · 하도급 = 순공사비 × 율 ·
  공사이행보증 = 금액 직접 입력(performance_fee) · 자동 요율 없음 · 요율 빈칸 = 해당 없음(0)
- 요율 출처 「불러옴」 = rates_imported(707-2 불러오기가 실음 · 요율 고치면 사용자)
- 707-2 불러오기 — 「셈 없음」 알림 뺌 · 공사이행보증 줄 금액 = 손입력 칸
- 총괄 · 원가계산서 탭 · 인쇄 미리보기 = 같은 줄 짜기
- 낡은 시험 고침 — 현행 비목 기대값 빈칸 → 0 · 퇴직공제 요율 칸 고침 가능 · 거창 총공사비 = 엑셀

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