Merge remote-tracking branch 'origin/sub_desktop_1' into main_desktop_1
This commit is contained in:
@@ -208,8 +208,16 @@ def _leaf_row(
|
||||
row.note = f"이 공종엔 일위대가가 없고 한 층 아래에 있습니다 — 후보: {names}"
|
||||
reason = f"일위대가가 하위 공종에 있음(후보 {len(children)}건)"
|
||||
else:
|
||||
row.note = "일위대가가 아직 없습니다 — 금액을 0 으로 때우지 않습니다."
|
||||
reason = "일위대가 없음"
|
||||
# ⚠ 「아직 안 만든 것」과 「성분이 빠져 못 세운 것」은 **할 일이 다르다**.
|
||||
# 뭉뚱그리면 사용자가 무엇을 기다려야 하는지 못 읽는다(2026-09-08 산마루측구:
|
||||
# 표에 수량 칸이 비어 있고 「철근가공조립(간단)의 30 %」처럼 참조로만 적힌 자리).
|
||||
gap = unit_prices.component_gaps.get(node.code)
|
||||
if gap:
|
||||
row.note = f"성분이 빠져 단가를 못 세웠습니다 — {gap}"
|
||||
reason = f"성분 미확보 — {gap}"
|
||||
else:
|
||||
row.note = "일위대가가 아직 없습니다 — 금액을 0 으로 때우지 않습니다."
|
||||
reason = "일위대가 없음"
|
||||
result.missing.append(
|
||||
{
|
||||
"name": row.name,
|
||||
|
||||
@@ -299,3 +299,62 @@ _RE_RATIO = re.compile(r"[((]\s*(\d+(?:\.\d+)?)\s*%\s*[))]")
|
||||
def _ratio_of(cell: str) -> Decimal | None:
|
||||
found = _RE_RATIO.search(str(cell))
|
||||
return Decimal(found.group(1)) if found else None
|
||||
|
||||
|
||||
def attach_machine_share(
|
||||
book: Any,
|
||||
factor_gaps: dict[str, FactorGap],
|
||||
cycle_factors: dict[str, CycleFactors],
|
||||
master: dict[str, Any],
|
||||
work_item_code: str,
|
||||
title_code: str,
|
||||
) -> Decimal:
|
||||
"""시공능력 공식(8-1-4)으로 **장비 몫**을 붙인다. 붙인 비율(%)을 돌려준다.
|
||||
|
||||
⚠ 불도저 쪽(`attach_dozer_share`)과 **자리를 나눠 쓴다** — 두 식이 한 일위대가에
|
||||
붙으면 장비를 두 번 세는 것이 된다.
|
||||
|
||||
계수가 다 안 서면 **아무것도 안 붙이고 0 을 돌려준다** — 그러면 그 공종은
|
||||
`partial_ratio` 에 남아 내역서에서 금액이 안 붙는다(지어낸 값이 서는 것보다 낫다).
|
||||
"""
|
||||
node = next(
|
||||
(w for w in master.get("work_items", []) if w.get("work_item_code") == work_item_code),
|
||||
None,
|
||||
)
|
||||
if node is None:
|
||||
return _ZERO
|
||||
|
||||
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail
|
||||
|
||||
for table in node.get("tables", []):
|
||||
factors = extract_cycle_factors(work_item_code, table)
|
||||
if not isinstance(factors, CycleFactors):
|
||||
if isinstance(factors, FactorGap):
|
||||
factor_gaps[work_item_code] = factors
|
||||
continue
|
||||
hourly_code = f"X-{factors.machine_code}"
|
||||
if hourly_code not in book.titles:
|
||||
# 기계 층이 안 섰다 — 지어내지 않고 못 붙인 채로 둔다.
|
||||
factor_gaps[work_item_code] = FactorGap(
|
||||
work_item_code=work_item_code,
|
||||
pum_table_id=factors.pum_table_id,
|
||||
missing=("시간당 사용료",),
|
||||
note=f"{factors.machine_name} 의 시간당 사용료가 아직 안 섰습니다.",
|
||||
)
|
||||
continue
|
||||
share = (
|
||||
Decimal(1)
|
||||
if factors.machine_ratio_pct is None
|
||||
else Decimal(str(factors.machine_ratio_pct)) / Decimal(100)
|
||||
)
|
||||
book.add_detail(
|
||||
PriceDetail(
|
||||
title_code,
|
||||
hourly_code,
|
||||
machine_hours_per_unit(factors) * share,
|
||||
note=factors.formula_text,
|
||||
)
|
||||
)
|
||||
cycle_factors[work_item_code] = factors
|
||||
return share * Decimal(100)
|
||||
return _ZERO
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
"""B09 원가계산 — **불도저** 시공능력 (건설품셈 8-2-1).
|
||||
|
||||
**굴착기(8-1-4)와 식이 다르다.** 한 파일에 두면 700 줄을 넘고, 무엇보다 두 식이
|
||||
섞여 읽힌다 — 실제로 임도 불도저 표(FP-10-11)가 굴착기 식으로 읽혀 「K·Cm 없음」으로
|
||||
잘못 진단되고 있었다(2026-09-08).
|
||||
|
||||
Q = 60 ÷ cm · (q₀ × e) · f · E cm = L/V1 + L/V2 + t
|
||||
t = 기어 변속시간 0.25 분
|
||||
|
||||
q₀ 거리를 고려하지 않은 삽날 용량(㎥) · e 운반거리계수 · L 운반거리(m)
|
||||
V1 전진속도(m/분) · V2 후진속도(m/분) · f 체적환산계수 · E 작업효율
|
||||
|
||||
⚠ **밑수가 60(분)이다.** 굴착기는 3600(초)이라 섞으면 60 배 어긋난다.
|
||||
|
||||
⚠ **표가 기종 이름을 안 적는다.** 임도 표는 `q₀`·`V1`·`V2` 만 주므로 8-2-1 표에서
|
||||
규격을 **되짚는다** — 하나로 안 좁혀지면 안 고른다(지어내지 않는다).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
|
||||
from B09_Estimation.B09_Estimation_MachineProductivity import (
|
||||
CycleFactors,
|
||||
FactorGap,
|
||||
ProductivityError,
|
||||
_first_measure,
|
||||
extract_cycle_factors,
|
||||
parse_measure,
|
||||
)
|
||||
|
||||
_ZERO = Decimal(0)
|
||||
|
||||
|
||||
#: 전진·후진 속도 (m/분) — 8-2-1 2.가·나. **단(gear)마다 다르다.**
|
||||
#: {규격(ton): {단: (전진, 후진)}}
|
||||
#: ⚠ 무한궤도 4·13 톤과 타이어 전 규격은 **후진 3단이 표에 없다**(「-」) — 그 단은
|
||||
#: 아예 안 담는다. 담으면 없는 속도로 싸이클이 서서 값이 조용히 틀린다.
|
||||
#: ⚠ **단은 작업이 정한다** (8-2-1 2.가 [주]) — 굴착·굴착운반 1단, 흐트러진 토사운반
|
||||
#: 2단, 평탄 정지·전압 3단. 그래서 임도 표가 「55m/분(2단)」처럼 단을 적어 온다.
|
||||
_DOZER_SPEEDS = {
|
||||
"무한궤도": {
|
||||
Decimal("4"): {1: (Decimal(40), Decimal(63)), 2: (Decimal(57), Decimal(85))},
|
||||
Decimal("7"): {
|
||||
1: (Decimal(43), Decimal(53)),
|
||||
2: (Decimal(67), Decimal(78)),
|
||||
3: (Decimal(92), Decimal(107)),
|
||||
},
|
||||
Decimal("10"): {
|
||||
1: (Decimal(42), Decimal(50)),
|
||||
2: (Decimal(64), Decimal(75)),
|
||||
3: (Decimal(88), Decimal(105)),
|
||||
},
|
||||
Decimal("12"): {
|
||||
1: (Decimal(40), Decimal(48)),
|
||||
2: (Decimal(55), Decimal(70)),
|
||||
3: (Decimal(75), Decimal(100)),
|
||||
},
|
||||
Decimal("13"): {1: (Decimal(40), Decimal(48)), 2: (Decimal(55), Decimal(70))},
|
||||
Decimal("19"): {
|
||||
1: (Decimal(40), Decimal(46)),
|
||||
2: (Decimal(55), Decimal(70)),
|
||||
3: (Decimal(75), Decimal(98)),
|
||||
},
|
||||
Decimal("32"): {
|
||||
1: (Decimal(40), Decimal(43)),
|
||||
2: (Decimal(52), Decimal(58)),
|
||||
3: (Decimal(70), Decimal(78)),
|
||||
},
|
||||
},
|
||||
"타이어": {
|
||||
Decimal("15"): {1: (Decimal(83), Decimal(92)), 2: (Decimal(200), Decimal(125))},
|
||||
Decimal("28"): {1: (Decimal(92), Decimal(92)), 2: (Decimal(200), Decimal(200))},
|
||||
Decimal("33"): {1: (Decimal(92), Decimal(110)), 2: (Decimal(210), Decimal(250))},
|
||||
},
|
||||
}
|
||||
#: 기어 변속시간 (분) — 8-2-1 「t: 기어 변속시간(0.25분)」
|
||||
_DOZER_GEAR_SHIFT_MIN = Decimal("0.25")
|
||||
_MINUTES_PER_HOUR = Decimal(60)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DozerFactors:
|
||||
"""불도저 시공능력 계수 한 벌 (건설품셈 8-2-1)."""
|
||||
|
||||
work_item_code: str
|
||||
blade_capacity_m3: Decimal # q₀
|
||||
distance_factor: Decimal # e
|
||||
volume_factor: Decimal # f
|
||||
efficiency: Decimal # E
|
||||
haul_distance_m: Decimal # L
|
||||
forward_speed_m_min: Decimal # V1
|
||||
reverse_speed_m_min: Decimal # V2
|
||||
#: 표가 기종 이름을 안 적어 `q゚`·`V1`·`V2` 로 되짚은 결과(`resolve_dozer`).
|
||||
machine_code: str = ""
|
||||
machine_name: str = ""
|
||||
|
||||
@property
|
||||
def cycle_minutes(self) -> Decimal:
|
||||
"""cm = L/V1 + L/V2 + t — **분**이다."""
|
||||
return (
|
||||
self.haul_distance_m / self.forward_speed_m_min
|
||||
+ self.haul_distance_m / self.reverse_speed_m_min
|
||||
+ _DOZER_GEAR_SHIFT_MIN
|
||||
)
|
||||
|
||||
@property
|
||||
def formula_text(self) -> str:
|
||||
return (
|
||||
f"Q = 60 ÷ {self.cycle_minutes:.4f}분 × ({self.blade_capacity_m3} × "
|
||||
f"{self.distance_factor}) × {self.volume_factor} × {self.efficiency}"
|
||||
)
|
||||
|
||||
|
||||
def dozer_hourly_output(factors: DozerFactors) -> Decimal:
|
||||
"""불도저 시간당 작업량 `Q` (㎥/hr). **밑수는 60(분)** 이다."""
|
||||
if factors.cycle_minutes <= 0:
|
||||
raise ProductivityError(f"{factors.work_item_code}: 싸이클 시간이 0 이하입니다.")
|
||||
blade = factors.blade_capacity_m3 * factors.distance_factor
|
||||
output = (
|
||||
_MINUTES_PER_HOUR
|
||||
/ factors.cycle_minutes
|
||||
* blade
|
||||
* factors.volume_factor
|
||||
* factors.efficiency
|
||||
)
|
||||
if output <= 0:
|
||||
raise ProductivityError(f"{factors.work_item_code}: 시간당 작업량이 {output} 입니다.")
|
||||
return output
|
||||
|
||||
|
||||
def dozer_speeds(
|
||||
tonnage: Decimal, gear: int = 2, track: str = "무한궤도"
|
||||
) -> tuple[Decimal, Decimal] | None:
|
||||
"""그 규격·그 단의 전진·후진 속도. 표에 없으면 `None` — 지어내지 않는다."""
|
||||
return _DOZER_SPEEDS.get(track, {}).get(tonnage, {}).get(gear)
|
||||
|
||||
|
||||
#: 삽날 용량 q゚(㎥) — 8-2-1 1.가. **규격을 되짚는 열쇠**로도 쓴다.
|
||||
#: ⚠ 무한궤도 10 톤과 13 톤이 둘 다 1.5 ㎥ 라 **용량만으로는 못 가른다** — 속도로 마저 가른다.
|
||||
_DOZER_BLADE_M3 = {
|
||||
("무한궤도", Decimal("4")): Decimal("0.5"), # 초습지
|
||||
("무한궤도", Decimal("7")): Decimal("1.1"),
|
||||
("무한궤도", Decimal("10")): Decimal("1.5"),
|
||||
("무한궤도", Decimal("12")): Decimal("2.0"),
|
||||
("무한궤도", Decimal("13")): Decimal("1.5"), # 습지
|
||||
("무한궤도", Decimal("19")): Decimal("3.2"),
|
||||
("무한궤도", Decimal("32")): Decimal("5.5"),
|
||||
("타이어", Decimal("15")): Decimal("3.1"),
|
||||
("타이어", Decimal("28")): Decimal("4.0"),
|
||||
("타이어", Decimal("33")): Decimal("5.7"),
|
||||
}
|
||||
|
||||
#: 습지·초습지 갈래는 카탈로그 이름이 따로다 — 「습지 불도저」.
|
||||
_DOZER_WET_TONS = (Decimal("4"), Decimal("13"))
|
||||
|
||||
#: 표의 머리말. ⚠ **`e` 와 `E` 는 대소문자만 다르고 뜻이 전혀 다르다** —
|
||||
#: `e` 는 운반거리계수, `E` 는 작업효율이다. 그래서 이 표는 **소문자로 내려 읽으면 안 된다**
|
||||
#: (굴착기 쪽 `extract_cycle_factors` 는 내려 읽는다 — 그쪽엔 `e` 가 없어 안전하다).
|
||||
_DOZER_SINGLE_KEYS = {
|
||||
"L": "L",
|
||||
"q0": "q0",
|
||||
"q゚": "q0",
|
||||
"q₀": "q0",
|
||||
"e": "e",
|
||||
"V1": "V1",
|
||||
"V2": "V2",
|
||||
"t": "t",
|
||||
}
|
||||
#: 갈래를 거느리는 머리말 — 「E | 토사 | 0.55」 아래에 「암석 | 0.25」가 딸려 온다.
|
||||
_DOZER_GROUP_KEYS = ("E", "f")
|
||||
|
||||
_RE_GEAR = re.compile(r"(\d+)\s*단")
|
||||
|
||||
|
||||
def resolve_dozer(
|
||||
blade_m3: Decimal,
|
||||
forward: Decimal,
|
||||
reverse: Decimal,
|
||||
gear: int = 2,
|
||||
) -> tuple[str, str] | None:
|
||||
"""삽날 용량과 속도로 **불도저 기종을 되짚는다**.
|
||||
|
||||
임도 품셈 표는 기종 이름을 안 적고 `q゚`·`V1`·`V2` 만 준다. 그 셋이 8-2-1 표에서
|
||||
한 규격만 가리킬 때 그 기종으로 본다 — **둘 이상이면 안 고른다**(지어내지 않는다).
|
||||
|
||||
q゚ 3.2㎥ + 55/70 m/분(2단) → 불도저(무한궤도) 19 톤
|
||||
|
||||
⚠ **단(gear)까지 맞아야 한다** — 같은 삽날이라도 단이 다르면 다른 규격을 가리킨다.
|
||||
"""
|
||||
candidates = []
|
||||
for (track, tonnage), blade in _DOZER_BLADE_M3.items():
|
||||
if blade != blade_m3:
|
||||
continue
|
||||
if dozer_speeds(tonnage, gear, track) == (forward, reverse):
|
||||
candidates.append((track, tonnage))
|
||||
if len(candidates) != 1:
|
||||
return None
|
||||
|
||||
track, tonnage = candidates[0]
|
||||
if track == "무한궤도" and tonnage in _DOZER_WET_TONS:
|
||||
wanted = "습지 불도저"
|
||||
else:
|
||||
wanted = f"불도저({track})"
|
||||
for code, machine in load_machine_catalog().machines.items():
|
||||
if machine.name == wanted and parse_measure(machine.specification) == tonnage:
|
||||
return code, f"{machine.name} {machine.specification}"
|
||||
return None
|
||||
|
||||
|
||||
def dozer_machine_hours_per_unit(factors: DozerFactors) -> Decimal:
|
||||
"""수량 1단위당 불도저 소요시간(hr)."""
|
||||
return Decimal(1) / dozer_hourly_output(factors)
|
||||
|
||||
|
||||
def extract_dozer_factors(
|
||||
work_item_code: str,
|
||||
table: dict[str, Any],
|
||||
) -> dict[str, DozerFactors] | FactorGap | None:
|
||||
"""불도저 표 하나에서 **갈래별** 계수를 뽑는다 (품셈 8-2-1).
|
||||
|
||||
돌려주는 것 — 불도저 표가 아니면 `None`, 계수가 모자라면 `FactorGap`,
|
||||
다 서면 `{갈래: DozerFactors}` (토사·파쇄암·발파암처럼 `f` 갈래마다 한 벌).
|
||||
|
||||
⚠ **딸린 줄은 한 칸 왼쪽으로 밀려 온다** — 머리 줄은 「f | 토사 | 1/1.30」이고
|
||||
다음 줄은 「파쇄암 | 1/1.35」다. 자리를 그대로 읽으면 갈래가 통째로 빠진다.
|
||||
"""
|
||||
rows = [[str(c).strip() for c in row] for row in (table.get("raw_row") or [])]
|
||||
if not rows:
|
||||
return None
|
||||
|
||||
single: dict[str, Decimal] = {}
|
||||
groups: dict[str, dict[str, Decimal]] = {"E": {}, "f": {}}
|
||||
gears: dict[str, int] = {}
|
||||
current: str | None = None
|
||||
|
||||
for cells in rows:
|
||||
if not cells or not cells[0]:
|
||||
continue
|
||||
head = cells[0]
|
||||
if head in _DOZER_SINGLE_KEYS:
|
||||
current = None
|
||||
key = _DOZER_SINGLE_KEYS[head]
|
||||
value = _first_measure(cells[1:])
|
||||
if value is not None:
|
||||
single[key] = value
|
||||
found = _RE_GEAR.search(" ".join(cells[1:]))
|
||||
if found:
|
||||
gears[key] = int(found.group(1))
|
||||
elif head in _DOZER_GROUP_KEYS:
|
||||
current = head
|
||||
label = cells[1] if len(cells) > 1 else ""
|
||||
value = _first_measure(cells[2:])
|
||||
if value is None:
|
||||
# 갈래가 없는 표 — 「f | 1/1.3」처럼 값이 바로 붙는다. 갈래 이름은 빈 문자열.
|
||||
value = _measure_with_adjustment(label)
|
||||
label = ""
|
||||
if value is not None:
|
||||
groups[head][_normalize_label(label)] = value
|
||||
elif current is not None:
|
||||
# 딸린 줄 — 「파쇄암 | 1/1.35」. 값이 없으면 갈래 줄이 아니다.
|
||||
value = _first_measure(cells[1:])
|
||||
if value is not None:
|
||||
groups[current][_normalize_label(head)] = value
|
||||
|
||||
if not groups["f"] or "q0" not in single or "V1" not in single:
|
||||
return None # 불도저 표가 아니다
|
||||
|
||||
missing = [key for key in ("L", "q0", "e", "V1", "V2", "t") if key not in single]
|
||||
if not groups["E"]:
|
||||
missing.append("E(작업효율)")
|
||||
if missing:
|
||||
return FactorGap(
|
||||
work_item_code=work_item_code,
|
||||
pum_table_id=str(table.get("pum_table_id", "")),
|
||||
missing=tuple(missing),
|
||||
note="불도저 표(8-2-1)인데 계수가 모자랍니다.",
|
||||
)
|
||||
|
||||
gear = gears.get("V1", gears.get("V2", 2))
|
||||
machine = resolve_dozer(single["q0"], single["V1"], single["V2"], gear)
|
||||
if machine is None:
|
||||
return FactorGap(
|
||||
work_item_code=work_item_code,
|
||||
pum_table_id=str(table.get("pum_table_id", "")),
|
||||
missing=("기계",),
|
||||
note=(
|
||||
f"삽날 {single['q0']}㎥ · {single['V1']}/{single['V2']}m/분({gear}단) "
|
||||
"으로는 규격이 하나로 안 좁혀집니다."
|
||||
),
|
||||
)
|
||||
|
||||
built: dict[str, DozerFactors] = {}
|
||||
for label, volume_factor in groups["f"].items():
|
||||
efficiency = _dozer_efficiency(groups["E"], label)
|
||||
if efficiency is None:
|
||||
continue # 그 갈래의 작업효율이 없다 — 가운데값을 지어내지 않는다
|
||||
built[label] = DozerFactors(
|
||||
work_item_code=work_item_code,
|
||||
blade_capacity_m3=single["q0"],
|
||||
distance_factor=single["e"],
|
||||
volume_factor=volume_factor,
|
||||
efficiency=efficiency,
|
||||
haul_distance_m=single["L"],
|
||||
forward_speed_m_min=single["V1"],
|
||||
reverse_speed_m_min=single["V2"],
|
||||
machine_code=machine[0],
|
||||
machine_name=machine[1],
|
||||
)
|
||||
return built or FactorGap(
|
||||
work_item_code=work_item_code,
|
||||
pum_table_id=str(table.get("pum_table_id", "")),
|
||||
missing=("E(갈래별 작업효율)",),
|
||||
note="`f` 갈래에 맞는 작업효율을 못 골랐습니다.",
|
||||
)
|
||||
|
||||
|
||||
_RE_ADJUSTED = re.compile(r"^\(?\s*(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)\s*\)?$")
|
||||
|
||||
|
||||
def _measure_with_adjustment(cell: str) -> Decimal | None:
|
||||
"""계수 한 칸. **「(0.55-0.1)」 같은 보정식도 읽는다.**
|
||||
|
||||
8-2-1 [주]④⑤ 가 「정지작업을 겸하면 0.1 을, 터파기는 0.05 를 뺀 값」이라 적어,
|
||||
임도 표가 뺄셈을 그대로 적어 온다(비다듬기·정지 9-17-2). ⚠ **범위(「0.55∼0.45」)와
|
||||
헷갈리면 안 된다** — 범위는 확정값이 아니라 `parse_measure` 가 `None` 을 낸다.
|
||||
여기서 읽는 것은 **하이픈 뺄셈 한 가지**뿐이다.
|
||||
"""
|
||||
found = _RE_ADJUSTED.match(" ".join(str(cell).split()))
|
||||
if found:
|
||||
return Decimal(found.group(1)) - Decimal(found.group(2))
|
||||
return parse_measure(cell)
|
||||
|
||||
|
||||
def _normalize_label(text: str) -> str:
|
||||
return "".join(str(text).split())
|
||||
|
||||
|
||||
def _dozer_efficiency(efficiencies: dict[str, Decimal], label: str) -> Decimal | None:
|
||||
"""그 갈래의 작업효율 `E`.
|
||||
|
||||
⚠ 표가 `f` 는 「토사·파쇄암·발파암」으로 잘게 주고 `E` 는 「토사·암석」으로 굵게 준다.
|
||||
그래서 **암 갈래는 「암석」 줄을 쓴다** — 그 표가 암을 한 값으로 묶어 준 것이다.
|
||||
"""
|
||||
if label in efficiencies:
|
||||
return efficiencies[label]
|
||||
if "암" in label:
|
||||
for key, value in efficiencies.items():
|
||||
if "암" in key:
|
||||
return value
|
||||
return efficiencies.get("토사") if len(efficiencies) == 1 else None
|
||||
|
||||
|
||||
def dozer_variants(node: dict[str, Any]) -> list[str]:
|
||||
"""그 공종이 불도저 공식으로 세울 수 있는 갈래 이름들. 아니면 빈 목록."""
|
||||
labels: list[str] = []
|
||||
for table in node.get("tables", []):
|
||||
found = extract_dozer_factors(str(node.get("work_item_code", "")), table)
|
||||
if isinstance(found, dict):
|
||||
labels.extend(label for label in found if label not in labels)
|
||||
return labels
|
||||
|
||||
|
||||
def formula_machine_codes(master: dict[str, Any]) -> set[str]:
|
||||
"""**공식표에서만 드러나는 기종 코드.**
|
||||
|
||||
⚠ 자원 축에는 안 나온다 — 표가 기계를 「줄」로 안 적고 계수로만 적기 때문이다.
|
||||
이 코드를 시간당 사용료 층에 안 넣으면, 공식은 다 서 놓고 **붙일 사용료가 없어**
|
||||
빈 일위대가가 남는다(2026-09-08 불도저 운반에서 실제로 그랬다).
|
||||
"""
|
||||
codes: set[str] = set()
|
||||
for node in master.get("work_items", []):
|
||||
code = str(node.get("work_item_code", ""))
|
||||
for table in node.get("tables", []):
|
||||
factors = extract_cycle_factors(code, table)
|
||||
if isinstance(factors, CycleFactors):
|
||||
codes.add(factors.machine_code)
|
||||
found = extract_dozer_factors(code, table)
|
||||
if isinstance(found, dict):
|
||||
codes.update(f.machine_code for f in found.values() if f.machine_code)
|
||||
return codes
|
||||
|
||||
|
||||
def attach_dozer_share(
|
||||
book: Any,
|
||||
factor_gaps: dict[str, FactorGap],
|
||||
master: dict[str, Any],
|
||||
work_item_code: str,
|
||||
title_code: str,
|
||||
variant: str,
|
||||
) -> Decimal:
|
||||
"""불도저 공식으로 **장비 몫**을 붙인다. 붙였으면 100(%), 아니면 0.
|
||||
|
||||
⚠ 굴착기 쪽(`_attach_machine_share`)과 **자리를 나눠 쓴다** — 두 식이 같은 일위대가에
|
||||
붙으면 장비를 두 번 세는 것이 된다. 그래서 부르는 쪽이 **먼저 이쪽을 보고, 안 붙었을
|
||||
때만** 굴착기 쪽으로 간다.
|
||||
"""
|
||||
from B09_Estimation.B09_Estimation_PriceBook import PriceDetail
|
||||
|
||||
node = next(
|
||||
(w for w in master.get("work_items", []) if w.get("work_item_code") == work_item_code),
|
||||
None,
|
||||
)
|
||||
if node is None:
|
||||
return _ZERO
|
||||
wanted = _normalize_label(variant)
|
||||
for table in node.get("tables", []):
|
||||
found = extract_dozer_factors(work_item_code, table)
|
||||
if isinstance(found, FactorGap):
|
||||
factor_gaps[work_item_code] = found
|
||||
continue
|
||||
if not isinstance(found, dict):
|
||||
continue
|
||||
factors = found.get(wanted)
|
||||
if factors is None:
|
||||
continue
|
||||
hourly_code = f"X-{factors.machine_code}"
|
||||
if hourly_code not in book.titles:
|
||||
factor_gaps[work_item_code] = FactorGap(
|
||||
work_item_code=work_item_code,
|
||||
pum_table_id=factors.work_item_code,
|
||||
missing=("시간당 사용료",),
|
||||
note=f"{factors.machine_name} 의 시간당 사용료가 아직 안 섰습니다.",
|
||||
)
|
||||
continue
|
||||
book.add_detail(
|
||||
PriceDetail(
|
||||
title_code,
|
||||
hourly_code,
|
||||
dozer_machine_hours_per_unit(factors),
|
||||
note=factors.formula_text,
|
||||
)
|
||||
)
|
||||
return Decimal(100)
|
||||
return _ZERO
|
||||
@@ -27,8 +27,14 @@ from B09_Estimation.B09_Estimation_MachineCost import load_machine_catalog
|
||||
from B09_Estimation.B09_Estimation_MachineProductivity import (
|
||||
CycleFactors,
|
||||
FactorGap,
|
||||
attach_machine_share,
|
||||
extract_cycle_factors,
|
||||
machine_hours_per_unit,
|
||||
)
|
||||
from B09_Estimation.B09_Estimation_MachineProductivity_Dozer import (
|
||||
attach_dozer_share,
|
||||
dozer_variants,
|
||||
extract_dozer_factors,
|
||||
formula_machine_codes,
|
||||
)
|
||||
from B09_Estimation.B09_Estimation_MachineOperating import (
|
||||
load_fuel_price,
|
||||
@@ -78,6 +84,10 @@ class UnitPriceBuild:
|
||||
skipped: list[str] = field(default_factory=list)
|
||||
#: 기계 성분이 비어 있는 채로 쓰인 기종 (연료·조종원 미확보).
|
||||
incomplete_machines: list[str] = field(default_factory=list)
|
||||
#: 자원은 알아봤는데 **값을 못 읽은 줄**이 있어 단가를 못 세운 공종 — 사유 문구.
|
||||
#: ⚠ 일위대가가 **아예 안 선** 경우에도 남는다 — 「일위대가 없음」과 「성분이 빠져
|
||||
#: 못 세움」은 할 일이 다르므로 화면에서 갈라 보여야 한다(2026-09-08 산마루측구).
|
||||
component_gaps: dict[str, str] = field(default_factory=dict)
|
||||
#: 배분율 표인데 일부 몫만 붙은 공종 — 「단가가 일부만 섬」. 값은 붙은 몫(%).
|
||||
partial_ratio: dict[str, Decimal] = field(default_factory=dict)
|
||||
#: 시공능력 공식으로 장비 몫을 세운 공종 — 산출근거를 화면에 그대로 보인다.
|
||||
@@ -315,11 +325,14 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
|
||||
names = {w["work_item_code"]: w.get("name", "") for w in master.get("work_items", [])}
|
||||
|
||||
build = UnitPriceBuild()
|
||||
build.component_gaps = dict(axis.partial_items)
|
||||
missing_basis = load_basis_missing()
|
||||
wages = load_operator_wages()
|
||||
_add_labor_titles(build.book, wages)
|
||||
|
||||
machine_codes = {r.resource_code for r in axis.rows if r.resource_kind == "machine"}
|
||||
# 공식표에만 나오는 기종도 사용료 층을 세운다 — 안 세우면 공식이 붙을 데가 없다.
|
||||
machine_codes |= formula_machine_codes(master)
|
||||
build.incomplete_machines = _add_machine_layers(build.book, machine_codes)
|
||||
|
||||
# 규격 갈래(`variant`)가 있으면 **갈래마다 따로 세운다** — 무근·철근·소형구조물은
|
||||
@@ -328,6 +341,26 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
|
||||
for row in axis.rows:
|
||||
by_item.setdefault((row.work_item_code, getattr(row, "variant", "")), []).append(row)
|
||||
|
||||
# ⚠ **자원 줄이 하나도 없어도 공식이 온전하면 세운다.** 기계만 쓰는 공종(층따기 9-18 ·
|
||||
# 쇄석 부설 11-4)은 표에 인력 줄이 없어 자원 축이 비고, 그러면 아래 반복문이 그 공종을
|
||||
# 아예 안 본다 — 「미판정」으로 남아 있던 것이 실은 **시공능력 공식표**였다
|
||||
# (2026-09-08 미판정 77건을 훑다 발견).
|
||||
for node in master.get("work_items", []):
|
||||
code = node.get("work_item_code")
|
||||
if not code or (code, "") in by_item:
|
||||
continue
|
||||
# 불도저 운반(8-2-1)은 **갈래(토사·파쇄암·발파암)마다 값이 다르다** — 한 벌로
|
||||
# 뭉치면 어느 것도 안 맞는다. 갈래가 있으면 갈래를 세우고 여기서 끝낸다.
|
||||
labels = dozer_variants(node)
|
||||
if labels:
|
||||
for label in labels:
|
||||
by_item.setdefault((code, label), [])
|
||||
continue
|
||||
for table in node.get("tables", []):
|
||||
if isinstance(extract_cycle_factors(code, table), CycleFactors):
|
||||
by_item[(code, "")] = []
|
||||
break
|
||||
|
||||
for (work_item_code, variant), rows in sorted(by_item.items()):
|
||||
# 갈래 키는 **내부 공백을 지운 것**, 화면 문구는 **원문 그대로**
|
||||
# (2026-09-08 두 창 합의). 원문이 「보 통」·「보 통」으로 들쭉날쭉해
|
||||
@@ -344,7 +377,8 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
|
||||
for row in rows
|
||||
]
|
||||
attachable = [(row, ref) for row, ref in attachable if ref in build.book.titles]
|
||||
if not attachable:
|
||||
if not attachable and not _has_full_formula(master, work_item_code):
|
||||
# 붙을 상세도 없고 공식도 없으면 **제목도 안 세운다**(0 원 일위대가 금지).
|
||||
build.skipped.append(work_item_code)
|
||||
continue
|
||||
|
||||
@@ -391,9 +425,20 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
|
||||
)
|
||||
|
||||
# 장비 몫은 자원 수량이 아니라 **시공능력 공식**으로 온다 (품셈 8-1-4).
|
||||
machine_share = (
|
||||
_ZERO if variant else _attach_machine_share(build, master, work_item_code, title_code)
|
||||
# ⚠ 불도저와 굴착기는 **식이 다르다**(8-2-1 vs 8-1-4). 둘 다 붙이면 장비를 두 번
|
||||
# 세므로, 불도저가 붙은 자리는 굴착기 쪽을 아예 안 본다.
|
||||
machine_share = attach_dozer_share(
|
||||
build.book, build.factor_gaps, master, work_item_code, title_code, variant
|
||||
)
|
||||
if not machine_share and not variant:
|
||||
machine_share = attach_machine_share(
|
||||
build.book,
|
||||
build.factor_gaps,
|
||||
build.cycle_factors,
|
||||
master,
|
||||
work_item_code,
|
||||
title_code,
|
||||
)
|
||||
|
||||
# ⚠ **배분율이 있는 표는 「몇 %가 실제로 붙었나」를 세어 둔다.**
|
||||
# 「인력(10%)·장비(90%)」 표에서 인력만 붙으면 단가가 10 % 몫만인데, 그 값이
|
||||
@@ -403,6 +448,15 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
|
||||
if work_item_code in axis.partial_items:
|
||||
build.partial_ratio.setdefault(work_item_code, _ZERO)
|
||||
|
||||
# ⚠ 공식은 있는데 **아무것도 안 붙은** 제목은 남기지 않는다 — 「상세 줄이 없어
|
||||
# 조립 불가」로 화면에서 터진다. 기계 층이 못 선 경우가 그 자리다.
|
||||
if not attachable and not build.book.details.get(title_code):
|
||||
build.book.titles.pop(title_code, None)
|
||||
if variant in build.variants.get(work_item_code, []):
|
||||
build.variants[work_item_code].remove(variant)
|
||||
build.skipped.append(work_item_code)
|
||||
continue
|
||||
|
||||
covered = _covered_ratio_pct(rows, {ref for _, ref in attachable}, build)
|
||||
if covered is not None:
|
||||
covered += machine_share
|
||||
@@ -411,62 +465,25 @@ def build_unit_prices(axis: AxisResult | None = None) -> UnitPriceBuild:
|
||||
return build
|
||||
|
||||
|
||||
def _share_of(row) -> Decimal:
|
||||
"""그 줄이 차지하는 몫(0~1). 배분율이 없으면 1 — 종전과 같다."""
|
||||
ratio = getattr(row, "group_ratio_pct", None)
|
||||
return Decimal(1) if ratio is None else Decimal(str(ratio)) / Decimal(100)
|
||||
|
||||
|
||||
def _attach_machine_share(
|
||||
build: UnitPriceBuild,
|
||||
master: dict,
|
||||
work_item_code: str,
|
||||
title_code: str,
|
||||
) -> Decimal:
|
||||
"""시공능력 공식으로 **장비 몫**을 붙인다. 붙인 비율(%)을 돌려준다.
|
||||
|
||||
계수가 다 안 서면 **아무것도 안 붙이고 0 을 돌려준다** — 그러면 그 공종은
|
||||
`partial_ratio` 에 남아 내역서에서 금액이 안 붙는다(지어낸 값이 서는 것보다 낫다).
|
||||
"""
|
||||
def _has_full_formula(master: dict, work_item_code: str) -> bool:
|
||||
"""그 공종에 **온전한 시공능력 공식**이 있는가 (기계만 쓰는 공종용)."""
|
||||
node = next(
|
||||
(w for w in master.get("work_items", []) if w.get("work_item_code") == work_item_code),
|
||||
None,
|
||||
)
|
||||
if node is None:
|
||||
return _ZERO
|
||||
return False
|
||||
return any(
|
||||
isinstance(extract_cycle_factors(work_item_code, table), CycleFactors)
|
||||
or isinstance(extract_dozer_factors(work_item_code, table), dict)
|
||||
for table in node.get("tables", [])
|
||||
)
|
||||
|
||||
for table in node.get("tables", []):
|
||||
factors = extract_cycle_factors(work_item_code, table)
|
||||
if not isinstance(factors, CycleFactors):
|
||||
if isinstance(factors, FactorGap):
|
||||
build.factor_gaps[work_item_code] = factors
|
||||
continue
|
||||
hourly_code = f"X-{factors.machine_code}"
|
||||
if hourly_code not in build.book.titles:
|
||||
# 기계 층이 안 섰다 — 지어내지 않고 못 붙인 채로 둔다.
|
||||
build.factor_gaps[work_item_code] = FactorGap(
|
||||
work_item_code=work_item_code,
|
||||
pum_table_id=factors.pum_table_id,
|
||||
missing=("시간당 사용료",),
|
||||
note=f"{factors.machine_name} 의 시간당 사용료가 아직 안 섰습니다.",
|
||||
)
|
||||
continue
|
||||
share = (
|
||||
Decimal(1)
|
||||
if factors.machine_ratio_pct is None
|
||||
else Decimal(str(factors.machine_ratio_pct)) / Decimal(100)
|
||||
)
|
||||
build.book.add_detail(
|
||||
PriceDetail(
|
||||
title_code,
|
||||
hourly_code,
|
||||
machine_hours_per_unit(factors) * share,
|
||||
note=factors.formula_text,
|
||||
)
|
||||
)
|
||||
build.cycle_factors[work_item_code] = factors
|
||||
return share * Decimal(100)
|
||||
return _ZERO
|
||||
|
||||
def _share_of(row) -> Decimal:
|
||||
"""그 줄이 차지하는 몫(0~1). 배분율이 없으면 1 — 종전과 같다."""
|
||||
ratio = getattr(row, "group_ratio_pct", None)
|
||||
return Decimal(1) if ratio is None else Decimal(str(ratio)) / Decimal(100)
|
||||
|
||||
|
||||
def _covered_ratio_pct(
|
||||
|
||||
Reference in New Issue
Block a user