fix(B09): 표의 일부만 붙은 단가가 조용히 서던 것 — 본표가 말하게 함

일위대가가 선 141 공종 중 **70 공종**이 「품셈 표에 있는데 못 붙은 줄」을 갖고 있었음.
막지 않는 것은 맞으나(막으면 정상 공종이 무더기로 멈춤) 화면이 말하지 않아
**조용히 싼 단가**가 내역서에 그대로 들 수 있었음.

실례 — 초류종자살포(FP-05-24-01, 수량 17,873.80㎡)
  품셈 5-24-1 은 자재 5(종자·비료·피복제·침식안정제·색소) + 장비 3(종자살포기·트럭·
  물탱크) + 인력 2 인데, 우리 단가는 **인력 둘만**으로 233.4원/㎡ 였음.

- `UnitPriceBuild.unattached` 신설 — 공종별로 못 붙은 줄 이름을 들고 있음
- 본표(`detail_of`)가 `unattached`·`unattached_note` 를 함께 냄
- 일위대가 상세 화면이 그 문구를 굵게 띄움. 다 붙은 단가는 아무 말도 안 함
  (늘 경고가 뜨면 아무도 안 읽음)
- 품셈이 자간을 벌려 적은 이름(「종      자」)은 눌러서 보임

⚠ 지역 이름 충돌 하나 잡음 — `names` 가 공종 이름표라 같은 이름을 쓰니 조립이 통째로
터졌음. 주석으로 못 박음.

