Merge remote-tracking branch 'origin/dev' into sub_laptop_1
This commit is contained in:
@@ -72,6 +72,9 @@ def verify_double_count_boundaries(
|
|||||||
name = str(component.get("name") or "").strip()
|
name = str(component.get("name") or "").strip()
|
||||||
destination = str(component.get("destination") or "")
|
destination = str(component.get("destination") or "")
|
||||||
destinations.setdefault(name, set()).add(destination)
|
destinations.setdefault(name, set()).add(destination)
|
||||||
|
# 집는 쪽은 이름+규격 키(「이형철근 D13」)로도 집음 — 두 키에 다 올려야 검사가 안 놂.
|
||||||
|
key = f"{name} {str(component.get('spec') or '').strip()}".strip()
|
||||||
|
destinations.setdefault(key, set()).add(destination)
|
||||||
if name in EARTHWORK_ONLY and destination != "earthwork":
|
if name in EARTHWORK_ONLY and destination != "earthwork":
|
||||||
found.append(
|
found.append(
|
||||||
f"① {label} 「{name}」 갈 곳이 {destination or '(없음)'} — "
|
f"① {label} 「{name}」 갈 곳이 {destination or '(없음)'} — "
|
||||||
|
|||||||
@@ -327,10 +327,18 @@ def composite_quantities(
|
|||||||
note = "; ".join(structure.get("notes") or []) or "구조물 원단위가 없음"
|
note = "; ".join(structure.get("notes") or []) or "구조물 원단위가 없음"
|
||||||
return [], [{"code": None, "reason": note}]
|
return [], [{"code": None, "reason": note}]
|
||||||
|
|
||||||
amounts: dict[str, tuple[float, str]] = {}
|
# ⚠ 조각 이름은 **이름+규격 키**(「이형철근 D13」)로도, 이름만(「콘크리트」)으로도 옴 — 두
|
||||||
for component in structure.get("components") or []:
|
# 색인을 함께 둠. 이름 한 칸만 키로 쓰면 규격만 다른 성분(D13·D16)이 **서로 덮어씀**.
|
||||||
|
by_key: dict[str, float] = {}
|
||||||
|
by_name: dict[str, float] = {}
|
||||||
|
key_of: dict[int, str] = {}
|
||||||
|
for index, component in enumerate(structure.get("components") or []):
|
||||||
name = str(component.get("name") or "").strip()
|
name = str(component.get("name") or "").strip()
|
||||||
amounts[name] = (float(component.get("amount") or 0.0), str(component.get("unit") or ""))
|
key = f"{name} {str(component.get('spec') or '').strip()}".strip()
|
||||||
|
amount = float(component.get("amount") or 0.0)
|
||||||
|
by_key[key] = by_key.get(key, 0.0) + amount
|
||||||
|
by_name[name] = by_name.get(name, 0.0) + amount
|
||||||
|
key_of[index] = key
|
||||||
kg_to_ton = float((mapping.unit_conversion or {}).get("kg_to_ton") or 0.001)
|
kg_to_ton = float((mapping.unit_conversion or {}).get("kg_to_ton") or 0.001)
|
||||||
|
|
||||||
parts: list[dict[str, Any]] = []
|
parts: list[dict[str, Any]] = []
|
||||||
@@ -340,14 +348,14 @@ def composite_quantities(
|
|||||||
parts.append({"code": str(spec)})
|
parts.append({"code": str(spec)})
|
||||||
continue
|
continue
|
||||||
sources = list(spec.get("from_components") or [])
|
sources = list(spec.get("from_components") or [])
|
||||||
found = [name for name in sources if name in amounts]
|
found = [name for name in sources if name in by_key or name in by_name]
|
||||||
total = sum(amounts[name][0] for name in found)
|
total = sum(by_key[name] if name in by_key else by_name[name] for name in found)
|
||||||
if spec.get("unit_from") == "kg" and spec.get("unit") == "ton":
|
if spec.get("unit_from") == "kg" and spec.get("unit") == "ton":
|
||||||
total *= kg_to_ton
|
total *= kg_to_ton
|
||||||
kinds = {
|
kinds = {
|
||||||
component.get("basis_kind")
|
component.get("basis_kind")
|
||||||
for component in structure.get("components") or []
|
for index, component in enumerate(structure.get("components") or [])
|
||||||
if str(component.get("name") or "").strip() in found
|
if key_of[index] in found or str(component.get("name") or "").strip() in found
|
||||||
}
|
}
|
||||||
suffix = spec.get("kind_suffix")
|
suffix = spec.get("kind_suffix")
|
||||||
entry: dict[str, Any] = {
|
entry: dict[str, Any] = {
|
||||||
|
|||||||
@@ -304,6 +304,8 @@ def expand_observed(
|
|||||||
+ (f" ({note})" if note else ""),
|
+ (f" ({note})" if note else ""),
|
||||||
"basis_kind": BASIS_OBSERVED,
|
"basis_kind": BASIS_OBSERVED,
|
||||||
"source": source_key,
|
"source": source_key,
|
||||||
|
# 규격은 이름과 따로(명세 13장 Ⓒ) — 「이형철근」 + 「D13」(2026-09-14 가르기).
|
||||||
|
"spec": str(item.get("spec") or ""),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
notes = [f"관측 원단위 적용 — {found.get('source_note') or source_key}"]
|
notes = [f"관측 원단위 적용 — {found.get('source_note') or source_key}"]
|
||||||
|
|||||||
@@ -53,9 +53,8 @@ DESTINATION = {
|
|||||||
RUBBLE_BASE_NAME: "unit_price",
|
RUBBLE_BASE_NAME: "unit_price",
|
||||||
# ⭐ 2026-09-14 — 갈 곳 기본값을 걷자 드러난 넷(L형수로·개거·떼흙막이 정본 줄).
|
# ⭐ 2026-09-14 — 갈 곳 기본값을 걷자 드러난 넷(L형수로·개거·떼흙막이 정본 줄).
|
||||||
# 관측 원단위(`structure_unit_observed`)가 같은 이름에 둔 갈 곳과 맞춤(두 벌이 안 갈리게).
|
# 관측 원단위(`structure_unit_observed`)가 같은 이름에 둔 갈 곳과 맞춤(두 벌이 안 갈리게).
|
||||||
# ⚠ 「이형철근 D13」 은 이름에 규격이 섞인 글자 그대로 — 인계 대응표 `from_components` 가
|
# 철근은 이름 「이형철근」 + 규격 「D13·D16」(같은 날 가름 — 대응표 조각은 이름+규격 키로 집음).
|
||||||
# 그 글자로 조각을 찾아 지금 가르면 옹벽 철근 조각이 끊김(이름 가르기는 대응표와 함께 따로).
|
"이형철근": "material",
|
||||||
"이형철근 D13": "material",
|
|
||||||
"유로폼": "unit_price", # 거푸집 계열(품셈 12-38) — 설치·해체 품
|
"유로폼": "unit_price", # 거푸집 계열(품셈 12-38) — 설치·해체 품
|
||||||
"면목": "material",
|
"면목": "material",
|
||||||
"떼": "material", # 사면 떼(자재총괄 extra)와 같은 자리 · 할증 10%(1-3-1)
|
"떼": "material", # 사면 떼(자재총괄 extra)와 같은 자리 · 할증 10%(1-3-1)
|
||||||
|
|||||||
@@ -439,7 +439,7 @@ OPEN_DITCH_FORMS: dict[str, dict[str, Any]] = {
|
|||||||
0.1508,
|
0.1508,
|
||||||
"{(0.2+0.15)÷2×0.7} + (0.21×0.13)×1 + {(0.15+0.21)÷2×0.20}×1",
|
"{(0.2+0.15)÷2×0.7} + (0.21×0.13)×1 + {(0.15+0.21)÷2×0.20}×1",
|
||||||
),
|
),
|
||||||
("이형철근 D13", "kg", 0.398, "0.2 × 2 × 0.995"),
|
("이형철근", "kg", 0.398, "0.2 × 2 × 0.995", "D13"), # 이름 · 규격 따로
|
||||||
("거푸집", "㎡", 0.738, "0.2 + 0.33 + √(0.2² + 0.06²)"),
|
("거푸집", "㎡", 0.738, "0.2 + 0.33 + √(0.2² + 0.06²)"),
|
||||||
("면목", "m", 1.0, "1"),
|
("면목", "m", 1.0, "1"),
|
||||||
),
|
),
|
||||||
@@ -540,8 +540,9 @@ def open_ditch(length_m: float, options: dict[str, Any]) -> tuple[list[Component
|
|||||||
per_m * length_m,
|
per_m * length_m,
|
||||||
destination,
|
destination,
|
||||||
f"{basis} × 연장 {length_m:g}m (m당 {per_m:g})",
|
f"{basis} × 연장 {length_m:g}m (m당 {per_m:g})",
|
||||||
|
spec="".join(spec),
|
||||||
)
|
)
|
||||||
for name, unit, per_m, basis in table["rows"]
|
for name, unit, per_m, basis, *spec in table["rows"]
|
||||||
if (destination := routed(name, notes))
|
if (destination := routed(name, notes))
|
||||||
]
|
]
|
||||||
if not options.get("ditch_spec"):
|
if not options.get("ditch_spec"):
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ def raise_material_rows(
|
|||||||
bill_line: Callable[[Money3, Decimal], Money3],
|
bill_line: Callable[[Money3, Decimal], Money3],
|
||||||
) -> None:
|
) -> None:
|
||||||
"""자재대 표의 사급·수동 단가 줄 → 본체 「자재(사급)」 묶음 줄. 가드에 걸린 줄은 사유만."""
|
"""자재대 표의 사급·수동 단가 줄 → 본체 「자재(사급)」 묶음 줄. 가드에 걸린 줄은 사유만."""
|
||||||
|
from B09_Estimation.B09_Estimation_BillOfQuantities_Rows import settle_quantity
|
||||||
|
|
||||||
sheet = result.material_sheet
|
sheet = result.material_sheet
|
||||||
picked = []
|
picked = []
|
||||||
for item in getattr(sheet, "contractor_rows", []):
|
for item in getattr(sheet, "contractor_rows", []):
|
||||||
@@ -99,7 +101,6 @@ def raise_material_rows(
|
|||||||
)
|
)
|
||||||
for index, item in enumerate(picked, start=1):
|
for index, item in enumerate(picked, start=1):
|
||||||
price = item.unit_price_krw
|
price = item.unit_price_krw
|
||||||
line = bill_line(Money3(material=price), item.total_amount)
|
|
||||||
row = BillRow(
|
row = BillRow(
|
||||||
item_no=f"{group_no}-{index}",
|
item_no=f"{group_no}-{index}",
|
||||||
level=2,
|
level=2,
|
||||||
@@ -109,6 +110,7 @@ def raise_material_rows(
|
|||||||
unit=item.unit,
|
unit=item.unit,
|
||||||
quantity=item.total_amount,
|
quantity=item.total_amount,
|
||||||
)
|
)
|
||||||
|
line = bill_line(Money3(material=price), settle_quantity(row)) # 수량 확정 뒤 금액
|
||||||
row.unit_material_krw, row.unit_labor_krw, row.unit_expense_krw = price, _ZERO, _ZERO
|
row.unit_material_krw, row.unit_labor_krw, row.unit_expense_krw = price, _ZERO, _ZERO
|
||||||
row.unit_price_krw = price
|
row.unit_price_krw = price
|
||||||
row.amount_krw = line.total
|
row.amount_krw = line.total
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from B09_Estimation.B09_Estimation_MachineProductivity_Dump import (
|
|||||||
)
|
)
|
||||||
from B09_Estimation.B09_Estimation_MaterialPrices import manual_count
|
from B09_Estimation.B09_Estimation_MaterialPrices import manual_count
|
||||||
from B09_Estimation.B09_Estimation_PriceBook import Money3
|
from B09_Estimation.B09_Estimation_PriceBook import Money3
|
||||||
|
from B09_Estimation.B09_Estimation_QuantityDigits import round_quantity
|
||||||
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
|
from B09_Estimation.B09_Estimation_Rounding import OutputPlace, round_at
|
||||||
from B09_Estimation.B09_Estimation_UnitPrice import (
|
from B09_Estimation.B09_Estimation_UnitPrice import (
|
||||||
UnitPriceBuild,
|
UnitPriceBuild,
|
||||||
@@ -52,6 +53,17 @@ def bill_line(unit: Money3, quantity) -> Money3:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def settle_quantity(row: BillRow) -> Decimal:
|
||||||
|
"""수량을 품셈 1-2-2 자리로 **반올림해 확정**하고 그 값을 돌려줌 — 금액은 확정한 수량으로 셈.
|
||||||
|
|
||||||
|
산림청고시 2025-82호 1-2-2 [주]① 「설계서 수량의 단위와 소수자리 표시는 본 표에 따르며,
|
||||||
|
**반올림하여 적용**한다」 ⇒ 수량 먼저 확정 → 금액(`bill_line` 버림). 둘을 섞지 않음(2026-09-14
|
||||||
|
브레인 판정). 종전엔 인계 전정밀(0.28181999…96)로 곱해 원 미만 절사에 1원씩 샜음.
|
||||||
|
"""
|
||||||
|
row.quantity, _ = round_quantity(row.quantity, row.name, row.unit, row.spec)
|
||||||
|
return row.quantity
|
||||||
|
|
||||||
|
|
||||||
def _sum_groups(rows: list[BillRow]) -> None:
|
def _sum_groups(rows: list[BillRow]) -> None:
|
||||||
"""머리글 줄 금액 = 그 아래 줄 금액의 합(성분마다) — 실무 내역서 계 줄. 화면은 더하지 않음.
|
"""머리글 줄 금액 = 그 아래 줄 금액의 합(성분마다) — 실무 내역서 계 줄. 화면은 더하지 않음.
|
||||||
|
|
||||||
@@ -141,7 +153,7 @@ def _composite_row(
|
|||||||
return row
|
return row
|
||||||
|
|
||||||
money = money.floored(Decimal(1))
|
money = money.floored(Decimal(1))
|
||||||
line = bill_line(money, item.quantity)
|
line = bill_line(money, settle_quantity(row))
|
||||||
_set_unit(row, money)
|
_set_unit(row, money)
|
||||||
row.unit_price_krw = round_at(money.total, OutputPlace.UNIT_PRICE_ROW)
|
row.unit_price_krw = round_at(money.total, OutputPlace.UNIT_PRICE_ROW)
|
||||||
row.amount_krw = line.total
|
row.amount_krw = line.total
|
||||||
@@ -199,7 +211,7 @@ def _structure_price_row(
|
|||||||
return row
|
return row
|
||||||
|
|
||||||
# 단가 = 호표 계금(구조물도 화면과 같은 값) · 금액 = 호표 성분 소계 × 수량(명세 7장).
|
# 단가 = 호표 계금(구조물도 화면과 같은 값) · 금액 = 호표 성분 소계 × 수량(명세 7장).
|
||||||
line = bill_line(entry["money"], item.quantity)
|
line = bill_line(entry["money"], settle_quantity(row))
|
||||||
_set_unit(row, entry["money"])
|
_set_unit(row, entry["money"])
|
||||||
row.price_code = ref
|
row.price_code = ref
|
||||||
row.parts = list(entry.get("parts") or [])
|
row.parts = list(entry.get("parts") or [])
|
||||||
@@ -544,7 +556,7 @@ def _leaf_row(
|
|||||||
row.price_code = price_code
|
row.price_code = price_code
|
||||||
|
|
||||||
unit_money = unit_prices.book.resolve(price_code)
|
unit_money = unit_prices.book.resolve(price_code)
|
||||||
line = bill_line(unit_money, item.quantity)
|
line = bill_line(unit_money, settle_quantity(row))
|
||||||
_set_unit(row, unit_money)
|
_set_unit(row, unit_money)
|
||||||
row.unit_price_krw = round_at(unit_money.total, OutputPlace.UNIT_PRICE_ROW)
|
row.unit_price_krw = round_at(unit_money.total, OutputPlace.UNIT_PRICE_ROW)
|
||||||
# 내역서 **본체** 행은 성분마다 절사 — 집계표(반올림)와 어긋나는 것이 정상.
|
# 내역서 **본체** 행은 성분마다 절사 — 집계표(반올림)와 어긋나는 것이 정상.
|
||||||
|
|||||||
@@ -16,9 +16,10 @@
|
|||||||
**항목에서 제시하는 소숫자리를 우선**」). 그래서 `override` 를 받는다. 다만 지금 공종
|
**항목에서 제시하는 소숫자리를 우선**」). 그래서 `override` 를 받는다. 다만 지금 공종
|
||||||
마스터에 「이 항목의 소수자리」를 담은 칸이 **아직 없다** — 그 칸이 생기면 여기로 흘리면 된다.
|
마스터에 「이 항목의 소수자리」를 담은 칸이 **아직 없다** — 그 칸이 생기면 여기로 흘리면 된다.
|
||||||
|
|
||||||
⚠ **모르는 종목은 손대지 않는다.** 규칙을 넓게 잡아 엉뚱한 줄까지 자르는 사고를 오늘만
|
⚠ **표에 없는 종목은 우리 기본 소수 2자리**(2026-09-14 브레인 판정 — 종전 「손대지 않음」 폐기).
|
||||||
여러 번 겪었다. 표에 없으면 `None` 을 돌려주고 **화면이 종전대로** 찍게 둔다 —
|
수량을 이 자리로 **먼저 확정한 뒤** 금액을 셈 — [주]① 이 시킨 차례이고, 명세의 「Q 를 소수 2자리로
|
||||||
「모른다」가 보이는 편이 조용히 틀리는 것보다 낫다.
|
먼저 확정한 뒤 나눔」과 같은 원칙(내역 줄 `BillOfQuantities_Rows.settle_quantity`).
|
||||||
|
못 맞춘 이름은 종전대로 로그로 드러냄.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -50,6 +51,9 @@ _DIGITS: tuple[tuple[tuple[str, ...], tuple[str, ...], int], ...] = (
|
|||||||
(("철강재", "강재"), ("kg", "㎏"), 3),
|
(("철강재", "강재"), ("kg", "㎏"), 3),
|
||||||
(("용접봉",), ("kg", "㎏"), 1),
|
(("용접봉",), ("kg", "㎏"), 1),
|
||||||
(("철근",), ("kg", "㎏"), 0),
|
(("철근",), ("kg", "㎏"), 0),
|
||||||
|
# 같은 줄의 단위 환산 — 원문 「철근 kg 정수」 = ton 소수 3자리. STmate 실무 원본 영월·울진
|
||||||
|
# 설계내역서 철근·철근운반 Ton 0.264·0.008 이 그 자리(2026-09-14 골든셋 대조 · 원문 값 아님).
|
||||||
|
(("철근",), ("ton", "톤"), 3),
|
||||||
(("볼트", "너트", "꺽쇠"), ("개",), 0),
|
(("볼트", "너트", "꺽쇠"), ("개",), 0),
|
||||||
(("철선", "철사"), ("kg", "㎏"), 2),
|
(("철선", "철사"), ("kg", "㎏"), 2),
|
||||||
(("못",), ("kg", "㎏"), 2),
|
(("못",), ("kg", "㎏"), 2),
|
||||||
@@ -68,6 +72,8 @@ _DIGITS: tuple[tuple[tuple[str, ...], tuple[str, ...], int], ...] = (
|
|||||||
#: ⚠ **면적을 1자리로 내리지 않는다** — 「토적(단면적)」은 횡단면적을 말하는 것이라
|
#: ⚠ **면적을 1자리로 내리지 않는다** — 「토적(단면적)」은 횡단면적을 말하는 것이라
|
||||||
#: 사면적·거푸집 면적까지 1자리로 자르면 틀린다. 확실한 ㎥ 만 잡는다.
|
#: 사면적·거푸집 면적까지 1자리로 자르면 틀린다. 확실한 ㎥ 만 잡는다.
|
||||||
_UNIT_ONLY = {"㎥": 2, "m3": 2}
|
_UNIT_ONLY = {"㎥": 2, "m3": 2}
|
||||||
|
#: 표에도 단위에도 안 걸리는 줄의 자리 — 우리 기본(브레인 판정 2026-09-14).
|
||||||
|
DEFAULT_DIGITS = 2
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
#: 못 맞춘 줄을 이미 알린 (이름, 단위) — 내역을 그릴 때마다 같은 로그가 쌓이지 않게.
|
#: 못 맞춘 줄을 이미 알린 (이름, 단위) — 내역을 그릴 때마다 같은 로그가 쌓이지 않게.
|
||||||
@@ -78,15 +84,16 @@ def _tight(text: str) -> str:
|
|||||||
return "".join(str(text or "").split())
|
return "".join(str(text or "").split())
|
||||||
|
|
||||||
|
|
||||||
def digits_for(name: str, unit: str, spec: str = "") -> int | None:
|
def digits_for(name: str, unit: str, spec: str = "") -> int:
|
||||||
"""그 줄의 수량 소수자리. **표에 없으면 `None`** — 지어내지 않는다.
|
"""그 줄의 수량 소수자리. 표에 없으면 단위(㎥ 2) → 그것도 없으면 우리 기본 2.
|
||||||
|
|
||||||
⚠ 이름으로 종목을 찾는다 — 품셈 1-2-2 표 자체가 종목 이름 표라 코드로 옮길 대응표가 없음
|
⚠ 이름으로 종목을 찾는다 — 품셈 1-2-2 표 자체가 종목 이름 표라 코드로 옮길 대응표가 없음
|
||||||
(금액이 아닌 표시 자리). **이름을 못 맞춘 줄은 로그**로 남긴다 — 마스터 갈래 키(1장)가
|
(금액이 아닌 표시 자리). **이름을 못 맞춘 줄은 로그**로 남긴다 — 마스터 갈래 키(1장)가
|
||||||
서면 함께 정리함(2026-09-13 브레인 판정: 이름은 그대로 두고 못 맞춘 것만 드러냄).
|
서면 함께 정리함(2026-09-13 브레인 판정: 이름은 그대로 두고 못 맞춘 것만 드러냄).
|
||||||
"""
|
"""
|
||||||
haystack = _tight(name) + _tight(spec)
|
haystack = _tight(name) + _tight(spec)
|
||||||
unit_tight = _tight(unit)
|
# 단위 대소문자는 뜻이 같음(실무 원본 「M3」·「TON」·「Ton」) — 표기만 흡수.
|
||||||
|
unit_tight = _tight(unit).lower()
|
||||||
for words, units, digits in _DIGITS:
|
for words, units, digits in _DIGITS:
|
||||||
if unit_tight not in units:
|
if unit_tight not in units:
|
||||||
continue
|
continue
|
||||||
@@ -100,9 +107,11 @@ def digits_for(name: str, unit: str, spec: str = "") -> int | None:
|
|||||||
"B09 수량 자리 — 종목 이름을 못 맞춤: 「%s」 %s → %s",
|
"B09 수량 자리 — 종목 이름을 못 맞춤: 「%s」 %s → %s",
|
||||||
name,
|
name,
|
||||||
unit,
|
unit,
|
||||||
"단위만으로 %s자리" % fallback if fallback is not None else "표시 자리 없음",
|
"단위만으로 %s자리" % fallback
|
||||||
|
if fallback is not None
|
||||||
|
else "기본 %s자리" % DEFAULT_DIGITS,
|
||||||
)
|
)
|
||||||
return _UNIT_ONLY.get(unit_tight)
|
return _UNIT_ONLY.get(unit_tight, DEFAULT_DIGITS)
|
||||||
|
|
||||||
|
|
||||||
def round_quantity(
|
def round_quantity(
|
||||||
@@ -114,11 +123,9 @@ def round_quantity(
|
|||||||
) -> tuple[Decimal, int | None]:
|
) -> tuple[Decimal, int | None]:
|
||||||
"""수량을 그 종목의 자리로 **반올림**한다.
|
"""수량을 그 종목의 자리로 **반올림**한다.
|
||||||
|
|
||||||
돌려주는 것 — (자른 값, 쓴 자리). 자리를 못 찾으면 **값을 안 건드리고** `(값, None)`.
|
돌려주는 것 — (자른 값, 쓴 자리).
|
||||||
`override` 는 품셈 **항목이 따로 제시한 자리**([주]②) — 표보다 우선한다.
|
`override` 는 품셈 **항목이 따로 제시한 자리**([주]②) — 표보다 우선한다(마스터 칸 대기).
|
||||||
"""
|
"""
|
||||||
digits = override if override is not None else digits_for(name, unit, spec)
|
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)
|
quantum = Decimal(1).scaleb(-digits)
|
||||||
return value.quantize(quantum, rounding=ROUND_HALF_UP), digits
|
return value.quantize(quantum, rounding=ROUND_HALF_UP), digits
|
||||||
|
|||||||
@@ -86,13 +86,15 @@
|
|||||||
"basis_note": "Ø50"
|
"basis_note": "Ø50"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "이형철근 D13",
|
"name": "이형철근",
|
||||||
|
"spec": "D13",
|
||||||
"unit": "kg",
|
"unit": "kg",
|
||||||
"amount": 13.45,
|
"amount": 13.45,
|
||||||
"destination": "material"
|
"destination": "material"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "이형철근 D16",
|
"name": "이형철근",
|
||||||
|
"spec": "D16",
|
||||||
"unit": "kg",
|
"unit": "kg",
|
||||||
"amount": 30.42,
|
"amount": 30.42,
|
||||||
"destination": "material"
|
"destination": "material"
|
||||||
@@ -218,7 +220,8 @@
|
|||||||
"basis_note": "원문 값(라이브러리 표기 21.28)"
|
"basis_note": "원문 값(라이브러리 표기 21.28)"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "이형철근 D13",
|
"name": "이형철근",
|
||||||
|
"spec": "D13",
|
||||||
"unit": "kg",
|
"unit": "kg",
|
||||||
"amount": 4.776,
|
"amount": 4.776,
|
||||||
"destination": "material",
|
"destination": "material",
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ def test_정본_표의_줄_이름은_전부_갈_곳_표에_있음() -> None:
|
|||||||
def test_드러난_넷이_제자리로_감() -> None:
|
def test_드러난_넷이_제자리로_감() -> None:
|
||||||
ditch, notes = open_ditch(10.0, {"ditch_spec": "L형수로 H=0.2"})
|
ditch, notes = open_ditch(10.0, {"ditch_spec": "L형수로 H=0.2"})
|
||||||
got = {component.name: component.destination for component in ditch}
|
got = {component.name: component.destination for component in ditch}
|
||||||
assert got["이형철근 D13"] == "material" and got["면목"] == "material"
|
assert got["이형철근"] == "material" and got["면목"] == "material"
|
||||||
|
assert next(c.spec for c in ditch if c.name == "이형철근") == "D13" # 이름·규격 따로
|
||||||
assert not [note for note in notes if "갈 곳이 표" in note]
|
assert not [note for note in notes if "갈 곳이 표" in note]
|
||||||
plain, _ = open_ditch(10.0, {})
|
plain, _ = open_ditch(10.0, {})
|
||||||
assert {c.name: c.destination for c in plain}["유로폼"] == "unit_price"
|
assert {c.name: c.destination for c in plain}["유로폼"] == "unit_price"
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""철근 이름·규격 가르기(2026-09-14 · 목록 3+4) — **가르기 전후 물량이 그대로**인지 잼.
|
||||||
|
|
||||||
|
「이형철근 D13」 한 칸을 이름 「이형철근」 + 규격 「D13」 으로 가르면 셋이 같은 글자에 묶여 있어
|
||||||
|
하나만 고치면 조용히 어긋남(브레인 경고): ① 묶음 조각 `from_components` · `amounts[name]`
|
||||||
|
(D13·D16 이 서로 덮어씀) ② 이중계상 경계 검사 `_pickers`·`destinations`(못 집으면 검사가 놂)
|
||||||
|
③ 자재총괄 줄·할증. 이 시험은 **가르기 전 코드에서 먼저 초록**으로 만들고 가른 뒤에도 초록이어야 함.
|
||||||
|
|
||||||
|
옹벽 반중력식 10m D13 13.45 · D16 30.42 ㎏/m → 조각 0.4387 ton · 자재 134.5 · 304.2 ㎏
|
||||||
|
집수정 □형 Ø800 1개소 D13 4.776 ㎏
|
||||||
|
L형수로 H=0.2 10m D13 0.398 ㎏/m → 3.98 ㎏
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, load_mapping # noqa: E402
|
||||||
|
from B08_Quantity.B08_Quantity_Engine_Handoff_Boundaries import ( # noqa: E402
|
||||||
|
verify_double_count_boundaries,
|
||||||
|
)
|
||||||
|
from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table # noqa: E402
|
||||||
|
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _구조물들() -> dict:
|
||||||
|
return build_unit(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"structure_id": "w1",
|
||||||
|
"type_id": "retaining_wall",
|
||||||
|
"start_m": 100.0,
|
||||||
|
"end_m": 110.0,
|
||||||
|
"options": {"form": "반중력식", "height_m": 2.0, "length_m": 10.0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"structure_id": "b1",
|
||||||
|
"type_id": "pipe_inlet_basin",
|
||||||
|
"start_m": 200.0,
|
||||||
|
"end_m": 200.0,
|
||||||
|
"options": {
|
||||||
|
"inlet_basin_form": "□형(기본형)",
|
||||||
|
"inlet_basin_material": "콘크리트",
|
||||||
|
"pipe_diameter_mm": "800",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"structure_id": "d1",
|
||||||
|
"type_id": "open_ditch",
|
||||||
|
"start_m": 300.0,
|
||||||
|
"end_m": 310.0,
|
||||||
|
"options": {"ditch_spec": "L형수로 H=0.2", "length_m": 10.0},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{"retaining_wall": "옹벽", "pipe_inlet_basin": "집수정", "open_ditch": "개거"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_옹벽_철근_조각은_D13_D16_을_다_모아_ton() -> None:
|
||||||
|
row = next(
|
||||||
|
r
|
||||||
|
for r in build_handoff(unit_quantity_table=_구조물들())["work_items"]
|
||||||
|
if r.get("composite_parts")
|
||||||
|
)
|
||||||
|
rebar = next(p for p in row["composite_parts"] if "12-03" in str(p.get("code")))
|
||||||
|
assert rebar["quantity"] == pytest.approx(0.4387) and not rebar.get("not_ready")
|
||||||
|
from B08_Quantity.B08_Quantity_Engine_Handoff import structure_kind
|
||||||
|
|
||||||
|
wall = next(s for s in _구조물들()["structures"] if s["type_id"] == "retaining_wall")
|
||||||
|
assert structure_kind(wall) == "철근구조물"
|
||||||
|
|
||||||
|
|
||||||
|
def test_자재총괄_철근_줄_물량과_할증이_그대로() -> None:
|
||||||
|
table = build_table(_구조물들())
|
||||||
|
rebar = {
|
||||||
|
row["supply_key"]: (row["net_amount"], row["surcharge_pct"], row["total_amount"])
|
||||||
|
for row in table["rows"]
|
||||||
|
if row["supply_key"].startswith("이형철근")
|
||||||
|
}
|
||||||
|
assert set(rebar) == {"이형철근 D13", "이형철근 D16"} # 관급구분·단가 키는 전후 같은 글자
|
||||||
|
assert rebar["이형철근 D13"][0] == pytest.approx(134.5 + 4.776 + 3.98)
|
||||||
|
assert rebar["이형철근 D16"][0] == pytest.approx(304.2)
|
||||||
|
assert rebar["이형철근 D13"][1] == 3 and rebar["이형철근 D16"][1] == 3 # 1-3-1 이형철근
|
||||||
|
assert "이형철근" not in " ".join(table["missing_rate_materials"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_경계_검사가_철근을_여전히_집는다() -> None:
|
||||||
|
"""철근이 토공 축으로 잘못 가면 묶음 조각이 또 센다고 걸려야 함 — 못 집으면 검사가 놂."""
|
||||||
|
unit = copy.deepcopy(_구조물들())
|
||||||
|
wall = next(s for s in unit["structures"] if s["type_id"] == "retaining_wall")
|
||||||
|
for component in wall["components"]:
|
||||||
|
if str(component["name"]).startswith("이형철근"):
|
||||||
|
component["destination"] = "earthwork"
|
||||||
|
found = verify_double_count_boundaries(unit, None, load_mapping())
|
||||||
|
assert any("이형철근" in text and "묶음 조각" in text for text in found), found
|
||||||
|
assert not verify_double_count_boundaries(_구조물들(), None, load_mapping())
|
||||||
|
|
||||||
|
|
||||||
|
def test_가른_뒤_이름과_규격이_따로_서고_할증은_별칭_없이_이어짐() -> None:
|
||||||
|
table = build_table(_구조물들())
|
||||||
|
rows = [(r["name"], r["spec"]) for r in table["rows"] if r["name"].startswith("이형철근")]
|
||||||
|
assert sorted(rows) == [("이형철근", "D13"), ("이형철근", "D16")]
|
||||||
|
unit = _구조물들()
|
||||||
|
specs = {
|
||||||
|
(c["name"], c.get("spec"))
|
||||||
|
for s in unit["structures"]
|
||||||
|
for c in s["components"]
|
||||||
|
if str(c["name"]).startswith("이형철근")
|
||||||
|
}
|
||||||
|
assert specs == {("이형철근", "D13"), ("이형철근", "D16")}
|
||||||
@@ -109,8 +109,9 @@ def test_사급_자재총괄_줄은_본체_자재_줄로_서고_관급은_수량
|
|||||||
group = next(r for r in result.rows if r.name == "자재(사급)")
|
group = next(r for r in result.rows if r.name == "자재(사급)")
|
||||||
rows = [r for r in result.rows if r.item_no.startswith(f"{group.item_no}-")]
|
rows = [r for r in result.rows if r.item_no.startswith(f"{group.item_no}-")]
|
||||||
assert [(r.name, r.spec) for r in rows] == [("각재", "50×50")] # 판재 단가 없음 · 관급 안 올림
|
assert [(r.name, r.spec) for r in rows] == [("각재", "50×50")] # 판재 단가 없음 · 관급 안 올림
|
||||||
assert rows[0].material_krw == Decimal("174460") # 0.2684 × 650,000
|
# 수량을 1-2-2 자리(㎥ 2자리)로 반올림해 확정한 뒤 곱함 — 0.2684 → 0.27 × 650,000
|
||||||
assert rows[0].unconfirmed == 1 and result.direct_material_krw == Decimal("174460")
|
assert rows[0].quantity == Decimal("0.27") and rows[0].material_krw == Decimal("175500")
|
||||||
|
assert rows[0].unconfirmed == 1 and result.direct_material_krw == Decimal("175500")
|
||||||
assert any(m["name"] == "판재 T12" and "단가 없음" in m["reason"] for m in result.missing)
|
assert any(m["name"] == "판재 T12" and "단가 없음" in m["reason"] for m in result.missing)
|
||||||
sheet = result.material_sheet
|
sheet = result.material_sheet
|
||||||
assert (
|
assert (
|
||||||
@@ -162,3 +163,27 @@ def test_목록은_자원_축_자재와_사라진_저장_줄() -> None:
|
|||||||
"material_sheet",
|
"material_sheet",
|
||||||
)
|
)
|
||||||
assert sheet_row["supply_type"] == "contractor_supplied" and rows["못"]["missing"] is True
|
assert sheet_row["supply_type"] == "contractor_supplied" and rows["못"]["missing"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_내역_수량은_자리로_반올림해_확정한_뒤_금액은_버림() -> None:
|
||||||
|
"""1-2-2 [주]① 수량 반올림 → 금액 버림 — 둘을 섞지 않음(2026-09-14 브레인 판정).
|
||||||
|
|
||||||
|
인계 전정밀 0.28181999…96 로 곱하면 183,182 원(1원 샘) — 확정 수량 0.28 × 650,000 = 182,000.
|
||||||
|
"""
|
||||||
|
from B09_Estimation.B09_Estimation_BillOfQuantities import build_bill
|
||||||
|
from B09_Estimation.B09_Estimation_QuantityDigits import digits_for
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"work_items": [],
|
||||||
|
"materials": [
|
||||||
|
_자재("각재", "50×50", "0.28181999999999996", "contractor_supplied", ["비탈 규준틀"])
|
||||||
|
],
|
||||||
|
}
|
||||||
|
result = build_bill(
|
||||||
|
payload, build=cached_build(), material_prices={"각재 50×50": {"price_krw": "650000"}}
|
||||||
|
)
|
||||||
|
row = next(r for r in result.rows if r.name == "각재")
|
||||||
|
assert (row.quantity, row.amount_krw) == (Decimal("0.28"), Decimal("182000"))
|
||||||
|
assert row.as_dict()["quantity"] == "0.28" # 계약·기성이 읽는 수량도 확정값
|
||||||
|
assert digits_for("낯선종목", "개") == 2 # 표에 없는 종목은 우리 기본 2자리
|
||||||
|
assert digits_for("돌쌓기(찰)", "㎡") == 1 and digits_for("철근", "kg") == 0
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""수량 자리 맞춤 골든 대조 — STmate 실무 원본 내역 수량이 우리 자리 규칙으로 **안 바뀌어야** 함.
|
||||||
|
|
||||||
|
2026-09-14 브레인 판정 — 내역 수량은 1-2-2 자리로 반올림해 확정한 뒤 금액(버림). 골든 금액 시험
|
||||||
|
(`test_b09_golden_stmate`)은 원본 수량을 `bill_line` 에 바로 넣어 이 길을 안 거치므로, 원본 수량
|
||||||
|
자체로 자리 규칙을 따로 잰다(321줄 · 철근 ton 소수 3자리가 이 대조에서 드러남).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from test_b09_golden_stmate import _bill_rows, _num
|
||||||
|
|
||||||
|
from B09_Estimation.B09_Estimation_QuantityDigits import round_quantity
|
||||||
|
|
||||||
|
|
||||||
|
def test_실무_원본_내역_수량이_자리_규칙으로_안_바뀐다() -> None:
|
||||||
|
rows = _bill_rows()
|
||||||
|
if not rows:
|
||||||
|
pytest.skip("실무 원본 XLSX 가 없음")
|
||||||
|
changed = []
|
||||||
|
for name, row in rows:
|
||||||
|
quantity, unit = _num(row[3]), str(row[4] or "").strip()
|
||||||
|
if quantity is None or unit == "%":
|
||||||
|
continue
|
||||||
|
settled, digits = round_quantity(quantity, str(row[1] or ""), unit, str(row[2] or ""))
|
||||||
|
if settled != quantity:
|
||||||
|
changed.append((name, row[1], unit, str(quantity), digits))
|
||||||
|
assert len(rows) >= 300, len(rows)
|
||||||
|
assert not changed, (len(changed), changed[:5])
|
||||||
Reference in New Issue
Block a user