From 1400b7b7610b550237a7fdc6aea9598ec6b92e66 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 14 Sep 2026 20:13:25 +0900 Subject: [PATCH] =?UTF-8?q?feat(b09):=20=EA=B8=B0=EA=B3=84=20=EC=88=98?= =?UTF-8?q?=EC=86=A1=EB=B9=84=20=ED=9A=8C=EC=88=98(=EB=8C=80=EC=88=98=20?= =?UTF-8?q?=C3=97=20=EC=99=95=EB=B3=B5)=20=EC=84=A4=EA=B3=84=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=EC=B9=B8=20=E2=80=94=20=EC=9B=90=EB=AC=B8=EC=97=90?= =?UTF-8?q?=20=EA=B3=B5=EC=8B=9D=EC=9D=B4=20=EC=97=86=EC=96=B4=20=EC=82=AC?= =?UTF-8?q?=EC=9C=A0=EC=99=80=20=ED=95=A8=EA=BB=98=20=EC=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 산림품셈 10-4 · 건설품셈 8-1-3 은 회당 단가 산출식만 주고 대수·횟수를 정하지 않음(전수 확인). 지어내지 않고 설계 입력으로 닫음. · parse_trips — 비거나 0 이면 없음. 기본값을 두지 않음(1 회로 안 때움) · transport_amount — 회수 × 회당 단가. 회수가 없으면 None(0 원으로 안 채움) · TRIPS_NOTE — 「원문에 공식 없음 · 설계 입력 · 비우면 금액이 안 섬」 사유 · 도는 자리 넷 — 저장(PUT estimation/factors) · 산출 조건 ② · 산출 조건 화면(회당 N원 × M회) · 비면 transport_notes 에 사유가 붙어 못 채운 자리로 뜸 · 화면에 있던 「회수는 여기서 안 정합니다」 안내는 칸으로 바뀌어 걷음 잴 시험 먼저 빨강 확인한 뒤 고침. 전체 시험 1976 통과 · 28 건너뜀 · 1 xfail. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA --- B09_Estimation/B09_Estimation_BasisSheet.py | 1 + .../B09_Estimation_Router_Factors.py | 25 ++++++++++++++- B09_Estimation/B09_Estimation_Transport.py | 32 +++++++++++++++++-- B09_Estimation/B09_Estimation_UI_Factors.ts | 24 ++++++++------ resources/tester/test_b09_transport.py | 30 +++++++++++++++++ 5 files changed, 100 insertions(+), 12 deletions(-) diff --git a/B09_Estimation/B09_Estimation_BasisSheet.py b/B09_Estimation/B09_Estimation_BasisSheet.py index fcb0419b..006bfb3a 100644 --- a/B09_Estimation/B09_Estimation_BasisSheet.py +++ b/B09_Estimation/B09_Estimation_BasisSheet.py @@ -85,6 +85,7 @@ def chosen_conditions(settings: dict[str, Any] | None) -> list[dict[str, str]]: ("fuel_region", "유가 지역(시도코드)"), ("transport_distance_km", "기계 수송 거리(편도 ㎞)"), ("transport_road", "수송 도로 구분"), + ("transport_trips", "기계 수송 회수(대수 × 왕복)"), ): value = str(picked.get(key) or "").strip() if value: diff --git a/B09_Estimation/B09_Estimation_Router_Factors.py b/B09_Estimation/B09_Estimation_Router_Factors.py index bc11c3b8..f2bbc9c6 100644 --- a/B09_Estimation/B09_Estimation_Router_Factors.py +++ b/B09_Estimation/B09_Estimation_Router_Factors.py @@ -110,7 +110,9 @@ async def get_factor_choices(project_id: UUID) -> JSONResponse: from B09_Estimation.B09_Estimation_Transport import BASIS_TEXT as TRANSPORT_BASIS from B09_Estimation.B09_Estimation_Transport import ROAD_CLASSES as TRANSPORT_ROADS from B09_Estimation.B09_Estimation_Transport import TRANSPORT_VARIANTS + from B09_Estimation.B09_Estimation_Transport import TRIPS_NOTE as TRANSPORT_TRIPS_NOTE from B09_Estimation.B09_Estimation_Transport import WORK_ITEM_CODE as TRANSPORT_CODE + from B09_Estimation.B09_Estimation_Transport import parse_trips, transport_amount # 「넣을 데가 있는가」 — 주재료비가 선 일위대가가 몇인지 세어 그대로 알린다. # 지금은 사급 자재 단가가 미결(확정 5차 큰 것 8)이라 0 이 정상이다. @@ -133,10 +135,19 @@ async def get_factor_choices(project_id: UUID) -> JSONResponse: book = prices.book transport_notes = list(prices.transport_notes) transport_prices = {} + transport_amounts = {} + # 회수는 품셈이 안 정하는 설계 입력 — 비면 금액을 안 세우고 사유만 남긴다. + transport_trips = parse_trips(settings.get("transport_trips")) for variant in TRANSPORT_VARIANTS: code = f"B-{TRANSPORT_CODE}#{variant['key']}" if code in book.titles: - transport_prices[variant["key"]] = f"{book.resolve(code).total:,.0f}" + unit_price = book.resolve(code).total + transport_prices[variant["key"]] = f"{unit_price:,.0f}" + amount = transport_amount(unit_price, transport_trips) + if amount is not None: + transport_amounts[variant["key"]] = f"{amount:,.0f}" + if transport_prices and transport_trips is None: + transport_notes.append(TRANSPORT_TRIPS_NOTE) with_material = sum( 1 for unit_code, unit_title in book.titles.items() @@ -174,6 +185,8 @@ async def get_factor_choices(project_id: UUID) -> JSONResponse: "transport": { "distance_km": str(settings.get("transport_distance_km") or ""), "road": str(settings.get("transport_road") or ""), + "trips": str(settings.get("transport_trips") or ""), + "trips_note": TRANSPORT_TRIPS_NOTE, "roads": [ {"key": row["key"], "label": row["label"]} for row in TRANSPORT_ROADS ], @@ -182,6 +195,7 @@ async def get_factor_choices(project_id: UUID) -> JSONResponse: "key": variant["key"], "label": variant["label"], "unit_price_krw": transport_prices.get(variant["key"], ""), + "amount_krw": transport_amounts.get(variant["key"], ""), } for variant in TRANSPORT_VARIANTS ], @@ -236,6 +250,7 @@ class FactorChoiceBody(BaseModel): #: 기계 수송 거리(㎞)·도로 구분 — **비면 수송비 줄이 안 선다.** transport_distance_km: str | None = None transport_road: str | None = None + transport_trips: str | None = None #: 품 할인·할증(1-4) — 계열코드 → 고른 행. **빈 값이면 그 계열을 끄는 것.** labor_surcharge: dict[str, str] | None = None @@ -312,6 +327,14 @@ async def put_factor_choices(project_id: UUID, body: FactorChoiceBody) -> JSONRe content={"status": "error", "message": f"원문에 없는 도로 구분입니다: {road}"}, ) values["transport_road"] = road + if body.transport_trips is not None: + from B09_Estimation.B09_Estimation_Transport import parse_trips + + try: + trips = parse_trips(body.transport_trips) + except ValueError as exc: + return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) + values["transport_trips"] = "" if trips is None else str(trips) if body.labor_surcharge is not None: from B09_Estimation.B09_Estimation_LaborSurcharge import parse_choices diff --git a/B09_Estimation/B09_Estimation_Transport.py b/B09_Estimation/B09_Estimation_Transport.py index 531a944a..1b3785bb 100644 --- a/B09_Estimation/B09_Estimation_Transport.py +++ b/B09_Estimation/B09_Estimation_Transport.py @@ -24,8 +24,10 @@ ⚠ **거리는 설계 입력이다** — 안 넣으면 **줄이 안 선다**(사토장 운반거리와 같은 자리). 임의 거리를 넣으면 금액이 조용히 서므로 **비면 사유만 남긴다.** -⚠ **회수(몇 대를 몇 번 나르나)는 여기서 안 정한다** — 단가는 「회당」이고, 회수는 수량 쪽 - (설계 입력)이다. 품셈이 대수·회수를 정해 주지 않는다. +⚠ **회수(몇 대를 몇 번 나르나)도 설계 입력이다** (2026-09-14 브레인 판정으로 닫음). + 원문 전수 확인 — 산림품셈 10-4 · 건설품셈 8-1-3 은 **회당 단가 산출식만** 주고 + 대수·왕복 횟수를 정하는 공식이 **없다**. 그래서 `transport_trips` 칸을 두고, + 비면 금액을 안 세우고 사유만 남긴다. **회수 × 회당 단가** 곱하기 하나뿐이다. """ from __future__ import annotations @@ -86,6 +88,12 @@ ASSUMPTION_TEXT = ( "⚠ 원문이 안 정해 우리가 정한 둘 — ㉠ 운반시간의 속도는 8-1-6의 2 나 이동속도표에서 가져옴" "(그 표는 자주식 이동표라 쓰임이 꼭 같지는 않음) · ㉡ 운반시간을 왕복으로 봄." ) +#: 회수 칸 사유 — 화면이 「왜 설계자가 넣나」를 읽는 자리. +TRIPS_NOTE = ( + "회수(대수 × 왕복)는 **설계 입력**입니다 — 산림품셈 10-4 · 건설품셈 8-1-3 은 **회당 단가" + " 산출식만** 주고 대수·횟수를 정하는 공식이 원문에 없습니다(2026-09-14 전수 확인)." + " 비워 두면 수송비 금액이 서지 않습니다." +) def parse_distance_km(raw: Any) -> Decimal | None: @@ -102,6 +110,26 @@ def parse_distance_km(raw: Any) -> Decimal | None: return value +def parse_trips(raw: Any) -> Decimal | None: + """설정 칸의 **회수**(대수 × 왕복). 비거나 0 이면 `None` — 금액이 안 선다. + + ⚠ 품셈이 안 정하는 값이라 **우리가 기본값을 두지 않는다**(1 회로 때우지 않음). + """ + text = str(raw or "").strip().rstrip("회").strip() + if not text: + return None + try: + value = Decimal(text) + except (ArithmeticError, ValueError): + raise ValueError(f"수송 회수를 숫자로 못 읽었습니다: {raw!r}") from None + return value if value > 0 else None + + +def transport_amount(unit_price_krw: Decimal, trips: Decimal | None) -> Decimal | None: + """수송비 = 회당 단가 × 회수. 회수가 없으면 `None`(0 원으로 안 채운다).""" + return None if trips is None else unit_price_krw * trips + + def road_class(key: str | None) -> dict[str, Any] | None: """도로 구분. 못 고르면 `None` — **기본 도로를 우리가 정하지 않는다.**""" return _ROAD_BY_KEY.get(str(key or "")) diff --git a/B09_Estimation/B09_Estimation_UI_Factors.ts b/B09_Estimation/B09_Estimation_UI_Factors.ts index dd64b8e0..fba13b20 100644 --- a/B09_Estimation/B09_Estimation_UI_Factors.ts +++ b/B09_Estimation/B09_Estimation_UI_Factors.ts @@ -58,7 +58,9 @@ interface TransportRow { distance_km: string; road: string; roads: Array<{ key: string; label: string }>; - variants: Array<{ key: string; label: string; unit_price_krw: string }>; + trips: string; + trips_note: string; + variants: Array<{ key: string; label: string; unit_price_krw: string; amount_krw: string }>; basis: string[]; notes: string[]; } @@ -105,6 +107,7 @@ export async function saveFactorChoices( fuel_region?: string; transport_distance_km?: string; transport_road?: string; + transport_trips?: string; labor_surcharge?: Record; }, ): Promise { @@ -251,22 +254,25 @@ export function drawFactorChoices( body.append( picker("수송 도로 구분", roadOptions, transport.road, (key) => save({ transport_road: key })), ); + body.append( + percentBox("기계 수송 회수 (대수 × 왕복)", transport.trips, "비움", (text) => + save({ transport_trips: text }), + ), + ); for (const variant of transport.variants) { body.append( note( - variant.unit_price_krw - ? `${variant.label} — 회당 ${variant.unit_price_krw}원` - : `${variant.label} — 아직 안 섬`, + !variant.unit_price_krw + ? `${variant.label} — 아직 안 섬` + : variant.amount_krw + ? `${variant.label} — 회당 ${variant.unit_price_krw}원 × ${transport.trips}회 = ${variant.amount_krw}원` + : `${variant.label} — 회당 ${variant.unit_price_krw}원 (회수를 넣으면 금액이 섭니다)`, ), ); } for (const line of transport.notes) body.append(note(`⚠ ${line}`)); for (const line of transport.basis) body.append(note(line)); - body.append( - note( - "⚠ 단가는 「회당」입니다 — 몇 대를 몇 번 나르는지(회수)는 설계 입력이라 여기서 안 정합니다.", - ), - ); + body.append(note(transport.trips_note)); } const surcharge = data.labor_surcharge; diff --git a/resources/tester/test_b09_transport.py b/resources/tester/test_b09_transport.py index 671b75de..7f188e2b 100644 --- a/resources/tester/test_b09_transport.py +++ b/resources/tester/test_b09_transport.py @@ -29,10 +29,13 @@ from B09_Estimation.B09_Estimation_MachineOperating import ( # noqa: E402 ) from B09_Estimation.B09_Estimation_Transport import ( # noqa: E402 TRANSPORT_VARIANTS, + TRIPS_NOTE, cycle_minutes, hours_per_trip, parse_distance_km, + parse_trips, road_class, + transport_amount, ) from B09_Estimation.B09_Estimation_UnitPrice import build_unit_prices # noqa: E402 @@ -114,3 +117,30 @@ def test_앞자리_이어받기는_이어받을_것이_있을_때만() -> None: assert expand_codes(["0080", "0100"]) == [] assert expand_codes(["0080", "0100"], "2101") == ["2101-0080", "2101-0100"] assert expand_codes(["2101-0010", "0015"]) == ["2101-0010", "2101-0015"] + + +# ── 회수(대수 × 왕복) — 품셈이 안 정하는 자리 (2026-09-14 브레인 판정) ── + + +def test_회수는_설계_입력_칸이고_비우면_없음이다() -> None: + """품셈 원문에 **회수 공식이 없음** — 산림품셈 10-4·건설품셈 8-1-3 은 회당 단가 산출식뿐. + + ⇒ 대수·왕복은 **설계자가 넣는 값**이다. 비면 `None` 이고 금액이 안 선다(지어내지 않음). + """ + assert parse_trips("") is None and parse_trips(None) is None + assert parse_trips("0") is None # 0 회는 「안 나른다」 — 줄이 안 섬 + assert parse_trips("3") == Decimal(3) + assert parse_trips(" 2.5 ") == Decimal("2.5") # 반 회(편도 한 번)도 설계자가 넣을 수 있음 + with pytest.raises(ValueError): + parse_trips("두 번") + + +def test_회수를_넣으면_회당_단가에_곱해진다() -> None: + """회수 × 회당 단가 = 수송비. **곱하기 하나뿐** — 품셈이 안 준 규칙을 끼워 넣지 않는다.""" + assert transport_amount(Decimal(12_345), None) is None + assert transport_amount(Decimal(12_345), Decimal(3)) == Decimal(37_035) + + +def test_회수_사유가_늘_붙는다() -> None: + """화면이 「왜 설계자가 넣나」를 읽을 수 있어야 함 — 근거 문구에 원문 없음이 적혀 있음.""" + assert "회수" in TRIPS_NOTE and "설계" in TRIPS_NOTE