검증: pytest 282 통과(신규 3) · tsc 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-09 06:34:38 +09:00
co-authored by Claude Opus 5
parent 5780eb9359
commit f50c55a8e2
3 changed files with 42 additions and 0 deletions
+14
View File
@@ -108,6 +108,9 @@ interface UnitPriceDetailDto {
expense: string; expense: string;
total: string; total: string;
sum_matches: boolean; sum_matches: boolean;
/** 품셈 표에 있는데 아직 안 붙은 줄 — 있으면 이 단가는 **붙은 줄만의 값**이다. */
unattached: string[];
unattached_note: string;
rows: UnitPriceDetailRow[]; rows: UnitPriceDetailRow[];
} }
@@ -355,6 +358,17 @@ function buildUnitPriceDetail(
` · ${detail.sum_matches ? L("B09_Estimation_UP_SumOk") : L("B09_Estimation_UP_SumBad")}`; ` · ${detail.sum_matches ? L("B09_Estimation_UP_SumOk") : L("B09_Estimation_UP_SumBad")}`;
wrap.append(caption); wrap.append(caption);
// ⚠ **일부만 선 단가는 반드시 말한다.** 안 말하면 조용히 싼 값이 내역서에 그대로 든다
// (2026-09-09 실측: 일위대가가 선 141 공종 중 70 공종이 이 자리 — 초류종자살포는
// 자재 다섯·장비 셋이 빠진 채 인력 둘만으로 서 있었다).
if (detail.unattached_note) {
const gap = document.createElement("div");
gap.className = "b09-hint";
gap.style.fontWeight = "600";
gap.textContent = detail.unattached_note;
wrap.append(gap);
}
// 행별로 0.1원 미만을 버리므로 전정밀 합과 끝자리가 어긋난다 — **정상이다.** // 행별로 0.1원 미만을 버리므로 전정밀 합과 끝자리가 어긋난다 — **정상이다.**
// 숨기면 나중에 「합계가 안 맞는다」며 계산을 고치려 든다. // 숨기면 나중에 「합계가 안 맞는다」며 계산을 고치려 든다.
if (detail.precise_total !== detail.total) { if (detail.precise_total !== detail.total) {
@@ -92,6 +92,12 @@ class UnitPriceBuild:
#: ⚠ 일위대가가 **아예 안 선** 경우에도 남는다 — 「일위대가 없음」과 「성분이 빠져 #: ⚠ 일위대가가 **아예 안 선** 경우에도 남는다 — 「일위대가 없음」과 「성분이 빠져
#: 못 세움」은 할 일이 다르므로 화면에서 갈라 보여야 한다(2026-09-08 산마루측구). #: 못 세움」은 할 일이 다르므로 화면에서 갈라 보여야 한다(2026-09-08 산마루측구).
component_gaps: dict[str, str] = field(default_factory=dict) component_gaps: dict[str, str] = field(default_factory=dict)
#: 공종코드 → **표에 있는데 못 붙은 줄 이름들**.
#: ⚠ 값이 있다는 것은 그 단가가 **표의 일부만으로 서 있다**는 뜻이다 — 대개 자재·기계
#: 카탈로그가 없어서다. 막지는 않지만(막으면 정상 공종이 무더기로 멈춘다) **화면이
#: 반드시 말해야 한다.** 안 말하면 조용히 싼 단가가 내역서에 그대로 든다
#: (2026-09-09 실측: 일위대가가 선 141 공종 중 **70 공종**이 이 자리였다).
unattached: dict[str, list[str]] = field(default_factory=dict)
#: 배분율 표인데 일부 몫만 붙은 공종 — 「단가가 일부만 섬」. 값은 붙은 몫(%). #: 배분율 표인데 일부 몫만 붙은 공종 — 「단가가 일부만 섬」. 값은 붙은 몫(%).
partial_ratio: dict[str, Decimal] = field(default_factory=dict) partial_ratio: dict[str, Decimal] = field(default_factory=dict)
#: 시공능력 공식으로 장비 몫을 세운 공종 — 산출근거를 화면에 그대로 보인다. #: 시공능력 공식으로 장비 몫을 세운 공종 — 산출근거를 화면에 그대로 보인다.
@@ -354,6 +360,13 @@ def build_unit_prices(
build = UnitPriceBuild() build = UnitPriceBuild()
build.component_gaps = dict(axis.partial_items) build.component_gaps = dict(axis.partial_items)
for unmatched_row in axis.unmatched:
# ⚠ 지역 이름을 조심할 것 — 바로 위 `names` 는 **공종 이름표**다. 같은 이름을 쓰면
# 그 표가 리스트로 덮여 조립이 통째로 터진다(2026-09-09 실측).
labels = build.unattached.setdefault(unmatched_row.work_item_code, [])
label = " ".join(str(unmatched_row.cell).split())
if label and label not in labels:
labels.append(label)
missing_basis = load_basis_missing() missing_basis = load_basis_missing()
wages = load_operator_wages() wages = load_operator_wages()
_add_labor_titles(build.book, wages) _add_labor_titles(build.book, wages)
@@ -165,6 +165,9 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict:
"""본표 — 「그것이 무엇으로 이루어졌나」. 줄마다 원천과 파고들기 여부를 함께 낸다.""" """본표 — 「그것이 무엇으로 이루어졌나」. 줄마다 원천과 파고들기 여부를 함께 낸다."""
title = build.book.title(code) title = build.book.title(code)
money = build.book.resolve(code) money = build.book.resolve(code)
# 코드에서 공종을 도로 뽑는다 — 「B-FP-09-11-01#갈래」의 갈래는 떼고 본다.
work_item_code = code[2:].split("#")[0] if code.startswith("B-") else ""
unattached = list(build.unattached.get(work_item_code, []))
rows: list[dict] = [] rows: list[dict] = []
for detail in build.book.details.get(code, []): for detail in build.book.details.get(code, []):
if detail.percent_of_labor is not None: if detail.percent_of_labor is not None:
@@ -255,4 +258,16 @@ def detail_of(build: UnitPriceBuild, code: str) -> dict:
# 전정밀 합과의 차이 — 행별 절사 탓에 끝자리가 어긋나는 것은 **정상**이다. # 전정밀 합과의 차이 — 행별 절사 탓에 끝자리가 어긋나는 것은 **정상**이다.
"precise_total": _money_text(money.total), "precise_total": _money_text(money.total),
"rows": rows, "rows": rows,
# ⚠ **표에 있는데 못 붙은 줄** — 이 단가가 일부만으로 섰다는 뜻이다.
# 안 보이면 조용히 싼 단가가 내역서에 그대로 든다.
"unattached": unattached,
"unattached_note": (
f"⚠ 품셈 표에 있는 {len(unattached)}줄이 아직 안 붙었습니다 — "
f"{', '.join(unattached[:4])}"
+ ("" if len(unattached) > 4 else "")
+ ". 자재·기계 카탈로그가 서면 채워집니다. 그때까지 이 단가는 "
"**붙은 줄만의 값**입니다."
)
if unattached
else "",
} }