Merge remote-tracking branch 'origin/dev' into main_laptop_1
This commit is contained in:
@@ -132,9 +132,9 @@ def price_sheets(
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""B09 내역이 쓸 호표 금액 `{B-AX-ST-…#키: {money, total, blocked, unconfirmed, reasons}}`.
|
||||
|
||||
⚠ `money` 는 **호표 표의 성분 소계**(칸마다 0.1원 절사한 합) · `total` 은 계금(1원 절사) —
|
||||
명세 7장 「호표 안 성분 소계는 절사」. 안 자른 값을 쓰면 구조물도 화면(192,429)과
|
||||
내역 단가(192,430)가 1원 갈림(2026-09-13 검증 프로젝트 실측).
|
||||
⚠ `money` 는 **호표 표의 성분 소계**(칸마다 0.1원 절사해 더한 뒤 성분마다 1원 절사) ·
|
||||
`total` 은 그 합 — 명세 7장 「호표 안 성분 소계는 절사」. 안 자른 값을 쓰면 구조물도
|
||||
화면(192,429)과 내역 단가(192,430)가 1원 갈림(2026-09-13 검증 프로젝트 실측).
|
||||
"""
|
||||
from decimal import Decimal
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
⚠ 값을 여기서 새로 짓지 않음 — 줄 단가는 B09 `PriceBook.resolve`(읽기만), 수량은 원단위 줄 값.
|
||||
⚠ 못 푼 줄은 0 이 아니라 **막힘 + 까닭** · 막힌 줄이 하나라도 있으면 합계는 「미완」.
|
||||
⚠ 반올림은 B09 자리 규칙 그대로 — 금액란 0.1원 미만 버림 · 계금 1원 미만 버림(품셈 1-2-2).
|
||||
하위 일위대가를 윗 표에 넣을 때는 **안 자른 값**을 씀 — B09 `resolve` 도 조립 중엔 안 자름.
|
||||
⚠ 반올림은 B09 자리 규칙 그대로 — 금액란 0.1원 미만 버림 · 성분 소계 1원 미만 버림(명세 7장).
|
||||
하위 일위대가를 윗 표에 넣을 때도 **자른 성분 소계**를 씀 — B09 `resolve` 의 B 호표와 같음.
|
||||
⚠ **재귀** — 줄이 `B-AX-ST-*`(다른 구조물 양식)를 가리키면 그 양식을 제원(`sub_vars`)으로 풀어
|
||||
일위대가를 먼저 세우고 그 단위당 금액을 씀. 레시피 150/350 이 2단 이상(명세 16장).
|
||||
깊이 **5단**까지(PLAN 10장 판정) · 돌면 막힘. B-FP·X·L 쪽 재귀는 단가표가 이미 함.
|
||||
@@ -166,7 +166,6 @@ def _assemble(
|
||||
values = (sheet.get("formula_sheet") or {}).get("vars") or {}
|
||||
rows: list[dict[str, Any]] = []
|
||||
sums = {"material": Decimal(0), "labor": Decimal(0), "expense": Decimal(0)}
|
||||
exact = Money3()
|
||||
blocked = 0
|
||||
unconfirmed = 0
|
||||
for row in spec.get("rows") or []:
|
||||
@@ -244,7 +243,6 @@ def _assemble(
|
||||
continue
|
||||
|
||||
money = priced.money
|
||||
exact = exact + money.scaled(quantity)
|
||||
cells = {
|
||||
"material": round_at(money.material * quantity, OutputPlace.UNIT_PRICE_ROW),
|
||||
"labor": round_at(money.labor * quantity, OutputPlace.UNIT_PRICE_ROW),
|
||||
@@ -269,15 +267,21 @@ def _assemble(
|
||||
"name": template.get("name") or "",
|
||||
"unit": sheet.get("billing_unit") or "",
|
||||
"rows": rows,
|
||||
**{key: float(value) for key, value in sums.items()},
|
||||
# 계금 — 1원 미만 버림. ⚠ 막힌 줄이 있으면 이 값은 「미완」이라 화면이 그렇게 적음.
|
||||
"total": float(round_at(sum(sums.values()), OutputPlace.UNIT_PRICE_TOTAL)),
|
||||
# 성분 소계 — 성분마다 1원 미만 버림(명세 7장 · B09 일위대가 호표와 같은 규칙).
|
||||
**{
|
||||
key: float(round_at(value, OutputPlace.UNIT_PRICE_TOTAL)) for key, value in sums.items()
|
||||
},
|
||||
# 계금 = 자른 성분 소계의 합. ⚠ 막힌 줄이 있으면 이 값은 「미완」이라 화면이 그렇게 적음.
|
||||
"total": float(
|
||||
sum(round_at(value, OutputPlace.UNIT_PRICE_TOTAL) for value in sums.values())
|
||||
),
|
||||
"blocked": blocked,
|
||||
"complete": blocked == 0,
|
||||
#: 수동 단가로 선 줄 수 — 화면 「미확정 N건」 배지.
|
||||
"unconfirmed": unconfirmed,
|
||||
}
|
||||
return table, exact
|
||||
# 윗 표·내역이 쓸 단위당 3분할 = **자른 성분 소계**(하위 호표 합계를 그대로 부름 — 명세 7장).
|
||||
return table, Money3(*(Decimal(str(table[key])) for key in _MONEY_KEYS))
|
||||
|
||||
|
||||
def unit_price_table(
|
||||
@@ -307,7 +311,7 @@ def unit_price_money(
|
||||
library: Iterable[dict[str, Any]] = (),
|
||||
manual: dict[str, dict[str, Any]] | None = None,
|
||||
) -> tuple[dict[str, Any], Any] | None:
|
||||
"""`unit_price_table` 과 같되 **안 자른 단위당 3분할**도 — B09 내역 줄이 이 값에 수량을 곱함."""
|
||||
"""`unit_price_table` 과 같되 **단위당 3분할**(자른 성분 소계)도 — B09 내역 줄이 이 값에 수량을 곱함."""
|
||||
ctx = _Context(
|
||||
book, find_variant, {str(t["code"]): t for t in library if t.get("code")}, manual or {}
|
||||
)
|
||||
|
||||
@@ -370,6 +370,7 @@ from B09_Estimation.B09_Estimation_BillOfQuantities_Rows import ( # noqa: E402
|
||||
_leaf_row,
|
||||
_material_row,
|
||||
_structure_price_row,
|
||||
bill_line, # noqa: F401 — 내역 줄 성분별 절사(골든셋 시험이 여기서 부름)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from B09_Estimation.B09_Estimation_BillOfQuantities import (
|
||||
SUPPLY_OWNER,
|
||||
SUPPLY_UNKNOWN,
|
||||
@@ -20,10 +22,24 @@ from B09_Estimation.B09_Estimation_BillOfQuantities import (
|
||||
_MasterNode,
|
||||
_decimal,
|
||||
)
|
||||
from B09_Estimation.B09_Estimation_PriceBook import Money3
|
||||
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
|
||||
from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, find_variant_code
|
||||
|
||||
|
||||
def bill_line(unit: Money3, quantity) -> Money3:
|
||||
"""내역 줄 금액 — **성분마다** `절사(수량 × 성분 단가)`, 줄 합계는 셋의 합(명세 7장).
|
||||
|
||||
근거 STmate 16번 행 금액 1,515건(505줄 × 성분 셋) · 착공내역서 178건 전수 절사.
|
||||
⚠ 합을 한 번에 자르면 성분 합과 1~2원 갈림 — 원가계산서 직접비 밑수도 이 자른 성분의 합.
|
||||
"""
|
||||
return Money3(
|
||||
material=round_at(unit.material * quantity, OutputPlace.BOQ_ROW),
|
||||
labor=round_at(unit.labor * quantity, OutputPlace.BOQ_ROW),
|
||||
expense=round_at(unit.expense * quantity, OutputPlace.BOQ_ROW),
|
||||
)
|
||||
|
||||
|
||||
def _composite_row(
|
||||
item_no: str,
|
||||
item: HandoffWorkItem,
|
||||
@@ -54,7 +70,8 @@ def _composite_row(
|
||||
if not code or amount is None or f"B-{code}" not in unit_prices.book.titles:
|
||||
missing_parts.append(code or str(part.get("name") or "이름 없음"))
|
||||
continue
|
||||
scaled = unit_prices.book.resolve(f"B-{code}").scaled(amount)
|
||||
# 묶음도 호표 한 장 — 조각 줄 0.1원 · 성분 소계 원 미만 절사(아래 `floored`, 명세 7장).
|
||||
scaled = unit_prices.book.resolve(f"B-{code}").scaled(amount).floored(Decimal("0.1"))
|
||||
money = scaled if money is None else money + scaled
|
||||
|
||||
reasons: list[str] = []
|
||||
@@ -85,9 +102,10 @@ def _composite_row(
|
||||
)
|
||||
return row
|
||||
|
||||
line = money.scaled(item.quantity)
|
||||
money = money.floored(Decimal(1))
|
||||
line = bill_line(money, item.quantity)
|
||||
row.unit_price_krw = round_at(money.total, OutputPlace.UNIT_PRICE_ROW)
|
||||
row.amount_krw = round_at(line.total, OutputPlace.BOQ_ROW)
|
||||
row.amount_krw = line.total
|
||||
row.material_krw = line.material
|
||||
row.labor_krw = line.labor
|
||||
row.expense_krw = line.expense
|
||||
@@ -141,9 +159,9 @@ def _structure_price_row(
|
||||
return row
|
||||
|
||||
# 단가 = 호표 계금(구조물도 화면과 같은 값) · 금액 = 호표 성분 소계 × 수량(명세 7장).
|
||||
line = entry["money"].scaled(item.quantity)
|
||||
line = bill_line(entry["money"], item.quantity)
|
||||
row.unit_price_krw = entry["total"]
|
||||
row.amount_krw = round_at(line.total, OutputPlace.BOQ_ROW)
|
||||
row.amount_krw = line.total
|
||||
row.material_krw = line.material
|
||||
row.labor_krw = line.labor
|
||||
row.expense_krw = line.expense
|
||||
@@ -443,11 +461,11 @@ def _leaf_row(
|
||||
row.price_code = price_code
|
||||
|
||||
unit_money = unit_prices.book.resolve(price_code)
|
||||
line = unit_money.scaled(item.quantity)
|
||||
line = bill_line(unit_money, item.quantity)
|
||||
row.unit_price_krw = round_at(unit_money.total, OutputPlace.UNIT_PRICE_ROW)
|
||||
# 내역서 **본체** 행은 절사다 — 집계표(반올림)와 어긋나는 것이 정상.
|
||||
row.amount_krw = round_at(line.total, OutputPlace.BOQ_ROW)
|
||||
# 3분할은 전정밀로 들고 간다 — ⑤ 밑수가 비목마다 갈리므로 여기서 자르면 안 된다.
|
||||
# 내역서 **본체** 행은 성분마다 절사 — 집계표(반올림)와 어긋나는 것이 정상.
|
||||
row.amount_krw = line.total
|
||||
# 3분할도 자른 값 — ⑤ 직접비 밑수가 곧 성분별로 자른 줄 금액의 합(STmate 16번 원 단위 일치).
|
||||
row.material_krw = line.material
|
||||
row.labor_krw = line.labor
|
||||
row.expense_krw = line.expense
|
||||
|
||||
@@ -61,8 +61,10 @@ CATALOG_KINDS = frozenset({PriceKind.MATERIAL, PriceKind.LABOR, PriceKind.MACHIN
|
||||
#: 근거 STmate 17번 — 중기사용료 호표 성분 소계 398/398 절사
|
||||
#: (굴삭기 0.7㎥ 23,128 + 55,700 + 18,015 = 96,843 · 골든셋 실무 143 호표 전수).
|
||||
#: D(단가산출)도 같음 — 줄 `중기 성분 ÷ Q` 0.1원 · 머리 성분 원 미만(봉화 2024 제2호표 3,545.5 → 3,545).
|
||||
#: ⚠ 일위대가(B) 호표도 같은 규칙(345 중 94.2%)이나 **아래층(D)부터 맞춘 뒤** 붙임(PLAN 6장 판정).
|
||||
TRUNCATED_KINDS = frozenset({PriceKind.MACHINE_HOURLY, PriceKind.PRICE_BASIS})
|
||||
#: B(일위대가)도 같음 — 골든셋 실무 성분 소계 477/510(93.5%, 17번 원문 94.2%). 안 맞는 것은 반올림으로
|
||||
#: 뽑은 원본 한 건·적용률 줄 서식 — **새 반올림 규칙을 넣지 말 것**(PLAN 6장 판정).
|
||||
#: ⚠ 비율 줄(공구손료·제잡비·품 할증)도 줄 금액이라 0.1원 미만 절사.
|
||||
TRUNCATED_KINDS = frozenset({PriceKind.MACHINE_HOURLY, PriceKind.PRICE_BASIS, PriceKind.UNIT_PRICE})
|
||||
_TENTH = Decimal("0.1")
|
||||
_WON = Decimal(1)
|
||||
|
||||
@@ -290,6 +292,8 @@ class PriceBook:
|
||||
# 임도는 산림품셈 문구를 따른다 — 결과값은 어차피 같다.
|
||||
# ⚠ 「**상한**」이다 — 곱한 값 **이하**로 계상하는 값이라 설계자가 낮출 수 있다.
|
||||
share = direct_labor * row.percent_of_labor / Decimal(100)
|
||||
if truncate:
|
||||
share = share.quantize(_TENTH, rounding=ROUND_FLOOR)
|
||||
if row.percent_of_labor_target == "labor":
|
||||
# 품 할인·할증(1-4) — **품이 늘어난 것**이라 노무비로 들어가고, 뒤에 오는
|
||||
# 제잡비의 밑수에도 든다.
|
||||
@@ -317,7 +321,8 @@ class PriceBook:
|
||||
child = self.resolve(row.ref_code, (*_seen, code))
|
||||
if row.percent_of_parent is not None:
|
||||
# 비율 행 — 지금까지 쌓인 값의 %로 붙는다(공구손료 등).
|
||||
total = total + total.scaled(row.percent_of_parent / Decimal(100))
|
||||
share = total.scaled(row.percent_of_parent / Decimal(100))
|
||||
total = total + (share.floored(_TENTH) if truncate else share)
|
||||
continue
|
||||
scaled = child.scaled(row.quantity)
|
||||
if truncate:
|
||||
|
||||
@@ -1238,7 +1238,8 @@ def direct_cost_from_quantities(
|
||||
result.missing.append(raw_code)
|
||||
continue
|
||||
unit_money = book.resolve(code)
|
||||
line = unit_money.scaled(Decimal(str(quantity)))
|
||||
# 내역 줄과 같은 자르기 — 성분마다 1원 미만 절사(`BillOfQuantities_Rows.bill_line`).
|
||||
line = unit_money.scaled(Decimal(str(quantity))).floored(Decimal(1))
|
||||
result.material += line.material
|
||||
result.labor += line.labor
|
||||
result.expense += line.expense
|
||||
|
||||
@@ -202,9 +202,37 @@ AR-<종류>-<8자리 16진> 카탈로그에 없는 자원
|
||||
| **자원 종류** (`resource_kind`) | `labor` · `material` · `machine` | 품셈 줄이 무엇을 부르나 |
|
||||
| **단가 층** (`PriceKind`) | `M·L·S·X·B·D·W` | 단가가 어느 겹에 사나 |
|
||||
|
||||
단가 층 넷의 차례 (STC `COSTN` 186행 실측) —
|
||||
`S 중기취득가(천원) → X 시간당 중기사용료 → B 일위대가 → D 단가산출`,
|
||||
`L 노임`·`M 자재`는 카탈로그 층(상세 없이 값이 바로 있음).
|
||||
### ⚠⚠ 층 차례 — 2026-09-13 정정. 화살표 방향이 모호했음
|
||||
|
||||
**조립(참조) 차례는 아래가 맞음.** 앞서 적은 `… → B → D` 는 **거꾸로 읽힐 수 있었음.**
|
||||
|
||||
```
|
||||
L 노임 · M 자재 (카탈로그 — 상세 없이 값이 바로 있음)
|
||||
↓
|
||||
S 중기취득가(천원) → X 시간당 중기사용료
|
||||
↓
|
||||
D 단가산출 (Q 식으로 시간당 단가 ÷ Q)
|
||||
↓
|
||||
B 일위대가 (D 와 카탈로그를 물어 조립)
|
||||
```
|
||||
|
||||
⇒ **D 가 먼저 서고 B 가 D 를 참조함.** 우리 코드는 지금 **D 가 B 를 참조해 뒤집혀 있음**(랩탑 서브 실측).
|
||||
⚠ 차례가 뒤집히면 **위상순서·재귀·이중계상 판정이 다 영향받음** — 값 정밀도 고치기와 **따로 뗄 것.**
|
||||
|
||||
### D 는 **Q 를 쓰는 자리에만** 섬 (2026-09-13 실측)
|
||||
|
||||
```
|
||||
D 가 서는 자리 Q 식으로 시간당 단가를 나누는 기계 공종
|
||||
(굴착기 · 도자 · 직접 작업량/다짐 · 암 잎)
|
||||
D 가 없는 자리 사람 품만 있는 공종 · 자원축 표 시간(Q 아님) → B 가 X·카탈로그를 바로 물음
|
||||
```
|
||||
|
||||
- `D-<공종>[#갈래]` · **D→X 수량 `1/Q × 몫`** · **B→D 수량 1**
|
||||
- STmate 한 `D` 에 **`Q` 가 여럿일 수 있음**(기계조합 호표)
|
||||
- ⚠ **「단산 N」 번호는 실제 `D` 가 든 줄에만.** 단가산출이 없는 줄에 번호를 붙이면 **없는 근거를 가리킴**
|
||||
- ⚠ **내역이 부르는 코드 축은 `B` 하나로 둠.** STmate 는 순수 기계 공종에서 내역이 `D` 를 바로 부르나
|
||||
(봉화 「토사절취 … 산근 19호표」), 금액은 같고(`B→D` 수량 1) **축이 하나여야 인계·골든셋·이중계상이 단순함.**
|
||||
⇒ **골든셋 대조에서 STmate 의 `D` 줄과 우리 `B` 줄을 짝으로 알아보게** 해 둘 것
|
||||
|
||||
---
|
||||
|
||||
@@ -305,11 +333,32 @@ B08 인계본 `ground_class_aliases` — **B09 가 안 읽음**)을 한 벌로
|
||||
| **단가산출 성분단가** | 소수 1자리 절사 | 211건 중 137건 설명 |
|
||||
| 내부 계산 | 은행가(최근접-짝수) | STmate 내부. ⚠ 뽑은 엑셀은 사사오입이라 20개 경계 중 10개가 갈림 |
|
||||
|
||||
### ⭐ 규정이 없는 자리 — 소수점 2자리가 기본값 (2026-09-13 사용자 확정)
|
||||
|
||||
**단수처리에 법적 근거가 없음이 확인됐음** — 기재부 **예정가격작성기준 전 조문에
|
||||
원단위 절사·관급 올림 조문이 없고**, 국고금관리법 §47 과 **발주청 지침 영역**임(안티그래비티 조사).
|
||||
|
||||
```
|
||||
규정이 있는 자리 그 규정을 따름 (품셈·발주청 지침·STmate 실증)
|
||||
규정이 없는 자리 소수점 2자리 · 반올림 ← 기본값. 사용자가 칸으로 바꿈
|
||||
```
|
||||
|
||||
⇒ STmate 관행(절사·은행가)을 따라도 되는 근거가 생겼음. **발주청 지침이 오면 그것으로 덮음.**
|
||||
⚠ **붙박이로 박지 말 것** — 사용자가 바꿀 칸으로 둘 것.
|
||||
|
||||
**식을 접지 말 것 둘**
|
||||
|
||||
- **노임 시간당 환산** — 식 문자열을 **좌→우 순차 평가**.
|
||||
계수로 미리 곱하면 `267,360 × 0.20833… = 55,699.99…` → **55,699** 로 1원 틀림.
|
||||
좌→우는 모든 단계가 정수라 **55,700**. 18건 중 계수 선계산은 14/18 만 맞음
|
||||
좌→우는 모든 단계가 정수라 **55,700**.
|
||||
|
||||
⚠⚠ **2026-09-13 정정 — 「18/18 원 단위」는 틀렸음**(랩탑 서브 실측).
|
||||
```
|
||||
다섯 건 원 미만 절사
|
||||
2025 울진소광 0.1 원 미만 절사 (57,077.2 · 49,479.1 · 35,608.3)
|
||||
```
|
||||
⇒ **자릿수가 프로젝트마다 다름.** 실무 설정값임.
|
||||
**프로젝트 설정 칸으로 둘 것 · 기본 0**(원 단위). 붙박이로 박지 말 것
|
||||
- **단가산출 Q 식** — `Q = 3600 × q × K × f × E / Cm`.
|
||||
⚠ **Q 를 소수 2자리로 먼저 확정한 뒤 나눌 것**(`55,700/15.708 = 3,545.96` ≠ `55,700/15.71 = 3,545.5`)
|
||||
|
||||
@@ -590,7 +639,30 @@ IF 36(비교식 11) · VLOOKUP 13(**전부 근사**) · SUM 47.
|
||||
| 공종 축 ↔ 자원 축 | **서로 다른 표를 잇는 것** | ⛔ 안 됨. 코드로만 |
|
||||
| `#갈래` | **한 공종 안의 조건 딱지**(토질·암종·규격) | ⚠ 지금은 이름으로 맞추고 있어 약함 |
|
||||
|
||||
**⇒ 판정 — 마스터의 `variant_key` 를 정본으로 삼을 것.**
|
||||
### ⚠⚠ 2026-09-13 정정 — 위 판정이 틀렸음
|
||||
|
||||
**`variant_key` 를 정본으로 못 씀.** 브레인이 「456표에 다 있다」만 보고 「쓸 수 있다」로 넘겼으나,
|
||||
그 값은 **「표 첫 열 값 24개까지」**(`Build_WorkItemMaster.variant_axis`)임.
|
||||
|
||||
```
|
||||
단가표 갈래 제목 294개 중 그 목록에 있는 것 86개 (약 1/3)
|
||||
나머지 208 — 머리행에 갈래가 있는 표(벌목 「5m이상~8m미만」)
|
||||
두 단 머리(「굴착기(무한궤도)0.2·밀」)
|
||||
구간 이름 어긋남(표 「0.3~0.7m이하」 ↔ 목록 「0.3m미만」)
|
||||
첫 열이 자원 이름인 표
|
||||
```
|
||||
|
||||
⇒ 지금 정본으로 읽으면 **3분의 2가 폴백으로 떨어짐.**
|
||||
|
||||
**고쳐 세운 판정 둘**
|
||||
|
||||
1. **지금은 B09 단가표 제목을 정본으로 둘 것**(모양별 읽기가 이미 만든 것).
|
||||
`find_variant_code` 의 **느슨한 맞춤**(수의 짝·구간 고르기)이 쓰일 때 **로그로 남길 것** —
|
||||
**조용히 다른 갈래에 붙는 자리가 바로 거기임**
|
||||
2. **마스터 생성기가 표 모양(첫 열 / 머리행 / 두 단)대로 `variant_key` 를 다시 뽑게** 할 것 —
|
||||
**1장 마스터 일감**. 그것이 서면 그때 정본을 옮김
|
||||
|
||||
### (지난 판정) 마스터의 `variant_key` 를 정본으로 삼을 것 — ⛔ 위 정정으로 뒤집힘
|
||||
|
||||
- 마스터 **456표 전부에 `variant_key` 가 이미 있음**(브레인 확인). 그런데 **B09 가 안 읽고**
|
||||
변형 문자열에서 매번 다시 만들어 씀
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
② 노임 시간당 환산 `환율및기초자료` 시트 — 일당 × 식 → 시간당 ✅
|
||||
④ 중기 시간당 사용료 `중기사용료` 시트 호표 — 손료·운전원·연료·잡품 ✅
|
||||
③ 단가산출 Q 식 `단가산출근거` 시트 — 중기 성분 × 1/Q(0.1원) · 머리 원 미만 ✅
|
||||
① 일위대가·내역 절사 일위대가·내역 줄 — 성분별 절사 (마지막)
|
||||
① 일위대가·내역 절사 `일위대가표` 성분 소계 93.5% · `설계내역서` 줄 성분별 전수 ✅
|
||||
|
||||
⚠ 실무 원본은 **git 안 지식DB**라 어느 창에서든 돎. 원본이 없으면 건너뜀(시험 코드 탓이 아님).
|
||||
⚠ 값을 여기서 짓지 않음 — 원본 칸을 읽어 **엔진 함수의 입력**으로만 씀.
|
||||
@@ -39,7 +39,7 @@ PRACTICE = ROOT / "resources" / "knowledge" / "original" / "실무문서"
|
||||
def _workbooks() -> tuple[tuple[str, dict[str, list[tuple]]], ...]:
|
||||
"""실무 XLSX 마다 쓰는 시트만 값으로 읽어 둠(한 번). `(상대 경로, {시트: 줄들})`."""
|
||||
openpyxl = pytest.importorskip("openpyxl")
|
||||
wanted = ("환율및기초자료", "중기사용료", "단가산출근거")
|
||||
wanted = ("환율및기초자료", "중기사용료", "단가산출근거", "일위대가표", "설계내역서")
|
||||
found = []
|
||||
for path in sorted(PRACTICE.rglob("*.xlsx")):
|
||||
if path.name.startswith("~$"):
|
||||
@@ -51,7 +51,7 @@ def _workbooks() -> tuple[tuple[str, dict[str, list[tuple]]], ...]:
|
||||
except Exception: # 깨진 사본 — 기준점이 아님
|
||||
continue
|
||||
sheets = {
|
||||
name: list(book[name].iter_rows(max_col=12, values_only=True))
|
||||
name: list(book[name].iter_rows(max_col=13, values_only=True))
|
||||
for name in wanted
|
||||
if name in book.sheetnames
|
||||
}
|
||||
@@ -280,3 +280,114 @@ def test_Q_는_소수_2자리로_먼저_확정한_뒤_나눈다() -> None:
|
||||
book.add_detail(PriceDetail("D", "X", machine_hours_per_unit(factors)))
|
||||
# 줄 55,700 ÷ 15.71 = 3,545.5(0.1원 절사) → 머리 원 미만 3,545 · 원값 Q 로 나누면 3,545.9 로 갈림.
|
||||
assert book.resolve("D").expense == Decimal(3545)
|
||||
|
||||
|
||||
_COMPONENT_KINDS = (PriceKind.LABOR, PriceKind.MATERIAL, PriceKind.MACHINE_BASE)
|
||||
|
||||
|
||||
def _unit_price_blocks() -> list[tuple[str, str, tuple, list[tuple]]]:
|
||||
"""`일위대가표` 시트의 호표 — `(원본, 호표, 합계 줄, 구성 줄들)`.
|
||||
|
||||
칸: 명칭 · 규격 · 수량 · 단위 · 합계(단가·금액) · 노무 · 재료 · 경비. 끝 줄은 「합계」·「계」·
|
||||
「합계(경비로적용)」 — 뒤따르는 「계약단가」(낙찰률 곱)는 절사 규칙 대조 밖.
|
||||
"""
|
||||
found = []
|
||||
for name, sheets in _workbooks():
|
||||
rows: list[tuple] | None = None
|
||||
title = ""
|
||||
for row in sheets.get("일위대가표", []):
|
||||
head = str(row[0] or "").replace(" ", "")
|
||||
if head.startswith("제") and head.endswith("호표"):
|
||||
rows, title = [], head
|
||||
elif rows is None:
|
||||
continue
|
||||
elif head in ("합계", "계") or head.startswith("합계("):
|
||||
found.append((name, title, row, rows))
|
||||
rows = None
|
||||
elif _num(row[2]) is not None and row[3]:
|
||||
rows.append(row)
|
||||
return found
|
||||
|
||||
|
||||
def _assemble_unit_price(rows: list[tuple]) -> Money3:
|
||||
"""원본 구성 줄(수량 · 성분 단가)을 **우리 B 층**에 올려 풂 — 줄 0.1원 · 성분 소계 원 미만(엔진)."""
|
||||
book = PriceBook()
|
||||
book.add_title(PriceTitle("B", PriceKind.UNIT_PRICE, "일위대가"))
|
||||
for index, row in enumerate(rows):
|
||||
quantity = _num(row[2])
|
||||
if str(row[3]).strip() == "%": # 「노무비의 3 %」 — 단가 칸이 밑수, 수량 칸이 백분율 수
|
||||
quantity /= 100
|
||||
for kind, part in zip(_COMPONENT_KINDS, (_num(row[6]), _num(row[8]), _num(row[10]))):
|
||||
if part:
|
||||
code = f"R{index}{kind.name}"
|
||||
book.add_title(PriceTitle(code, kind, str(row[0]), slots=_slots(part)))
|
||||
book.add_detail(PriceDetail("B", code, quantity))
|
||||
return book.resolve("B") if book.details else Money3()
|
||||
|
||||
|
||||
def test_일위대가_실무_원본_호표_성분_소계_절사() -> None:
|
||||
"""① 줄 = 수량 × 성분 단가 → 0.1원 미만 절사 · 성분 소계 원 미만 절사(명세 7장).
|
||||
|
||||
⚠ **100%를 좇지 않음**(브레인 판정) — 17번 원문이 「345건 중 94.2%, 나머지는 호표 머리·부분계
|
||||
서식이 달라 자동 구획이 어긋난 것」이라 적음. 안 맞는 호표는 **서식 탓·반올림 탓**을 갈라 둠.
|
||||
"""
|
||||
blocks = _unit_price_blocks()
|
||||
if not blocks:
|
||||
pytest.skip("실무 원본 XLSX 가 없음")
|
||||
misses, components, matched = [], 0, 0
|
||||
for name, title, total, rows in blocks:
|
||||
got = _assemble_unit_price(rows)
|
||||
want = (_num(total[5]), _num(total[7]), _num(total[9]), _num(total[11]))
|
||||
if not want[1] and not want[2] and want[0] == want[3]:
|
||||
got = Money3(expense=got.total) # 합계(경비로적용)
|
||||
pairs = list(zip(want[1:], (got.labor, got.material, got.expense)))
|
||||
components += len(pairs)
|
||||
matched += sum(w == g for w, g in pairs)
|
||||
if (got.total, got.labor, got.material, got.expense) != want:
|
||||
misses.append(
|
||||
(name, title, want, (got.total, got.labor, got.material, got.expense), rows)
|
||||
)
|
||||
assert len(blocks) >= 150, len(blocks) # 6건 170 호표 · 성분 소계 510
|
||||
# 성분 소계 477/510 = 93.5%(17번 원문 94.2% 언저리). 안 맞는 19 호표는 둘로 갈림 —
|
||||
# 반올림 탓 17: 영월 2024 한 건이 일위대가표를 **절사가 아닌 반올림 자리**로 뽑음(같은 원본의
|
||||
# 중기사용료·단가산출근거는 절사로 전수 일치 — 그 원본의 단수 설정, 엔진 규칙 아님)
|
||||
# 서식 탓 2: 영덕 2025 「켜쌓기적용 90 %」 줄이 앞 줄 합을 밑수로 받아 **앞 줄을 대신함**
|
||||
assert matched >= components * 0.93, (matched, components)
|
||||
formatted = [m for m in misses if "영월" not in m[0]]
|
||||
assert len(formatted) <= 2, [m[:4] for m in formatted]
|
||||
assert all(any(str(r[3]).strip() == "%" for r in m[4]) for m in formatted)
|
||||
# 봉화 2024 제1호표 깬잡석찰쌓기 — 노무 20,714.8 + 6,621.8 + 16,710 = 44,046 · 합계 58,889.
|
||||
first = next(b for b in blocks if "현동" in b[0] and b[1] == "제1호표")
|
||||
assert _assemble_unit_price(first[3]).total == Decimal(58889)
|
||||
|
||||
|
||||
def _bill_rows() -> list[tuple[str, tuple]]:
|
||||
"""`설계내역서` 말단 줄 — 칸: 공종 · 명칭 · 규격 · 수량 · 단위 · 합계 · 노무 · 재료 · 경비(단가·금액)."""
|
||||
return [
|
||||
(name, row)
|
||||
for name, sheets in _workbooks()
|
||||
for row in sheets.get("설계내역서", [])
|
||||
if _num(row[3]) is not None and row[4] and any(_num(row[i]) for i in (7, 9, 11))
|
||||
]
|
||||
|
||||
|
||||
def test_내역_줄_금액은_성분마다_절사한_합() -> None:
|
||||
"""① 내역 줄 — 성분마다 `절사(수량 × 성분 단가)` · 합계 = 셋의 합(16번 1,515건 · 착공 178건 100%)."""
|
||||
from B09_Estimation.B09_Estimation_BillOfQuantities import bill_line
|
||||
|
||||
rows = _bill_rows()
|
||||
if not rows:
|
||||
pytest.skip("실무 원본 XLSX 가 없음")
|
||||
misses = []
|
||||
for name, row in rows:
|
||||
unit = Money3(material=_num(row[9]), labor=_num(row[7]), expense=_num(row[11]))
|
||||
quantity = _num(row[3])
|
||||
if str(row[4]).strip() == "%": # 조달수수료 「재료비 × 0.54 %」 — 단가 칸이 밑수
|
||||
quantity /= 100
|
||||
line = bill_line(unit, quantity)
|
||||
got = (line.total, line.labor, line.material, line.expense)
|
||||
want = (_num(row[6]), _num(row[8]), _num(row[10]), _num(row[12]))
|
||||
if got != want:
|
||||
misses.append((name, row[1], want, got))
|
||||
assert len(rows) >= 300, len(rows)
|
||||
assert not misses, (len(misses), misses[:5])
|
||||
|
||||
@@ -18,7 +18,9 @@ def test_모르타르_배합은_보통인부_066인이고_자재는_드러내기
|
||||
assert (title.name, title.spec, title.unit) == ("모르타르 배합", "1:3", "㎥")
|
||||
money = build.book.resolve(f"B-{MORTAR_MIX}")
|
||||
wage = build.book.resolve("1002").labor # 보통인부
|
||||
assert money.labor == wage * Decimal("0.66") and money.material == 0
|
||||
# 일위대가 호표 성분 소계는 원 미만 절사(명세 7장 · PLAN 6장 ①).
|
||||
assert money.labor == (wage * Decimal("0.66")).to_integral_value("ROUND_FLOOR")
|
||||
assert money.material == 0
|
||||
assert [label.split(" —")[0] for label in build.unattached[MORTAR_MIX]] == [
|
||||
"시멘트 510 kg",
|
||||
"모래 1.10 ㎥",
|
||||
|
||||
Reference in New Issue
Block a user