diff --git a/resources/data_cost_input_value/_build_mach.py b/resources/data_cost_input_value/_build_mach.py index 35ccd8a5..01847b15 100644 --- a/resources/data_cost_input_value/_build_mach.py +++ b/resources/data_cost_input_value/_build_mach.py @@ -254,24 +254,24 @@ def parse_fuel( specs = split_values(row[spec_col], len(codes)) if spec_col is not None and spec_col < len(row) else None # fmt: skip for n, code in enumerate(codes): raw = values["fuel_rate_l_per_hour"][n] - # ⚠ 연료가 둘인 칸(`73.7+휘발유54.5`)은 한쪽만 적으면 틀림 — 안 싣고 셈만 남김 - if _num(raw) is None or "+" in raw: + fuels, power = split_fuels(raw) + if not fuels: # ⚠ 주연료가 수가 아닌 기종(전기 `㎾` · `-`)은 **연료 목록에 안 실음** — ℓ/hr 자료임. skipped.append({"machine_code": code, "what": raw.strip()[:20]}) continue record = { "machine_code": code, "machine_name": names.get(code[:4], ""), - # 표시 없는 것은 경유([주]① ㉰) · `휘발유0.7` · `중유487.2` - "fuel_type": ( - "gasoline" - if re.search(r"휘발유|가솔린", raw) - else "heavy_oil" - if "중유" in raw - else "diesel" - ), - "fuel_rate_l_per_hour": _num(raw), + "fuel_type": fuels[0][0], + "fuel_rate_l_per_hour": fuels[0][1], } + # ⚠ **겸용 연료는 둘 다 싣는다** — 한쪽만 적으면 그만큼 경비가 빈다 + # (`73.7+휘발유54.5` 현장가열표층재생기 · `1.87㎾ 휘발유1.3` 모르타르 믹서). + if len(fuels) > 1: + record["secondary_fuel_type"] = fuels[1][0] + record["secondary_fuel_rate_l_per_hour"] = fuels[1][1] + if power is not None: + record["electric_power_kw"] = power for field in ("misc_material_percent_of_fuel", "operator_person_per_day"): value = _num(values[field][n]) if field in values else None if value is not None: @@ -282,6 +282,36 @@ def parse_fuel( return records, bad, source_tables, skipped +#: 겸용 연료 칸을 토막낸다 — `73.7+휘발유54.5` · `1.87㎾ 휘발유1.3` · `중유487.2`. +#: ⚠ 표시가 없으면 경유다([주]① ㉰). +_FUEL_PIECE_RE = re.compile(r"(휘발유|가솔린|중유|경유)?\s*([\d,]+(?:\.\d+)?)\s*(㎾|kW|kw)?") + + +def split_fuels(raw: str) -> tuple[list[tuple[str, float]], float | None]: + """연료 칸 → (`[(연료갈래, ℓ/hr), …]`, 전력 ㎾). + + 한 칸에 연료가 둘인 기종이 있다 — 그 둘을 **다 싣는다**. 수가 없으면 빈 목록(전기·`-`). + """ + fuels: list[tuple[str, float]] = [] + power: float | None = None + for mark, number, kw in _FUEL_PIECE_RE.findall(str(raw or "")): + value = _num(number) + if value is None: + continue + if kw: # 전력은 연료(ℓ/hr)가 아니라 곁값으로 적어 둔다 + power = value + continue + kind = ( + "gasoline" + if mark in ("휘발유", "가솔린") + else "heavy_oil" + if mark == "중유" + else "diesel" + ) + fuels.append((kind, value)) + return fuels, power + + def extract(old_doc: dict, data: bytes) -> tuple[dict, dict]: """옛 벌 + 8장 md → (새 벌, 알림). 사람 판단 칸은 옛 벌에서 이어받음.""" lines = data.decode("utf-8").splitlines() diff --git a/resources/data_cost_input_value/mach_base_2026.json b/resources/data_cost_input_value/mach_base_2026.json index f1779672..f1839ff3 100644 --- a/resources/data_cost_input_value/mach_base_2026.json +++ b/resources/data_cost_input_value/mach_base_2026.json @@ -2,7 +2,7 @@ "schema_version": "1.0", "dataset_id": "mach_base", "effective_date": "2026-01-01", - "generated_at": "2026-09-18T13:19:25+09:00", + "generated_at": "2026-09-18T14:10:46+09:00", "sources": [ { "path": "resources/knowledge/original/원가계산/건설공사_표준품셈/01_공통부문/제8장_건설기계.md", @@ -14132,6 +14132,17 @@ "operator_person_per_day": 1, "specification": "400" }, + { + "machine_code": "3450-0642", + "machine_name": "현장가열 표층재생기", + "fuel_type": "diesel", + "fuel_rate_l_per_hour": 73.7, + "secondary_fuel_type": "gasoline", + "secondary_fuel_rate_l_per_hour": 54.5, + "misc_material_percent_of_fuel": 20, + "operator_person_per_day": 7, + "specification": "479" + }, { "machine_code": "3530-0015", "machine_name": "스테이빌라이저(안정기)", @@ -15085,6 +15096,15 @@ "operator_person_per_day": 1, "specification": "11.19" }, + { + "machine_code": "7992-0001", + "machine_name": "모르타르 믹서", + "fuel_type": "gasoline", + "fuel_rate_l_per_hour": 1.3, + "electric_power_kw": 1.87, + "misc_material_percent_of_fuel": 2, + "specification": "0.3㎥" + }, { "machine_code": "7993-0020", "machine_name": "양수기", @@ -19124,6 +19144,6 @@ "unparsed_price_rows": 0, "unparsed_loss_rows": 10, "unparsed_fuel_rows": 0, - "fuel_not_litre_rows": 30 + "fuel_not_litre_rows": 28 } } diff --git a/resources/data_master_labels/labels_2026-01-01.json b/resources/data_master_labels/labels_2026-01-01.json index 4ba5bb72..36defec6 100644 --- a/resources/data_master_labels/labels_2026-01-01.json +++ b/resources/data_master_labels/labels_2026-01-01.json @@ -1977,7 +1977,7 @@ "merged_columns": 178, "files": 41, "tables": 101, - "columns": 543, + "columns": 546, "value_groups": 207, "unknown": 0, "value_groups_by_kind": { @@ -4813,7 +4813,7 @@ "name_ko": "기계 연료소모량·운전경비", "summary": "기계마다 한 시간에 쓰는 연료량과 잡재료비 비율, 운전원 수.", "shape": "list", - "rows": 274, + "rows": 276, "columns": [ { "key": "machine_code", @@ -4862,6 +4862,23 @@ "name_ko": "읽은 방법", "unit": "", "visible": false + }, + { + "key": "secondary_fuel_type", + "name_ko": "겸용 연료 종류", + "unit": "", + "note": "원문이 한 칸에 연료를 둘 적은 기종 — 현장가열 표층재생기 `73.7+휘발유54.5`." + }, + { + "key": "secondary_fuel_rate_l_per_hour", + "name_ko": "겸용 시간당 연료량", + "unit": "L" + }, + { + "key": "electric_power_kw", + "name_ko": "전력", + "unit": "㎾", + "note": "연료(ℓ/hr)가 아니라 곁값 — 모르타르 믹서 `1.87㎾ 휘발유1.3`." } ] }, diff --git a/resources/tester/fixtures/base_prices_source_known.json b/resources/tester/fixtures/base_prices_source_known.json index 1681dcc0..5164c29f 100644 --- a/resources/tester/fixtures/base_prices_source_known.json +++ b/resources/tester/fixtures/base_prices_source_known.json @@ -19,11 +19,15 @@ "owner": "랩탑 메인", "owner_ask": "기계 원천 벌(mach_base_2026.json) 재추출 — 그 추출기가 이 저장소에 없어 우리가 못 돌림" }, + "셈 연료 원문에 없는 자료 줄": { + "source": 0, + "data": 1, + "why": "원문이 규격을 연료 칸에 밀어 적은 양수기 7993-0020 — 사람이 맞춘 1.3ℓ(휘발유)를 옛 벌에서 이어받음(원문 값 그대로) · 이 시험 읽기는 그 줄을 못 따라감" + }, "셈 연료 코드 수": { - "source": 272, - "data": 274, - "why": "원문이 `7360-0055 0090` 꼴로 앞자리를 이어 적은 줄 하나(추출기는 읽고 이 시험 읽기는 못 따라감) + 원문이 규격을 연료 칸에 밀어 적은 양수기 7993-0020(사람이 맞춘 값을 이어받음) — 둘 다 값은 원문 그대로", - "class": "㉯ 남이 고쳐야 풀림" + "source": 275, + "data": 276, + "why": "원문이 규격을 연료 칸에 밀어 적은 양수기 7993-0020 — 사람이 맞춘 1.3ℓ(휘발유)를 옛 벌에서 이어받음(원문 값 그대로) · 이 시험 읽기는 그 줄을 못 따라감" }, "손료 8201-0100 machine_name": { "source": "3D GNSS 머신 가이던스(굴착기용)", @@ -32,6 +36,18 @@ "class": "㉯ 남이 고쳐야 풀림", "owner": "랩탑 메인", "owner_ask": "기계 원천 벌(mach_base_2026.json) 재추출 — 그 추출기가 이 저장소에 없어 우리가 못 돌림" + }, + "연료 7993-0020": { + "source": null, + "data": { + "machine_code": "7993-0020", + "machine_name": "양수기", + "fuel_type": "gasoline", + "fuel_rate_l_per_hour": 1.3, + "parse_method": "verified_source_row_alignment", + "specification": "1.49㎾" + }, + "why": "원문이 규격을 연료 칸에 밀어 적은 양수기 7993-0020 — 사람이 맞춘 1.3ℓ(휘발유)를 옛 벌에서 이어받음(원문 값 그대로) · 이 시험 읽기는 그 줄을 못 따라감" } }, "material": {}, @@ -245,26 +261,6 @@ "owner": "랩탑 메인", "owner_ask": "기계 원천 벌(mach_base_2026.json) 재추출 — 그 추출기가 이 저장소에 없어 우리가 못 돌림" }, - "연료 못 읽은 줄 7360-0090": { - "what": "원문 표 토막 수가 안 맞음", - "why": "원문이 `7360-0055 0090` 꼴로 앞자리를 이어 적음 — 이 시험 읽기가 못 따라가는 자리(추출기는 읽음)", - "class": "㉯ 남이 고쳐야 풀림" - }, - "연료 못 읽은 줄 7993-0020": { - "what": "원문 표 토막 수가 안 맞음", - "why": "8-4 양수기 줄이 `| 7993-0020 | 양수기 | 1.49㎾ | 1.49㎾ | - | - |` — 원문이 규격을 연료 칸에 밀어 적음 · 사람이 맞춘 1.3ℓ(휘발유)를 옛 벌에서 이어받음", - "class": "㉯ 남이 고쳐야 풀림", - "owner": "랩탑 메인", - "owner_ask": "기계 원천 벌(mach_base_2026.json) 재추출 — 그 추출기가 이 저장소에 없어 우리가 못 돌림" - }, - "연료 표 3450-0642": { - "codes": [ - "3450-0642", - "3450-0642" - ], - "why": "원문 8-4 가 코드·값을 한 칸에 뭉쳐 적어 토막 수가 안 맞음 — 시험 읽기가 건너뜀(랩탑 서브 전사 뒤 새로 생긴 자리)", - "class": "㉯ 남이 고쳐야 풀림" - }, "연료 표 5801-0045": { "codes": [ "5801-0045", @@ -273,22 +269,6 @@ "why": "⬜ **기계 자료 재추출 대기** — 코덱스가 8-4 운전경비 92종을 md 에 전사했으나(2026-09-18) `mach_base_2026.json` 은 그 앞 판이다. 그 벌을 만드는 추출기가 이 저장소에 없어 우리가 못 돌린다. ⇒ 고칠 자리는 마스터가 아니라 **기계 원천 벌 재추출**이다.", "class": "㉯ 남이 고쳐야 풀림" }, - "연료 표 5805-0002": { - "codes": [ - "5805-0002", - "5805-0002" - ], - "why": "⬜ **기계 자료 재추출 대기** — 코덱스가 8-4 운전경비 92종을 md 에 전사했으나(2026-09-18) `mach_base_2026.json` 은 그 앞 판이다. 그 벌을 만드는 추출기가 이 저장소에 없어 우리가 못 돌린다. ⇒ 고칠 자리는 마스터가 아니라 **기계 원천 벌 재추출**이다.", - "class": "㉯ 남이 고쳐야 풀림" - }, - "연료 표 5805-0003": { - "codes": [ - "5805-0003", - "5805-0003" - ], - "why": "⬜ **기계 자료 재추출 대기** — 코덱스가 8-4 운전경비 92종을 md 에 전사했으나(2026-09-18) `mach_base_2026.json` 은 그 앞 판이다. 그 벌을 만드는 추출기가 이 저장소에 없어 우리가 못 돌린다. ⇒ 고칠 자리는 마스터가 아니라 **기계 원천 벌 재추출**이다.", - "class": "㉯ 남이 고쳐야 풀림" - }, "연료 표 7202-0008": { "codes": [ "7202-0008", @@ -305,54 +285,6 @@ "why": "원문 8-4 가 코드·값을 한 칸에 뭉쳐 적어 토막 수가 안 맞음 — 시험 읽기가 건너뜀(랩탑 서브 전사 뒤 새로 생긴 자리)", "class": "㉯ 남이 고쳐야 풀림" }, - "연료 표 7360-0090": { - "codes": [ - "7360-0090", - "7360-0090" - ], - "why": "원문이 `7360-0055 0090` 꼴로 앞자리를 이어 적음 — 이 시험 읽기가 못 따라가는 자리(추출기는 읽음)", - "class": "㉯ 남이 고쳐야 풀림" - }, - "연료 표 7991-0050": { - "codes": [ - "7991-0050", - "7991-0050" - ], - "why": "원문 8-4 가 코드·값을 한 칸에 뭉쳐 적어 토막 수가 안 맞음 — 시험 읽기가 건너뜀(랩탑 서브 전사 뒤 새로 생긴 자리)", - "class": "㉯ 남이 고쳐야 풀림" - }, - "연료 표 7991-0100": { - "codes": [ - "7991-0100", - "7991-0100" - ], - "why": "원문 8-4 가 코드·값을 한 칸에 뭉쳐 적어 토막 수가 안 맞음 — 시험 읽기가 건너뜀(랩탑 서브 전사 뒤 새로 생긴 자리)", - "class": "㉯ 남이 고쳐야 풀림" - }, - "연료 표 7991-0500": { - "codes": [ - "7991-0500", - "7991-0500" - ], - "why": "원문 8-4 가 코드·값을 한 칸에 뭉쳐 적어 토막 수가 안 맞음 — 시험 읽기가 건너뜀(랩탑 서브 전사 뒤 새로 생긴 자리)", - "class": "㉯ 남이 고쳐야 풀림" - }, - "연료 표 7992-0001": { - "codes": [ - "7992-0001", - "7992-0001" - ], - "why": "원문 8-4 가 코드·값을 한 칸에 뭉쳐 적어 토막 수가 안 맞음 — 시험 읽기가 건너뜀(랩탑 서브 전사 뒤 새로 생긴 자리)", - "class": "㉯ 남이 고쳐야 풀림" - }, - "연료 표 7993-0020": { - "codes": [ - "7993-0020", - "7993-0020" - ], - "why": "원문 8-4 가 코드·값을 한 칸에 뭉쳐 적어 토막 수가 안 맞음 — 시험 읽기가 건너뜀(랩탑 서브 전사 뒤 새로 생긴 자리)", - "class": "㉯ 남이 고쳐야 풀림" - }, "운전사 기종별 판단": { "what": 121, "why": "기종→운전사 구분(explicit_mappings)은 사람 판단 — 원문 8-1-3 5호는 기종 이름 글만 있음", diff --git a/resources/tester/fixtures/const_pdf_coverage_known.json b/resources/tester/fixtures/const_pdf_coverage_known.json new file mode 100644 index 00000000..70cb8f9a --- /dev/null +++ b/resources/tester/fixtures/const_pdf_coverage_known.json @@ -0,0 +1,723 @@ +{ + "결손_쪽별": { + "11": 1, + "12": 1, + "16": 1, + "18": 2, + "19": 1, + "20": 2, + "21": 1, + "22": 1, + "23": 1, + "27": 2, + "28": 2, + "29": 1, + "30": 1, + "31": 2, + "32": 1, + "33": 2, + "35": 1, + "36": 2, + "37": 3, + "38": 2, + "39": 1, + "40": 1, + "41": 2, + "42": 1, + "46": 1, + "47": 1, + "49": 1, + "50": 1, + "60": 1, + "62": 2, + "64": 17, + "65": 2, + "66": 6, + "67": 3, + "68": 2, + "71": 2, + "72": 5, + "73": 10, + "74": 4, + "75": 10, + "79": 1, + "81": 1, + "87": 1, + "89": 2, + "91": 2, + "92": 5, + "93": 6, + "95": 11, + "96": 1, + "97": 2, + "98": 1, + "100": 4, + "101": 15, + "102": 3, + "103": 1, + "104": 3, + "105": 1, + "106": 4, + "107": 2, + "109": 4, + "110": 5, + "111": 6, + "112": 1, + "113": 3, + "114": 5, + "115": 2, + "116": 7, + "117": 4, + "118": 1, + "119": 2, + "120": 1, + "121": 9, + "122": 2, + "123": 2, + "124": 4, + "126": 1, + "127": 3, + "130": 1, + "134": 1, + "136": 1, + "138": 1, + "139": 1, + "140": 6, + "141": 5, + "142": 1, + "145": 1, + "149": 1, + "150": 4, + "151": 3, + "155": 1, + "157": 1, + "158": 2, + "159": 1, + "160": 1, + "161": 1, + "162": 1, + "165": 2, + "167": 1, + "168": 2, + "169": 1, + "170": 2, + "172": 1, + "174": 1, + "175": 2, + "177": 1, + "178": 1, + "179": 2, + "180": 1, + "181": 6, + "182": 4, + "183": 2, + "185": 1, + "187": 1, + "188": 2, + "190": 2, + "192": 3, + "193": 2, + "195": 1, + "196": 1, + "197": 1, + "198": 1, + "204": 5, + "205": 1, + "207": 1, + "208": 2, + "211": 1, + "213": 1, + "218": 1, + "220": 1, + "221": 1, + "223": 1, + "224": 1, + "225": 3, + "226": 2, + "227": 1, + "228": 3, + "229": 1, + "230": 1, + "231": 3, + "233": 1, + "235": 1, + "236": 2, + "238": 1, + "239": 1, + "241": 1, + "242": 4, + "243": 2, + "244": 9, + "245": 3, + "246": 4, + "248": 1, + "249": 2, + "253": 2, + "254": 2, + "257": 2, + "262": 1, + "263": 4, + "264": 1, + "265": 4, + "267": 1, + "268": 3, + "270": 1, + "271": 2, + "274": 1, + "275": 26, + "276": 2, + "277": 1, + "282": 2, + "284": 2, + "285": 14, + "286": 10, + "287": 33, + "288": 2, + "289": 6, + "290": 8, + "291": 4, + "292": 4, + "293": 3, + "294": 1, + "295": 6, + "297": 6, + "298": 14, + "299": 1, + "300": 2, + "301": 1, + "302": 1, + "308": 2, + "310": 7, + "311": 1, + "313": 1, + "315": 1, + "316": 1, + "317": 1, + "323": 2, + "324": 1, + "326": 12, + "327": 21, + "328": 5, + "331": 2, + "334": 1, + "335": 2, + "336": 1, + "338": 1, + "342": 3, + "353": 1, + "356": 1, + "357": 1, + "374": 1, + "377": 3, + "381": 1, + "382": 1, + "389": 1, + "405": 1, + "413": 11, + "421": 1, + "424": 4, + "427": 1, + "429": 2, + "431": 1, + "432": 1, + "433": 1, + "435": 1, + "436": 1, + "439": 1, + "441": 1, + "452": 2, + "487": 4, + "508": 1, + "533": 4, + "541": 3, + "542": 1, + "545": 1, + "579": 1, + "601": 1, + "625": 1, + "632": 1, + "663": 1, + "683": 1, + "701": 1, + "704": 2, + "718": 1, + "723": 1, + "733": 3, + "750": 1, + "770": 1, + "779": 1, + "780": 1, + "781": 1, + "794": 1, + "796": 1, + "806": 1, + "820": 1, + "822": 2, + "824": 2, + "832": 1, + "833": 1, + "834": 4, + "839": 1, + "840": 1, + "843": 11, + "852": 1, + "863": 1, + "866": 1, + "868": 1, + "877": 1, + "879": 1, + "880": 1, + "882": 1, + "883": 1, + "884": 1, + "885": 1, + "886": 1, + "887": 1, + "892": 1, + "897": 1, + "898": 1, + "899": 1, + "900": 1, + "905": 1, + "908": 1, + "915": 1, + "920": 1, + "921": 1, + "925": 1, + "931": 1, + "937": 1, + "940": 1, + "941": 1, + "943": 1, + "945": 1, + "947": 1, + "949": 1, + "950": 1, + "953": 1, + "959": 1, + "960": 1, + "961": 1, + "964": 1, + "969": 1, + "973": 1, + "980": 1 + }, + "결손_총": 749, + "허구_총": 547, + "허구_값": { + "0.0020": 1, + "0.0040": 1, + "0.0080": 1, + "0.00840": 1, + "0.010": 3, + "0.01070": 1, + "0.020": 2, + "0.04": 1, + "0.0460": 1, + "0.050": 5, + "0.0508": 1, + "0.0690": 1, + "0.070": 1, + "0.071": 1, + "0.07530": 1, + "0.099": 1, + "0.100": 2, + "0.110": 1, + "0.125": 1, + "0.1520": 1, + "0.300": 1, + "0.310": 1, + "0.312": 1, + "0.335": 7, + "0.413": 1, + "0.432": 1, + "0.4371": 1, + "0.484": 1, + "0.493": 1, + "0.51": 1, + "0.55": 2, + "0.60": 2, + "0.63": 1, + "0.649": 1, + "0.679": 1, + "0.70": 3, + "0.80": 2, + "0.8112": 1, + "0.814": 1, + "0.82": 1, + "0.855": 1, + "0.909": 1, + "0.92": 1, + "00.91": 1, + "000.90": 2, + "00017800.90": 4, + "00020.23": 1, + "00028337686601": 1, + "0002833768660100040.43": 1, + "0002833768660100060.63": 1, + "0002833768660100070.73": 1, + "0002833768660100080.83": 1, + "0002833768660100101.03": 1, + "0008900.90": 13, + "001020": 1, + "00223.0016420": 1, + "00818.16": 1, + "01.0": 1, + "01.01": 2, + "01.5": 1, + "010.01": 1, + "010.010": 1, + "010.02": 2, + "010.05": 1, + "010274.68": 1, + "0140.010": 1, + "02.2": 1, + "020.02": 1, + "020.020": 6, + "020.10": 1, + "022016270008900.90": 1, + "0241500.2260": 1, + "0327": 1, + "0332000.3160": 1, + "03730.174": 1, + "042": 1, + "043.1": 1, + "04532": 1, + "050.010": 1, + "050.020": 1, + "050.05": 4, + "050.050": 9, + "050.300": 1, + "0561.1203": 1, + "060650.0730": 1, + "070.07": 1, + "070.070": 1, + "070.10": 1, + "07467.465": 1, + "075800.1000": 1, + "08.8": 1, + "080": 1, + "1.01": 1, + "1.02": 1, + "1.10431": 1, + "1.129": 1, + "1.15": 1, + "1.150490": 1, + "1.17": 1, + "1.209": 1, + "1.24": 1, + "1.308": 1, + "1.31": 1, + "1.33": 2, + "1.369": 1, + "1.39": 1, + "1.44": 1, + "1.56": 1, + "1.629": 1, + "1.75": 2, + "1.8110": 1, + "1.85": 1, + "10.0": 1, + "10.100": 1, + "100": 11, + "100.020": 1, + "100.05": 1, + "100.070": 1, + "100.100": 1, + "100.20": 1, + "100.50": 1, + "1000.1360": 1, + "10001": 3, + "10003.533331": 1, + "10004.54443": 1, + "1001096": 1, + "1007": 1, + "105": 1, + "107": 1, + "11.52": 1, + "110": 3, + "115": 1, + "120": 1, + "120032.52": 1, + "120043.5333300": 1, + "12211611110610296": 1, + "125": 1, + "1250.1810": 1, + "1255006742299": 1, + "12550067422990202": 1, + "12550067422990204": 1, + "12550067422990302": 1, + "12550067422990402": 1, + "126.1145": 1, + "128": 1, + "134.940": 1, + "1380": 1, + "1400012500.90": 1, + "15.0": 1, + "150": 2, + "150.075": 1, + "1504": 1, + "1521220520019318718217520176171165160154148": 1, + "156": 1, + "1560035": 1, + "15600506.0037320": 1, + "15600727.5053720": 1, + "156016012.501": 1, + "156018016.001": 1, + "156020025.001": 1, + "160.48": 1, + "1600": 2, + "1610.3181": 1, + "166": 2, + "169": 1, + "17.18": 1, + "1705": 1, + "171": 1, + "1722": 1, + "173": 2, + "176": 1, + "18.5": 1, + "18002000220024002600280030000": 1, + "180020002200240026002800300010219": 1, + "180174169163157152": 1, + "186.58": 1, + "1942000017800.90": 1, + "2.0411": 1, + "2.05": 1, + "2.3112": 1, + "2.40": 1, + "2.45": 1, + "2.86": 1, + "2.96": 1, + "20.950": 1, + "200": 8, + "200.050": 1, + "200.100": 1, + "200.200": 1, + "20145139135130124118114": 1, + "20180175170165160155150": 1, + "203": 1, + "203197190": 1, + "21.101": 1, + "21.87": 1, + "21.98": 1, + "210": 1, + "2100": 1, + "214209": 1, + "2168.4191": 1, + "22222233333": 1, + "224.08": 1, + "2300": 1, + "232": 2, + "240": 1, + "250": 5, + "250.028": 1, + "2500": 1, + "250001": 1, + "25001": 1, + "250300350400450500": 1, + "2510": 2, + "26.330": 1, + "260": 1, + "261": 1, + "271.0": 1, + "271.2": 1, + "2867146822682": 1, + "290": 1, + "299.98": 1, + "3.01": 1, + "3.06": 1, + "3.1014": 1, + "3.21": 1, + "3.2516": 1, + "3.27": 1, + "3.40": 1, + "3.61": 1, + "3.67": 1, + "3.81": 1, + "300": 5, + "300.30": 1, + "300.300": 1, + "300523": 1, + "3012111611110610195904066": 1, + "3015515114614113613212640128124119113109104": 1, + "31.101": 1, + "320": 1, + "320034003600380040004200440010": 1, + "325.531": 1, + "3432000017800.90": 1, + "3473413342328322315309303296290": 1, + "350.6": 1, + "3507": 2, + "360": 1, + "361355": 1, + "377370": 1, + "382.6": 1, + "386.3": 1, + "4.01": 1, + "40.11": 5, + "400": 7, + "400500600700": 1, + "4095908681": 1, + "413": 1, + "419": 2, + "423": 1, + "43.049": 1, + "437.344": 1, + "440": 1, + "4450": 1, + "447": 2, + "4545": 1, + "461": 2, + "47.89": 1, + "471121": 1, + "474236": 1, + "4912000017800.90": 1, + "5.0026120": 1, + "5.01": 1, + "5.02": 1, + "5.36": 1, + "5.7118": 1, + "50.11": 3, + "50.257": 1, + "500.056": 1, + "500.300": 1, + "50001": 1, + "5008.57": 1, + "5008336933026": 1, + "501": 2, + "5010197": 1, + "51.72": 1, + "51100": 1, + "5149.2": 1, + "5150": 1, + "52.52": 1, + "5207.7": 1, + "525": 2, + "528027526826225525024410248242235230223218": 1, + "550": 1, + "5500": 1, + "555": 1, + "5600": 1, + "576.56": 1, + "6.01": 1, + "6.02": 1, + "6.0722": 1, + "6.52": 1, + "6.62": 1, + "600": 6, + "612.715": 1, + "615": 1, + "615751": 1, + "6363636363636363636363": 1, + "637": 1, + "6500": 1, + "657.665": 1, + "670": 1, + "6790": 1, + "7.12": 1, + "7.22": 1, + "7.4": 1, + "7.522": 1, + "7.62": 1, + "7.8": 1, + "7.93": 1, + "7.96": 1, + "70.16435004721": 1, + "700": 3, + "70054.5444": 1, + "7006.565": 1, + "7065": 1, + "710121519283233": 1, + "750.14503753311": 7, + "761": 1, + "76413": 1, + "76813": 1, + "77413": 1, + "77613": 1, + "77713": 1, + "78.36": 1, + "78313": 1, + "78413": 1, + "78713": 1, + "78913": 1, + "79613": 1, + "7975": 1, + "8.43": 1, + "80.15": 1, + "800": 3, + "8001000708": 1, + "8052": 1, + "80713": 1, + "81.0": 1, + "81.02": 1, + "81013": 1, + "81213": 1, + "82113": 1, + "82313": 1, + "82413": 1, + "82513": 1, + "82613": 1, + "82713": 1, + "82813": 1, + "82913": 1, + "83013": 1, + "83113": 1, + "83413": 1, + "83613": 1, + "838.148": 1, + "84013": 1, + "84113": 1, + "84213": 1, + "84313": 1, + "84413": 1, + "84913": 1, + "850.13": 6, + "85013": 1, + "85213": 1, + "8581": 1, + "8591": 1, + "8631": 1, + "8641": 1, + "8651": 1, + "8691": 1, + "87.17": 1, + "874.689": 1, + "8752": 1, + "8802": 1, + "8812": 1, + "8842": 1, + "8852": 1, + "8872": 1, + "8892": 1, + "8912": 1, + "8932": 1, + "8942": 1, + "8962": 1, + "8972": 1, + "9.0": 1, + "9.23": 1, + "9.8": 1, + "90.951": 1, + "900": 2, + "900.80": 1, + "9012": 1, + "9032": 1, + "9042": 1, + "9052": 1, + "9060": 5, + "9072": 1, + "9082": 1, + "9133": 1, + "916": 1, + "9173": 1, + "9183": 1, + "9214": 1, + "9224": 1, + "9244": 1, + "938983": 1, + "951.01": 1, + "967.176": 1, + "982": 1 + } +} diff --git a/resources/tester/test_base_prices_source_match.py b/resources/tester/test_base_prices_source_match.py index e3ab6501..52219a8f 100644 --- a/resources/tester/test_base_prices_source_match.py +++ b/resources/tester/test_base_prices_source_match.py @@ -187,18 +187,31 @@ def _codes(cell: str, prefix: str | None) -> tuple[list[str] | None, str | None] LOSS_COLS = {"economic_life_hours": "내용시간", "annual_standard_hours": "가동시간", "depreciation_ratio": "상각비율", "maintenance_ratio": "정비비율", "annual_management_ratio": "관리비율"} # fmt: skip LOSS_COEF = ("depreciation_coefficient_1e_minus_7", "maintenance_coefficient_1e_minus_7", "management_coefficient_1e_minus_7", "source_coefficient_1e_minus_7") # fmt: skip -FUEL_FIELDS = ("fuel_type", "fuel_rate_l_per_hour", "misc_material_percent_of_fuel", "operator_person_per_day") # fmt: skip +FUEL_FIELDS = ("fuel_type", "fuel_rate_l_per_hour", "misc_material_percent_of_fuel", "operator_person_per_day", "secondary_fuel_type", "secondary_fuel_rate_l_per_hour", "electric_power_kw") # fmt: skip + + +#: 연료 토막 — `9.0` 경유([주]① ㉰) · `휘발유0.7` · `중유487.2` · 겸용 `73.7+휘발유54.5` · +#: 전력과 겸용 `1.87㎾ 휘발유1.3`. ⚠ 꼴이 다르면 ValueError — 지어 읽지 않는다. +_FUEL_TOKEN_RE = re.compile(r"(휘발유|중유|경유)?(\d+(?:\.\d+)?)(㎾)?") def _fuel(tok: str) -> tuple: - """주연료 토막 — `9.0` 경유([주]① ㉰: 표시 없는 것은 경유) · `휘발유0.7` · `중유487.2` · `-` 없음.""" + """`(갈래, ℓ/hr, 겸용 갈래, 겸용 ℓ/hr, 전력㎾)`. 값이 없으면 자리마다 `None`.""" if tok == "-": - return None, None - m = re.fullmatch(r"(휘발유|중유)?(\d+(?:\.\d+)?)", tok) - if m is None: + return None, None, None, None, None + rest, fuels, power = tok, [], None + for m in _FUEL_TOKEN_RE.finditer(tok): + rest = rest.replace(m.group(0), "", 1) + if m.group(3): # 전력은 ℓ/hr 가 아니다 — 곁값 + power = float(m.group(2)) + continue + kind = {"휘발유": "gasoline", "중유": "heavy_oil", "경유": "diesel", None: "diesel"}[m.group(1)] # fmt: skip + fuels.append((kind, float(m.group(2)))) + if rest.replace("+", "").strip() or not (fuels or power): raise ValueError(tok) - kind = {"휘발유": "gasoline", "중유": "heavy_oil", None: "diesel"}[m.group(1)] - return kind, float(m.group(2)) + first = fuels[0] if fuels else (None, None) + second = fuels[1] if len(fuels) > 1 else (None, None) + return first[0], first[1], second[0], second[1], power def read_machine(path: Path) -> dict: @@ -268,11 +281,16 @@ def read_machine(path: Path) -> dict: continue for r in t["rows"]: codes, prefix = _codes(r[0], prefix) - cols = [r[3].split(), r[4].split(), r[5].split()] + # ⚠ 코드가 하나인 줄은 **칸 통째로** 한 값이다 — `1.87㎾ 휘발유1.3` 처럼 값 안에 공백이 온다. + cols = [[c.strip()] if len(codes) == 1 else c.split() for c in (r[3], r[4], r[5])] try: if not codes or any(len(c) != len(codes) for c in cols): raise ValueError(r[0]) - parsed = [(*_fuel(f), _num(m), _num(o)) for f, m, o in zip(*cols)] + parsed = [ + (ft[0], ft[1], _num(m), _num(o), ft[2], ft[3], ft[4]) + for f, m, o in zip(*cols) + for ft in (_fuel(f),) + ] except ValueError: out["skipped"][f"연료 표 {codes[0] if codes else _t(r[0])[:24]}"] = { "codes": codes and [codes[0], codes[-1]] diff --git a/resources/tester/test_const_pdf_coverage.py b/resources/tester/test_const_pdf_coverage.py new file mode 100644 index 00000000..d731ec38 --- /dev/null +++ b/resources/tester/test_const_pdf_coverage.py @@ -0,0 +1,198 @@ +"""건설 원문 PDF ↔ md **전수 커버리지** — 2026-09-18 브레인 B14(사용자 지시). + +왜 — 지금껏 증상이 잡히는 대로 하나씩 찾아 고쳐 느렸다. **구멍 전체 크기를 한 번에** 드러내 +목록으로 몰아 고치려고 둔 검사다. + +무엇을 재나 — PDF 글자층에서 좌표로 줄을 다시 묶어 **값처럼 생긴 수**를 뽑고, md 표의 수와 +양방향으로 맞댄다. + +``` +결손 PDF 에 있는데 md 에 없음 ← 9020 해상기계처럼 표째 빠진 병 +허구 md 에 있는데 PDF 에 없음 ← 잘못 지어 넣은 값 +``` + +⚠ **줄 짝은 안 맞춘다** — PDF 는 칸마다 줄을 바꾸고 md 는 칸을 묶어 적어, 줄끼리 맞대면 + 멀쩡한 표도 어긋난 것으로 뜬다. **수의 갯수(중복 포함)** 로만 맞댄다. +⚠ **값처럼 생긴 수만 본다** — 소수점이 있거나 세 자리 이상. 쪽번호·차례 번호·항목 번호를 + 값으로 세면 셈이 노이즈에 묻힌다. +⚠ 차례(`···· 123`) 줄은 뺀다 — 쪽 번호가 값으로 섞인다. + +지금 남은 것은 `fixtures/const_pdf_coverage_known.json` 에 **쪽별 셈**으로 적어 둔다. +줄이면 초록, 늘면 빨강 — 새 구멍이 생겼다는 뜻이다. + +목록 갱신: `./venv/Scripts/python.exe resources/tester/test_const_pdf_coverage.py --prune` +""" + +from __future__ import annotations + +import collections +import json +import re +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +PDF = ROOT / "resources/knowledge/original/원가계산/건설공사_표준품셈/2026년_건설공사_표준품셈.pdf" +MD = ROOT / "resources/knowledge/original/원가계산/건설공사_표준품셈/2026년_건설공사_표준품셈.md" +KNOWN = Path(__file__).with_name("fixtures") / "const_pdf_coverage_known.json" + +_NUM = re.compile(r"\d[\d,]*(?:\.\d+)?") +#: 차례 줄 — 점선 뒤 쪽번호가 값으로 섞인다. +_LEADER = re.compile(r"[·․…\.]{4,}") +#: 쪽번호 줄 — 수 하나만 있고 다른 글자가 없다. +_ONLY_NUMBER = re.compile(r"\d[\d,]*(?:\.\d+)?") +#: 한 줄로 묶을 때 세로 오차(포인트). +_ROW_TOLERANCE = 2.5 + + +def _norm(token: str) -> str: + return token.replace(",", "") + + +def _valuish(token: str) -> bool: + """값처럼 생긴 수인가 — 소수점이 있거나 세 자리 이상. + + ⚠ 연도(`2026`·`1999`)는 뺀다 — 「'22년 보완」·발행연도가 값으로 섞인다. + """ + if re.fullmatch(r"(?:19|20)\d\d", token): + return False + return "." in token or len(token) >= 3 + + +def _page_rows(page) -> list[str]: + """글자 좌표로 **줄을 다시 묶는다** — PDF 글자층은 칸마다 줄을 바꾼다.""" + words = sorted(page.get_text("words"), key=lambda w: (round(w[1] / _ROW_TOLERANCE), w[0])) + rows: list[list] = [] + current: list = [] + top: float | None = None + for word in words: + if top is None or abs(word[1] - top) <= _ROW_TOLERANCE: + current.append(word) + top = word[1] if top is None else top + else: + rows.append(current) + current, top = [word], word[1] + if current: + rows.append(current) + return [" ".join(w[4] for w in sorted(row, key=lambda x: x[0])) for row in rows] + + +def pdf_numbers() -> tuple[collections.Counter, dict[str, list[int]]]: + """PDF 값 수의 셈과 **어느 쪽에서 나왔는지**.""" + import pymupdf + + counts: collections.Counter = collections.Counter() + pages: dict[str, list[int]] = collections.defaultdict(list) + with pymupdf.open(PDF) as doc: + for number, page in enumerate(doc, start=1): + for line in _page_rows(page): + if _LEADER.search(line): + continue + found = [_norm(x) for x in _NUM.findall(line) if _valuish(_norm(x))] + # ⚠ **쪽번호 줄만 뺀다** — 줄에 수 하나뿐이고 다른 글자가 없는 줄이다. + # 값 하나짜리 줄까지 빼면 여러 줄로 갈린 칸(`[80∼95]`)이 통째로 안 세어진다. + if not found or _ONLY_NUMBER.fullmatch(line.strip()): + continue + for token in found: + counts[token] += 1 + pages[token].append(number) + return counts, pages + + +def md_numbers() -> collections.Counter: + """md 전문의 값 수 — ⚠ **표만 보지 않는다.** md 는 PDF 를 옮겨 적은 글이라 본문·[주]까지 + 맞대야 「옮겨 적다 빠뜨린 것」이 드러난다(표만 보면 PDF 본문 쪽이 통째로 결손으로 뜬다).""" + counts: collections.Counter = collections.Counter() + for line in MD.read_text(encoding="utf-8").splitlines(): + if _LEADER.search(line): + continue + found = [_norm(x) for x in _NUM.findall(line) if _valuish(_norm(x))] + if not found or _ONLY_NUMBER.fullmatch(line.strip()): + continue + counts.update(found) + return counts + + +def measure() -> dict[str, object]: + """`{결손 쪽별 셈, 허구 수, 총계}` — 양방향 구멍.""" + pdf, pages = pdf_numbers() + md = md_numbers() + missing, invented = pdf - md, md - pdf + per_page: collections.Counter = collections.Counter() + for token, count in missing.items(): + for page in pages[token][:count]: + per_page[page] += 1 + return { + "결손_쪽별": {str(k): v for k, v in sorted(per_page.items())}, + "결손_총": sum(missing.values()), + "허구_총": sum(invented.values()), + "허구_값": {k: v for k, v in sorted(invented.items())}, + } + + +@pytest.fixture(scope="module") +def now() -> dict[str, object]: + return measure() + + +def _known() -> dict: + return json.loads(KNOWN.read_text(encoding="utf-8")) + + +def test_결손이_목록보다_늘지_않음(now: dict) -> None: + """PDF 에 있는데 md 에 없는 값 — **줄면 초록, 늘면 빨강**(새 구멍).""" + known = _known() + grew = [ + f" 쪽 {page}: 목록 {known['결손_쪽별'].get(page, 0)} → 지금 {count}" + for page, count in now["결손_쪽별"].items() + if count > known["결손_쪽별"].get(page, 0) + ] + assert not grew, "PDF 에만 있는 값이 늘었음 — md 에서 빠진 자리:\n" + "\n".join(grew) + + +def test_허구가_목록보다_늘지_않음(now: dict) -> None: + """md 에 있는데 PDF 에 없는 값 — 지어 넣은 자리.""" + known = _known() + grew = { + token: count + for token, count in now["허구_값"].items() + if count > known["허구_값"].get(token, 0) + } + assert not grew, f"PDF 에 없는 값이 md 에 늘었음: {grew}" + + +def test_목록이_원문과_같은_판(now: dict) -> None: + """목록의 총계가 지금 셈과 같은가 — 줄었으면 `--prune` 으로 목록을 줄일 것.""" + known = _known() + assert now["결손_총"] <= known["결손_총"], ( + f"결손 {known['결손_총']} → {now['결손_총']}" # 늘었다 + ) + assert now["허구_총"] <= known["허구_총"], f"허구 {known['허구_총']} → {now['허구_총']}" + + +if __name__ == "__main__" and "--report" in sys.argv: + # 구멍 목록 — md 를 고치는 창(코덱스·랩탑서브·안티그래비티)이 볼 자리 + pdf_counts, pdf_pages = pdf_numbers() + md_counts = md_numbers() + missing, invented = pdf_counts - md_counts, md_counts - pdf_counts + holes: collections.Counter = collections.Counter() + for value, count in missing.items(): + for page in pdf_pages[value][:count]: + holes[page] += 1 + print(f"결손 {sum(missing.values())} · 허구 {sum(invented.values())}") + print("구멍 큰 쪽 스물") + for page, count in holes.most_common(20): + print(f" 쪽 {page:4d} {count}") + print("PDF 에 없는 값 열(붙은 줄 잔재가 많다)") + for value, count in invented.most_common(10): + print(f" {value} x{count}") + +if __name__ == "__main__" and "--prune" in sys.argv: + got = measure() + before = _known() + KNOWN.write_text(json.dumps(got, ensure_ascii=False, indent=1) + "\n", encoding="utf-8") + print( + f"결손 {before['결손_총']} → {got['결손_총']} · 허구 {before['허구_총']} → {got['허구_총']}" + )