Merge remote-tracking branch 'origin/main_desktop_1' into sub_desktop_1

This commit is contained in:
2026-09-08 06:11:35 +09:00
8 changed files with 146 additions and 166 deletions
@@ -81,6 +81,19 @@ COEFFICIENT_ROW_KEYS = {"K", "f", "E", "Cm", "㎝(sec)", "q", "qo", "Q", "V", "L
REFERENCE_MARKS = ("할증률", "할인", "공제율", "적재량", "지위")
# 값 대신 「어디를 따르라」고만 적은 표. 품이 아니므로 공종으로 세우지 않는다.
REFERENCE_CELL_MARKS = ("별도계상", "구역화물", "적용한다", "따른다", "준용")
# 품셈이 실어 둔 **빈 단가산출서 서식** — 값이 없는 예시 양식이다(2026-09-08 ㉙, 서브가 찾음).
# 첫 줄이 「위치 및 임·소반」·「위치 및 면적」이고 다음 줄이 「구분 | 작업량 | 단위품 |
# 소요품 | 단가」인 모양. 채워 넣으라고 둔 칸이라 **자원도 값도 없다.**
# ⚠ 이것을 공종으로 세우면 「값이 있는데 안 서는 자리」로 오해된다 — 애초에 값이 없다.
# 서브가 그 표들 탓에 못 맞춘 자원이 882 → 497 로 부풀어 있었다.
# ⚠ **두 조건을 다 만족할 때만** 본다 — 「구분」·「단가」는 정상 표에도 흔한 낱말이라
# 하나만 보면 정상 표를 지운다(오늘 그 병을 열한 번 겪었다).
#: 표지는 **머리글에 그대로 적혀 있다** — 「ha당 참나무시들음병방제 **단가산출서(예시)**」.
#: 낱말 하나로 충분히 좁다(정상 표 머리글에 「단가산출서」가 올 일이 없다).
BLANK_FORM_MARK = "단가산출서"
#: 머리글이 비어 있는 판을 대비한 보조 표지 — **두 조건을 다 만족할 때만** 본다.
BLANK_FORM_HEAD_MARKS = ("위치및임소반", "위치및면적")
BLANK_FORM_BODY_MARKS = ("단위품", "소요품")
# 직종이 값의 주인인 표 = 소요량형. `보통인부(인)`·`콘크리트공(인)` 처럼 `(인)` 이 붙는다.
# ⚠ 원문에 `인 부` 처럼 낱말 사이 공백이 있어, 비교 전에 공백을 모두 지운다(`squeeze`).
OCCUPATION_RE = re.compile(
@@ -200,6 +213,19 @@ def detect_form(table: dict[str, Any], chapter: str | None) -> tuple[str, str]:
if mark in squeezed:
return "reference", f"'{mark}' — 값이 아니라 참조 지시"
# 빈 단가산출서 서식 — 머리글 표지가 정본, 머리 두 줄은 보조.
if BLANK_FORM_MARK in hay.replace(" ", ""):
return "reference", f"'{BLANK_FORM_MARK}' — 채워 넣으라고 둔 빈 서식"
head2 = "".join(
norm(str(c)) for row in (table.get("rows") or table.get("raw_row") or [])[:2] for c in row
)
for mark in ("", "·", "", " "):
head2 = head2.replace(mark, "")
if any(m in head2 for m in BLANK_FORM_HEAD_MARKS) and any(
m in head2 for m in BLANK_FORM_BODY_MARKS
):
return "reference", "품셈이 실어 둔 빈 단가산출서 서식 — 채워 넣으라고 둔 칸"
# ⚠ 생산량형을 **직종보다 먼저** 본다. 「작업능력(㎥/hr)」 표의 비고란에 흔히
# 「보통인부 1인/일」이 붙어 있어, 직종을 먼저 보면 생산량형이 소요량형으로 뒤집힌다.
# 그 뒤집힘이 곧 PLAN 8-6 이 경고한 「값이 조용히 반대로 들어가는」 사고다.
@@ -687,6 +687,87 @@ def _structure_rows(
return rows, unmatched
#: 타설 대상으로 보는 성분 이름 — **정확히 같은 이름**으로만 본다.
#: ⚠ **「채움콘크리트」는 뺀다** — 돌쌓기 뒤채움이라 그 공종의 품에 이미 들어 있을 수 있다.
#: 품셈 13-6 [주]① 은 큰돌쌓기의 채움콘크리트를 **품에 포함**이라고 못 박았고, 13-4 는
#: 그 [주]가 없어 **확인 전까지 세우지 않는다.** 넓게 잡으면 그것이 곧 이중계상이다.
#: (넣으려면 돌쌓기 일위대가에 타설 품이 있는지부터 확인할 것 — B09 ㉢ 과 같은 자리.)
PLACING_TARGET_NAMES = frozenset({"콘크리트", "버림콘크리트", "레미콘"})
def _placing_rows(
unit_quantity_table: dict[str, Any],
mapping: WorkItemMapping,
method: str | None,
) -> tuple[list[dict[str, Any]], list[str]]:
"""콘크리트 **타설 공종** 줄 — 구조물 종류별로 체적을 모아 한 줄씩 낸다.
⚠ **이중계상이 아니다** (2026-09-08 두 창 확인). 품셈 12-1-1 표는 **직종·품만** 주고
재료를 안 준다(원문도 「콘크리트공(인) | 보통인부(인)」 두 열뿐). 서브 일위대가
`B-FP-12-01-01#철근구조물` 도 **재료 0원 · 노무 65,826.48원**이다.
⇒ **품은 이 줄, 재료는 자재 쪽**으로 갈려 있어 겹치지 않는다.
⚠ 방식은 **설계 판단**이고 종류(무근/철근)는 **철근이 있나 없나로 자동 판정**한다 —
사람이 고르는 값이 아니다(`work_item_mapping` 의 `kind_rule`).
"""
code, used_default = placing_code(mapping, method)
if code is None:
return [], []
buckets: dict[str, float] = {}
for structure in unit_quantity_table.get("structures") or []:
volume = sum(
float(component.get("amount") or 0.0)
for component in structure.get("components") or []
if str(component.get("name") or "").strip() in PLACING_TARGET_NAMES
and component.get("unit") == ""
)
if volume <= 0:
continue
buckets[structure_kind(structure)] = buckets.get(structure_kind(structure), 0.0) + volume
rows = [
{
"work_item_code": code,
"name": "콘크리트 타설",
"spec": kind,
"unit": "",
"quantity": volume,
"quantity_gross": None,
"application_ratio_pct": None,
"application_ratio_breakdown": None,
"quantity_breakdown": None,
"ground_class": None,
"haul_distance_m": None,
"haul_equipment": None,
"station_from": None,
"station_to": None,
"excavation_method": None,
"spec_detail": kind,
"composite_parts": None,
"structure_kind": kind,
"blocked_kind": None,
"blocked_reason": "",
"variant_axis": "structure_kind",
"variant_value": kind,
"spec_class": kind,
"spec_class_basis": (
"철근이 있으면 철근구조물, 없으면 무근구조물 — 원단위로 자동 판정"
),
"composite_not_ready": None,
"in_bill": True,
"in_bill_reason": "",
"origin": ORIGIN_STRUCTURE,
}
for kind, volume in sorted(buckets.items())
]
notes: list[str] = []
if rows and used_default:
notes.append(
"콘크리트 타설 방식을 아직 안 정해 기본값(레디믹스트)으로 섰습니다 — "
"산출 조건에서 정하면 이 공종의 단가가 달라집니다"
)
return rows, notes
def _material_rows(material_table: dict[str, Any]) -> list[dict[str, Any]]:
"""자재 줄 — **공종코드를 붙이지 않는다.** 자재 축은 B09 카탈로그가 잇는다(8-7)."""
rows: list[dict[str, Any]] = []
@@ -718,6 +799,7 @@ def build_handoff(
ground_class_set: str | None = None,
ground_classes: list[str] | None = None,
ground_methods: dict[str, str | None] | None = None,
concrete_placing_method: str | None = None,
) -> dict[str, Any]:
"""B09 가 그대로 받는 모양. 없는 표는 건너뛰되 **빈 표와 구별해 적는다**."""
table = mapping or load_mapping()
@@ -738,6 +820,12 @@ def build_handoff(
work_items.extend(rows)
unmatched.extend(misses)
# 콘크리트 타설 — 품은 이 줄, 재료는 자재 쪽. 겹치지 않는다(위 `_placing_rows` 주석).
placing_rows, placing_notes = _placing_rows(
unit_quantity_table or {}, table, concrete_placing_method
)
work_items.extend(placing_rows)
materials = _material_rows(material_table or {})
result: dict[str, Any] = {
"work_items": work_items,
@@ -780,6 +868,7 @@ def build_handoff(
result["ratio_math_warnings"] = verify_ratio_math(result)
result["material_code_warnings"] = verify_no_code_on_materials(result)
result["bill_flag_warnings"] = verify_bill_flags(result)
result["placing_notes"] = placing_notes
return result
@@ -29,6 +29,7 @@ from B08_Quantity.B08_Quantity_Engine_Handoff import build_handoff, summarize
from B08_Quantity.B08_Quantity_Engine_MaterialSummary import build_table as build_material_table
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table
from common_util.common_util_project_settings import (
concrete_placing_method,
quantity_settings,
rock_classes,
rock_method,
@@ -161,6 +162,8 @@ async def get_handoff(project_id: UUID) -> JSONResponse:
ground_class_set=settings.get("rock_class_set"),
ground_classes=rock_classes(settings),
ground_methods={name: rock_method(settings, name) for name in rock_classes(settings)},
# 타설 방식 — 안 정했으면 기본값으로 서되 그 사실을 `placing_notes` 가 알린다.
concrete_placing_method=concrete_placing_method(settings)[0],
)
handoff["summary"] = summarize(handoff)
handoff["skipped_structures"] = skipped
+3 -3
View File
@@ -352,9 +352,9 @@ function buildQuantitySidePanel(
placing.is_default ? `${label} (${L("B08_Quantity_Placing_Default_Tag")})` : label,
),
);
// ⚠ 고른 값이 **아직 금액에 안 닿는다**는 사실을 숨기지 않는다(2026-09-08 ㉘).
// 인계가 타설 공종 줄을 아직 안 세운다 — B09 일위대가가 타설 품을 이미 갖고 있는지
// 확인되기 전까지는 세우면 같은 콘크리트를 두 번 센다(이중계상 ㉢ 과 같은 자리).
// 2026-09-08 ㉙: 인계가 **타설 공종 줄을 실제로 세운다.** 겹치지 않는 것이 확인됐다 —
// 품셈 12-1-1 표는 직종·품만 주고 재료를 안 줘서, 품은 이 줄 · 재료는 자재 쪽이다.
// ⚠ 돌쌓기 뒤채움(채움콘크리트)은 뺐다 — 그 공종 품에 이미 들어 있을 수 있다.
const pending = document.createElement("p");
pending.className = "b08-quantity__note";
pending.textContent = L("B08_Quantity_Placing_NotApplied");
@@ -1,7 +1,7 @@
{
"schema_version": "1.0",
"dataset_id": "data_work_item_master_manifest",
"generated_at": "2026-09-08T04:57:04+09:00",
"generated_at": "2026-09-08T06:00:25+09:00",
"built_by": "B08_Quantity/B08_Quantity_Build_WorkItemMaster.py",
"source": {
"dataset_id": "pum_forest",
@@ -12,8 +12,8 @@
"files": [
{
"file": "work_item_master_2026-01-01.json",
"sha256": "1c49012f9f50d721676ad8def15c87c96b77aad83123286dd6981b7d9cefcd81",
"size_bytes": 856450
"sha256": "4d89c1eec7929c39d5adb36547b03f4675205b10f6b629eeb2f246a1d9230dfd",
"size_bytes": 856818
},
{
"file": "form_undetermined_2026-01-01.json",
@@ -22,8 +22,8 @@
},
{
"file": "basis_missing_2026-01-01.json",
"sha256": "b69091149adb942ce507d902b7e6ac400777674301b9200b1f1c60cfbd709839",
"size_bytes": 22832
"sha256": "9d7b9ee8df65be1c944a2e0af9efe0742bd9a356b367fc4de8eec47b3ece2147",
"size_bytes": 18627
}
]
}
@@ -825,144 +825,6 @@
"section": "13-16-2. 통기성매트",
"pum_form": "requirement",
"line": 7788
},
{
"pum_table_id": "F0454",
"section": "8-2-3 굴착기(2025)」를 참조하여 적용계수를 달리 적용하도록 한다.",
"pum_form": "requirement",
"line": 7983
},
{
"pum_table_id": "F0455",
"section": "2-1. 풀베기, (1) 둘레베기(조림목 본수 2,700본/ha, 조림1년차)",
"pum_form": "requirement",
"line": 8031
},
{
"pum_table_id": "F0456",
"section": "2-2. 풀베기, (2) 모두베기(조림목 본수 2,700본/ha, 조림2년차)",
"pum_form": "requirement",
"line": 8061
},
{
"pum_table_id": "F0457",
"section": "2-3. 풀베기, (3) 줄베기(조림목 본수 2,700본/ha, 조림2년차)",
"pum_form": "requirement",
"line": 8092
},
{
"pum_table_id": "F0458",
"section": "2-4. 풀베기, (4) 맹아제거+둘레베기(제거대상 맹아 1,000본/ha. 조림목 본수 2,700본/ha, 조림1년차)",
"pum_form": "requirement",
"line": 8124
},
{
"pum_table_id": "F0459",
"section": "2-5. 덩굴제거, (1) 지상부 덩굴걷기(큰나무 피해지. 덩굴 피복도 20~40%미만)",
"pum_form": "requirement",
"line": 8156
},
{
"pum_table_id": "F0460",
"section": "2-6. 덩굴제거, (2) 지상부 약제살포(큰나무 피해지. 덩굴 피복도 60~80%미만)",
"pum_form": "requirement",
"line": 8189
},
{
"pum_table_id": "F0461",
"section": "2-7. 덩굴제거, (3) 뿌리굴취(풀베기단계 조림지 1㎝미만 500본, 1~4㎝ 400본, 4㎝초과 100본)",
"pum_form": "requirement",
"line": 8225
},
{
"pum_table_id": "F0462",
"section": "2-8. 덩굴제거, (4) 뿌리굴취(풀베기단계 조림지 1㎝미만 500본, 14㎝ 400본,",
"pum_form": "requirement",
"line": 8265
},
{
"pum_table_id": "F0463",
"section": "2-9. 어린나무가꾸기, (1) 치수림단계 (20m 간격 소작업로 설치, 제거대상 피복도 ‘소’, 가지치기 미실행)",
"pum_form": "requirement",
"line": 8303
},
{
"pum_table_id": "F0464",
"section": "2-10. 어린나무가꾸기, (2) 유령림단계 (제거대상 피복도 ‘밀’, 가지치기 잣나무 02m. 500본/ha)",
"pum_form": "requirement",
"line": 8344
},
{
"pum_table_id": "F0465",
"section": "2-11. 솎아베기, (1) 산물을 임내에 버리는 경우",
"pum_form": "requirement",
"line": 8385
},
{
"pum_table_id": "F0466",
"section": "2-12. 솎아베기, (2) 산물을 전간재로 생산하는 경우",
"pum_form": "requirement",
"line": 8434
},
{
"pum_table_id": "F0467",
"section": "2-13. 위험목 베기",
"pum_form": "requirement",
"line": 8496
},
{
"pum_table_id": "F0468",
"section": "2-14. 산물수집, (1) 인력집재(단목 집재 + 집적)",
"pum_form": "requirement",
"line": 8542
},
{
"pum_table_id": "F0469",
"section": "2-15. 산물수집, (2) 지면끌기집재(공정별 독립작업)",
"pum_form": "requirement",
"line": 8585
},
{
"pum_table_id": "F0470",
"section": "2-16. 산물수집, (3) 지면끌기집재 (동시작업)",
"pum_form": "requirement",
"line": 8655
},
{
"pum_table_id": "F0471",
"section": "2-17. 산물임내정리",
"pum_form": "requirement",
"line": 8720
},
{
"pum_table_id": "F0472",
"section": "3-1. 임업용 동력기계톱, 우드그래플, 소형트럭",
"pum_form": "requirement",
"line": 8753
},
{
"pum_table_id": "F0473",
"section": "3-2. 임업용 동력기계톱, 스마트집재기, 우드그래플, 초소형포워더",
"pum_form": "requirement",
"line": 8828
},
{
"pum_table_id": "F0474",
"section": "3-3. 하베스터, 타워야더, 우드그래플, 소형포워더",
"pum_form": "requirement",
"line": 8912
},
{
"pum_table_id": "F0475",
"section": "4-1. 소나무재선충병방제",
"pum_form": "requirement",
"line": 8989
},
{
"pum_table_id": "F0476",
"section": "4-2. 참나무시들음병방제",
"pum_form": "requirement",
"line": 9106
}
]
}
@@ -2,7 +2,7 @@
"schema_version": "1.0",
"dataset_id": "work_item_master_forest",
"effective_date": "2026-01-01",
"generated_at": "2026-09-08T04:57:04+09:00",
"generated_at": "2026-09-08T06:00:25+09:00",
"dataset_version": {
"dataset_id": "pum_forest",
"effective_date": "2026-01-01",
@@ -22,7 +22,7 @@
"tables_orphan": 19,
"form_undetermined": 77,
"basis_found": 183,
"basis_missing": 160,
"basis_missing": 137,
"basis_grouped": 36
},
"orphan_tables": [
@@ -4157,8 +4157,8 @@
"pum_table_id": "F0455",
"section": "2-1. 풀베기, (1) 둘레베기(조림목 본수 2,700본/ha, 조림1년차)",
"source_line": 8031,
"pum_form": "requirement",
"form_basis": "'수량'",
"pum_form": "reference",
"form_basis": "'단가산출서' — 채워 넣으라고 둔 빈 서식",
"basis_quantity": null,
"basis_unit": null,
"basis_source": null,
@@ -5704,8 +5704,8 @@
"pum_table_id": "F0456",
"section": "2-2. 풀베기, (2) 모두베기(조림목 본수 2,700본/ha, 조림2년차)",
"source_line": 8061,
"pum_form": "requirement",
"form_basis": "'수량'",
"pum_form": "reference",
"form_basis": "'단가산출서' — 채워 넣으라고 둔 빈 서식",
"basis_quantity": null,
"basis_unit": null,
"basis_source": null,
@@ -6796,8 +6796,8 @@
"pum_table_id": "F0472",
"section": "3-1. 임업용 동력기계톱, 우드그래플, 소형트럭",
"source_line": 8753,
"pum_form": "requirement",
"form_basis": "'수량'",
"pum_form": "reference",
"form_basis": "'단가산출서' — 채워 넣으라고 둔 빈 서식",
"basis_quantity": null,
"basis_unit": null,
"basis_source": null,
@@ -7776,8 +7776,8 @@
"pum_table_id": "F0473",
"section": "3-2. 임업용 동력기계톱, 스마트집재기, 우드그래플, 초소형포워더",
"source_line": 8828,
"pum_form": "requirement",
"form_basis": "'수량'",
"pum_form": "reference",
"form_basis": "'단가산출서' — 채워 넣으라고 둔 빈 서식",
"basis_quantity": null,
"basis_unit": null,
"basis_source": null,
@@ -8874,8 +8874,8 @@
"pum_table_id": "F0474",
"section": "3-3. 하베스터, 타워야더, 우드그래플, 소형포워더",
"source_line": 8912,
"pum_form": "requirement",
"form_basis": "'수량'",
"pum_form": "reference",
"form_basis": "'단가산출서' — 채워 넣으라고 둔 빈 서식",
"basis_quantity": null,
"basis_unit": null,
"basis_source": null,
@@ -10245,8 +10245,8 @@
"pum_table_id": "F0475",
"section": "4-1. 소나무재선충병방제",
"source_line": 8989,
"pum_form": "requirement",
"form_basis": "'수량'",
"pum_form": "reference",
"form_basis": "'단가산출서' — 채워 넣으라고 둔 빈 서식",
"basis_quantity": null,
"basis_unit": null,
"basis_source": null,
@@ -11867,8 +11867,8 @@
"pum_table_id": "F0476",
"section": "4-2. 참나무시들음병방제",
"source_line": 9106,
"pum_form": "requirement",
"form_basis": "'수량'",
"pum_form": "reference",
"form_basis": "'단가산출서' — 채워 넣으라고 둔 빈 서식",
"basis_quantity": null,
"basis_unit": null,
"basis_source": null,
@@ -24844,8 +24844,8 @@
"pum_table_id": "F0454",
"section": "8-2-3 굴착기(2025)」를 참조하여 적용계수를 달리 적용하도록 한다.",
"source_line": 7983,
"pum_form": "requirement",
"form_basis": "'수량'",
"pum_form": "reference",
"form_basis": "'단가산출서' — 채워 넣으라고 둔 빈 서식",
"basis_quantity": null,
"basis_unit": null,
"basis_source": null,
+2 -2
View File
@@ -629,8 +629,8 @@ export const ui_locales_b2 = {
B08_Quantity_Method_Ripping: ["긁어내기(암절취)", "Ripping"],
B08_Quantity_Method_Blasting: ["터뜨리기(발파암)", "Blasting"],
B08_Quantity_Placing_NotApplied: [
"⚠ 이 선택은 아직 금액에 반영되지 않습니다 — 견적의 일위대가가 타설 품을 이미 갖고 있는지 확인 중입니다.",
"Not yet applied to cost — checking whether the unit-price already covers placing labour.",
"타설 방식에 따라 이 공종의 단가가 달라집니다 — 콘크리트 물량이 「콘크리트 타설」 공종으로 견적에 넘어갑니다.",
"The placing method changes this work item's unit price — concrete volume is handed over as a placing work item.",
],
B08_Quantity_Side_Topsoil: ["표토제거", "Topsoil Removal"],
B08_Quantity_Side_Topsoil_Label: ["표토 두께(m)", "Topsoil thickness (m)"],