main_laptop_1 -> main byeonghap (4 hwangyeong 585 commits) #12

Merged
eomsangdon merged 585 commits from main_laptop_1 into main 2026-09-08 17:26:30 +09:00
6 changed files with 218 additions and 10 deletions
Showing only changes of commit e55ff8a82e - Show all commits
@@ -32,6 +32,7 @@ from B09_Estimation.B09_Estimation_Guards import (
check_haul_volume_within_cut,
)
from B09_Estimation.B09_Estimation_ResourceAxis import load_work_item_master
from B09_Estimation.B09_Estimation_QuantityDigits import round_quantity
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
from B09_Estimation.B09_Estimation_UnitPrice import (
UnitPriceBuild,
@@ -157,6 +158,13 @@ class BillRow:
def money(value: Decimal | None) -> str | None:
return None if value is None else str(value)
# 수량 소수자리는 **종목마다 다르다** (품셈 1-2-2). 값은 전정밀로 두고 **찍을 자리만**
# 함께 보낸다 — 자르는 것은 표를 그리는 쪽 몫이다(단수 규칙과 같은 원칙).
shown, digits = (
(None, None)
if self.quantity is None
else round_quantity(self.quantity, self.name, self.unit, self.spec)
)
return {
"item_no": self.item_no,
"level": self.level,
@@ -165,6 +173,10 @@ class BillRow:
"spec": self.spec,
"unit": self.unit,
"quantity": money(self.quantity),
#: 품셈 1-2-2 종목별 자리로 **반올림한** 표시값. 자리를 모르면 `None` 이고,
#: 그때 화면은 종전대로 찍는다(지어내지 않는다).
"quantity_shown": money(shown) if digits is not None else None,
"quantity_digits": digits,
"unit_price_krw": money(self.unit_price_krw),
"amount_krw": money(self.amount_krw),
"material_krw": str(self.material_krw),
@@ -277,6 +277,29 @@ def _leaf_row(
if part
)
if title.unit and row.unit and not _same_unit(title.unit, row.unit):
# ⚠⚠ **단위가 다르면 곱하지 않는다** (2026-09-08 실측으로 드러난 자리).
# 돌쌓기(찰)이 B08 에서 **연장 10 m** 로 오는데 품셈 일위대가는 **㎡당**이라,
# 52,938.9원/㎡ × 10 m = 529,389원이 조용히 서 있었다. 면적으로 세면 26.101㎡ ×
# 52,938.9 = 1,381,753원이라 **2.6 배 적은 금액**이 내역서에 든 셈이다.
# 어느 쪽이 맞는지는 우리가 정할 일이 아니다 — **B08 이 면적을 보내거나 묶음
# 조각으로 보내야** 풀린다. 그때까지 **금액을 만들지 않고 드러낸다.**
row.note = (
f"단위가 안 맞습니다 — 수량은 {row.unit}, 단가는 {title.unit}당입니다. "
"곱하면 금액이 틀리므로 비워 둡니다."
)
result.missing.append(
{
"name": row.name,
"code": node.code,
"unit": row.unit,
"quantity": str(item.quantity),
"reason": f"단위 불일치 — 수량 {row.unit} vs 단가 {title.unit}",
"blocked_kind": "unit_mismatch",
}
)
return row
# 쓰인 차례를 기억한다 — 실무 참조번호(「단산 46」)가 그 차례다.
if price_code not in result.used_unit_prices:
result.used_unit_prices.append(price_code)
@@ -340,3 +363,35 @@ def _material_row(material: HandoffMaterial, result: BillResult) -> BillRow:
}
)
return row
#: 같은 단위의 다른 표기 — 표기만 다르고 뜻이 같은 것을 「다르다」고 하면 멀쩡한 줄이 멈춘다.
_UNIT_ALIASES = {
"": "m3",
"": "m3",
"M3": "m3",
"루베": "m3",
"": "m2",
"": "m2",
"M2": "m2",
"": "kg",
"KG": "kg",
"": "ton",
"TON": "ton",
"t": "ton",
"개소": "",
"EA": "",
"ea": "",
"": "",
"인/일": "",
}
def _same_unit(left: str, right: str) -> bool:
"""두 단위가 같은가. **표기 차이만 흡수하고, 환산은 하지 않는다** — m 과 ㎡ 는 다르다."""
def key(text: str) -> str:
tight = "".join(str(text or "").split())
return _UNIT_ALIASES.get(tight, tight)
return key(left) == key(right)
+15 -2
View File
@@ -215,8 +215,21 @@ class PriceBook:
# 명시하는 서식인데 13-6-2 는 그냥 「노무비」다.
# 뜻으로도 그렇다 — 제잡비는 **본 자원에 안 선 잔 기계 손료**를 사람
# 품에 비례해 얹는 자리인데, 그 표엔 굴착기가 이미 본 자원으로 서 있다.
# ⚠ 잠정 — 조종원 노임을 넣으면 찰쌓기 60~80 기준 2,098.46 → 2,565.90
# (약 +22 %). 사용자 확정 대기(PLAN 9-6).
#
# ⚠⚠ **닫힌 물음이다 — 다시 뒤집지 말 것** (2026-09-08 전수 대조로 확정).
# 이 값은 하루에 두 번 뒤집혔고 금액이 약 ±22 % 움직였다. 세 번째가
# 없도록 근거를 여기 못 박는다.
# ㉠ **산림품셈이 1차 적용**이고(품셈 1-1·별표2, 건설품셈은 보완),
# 그 [주]③ 은 여섯 자리에서 한결같이 **「노무비의 합계액」**이라 적는다
# (원문 L5498·L7013·L7299·L7323·L7350·L7401).
# ㉡ **일위대가 안에는 간접노무비가 없다.** 간접노무비는 원가계산서 층에서
# 「직접노무비 × 율」로 나중에 생기는 값이다
# (resources/knowledge/.../05_원가정보/원가계산_체계.md §2 밑수 정의표).
# ⇒ 그러므로 **그 표의 노무비 줄 합 = 직접노무비**이고, 「직접노무비냐
# 노무비 계정이냐」라는 물음 자체가 이 층에서는 성립하지 않는다.
# ⚠ 건설품셈 제8장(말뚝)은 **「직접노무비」**라고 다르게 적지만, 위 ㉠ 으로
# 임도는 산림품셈 문구를 따른다 — 결과값은 어차피 같다.
# ⚠ 「**상한**」이다 — 곱한 값 **이하**로 계상하는 값이라 설계자가 낮출 수 있다.
total = total + Money3(expense=direct_labor * row.percent_of_labor / Decimal(100))
continue
@@ -0,0 +1,104 @@
"""B09 원가계산 — **수량의 소수자리**는 종목마다 다르다 (산림품셈 1-2-2의 1).
**왜 있는가** — 지금까지 화면이 **모든 수량을 2자리로** 찍고 있었는데, 품셈은 종목별로
자리를 따로 정해 두었다. 체적합계·시멘트·철근은 **정수**, 돌쌓기·옹벽·떼는 **1자리**,
철강재는 **3자리**다. 2자리 고정은 그 어느 것도 아니다 (2026-09-08 지식DB 대조에서 드러남).
원문 = resources/knowledge/original/행정규칙/임도 품셈 적용기준
(현 산림사업 표준품셈)/첨부/(산림청고시 제2025-82호) 산림사업 표준품셈.md L575~660
요약 = resources/knowledge/technical_info/01_임도/04_수량분석정보/수량산출_일반.md §2
⚠ **수량은 반올림이다** ([주]① 「본 표에 따르며, **반올림**하여 적용한다」).
금액이 전부 **버림**인 것과 반대다 — 한 모듈에 섞어 두면 반드시 헷갈리므로
금액은 `B09_Estimation_Rounding` 이 따로 맡는다.
⚠ **항목이 표를 이긴다** ([주]② 「품셈 각 항목에서 제시한 소숫자리가 본 표와 상이할 경우
**항목에서 제시하는 소숫자리를 우선**」). 그래서 `override` 를 받는다. 다만 지금 공종
마스터에 「이 항목의 소수자리」를 담은 칸이 **아직 없다** — 그 칸이 생기면 여기로 흘리면 된다.
⚠ **모르는 종목은 손대지 않는다.** 규칙을 넓게 잡아 엉뚱한 줄까지 자르는 사고를 오늘만
여러 번 겪었다. 표에 없으면 `None` 을 돌려주고 **화면이 종전대로** 찍게 둔다 —
「모른다」가 보이는 편이 조용히 틀리는 것보다 낫다.
"""
from __future__ import annotations
from decimal import ROUND_HALF_UP, Decimal
#: (이름에 들어 있는 말, 그 단위) → 소수자리. **원문 표 순서 그대로** 옮겼다.
#: ⚠ 값을 고칠 일이 생기면 원문을 먼저 볼 것 — 여기서 고치면 근거가 끊긴다.
_DIGITS: tuple[tuple[tuple[str, ...], tuple[str, ...], int], ...] = (
# 종목 이름 후보 · 단위 후보 · 소수자리
(("공사연장", "노선연장"), ("m",), 0),
(("공사폭원", "노폭"), ("m",), 1),
(("직공인부",), ("",), 2),
(("체적합계", "토적합계"), ("", "m3"), 0),
(("", "평떼", "줄떼"), ("", "m2"), 1),
(("모래", "자갈", "조약돌"), ("", "m3"), 2),
(("견치돌", "깬돌"), ("", "m2"), 1),
(("야면석",), ("", "m3"), 1),
(("돌쌓기", "돌붙임"), ("", "m3", "", "m2"), 1),
(("사석",), ("", "m3"), 1),
(("다듬돌", "절석", "판석"), ("",), 2),
(("벽돌", "블록"), ("",), 0),
(("시멘트",), ("kg", ""), 0),
(("모르타르", "모르터"), ("kg", ""), 2),
(("콘크리트",), ("", "m3"), 2),
(("합판",), ("",), 1),
(("말뚝",), ("",), 0),
(("철강재", "강재"), ("kg", ""), 3),
(("용접봉",), ("kg", ""), 1),
(("철근",), ("kg", ""), 0),
(("볼트", "너트", "꺽쇠"), ("",), 0),
(("철선", "철사"), ("kg", ""), 2),
(("",), ("kg", ""), 2),
(("화약",), ("kg", ""), 3),
(("뇌관",), ("",), 0),
(("도화선",), ("m",), 1),
(("수로연장",), ("m",), 1),
(("옹벽",), ("", "m2"), 1),
(("도장", "칠하기"), ("", "m2"), 1),
(("방수",), ("", "m2"), 1),
(("보오링", "보링"), ("m",), 1),
)
#: 위 표에 이름이 안 걸릴 때 **단위만으로** 줄 수 있는 자리 — 품셈 「토적」 줄이 근거다.
#: 토적: 높이·너비 m 2 · 단면적 ㎡ 1 · 체적 ㎥ 2.
#: ⚠ **면적을 1자리로 내리지 않는다** — 「토적(단면적)」은 횡단면적을 말하는 것이라
#: 사면적·거푸집 면적까지 1자리로 자르면 틀린다. 확실한 ㎥ 만 잡는다.
_UNIT_ONLY = {"": 2, "m3": 2}
def _tight(text: str) -> str:
return "".join(str(text or "").split())
def digits_for(name: str, unit: str, spec: str = "") -> int | None:
"""그 줄의 수량 소수자리. **표에 없으면 `None`** — 지어내지 않는다."""
haystack = _tight(name) + _tight(spec)
unit_tight = _tight(unit)
for words, units, digits in _DIGITS:
if unit_tight not in units:
continue
if any(word in haystack for word in words):
return digits
return _UNIT_ONLY.get(unit_tight)
def round_quantity(
value: Decimal,
name: str,
unit: str,
spec: str = "",
override: int | None = None,
) -> tuple[Decimal, int | None]:
"""수량을 그 종목의 자리로 **반올림**한다.
돌려주는 것 — (자른 값, 쓴 자리). 자리를 못 찾으면 **값을 안 건드리고** `(값, None)`.
`override` 는 품셈 **항목이 따로 제시한 자리**([주]②) — 표보다 우선한다.
"""
digits = override if override is not None else digits_for(name, unit, spec)
if digits is None:
return value, None
quantum = Decimal(1).scaleb(-digits)
return value.quantize(quantum, rounding=ROUND_HALF_UP), digits
+14 -1
View File
@@ -38,6 +38,12 @@ class OutputPlace(str, Enum):
OWNER_MATERIAL_TOTAL = "owner_material_total"
#: 일위대가표 금액란 — **0.1원 미만 버림** (품셈 1-2-2 「일위대가 금액란 0.1원 미만 버림」)
UNIT_PRICE_ROW = "unit_price_row"
#: 일위대가표의 **계금** — 1원 미만 버림. ⚠ 금액란(0.1원)과 **자리가 다르다**
#: (품셈 1-2-2의 2 표에 두 줄이 따로 있다).
UNIT_PRICE_TOTAL = "unit_price_total"
#: **설계서의 총액** — 1,000원 미만 버림 (품셈 1-2-2의 2 「설계서의 총액 … 1,000 미만버림」).
#: ⚠ 소계·금액란(1원)과 다르다. 맨 마지막 한 자리에서만 쓴다.
GRAND_TOTAL = "grand_total"
def round_at(value: Decimal, place: OutputPlace) -> Decimal:
@@ -46,7 +52,14 @@ def round_at(value: Decimal, place: OutputPlace) -> Decimal:
자리를 안 대고 부르는 길을 두지 않는다 — 기본값을 두면 어느 자리인지 모른 채
아무 함수나 쓰게 된다.
"""
if place in (OutputPlace.BOQ_ROW, OutputPlace.OVERHEAD_ROW, OutputPlace.PROCUREMENT_FEE):
if place is OutputPlace.GRAND_TOTAL:
return (value / _THOUSAND).quantize(_ONE, rounding=ROUND_FLOOR) * _THOUSAND
if place in (
OutputPlace.BOQ_ROW,
OutputPlace.OVERHEAD_ROW,
OutputPlace.PROCUREMENT_FEE,
OutputPlace.UNIT_PRICE_TOTAL,
):
return value.quantize(_ONE, rounding=ROUND_FLOOR)
if place is OutputPlace.RESOURCE_SUMMARY:
return value.quantize(_ONE, rounding=ROUND_HALF_UP)
+18 -7
View File
@@ -653,6 +653,9 @@ interface BillRowDto {
spec: string;
unit: string;
quantity: string | null;
/** 품셈 1-2-2 종목별 자리로 반올림한 표시값. 자리를 모르면 `null`. */
quantity_shown: string | null;
quantity_digits: number | null;
unit_price_krw: string | null;
amount_krw: string | null;
is_group: boolean;
@@ -713,19 +716,24 @@ interface MaterialSheetDto {
}
/**
* 수량 표시 — 소수 **2자리**. 계산은 전정밀 그대로다.
* 수량 표시 — **종목마다 자리가 다르다** (산림품셈 1-2-2의 1).
*
* 체적합계·시멘트·철근은 정수, 돌쌓기·옹벽·떼는 1자리, 철강재는 3자리다. 서버가 줄마다
* `quantity_digits` 를 실어 보내므로 여기서는 그 자리로 찍기만 한다.
* ⚠ 자리를 못 찾은 줄은 `null` 로 오고, 그때만 **종전 2자리**로 찍는다 — 모르는 것을
* 아는 척 자르지 않는다.
*
* ⚠ 표시값끼리 곱하면 금액이 몇 원 어긋난다(90.51 × 5,288.6 ≠ 화면 금액). 그것이
* 정상임을 표 아래 문구로 밝힌다 — 밝히지 않으면 「1원 틀린다」는 지적으로 돌아온다.
* 실무 서식이 수량을 몇 자리로 쓰는지는 기준 문서에 없어(미결) 2자리는 잠정이다.
*/
function formatQuantity(value: string | null): string {
function formatQuantity(value: string | null, digits: number | null = null): string {
if (value === null || value === "") return "";
const parsed = Number(value);
if (!Number.isFinite(parsed)) return value;
const places = digits ?? 2;
return parsed.toLocaleString("ko-KR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
minimumFractionDigits: places,
maximumFractionDigits: places,
});
}
@@ -863,7 +871,7 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
indent + row.name,
row.spec,
row.unit,
formatQuantity(row.quantity),
formatQuantity(row.quantity_shown ?? row.quantity, row.quantity_digits),
row.unit_price_krw ?? "",
row.amount_krw ?? "",
row.note,
@@ -902,7 +910,10 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
note.textContent =
`${L("B09_Estimation_Boq_Excluded")}: ` +
bill.excluded
.map((row) => `${row.name} ${formatQuantity(row.quantity)}${row.unit}`)
.map(
(row) =>
`${row.name} ${formatQuantity(row.quantity_shown ?? row.quantity, row.quantity_digits)}${row.unit}`,
)
.join(", ");
body.append(note);
}