Merge remote-tracking branch 'origin/dev' into main_laptop_1

This commit is contained in:
2026-09-14 20:32:32 +09:00
5 changed files with 100 additions and 12 deletions
@@ -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:
@@ -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
+30 -2
View File
@@ -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 ""))
+15 -9
View File
@@ -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<string, string>;
},
): Promise<void> {
@@ -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;
+30
View File
@@ -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