From f255ebc0feffe865ff7bcbc571b97341577f2776 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 5 Nov 2026 17:57:49 +0900 Subject: [PATCH 1/4] fix(est): preserve precision and parse source notes --- resources/tester/test_est_text_units.py | 122 ++++++++++++++++++++++++ tools/est-test/README.md | 6 +- tools/est-test/native-text.js | 26 +++-- 3 files changed, 143 insertions(+), 11 deletions(-) diff --git a/resources/tester/test_est_text_units.py b/resources/tester/test_est_text_units.py index af7b3bae0..a82af7fea 100644 --- a/resources/tester/test_est_text_units.py +++ b/resources/tester/test_est_text_units.py @@ -135,3 +135,125 @@ def test_subtotal_includes_price_only_rows_and_updates_truncated_final_amount(): 149380, 158440, ] + + +def test_operator_followed_by_spaces_does_not_start_a_new_assignment(): + result = run( + "math", + r"""[1700,2000].map(weight=>EstText.calculate([ + {Index:1,Code:'forest',LineData:'forest'}, + {LineData:` N = 54.93 q = 250 W = ${weight}`}, + {LineData:' Q = N × q ÷ W = 54.93 × 250 ÷ 1,700 = \x128.08\x12 ㎥/hr'}, + {LineData:' V = Q × 2 = \x1216.16\x12'} + ]))""", + ) + assert all(item["errors"] == [] for item in result) + assert [item["rows"][2]["TC"] for item in result] == [8.08, 6.87] + assert [item["rows"][3]["TC"] for item in result] == [16.16, 13.74] + + +def test_curly_wrapped_results_and_explanatory_calculations_keep_their_text(): + result = run( + "math", + r"""EstText.calculate([ + {Index:1,Code:'forest',LineData:'forest'}, + {LineData:' {0.91m×0.91m×0.6m = \x120.49686\x12㎥}'}, + {LineData:' {0.49686 ×2.6Ton = \x121.29\x12}'}, + {LineData:' ※ ㎡당 중량 1.6개 ×1,325÷1,000 = \x122.12\x12Ton/㎡'} + ])""", + ) + assert result["errors"] == [] + assert [row["TC"] for row in result["rows"][1:]] == [0.49686, 1.29, 2.12] + assert result["rows"][1]["LineData"].strip().startswith("{") + assert result["rows"][1]["LineData"].endswith("}") + + +def test_won_unit_glyphs_are_units_instead_of_operators(): + result = run( + "math", + r"""EstText.calculate([ + {Index:1,Code:'money',LineData:'money'}, + {LineData:' 0.0347㎥ / 2.5 × 0 \\/㎥ = \x120\x12'}, + {LineData:' 0.0347㎥ / 2.5 × 2500 \\/㎥ = \x1234.7\x12'}, + {LineData:' 2㎥ × 2500₩/㎥ = \x125000\x12'} + ])""", + ) + assert result["errors"] == [] + assert [row["TC"] for row in result["rows"][1:]] == [0, 34.7, 5000] + + +def test_range_conditions_are_not_assigned_arbitrary_values(): + result = run( + "math", + r"""EstText.calculate([ + {Index:1,Code:'range',LineData:'range'}, + {LineData:' 앙카핀 (D=13, L=450~600mm) 1.0㎡ : 별도계상'}, + {LineData:' H = 0~4m : (기계80%, 인력20%)'}, + {LineData:' Q = 2 × 3 = \x126\x12'}, + {LineData:' V = H × 2 = \x128\x12'} + ])""", + ) + assert len(result["errors"]) == 1 + assert "참조 항목 없음: H" in result["errors"][0] + assert result["rows"][3]["TC"] == 6 + + +def test_quantity_subtotal_keeps_six_digits_before_pricing(): + result = run( + "math", + r"""[4.4,5].map(length=>EstText.calculate([ + {Index:1,Code:'sign',LineData:'sign'}, + {Level:2,LineData:'각재 수량'}, + {Diget:7,DigetIndex:1,LineData:` 0.09 ×0.09 ×${length}m ×2개 = \x120.07128\x12`}, + {Diget:7,DigetIndex:1,LineData:' 0.06 ×0.06 ×2.28m ×5개 = \x120.04104\x12'}, + {Diget:7,DigetIndex:1,LineData:' 0.06 ×0.06 ×2.28m ×7개 = \x120.057456\x12'}, + {Diget:7,DigetIndex:1,LinkIndex:61,LineData:' 소 계 : Q1 = \x0c0.169776\x0c ㎥/개소'}, + {SCheck:-2,LinkIndex:5,LineData:' 재료비 : \x03577,842\x03 × Q1 = 577,842 × 0.169776 = \x1298,103.7\x12'} + ]))""", + ) + assert all(item["errors"] == [] for item in result) + assert [item["rows"][5]["TC"] for item in result] == [0.169776, 0.179496] + assert [item["rows"][6]["TC"] for item in result] == [98103.7, 103720.3] + + +def test_declaration_can_follow_an_hour_unit_with_a_single_space(): + result = run( + "math", + r"""EstText.calculate([ + {Index:1,Code:'transport',LineData:'transport'}, + {LineData:' L = 1.0Km{?} V1 = 5.5Km/hr V2 = 9.3Km/hr t=35분'}, + {LineData:' Q = t × 2 = \x1270\x12'} + ])""", + ) + assert result["errors"] == [] + assert result["rows"][2]["TC"] == 70 + + +def test_fullwidth_unit_slash_and_ground_level_annotations(): + result = run( + "math", + r"""EstText.calculate([ + {Index:1,Code:'annotations',LineData:'annotations'}, + {LineData:' Q = 23.32 ㎥/hr'}, + {LineData:' D1 = {-}6.6m [평균지반고]'}, + {LineData:' D2 = {-}18.6m [기초터파기 평균 저면 EL.]'}, + {LineData:' V = Q + D1 - D2 = \x1235.32\x12',Precision:2} + ])""", + ) + assert result["errors"] == [] + assert result["rows"][4]["TC"] == 35.32 + + +def test_spaced_metre_unit_is_not_a_second_variable_declaration(): + result = run( + "math", + r"""EstText.calculate([ + {Index:1,Code:'spaced_unit',LineData:'spaced_unit'}, + {LineData:' L7 = 4.5m'}, + {LineData:' Q2 = L7 / 0.8 M = 4.5 / 0.8 = \x125.63\x12 개'}, + {SCheck:-2,LineData:' 재료비 : \x033,000\x03 × Q2 = 3,000 × 5.63 = \x1216,890\x12'} + ])""", + ) + assert result["errors"] == [] + assert result["rows"][2]["TC"] == 5.63 + assert result["rows"][3]["TC"] == 16890 diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 98f8d0eb7..8c40544eb 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -36,11 +36,13 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요 설계변경 비교는 웹에서 확정한 당초 사본이 없을 때 원본 파일의 당초 수량·금액을 사용합니다. 별도 당초 확정 사본이 있으면 그 사본이 우선합니다. 원본 당초 전체 자료와 원가 서식의 활성 선택을 자동 추정하지는 않습니다. -원본 산근의 `배`·`번`·`줄`·`set`·`kg/set`·`㎜`, 괄호 뒤 면적·체적 단위와 별표·따옴표로 감싼 변수 선언도 계산합니다. 측량설계 저장값 대조는 80,659/80,682 일치·해석 오류 5건, 가시설은 13,978/13,979 일치·해석 오류 4건입니다. 범위 조건과 누락된 참조 등은 임의 값으로 대체하지 않습니다. +원본 산근의 `배`·`번`·`줄`·`set`·`kg/set`·`㎜`, 괄호 뒤 면적·체적 단위와 별표·따옴표로 감싼 변수 선언도 계산합니다. 연산자 뒤 공백과 새 선언을 구분하며, `\\/㎥`·`₩/㎥` 금액 단위와 전각 단위 구분자도 읽습니다. 측량설계 저장값 대조는 80,671/80,682 일치·해석 오류 0, 산림토목은 15,049/15,055 일치·해석 오류 0, 가시설은 13,978/13,979 일치·해석 오류 3건입니다. 범위 조건과 누락된 참조 등은 임의 값으로 대체하지 않습니다. + +소계는 원본처럼 정밀도를 유지하며 합산한 뒤 행의 자릿수를 적용합니다. 수량 `0.169776`을 4자리로 미리 줄이지 않습니다. 원본 산식과 저장 표지가 다른 경우는 구분합니다. 예를 들어 상수공사 원문의 `{ 소요살수량 1.7 ÷ 0.95 = ⟦결과⟧1.7⟦결과⟧9 Ton/㎥ }`는 저장 표지 1.7과 재계산 1.79가 다릅니다. 구간 합계와 소계는 결과 금액뿐 아니라 단가만 적힌 지정 행도 포함합니다. 구간 합계의 표지와 최종 합계 표지를 구분하며, 이미 정수로 절사된 최종 합계도 앞 성분 금액과의 합산 관계가 확인되면 변경액을 다시 집계합니다. 단가 550→600 변경이 구간 비율 27.5→30, 최종 합계 27→30, 내역 270→300으로 전달되는 것을 저장·취소까지 확인했습니다. -이 보완 후 해양 자료는 소계 표지까지 포함해 134,133/134,208 저장값 일치·해석 오류 99건입니다. 계산·이력 전용 시험 64개가 통과했으며, 남은 불일치와 원본 실행 미확인은 별개로 유지합니다. +이 보완 후 해양 자료는 소계 표지까지 포함해 134,133/134,208 저장값 일치·해석 오류 95건입니다. 계산·이력 전용 시험 72개가 통과했으며, 남은 불일치와 원본 실행 미확인은 별개로 유지합니다. **전체 원본 동등성은 아직 확보하지 못했습니다.** 기성·물가변동·연차 등은 분석 분기와 합성 시나리오를 시험했으며 원본 업무 샘플 대조는 없습니다. 원본 화면 실행 비교는 못했습니다. 원본 페이지·도면 배치, CAD/VEC0650 편집, 원본 인쇄 서식, Excel/XML/JDL 변환, ESTX 수정 저장은 미구현입니다. 원본 대체품의 완료 판정으로 보지 않습니다. diff --git a/tools/est-test/native-text.js b/tools/est-test/native-text.js index af6dc3ce6..5ca99405f 100644 --- a/tools/est-test/native-text.js +++ b/tools/est-test/native-text.js @@ -18,19 +18,21 @@ const EstText = (() => { .replace(/[;;]/g, ":"); function expression(text) { return clean( - String(text).replace( - /\x0e[^\x0e]*\x0e|\x13[^\x13]*\x13|\x15[^\x15]*\x15|\{[^}]*\}|〔[^〕]*〕|[[^]]*]|\([가-힣][^()]*\)|\(\s*\d+(?:\.\d+)?\s*[˚°]\s*\)|<[^>]*>/g, - "", - ), + String(text) + .replace(///g, "/") + .replace( + /\x0e[^\x0e]*\x0e|\x13[^\x13]*\x13|\x15[^\x15]*\x15|\{[^}]*\}|〔[^〕]*〕|[[^]]*]|\[[가-힣][^\]]*\]|\([가-힣][^()]*\)|\(\s*\d+(?:\.\d+)?\s*[˚°]\s*\)|<[^>]*>/g, + "", + ), ) .replace( - /(? { ? EstMath.precision(row) : { digits: 1, method: 2 }; const subtotal = floating( - sumCurrency(values), + // f251d5-f251df accumulates Extended values before applying the row precision. + values.reduce((total, value) => total + value, 0), options.CostValueTrunc && sc < 0 ? 0 : precision.digits, precision.method, ); @@ -318,9 +321,9 @@ const EstText = (() => { ? [] : [ ...assignmentText.matchAll( - /(?:^\s*| {2,}|[,,]\s*|(?<=[가-힣])\s+|[::]\s*|['"]\s*)([A-Za-z][A-Za-z0-9]*)\s*=/g, + /(?:^\s*| {2,}|[,,]\s*|(?<=[가-힣])\s+|(?<=hr)\s+|[::]\s*|['"]\s*)([A-Za-z][A-Za-z0-9]*)\s*=/g, ), - ]; + ].filter((match) => !/[+*/÷×−-]\s*$/.test(assignmentText.slice(0, match.index))); for (let j = 0; j < assignments.length; j++) { const assignment = assignments[j], end = assignments[j + 1]?.index ?? text.length; @@ -336,6 +339,10 @@ const EstText = (() => { continue; } const result = body.match(marker); + if (!result && /\d\s*[~~]\s*\d/.test(body)) { + delete variables[assignment[1]]; + continue; + } const parts = body.split("="); body = parts[0]; body = expression(body) @@ -385,6 +392,7 @@ const EstText = (() => { .filter((x) => x.trim()) .pop(); if (body.includes(":")) body = body.slice(body.indexOf(":") + 1); + if (/^\s*\{[\s\S]*\}\s*$/.test(text)) body = body.replace(/^\s*\{/, ""); body = expression(body) .replace(/\s*([×÷·+*/−-])\s*/g, "$1") .trim() From a0bcf5b48af710b19fbd3d2443dbc8a1d77107f1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 5 Nov 2026 18:04:11 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(792-3):=20run=5Fline=20=C2=B7=20B09=20?= =?UTF-8?q?=EC=82=B0=EA=B7=BC=20=EC=85=88=EC=9D=B4=20=EC=97=94=EC=A7=84=20?= =?UTF-8?q?=EC=83=88=20=EC=9D=98=EB=AF=B8=EB=A5=BC=20=EB=94=B0=EB=A6=84=20?= =?UTF-8?q?=E2=80=94=20=EB=84=A4=20=EA=B8=B8=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=EA=B8=88=EC=95=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - master_cut: 엔진 답 덧칸 `셈법`(새 칸 뗀 작은 로직) · `plain(got, gone)` 이 셈법 있으면 줄 자리 · 합밖 · 단계 · 블록 끝전 · 조정 그대로(켜쌓기율 · 별도계상 뒤 다시 버림이 큰나머지 · 조정을 덮던 흠) · `kept` - M02_Labor_Doc: run_line 답이 단계 · 셈법을 넘김 · drop_apart 가 plain 에 뺄 줄 차례를 줌(686 줄 · 늘지 않음) - 새 B09_Estimation_Sangeun_Sum: 산근 풀이 · 산근 글이 새 의미 로직만 엔진 규칙(표시 · 줄 자리 · 합밖 · 번지 · 단계 · 끝전 · 조정) · 옛 로직 = Q ROUND 2 · ROUNDDOWN 1 · 0 그대로 - B09 산근 글: 로직 줄(중기 세 줄) 금액을 줄 하나로 묶어 차례를 호표와 맞춤(합 같음) - 지킴 시험 test_792_3_engine_paths: 견본 넷(봉화 · 소광 · 영월 · 임도) + 단위 로직 = 엔진 · M02 SK · SC 한 줄 · B09 산근(글 계 포함) 같은 금액 · 다시 버림 셈법 지킴 - test_791_1: 견본 넷을 만들기 함수로 떼어 냄(같은 기대) - test_222_1 낡은 기대 셋 고침: 751-3 끝수 뒤 별도계상 뺀 기준 = 남은 줄 비목 합 원 버림(소수 없음) · 옛 기대는 「원 버린 기준 − 소수 재료 금액」이라 0.6 원 어긋남 - test_145_1: 작업량 Q 이름 규칙이 B09_Estimation_Sangeun_Sum 으로 옮김 Co-Authored-By: Claude Opus 5.5 --- B09_Estimation/B09_Estimation_Sangeun.py | 18 +-- B09_Estimation/B09_Estimation_Sangeun_Sum.py | 89 +++++++++++ B09_Estimation/B09_Estimation_Sangeun_Text.py | 26 +-- M02_MasterTemplete/M02_Labor_Doc.py | 5 +- resources/master_data/scripts/master_cut.py | 42 +++-- .../master_data/scripts/master_formula_sum.py | 11 ++ resources/tester/test_145_1_b09_sangeun.py | 5 +- resources/tester/test_222_1_m02_apart.py | 28 ++-- .../tester/test_791_1_engine_semantics.py | 36 ++++- resources/tester/test_792_3_engine_paths.py | 148 ++++++++++++++++++ 10 files changed, 356 insertions(+), 52 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_Sangeun_Sum.py create mode 100644 resources/tester/test_792_3_engine_paths.py diff --git a/B09_Estimation/B09_Estimation_Sangeun.py b/B09_Estimation/B09_Estimation_Sangeun.py index 0e90f6548..fa224c392 100644 --- a/B09_Estimation/B09_Estimation_Sangeun.py +++ b/B09_Estimation/B09_Estimation_Sangeun.py @@ -36,13 +36,13 @@ from __future__ import annotations import copy import hashlib import json -import re from decimal import Decimal from pathlib import Path from typing import Any from B08_Quantity.B08_Quantity_Haul_Cycle import fill_given -from B09_Estimation.B09_Estimation_Calc_Base import gap, num, round_half, rounddown, total +from B09_Estimation import B09_Estimation_Sangeun_Sum as ss +from B09_Estimation.B09_Estimation_Calc_Base import gap, num, rounddown, total from B09_Estimation.B09_Estimation_Store import ref_rows from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Combo as combo_store @@ -64,7 +64,6 @@ PARTS = ("labor", "material", "expense") COST_PART = {"노무비": "labor", "재료비": "material", "경비": "expense"} PCT_OF = {**COST_PART, "인건비": "people"} # % 덧줄 밑수 → 덮개 다시 셈(719-3) DEPTH = 4 # 하위 산근 깊이 상한 -_Q = re.compile(r"Q\d*") # 작업량 중간값 이름(산림품셈 09장 토공 · 07장 집재 …) NO_SOURCE = "조합 밖 토공 · 운반 · 사토 · 준비공은 산근 없음" EMPTY = "연결된 구조물 조합 사본에 단가산출 줄 없음 — 연결 없음" @@ -239,9 +238,7 @@ def _env(whole: Any, row: dict, given: dict) -> dict: except mf.EmptyInput as e: env[step["이름"]] = mf.Blank(str(e)) continue - if _Q.fullmatch(step["이름"]) and isinstance(value, (Decimal, int, float)): - value = Decimal(str(round_half(float(value), 2))) - env[step["이름"]] = value + env[step["이름"]] = ss.shown(row, step, value) # 표시 · Q ROUND 2(792-3) return env @@ -375,11 +372,12 @@ class Solver: with mf.mp.choosing(src.get("picks")): for n, item in enumerate(row.get("호표", [])): lines.append(self._line(whole, item, n, env, times, rate, depth)) + ss.places(row, lines) # 줄 자리(792-3) self._extras(whole, row, env, lines) _annotate(whole, row, env, lines) except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e: return self._stop(code, name, str(e) or type(e).__name__) - sums = block_totals(lines) + sums = ss.totals(row, lines) or block_totals(lines) # 끝전 · 조정(792-3) why = next((ln["why"] for ln in lines if ln.get("why")), None) head, foot = self._text(whole, src, times, lines) spec, spec_raw = spec_text(whole, src["given"]) @@ -513,7 +511,7 @@ class Solver: whole, row, heads + [{} for _ in row.get("덧줄", [])], dict(env) ) for extra, pct in zip(row.get("덧줄", []), pcts[len(heads) :]): - sums = {name: total([ln[p] for ln in lines]) for name, p in COST_PART.items()} + sums = ss.sums(row, lines) # 합밖 · 제외(792-3) part = COST_PART.get(extra.get("비목"), "material") line = {"kind": "extra", "name": extra["이름"], "spec": extra["식"], "qty": 1.0, "unit": "식", "ref": None, **dict.fromkeys(PARTS, 0.0)} # fmt: skip @@ -525,6 +523,7 @@ class Solver: lines.append({**line, part: None, "price": {}, "why": "밑수 빈칸"}) continue env.update({k: Decimal(str(v)) for k, v in sums.items()}) + ss.scope(row, lines, env) # 번지 · 단계(792-3) env["줄"] = { ln["name"]: Decimal(str(sum(ln[p] or 0 for p in PARTS))) for ln in lines @@ -535,7 +534,8 @@ class Solver: except (mf.FormulaError, ArithmeticError, TypeError, ValueError) as e: lines.append({**line, part: None, "price": {}, "why": str(e)}) continue - lines.append({**line, "price": {part: value}, part: rounddown(value, 1), "why": None}) + money = ss.extra(row, extra, value, env) # 줄 자리(792-3) + lines.append({**line, "price": {part: value}, part: money, "why": None}) def _annotate(whole: Any, row: dict, env: dict, lines: list[dict[str, Any]]) -> None: diff --git a/B09_Estimation/B09_Estimation_Sangeun_Sum.py b/B09_Estimation/B09_Estimation_Sangeun_Sum.py new file mode 100644 index 000000000..7d8eb85d5 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Sangeun_Sum.py @@ -0,0 +1,89 @@ +"""B09 산근 풀이 셈 의미(PLAN 792-3) — 791-1 엔진 새 의미(줄 자리 · 합밖 · 번지 · 단계 · 블록 끝전 · +결과 조정 · 변수 표시)를 쓰는 로직만 엔진 규칙(`master_cut` · `master_formula_sum`) 그대로 · +새 칸 없는 로직 = 옛 B09 규칙(작업량 Q = ROUND 2 · 줄 ROUNDDOWN 1 · 총계 비목마다 ROUNDDOWN 0). +쓰는 곳 = `B09_Estimation_Sangeun.Solver` · 지킴 시험 = `test_792_3_engine_paths.py`(엔진 · M02 SK · +SC · B09 산근 네 길 같은 금액). +""" + +from __future__ import annotations + +import importlib +import re +from decimal import Decimal +from typing import Any + +from B09_Estimation.B09_Estimation_Calc_Base import round_half, rounddown, total +from M01_MasterData import M01_MasterData_Store as _m01 # noqa: F401 — scripts 길을 둠 + +mcut = importlib.import_module("master_cut") +ms = importlib.import_module("master_formula_sum") + +PARTS = ("labor", "material", "expense") +COST_PART = {"노무비": "labor", "재료비": "material", "경비": "expense"} +_Q = re.compile(r"Q\d*") # 작업량 중간값 이름(산림품셈 09장 토공 · 07장 집재 …) + + +def shown(row: dict, step: dict, value: Any) -> Any: + """중간값 — `표시` 칸 · 새 의미 로직 = 엔진 그대로(`표시` 자리 반올림) · 옛 = Q ROUND 2.""" + if step.get("표시") is not None or ms.uses(row): + return ms.shown(step, value) + if _Q.fullmatch(step["이름"]) and isinstance(value, (Decimal, int, float)): + return Decimal(str(round_half(float(value), 2))) + return value + + +def _cut(value: float, rule: dict | None) -> float: + return float(mcut._line(value, rule)) if rule else rounddown(value, 1) + + +def places(row: dict, lines: list[dict]) -> None: + """호표 줄 `자리`(줄 자리) — 그 줄 금액만 단가 × 수량을 그 규칙으로 다시 버림(제자리).""" + for item, ln in zip(row.get("호표", []), lines): + rule = item.get("자리") + if not rule or ln.get("qty") is None: + continue + for part, price in (ln.get("price") or {}).items(): + if part in PARTS and price is not None: + ln[part] = _cut(price * ln["qty"], rule) + + +def _engine(lines: list[dict]) -> list[dict]: + """B09 줄 → 엔진 줄 꼴 `{비목}`(번지 · 단계 셈).""" + return [ + {"비목": {k: Decimal(str(ln.get(p) or 0)) for k, p in COST_PART.items()}} for ln in lines + ] + + +def sums(row: dict, lines: list[dict]) -> dict[str, float | None]: + """덧줄 밑 비목 합 — 새 의미 로직은 합밖 · 제외 단계 줄을 뺌.""" + skip = ms.skipped(row) if ms.uses(row) else set() + kept = [ln for n, ln in enumerate(lines) if n not in skip] + return {name: total([ln[p] for ln in kept]) for name, p in COST_PART.items()} + + +def scope(row: dict, lines: list[dict], env: dict) -> None: + """덧줄 식 이름 `번지` · `단계`(지금까지 줄) — 새 의미 로직만.""" + if ms.uses(row): + env.update(ms.scope(row, _engine(lines))) + + +def extra(row: dict, item: dict, value: float, env: dict) -> float: + """덧줄 금액 — 줄 `자리` 면 그 규칙 · 아니면 ROUNDDOWN 1 · + 새 의미 로직은 뒤 덧줄이 그 값을 부름.""" + money = _cut(value, item.get("자리")) + if ms.uses(row): + env[item["이름"]] = Decimal(str(money)) + return money + + +def totals(row: dict, lines: list[dict]) -> dict[str, float | None] | None: + """총계 — 새 의미 로직 = 합밖 · 제외 빼고 블록 끝전 · `끝수` · 조정(`master_cut.settle`) · + 옛 로직 · 빈칸 비목 있음 = None(옛 `block_totals`).""" + if not ms.uses(row): + return None + got = sums(row, lines) + if any(v is None for v in got.values()): + return None + costs = mcut.settle(row, {k: Decimal(str(v)) for k, v in got.items()}) + out = {COST_PART[k]: float(v) for k, v in costs.items()} + return {**out, "sum": sum(out.values())} diff --git a/B09_Estimation/B09_Estimation_Sangeun_Text.py b/B09_Estimation/B09_Estimation_Sangeun_Text.py index 53d112e4d..18d1e3c00 100644 --- a/B09_Estimation/B09_Estimation_Sangeun_Text.py +++ b/B09_Estimation/B09_Estimation_Sangeun_Text.py @@ -27,6 +27,7 @@ from decimal import Decimal from typing import Any from B09_Estimation import B09_Estimation_Sangeun as sg +from B09_Estimation import B09_Estimation_Sangeun_Sum as ss from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_Labor_Doc as labor_doc from M02_MasterTemplete import M02_Labor_Inputs as labor_inputs @@ -58,8 +59,8 @@ def _qty_text(item: dict, env: dict, whole: Any, times: Decimal, rate: Decimal | return text -def _line(name: str, price: Any, qty_text: str, q: float) -> tuple[str, float | None]: - money = None if price is None else sg._money(float(price), q) +def _line(name: str, price: Any, qty_text: str, q: float, rule=None) -> tuple[str, float | None]: + money = None if price is None else ss._cut(float(price) * q, rule) # 줄 자리(792-3) return f"{name} : {_price(price)} * {qty_text} = {_money(money)}", money @@ -308,21 +309,24 @@ def _pct_specs(whole, row, env) -> list[str]: return [str(one.get("규격") or "") for one in got] +def _full(amounts: list[dict]) -> list[dict]: + return [{**dict.fromkeys(sg.PARTS, 0.0), **a} for a in amounts] + + def _extra_lines(whole, row, env, amounts: list[dict]) -> list[tuple[str, dict]]: """% 줄(덧줄) — 밑수 = 그때까지 B09 비목 합(`Sangeun._extras` 와 같음) · 금액 ROUNDDOWN 1.""" out: list[tuple[str, dict]] = [] specs = _pct_specs(whole, row, env) for extra, spec in zip(row.get("덧줄", []), specs): part = sg.COST_PART.get(extra.get("비목"), "material") - sums = { - name: sg.total([a.get(p, 0.0) for a in amounts]) for name, p in sg.COST_PART.items() - } + sums = ss.sums(row, _full(amounts)) # 합밖 · 제외(792-3) head = f"{extra['이름']}{f' ({spec})' if spec else ''} : " if any(v is None for v in sums.values()): out.append((f"{head}밑수 빈칸", {part: None})) amounts.append({part: None}) continue env.update({k: Decimal(str(v)) for k, v in sums.items()}) + ss.scope(row, _full(amounts), env) # 번지 · 단계(792-3) try: node = mf.parse(extra["식"]) value = float(mf.evaluate(node, env, whole, 0)) @@ -331,7 +335,7 @@ def _extra_lines(whole, row, env, amounts: list[dict]) -> list[tuple[str, dict]] out.append((f"{head}계산 못 함 — {e}", {part: None})) amounts.append({part: None}) continue - money = sg.rounddown(value, 1) + money = ss.extra(row, extra, value, env) # 줄 자리(792-3) out.append((f"{head}{shown} = {_money(money)}", {part: money})) amounts.append({part: money}) return out @@ -377,13 +381,15 @@ def render_sangeun_text( q = float(Decimal(str(raw)) / times * (scale or 1)) kind = item.get("종류") if kind == "로직": + merged: dict = {} # 줄 하나 = 금액 하나(중기 세 줄 · 792-3 합밖 · 번지 차례) for text, money in _logic_lines(whole, item, env, qty_text, q, price_of): out.append(text) - amounts.append(money) + merged.update(money) + amounts.append(merged) continue if kind == "인력": key = mf.fill(item["요소"], env) if isinstance(item["요소"], str) else "" - text, money = _line(name, price_of(key), qty_text, q) + text, money = _line(name, price_of(key), qty_text, q, item.get("자리")) out.append(text) amounts.append({"labor": money}) continue @@ -397,12 +403,12 @@ def render_sangeun_text( if q == 0 and price is None: price = 0.0 # 안 쓰는 재료 줄 — 금액 0(`Sangeun._line` 과 같음) label = f"{name}({spec})" if spec else name - text, money = _line(label, price, qty_text, q) + text, money = _line(label, price, qty_text, q, item.get("자리")) out.append(text) amounts.append({sg.COST_PART.get(item.get("비목"), "material"): money}) for text, _money_part in _extra_lines(whole, row, env, amounts): out.append(text) - sums = sg.block_totals([{**dict.fromkeys(sg.PARTS, 0.0), **a} for a in amounts]) + sums = ss.totals(row, _full(amounts)) or sg.block_totals(_full(amounts)) # 끝전 · 조정(792-3) parts = " + ".join( f"{PART_NAME[p]} {'빈칸' if sums[p] is None else f'{sums[p]:,.0f}'}" for p in sg.PARTS ) diff --git a/M02_MasterTemplete/M02_Labor_Doc.py b/M02_MasterTemplete/M02_Labor_Doc.py index a4d8412df..0f98b6287 100644 --- a/M02_MasterTemplete/M02_Labor_Doc.py +++ b/M02_MasterTemplete/M02_Labor_Doc.py @@ -267,8 +267,7 @@ def drop_apart(got: dict[str, Any], shown: Any) -> dict[str, Any]: } if not out or not got.get("기준"): return got - kept = [x for i, x in enumerate(lines) if i not in out] # 기준 = 남은 줄 합(751-3 끝수) - got = mcut.plain({**got, "줄": kept}) + got = mcut.plain(got, out) # 기준 = 남은 줄 합(751-3 끝수 · 791-1 셈법) return {**got, "기준": _json(got["기준"])} @@ -559,7 +558,7 @@ def run_line( money = {k: got[k] for k in (*COSTS, "계")} mats = logic_mats(whole, logic, got["줄"], picks) lines = pct_extras(whole, logic, got["줄"], got.get("중간") or {}) - out = {"기준": money, "줄": lines, "재료": mats, "까닭": None} + out = {"기준": money, "줄": lines, "재료": mats, "까닭": None, **mcut.kept(got)} return out if raw or rate in (None, 100) else mcut.plain(out) # 켜쌓기율 곱 뒤 다시 버림 diff --git a/resources/master_data/scripts/master_cut.py b/resources/master_data/scripts/master_cut.py index b3919abec..6bf98c2bd 100644 --- a/resources/master_data/scripts/master_cut.py +++ b/resources/master_data/scripts/master_cut.py @@ -133,7 +133,7 @@ def engine(master, ref: str, got: dict, depth: int = 0) -> dict: # 중간 = 덧줄이 본 값(버린 줄 합 · 덧줄 값 · `줄`) — 시험 계산 중간 칸 return {**got, "줄": lines, **costs, "계": sum(costs.values()), "중간": env} costs = settle(logic, total) - more = {"단계": ms.steps(logic, lines)} + more = {"단계": ms.steps(logic, lines), "셈법": ms.rule(logic)} return {**got, "줄": lines, **costs, "계": sum(costs.values()), "중간": env, **more} @@ -150,17 +150,41 @@ def settle(logic: dict, total: dict) -> dict: return {k: down(v, 0) for k, v in costs.items()} -def plain(got: dict) -> dict: +def kept(got: dict) -> dict: + """`run_line` 이 엔진 답에서 그대로 넘기는 덧칸(792-3) — 단계 · 셈법.""" + return {k: got[k] for k in ("단계", "셈법") if k in got} + + +def plain(got: dict, gone=()) -> dict: """로직 줄 답(`run_line` 꼴 `{기준, 줄, 재료}`)을 줄만 다시 버림 — - 켜쌓기율 곱 · 별도계상 뺀 뒤 · 기준 = 남은 줄 비목 합 ROUNDDOWN 0.""" + 켜쌓기율 곱 · 별도계상 뺀 뒤(`gone` = 뺄 줄 차례) · 기준 = 남은 줄 비목 합 ROUNDDOWN 0 · + 답 `셈법`(791-1 새 의미 로직) = 줄 자리 · 합밖 · 단계 · 블록 끝전 · 조정 그대로(`settle`).""" if not got.get("기준"): return got + rule = got.get("셈법") + places = [x.get("자리") for x in ms.items(rule)] if rule else [] + skip = set(gone) | (ms.skipped(rule) if rule else set()) + + def at(n): + return places[n] if isinstance(n, int) and 0 <= n < len(places) else None + lines, total = [], dict.fromkeys(COSTS, Decimal(0)) - for line in got.get("줄") or []: - cut, money = _parts(dict(line.get("비목") or {})) + for n, line in enumerate(got.get("줄") or []): + cut, money = _parts(dict(line.get("비목") or {}), at(n)) for k, v in cut.items(): - total[k] += Decimal(str(v or 0)) + total[k] += 0 if n in skip else Decimal(str(v or 0)) lines.append({**line, "비목": cut, "금액": money}) - mats = [{**m, "금액": down(m.get("금액"), 1)} for m in got.get("재료") or []] - base = {k: down(v, 0) for k, v in total.items()} - return {**got, "줄": lines, "재료": mats, "기준": {**base, "계": sum(base.values())}} + mats = [{**m, "금액": _line(m.get("금액"), at(m.get("차례")))} for m in got.get("재료") or []] + kept = [x for n, x in enumerate(lines) if n not in gone] + if not rule: + base = {k: down(v, 0) for k, v in total.items()} + return {**got, "줄": kept, "재료": mats, "기준": {**base, "계": sum(base.values())}} + base = settle(rule, total) + steps = ms.steps(rule, [{} if n in gone else x for n, x in enumerate(lines)]) + return { + **got, + "줄": kept, + "재료": mats, + "기준": {**base, "계": sum(base.values())}, + "단계": steps, + } diff --git a/resources/master_data/scripts/master_formula_sum.py b/resources/master_data/scripts/master_formula_sum.py index 584967d4c..e0a11d373 100644 --- a/resources/master_data/scripts/master_formula_sum.py +++ b/resources/master_data/scripts/master_formula_sum.py @@ -33,6 +33,17 @@ def uses(row: dict) -> bool: return any(k in row for k in ROW_KEYS) or any(k in x for x in items(row) for k in LINE_KEYS) +def rule(row: dict) -> dict: + """셈법(792-3) — 새 의미 칸만 뗀 작은 로직 꼴(이 모듈 · `master_cut.settle` 이 읽음) · + 엔진 답 `셈법` 으로 실어 켜쌓기율 · 별도계상 뒤 다시 버림(`master_cut.plain`)이 씀.""" + keep = ("이름", *LINE_KEYS) + lines = { + k: [{x: one[x] for x in keep if x in one} for one in row.get(k, [])] + for k in ("호표", "덧줄") + } + return {**lines, **{k: row[k] for k in (*ROW_KEYS, "끝수") if k in row}} + + def shown(step: dict, value): """중간 `표시` = 값을 n 자리 반올림해 뒤 줄에 넘김(STmate `X = 식 =`).""" if step.get("표시") is None or not isinstance(value, Decimal): diff --git a/resources/tester/test_145_1_b09_sangeun.py b/resources/tester/test_145_1_b09_sangeun.py index 087203723..61ce40b1a 100644 --- a/resources/tester/test_145_1_b09_sangeun.py +++ b/resources/tester/test_145_1_b09_sangeun.py @@ -22,6 +22,7 @@ if str(ROOT) not in sys.path: from B09_Estimation import B09_Estimation_Prices as prices # noqa: E402 from B09_Estimation import B09_Estimation_Router as router_mod # noqa: E402 from B09_Estimation import B09_Estimation_Sangeun as sg # noqa: E402 +from B09_Estimation import B09_Estimation_Sangeun_Sum as ss # noqa: E402 from B09_Estimation.B09_Estimation_Calc_Base import compute_base # noqa: E402 from B09_Estimation.B09_Estimation_Linked import used_keys # noqa: E402 from M02_MasterTemplete import M02_Item_Prices as item_prices # noqa: E402 @@ -163,13 +164,13 @@ def test_작업량_Q는_소수_둘(): whole = m01.loaded()[1] for code, row in sorted((whole.index.get("GF") or {}).items()): - if not any(sg._Q.fullmatch(s["이름"]) for s in row.get("중간", [])): + if not any(ss._Q.fullmatch(s["이름"]) for s in row.get("중간", [])): continue try: env = sg._env(whole, row, {}) except Exception: # noqa: BLE001 — 입력이 필요한 로직은 건너뜀 continue - q = next(env[s["이름"]] for s in row["중간"] if sg._Q.fullmatch(s["이름"])) + q = next(env[s["이름"]] for s in row["중간"] if ss._Q.fullmatch(s["이름"])) if hasattr(q, "as_tuple"): assert q == round(q, 2), code return diff --git a/resources/tester/test_222_1_m02_apart.py b/resources/tester/test_222_1_m02_apart.py index 9d13b45f1..d83937571 100644 --- a/resources/tester/test_222_1_m02_apart.py +++ b/resources/tester/test_222_1_m02_apart.py @@ -25,6 +25,7 @@ SRC = os.path.join(HERE, "..", "..", "M02_MasterTemplete") MIX = "GF000156" # 기계비빔타설 — 호표 3 시멘트 · 4 모래 · 5 자갈 GIVEN = {"재료배합": "(A)", "골재최대치수": 40} MAT = "MT000008" +COSTS = ("노무비", "재료비", "경비") DIRECT = {"별도계상": "직접계산"} @@ -36,6 +37,15 @@ def _rows(got): return {x["이름"]: {**x, "수량": float(x["수량"])} for x in got["줄"]} +def _kept(raw, names=None): + """별도계상 뺀 기준(751-3 끝수) = 남은 줄 비목 합마다 원 버림 · 계 = 셋 합 · + `names` = 뺄 재료 이름(없으면 재료 전부).""" + gone = {m["차례"] for m in raw["재료"] if names is None or m["이름"] in names} + lines = [x for n, x in enumerate(raw["줄"]) if n not in gone] + out = {k: int(sum(Decimal(str(x["비목"].get(k) or 0)) for x in lines)) for k in COSTS} + return {**out, "계": sum(out.values())} + + # ── ① 단위 ────────────────────────────────────────────────────────── def test_단위중량_칸에_t_값이면_1000배_0이면_표_위끝(): wrong = labor.run_line(_whole(), MIX, {**GIVEN, "모래단위중량": 2, "자갈단위중량": 2.1}) @@ -60,16 +70,14 @@ def _calc(shown=None, mats=()): def test_로직_자재_기본_별도계상은_기준_줄에서_빠지고_재료엔_남음(): raw = labor.run_line(_whole(), MIX, GIVEN) - cut = sum(Decimal(str(m["금액"])) for m in raw["재료"]) one = _calc()["줄"][0] names = {x["이름"] for x in one["줄"]} assert not names & {"시멘트", "모래(건조상태)", "자갈 또는 부순돌"} assert {"콘크리트공", "보통인부"} <= names assert len(one["재료"]) == 3 # 자재목록(세 칸 고침)엔 남음 - want = Decimal(str(raw["기준"]["재료비"])) - cut - assert one["기준"]["재료비"] == pytest.approx(float(want)) - assert one["기준"]["계"] == pytest.approx(float(Decimal(str(raw["기준"]["계"])) - cut)) - assert one["기준"]["노무비"] == pytest.approx(float(raw["기준"]["노무비"])) + want = _kept(raw) + assert {k: one["기준"][k] for k in (*COSTS, "계")} == want + assert one["기준"]["노무비"] == raw["기준"]["노무비"] def test_직접계산은_그대로_섞이면_그_자재만_빠짐(): @@ -80,9 +88,8 @@ def test_직접계산은_그대로_섞이면_그_자재만_빠짐(): part = _calc({"3": DIRECT})["줄"][0] # 시멘트만 셈 names = {x["이름"] for x in part["줄"]} assert "시멘트" in names and "모래(건조상태)" not in names - cement = next(m for m in raw["재료"] if m["이름"] == "시멘트") - others = sum(Decimal(str(m["금액"])) for m in raw["재료"]) - Decimal(str(cement["금액"])) - assert part["기준"]["계"] == pytest.approx(float(Decimal(str(raw["기준"]["계"])) - others)) + others = {m["이름"] for m in raw["재료"]} - {"시멘트"} + assert part["기준"]["계"] == _kept(raw, others)["계"] def test_사용자_자재_별도계상은_합에서_빠지고_반영은_보임(): @@ -106,7 +113,6 @@ def test_사용자_자재_별도계상_파이썬과_TS_같은_답(mark): def test_사전조합_안_줄도_별도계상_뺌(): raw = labor.run_line(_whole(), MIX, GIVEN) - cut = sum(Decimal(str(m["금액"])) for m in raw["재료"]) doc = { "이름": "비빔 조합", "단위": "㎥", @@ -120,8 +126,8 @@ def test_사전조합_안_줄도_별도계상_뺌(): assert [x.get("자재") for x in got["안"]] == [None] pill = got["안"][0] times = Decimal(1) # 기계비빔타설 알약 배 1 - assert pill["기준"]["계"] * times == pytest.approx(Decimal(str(raw["기준"]["계"])) - cut) - assert got["기준"]["계"] == pytest.approx(Decimal(str(raw["기준"]["계"])) - cut) + assert pill["기준"]["계"] * times == _kept(raw)["계"] + assert got["기준"]["계"] == _kept(raw)["계"] def test_화면_별도계상_바꾸면_다시_셈(): diff --git a/resources/tester/test_791_1_engine_semantics.py b/resources/tester/test_791_1_engine_semantics.py index c4962d674..81c016c1b 100644 --- a/resources/tester/test_791_1_engine_semantics.py +++ b/resources/tester/test_791_1_engine_semantics.py @@ -324,7 +324,7 @@ def test_검사(): # ── ③ 실무 손 옮김 견본 넷 ───────────────────────────────────────────── -def test_봉화_D00102_합판거푸집_STmate_번지_율(): +def 봉화(): """봉화 단가산출근거 2909~2943 행 — `&M00081& * 1.03 * 0.327 :=A01` · `&PA01M:M& * 11'%'` · `&PA02L:S& * 1'%'` · `(=)` · `(==)` · 총계 45,652 · 34,119 · 11,192 · 341.""" row = _logic( @@ -358,16 +358,21 @@ def test_봉화_D00102_합판거푸집_STmate_번지_율(): {"LB990101": ("형틀목공", 274978), "LB990102": ("보통인부", 165545)}, {"MT990101": ("합판(내수)", "㎡", 11468), "MT990102": ("각재", "㎥", 500661)}, ) + return master, row, {"노무비": 34119, "재료비": 11192, "경비": 341, "계": 45652} + + +def test_봉화_D00102_합판거푸집_STmate_번지_율(): + master, row, want = 봉화() assert mf.check_logic(master, row)[0] == [] got = _calc(master, "GX990101") - assert _money(got) == {"노무비": 34119, "재료비": 11192, "경비": 341, "계": 45652} + assert _money(got) == want assert [x["금액"] for x in got["줄"]] == [ D(x) for x in ("3862.5", "6221.2", "27497.8", "6621.8", "1109.2", "341.1") ] assert [x["계"] for x in got["단계"]] == [D("11192.9"), D("34460.7"), D("45653.6")] -def test_울진소광_D01489_낙석방지책_STmate_L퍼센트(): +def 소광(): """울진소광 32 호표(D01489) — `'공구손료 :'&S01045& * 2'%'`(S01045 단위 L% = 처음부터 노무비 누계) · `L = Q/10 =` 표시 2 · 계 줄 420,734 · 182,223 · 230,359 · 8,152 (계약단가 87.745 % 는 B09 몫).""" @@ -412,15 +417,20 @@ def test_울진소광_D01489_낙석방지책_STmate_L퍼센트(): }, {"MT990103": ("낙석방지책", "경간", 227000)}, ) + return master, row, {"노무비": 182223, "재료비": 230359, "경비": 8152, "계": 420734} + + +def test_울진소광_D01489_낙석방지책_STmate_L퍼센트(): + master, row, want = 소광() assert mf.check_logic(master, row)[0] == [] got = _calc(master, "GX990102") - assert _money(got) == {"노무비": 182223, "재료비": 230359, "경비": 8152, "계": 420734} + assert _money(got) == want assert _line(got, "공구손료")["금액"] == D("3644.4") assert _step(got, "지주 계")["계"] == D("49824.2") assert _step(got, "합계")["계"] == D("420735.3") -def test_동부청영월_D00003_터파기_STmate_큰나머지_끝전(): +def 영월(): """동부청영월 단가산출근거 54~103 행(D00003) — `f = 1/1.3 =` · `E=…=` · `Q = 3600*q*K*f*E/Cm =`(표시 2) · `&X00205L/M/S& / Q * 0.9` · 계 원 버림 + 큰 나머지 5,471 · 4,764 · 310 · 397.""" @@ -446,16 +456,21 @@ def test_동부청영월_D00003_터파기_STmate_큰나머지_끝전(): 끝전="큰나머지", ) master = _master([row, _HOUR], {"LB990106": ("보통인부", 165545)}) + return master, row, {"노무비": 4764, "재료비": 310, "경비": 397, "계": 5471} + + +def test_동부청영월_D00003_터파기_STmate_큰나머지_끝전(): + master, row, want = 영월() assert mf.check_logic(master, row)[0] == [] got = _calc(master, "GX990103") assert got["중간"]["Q"] == D("52.39") - assert _money(got) == {"노무비": 4764, "재료비": 310, "경비": 397, "계": 5471} + assert _money(got) == want assert _step(got, "계")["계"] == D("5471.0") row["끝전"] = "비목버림" # 비목 원 버림이면 1 원 모자람 assert _calc(master, "GX990103")["계"] == 5470 -def test_북부청임도_18_문양거푸집_EST_줄_자리(): +def 임도(): """북부청임도 단가산출 18 번(문양거푸집) — `5,786.1 × 2% = 116`(그 줄만 반올림 0) · `인부소계 : 24,467.3` · 노무비 24,467 · 재료비 5,902 · 계 30,369.""" row = _logic( @@ -480,11 +495,16 @@ def test_북부청임도_18_문양거푸집_EST_줄_자리(): {"LB990107": ("형틀목공", 275790), "LB990108": ("보통인부", 172068)}, {"MT990104": ("문양거푸집(ABS코팅폼)", "㎡", 4166)}, ) + return master, row, {"노무비": 24467, "재료비": 5902, "경비": 0, "계": 30369} + + +def test_북부청임도_18_문양거푸집_EST_줄_자리(): + master, row, want = 임도() assert mf.check_logic(master, row)[0] == [] got = _calc(master, "GX990104") assert _line(got, "잡재료비")["금액"] == 116 assert _step(got, "인부소계")["계"] == D("24467.3") - assert _money(got) == {"노무비": 24467, "재료비": 5902, "경비": 0, "계": 30369} + assert _money(got) == want del row["덧줄"][0]["자리"] # 기본 0.1 버림이면 엑셀과 1 원 다름 assert _calc(master, "GX990104")["계"] == 30368 diff --git a/resources/tester/test_792_3_engine_paths.py b/resources/tester/test_792_3_engine_paths.py new file mode 100644 index 000000000..e3f95ada1 --- /dev/null +++ b/resources/tester/test_792_3_engine_paths.py @@ -0,0 +1,148 @@ +"""792-3 엔진 새 의미 네 길 지킴 시험 — 같은 로직 JSON 이 ① 엔진 직접(`master_cut.engine`) · +② M02 단가산출 SK 셈(`M02_Sangeun_Calc.run`) · ③ 일위대가 SC 한 줄(수량 1 · `M02_Labor_Doc.calc`) · +④ B09 산근 풀이(`B09_Estimation_Sangeun.Solver` · 단가 = 같은 값의 B09 목록표)에서 같은 금액. + +견본 = 791-1 실무 손 옮김 넷(봉화 D00102 · 울진소광 D01489 · 동부청영월 D00003 · 북부청임도 18 = +엑셀 금액) + 합밖 · 제외 · 조정 단위 로직. 켜쌓기율 · 별도계상 뒤 다시 버림(`master_cut.plain`)도 +엔진 셈법(줄 자리 · 끝전 · 조정)을 지킴. 옛 로직(새 칸 없음) 전후 같음 = `test_791_1` M01 지문. +""" + +import copy +import importlib +from decimal import Decimal + +import pytest + +import resources.tester.test_791_1_engine_semantics as t +from B09_Estimation import B09_Estimation_Sangeun as sg +from M02_MasterTemplete import M02_Labor_Doc as labor +from M02_MasterTemplete import M02_Sangeun_Calc as sc +from M02_MasterTemplete import M02_Sangeun_Doc as sd + +mcut = importlib.import_module("master_cut") +D = Decimal +COSTS = ("노무비", "재료비", "경비", "계") +PART = {"labor": "노무비", "material": "재료비", "expense": "경비", "sum": "계"} +EQ = "EQ990001" + + +def _units(): + """합밖 · 제외 단계 · 조정 · 큰나머지 · 줄 자리를 한 로직에(791-1 단위 견본 바탕).""" + master = t._base( + 덧줄=[ + {"이름": "공구손료", "식": "단계.'인부 소계'.노무비 * 3 / 100", "비목": "경비"}, + {"이름": "잡", "식": "번지.A01.재료비 * 1.5 / 100", "비목": "재료비"}, + {"이름": "덧", "식": "0.6", "비목": "노무비", "자리": {"방법": "올림", "자리": 0}}, + ], + 단계=[ + {"이름": "자재 소계", "단": 1, "줄": ["갑", "을"]}, + {"이름": "인부 소계", "단": 1, "줄": ["보통", "특별"]}, + {"이름": "경비 소계", "단": 1, "줄": ["공구손료"], "제외": True}, + ], + 끝전="큰나머지", + 조정={"배율": {"재료비": D("0.9")}}, + ) + row = master.logic("GX990001") + row["호표"][1]["합밖"] = True + return master, row, None + + +CASES = {"봉화": t.봉화, "소광": t.소광, "영월": t.영월, "임도": t.임도, "단위": _units} + + +def _key(row) -> str: + return row["키"] + + +def _engine(master, row) -> dict: + got = t._calc(master, _key(row)) + return {k: got[k] for k in COSTS} + + +def _sk(master, row) -> dict: + doc = {"키": "SK-990001", "이름": "시험", "단위": "식", "바탕": {"로직": _key(row)}} + got = sc.run(sd.clean(doc, None, master), master) + return {k: got["기준"][k] for k in COSTS} + + +def _sc(master, row) -> dict: + mats = { + str(n): {"별도계상": "직접계산"} for n, x in enumerate(row["호표"]) if x["종류"] == "재료" + } + line = {"로직": _key(row), "입력값": {}, "수량": {"손": 1}, "자재표시": mats} + got = labor.calc({"담은로직": [line], "자재": []}, {}, whole=master, pills=[]) + return {k: got["반영합"][k] for k in COSTS} + + +def _b09_master(master, row): + """B09 = 기계 줄 단가를 중기목록표에서 — 기계경비 로직이 부르는 EQ 키를 잡게 + 시간당 로직에 한 줄.""" + hour = copy.deepcopy(master.index["GX"].get("GX990009") or {}) + if hour: + hour["호표"] = [t._mat(EQ, "1", "기계", 비목="경비")] + master.index["GX"]["GX990009"] = hour + master.index["EQ"] = {EQ: {"키": EQ, "이름": "시험 기계", "값": D(0)}} + return master + + +def _b09(build, row) -> dict: + master, row, _ = build() + master = _b09_master(master, row) + files = master.index + machine = next((x["값묶음"] for x in row["호표"] if "값묶음" in x), None) + sheets = { + "labor": [{"key": k, "price": float(r["값"])} for k, r in files.get("LB", {}).items()], + "compare": [ + {"key": k, "applied": float(r["물가자료"])} for k, r in files.get("MT", {}).items() + ], + "machine": [{"key": EQ, **{p: float(machine[c]) for p, c in PART.items() if c in machine}}] + if machine + else [], + } + block = sg.Solver(sheets).add(master, {"code": _key(row), "given": {}}, None) + assert block["why"] is None, block["why"] + texts = [x for ln in block["lines"] for x in ln.get("text") or []] + assert not [x for x in texts if "못" in x or "빈칸" in x], texts # 산근 글도 같은 셈 + assert block["foot"].endswith(f"= {block['sum']:,.0f}"), block["foot"] + return {PART[p]: D(str(block[p])) for p in PART} + + +@pytest.mark.parametrize("name", list(CASES)) +def test_네_길_같은_금액(name): + build = CASES[name] + master, row, want = build() + one = _engine(master, row) + if want is not None: + assert one == want # 엑셀 금액 + assert _sk(*build()[:2]) == one + assert _sc(*build()[:2]) == one + assert _b09(build, row) == one + + +def test_단위_로직_새_의미가_금액을_바꿈(): + master, row, _ = _units() + got = _engine(master, row) + plain = copy.deepcopy(row) + for k in ("끝전", "조정", "단계"): + plain.pop(k) + for x in (*plain["호표"], *plain["덧줄"]): + for k in ("합밖", "자리", "번지"): + x.pop(k, None) + assert got != _engine(t._master([plain], t.LAB, t.MAT), plain) # 시험이 새 길을 거침 + + +@pytest.mark.parametrize("name", ["영월", "단위"]) +def test_다시_버림도_셈법_지킴(name): + """켜쌓기율 · 별도계상 뒤 다시 버림(`plain`) — 셈법이 있으면 + 줄 자리 · 합밖 · 끝전 · 조정 그대로.""" + master, row, _ = CASES[name]() + out = labor.run_line(master, _key(row), {}) + assert "셈법" in out and out["단계"] + again = mcut.plain(out) + assert again["기준"] == out["기준"] + assert [x["계"] for x in again["단계"]] == [x["계"] for x in out["단계"]] + old = mcut.plain({k: v for k, v in out.items() if k != "셈법"}) + if name == "영월": + assert ( + old["기준"]["계"] == 5470 and again["기준"]["계"] == 5471 + ) # 옛 다시 버림 = 1 원 모자람 From 8ad18532420267f04d81c1c4b93435b251381564 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 5 Nov 2026 18:04:56 +0900 Subject: [PATCH 3/4] fix(est): preserve fees in independent annual copies --- resources/tester/test_est_business.py | 67 +++++++++++++++++++++++++++ tools/est-test/README.md | 4 +- tools/est-test/native-actions.js | 4 +- tools/est-test/native-business.js | 10 ++-- tools/est-test/native-engine.js | 7 ++- tools/est-test/native-views.js | 1 + 6 files changed, 85 insertions(+), 8 deletions(-) diff --git a/resources/tester/test_est_business.py b/resources/tester/test_est_business.py index 4b8b3fc7a..254fddba8 100644 --- a/resources/tester/test_est_business.py +++ b/resources/tester/test_est_business.py @@ -47,6 +47,73 @@ def test_annual_fixed_amount_with_zero_quantity_and_duplicate_names(): assert len(result["invalid"]["errors"]) == 1 +def test_annual_procurement_fee_is_allocated_and_kept_in_government_costs(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false; + p.id='123456789012345678901234';p.options.GangubExceptJodalRatio=true; + p.tables.EstMainDat=[ + {PkID:1,Name:'labor',GN:1,NU:1000}, + {LinkIndex:54}, + {PkID:2,Name:'관급자재',Level:1}, + {PkID:3,Name:'material',GN:1,GU:301}, + {PkID:4,Name:'조달수수료',LinkIndex:57,GN:10,Unite:'%',OCheck:4,SCheck:3}]; + p.tables.Wonga=[ + {Gubun:'F',Name:'직접노무비',BaseField:'NC'}, + {Gubun:'G',Name:'관급자재대'}, + {Gubun:'S',Name:'산업안전보건관리비',VSusic:'G*0.1'}, + {Gubun:'T',Name:'총공사비',LinkIndex:55,VSusic:'F+G+S'}]; + p.annual=[{name:'1차',formula:'[기준]*0.5'},{name:'2차',formula:'[기준]-[1차]'}]; + const q=EstNative.calculate(p),before=JSON.stringify(q),output=EstBusiness.annual(q); + return {q,output,preserved:before===JSON.stringify(q), + again:output.results.map(item=>EstNative.calculate(item.project))}; + })()""", + ) + assert result["preserved"] + assert result["output"]["errors"] == [] + assert result["q"]["calculation"]["total"] == 1361 + projects = [item["project"] for item in result["output"]["results"]] + for project in projects + result["again"]: + assert "id" not in project + fee = project["tables"]["EstMainDat"][4] + assert fee["LinkIndex"] == 99 + assert fee["CalcIndex"] == 1 + assert fee["GN"] == 10 + assert fee["TC"] == fee["JC"] == 15 + assert fee["TU"] == fee["JU"] == 0 + government = project["tables"]["Wonga"][1] + assert government["NC"] == 15 + assert project["tables"]["Wonga"][2]["Cost"] == 15 + assert [project["calculation"]["total"] for project in projects] == [680, 681] + assert [project["calculation"]["total"] for project in result["again"]] == [680, 681] + + +def test_annual_excluded_fee_does_not_reduce_the_government_safety_base(): + result = run( + "math", + """(()=>{ + const p=EstNative.empty();p.options.SourceRefresh=false; + p.options.GangubExceptJodalRatio=true; + p.tables.EstMainDat=[{LinkIndex:54},{Level:1,Name:'관급자재'}, + {GN:1,GU:301},{LinkIndex:57,Name:'조달수수료',GN:10,CalcIndex:2}]; + p.tables.Wonga=[{Gubun:'G',Name:'관급자재대'}, + {Gubun:'S',Name:'산업안전보건관리비',VSusic:'G*0.1'}, + {Gubun:'T',Name:'총공사비',VSusic:'G+S',LinkIndex:55}]; + p.annual=[{name:'first',formula:'[기준]*0.5'}]; + const q=EstNative.calculate(p); + return {q,output:EstBusiness.annual(q)}; + })()""", + ) + assert result["q"]["tables"]["Wonga"][0]["NC"] == 0 + project = result["output"]["results"][0]["project"] + assert project["tables"]["EstMainDat"][3]["CalcIndex"] == 3 + assert project["tables"]["EstMainDat"][3]["TC"] == 15 + assert project["tables"]["Wonga"][0]["Cost"] == 150 + assert project["tables"]["Wonga"][0]["NC"] == 0 + assert project["tables"]["Wonga"][1]["Cost"] == 15 + + def test_resource_expansion_preserves_different_labor_and_material_bid_rates(): result = run( "math", diff --git a/tools/est-test/README.md b/tools/est-test/README.md index 8c40544eb..b8967b6df 100644 --- a/tools/est-test/README.md +++ b/tools/est-test/README.md @@ -32,6 +32,8 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요 연차공사의 `[기준]*0.5` 수식은 수량과 각 성분 금액에 각각 적용됩니다. 금액은 정수 절사하고, 다음 차수의 `[기준]-[1차]`로 절사 후 잔액을 배분합니다. 차수 사본은 단가를 비운 고정 금액으로 저장하여 다시 열어도 금액을 유지합니다. `[계약수량]`은 이전 작업 파일과의 호환 표기로 `[기준]`과 같은 역할을 합니다. +연차의 조달수수료는 원본처럼 연결 종류 99로 전환해 금액을 배분하며 요율은 유지합니다. 관급 합계와 안전관리비의 수수료 제외 계산에 배분액을 반영하고, 합산 제외 행은 제외 상태를 유지합니다. 차수 내역 열기는 해당 내역서를 표시하며, 저장하면 원본과 다른 파일 ID를 부여해 원본을 덮어쓰지 않습니다. + 동원 집계는 비목별 낙찰률과 단위환산을 하위 산근·일위대가에도 전달합니다. 연결 단가가 없는 금액 입력분은 원본 전용 코드로 따로 합산하고, 장비 시간으로 바꿀 수 없는 경비는 `제외금액` 단위로 보존합니다. 산근에서 연결을 해제한 단가는 앞 행의 원천을 가져오지 않습니다. 설계변경 비교는 웹에서 확정한 당초 사본이 없을 때 원본 파일의 당초 수량·금액을 사용합니다. 별도 당초 확정 사본이 있으면 그 사본이 우선합니다. 원본 당초 전체 자료와 원가 서식의 활성 선택을 자동 추정하지는 않습니다. @@ -42,7 +44,7 @@ Ctrl+S는 입력 중인 셀도 확정한 뒤 저장합니다. 연속 저장 요 구간 합계와 소계는 결과 금액뿐 아니라 단가만 적힌 지정 행도 포함합니다. 구간 합계의 표지와 최종 합계 표지를 구분하며, 이미 정수로 절사된 최종 합계도 앞 성분 금액과의 합산 관계가 확인되면 변경액을 다시 집계합니다. 단가 550→600 변경이 구간 비율 27.5→30, 최종 합계 27→30, 내역 270→300으로 전달되는 것을 저장·취소까지 확인했습니다. -이 보완 후 해양 자료는 소계 표지까지 포함해 134,133/134,208 저장값 일치·해석 오류 95건입니다. 계산·이력 전용 시험 72개가 통과했으며, 남은 불일치와 원본 실행 미확인은 별개로 유지합니다. +이 보완 후 해양 자료는 소계 표지까지 포함해 134,133/134,208 저장값 일치·해석 오류 95건입니다. 계산·이력·연차 전용 시험 74개가 통과했으며, 남은 불일치와 원본 실행 미확인은 별개로 유지합니다. **전체 원본 동등성은 아직 확보하지 못했습니다.** 기성·물가변동·연차 등은 분석 분기와 합성 시나리오를 시험했으며 원본 업무 샘플 대조는 없습니다. 원본 화면 실행 비교는 못했습니다. 원본 페이지·도면 배치, CAD/VEC0650 편집, 원본 인쇄 서식, Excel/XML/JDL 변환, ESTX 수정 저장은 미구현입니다. 원본 대체품의 완료 판정으로 보지 않습니다. diff --git a/tools/est-test/native-actions.js b/tools/est-test/native-actions.js index bc7cb825a..3245504c3 100644 --- a/tools/est-test/native-actions.js +++ b/tools/est-test/native-actions.js @@ -415,8 +415,10 @@ const EstActions = (() => { else if (name === "open-annual") { const result = EstBusiness.annual(project); if (result.errors.length) throw Error(result.errors.join("\n")); - if (confirm("현재 화면을 차수 내역 사본으로 바꾸시겠습니까?")) + if (confirm("현재 화면을 차수 내역 사본으로 바꾸시겠습니까?")) { EstApp.setProject(result.results[Number(node.dataset.row)].project); + EstApp.navigate("EstMainDat"); + } } else if (name === "delete-extra") { if (confirm("이 항목을 삭제하시겠습니까?")) EstApp.change( diff --git a/tools/est-test/native-business.js b/tools/est-test/native-business.js index aa743b86c..d56146b69 100644 --- a/tools/est-test/native-business.js +++ b/tools/est-test/native-business.js @@ -369,6 +369,7 @@ const EstBusiness = (() => { for (const year of project.annual || []) { const next = copy(project); next.name = `${project.name} · ${year.name}`; + delete next.id; next.progress = []; next.annual = []; next.options.TotalFix = false; @@ -376,10 +377,9 @@ const EstBusiness = (() => { for (let index = 0; index < next.tables.EstMainDat.length; index++) { const row = next.tables.EstMainDat[index]; if (num(row.LinkIndex) === 57) { - row.CalcIndex = 3; - components.forEach((k) => (row[k] = 0)); - row.TC = 0; - continue; + // f0fcc0 keeps the fee rate and allocates the amount as a fixed row (99). + row.AnnualSourceLink = 57; + row.LinkIndex = 99; } if (num(row.Level) > 0 || [54, 55, 58, 59, 62, 67].includes(num(row.LinkIndex))) continue; try { @@ -391,7 +391,7 @@ const EstBusiness = (() => { const previous = results.find((result) => result.name === name); return previous ? num(previous.project.tables.EstMainDat[index]?.[field]) : undefined; }); - row.GN = value("GN"); + if (num(row.LinkIndex) !== 99) row.GN = value("GN"); components.forEach((key) => (row[key] = Math.trunc(value(key)))); row.TC = sum(row); units.forEach((key) => (row[key] = 0)); diff --git a/tools/est-test/native-engine.js b/tools/est-test/native-engine.js index 8fc108da0..cbb618737 100644 --- a/tools/est-test/native-engine.js +++ b/tools/est-test/native-engine.js @@ -251,7 +251,12 @@ const EstNative = (() => { i < tables.EstMainDat.length && num(tables.EstMainDat[i].Level) < level; i++ ) - if (num(tables.EstMainDat[i].LinkIndex) === 57) + if ( + num(tables.EstMainDat[i].CalcIndex) < 2 && + (num(tables.EstMainDat[i].LinkIndex) === 57 || + (num(tables.EstMainDat[i].LinkIndex) === 99 && + num(tables.EstMainDat[i].AnnualSourceLink) === 57)) + ) procurementFees += num(tables.EstMainDat[i].TC); } if (options.AutoWongaBase !== false) diff --git a/tools/est-test/native-views.js b/tools/est-test/native-views.js index 9915f66f0..0f496e911 100644 --- a/tools/est-test/native-views.js +++ b/tools/est-test/native-views.js @@ -20,6 +20,7 @@ const EstViews = (() => { [62, "구간비율"], [67, "내역비율"], [68, "묶음끝"], + [99, "연차 배분"], ], CalcIndex: [ [0, "계산·합산"], From 532cc52c8e6783e5faff4707d6d2e643b7c15c92 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 5 Nov 2026 18:19:26 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat(793-3a):=20=EA=B7=BC=EA=B1=B0=20?= =?UTF-8?q?=EC=A0=9C=EC=95=88=20=EC=97=94=EC=A7=84=20=E2=80=94=20=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=E2=88=A9=20=EA=B0=92=20=E2=88=A9=20=EC=A0=9C?= =?UTF-8?q?=EB=AA=A9=20=E2=88=A9=20=EA=B8=B0=EA=B3=84=20=EC=83=81=EC=88=98?= =?UTF-8?q?=20=E2=86=92=20M01=20=EA=B8=B0=EC=B4=88=20+=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=20=C2=B7=20=ED=92=88=20=EA=B2=80=EC=82=B0=20=C2=B7=20?= =?UTF-8?q?=EC=84=B8=20=EB=8B=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M01 값 역색인 · 숫자 정규화 한 벌(N1~N10 · 단위 기준 · % ↔ 소수 · t ↔ kg · 인/양 몫) · M01 지문마다 한 번 · 서버 시작 때 뒤에서 지음 - 값 길(상수 지문 → 로직 · 우연 보정 유의 4) · 합친 길(분석 648-D2 차례 · 후보 2~6 엔진 품 대조) · 검산 · 옵션(B1~B6 → 값 길 V → 고르기 맞댐 → 입력 필요) - 세 단 확정 · 근접(판 차이 · 현장 값 · 감률 · 할증 배수 · 단위 기준 차이) · 사람(+ 값 약한 1 등) · 없음(M01 보강 목록) - 길 GET hopyo-basis/{키} · hopyo-basis/{키}/{번} · 파생 저장본 · 올림 때 HP 숨은식(STmate G 식) · 끝내기 때 지움 · 판정 길 그대로 - 보정 자료 = 여덟 파일 상수 1,094 · 시험 test_793_3a 11(459 블록 기초 260 · 옵션 전부 52 · 품 같음 141 · 없음 85) Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_Hopyo_Router.py | 29 + M02_MasterTemplete/M02_San_Basis.py | 528 ++++++++++++++++ M02_MasterTemplete/M02_San_Basis_Check.py | 324 ++++++++++ M02_MasterTemplete/M02_San_Basis_Const.py | 378 ++++++++++++ M02_MasterTemplete/M02_San_Basis_Index.py | 611 +++++++++++++++++++ M02_MasterTemplete/M02_San_Basis_Store.py | 134 ++++ M02_MasterTemplete/M02_San_Basis_Value.py | 307 ++++++++++ M02_MasterTemplete/M02_San_Basis_corpus.json | 1 + main.py | 4 + resources/tester/test_793_3a_san_basis.py | 183 ++++++ 10 files changed, 2499 insertions(+) create mode 100644 M02_MasterTemplete/M02_San_Basis.py create mode 100644 M02_MasterTemplete/M02_San_Basis_Check.py create mode 100644 M02_MasterTemplete/M02_San_Basis_Const.py create mode 100644 M02_MasterTemplete/M02_San_Basis_Index.py create mode 100644 M02_MasterTemplete/M02_San_Basis_Store.py create mode 100644 M02_MasterTemplete/M02_San_Basis_Value.py create mode 100644 M02_MasterTemplete/M02_San_Basis_corpus.json create mode 100644 resources/tester/test_793_3a_san_basis.py diff --git a/M02_MasterTemplete/M02_Hopyo_Router.py b/M02_MasterTemplete/M02_Hopyo_Router.py index cc96feae1..e7b8ae1be 100644 --- a/M02_MasterTemplete/M02_Hopyo_Router.py +++ b/M02_MasterTemplete/M02_Hopyo_Router.py @@ -5,6 +5,8 @@ SC · SK · 장부 · `M02_Hopyo_Make`) · 경로 층 = 파일 층(판정 · 검산 · `to` = 만들 층) / 만들 층(옮기기). 올림 = Book + 격자 + 엑셀 사전(`Row.lexicon`) + 블록 풀이 뒤 셈(`Judge.ensure` · 파생 저장본) · 옛 맞춤 뒤 셈 · 시동 데움 없음 · [끝내기] = 저장본도 지움. +근거 제안(793-3a) = `hopyo-basis/{키}`(목록) · `hopyo-basis/{키}/{번}`(블록) · `M02_San_Basis_Store` +(올림 때 HP `숨은식` + 뒤 셈 · 파생 저장본 · 판정 길 `hopyo-judge` 칸은 그대로). 그대로 쓰는 길(목록 · 올림 · 엑셀 보기 머리 · 격자 · 지우기 · 장부 · 모두 고정 · 붙여넣기 · 다시 잇기 · 구조물 조합 · 칠 = B09 · M01 이 씀) · 옛 맞춤 길(호표 하나 맞춤 · 미리 셈 · 실무 호표 · 확인 만들기 · 안 이은 줄 · 단가산출 만들기)은 785-7 에서 지움. @@ -36,6 +38,7 @@ from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store from M02_MasterTemplete import M02_Labor_Doc as labor from M02_MasterTemplete import M02_Layer_Access as access from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_San_Basis_Store as san_basis from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root router = APIRouter(prefix="/api/m02", tags=["M02 Hopyo"]) @@ -93,6 +96,28 @@ def hopyo_judge_block( return _call(folder, lambda: hopyo_judge.block(folder, key, no, target)) +@router.get("/layers/{layer}/hopyo-basis/{key}") +def hopyo_basis_list( + layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) +) -> dict: + """근거 제안(793-3a) — `{셈중, 블록 {번: 제안}, 보강}` · 셈 중 = 뒤 셈 시작(읽기만).""" + folder = _root(session, layer, owner) + return _call(folder, lambda: san_basis.listing(folder, key)) + + +@router.get("/layers/{layer}/hopyo-basis/{key}/{no}") +def hopyo_basis_block( + layer: Layer, + key: str, + no: int, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """근거 제안 블록 하나 — 저장본 › 그 자리 셈(`M02_San_Basis.suggest` 꼴).""" + folder = _root(session, layer, owner) + return _call(folder, lambda: san_basis.block(folder, key, no)) + + class Pick(BaseModel): """고른 것(785-3 꼴) — 금액 · 품 칸 없음(서버가 셈) · 고정 단가 = 엑셀 줄 것.""" @@ -246,6 +271,7 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]: hopyo_forget.sweep(store.root()) # 하루 지난 작업 중 HP 끝냄(743-1) if same := next((r for r in hopyo_store.rows(store.root()) if r.get("지문") == mark), None): hopyo_judge.ensure(store.root(), same["키"]) + san_basis.ensure(store.root(), same["키"]) return {**same, "이어서": True} try: parsed = hopyo_excel.read_book(name, data) @@ -257,8 +283,10 @@ def _upload(name: str, data: bytes, who: dict[str, Any]) -> dict[str, Any]: grid = None sheets, _ = hopyo_excel._open(name, data, hopyo_row.SHEETS) parsed["사전"] = hopyo_row.lexicon(sheets) # 엑셀 사전(785-3 · 1차 줄 판정) + parsed["숨은식"] = san_basis.hidden(parsed, sheets) # STmate 숨은 G 식(793-3a 근거 제안) row = hopyo_store.save(store.root(), name, parsed, who, grid, mark) hopyo_judge.ensure(store.root(), row["키"]) # 블록 풀이 뒤 셈(파생 저장본 · 화면 안 기다림) + san_basis.ensure(store.root(), row["키"]) # 근거 제안 뒤 셈(793-3a) return row @@ -411,6 +439,7 @@ def hopyo_delete( folder = _root(session, layer, owner, write=True) got = _call(folder, hopyo_forget.finish, folder, key) hopyo_judge.forget(folder, key) # 블록 풀이 저장본(785-5) + san_basis.forget(folder, key) # 근거 제안 저장본(793-3a) return got diff --git a/M02_MasterTemplete/M02_San_Basis.py b/M02_MasterTemplete/M02_San_Basis.py new file mode 100644 index 000000000..e7d513f5e --- /dev/null +++ b/M02_MasterTemplete/M02_San_Basis.py @@ -0,0 +1,528 @@ +"""근거 제안 엔진(PLAN 793-3a · 요구 648 · 649 · 652 갈래 C · 분석 648-F H1~H5 · 648-D2) — 서버 +단독 · 읽기만. + +산근 블록(HP `단산` 꼴) → 절마다 번호 후보(`M02_Hopyo_Clause.basis`) ∩ 값 +후보(`M02_San_Basis_Value` · +역색인 · 유의 4) ∩ 제목 후보(이름 두 글자 묶음 Dice ≥ 0.35 · 같은 말 묶음) · 기계 작업량 절(q · K +· f · E · Cm +둘+)은 일 갈래(번호 · 제목) 먼저 · 차례 = 분석 648-D2 1 장 · 후보 2~6 이면 엔진 품 대조로 하나만 +맞으면 확정 · +같은 기초 절은 묶어 검산(`M02_San_Basis_Check`) · 절 2+ 블록은 블록 통째 길도 셈해 나은 꼴. +꼴(648-D2) = 확정·전부 · 확정·일부 · 확정·일부(절 미확정) · 후보 2~3 · 없음. +단(근거후보 · 화면 · 793-2 SK) = 확정(기초 확정 · 엔진 품 = 엑셀 품 · 품 다름 0) · 근접(기초 확정 +· 품 다름 · +품 대조 못 함 · 까닭 표지) · 사람(후보 2~3) · 없음(M01 없음 의심 → `보강` = 794-2 근거). +자동 바꿈 없음 · 지어 넣는 값 없음(못 읽은 칸 · 글에 없는 감률 · 할증 = 입력필요). +""" + +from __future__ import annotations + +import re +from typing import Any + +from M02_MasterTemplete import M02_Hopyo_Bind as hb +from M02_MasterTemplete import M02_Hopyo_Clause as clause +from M02_MasterTemplete import M02_Hopyo_Formula as formula +from M02_MasterTemplete import M02_Hopyo_Solve as solve +from M02_MasterTemplete import M02_San_Basis_Check as chk +from M02_MasterTemplete import M02_San_Basis_Const as bc +from M02_MasterTemplete import M02_San_Basis_Index as bi +from M02_MasterTemplete import M02_San_Basis_Value as bv + +TITLE_MIN, TITLE_GAP, TITLE_TOP = 0.35, 0.15, 5 +SYN = [ + ("B/H", "굴착기"), + ("BACK-HOE", "굴착기"), + ("BACKHOE", "굴착기"), + ("백호", "굴착기"), + ("굴삭", "굴착"), + ("D/Z", "불도저"), + ("도자", "불도저"), + ("불도우저", "불도저"), + ("D/P", "덤프"), + ("브레카", "브레이커"), + ("브레이카", "브레이커"), + ("깍기", "깎기"), + ("뒷채움", "뒤채움"), + ("로울러", "롤러"), + ("콤펙터", "콤팩터"), + ("레미콘", "레디믹스트콘크리트"), + ("컨테이너", "콘테이너"), +] +ORDER = ["확정·전부", "확정·일부", "확정·일부(절 미확정)", "후보 2~3", "없음"] +NONE = {"확정": [], "동점": [], "약함": []} + + +class Ctx: + """역색인 + M01 Master + 로직 이름 묶음(역색인 지문마다 한 벌).""" + + def __init__(self, idx: bi.Index): + self.idx, self.m = idx, hb.m01.cm.master() + self.logic = { + **self.m.index.get("GC", {}), + **self.m.index.get("GF", {}), + **self.m.index.get("GX", {}), + } + self.grams = {k: grams(r.get("이름")) for k, r in self.logic.items()} + + +_CTX: list = [None, None] + + +def ctx() -> Ctx: + idx = bi.get() + if _CTX[0] is not idx: + _CTX[:] = [idx, Ctx(idx)] + return _CTX[1] + + +# ── 제목 → 로직 이름 후보 ─────────────────────────────────────────── +def grams(text: Any) -> set: + t = str(text or "") + for a, b in SYN: + t = re.sub(re.escape(a), b, t, flags=re.I) + return clause._grams(re.sub(r"\d", " ", t)) + + +def title_cands(c: Ctx, text: str, unit: Any) -> list[tuple[str, float]]: + """제목 ↔ 로직 이름 Dice ≥ 0.35 · 1등과 0.15 안 · 다섯까지 · 결과단위 성격 다르면 뺌.""" + mine = grams(text) + if not mine: + return [] + nat, scored = clause.nature(unit), [] + for k, gs in c.grams.items(): + theirs = clause.nature(c.logic[k].get("결과단위")) + if not gs or nat and theirs and nat != theirs: + continue + s = 2 * len(mine & gs) / (len(mine) + len(gs)) + if s >= TITLE_MIN: + scored.append((k, round(s, 3))) + scored.sort(key=lambda x: (-x[1], x[0])) + return [x for x in scored if x[1] >= scored[0][1] - TITLE_GAP][:TITLE_TOP] if scored else [] + + +def dice(c: Ctx, key: str, text: str) -> float: + a, b = grams(text), c.grams.get(key, set()) + return 2 * len(a & b) / (len(a) + len(b)) if a and b else 0.0 + + +# ── 블록 · 절 ─────────────────────────────────────────────────────── +def _flat(t: Any) -> str: + return re.sub(r"[\s.()\[\]:·,]", "", str(t or "")) + + +def parts_of(blk: dict) -> tuple[list[dict], dict, list]: + """일 절(`Formula.works`)마다 {제목, 번, 길, 항목, 상수, 식, 선택값, 몫} · 블록 통째 상수 · 쓸 + 항목. + """ + nodes = blk.get("절") or formula.tree( + blk.get("글") or [], blk.get("글행"), blk.get("항목") or [] + ) + top = formula.head(blk.get("글") or [], blk.get("글행")) + works = formula.works(nodes) + paths = {id(n): p for p, n in clause._paths(nodes)} + items = blk.get("항목") or [] + usable = solve._usable(items) + whole = {k: top[k] + solve._gather(nodes, k) for k in ("상수", "식", "선택값")} + if len(works) < 2: + one = {"제목": "(블록 통째)", "번": None, "길": None, "항목": usable, **whole} + one["몫"] = works[0].get("몫") if works else None + return [one], whole, usable + out = [] + for w in works: + at = [i for i in solve._gather([w], "항목") if not items[i].get("밖")] + part = { + "제목": f"{w['번']}. {w['제목']}", + "번": w["번"], + "길": paths.get(id(w)), + "항목": [items[i] for i in at], + } + part |= {k: top[k] + solve._gather([w], k) for k in ("상수", "식", "선택값")} + part["몫"] = w.get("몫") + out.append(part) + if not any(p["항목"] for p in out): # STmate — 글행 없음 → 항목 절 이름으로 붙임 + for it in usable: + name = _flat(it.get("윗절") or it.get("절")) + hit = next( + ( + p + for p in out + if name and (_flat(p["제목"]).endswith(name) or name in _flat(p["제목"])) + ), + None, + ) + if hit: + hit["항목"].append(it) + return out, whole, usable + + +def value_ev(rec: dict | None, parts: list[dict]) -> tuple[dict, list[dict], dict]: + """값 길 — 블록 {확정, 동점, 약함} · 절마다 · 로직별 옵션.""" + if rec is None: + return dict(NONE), [dict(NONE) for _ in parts], {} + opts: dict = {} + for b in rec.get("기초", []) + rec.get("동점", []): + opts.setdefault(b["로직"], {}).update(b.get("옵션") or {}) + blk = { + "확정": [b["로직"] for b in rec.get("기초", [])] if rec["판정"] in ("U", "P") else [], + "동점": [b["로직"] for b in rec.get("동점", [])] if rec["판정"] == "A" else [], + "약함": [b["로직"] for b in rec.get("약한후보", [])[:1]], + } + secs, out = rec.get("절") or [], [] + for i, p in enumerate(parts): + s = None + if len(parts) > 1: + head = lambda x: _flat(x["절"]).split(".")[-1][:6] # noqa: E731 + s = ( + secs[i] + if len(secs) == len(parts) + else next((x for x in secs if head(x) and head(x) in _flat(p["제목"])), None) + ) + one = {"확정": [], "동점": [], "약함": []} + if s and s["판정"] in ("U", "P") and s.get("기초"): + one["확정"] = [s["기초"]["로직"]] + opts.setdefault(s["기초"]["로직"], {}).update(s["기초"].get("옵션") or {}) + elif s and s["판정"] == "A": + one["동점"] = list(s.get("동점") or []) + out.append(one) + return blk, out, opts + + +def is_machine(part: dict) -> bool: + syms = {chk.base_sym(c.get("기호") or "") for c in part["상수"]} | { + chk.base_sym(f.get("기호") or "") for f in part["식"] + } + return len(syms - {""}) >= 2 + + +def pick( + c: Ctx, N: list, T: list, Vb: dict, Vs: dict, machine: bool, single: bool, title: str +) -> tuple[list, str]: + """일반: 번호∩값 › 값 › 번호∩제목 › 값동점∩제목 › 번호 › 제목 / + 기계 절: 번호∩제목 › 번호∩값 › 제목∩값 › 번호 › 제목 › 값.""" + Tk = [k for k, _ in T] + sure = [ + k + for k in (Vs["확정"] or (Vb["확정"] if single else [])) + if not title or dice(c, k, title) >= 0.2 + ] + anyv = list(dict.fromkeys(Vs["확정"] + Vs["동점"] + Vb["확정"] + Vb["동점"] + Vb["약함"])) + + def inter(a, b): + return [x for x in a if x in set(b)] + + if machine: + steps = [ + ("번호∩제목", inter(N, Tk)), + ("번호∩값", inter(N, anyv)), + ("제목∩값", inter(Tk, anyv)), + ("번호", N), + ("제목", Tk), + ("값", sure), + ] + else: + steps = [ + ("번호∩값", inter(N, anyv)), + ("값", sure), + ("번호∩제목", inter(N, Tk)), + ("값동점∩제목", inter(Vs["동점"] + Vb["동점"], Tk)), + ("번호", N), + ("제목", Tk), + ] + for name, s in steps: + if s: + return list(dict.fromkeys(s)), name + return [], "없음" + + +def _good(v: dict) -> bool: + return "오류" not in v and not v["셈"][solve.DIFF] and bool(v["셈"][solve.SAME]) + + +def resolve(S: list, how: str, check) -> tuple[list, str]: + """후보 2~6 → 검산(품 같음 있고 품 다름 없는 후보 하나)으로 거름.""" + if 2 <= len(S) <= 6: + good = [k for k in S if _good(check(k))] + if len(good) == 1: + return good, how + "+검산" + return S, how + + +def kind_of(S: list) -> str: + return "확정" if len(S) == 1 else "후보 2~3" if 2 <= len(S) <= 3 else "없음" + + +def _found(c: Ctx, key: str, v: dict, mc: list, rec: dict | None, how: str) -> dict: + kind, missing, differ = chk.judge_one(v, mc) + return { + "꼴": kind, + "기초": key, + "기초이름": c.logic.get(key, {}).get("이름", ""), + "차례": how, + "옵션": v.get("입력값"), + "옵션길": v.get("길"), + "입력필요": v.get("입력필요"), + "0간주": v.get("0간주"), + "글에없음": missing, + "값다름": differ, + "기계상수": mc, + "셈": v.get("셈"), + "다름까닭": chk.causes(v, mc, rec) if differ else [], + } + + +def run_block(c: Ctx, blk: dict, line: dict | None, same: list, rec: dict | None) -> dict: + """블록 하나 합친 길 — {꼴, 블록길, 절 [...], 통째, 기초 [키]} (648-D2 꼴).""" + basis = clause.basis(blk) + parts, whole, usable = parts_of(blk) + title_b = f"{blk.get('이름') or ''} {blk.get('규격') or ''}".strip() + src = {"줄": line, "같은줄": same, "블록": blk} + src["제목줄"] = { + "글": re.sub(r"[-_/()\[\],~]+", " ", f"{blk.get('이름') or ''} {blk.get('규격') or ''}"), + "행": None, + } + Vb, Vsecs, vopts = value_ev(rec, parts) + allN = list(dict.fromkeys(x["키"] for x in basis["후보"])) + memo: dict = {} + + def check(key, items, cons, share): + tag = (key, id(items), share, repr(cons)) # 값에 items 를 쥐어 id 를 다시 안 씀 + if tag not in memo: + memo[tag] = (items, chk.verify(c.m, key, items, cons, src, share, vopts.get(key, {}))) + return memo[tag][1] + + def share_of(p): + s = p.get("몫") + return (s.get("값") if isinstance(s, dict) else s) or 1 + + secs = [] + for p, Vs in zip(parts, Vsecs, strict=True): + if p["길"]: + N = [ + x["키"] + for x in basis["후보"] + if x.get("절") in (None, p["길"]) or str(p["길"]).startswith(f"{x.get('절')}.") + ] + text = f"{p['제목']} {title_b}" + else: + N, text = allN, title_b + N, mach = list(dict.fromkeys(N)), is_machine(p) + T = title_cands(c, text, blk.get("단위")) + S, how = pick(c, N, T, Vb, Vs, mach, len(parts) == 1, text) + cons = {x: p[x] for x in ("상수", "식", "선택값")} + S, how = resolve(S, how, lambda k, p=p, cons=cons: check(k, p["항목"], cons, share_of(p))) + secs.append( + { + "절": p["제목"], + "길": p["길"], + "항목수": len(p["항목"]), + "기계식": mach, + "번호": N, + "제목후보": [k for k, _ in T], + "차례": how, + "후보": S, + "_p": p, + } + ) + chosen = {i: s["후보"][0] for i, s in enumerate(secs) if len(s["후보"]) == 1} + bases = list(dict.fromkeys(chosen.values())) + group = {} + for b in bases: # 같은 기초 절끼리 묶어 검산(기초 하나뿐이면 블록 통째) + if len(bases) == 1: + items, cons, ps = usable, whole, list(parts) + else: + ps = [secs[i]["_p"] for i, k in chosen.items() if k == b] + items = [x for p in ps for x in p["항목"]] + cons = {x: [y for p in ps for y in p[x]] for x in ("상수", "식", "선택값")} + v = check(b, items, cons, share_of(ps[0]) if len(ps) == 1 else 1) + mc = ( + chk.mach_check(c.idx, b, chk.mach_consts(cons)) + if any(s["기계식"] for s in secs) + else [] + ) + group[b] = (v, mc) + for i, s in enumerate(secs): + s.pop("_p") + if ( + i in chosen + and s["차례"] == "제목" + and not _good({"셈": group[chosen[i]][0].get("셈") or {solve.DIFF: 0, solve.SAME: 0}}) + ): + s |= {"꼴": "후보 2~3", "제목단독": True} # 제목 하나뿐 · 품 대조로 못 받침 = 사람 확인 + elif i in chosen: + s |= _found(c, chosen[i], *group[chosen[i]], rec, s["차례"]) + else: + s["꼴"] = kind_of(s["후보"]) + kinds = [s["꼴"] for s in ([s for s in secs if s["항목수"]] or secs)] + if all(k == "확정·전부" for k in kinds): + bk = "확정·전부" + elif all(k.startswith("확정") for k in kinds): + bk = "확정·일부" + elif any(k.startswith("확정") for k in kinds): + bk = "확정·일부(절 미확정)" + else: + bk = "후보 2~3" if "후보 2~3" in kinds else "없음" + out = {"절": secs, "표지": basis["표지"], "출처": basis["출처"], "번호후보": allN} + wb = None + if len(parts) > 1: # 블록 통째 길 — 블록 제목 · 번호 · 블록 값으로 기초 하나 → 블록 전부로 검산 + mach = any(s["기계식"] for s in secs) + S, how = pick( + c, allN, title_cands(c, title_b, blk.get("단위")), Vb, dict(NONE), mach, True, title_b + ) + S, how = resolve(S, how, lambda k: check(k, usable, whole, 1)) + if len(S) == 1: + v = check(S[0], usable, whole, 1) + mc = chk.mach_check(c.idx, S[0], chk.mach_consts(whole)) if mach else [] + wb = _found(c, S[0], v, mc, rec, how) + if how == "제목" and not _good({"셈": v.get("셈") or {solve.DIFF: 0, solve.SAME: 0}}): + wb["꼴"] = "후보 2~3" + else: + wb = {"꼴": kind_of(S), "후보": S, "차례": how} + if wb and ORDER.index(wb["꼴"]) < ORDER.index(bk) and (wb.get("셈") or {}).get(solve.SAME): + out |= {"꼴": wb["꼴"], "블록길": "통째", "통째": wb, "기초": [wb["기초"]]} + else: + out |= {"꼴": bk, "블록길": "절 모음" if len(parts) > 1 else "통째(절 하나)", "통째": wb} + out["기초"] = list(dict.fromkeys(s["기초"] for s in secs if s.get("기초"))) + return out + + +# ── 세 단 · 근거후보 ──────────────────────────────────────────────── +def chosen_units(r: dict) -> list[dict]: + """블록 판정에 쓰인 단위 — 통째 길이면 통째 하나 · 아니면 기초마다 첫 절.""" + if r["블록길"] == "통째" and r.get("통째"): + return [r["통째"]] + out, seen = [], set() + for s in r["절"]: + if s.get("기초") and s["기초"] not in seen: + seen.add(s["기초"]) + out.append(s) + return out + + +def checked(s: dict) -> str: + """품 검산 — 다름 · 같음 · 짝 없음.""" + cnt = s.get("셈") or {} + return "다름" if cnt.get(solve.DIFF) else "같음" if cnt.get(solve.SAME) else "짝 없음" + + +def _tier(s: dict) -> str: + return "확정" if checked(s) == "같음" and not s["값다름"] else "근접" + + +def cands(c: Ctx, r: dict) -> list[dict]: + """근거후보 [{기초, 이름, 원문번호, 옵션, 옵션길, 입력필요, 품검산, 단, 까닭, 글에없음, + 값다름}] · + 사람 = 후보 2~3(각 후보 · 옵션 없음) · 없음 = [].""" + if r["꼴"].startswith("확정"): + out = [] + for s in chosen_units(r): + opts = {k: v for k, v in (s.get("옵션") or {}).items()} + row = c.logic.get(s["기초"], {}) + out.append( + { + "기초": s["기초"], + "이름": row.get("이름", ""), + "원문번호": row.get("원문번호"), + "차례": s["차례"], + "옵션": opts, + "옵션길": s.get("옵션길") or {}, + "입력필요": list(s["입력필요"] or []) + list(s["0간주"] or []), + "품검산": checked(s), + "단": _tier(s), + "까닭": [[chk.CAUSE[t], x] for t, x in s["다름까닭"]], + "글에없음": s["글에없음"], + "값다름": s["값다름"], + } + ) + return out + if r["꼴"] == "후보 2~3": + keys = list( + dict.fromkeys( + k for s in r["절"] if s["꼴"] == "후보 2~3" for k in (s.get("후보") or []) + ) + ) + return [ + { + "기초": k, + "이름": c.logic.get(k, {}).get("이름", ""), + "원문번호": c.logic.get(k, {}).get("원문번호"), + "옵션": {}, + "입력필요": [], + "품검산": None, + "단": "사람", + "까닭": [], + } + for k in keys[:6] + ] + return [] + + +def lack(blk: dict, r: dict) -> dict: + """없음 까닭 — a 기초 없음(번호 확인) · a′ 품목 없음 의심 · f 후보 4+ · 증거 흩어짐 · 794-2 + 보강 근거. + """ + if r["표지"] == "기초 없음": + why = "a 기초 없음(번호 확인)" + elif any(s.get("후보") for s in r["절"]): + why = "f 후보 4+ · 증거 흩어짐" + else: + why = "a′ 품목 없음 의심" + name = re.sub(r"\(.*?\)|\[.*?\]|[^가-힣]", "", f"{blk.get('이름') or ''}")[:8] + return {"까닭": why, "이름": name, "품셈": [f"{b} {n}" for b, n in r["출처"]]} + + +def suggest( + blk: dict, + kind: str, + line: dict | None = None, + same: list | None = None, + g: list[str] | None = None, +) -> dict: + """산근 블록 하나 → 근거 제안 {단, 꼴, 근거후보, 절, 보강?, 값판정}. + `kind` = 책 꼴(STmate · 엑셀꼴) · `line` · `same` = 부르는 첫 줄 · 그 호표 다른 줄(B1 · B6) · + `g` = STmate 숨은 G 식(없으면 보이는 글).""" + c = ctx() + pr = bc.prep(blk, kind, g) + rec = bv.value(c.idx, pr) + r = run_block(c, blk, line, same or [], rec) + out = {**r, "값판정": rec["판정"], "근거후보": cands(c, r)} + if r["꼴"].startswith("확정"): + tiers = {x["단"] for x in out["근거후보"]} + outside = [ + m[6:] for x in out["근거후보"] for m in x["글에없음"] if m.startswith("기초 밖 줄 ") + ] + out["까닭"] = [["기초 밖 줄", ", ".join(outside)]] if outside else [] # 엑셀에만 있는 줄 + sure = tiers == {"확정"} and r["꼴"] != "확정·일부(절 미확정)" + out["단"] = "확정" if sure else "근접" + else: + out["단"] = {"후보 2~3": "사람"}.get(r["꼴"], "없음") + weak = (rec.get("약한후보") or [None])[0] + title = f"{blk.get('이름') or ''} {blk.get('규격') or ''}" + if ( + weak + and weak["로직"] not in {x["기초"] for x in out["근거후보"]} + and dice(c, weak["로직"], title) >= 0.2 + ): + row = c.logic.get( + weak["로직"], {} + ) # 값 길 약한 1 등(유의 4 미만) — 제목과 뜻 겹치면 사람 후보 맨 앞 + opts = {k: v for k, v in weak["옵션"].items()} + out["근거후보"].insert( + 0, + { + "기초": weak["로직"], + "이름": row.get("이름", ""), + "원문번호": row.get("원문번호"), + "차례": "값약함", + "옵션": opts, + "옵션길": dict.fromkeys(opts, "V"), + "입력필요": [], + "품검산": None, + "단": "사람", + "까닭": [], + }, + ) + out["단"] = "사람" + if out["단"] == "없음": + out["보강"] = lack(blk, r) + return out diff --git a/M02_MasterTemplete/M02_San_Basis_Check.py b/M02_MasterTemplete/M02_San_Basis_Check.py new file mode 100644 index 000000000..39c5f3d56 --- /dev/null +++ b/M02_MasterTemplete/M02_San_Basis_Check.py @@ -0,0 +1,324 @@ +"""근거 제안 4 — 기초 하나 검산 · 옵션 · 까닭(PLAN 793-3a · 분석 648-D2 1 장) · 읽기만 · 지어 넣는 +값 없음. + +옵션 = 기존 `M02_Hopyo_Bind.bind`(B1 부르는 줄 규격 · B2 선택값 + 블록 제목 낱말 · B3 기호표 · B4 +대입식 · +B5 표 거꾸로 · B6 레미콘 규격) → 못 읽은 칸 = 값 길 옵션(「V」) → 그래도 고르기 칸이면 고르기 +값마다 넣어 +엑셀 품과 맞댐(조합 60 까지 · 다 같음 하나 = 「검산」 · ±15 % 근접 하나 = 「근접」) → 남은 칸 = +입력 필요. +감률 · 할증 · 가산 · 보정 · 구멍땜 수 칸은 셈 때만 0(검산 몫) · 옵션으로 안 냄(입력 필요 · 요구 +647-1). +품 대조 = `M02_Hopyo_Solve.pair`(엑셀 표시 자릿수 반올림 폭) · 기계 상수(q · K · f · E · E0 · Cm) = +그 로직(하위 포함)이 닿는 표의 같은 기호 칸 · 같은 기호 줄 값과 대조(`mach_check`). +까닭(`causes`) = b 판 차이 · c 현장 값 · d 감률 · 할증 배수 · e 단위 기준 차이 · f 그 밖(분석 +648-D2 5-2 규칙). +""" + +from __future__ import annotations + +import itertools +import math +import re +from typing import Any + +from M02_MasterTemplete import M02_Hopyo_Bind as hb +from M02_MasterTemplete import M02_Hopyo_Solve as solve +from M02_MasterTemplete import M02_San_Basis_Index as bi + +RATIOS = { + 8: "일↔시간 ×8", + 1 / 8: "일↔시간 ÷8", + 100: "% ×100", + 0.01: "% ÷100", + 1000: "t↔kg ×1000", + 0.001: "t↔kg ÷1000", + 10: "×10", + 0.1: "÷10", + 60: "분↔시간 ×60", + 1 / 60: "분↔시간 ÷60", +} +CAUSE = { + "b": "판 차이", + "c": "현장 값", + "d": "감률 · 할증 배수", + "e": "단위 기준 차이", + "f": "그 밖", +} +ZERO = re.compile(r"감률|할증|가산|구멍땜|보정|증가|추가") + + +# ── 기계 상수 ↔ 그 로직 표 ────────────────────────────────────────── +def _norm(s: Any) -> str: + s = re.sub(r"\(.*?\)", "", str(s or "").replace("㎝", "cm").lower()) + return re.sub(r"[^a-z0-9]", "", s) + + +def base_sym(s: str) -> str: + m = re.match(r"(cm|e0|q|k|f|e)\d*$", _norm(s)) + return m.group(1) if m else "" + + +def mach_consts(cons: dict) -> list[dict]: + out = list(cons.get("상수", [])) + for f in cons.get("식", []): # 식 끝값(Cm = t1+…+t4 = 10.7)은 셈 결과 — 대입 수만 + out += [{"기호": k, "값": v} for k, v in (f.get("대입") or {}).items()] + return out + + +def mach_check(idx: bi.Index, key: str, consts: list[dict]) -> list[dict]: + out, seen = [], set() + tables = idx.item_tables.get(key, set()) + for c in consts: + sym, v = base_sym(c.get("기호") or ""), c.get("값") + if not sym or not isinstance(v, (int, float)) or (c.get("기호"), v) in seen: + continue + seen.add((c.get("기호"), v)) + pool = [] + for t in tables: + tb = idx.tables[t] + for row in tb.get("줄", []): + row_sym = any(isinstance(x, str) and base_sym(x) == sym for x in row.values()) + for col in tb.get("값칸", {}): + if base_sym(col) == sym or row_sym: + pool += bi.nums_of(row.get(col)) + if not pool: + out.append({"기호": c["기호"], "값": v, "대조": "칸 없음"}) + continue + d = len(str(v).split(".")[1]) if "." in str(v) else 0 + near = min(pool, key=lambda x: abs(x - v)) + hit = abs(near - v) <= max(0.5 * 10 ** (-d), abs(v) * 0.005) + out.append( + { + "기호": c["기호"], + "값": v, + "대조": "표에 있음" if hit else "표에 없음", + "가까운": near, + } + ) + return out + + +# ── 옵션 · 검산 ───────────────────────────────────────────────────── +def zeroable(m: Any, key: str, name: str) -> bool: + """글에 없으면 0(없음)으로 볼 만한 수 칸 — 고르기 없음 · 이름이 감률 · 할증 · 가산 · 보정 류.""" + spec = next((x for x in hb.specs_of(m.logic(key)) if x["이름"] == name), {}) + return not spec.get("고르기") and bool(ZERO.search(name)) + + +def _conv(v: Any) -> Any: + if isinstance(v, list): + ends = [x for x in v if isinstance(x, (int, float))] + return sum(ends) / len(ends) if ends else None + return v + + +def probe( + m: Any, key: str, specs: dict, hard: list, base: dict, items: list, share: Any +) -> dict | None: + """못 읽은 고르기 칸을 고르기 값마다 넣어 엑셀 품과 맞댐 — 「검산」 · 「근접」 옵션.""" + if not hard or not all(specs.get(n, {}).get("고르기") for n in hard): + return None + opts = [specs[n]["고르기"] for n in hard] + if math.prod(len(o) for o in opts) > 60: + return None + exact, close = [], [] + + def sig(rr): + return tuple(round(r["우리"]["품"] or 0, 6) for r in rr if r.get("우리")) + + for combo in itertools.product(*opts): + g2 = dict(zip(hard, combo)) + try: + rr = solve.pair(hb.quantities(m, key, {**base, **g2}), solve._usable(items), share) + except Exception: # noqa: BLE001 — 그 조합은 셈 못 함 + continue + same = sum(r["판정"] == solve.SAME for r in rr) + if any(r["판정"] == solve.NEED for r in rr) or not same: + continue + ratios = [ + r["엑셀"]["품"] / r["우리"]["품"] + for r in rr + if r["판정"] == solve.DIFF and r["엑셀"]["품"] and r["우리"]["품"] + ] + if not ratios: + exact.append((g2, rr)) + elif all(abs(x - 1) <= 0.15 for x in ratios): + close.append((same, -len(ratios), g2, rr)) + if len(exact) == 1: + return {"값": exact[0][0], "줄": exact[0][1], "길": "검산"} + if len(exact) > 1 and len({sig(rr) for _, rr in exact}) == 1: + return {"값": exact[0][0], "줄": exact[0][1], "길": "검산(같은 품 여럿)"} + if not exact and close: + close.sort(key=lambda x: (-x[0], -x[1])) + top = [c for c in close if (c[0], c[1]) == (close[0][0], close[0][1])] + if len(top) == 1 or len({sig(c[3]) for c in top}) == 1: + return { + "값": close[0][2], + "줄": close[0][3], + "길": "근접" + ("" if len(top) == 1 else "(같은 품 여럿)"), + } + return None + + +def verify(m: Any, key: str, items: list, consts: dict, src: dict, share: Any, vopts: dict) -> dict: + """기초 하나 검산 — bind(B1~B6) · 값 길 옵션 · 고르기 맞댐 · 엑셀 품 대조.""" + mine = {**src, **consts} + if src.get("제목줄"): + mine["선택값"] = list(consts.get("선택값") or []) + [src["제목줄"]] + try: + b = hb.bind(m, key, mine, solve.fitter(m, key, items, share)) + specs = {s["이름"]: s for s in hb.specs_of(m.logic(key))} + given = {**b["입력값"], **{n: v[0]["값"] for n, v in b["엇갈림"].items()}} + fromv = { + n: _conv(vopts[n]) + for n in b["입력필요"] + b["안씀"] + if n in vopts and _conv(vopts[n]) is not None + } + need = [n for n in b["입력필요"] if n not in fromv] + zero = {n: 0 for n in need if zeroable(m, key, n)} + idle = { + n: b["기본"][n] for n in b["안씀"] if b["기본"].get(n) is not None and n not in fromv + } + base = {**idle, **given, **fromv, **zero} + rows = solve.pair(hb.quantities(m, key, base), solve._usable(items), share) + got = probe(m, key, specs, [n for n in need if n not in zero], base, items, share) + if got: + fromv |= got["값"] + need = [n for n in need if n not in got["값"]] + rows = got["줄"] + same_q = [] # 엇갈림인데 어느 값이든 품이 같음 = 문제 아님 + for n, alts in b["엇갈림"].items(): + sigs = set() + for alt in alts: + q = hb.quantities(m, key, {**idle, **given, **fromv, **zero, n: alt["값"]}) + sigs.add( + tuple(round(float(x["품"]), 6) if x.get("품") is not None else None for x in q) + ) + if len(sigs) == 1: + same_q.append(n) + except Exception as e: # noqa: BLE001 — 검산 못 함 = 오류 표지(확정 안 함) + return {"키": key, "오류": repr(e)[:160]} + blk = src["블록"] + text = " ".join( + [str(blk.get("이름") or ""), str(blk.get("규격") or "")] + + [str(t) for t in blk.get("글") or []] + ) + words = {"소량": "소량|소형|소규모", "높이": "높이|수직고"} + absorbed = [ + [n, hb.plain(given[n])] + for n, w in b["출처"].items() + if w["길"] == "B5" + and n.endswith("감률") + and n in given + and not re.search(words.get(n.replace("감률", ""), n.replace("감률", "") or "소량"), text) + ] + cnt = { + v: sum(r["판정"] == v for r in rows) + for v in (solve.SAME, solve.DIFF, solve.NEED, solve.EXCEL, solve.OURS) + } + diff = [] + for r in rows: + if r["판정"] == solve.DIFF: + ex, us = r["엑셀"]["품"], r["우리"]["품"] + diff.append( + { + "줄": r["엑셀"].get("이름") or r["우리"].get("이름"), + "엑셀": ex, + "우리": us, + "배": round(ex / us, 4) if ex and us else None, + "묶음": r["엑셀"].get("묶음"), + } + ) + outside = [ + r["엑셀"].get("이름") + for r in rows + if r["판정"] == solve.EXCEL and r["엑셀"] and r["엑셀"].get("품") + ] + way = {n: alts[0]["길"] for n, alts in b["엇갈림"].items()} # 엇갈림 = 첫 값 + way |= {k: v["길"] for k, v in b["출처"].items()} + way |= {k: "V" for k in fromv if not got or k not in got["값"]} + way |= {k: got["길"] for k in got["값"]} if got else {} + return { + "키": key, + "셈": cnt, + "기초밖": outside, + "감률흡수": absorbed, + "입력값": { + **{k: hb.plain(v) for k, v in given.items()}, + **{k: hb.plain(v) for k, v in fromv.items()}, + }, + "길": way, + "입력필요": [n for n in need if n not in zero], + "0간주": list(zero), + "엇갈림": { + k: [hb.plain(x["값"]) for x in v] for k, v in b["엇갈림"].items() if k not in same_q + }, + "품다름": diff, + } + + +def judge_one(v: dict, mach: list[dict]) -> tuple[str, list, list]: + """(확정·전부 | 확정·일부, 글에 없음, 값이 M01 과 다름).""" + if "오류" in v: + return "확정·일부", [], [f"검산 오류 {v['오류'][:60]}"] + missing = list(v["입력필요"]) + [f"옵션 여럿 {k} {vals}" for k, vals in v["엇갈림"].items()] + missing += [f"기초 밖 줄 {x}" for x in v.get("기초밖", [])] + differ = [ + f"품 {d['줄']} 엑셀 {d['엑셀']:.4g} ≠ 우리 {d['우리']:.4g}" + for d in v["품다름"] + if d["엑셀"] and d["우리"] + ] + differ += [ + f"기계 {x['기호']}={x['값']} 표에 없음(가까운 {x['가까운']:g})" + for x in mach + if x["대조"] == "표에 없음" + ] + differ += [f"감률 {n}={x} 로 흡수(글에 그 말 없음) — 품 배수" for n, x in v.get("감률흡수", [])] + differ += [ + f"근접 옵션 {k}={v['입력값'].get(k)}" + for k, w in (v.get("길") or {}).items() + if str(w).startswith("근접") + ] + if not v["셈"].get(solve.SAME) and not v["셈"].get(solve.DIFF): + missing.append("(품 짝 없음)") + return ("확정·전부" if not missing and not differ else "확정·일부"), missing, differ + + +def causes(v: dict, mach: list[dict], rec: dict | None) -> list[list[str]]: + """값 다름 → [갈래 b~f, 글] 【추정 규칙 · 648-D2 5-2】.""" + out = [] + user = bool(rec) and ( + bool(rec.get("입력")) + or any( + "kg/" in str(k.get("단위", "")).lower() or "㎏/" in str(k.get("단위", "")) + for k in rec.get("K", []) + ) + ) + labor = [d["배"] for d in v.get("품다름", []) if d["배"] and d["묶음"] in ("L", "X")] + uniform = len(labor) >= 2 and all(abs(x / labor[0] - 1) <= 0.02 for x in labor) + for d in v.get("품다름", []): + r = d["배"] + if not r: + out.append(["f", f"{d['줄']} 품 0"]) + continue + norm = next((name for k, name in RATIOS.items() if abs(r / k - 1) <= 0.01), None) + if norm: + out.append(["e", f"{d['줄']} {norm}"]) + elif uniform and abs(r - 1) > 0.15: + out.append(["d", f"{d['줄']} 같은 배 ×{r:g}"]) + elif abs(r - 1) <= 0.15: + out.append(["c" if user else "b", f"{d['줄']} ×{r:g}"]) + else: + out.append(["c" if user else "f", f"{d['줄']} ×{r:g}"]) + for n, x in v.get("감률흡수", []): + out.append(["b" if isinstance(x, (int, float)) and x < 15 else "d", f"감률 {n}={x} 흡수"]) + for x in mach: + if x["대조"] == "표에 없음": + out.append( + [ + "b" if x["가까운"] and abs(x["값"] / x["가까운"] - 1) <= 0.15 else "c", + f"기계 {x['기호']}={x['값']}", + ] + ) + return out diff --git a/M02_MasterTemplete/M02_San_Basis_Const.py b/M02_MasterTemplete/M02_San_Basis_Const.py new file mode 100644 index 000000000..daf790620 --- /dev/null +++ b/M02_MasterTemplete/M02_San_Basis_Const.py @@ -0,0 +1,378 @@ +"""근거 제안 2 — 산근 블록 글 → 상수 지문(PLAN 793-3a · 분석 648-D 1-2 뽑기) · 읽기만. + +뽑기 = 엑셀꼴(EST · STmate 보이는 글): 콜론 앞 이름 · 단가 앞 규격 · 쉼표 금액 · 항목 단가 · 「=」 +뒤 결과 +(앞 마디가 셈일 때) · 소계 줄 · 절 제목 줄 · 품셈 근거 줄 · 품셈 번호 · 연도 뺌 · 정수 끝 점 지움 / +STmate 숨은 G 식: 따옴표 글 뺌 · 수 바로 뒤 짧은 따옴표 = 단위 · `&…&` 참조 뺌. +N9 일반 수(1 · 100 · 1000 · 60 분 · 8 시간 = 「일반」 표지) · N10 입력(L · 거리 · km) 뺌 · +인력 줄 몫(「a 인 / b」 · 「a / Q」 = 블록 안 대입)에 직종 표지. +""" + +from __future__ import annotations + +import re +import unicodedata +from functools import lru_cache +from typing import Any + +from M02_MasterTemplete import M02_Hopyo_Formula as formula +from M02_MasterTemplete import M02_Hopyo_ReadEst as est +from M02_MasterTemplete import M02_San_Basis_Index as bi + +fam = bi.fam +UNIT = ( + r"(㎡|㎥|㎏|kg|Kg|KG|ton|Ton|TON|톤|m3|M3|m2|M2|㎝|cm|㎜|mm|km|Km|KM|ℓ|hr|Hr|HR" + r"|시간|분|초|sec|min|일|인|회|본|개|매|조|대|%|EA|ea|t(?![a-zA-Z])|m(?![a-zA-Z²³])|M(?![a-zA-Z]))" +) +NUMTOK = re.compile( + r"(? str: + s = unicodedata.normalize("NFKC", str(s or "")) + return s.replace("×", "*").replace("÷", "/").replace("=", "=").replace(":", ":") + + +def _f(s: Any) -> float | None: + try: + return float(str(s).replace(",", "").rstrip(".")) + except ValueError: + return None + + +def g_text(g: str) -> tuple[str, str]: + """STmate G 식 → (절 나누기 글, 수 뽑기 글) — 따옴표 글은 이름 · 수 뒤 짧은 따옴표 = 단위.""" + parts = g.split("'") + out: list[str] = [] + for i, p in enumerate(parts): + if i % 2 == 0: + out.append(p) + elif re.search(r"\d\s*$", parts[i - 1]) and len(p) <= 8 and not re.search(r"\d", p): + out[-1] = parts[i - 1].rstrip() + out.append(f"⟨{p}⟩") + else: + out.append(" ") + s = re.sub(r"&[^&]*&", " ¤ ", "".join(out)) # 단가 · 금액 범위 참조 + s = re.sub(r":=\s*A\d+|:\s*T\d+|->|\(-?=+\)|\[[A-Za-z]\w*\]", " ", s) + return g.replace("'", ""), s + + +def result_spans(s: str) -> list[tuple[int, int]]: + """「=」 마디 중 바로 앞 마디가 셈인 마디의 첫 수 = 결과 — 여러 대입 줄(「q = 0.7 K = + 0.9」)은 결과 아님. + """ + out, at = [], 0 + segs = s.split("=") + for i, seg in enumerate(segs): + last = i == len(segs) - 1 + if ( + i + and OPER.search(segs[i - 1].split(",")[-1]) + and (last or not OPER.search(seg.split(",")[0])) + ): + m = re.search(r"\d[\d,]*(?:\.\d+)?", seg) + if m and not seg[: m.start()].strip(" ({"): + out.append((at + m.start(), at + m.end())) + at += len(seg) + 1 + return out + + +def extract(text: str, prices: set, results: set, g: bool) -> tuple[list[dict], list[dict], str]: + """줄 하나 → (상수, 입력, 수 글) — 결과 · 단가 · 금액 · 이름 · 번호 뺌.""" + s = nk(text) + if not g and "=" not in s and (formula._title(s.strip()) or re.search(r"품\s*셈", s)): + return [], [], "" # 절 제목 · 품셈 근거 줄 — 규격 · 번호뿐 + for r in DROP: + s = r.sub(" ", s) + s = HEADNO.sub(" ", s) + s = re.sub(r"(?<=\d)\.(?!\d)", "", s) # 엑셀꼴 정수 끝 점(「5. 인 / 45. ㎡」) + if not g: + if ":" in s and "=" not in s.split(":")[0]: + s = s.split(":", 1)[1] + else: # 콜론 없는 재료 · 인력 줄 — 단가 앞(이름 · 규격) 자름 + for m in re.finditer(r"\d[\d,]*(?:\.\d+)?", s): + x = _f(m.group()) + if ( + x is not None + and ("," in m.group() or x in prices) + and "=" not in s[: m.start()] + ): + s = s[m.start() :] + break + skip = [] if g else result_spans(s) + consts, inputs = [], [] + for m in NUMTOK.finditer(s): + if any(a <= m.start(1) < z for a, z in skip): + continue # 셈 결과 + raw = m.group(1) + if "," in raw.strip(","): + continue # 금액 · 단가 + raw = raw.rstrip(".,") + x = _f(raw) + if x is None or x == 0: + continue + unit = m.group(2) if m.group(2) is not None else (m.group(3) or "") + if not unit and re.match(r"[가-힣]", s[m.end() : m.end() + 1]): + continue # 「2차선」 · 「3층」 같은 낱말 속 수 + sym_hit = SYM.search(s[: m.start()]) + sym = sym_hit.group(1) if sym_hit else "" + prev = s[: m.start()].rstrip()[-1:] + one = { + "v": x, + "d": len(raw.split(".")[1]) if "." in raw else 0, + "단위": unit, + "기호": sym, + "글": text.strip()[:90], + } + if x in prices or (not g and x in results): + continue + speed = re.match(r"\s*/\s*h", s[m.end() : m.end() + 4], re.I) + if ( + (sym and INPUT_SYM.match(sym)) + or fam(unit) == "길이" + and unit.lower() in ("km",) + and not speed + ): + inputs.append(one) + continue + if ( + (x in (1, 100, 1000) and unit != "%") + or (x == 60 and fam(unit) in (None, "시간")) + or ( + x == 8 + and (prev == "/" or fam(unit) == "시간" or re.search(r"hr|시간", s, re.I)) + and fam(unit) in (None, "시간") + ) + ): + one["일반"] = True + consts.append(one) + return consts, inputs, s + + +def quotients(body: str, labor: bool, syms: dict) -> list[dict]: + """인력 줄 「3 / 4.5」 · 「5인 / 45」 · 「2인 * 1일 / 50」 · 「5인 / Q」 → 몫 상수(인/양).""" + out = [] + for m in QUO.finditer(body): + a, bt = _f(m.group(1)), m.group(2) + b = syms.get(bt) if re.match(r"[A-Za-z]", bt) else _f(bt) + unit_p = bool(re.search(r"⟨인⟩|인|명", body[m.start() : m.start(1) + len(m.group(1)) + 4])) + if not a or not b or not (labor or unit_p) or b in (8, 60, 100): + continue + out.append( + { + "v": a / b, + "d": 9, + "단위": "인", + "기호": "", + "몫": f"{a:g}/{b:g}", + "글": body.strip()[:90], + } + ) + return out + + +def trade_of(text: str) -> str | None: + """인력 줄 이름 → 우리 인력 이름(모르면 None).""" + m = est._COLON.match(nk(text)) + if not m: + return None + name = re.sub(r"^[0-9가-힣]\.|^[○◇□※·\-*ㅇ\s]+", "", re.sub(r"\s+", "", m.group("name"))) + return bi.trade(name) + + +# ── 인력 줄 가름(엑셀꼴) ───────────────────────────────────────────── +_SWAP = [ + ("m3", "㎥"), + ("M3", "㎥"), + ("m2", "㎡"), + ("M2", "㎡"), + ("m", "m"), + ("Ton", "톤"), + ("TON", "톤"), + ("ton", "톤"), + ("굴착기", "굴삭기"), + ("백호", "굴삭기"), + ("B/H", "굴삭기"), + ("BACK-HOE", "굴삭기"), + ("브레카", "브레이커"), + ("브레이카", "브레이커"), + ("뒤채움", "뒷채움"), + ("Ⅰ", "I"), + ("Ⅱ", "II"), + ("ll", "II"), + ("=", "="), + (":", ":"), +] +_TOTAL = re.compile(r"^(소계|계|합계|총계|인부소계|자재소계|주자재소계|인건비소계)[:]?#?$") + + +def _fk(s: Any) -> str: + s = unicodedata.normalize("NFKC", str(s or "")) + for a, b in _SWAP: + s = s.replace(a, b) + return s + + +def _skel(line: str) -> str: + s = re.sub(r"(?<=[\d\s)])[xX×](?=[\s\d(])", "*", _fk(line)).replace("×", "*").replace("÷", "/") + s = re.sub(r"(?<=\d)\.(?=[*/=%+\-)\s]|$)", "", re.sub(r"\s+", "", s)) + return re.sub(r"\d[\d,]*(?:\.\d+)?", "#", s) + + +@lru_cache(maxsize=1) +def _names() -> dict: + return est.trade_names() + + +def labor(line: str) -> bool: + """인력 줄(「보통인부 : …」) — 소계 · 품셈 근거 줄 뺌.""" + s = _fk(line).strip() + f, sk = re.sub(r"\s+", "", s), _skel(s) + if ( + not f + or _TOTAL.match(sk) + or re.match(r"^(\d+\.)?(합계|소계|계)[:]?", f) + and "*" not in sk + and "/" not in sk + ): + return False + if re.search(r"품[셈줌]", s) and "=" not in s: + return False + cm = est._COLON.match(s) + nm = ( + re.sub( + r"^[○◇□※·\-*]+", "", re.sub(r"^[0-9가-힣]\.", "", re.sub(r"\s+", "", cm.group("name"))) + ) + if cm + else "" + ) + return bool(cm and (_names().get(nm) or bi.trade(nm))) + + +# ── 블록 → 줄 · 절 · 상수 ──────────────────────────────────────────── +def results_of(lines: list[str], items: list[dict]) -> tuple[set, set]: + prices, results = set(), set() + for it in items or []: + prices |= { + float(v) for v in (it.get("단가") or {}).values() if isinstance(v, (int, float)) and v + } + if isinstance(it.get("금액"), (int, float)): + results.add(float(it["금액"])) + if isinstance(it.get("기준값"), (int, float)): + results.add(round(float(it["기준값"]), 1)) + for t in lines: + s = nk(t) + results |= {_f(s[a:z]) for a, z in result_spans(s)} + if re.search(r"(소\s*계|합\s*계|총\s*계|^\s*계\s*:)", s): + results |= {_f(m.group()) for m in re.finditer(r"\d[\d,]*(?:\.\d+)?", s)} + results.discard(None) + return prices, results + + +def block_lines(blk: dict, kind: str, g: list[str] | None) -> list[dict]: + """[{글(절 나누기), 수글, g}] — STmate + 숨은 G 식이 있으면 G · 그 밖 보이는 글.""" + out = [] + if kind == "STmate" and g: + for gl in g: + tree, s = g_text(gl) + if tree.strip() and not re.fullmatch(r"\s*\(-?=+\)\s*", tree): + out.append({"글": tree, "수글": s, "g": True}) + else: + out = [{"글": t, "수글": t, "g": False} for t in blk.get("글") or [] if t and t.strip()] + return out + + +def sections(lines: list[dict]) -> list[tuple[str, list[int]]]: + texts = [ln["글"] for ln in lines] + works = formula.works(formula.tree(texts, list(range(len(texts))))) + if not works: + return [("(블록 통째)", list(range(len(texts))))] + return [ + (f"{w['번']}. {w['제목']}"[:40], list(range(w["글"][0], w["글"][1] + 1))) for w in works + ] + + +def prep(blk: dict, kind: str, g: list[str] | None = None) -> dict: + """블록 → 줄 · 줄별 (상수, 입력) · 절.""" + lines = block_lines(blk, kind, g) + prices, results = results_of(blk.get("글") or [], blk.get("항목") or []) + per_line, bodies = [], [] + for ln in lines: + cs, ins, body = extract(ln["수글"], prices, results, ln["g"]) + per_line.append([cs, ins]) + bodies.append(body) + syms: dict = {} + for cs, _ in per_line: + for c in cs: + if c["기호"] and c["기호"] not in syms: + syms[c["기호"]] = c["v"] + for i, ln in enumerate(lines): + lab = re.search(r"&L\d", ln["글"]) if ln["g"] else labor(ln["글"]) + tr = trade_of(ln["글"]) if lab else None + qs = quotients(bodies[i], bool(lab), syms) + if tr: + tops = {q["몫"].split("/")[0] for q in qs} + for c in per_line[i][0]: + if fam(c["단위"]) == "사람" or f"{c['v']:g}" in tops: + c["직종"] = tr + for q in qs: + q["직종"] = tr + per_line[i][0] += qs + in_vals = {round(c["v"], 9) for _, ins in per_line for c in ins if c.get("기호")} + for cs, _ in per_line: + cs[:] = [c for c in cs if round(c["v"], 9) not in in_vals or c.get("몫")] + return {"lines": lines, "per_line": per_line, "secs": sections(lines) if lines else []} + + +def gather(per_line: list, ids: Any) -> tuple[list[dict], list[dict]]: + """줄 묶음 → (상수 K, 입력) — 같은 수 · 율과 소수 쌍(2 % · 0.02)은 하나(단위 있는 쪽).""" + K: list[dict] = [] + seen, ins, at = set(), [], {} + for i in ids: + cs, ii = per_line[i] + ins += ii + for c in cs: + vk = (round(c["v"], 9), c.get("직종"), bool(c.get("몫"))) + if vk in at and fam(c["단위"]) != "율" and fam(K[at[vk]]["단위"]) != "율": + if not K[at[vk]]["단위"] and c["단위"]: + K[at[vk]] = {**c, "줄": i} + continue + key = (round(c["v"], 9), fam(c["단위"]), c.get("직종")) + twin = ( + (round(c["v"] * 100, 9), "율", None) + if fam(c["단위"]) is None and c["v"] < 1 + else ((round(c["v"] / 100, 9), None, None) if fam(c["단위"]) == "율" else None) + ) + if key in seen or twin in seen: + continue + seen.add(key) + at.setdefault(vk, len(K)) + K.append({**c, "줄": i}) + return K, ins + + +def corpus(preps: list[dict]) -> list[list]: + """보정 자료 — 블록들 상수(일반 뺌 · 같은 꼴 하나) [[v, d, 단위, 직종, 몫]].""" + keys: dict = {} + for pr in preps: + for c in gather(pr["per_line"], range(len(pr["lines"])))[0]: + if not c.get("일반"): + keys[bi.kkey(c)] = None + return [list(k) for k in keys] diff --git a/M02_MasterTemplete/M02_San_Basis_Index.py b/M02_MasterTemplete/M02_San_Basis_Index.py new file mode 100644 index 000000000..65d3eeeb7 --- /dev/null +++ b/M02_MasterTemplete/M02_San_Basis_Index.py @@ -0,0 +1,611 @@ +"""근거 제안 1 — M01 값 역색인 · 숫자 정규화 한 벌(PLAN 793-3a · 요구 648 · 분석 648-D 1 장) · +읽기만. + +칸(`cells`) = 표 칸 하나 {v, 표, 줄, 칸, 단위, 종류 val(값칸) | cond(조건 수) | ratio(「a 인 / b」 +몫) | +기준(「10㎡」 당) | eq(기계 요소 값)} · 쓰임(`uses`) = 표 → [(로직, 부름, 칸 | None, 글자 거름, +입력 묶음)] +(하위 로직 3 겹까지 펼침 · 인자 이름 바꿈) · 식 속 수(`inline`) · 호표 수량 속 수(`inline_trade` · +직종 표지). +정규화(`lookup`) — N1 표시 자릿수 반올림 · N2 % ↔ 소수 · N3 t ↔ kg · N4 인/양 몫 · N5 직종 묶음 · +N6 표 기준 · N7 식 속 수 · N8 단위 성격 · N9 일반 수 · N10 입력 뺌(N9 · N10 = +`M02_San_Basis_Const`). +우연 보정(`q_of` · `pb_sig`) = 보정 자료(실무 여덟 파일 상수 · `M02_San_Basis_corpus.json`) 몫 · +포아송 이항 꼬리 −log10. +짓기 = M01 폴더 지문(`stamp`)마다 한 번(`get` · 프로세스 안 캐시 · 지문 바뀌면 다시). +""" + +from __future__ import annotations + +import ast +import bisect +import glob +import json +import math +import re +import threading +import unicodedata +from collections import defaultdict +from functools import lru_cache +from pathlib import Path +from typing import Any + +from common_util import common_util_derived_store as derived +from M02_MasterTemplete import M02_Hopyo_Bind as hb +from M02_MasterTemplete import M02_Hopyo_ReadEst as est + +mf, ma = hb.mf, hb.ma +CORPUS = Path(__file__).with_name("M02_San_Basis_corpus.json") +INLINE_MAX = 20 # 식 속 수가 이보다 많은 로직(표를 식으로 펼친 것)은 식 속 수 맞음을 안 셈 +PERSON = ("인",) + +# ── 단위 성격(N8) ───────────────────────────────────────────────────── +FAM = [ + x.split(":") + for x in ( + "인:사람 ㎡:넓이 m2:넓이 ㎥:부피 m3:부피 ℓ:부피 km:길이 ㎝:길이 cm:길이 ㎜:길이 mm:길이 " + "m:길이 ㎏:무게 kg:무게 ton:무게 톤:무게 t:무게 g:무게 %:율 hr:시간 h:시간 시간:시간 " + "분:시간 min:시간 초:시간 sec:시간 일:시간 회:수 본:수 개:수 매:수 조:수 대:수 ea:수" + ).split() +] + + +def fam(unit: Any) -> str | None: + u = unicodedata.normalize("NFKC", str(unit or "")).strip().lower().split("/")[0].strip() + if "%" in u: + return "율" + for a, f in FAM: + if u == a or u.startswith(a) and a not in ("m", "t", "h", "g"): + return f + return None + + +def unit_ok(tu: Any, cu: Any) -> bool: + a, b = fam(tu), fam(cu) + return a is None or b is None or a == b + + +_OPS = { + ast.Add: float.__add__, + ast.Sub: float.__sub__, + ast.Mult: float.__mul__, + ast.Div: float.__truediv__, +} + + +def _calc(node: ast.AST) -> float: + """수 · 사칙 · 부호만(그 밖 = ValueError) — eval 없음.""" + if isinstance(node, ast.Expression): + return _calc(node.body) + if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)): + return float(node.value) + if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)): + x = _calc(node.operand) + return -x if isinstance(node.op, ast.USub) else x + if isinstance(node, ast.BinOp) and type(node.op) in _OPS: + return _OPS[type(node.op)](_calc(node.left), _calc(node.right)) + raise ValueError(node) + + +def _arith(s: str) -> float | None: + """「480 - 30」 · 「1/1.30」 → 수 · 못 셈 None.""" + if not re.fullmatch(r"[\d.\s+\-*/()]+", s) or not re.search(r"\d", s): + return None + try: + return _calc(ast.parse(s.strip(), mode="eval")) + except (SyntaxError, ValueError, ZeroDivisionError): + return None + + +def nums_of(v: Any) -> list[float]: + """칸 값 → [수] — 수 · [아래, 위] · 식 글자(셈 값 + 낱 수).""" + if isinstance(v, bool) or v is None: + return [] + if isinstance(v, (int, float)): + return [float(v)] + if isinstance(v, list): + return [float(x) for x in v if isinstance(x, (int, float)) and not isinstance(x, bool)] + if isinstance(v, str): + got = _arith(v) + if got is None: + return [] + parts = [float(x) for x in re.findall(r"\d+(?:\.\d+)?", v)] + return [got] + [p for p in parts if p != got] + return [] + + +# ── 직종(N5) ───────────────────────────────────────────────────────── +@lru_cache(maxsize=None) +def trade(name: str) -> str | None: + return est.trade(name) + + +def _flat(t: Any) -> str: + return re.sub(r"[\s()()_\-·.]", "", re.sub(r"\(.*?\)|_.*$", "", str(t or ""))) + + +def canon(t: str) -> str: + return trade(t) or trade(_flat(t)) or _flat(t) + + +def same_trade(a: str, b: str) -> bool: + x, y = canon(a), canon(b) + return bool(x and y) and (x == y or len(y) >= 2 and len(x) >= 2 and (x in y or y in x)) + + +# ── M01 읽기 · 로직 훑기 ───────────────────────────────────────────── +def folder() -> Path: + return Path(hb.m01.FOLDER) + + +def stamp() -> str: + """M01 지문 — 첫 층 파일 내용(`M02_Hopyo_Judge._stamp` 와 같은 몫) + 보정 자료.""" + files = { + p.name: derived.file_digest(p) + for p in sorted(folder().glob("*.json")) + if not p.name.startswith("_") + } + return derived.derived_key("san-basis-index", files, derived.file_digest(CORPUS)) + + +def load(root: Path) -> tuple[dict, dict, dict, dict]: + tables, logic, eq = {}, {}, {} + for f in glob.glob(str(root / "소요량_*.json")) + glob.glob(str(root / "계수_*.json")): + for t in json.loads(Path(f).read_text(encoding="utf-8")).get("표", []): + tables[t["키"]] = t + for f in glob.glob(str(root / "로직_*.json")): + for it in json.loads(Path(f).read_text(encoding="utf-8"))["줄"]: + logic[it["키"]] = it + for r in json.loads((root / "기계.json").read_text(encoding="utf-8"))["줄"]: + eq[r["키"]] = r + by_no: dict = {} + for k, t in tables.items(): + by_no.setdefault((k[:2], str(t.get("원문번호"))), []).append(k) + return tables, logic, eq, by_no + + +def _resolve(ref: str, tables: dict, by_no: dict) -> str | None: + if "{" in ref: + return None + if ref in tables: + return ref + tid, _, no = ref.partition(":") + hit = by_no.get((tid, no)) or [] + return hit[0] if len(hit) == 1 else None + + +def scan(item: dict, tables: dict, by_no: dict) -> tuple: + """로직 하나 → (찾기 부름, 식 속 수, 하위 로직 부름, 기계 부름, 호표 수량 속 수).""" + names = {s["이름"] for s in item.get("입력", [])} + finds, nums, calls, eqs = [], [], [], [] + + def arg_of(node): + if node[0] == "str": + return ("lit", node[1]) + if node[0] == "num": + return ("lit", float(node[1])) + if node[0] == "name": + return ("name", node[1]) if node[1] in names else ("expr", node[1]) + return ("expr", str(node)[:80]) + + def visit(node, field=None): + kind = node[0] + if kind == "field": + visit(node[1], node[2]) + elif kind in ("find", "logic"): + if kind == "find" and (ref := _resolve(node[1], tables, by_no)): + finds.append((ref, field, {c: arg_of(a) for c, a in node[2]})) + if kind == "logic" and isinstance(node[1], str) and "{" not in node[1]: + calls.append((node[1], {c: arg_of(a) for c, a in node[2]})) + for _, a in node[2]: + if a[0] not in ("str", "num", "name"): + visit(a) + elif kind == "ref": + if node[1].startswith("EQ") and field: + eqs.append((node[1], field)) + elif kind == "num": + nums.append(float(node[1])) + elif kind == "bin": + for side in (node[2], node[3]): # 비교 글자(「SCH == 20」)는 셈 수가 아님 + if node[1] in mf._OPS and side[0] == "num": + continue + visit(side) + elif kind == "neg": + visit(node[1]) + elif kind == "call": + for a in node[2]: + visit(a) + + for text in ma._texts(item): + if any(text is h["수량"] for h in item.get("호표", [])): + continue + try: + visit(mf.parse(text)) + except mf.FormulaError: + pass + trade_nums = [] # (호표 이름, 수량 식 속 수) + for h in item.get("호표", []): + before = len(nums) + try: + visit(mf.parse(h["수량"])) + except mf.FormulaError: + continue + trade_nums += [(h.get("이름") or "", x) for x in nums[before:]] + return finds, nums, calls, eqs, trade_nums + + +def _cells(tables: dict, eq: dict) -> list[dict]: + cells: list[dict] = [] + for tk, t in tables.items(): + vcols = t.get("값칸", {}) or {} + base = re.match(r"\s*(\d+(?:\.\d+)?)\s*(\S*)", str(t.get("기준") or "")) + if base and float(base.group(1)) not in (0, 1): # 「10㎡」 · 「100m」 당 — 표 기준 수(N6) + cells.append( + { + "v": float(base.group(1)), + "표": tk, + "줄": 0, + "칸": "기준", + "단위": base.group(2), + "종류": "기준", + } + ) + for ri, row in enumerate(t.get("줄", [])): + got = {} + for col, unit in vcols.items(): + for x in nums_of(row.get(col)): + cells.append( + { + "v": x, + "표": tk, + "줄": ri, + "칸": col, + "단위": row.get("단위") or unit, + "종류": "val", + } + ) + got.setdefault(col, x) + for col, kind in (t.get("조건", {}) or {}).items(): + x = row.get(col) + if kind == "수" and isinstance(x, (int, float)) and not isinstance(x, bool): + cells.append( + {"v": float(x), "표": tk, "줄": ri, "칸": col, "단위": "", "종류": "cond"} + ) + for a, ua in vcols.items(): # 인/양 몫(N4 · 「5인 / 45㎡」 = 0.111) + if not str(row.get("단위") or ua).startswith(PERSON) or a not in got: + continue + for b, ub in vcols.items(): + ub = str(row.get("단위") or ub) + if b == a or b not in got or ub.startswith(PERSON) or "%" in ub or not got[b]: + continue + cells.append( + { + "v": got[a] / got[b], + "표": tk, + "줄": ri, + "칸": f"{a}/{b}", + "단위": "인", + "종류": "ratio", + } + ) + for ek, r in eq.items(): # 기계 요소 수(가격 · 글 뺌) + for col, x in (r.get("값") or {}).items(): + if col in ("가격", "주연료종류", "조종원직종") or col.endswith("가격"): + continue + unit = (r.get("단위") or {}).get(col, "") if isinstance(r.get("단위"), dict) else "" + cells += [ + {"v": y, "표": ek, "줄": 0, "칸": col, "단위": unit or "", "종류": "eq"} + for y in nums_of(x) + ] + return cells + + +# ── 역색인 ─────────────────────────────────────────────────────────── +class Index: + def __init__(self, root: Path | None = None): + self.tables, self.logic, self.eq, by_no = load(root or folder()) + self.cells = _cells(self.tables, self.eq) + self.scans = {k: scan(it, self.tables, by_no) for k, it in self.logic.items()} + order = sorted(range(len(self.cells)), key=lambda i: self.cells[i]["v"]) + self.vals = [self.cells[i]["v"] for i in order] + self.ids = order + self.uses = defaultdict(list) # 표 → [(로직, 부름id, 칸 | None, 거름, 묶음)] + self.eq_uses = defaultdict(list) # 기계 칸 → [(로직, 부름id, (고정 | 입력, 값))] + self.inline = defaultdict(set) + self.inline_trade = defaultdict(set) + self.eq_no = {str(r.get("원문번호")): k for k, r in self.eq.items()} | { + k: k for k in self.eq + } + self.callers = defaultdict(set) # 하위 → 부르는 위 로직(어느 깊이든) + for k in self.logic: + self._expand(k, k, {}, {}, 0, k) + self.inl_index = defaultdict(set) + for k, xs in self.inline.items(): + for x in xs: + self.inl_index[round(x, 9)].add(k) + self.trade_index = defaultdict(set) + for k, xs in self.inline_trade.items(): + for _, x in xs: + self.trade_index[round(x, 9)].add(k) + self.cells_of_table = defaultdict(list) + for c in self.cells: + self.cells_of_table[c["표"]].append(c) + self.item_tables = defaultdict(set) + for t, us in self.uses.items(): + for u in us: + self.item_tables[u[0]].add(t) + self._hits: dict = {} + self._look: dict = {} + self._df: dict = {} + self.prior() + + def _expand(self, top, key, rename, lit, depth, path): + finds, nums, calls, eqs, tnums = self.scans[key] + for name, x in tnums: + self.inline_trade[top].add((name, x)) + for ref, field, args in finds: + filt, bind = {}, {} + for col, (kind, val) in args.items(): + if kind == "lit": + filt[col] = val + elif kind == "name" and depth == 0: + bind[col] = val + elif kind == "name" and val in rename: + bind[col] = rename[val] + elif kind == "name" and val in lit: + filt[col] = lit[val] + else: # 중간 · 식 · 안 넘긴 입력 — 같은 인자를 쓰는 부름끼리 같은 줄(가짜 입력 §) + bind[col] = f"§{path}:{val}" + if not filt and not bind: # 조건 없는 부름 — 같은 표 부름끼리 같은 줄 + bind["#row"] = f"§{path}:{ref}" + gid = f"{path}|{ref}|{sorted(filt.items(), key=str)}|{sorted(bind.items())}" + self.uses[ref].append((top, gid, field, filt, bind)) + self.inline[top].update(nums) + for ref, field in eqs: + m = re.match(r"EQ:\{(\w+)\}", ref) + if m: + arg = m.group(1) + inputs = {sp["이름"] for sp in self.logic[key].get("입력", [])} + if depth == 0 and arg in inputs: + use = ("입력", arg) + elif depth > 0 and arg in rename: + use = ("입력", rename[arg]) + elif depth > 0 and arg in lit and self.eq_no.get(str(lit[arg])): + use = ("고정", self.eq_no[str(lit[arg])]) + else: + use = ("입력", f"§{path}:EQ:{arg}") + self.eq_uses[field].append((top, f"{path}@{ref}", use)) + elif ref.startswith("EQ0"): + self.eq_uses[field].append((top, f"{path}@{ref}", ("고정", ref))) + if depth >= 3: + return + for j, (sub, args) in enumerate(calls): + if sub not in self.logic: + continue + self.callers[sub].add(top) + ren, lt = {}, {} + for name, (kind, val) in args.items(): + if kind == "lit": + lt[name] = val + elif kind == "name": + if depth == 0: + ren[name] = val + elif val in rename: + ren[name] = rename[val] + elif val in lit: + lt[name] = lit[val] + self._expand(top, sub, ren, lt, depth + 1, f"{path}>{sub}.{j}") + + @staticmethod + def _pass(row: dict, filt: dict) -> bool: + """칸 하나가 줄 거름을 통과?""" + for col, want in filt.items(): + have = row.get(col) + if have is None: + continue + if isinstance(have, list): + if isinstance(want, float) and len(have) == 2: + lo, hi = have + if (lo is not None and want < lo) or (hi is not None and want > hi): + return False + continue + if isinstance(want, float): + try: + if float(have) != want: + return False + except (TypeError, ValueError): + return False + elif str(have) != str(want): + return False + return True + + @staticmethod + def _trade_ok(name: str, c: dict) -> bool: + if c["종류"] == "eq": + return False + if c["종류"] != "ratio" and fam(c["단위"]) not in (None, "사람"): + return False + return same_trade(name, c["칸"].split("/")[0]) + + def lookup(self, k: dict) -> list[tuple[int, str]]: + """상수 → [(칸 id, 꼴 eq | pct | kgt)] — N1 · N2 · N3 · N4 · N5 · N8(캐시).""" + key = kkey(k) + if key not in self._look: + self._look[key] = self._lookup(k) + return self._look[key] + + def _lookup(self, k: dict) -> list[tuple[int, str]]: + v, d, unit = k["v"], k["d"], k["단위"] + sig = len(re.sub(r"^0+|\.", "", f"{v:.{d}f}").lstrip("0")) + tol = 0.5 * 10 ** (-d) if d >= 1 and sig >= 2 else 1e-9 + if k.get("몫"): + tol = abs(v) * 1e-6 + forms = [(v, tol, "eq")] + tf = fam(unit) + if tf in (None, "율"): + forms += [(v * 100, tol * 100, "pct"), (v / 100, tol / 100, "pct")] + if tf == "무게": + forms += [(v * 1000, tol * 1000, "kgt"), (v / 1000, tol / 1000, "kgt")] + out = [] + for x, t, how in forms: + t = max(t, abs(x) * 1e-9) + for j in range( + bisect.bisect_left(self.vals, x - t), bisect.bisect_right(self.vals, x + t) + ): + cid = self.ids[j] + c = self.cells[cid] + if c["종류"] == "cond": + continue # 조건 칸(관경 · SCH)은 옵션 값 — 상수 설명에 안 씀 + if k.get("직종") and not self._trade_ok(k["직종"], c): + continue + if c["종류"] == "ratio" and how != "eq": + continue + if how == "pct" and not (tf == "율" or "%" in str(c["단위"])): + continue + if how == "kgt" and not (tf == "무게" or fam(c["단위"]) == "무게"): + continue + if ( + how == "eq" + and abs(c["v"] - v) > 1e-9 + and c["종류"] != "ratio" + and tol < 1e-6 + and not k.get("몫") + ): + continue + if not unit_ok(unit, c["단위"]) and how == "eq": + continue + out.append((cid, how)) + return out + + def hits_of(self, cid: int) -> list[tuple]: + """칸 → [(로직, 부름id, 줄, 묶음)](캐시).""" + got = self._hits.get(cid) + if got is None: + got = self._hits[cid] = self._hits_of(cid) + return got + + def _hits_of(self, cid: int) -> list[tuple]: + c = self.cells[cid] + out = [] + if c["종류"] == "기준": + return [(u[0], f"{u[1]}|기준", 0, {}) for u in self.uses.get(c["표"], [])] + if c["종류"] == "eq": + for item, callid, (kind, val) in self.eq_uses.get(c["칸"], []): + if kind == "고정" and val != c["표"]: + continue + out.append( + ( + item, + callid, + c["표"], + {val: json.dumps(c["표"])} if kind == "입력" and val else {}, + ) + ) + return out + row = self.tables[c["표"]]["줄"][c["줄"]] + cols = c["칸"].split("/") if c["종류"] == "ratio" else [c["칸"]] + for item, callid, field, filt, bind in self.uses.get(c["표"], []): + if field is not None and field not in cols and c["종류"] != "cond": + continue + if not self._pass(row, filt): + continue + b = {} + for col, name in bind.items(): + if col == "#row": + b[name] = json.dumps(c["줄"]) + elif row.get(col) is not None: + b[name] = json.dumps(row[col], ensure_ascii=False) + out.append((item, callid, c["줄"], b)) + return out + + def explain(self, k: dict) -> list[tuple]: + """상수 하나 → [(로직, 부름id | None, 줄, 묶음, 꼴, 닻)] · + 닻 = 표 칸 · 직종 식 수로 맞음.""" + out = [] + for cid, how in self.lookup(k): + if self.cells[cid]["종류"] == "eq": + continue # 기계 요소 값(상각비계수 · 잡재료율 …)은 산근 글에 거의 안 나옴 + out += [(item, call, row, bd, how, True) for item, call, row, bd in self.hits_of(cid)] + x = round(k["v"], 9) + if k.get("직종"): + for item in self.trade_index.get(x, ()): + if any( + same_trade(k["직종"], n) for n, y in self.inline_trade[item] if round(y, 9) == x + ): + out.append((item, None, None, {}, "식", True)) + else: + forms = ( + (x, round(k["v"] * 100, 9), round(k["v"] / 100, 9)) + if fam(k["단위"]) in (None, "율") + else (x,) + ) + for form in forms: + for item in self.inl_index.get(form, ()): + if len(self.inline[item]) <= INLINE_MAX: + out.append((item, None, None, {}, "식", False)) + return out + + # ── 우연 보정 ──────────────────────────────────────────────────── + def df(self, k: dict) -> int: + key = kkey(k) + if key not in self._df: + self._df[key] = max(1, len({x[0] for x in self.explain(k)})) + return self._df[key] + + def prior(self) -> None: + """보정 자료 상수마다 설명할 수 있는 로직 → 로직 몫 p · 평균 df.""" + keys = [ + dict(zip(("v", "d", "단위", "직종", "몫"), row)) + for row in json.loads(CORPUS.read_text(encoding="utf-8")) + ] + cnt: dict = defaultdict(int) + for k in keys: + for it in {x[0] for x in self.explain(k)}: + cnt[it] += 1 + self.prom = {it: (cnt[it] + 1) / (len(keys) + 2) for it in self.logic} + self.mean_df = sum(self.df(k) for k in keys) / max(len(keys), 1) + + def q_of(self, item: str, k: dict) -> float: + """로직 item 이 상수 k 를 우연히 설명할 확률 ≈ df_k · p_item / 평균 df.""" + p = self.prom.get(item, 1 / max(len(self.prom), 1)) + return min(0.95, max(1e-6, self.df(k) * p / self.mean_df)) + + +def kkey(k: dict) -> tuple: + return (k["v"], k["d"], k["단위"], k.get("직종"), bool(k.get("몫"))) + + +def pb_sig(qs: list[float], c: int) -> float: + """포아송 이항 꼬리 P(X ≥ c) 의 -log10.""" + if c <= 0: + return 0.0 + dp = [1.0] + [0.0] * len(qs) + for q in qs: + for j in range(len(dp) - 1, 0, -1): + dp[j] = dp[j] * (1 - q) + dp[j - 1] * q + dp[0] *= 1 - q + return round(-math.log10(max(sum(dp[c:]), 1e-300)), 2) + + +_LOCK = threading.Lock() +_CACHE: dict[str, Index] = {} + + +def get() -> Index: + """M01 지문마다 한 번 지은 역색인(프로세스 안 · 지문 바뀌면 다시).""" + key = stamp() + with _LOCK: + got = _CACHE.get(key) + if got is None: + trade.cache_clear() + _CACHE.clear() + got = _CACHE[key] = Index() + return got + + +def warm() -> None: + """서버 시작 때 한 번(뒤에서) — 실패 = 다음 부름에서 다시.""" + try: + get() + except Exception: # noqa: BLE001 + pass diff --git a/M02_MasterTemplete/M02_San_Basis_Store.py b/M02_MasterTemplete/M02_San_Basis_Store.py new file mode 100644 index 000000000..38e9cb77e --- /dev/null +++ b/M02_MasterTemplete/M02_San_Basis_Store.py @@ -0,0 +1,134 @@ +"""근거 제안 길 · 저장본(PLAN 793-3a) — 서버 단독 · 읽기만(HP 는 안 고침). + +올림 = HP 에 `숨은식 {번: [G 식]}`(STmate 단가산출근거 숨은 G 열 · `hidden`) + 뒤 셈(`ensure`). +저장본 = HP 폴더 `_derived/san_basis_{키}` · 입력 지문 = HP(지문 · 판 · 올린때) + M01 역색인 지문 ++ 판 · +지문 다를 때만 다시 셈 · GET 은 안 지움 · [끝내기] = `forget`. +답 꼴 = `listing` {셈중, 블록 {번: 제안}, 보강 [{이름, 품셈, 까닭, 블록 [번]}]} · `block` = 제안 +하나 +(`M02_San_Basis.suggest` 꼴 — 단 · 꼴 · 근거후보 · 절 · 보강?). +""" + +from __future__ import annotations + +import shutil +import threading +from pathlib import Path +from typing import Any + +from common_util import common_util_derived_store as derived +from M02_MasterTemplete import M02_Hopyo_Judge as judge +from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store +from M02_MasterTemplete import M02_San_Basis as basis +from M02_MasterTemplete import M02_San_Basis_Index as bi + +KIND = "san_basis_" +VERSION = "793-3a.1" # 제안 꼴이 바뀌면 올림 +SHEET = "단가산출근거" +_LOCK, _RUNNING = threading.Lock(), set() + + +def hidden(book: dict[str, Any], sheets: dict[str, list[list[Any]]]) -> dict[str, list[str]]: + """STmate 블록마다 숨은 G 열 원문 식(「_x0007_」 머리 줄만) · 그 밖 = {}.""" + rows = sheets.get(SHEET) or [] + if book.get("꼴") != "STmate" or not rows: + return {} + out = {} + for blk in book.get("단산") or []: + start, end = blk["행"] + got = [] + for r in range(start, end + 1): + row = rows[r - 1] if 0 < r <= len(rows) else [] + v = str(row[6]) if len(row) > 6 and row[6] is not None else "" + if v.startswith("_x0007_") and v != "_x0007_": + got.append(v.replace("_x0007_", "").strip()) + out[str(blk["번"])] = got + return out + + +def one(book: dict[str, Any], no: int) -> dict[str, Any]: + blk = next(b for b in book.get("단산") or [] if int(b["번"]) == int(no)) + line, same = judge._caller(book, int(no)) + g = (book.get("숨은식") or {}).get(str(no)) + return basis.suggest(blk, book.get("꼴") or "", line, same, g) + + +def _stamp(key: str, book: dict[str, Any]) -> str: + return derived.derived_key( + VERSION, key, book.get("지문"), book.get("판"), book.get("올린때"), bi.stamp() + ) + + +def solved(folder: Path, key: str) -> dict[str, Any] | None: + book = hopyo_store.read(folder, key) + got = derived.read_derived(hopyo_store._dir(folder), KIND + key, _stamp(key, book)) + return got["value"] if got else None + + +def solve_all(folder: Path, key: str) -> dict[str, Any]: + book = hopyo_store.read(folder, key) + out = { + str(b["번"]): one(book, int(b["번"])) + for b in book.get("단산") or [] + if b.get("번") is not None + } + derived.write_derived(hopyo_store._dir(folder), KIND + key, _stamp(key, book), out, keep=1) + return out + + +def _work(folder: Path, key: str) -> None: + try: + solve_all(folder, key) + except Exception: # noqa: BLE001 — 뒤 셈 실패 = 다음 읽기에서 다시(파생 저장본) + pass + finally: + with _LOCK: + _RUNNING.discard(f"{folder}|{key}") + + +def ensure(folder: Path, key: str) -> bool: + """저장본 있음 = 참 · 없으면 뒤 셈 시작(같은 파일 한 번) = 거짓.""" + if solved(folder, key) is not None: + return True + tag = f"{folder}|{key}" + with _LOCK: + if tag in _RUNNING: + return False + _RUNNING.add(tag) + threading.Thread(target=_work, args=(Path(folder), key), daemon=True).start() + return False + + +def shortfall(got: dict[str, Any]) -> list[dict[str, Any]]: + """없음 블록 → M01 보강 목록(794-2 근거) — 이름 · 품셈 묶음마다 블록 번.""" + out: dict[tuple, dict] = {} + for no, r in got.items(): + if r.get("보강"): + b = r["보강"] + one_ = out.setdefault((b["이름"], tuple(b["품셈"])), {**b, "블록": []}) + one_["블록"].append(int(no)) + return sorted(out.values(), key=lambda x: (-len(x["블록"]), x["이름"])) + + +def listing(folder: Path, key: str) -> dict[str, Any]: + """{셈중, 블록 {번: 제안}, 보강} — 셈 중 = 블록 {} · 뒤 셈 시작.""" + got = solved(folder, key) + if got is None: + ensure(folder, key) + return {"키": key, "셈중": True, "블록": {}, "보강": []} + return {"키": key, "셈중": False, "블록": got, "보강": shortfall(got)} + + +def block(folder: Path, key: str, no: int) -> dict[str, Any]: + """블록 하나 — 저장본 › 그 자리 셈.""" + got = (solved(folder, key) or {}).get(str(no)) + if got is not None: + return got + book = hopyo_store.read(folder, key) + if not any(b.get("번") is not None and int(b["번"]) == int(no) for b in book.get("단산") or []): + raise judge._bad(f"없는 블록 「단산{no}」", 404) + return one(book, no) + + +def forget(folder: Path, key: str) -> None: + shutil.rmtree(derived.derived_dir(hopyo_store._dir(folder), KIND + key), ignore_errors=True) diff --git a/M02_MasterTemplete/M02_San_Basis_Value.py b/M02_MasterTemplete/M02_San_Basis_Value.py new file mode 100644 index 000000000..eed555c7e --- /dev/null +++ b/M02_MasterTemplete/M02_San_Basis_Value.py @@ -0,0 +1,307 @@ +"""근거 제안 3 — 값 길: 상수 지문 → M01 로직 후보(PLAN 793-3a · 분석 648-D 1-3) · 읽기만. + +로직마다 입력 값 조합(400 넘으면 입력마다 혼자 가장 많이 덮는 값) → 찾기마다 줄 하나(새로 덮는 +상수가 +가장 많은 줄부터) → 덮은 상수 · 우연 보정 유의(−log10 꼬리 확률) · 판정 U 유일 · 다 설명 · 옵션 +하나 / +P 유일 / A 동점(유의 1 안) / N 유의 4 미만 / N0 상수 없음 · 동점이 모두 한 하위 로직을 부르면 그 +하위(핵). +블록 = 일 절 2+ · 상수 2+ 주 절이 있으면 주 절 판정 모음 + 절 밖 상수 · 그 밖 블록 통째 + 덧 기초. +""" + +from __future__ import annotations + +import itertools +import json +import math +from collections import defaultdict +from typing import Any + +from M02_MasterTemplete import M02_San_Basis_Const as bc +from M02_MasterTemplete import M02_San_Basis_Index as bi + +TIE = 1.0 # 동점 폭(유의) +SIG_MIN = 4.0 # 1등 유의가 이 아래면 N — 로직 1,515 중 우연 1등 ≈ 10^-3.2 +fam = bi.fam + + +def solve(hits: list[tuple]) -> dict: + """hits = [(로직, 부름id, 줄, 묶음)] · 식 속 수 = 부름id None → 덮음 · 옵션 · 옵션여럿.""" + inl = {h[0] for h in hits if h[1] is None} + bound = [h for h in hits if h[1] is not None] + inputs = sorted({i for h in bound for i in h[3] if not i.startswith("§")}) + vals = {i: sorted({h[3][i] for h in bound if i in h[3]}) for i in inputs} + by_call: dict = defaultdict(lambda: defaultdict(set)) + row_bind = {} + for k, call, row, b in bound: + by_call[call][row].add(k) + row_bind[(call, row)] = b + order = sorted(by_call, key=lambda c: -max(len(x) for x in by_call[c].values())) + + def run(combo: dict): + cov, fixed, pick = set(inl), dict(combo), {} + while True: + best = None + for call in order: + if call in pick: + continue + for r, ks in by_call[call].items(): + gain = len(ks - cov) + if ( + gain + and (best is None or gain > best[0]) + and all( + fixed.get(i, v) == v if i.startswith("§") else combo.get(i) == v + for i, v in row_bind[(call, r)].items() + ) + ): + best = (gain, call, r, ks) + if best is None: + break + _, call, r, ks = best + cov |= ks + pick[call] = r + fixed |= {i: v for i, v in row_bind[(call, r)].items() if i.startswith("§")} + return cov, pick, -len(pick) + + if (math.prod(len(v) for v in vals.values()) if vals else 1) <= 400: + combos = [dict(zip(inputs, c)) for c in itertools.product(*[vals[i] for i in inputs])] or [ + {} + ] + else: # ponytail: 입력 값 조합이 많으면 입력마다 혼자 가장 많이 덮는 값 — 상호작용 놓칠 수 있음 + cnt = defaultdict(set) + for h in bound: + for i, v in h[3].items(): + cnt[(i, v)].add(h[0]) + combos = [{i: max(vals[i], key=lambda v: len(cnt[(i, v)])) for i in inputs}] + best, outs = (-1, -1), [] + for combo in combos: + cov, pick, ev = run(combo) + score = (len(cov), ev) + if score > best: + best, outs = score, [(combo, cov, pick)] + elif score == best: + outs.append((combo, cov, pick)) + used = { + i + for _, _, pick in outs + for call, r in pick.items() + for i in row_bind[(call, r)] + if not i.startswith("§") + } + resolved, opt_amb = {}, {} + for i in sorted(used): + seen = sorted({o[0].get(i) for o in outs if o[0].get(i) is not None}) + if len(seen) == 1: + resolved[i] = json.loads(seen[0]) + elif seen: + opt_amb[i] = [json.loads(x) for x in seen] + same_cov = len({frozenset(o[1]) for o in outs}) == 1 + return {"덮음": outs[0][1], "옵션": resolved, "옵션여럿": opt_amb, "조합같은덮음": same_cov} + + +def rank(idx: bi.Index, K: list[dict]) -> list[dict]: + """상수 목록 → 로직 후보(유의 차례).""" + per_item, anchored, how_of = defaultdict(list), set(), defaultdict(dict) + for ki, k in enumerate(K): + for item, call, row, bd, how, anc in idx.explain(k): + per_item[item].append((ki, call, row, bd)) + how_of[item].setdefault(ki, how) + if anc: + anchored.add(item) + real = sorted(i for i, k in enumerate(K) if not k.get("일반")) + ub = [] + for it, hs in per_item.items(): + if it in anchored: + c = len({h[0] for h in hs} & set(real)) + ub.append((bi.pb_sig([idx.q_of(it, K[i]) for i in real], c), c, it)) + ub.sort(key=lambda x: (-x[0], -x[1], x[2])) + out, best = [], 0.0 + for s_ub, c, item in ub: + if c == 0 or s_ub < best - TIE or len(out) >= 200: + break + got = solve(per_item[item]) + got_real = got["덮음"] & set(real) + s_val = bi.pb_sig([idx.q_of(item, K[i]) for i in real], len(got_real)) + best = max(best, s_val) + out.append({"로직": item, **got, "꼴": how_of[item], "실수": len(got_real), "유의": s_val}) + out.sort(key=lambda o: (-o["유의"], -o["실수"], o["로직"])) + return out + + +def collapse(idx: bi.Index, tied: list[dict]) -> tuple[list[dict], str]: + """동점 정리 — 다른 동점이 모두 그 로직을 부르면 그 하위(핵) · 위 로직 하나면 그 위 로직 · + 그 밖 = 동점 위 로직이 덮는 하위는 뺌.""" + for c in tied: + others = [o for o in tied if o is not c] + if others and all(o["로직"] in idx.callers.get(c["로직"], set()) for o in others): + return ( + (others, "위 로직 하나") + if len(others) == 1 + else ([c], f"핵 하위 로직(위 로직 {len(others)})") + ) + keep = [] + for o in tied: + if not [ + p + for p in tied + if p is not o + and p["로직"] in idx.callers.get(o["로직"], set()) + and o["덮음"] <= p["덮음"] + ]: + keep.append(o) + return keep, "" + + +def judge(idx: bi.Index, K: list[dict]) -> dict: + """한 단위(절 · 블록) 판정 — U · P · A · N · N0.""" + real = {i for i, k in enumerate(K) if not k.get("일반")} + if not real: + return {"판정": "N0", "까닭": "지문 없음(상수 0)", "후보": [], "K": K} + cands = [o for o in rank(idx, K) if o["실수"]] + if not cands: + return {"판정": "N", "까닭": "맞는 표 없음", "후보": [], "K": K} + top = cands[0] + if top["유의"] < SIG_MIN: + return { + "판정": "N", + "까닭": f"유의 못 미침(1등 {top['실수']}/{len(real)} · 유의 {top['유의']})", + "후보": cands[:5], + "K": K, + } + tied, note = collapse(idx, [o for o in cands if o["유의"] >= top["유의"] - TIE]) + if len(tied) >= 2: + return { + "판정": "A", + "까닭": f"동점 {len(tied)}", + "후보": tied[:12], + "n": len(tied), + "K": K, + "남음": sorted(real - top["덮음"]), + } + top = tied[0] + left = sorted(real - top["덮음"]) + full = not left and not top["옵션여럿"] and top["조합같은덮음"] + rest = [o for o in cands if o is not top] + return { + "판정": "U" if full else "P", + "까닭": note, + "후보": [top] + rest[:4], + "K": K, + "남음": left, + } + + +def cover(idx: bi.Index, K: list[dict], first: dict) -> list[dict]: + """블록 덮기 — 첫 기초 뒤 남은 상수로 다음 기초(새 상수 2+ · 유일 · 넷까지).""" + picks, done = [first], set(first["덮음"]) + for _ in range(3): + rest = [k if i not in done else {**k, "일반": True} for i, k in enumerate(K)] + if all(k.get("일반") for k in rest): + break + got = judge(idx, rest) + if got["판정"] not in ("U", "P"): + break + o = got["후보"][0] + if len(o["덮음"] - done) < 2: + break + picks.append({**o, "동점": 1}) + done |= o["덮음"] + return picks + + +def assemble(idx: bi.Index, bK: list[dict], mains: list) -> dict: + """절 2+ 블록 — 주 절 판정 모음 + 절 밖 상수(머리 · 비목 절)는 고른 기초가 덮는지 · + 남으면 따로.""" + picks, verdicts, covered = [], [], set() + + def key(c): + return (round(c["v"], 9), fam(c["단위"]), c.get("직종")) + + for _, _, K, sj in mains: + verdicts.append(sj["판정"]) + if sj["판정"] in ("U", "P"): + o = sj["후보"][0] + mine = {key(K[j]) for j in o["덮음"]} + picks.append({**o, "덮음": {i for i, c in enumerate(bK) if key(c) in mine}}) + covered |= {key(c) for c in K} + rest = [c if key(c) not in covered else {**c, "일반": True} for c in bK] + left = {i for i, c in enumerate(rest) if not c.get("일반")} + if left and picks: + chosen = {p["로직"] for p in picks} + for o in rank(idx, rest): + if o["로직"] not in chosen: + continue + got = o["덮음"] & left + for p in picks: + if p["로직"] != o["로직"]: + continue + p["덮음"] = p["덮음"] | got + if got: # 절 밖 상수가 정한 옵션도 얹음(같은 입력 다른 값 = 여럿) + for kk, vv in o["옵션"].items(): + if kk in p["옵션"] and p["옵션"][kk] != vv: + p["옵션여럿"] = {**p["옵션여럿"], kk: [p["옵션"][kk], vv]} + else: + p["옵션"] = {**p["옵션"], kk: vv} + left -= got + if len(left) >= 2: + extra = judge(idx, [c if i in left else {**c, "일반": True} for i, c in enumerate(bK)]) + if extra["판정"] in ("U", "P"): + picks.append(extra["후보"][0]) + left -= extra["후보"][0]["덮음"] + verdicts.append(extra["판정"]) + if not picks: + out = { + "판정": "A" if "A" in verdicts else ("N" if "N" in verdicts else "N0"), + "후보": [], + "K": bK, + } + a = next((x[3] for x in mains if x[3]["판정"] == "A"), None) + return out | ({"후보": a["후보"], "n": a["n"], "K": a["K"]} if a else {}) + allu = not left and all(not p["옵션여럿"] for p in picks) + return { + "판정": "U" if allu else "P", + "후보": picks, + "K": bK, + "기초목록": picks, + "남음": sorted(left), + } + + +def _show(o: dict) -> dict: + return {"로직": o["로직"], "옵션": o["옵션"], "옵션여럿": o["옵션여럿"]} + + +def value(idx: bi.Index, pr: dict) -> dict: + """블록 준비(`Const.prep`) → 값 판정 {판정 U|P|A|N|N0, 기초 · 동점 · 약한후보 [{로직, 옵션}], + 절 [{절, 판정, 기초?, 동점?}], K, 입력}.""" + lines, per_line, secs = pr["lines"], pr["per_line"], pr["secs"] + bK, bIns = bc.gather(per_line, range(len(lines))) + sec_res = [] + for title, ids in secs: + K, _ = bc.gather(per_line, ids) + sec_res.append((title, ids, K, judge(idx, K))) + mains = [x for x in sec_res if len([k for k in x[2] if not k.get("일반")]) >= 2] + multi = len(secs) >= 2 and len(mains) >= 1 + bj = assemble(idx, bK, mains) if multi else judge(idx, bK) + rec: dict[str, Any] = { + "판정": bj["판정"], + "K": [{"단위": c["단위"]} for c in bK], + "입력": [c["v"] for c in bIns], + } + if bj["판정"] in ("U", "P"): + rec["기초"] = [_show(p) for p in (bj.get("기초목록") or cover(idx, bK, bj["후보"][0]))] + elif bj["판정"] == "A": + rec["동점"] = [_show(o) for o in bj["후보"]] + rec["n"] = bj["n"] + elif bj["판정"] == "N" and bj.get("후보"): + rec["약한후보"] = [_show(o) for o in bj["후보"][:3]] + rec["절"] = [] + for title, _, _, sj in sec_res: + one = {"절": title, "판정": sj["판정"]} + if sj["판정"] in ("U", "P"): + one["기초"] = _show(sj["후보"][0]) + elif sj["판정"] == "A": + one["동점"] = [o["로직"] for o in sj["후보"]] + rec["절"].append(one) + return rec diff --git a/M02_MasterTemplete/M02_San_Basis_corpus.json b/M02_MasterTemplete/M02_San_Basis_corpus.json new file mode 100644 index 000000000..20fd4fd93 --- /dev/null +++ b/M02_MasterTemplete/M02_San_Basis_corpus.json @@ -0,0 +1 @@ +[[0.6, 1, "m3", null, false], [0.55, 2, "", null, false], [0.35, 2, "", null, false], [18.0, 0, "sec", null, false], [90.0, 0, "", null, false], [23.1, 1, "m3", null, false], [90.0, 0, "%", null, false], [0.3, 1, "인", "보통인부", false], [10.0, 0, "%", null, false], [0.7, 1, "분", null, false], [0.45, 2, "", null, false], [20.0, 0, "sec", null, false], [135.0, 0, "", null, false], [34.02, 2, "m3", null, false], [7.0, 0, "km", null, false], [4.5, 1, "ton", null, false], [1.75, 2, "ton", null, false], [1.1, 1, "분", null, false], [9.23, 2, "m3", null, false], [110.0, 0, "m3", null, false], [0.009, 3, "인", "보통인부", false], [0.009, 3, "인", "특별인부", false], [0.7, 1, "", null, false], [10.0, 0, "km", null, false], [10.0, 0, "", null, false], [0.42, 2, "분", null, false], [13.09, 2, "m3", null, false], [0.875, 3, "", null, false], [1.25, 2, "", null, false], [0.75, 2, "", null, false], [21.0, 0, "sec", null, false], [81.0, 0, "m3", null, false], [12.0, 0, "개", null, false], [2.0, 0, "", null, false], [51.02, 2, "m3", null, false], [290.0, 0, "", null, false], [0.003, 3, "인", "특별인부", false], [0.003, 3, "인", "보통인부", false], [50.0, 0, "Km", null, false], [40.0, 0, "", null, false], [30.0, 0, "Km", null, false], [15.0, 0, "Km", null, false], [10.0, 0, "Km", null, false], [0.17, 2, "인", "보통인부", false], [575.0, 0, "", null, false], [1750.0, 0, "", null, false], [20.79, 2, "m3", null, false], [9.96, 2, "m3", null, false], [50.0, 0, "cm", null, false], [2.0, 0, "m", null, false], [0.002, 3, "kg", null, false], [0.0586, 4, "kg", null, false], [0.0011, 5, "인", "특별인부", false], [0.01676, 5, "인", "보통인부", false], [270.0, 0, "", null, false], [800.0, 0, "mm", null, false], [0.24, 2, "인", "배관공(수도)", false], [6.0, 0, "", null, false], [0.04, 9, "인", "배관공(수도)", true], [0.12, 2, "인", "보통인부", false], [0.02, 9, "인", "보통인부", true], [2.0, 0, "%", null, false], [0.25, 2, "hr", null, false], [336.0, 0, "", null, false], [0.32, 2, "인", "배관공(수도)", false], [0.05333333333333334, 9, "인", "배관공(수도)", true], [0.16, 2, "인", "보통인부", false], [0.02666666666666667, 9, "인", "보통인부", true], [0.3, 1, "hr", null, false], [42.0, 0, "회", null, false], [575.0, 0, "kg", null, false], [25.0, 0, "kg", null, false], [6.0, 0, "개", null, false], [24.0, 0, "개", null, false], [36.0, 0, "개", null, false], [48.0, 0, "개", null, false], [18.0, 0, "", null, false], [23.0, 0, "", null, false], [34.0, 0, "", null, false], [56.0, 0, "", null, false], [78.0, 0, "", null, false], [26.7, 1, "%", null, false], [2.0, 1, "hr", null, false], [0.58, 2, "인", "비계공", false], [0.34, 2, "인", "특별인부", false], [0.1, 1, "인", "보통인부", false], [0.2, 1, "", null, false], [0.9, 1, "", null, false], [25.0, 0, "톤", null, false], [0.7, 1, "m3", null, false], [0.447, 3, "Ton", null, false], [20.0, 0, "", null, false], [30.0, 0, "km", null, false], [35.0, 0, "km", null, false], [11.46, 2, "m3", null, false], [1200.0, 0, "mm", null, false], [0.39, 2, "인", "배관공(수도)", false], [0.065, 9, "인", "배관공(수도)", true], [0.19, 2, "인", "보통인부", false], [0.03166666666666667, 9, "인", "보통인부", true], [0.35, 2, "hr", null, false], [505.0, 0, "", null, false], [1.3, 1, "", null, false], [0.85, 2, "", null, false], [15.0, 1, "ton", null, false], [1.9, 1, "ton", null, false], [39.87, 2, "m3", null, false], [266.0, 0, "", null, false], [518.0, 0, "", null, false], [20.0, 0, "m", null, false], [0.96, 2, "", null, false], [30.0, 0, "m", null, false], [0.92, 2, "", null, false], [40.0, 0, "m", null, false], [0.88, 2, "", null, false], [55.0, 0, "m", null, false], [70.0, 0, "m", null, false], [3.2, 1, "", null, false], [0.5, 1, "", null, false], [0.25, 2, "분", null, false], [0.91, 2, "분", null, false], [77.93, 2, "m3", null, false], [757.0, 0, "", null, false], [460.0, 0, "", null, false], [0.72, 2, "", null, false], [0.07, 2, "인", "형틀목공", false], [0.03, 2, "인", "보통인부", false], [15.0, 0, "", null, false], [1.9, 1, "", null, false], [21.0, 0, "", null, false], [7.0, 0, "", null, false], [29.36, 2, "m3", null, false], [703.0, 0, "", null, false], [15.0, 0, "톤", null, false], [1.175, 3, "", null, false], [1.7, 1, "ton", null, false], [1.72, 2, "", null, false], [1.8, 1, "", null, false], [8.0, 0, "", null, false], [14.0, 0, "", null, false], [0.6, 1, "", null, false], [0.5, 1, "분", null, false], [3.18, 2, "m3", null, false], [23.0, 0, "m3", null, false], [0.13043478260869565, 9, "인", "콘크리트공", true], [0.13043478260869565, 9, "인", "보통인부", true], [63.0, 0, "m3", null, false], [3.0, 0, "인", "콘크리트공", false], [0.047619047619047616, 9, "인", "콘크리트공", true], [0.015873015873015872, 9, "인", "보통인부", true], [55.0, 0, "m3", null, false], [0.05454545454545454, 9, "인", "콘크리트공", true], [0.01818181818181818, 9, "인", "보통인부", true], [1.03, 2, "m2", null, false], [38.0, 0, "%", null, false], [0.038, 3, "m3", null, false], [9.0, 0, "%", null, false], [45.0, 0, "m2", null, false], [5.0, 0, "인", "형틀목공", false], [0.1111111111111111, 9, "인", "형틀목공", true], [2.0, 0, "인", "보통인부", false], [0.044444444444444446, 9, "인", "보통인부", true], [1.0, 0, "%", null, false], [32.7, 1, "%", null, false], [11.0, 0, "%", null, false], [50.0, 0, "m2", null, false], [0.1, 9, "인", "형틀목공", true], [0.04, 9, "인", "보통인부", true], [0.095, 3, "매", null, false], [55.0, 0, "%", null, false], [35.0, 0, "m2", null, false], [4.0, 0, "인", "형틀목공", false], [0.11428571428571428, 9, "인", "형틀목공", true], [0.02857142857142857, 9, "인", "보통인부", true], [3.0, 0, "%", null, false], [10.0, 0, "cm", null, false], [0.65, 2, "", null, false], [19.0, 0, "sec", null, false], [66.32, 2, "m3", null, false], [0.1, 1, "", null, false], [0.15, 2, "m", null, false], [2.0, 0, "회", null, false], [25.8, 1, "", null, false], [12.3, 1, "", null, false], [12.8, 1, "", null, false], [13.0, 0, "%", null, false], [8.0, 0, "%", null, false], [1600.0, 0, "", null, false], [1416.0, 0, "kg", null, false], [160000.0, 0, "", null, false], [5.0, 0, "", null, false], [104.87, 2, "", null, false], [22.0, 0, "sec", null, false], [180.0, 0, "", null, false], [55.57, 2, "m3", null, false], [350.0, 0, "", null, false], [441.0, 0, "", null, false], [4.5, 1, "Ton", null, false], [0.3, 1, "", null, false], [0.09, 2, "", null, false], [6.0, 0, "인", "철근공", false], [3.4, 1, "Ton", null, false], [60.0, 0, "cm", null, false], [75.0, 0, "cm", null, false], [67.5, 1, "cm", null, false], [50.0, 0, "%", null, false], [500.0, 0, "m", null, false], [0.675, 3, "", null, false], [0.77, 2, "", null, false], [4.0, 0, "개", null, false], [40.0, 0, "cm", null, false], [0.026, 3, "인", "작업반장", false], [0.026, 3, "", null, false], [0.134, 3, "", null, false], [15.0, 0, "Ton", null, false], [2.4, 1, "ton", null, false], [1.35, 2, "", null, false], [12.2, 1, "m3", null, false], [0.83, 2, "", null, false], [10.0, 0, "m2", null, false], [1.3, 2, "", null, false], [4.8, 1, "", null, false], [1.04, 2, "", null, false], [3.84, 2, "", null, false], [26.95, 2, "m3", null, false], [14.43, 2, "m3", null, false], [0.096, 3, "", null, false], [0.011, 3, "인", "특별인부", false], [0.011, 3, "인", "보통인부", false], [24.26, 2, "m3", null, false], [40.0, 0, "Km", null, false], [20.0, 0, "Km", null, false], [1.1, 1, "", null, false], [10.5, 1, "", null, false], [11.0, 0, "", "보통인부", false], [450.0, 0, "", null, false], [0.024444444444444446, 9, "인", "보통인부", true], [5.0, 0, "%", null, false], [214.0, 0, "", null, false], [0.043, 3, "인", "형틀목공", false], [0.67, 2, "인", "철근공", false], [0.00623, 5, "", null, false], [0.22, 2, "인", "보통인부", false], [6.5, 1, "kg", null, false], [3.53, 2, "인", "철근공", false], [1.18, 2, "인", "보통인부", false], [0.11, 2, "", null, false], [0.04, 2, "", null, false], [0.22, 2, "hr", null, false], [77.7, 1, "m2", null, false], [759.0, 0, "", null, false], [250.0, 0, "", null, false], [15.0, 0, "mm", null, false], [0.029, 3, "인", "형틀목공", false], [0.006, 3, "인", "보통인부", false], [4.0, 0, "본", null, false], [0.25, 9, "인", "특별인부", true], [0.25, 9, "인", "보통인부", true], [390.0, 0, "m3", null, false], [250.0, 0, "m3", null, false], [230.0, 0, "m3", null, false], [3.0, 0, "", null, false], [817.0, 0, "", null, false], [1.06, 2, "인", "보통인부", false], [3.76, 2, "hr", null, false], [65.0, 0, "", null, false], [0.33, 2, "", null, false], [0.007, 3, "인", "화약취급공", false], [0.021, 3, "hr", null, false], [24.0, 0, "%", null, false], [0.009, 3, "hr", null, false], [1.4, 1, "", null, false], [1.625, 3, "", null, false], [1.85, 2, "", null, false], [20.46, 2, "m3", null, false], [290.0, 0, "m3", null, false], [0.0034482758620689655, 9, "인", "보통인부", true], [0.0034482758620689655, 9, "인", "특별인부", true], [310.0, 0, "m3", null, false], [844.0, 0, "", null, false], [764.0, 0, "", null, false], [50.0, 0, "m3", null, false], [23.43, 2, "m3", null, false], [150.0, 0, "m3", null, false], [78.0, 0, "m3", null, false], [756.0, 0, "", null, false], [419.0, 0, "", null, false], [379.0, 0, "", null, false], [37.71, 2, "m3", null, false], [785.0, 0, "", null, false], [190.0, 0, "m3", null, false], [58.21, 2, "m3", null, false], [28.0, 0, "", null, false], [19.0, 0, "", null, false], [3.5, 1, "%", null, false], [24.89, 2, "m3", null, false], [54.05, 2, "m3", null, false], [366.0, 0, "", null, false], [501.0, 0, "", null, false], [63.0, 0, "인", null, false], [2.0, 0, "인", "특별인부", false], [42.0, 0, "본", null, false], [0.047619047619047616, 9, "인", "특별인부", true], [0.023809523809523808, 9, "인", "보통인부", true], [0.08, 2, "%", null, false], [31.0, 0, "본", null, false], [0.06451612903225806, 9, "인", "특별인부", true], [0.03225806451612903, 9, "인", "보통인부", true], [1.05, 2, "", null, false], [0.006, 3, "", null, false], [300.0, 0, "mm", null, false], [12.56, 2, "m3", null, false], [0.08, 2, "인", "보통인부", false], [0.101, 3, "", null, false], [0.072, 3, "인", "석공", false], [0.053, 3, "인", "특별인부", false], [0.013, 3, "인", "보통인부", false], [150.0, 0, "m2", null, false], [0.18, 2, "", null, false], [0.01, 2, "", null, false], [200.0, 0, "", null, false], [30.86, 2, "매", null, false], [0.006666666666666667, 9, "인", "조경공", true], [30.86, 2, "", "보통인부", false], [0.03086, 9, "인", "보통인부", true], [0.2, 1, "m", null, false], [4.0, 1, "인", "포장공", false], [4.0, 0, "", null, false], [2.0, 1, "인", "보통인부", false], [431.0, 0, "", null, false], [1.2, 1, "", null, false], [517.0, 0, "", null, false], [600.0, 0, "m", null, false], [0.31, 2, "EA", null, false], [0.0016666666666666668, 9, "인", "특별인부", true], [0.0016666666666666668, 9, "인", "보통인부", true], [3.0, 0, "Ton", null, false], [15.0, 0, "km", null, false], [5.0, 0, "분", null, false], [10.0, 0, "분", null, false], [20.0, 0, "분", null, false], [5.5, 1, "", null, false], [7.28, 2, "", null, false], [450.0, 0, "M", null, false], [3000.0, 0, "", null, false], [0.0022, 4, "인", "특별인부", false], [0.0022, 4, "인", "보통인부", false], [0.15, 2, "", null, false], [0.21, 2, "", null, false], [0.24, 2, "", null, false], [3.0, 0, "개", null, false], [0.05, 2, "", null, false], [0.008, 9, "인", "보통인부", true], [0.03, 2, "", null, false], [0.003, 3, "", null, false], [300.0, 0, "m2", null, false], [0.31, 2, "hr", null, false], [0.75, 2, "인", "철근공", false], [0.25, 2, "인", "보통인부", false], [8.0, 0, "kg", null, false], [1.92, 2, "인", "철근공", false], [0.77, 2, "인", "보통인부", false], [4.0, 0, "%", null, false], [100.0, 0, "%", null, false], [0.38, 2, "", null, false], [0.06, 9, "인", "형틀목공", true], [48.6, 1, "mm", null, false], [2.3, 1, "mm", null, false], [3.99, 2, "m", null, false], [7.0, 0, "%", null, false], [0.5, 1, "EA", null, false], [12.0, 0, "%", null, false], [2.08, 2, "EA", null, false], [0.04, 2, "EA", null, false], [3.0, 0, "인", "비계공", false], [55.0, 0, "m2", null, false], [2.6, 1, "", null, false], [4.6, 1, "", null, false], [0.01, 2, "본", null, false], [1.5, 1, "", null, false], [14.77, 2, "m3", null, false], [3.3, 1, "", null, false], [5.9, 1, "", null, false], [30.0, 0, "cm", null, false], [2.7, 1, "", null, false], [0.02, 2, "", null, false], [0.08, 2, "", null, false], [0.05, 2, "kg", null, false], [4.17, 2, "", null, false], [1.6, 1, "", null, false], [16.0, 0, "m3", null, false], [76.3, 1, "mm", null, false], [3.0, 1, "m", null, false], [12.0, 0, "본", null, false], [0.16666666666666666, 9, "인", "특별인부", true], [22.0, 0, "본", null, false], [0.09090909090909091, 9, "인", "특별인부", true], [0.08333333333333333, 9, "인", "보통인부", true], [0.045454545454545456, 9, "인", "보통인부", true], [7.05, 2, "인", "철공", false], [2.57, 2, "인", "용접공", false], [1.92, 2, "인", "특별인부", false], [1.28, 2, "인", "보통인부", false], [15.0, 0, "%", null, false], [2.0, 0, "개", null, false], [0.55, 2, "인", null, false], [0.18, 2, "인", null, false], [30.0, 0, "개", null, false], [0.06, 2, "인", "특별인부", false], [150.0, 0, "M", null, false], [20.0, 0, "개", null, false], [60.0, 0, "%", null, false], [5.0, 0, "인", null, false], [10.0, 0, "인", null, false], [18.0, 0, "%", null, false], [23.0, 0, "%", null, false], [0.13, 2, "", null, false], [4.667, 3, "%", null, false], [23.38, 2, "m3", null, false], [0.58, 2, "", null, false], [1.01, 2, "", null, false], [3.36, 2, "", null, false], [2.4, 1, "", null, false], [3.6, 1, "", null, false], [2.2, 1, "", null, false], [1500.0, 0, "mm", null, false], [0.5, 2, "인", "배관공(수도)", false], [0.08333333333333333, 9, "인", "배관공(수도)", true], [0.041666666666666664, 9, "인", "보통인부", true], [0.43, 2, "hr", null, false], [0.006, 3, "인", "도장공", false], [0.001, 3, "인", "보통인부", false], [0.015, 3, "인", "도장공", false], [0.161, 3, "", null, false], [0.008, 3, "", null, false], [0.02, 2, "인", "도장공", false], [0.004, 3, "인", "보통인부", false], [0.166, 3, "인", null, false], [2.0, 0, "hr", null, false], [3.0, 0, "", "형틀목공", false], [50.0, 0, "인", "형틀목공", false], [50.0, 0, "인", "보통인부", false], [0.06, 2, "", null, false], [0.12, 2, "", null, false], [55.0, 0, "인", "비계공", false], [0.05454545454545454, 9, "인", "비계공", true], [55.0, 0, "인", "보통인부", false], [9.0, 0, "", null, false], [1.125, 3, "", null, false], [20.56, 2, "m3", null, false], [30.28, 2, "m3", null, false], [1.75, 2, "", null, false], [11.95, 2, "m3", null, false], [0.8, 1, "", null, false], [59.82, 2, "m3", null, false], [986.0, 0, "", null, false], [429.0, 0, "", null, false], [451.0, 0, "", null, false], [0.0034482758620689655, 9, "인", null, true], [35.0, 0, "", null, false], [1.325, 3, "", null, false], [24.51, 2, "m3", null, false], [90.0, 0, "m3", null, false], [0.011111111111111112, 9, "인", null, true], [40.37, 2, "m3", null, false], [1.7, 1, "", null, false], [2.95, 2, "m3", null, false], [3.04, 2, "m3", null, false], [0.00909090909090909, 9, "인", null, true], [33.0, 0, "kg", null, false], [0.0231, 4, "인", "콘크리트공", false], [0.0154, 4, "인", "특별인부", false], [0.0077, 4, "인", "보통인부", false], [3.0, 0, "", "콘크리트공", false], [2.0, 0, "인", "콘크리트공", false], [3.0, 0, "", "보통인부", false], [63.0, 0, "", null, false], [55.0, 0, "인", "콘크리트공", false], [55.0, 0, "", null, false], [5.0, 0, "인", "보통인부", false], [670.0, 0, "m2", null, false], [500.0, 0, "mm", null, false], [0.13, 2, "인", "배관공(수도)", false], [0.021666666666666667, 9, "인", "배관공(수도)", true], [0.07, 2, "인", "보통인부", false], [0.011666666666666667, 9, "인", "보통인부", true], [0.18, 2, "hr", null, false], [252.0, 0, "", null, false], [0.8, 1, "분", null, false], [45.21, 2, "m3", null, false], [26.46, 2, "m3", null, false], [780.0, 0, "", null, false], [0.9, 1, "본", null, false], [9.0, 0, "본", null, false], [0.99, 2, "본", null, false], [4.0, 0, "", "형틀목공", false], [130.0, 0, "인", "형틀목공", false], [0.03076923076923077, 9, "인", "형틀목공", true], [130.0, 0, "인", "보통인부", false], [0.007692307692307693, 9, "인", "보통인부", true], [2.0, 0, "", "형틀목공", false], [150.0, 0, "인", "형틀목공", false], [0.013333333333333334, 9, "인", "형틀목공", true], [2.0, 0, "", "보통인부", false], [150.0, 0, "인", "보통인부", false], [0.013333333333333334, 9, "인", "보통인부", true], [0.03333333333333333, 9, "인", "형틀목공", true], [0.008333333333333333, 9, "인", "보통인부", true], [150.0, 0, "", null, false], [430.0, 0, "", null, false], [0.0448, 4, "본", null, false], [0.009, 3, "본", null, false], [0.0895, 4, "개", null, false], [0.0492, 4, "개", null, false], [0.1628, 4, "개", null, false], [0.0163, 4, "개", null, false], [0.0407, 4, "개", null, false], [0.0081, 4, "개", null, false], [6.0, 0, "", "비계공", false], [150.0, 0, "인", "비계공", false], [0.04, 9, "인", "비계공", true], [0.0773, 4, "개", null, false], [0.0041, 4, "개", null, false], [0.8, 1, "m3", null, false], [0.94, 2, "", null, false], [46.52, 2, "m3", null, false], [27.23, 2, "m3", null, false], [0.45, 2, "m", null, false], [0.017, 3, "인", "보통인부", false], [0.04, 2, "인", "특별인부", false], [0.042, 3, "인", "석공", false], [399.0, 0, "", null, false], [29.0, 0, "%", null, false], [40.0, 0, "인", "형틀목공", false], [40.0, 0, "인", "보통인부", false], [0.025, 9, "인", "보통인부", true], [35.0, 0, "인", "형틀목공", false], [35.0, 0, "인", "보통인부", false], [50.45, 2, "m3", null, false], [535.0, 0, "", null, false], [0.2, 1, "인", "보통인부", false], [2.14, 2, "", "벌목부", false], [0.00214, 9, "인", "벌목부", true], [0.51, 2, "", "보통인부", false], [0.00051, 9, "인", "보통인부", true], [2.71, 2, "hr", null, false], [22.0, 0, "", null, false], [47.0, 0, "", null, false], [12.38, 2, "인", "철공", false], [2.25, 2, "인", "보통인부", false], [3.38, 2, "인", "용접공", false], [4.5, 2, "인", "특별인부", false], [50.0, 0, "km", null, false], [40.0, 0, "km", null, false], [20.0, 0, "km", null, false], [1.04, 2, "m", null, false], [0.042, 3, "kg", null, false], [0.21, 2, "kg", null, false], [0.151, 3, "", null, false], [0.116, 3, "", null, false], [34.23, 2, "m3", null, false], [750.0, 0, "", null, false], [789.0, 0, "", null, false], [49.9, 1, "m3", null, false], [0.22, 2, "", null, false], [1.05, 2, "분", null, false], [3.48, 2, "m3", null, false], [54.43, 2, "m3", null, false], [0.19, 2, "", null, false], [71.0, 0, "%", null, false], [60.0, 0, "Ton", null, false], [2.48, 2, "", null, false], [0.95, 2, "", null, false], [2333.0, 0, "", null, false], [12.0, 0, "", null, false], [30.0, 0, "톤", null, false], [73.0, 0, "%", null, false], [1.36, 2, "", null, false], [10.0, 0, "Ton", null, false], [273.0, 0, "", null, false], [0.058, 3, "", null, false], [0.083, 3, "", null, false], [0.83, 2, "", "작업반장", false], [0.08299999999999999, 9, "인", "작업반장", true], [1.04, 2, "", "특별인부", false], [0.10400000000000001, 9, "인", "특별인부", true], [1.04, 2, "", "보통인부", false], [0.10400000000000001, 9, "인", "보통인부", true], [0.384, 3, "", null, false], [0.336, 3, "", null, false], [1.3, 2, "", "특별인부", false], [0.13, 9, "인", "특별인부", true], [1.3, 2, "", "보통인부", false], [0.13, 9, "인", "보통인부", true], [0.48, 2, "", null, false], [70.0, 0, "cm", null, false], [0.4, 1, "", null, false], [6.0, 0, "%", null, false], [2.65, 2, "", null, false], [21.36, 2, "m3", null, false], [20.89, 2, "m3", null, false], [2.3, 1, "", null, false], [2.49, 2, "m3", null, false], [5.0, 0, "", "형틀목공", false], [45.0, 0, "인", "형틀목공", false], [45.0, 0, "인", "보통인부", false], [23.39, 2, "m3", null, false], [0.3420265070542967, 9, "인", "보통인부", true], [832.0, 0, "", null, false], [0.71, 2, "", null, false], [17.22, 2, "m3", null, false], [15.04, 2, "", null, false], [0.5319148936170213, 9, "인", "보통인부", true], [3.4, 1, "m3", null, false], [0.01, 2, "인", "보통인부", false], [0.024, 3, "hr", null, false], [410.0, 0, "", null, false], [562.0, 0, "", null, false], [364.0, 0, "", null, false], [57.99, 2, "m3", null, false], [1.6, 1, "ton", null, false], [19.35, 2, "", null, false], [9.46, 2, "", null, false], [2.97, 2, "", null, false], [0.36, 2, "m3", null, false], [34.41, 2, "m3", null, false], [0.31, 2, "", null, false], [88.94, 2, "m3", null, false], [5.0, 0, "m", null, false], [8.0, 0, "m", null, false], [2.8, 1, "", "벌목부", false], [2.8, 9, "인", "벌목부", true], [0.66, 2, "", "보통인부", false], [0.66, 9, "인", "보통인부", true], [0.2, 1, "m3", null, false], [3.54, 2, "", null, false], [208.9, 1, "", null, false], [28.2, 1, "", null, false], [62.1, 1, "", null, false], [3.8, 1, "", null, false], [224.2, 1, "", null, false], [30.3, 1, "", null, false], [66.6, 1, "", null, false], [1.06, 2, "", "보통인부", false], [1.06, 9, "인", "보통인부", true], [15.0, 0, "sec", null, false], [11.34, 2, "m3", null, false], [0.7054673721340388, 9, "인", "보통인부", true], [16.2, 1, "m3", null, false], [513.0, 0, "", null, false], [856.0, 0, "", null, false], [170.0, 0, "m2", null, false], [0.0058823529411764705, 9, "인", "조경공", true], [4.0, 0, "인", "보통인부", false], [0.023529411764705882, 9, "인", "보통인부", true], [12.0, 0, "m3", null, false], [0.42, 2, "Ton", null, false], [1.9, 1, "Ton", null, false], [0.62, 2, "", null, false], [13.67, 2, "m3", null, false], [15.0, 0, "ton", null, false], [33.06, 2, "", null, false], [34.63, 2, "", null, false], [10.75, 2, "m3", null, false], [0.89, 2, "", null, false], [19.62, 2, "m3", null, false], [7.89, 2, "", null, false], [20.49, 2, "", null, false], [21.47, 2, "", null, false], [0.14, 2, "", null, false], [15.24, 2, "m3", null, false], [21.26, 2, "m3", null, false], [2.65, 2, "ton", null, false], [11.83, 2, "m3", null, false], [0.14, 2, "인", "석공", false], [0.05, 2, "인", "보통인부", false], [0.3, 1, "m3", null, false], [0.11, 2, "인", "석공", false], [0.04, 2, "인", "보통인부", false], [57.0, 0, "", null, false], [414.0, 0, "", null, false], [145.0, 0, "", null, false], [0.17, 2, "", null, false], [0.06, 2, "인", "석공", false], [0.02, 2, "인", "보통인부", false], [0.04, 2, "인", "배관공(수도)", false], [0.046, 3, "", null, false], [387.0, 0, "", null, false], [1.47, 2, "", null, false], [9.02, 2, "", null, false], [7.48, 2, "", null, false], [43.66, 2, "m3", null, false], [0.001, 3, "", null, false], [0.42, 2, "", null, false], [0.086, 3, "", null, false], [0.732, 3, "", null, false], [0.936, 3, "", null, false], [2.5, 1, "인", "석공", false], [0.007, 3, "m2", null, false], [39.1, 1, "", null, false], [0.192, 3, "kg", null, false], [0.12, 2, "m", null, false], [0.022, 3, "인", "석공", false], [0.088, 3, "", null, false], [0.075, 3, "m3", null, false], [23.8, 1, "", null, false], [18.3, 1, "", null, false], [40.42, 2, "", null, false], [0.56, 2, "", null, false], [1.318, 3, "", null, false], [957.0, 0, "", null, false], [8.0, 0, "Ton", null, false], [30.0, 0, "", null, false], [0.61, 2, "", null, false], [0.523, 3, "", null, false], [1.34, 2, "", null, false], [1.15, 2, "", null, false], [4.64, 2, "", null, false], [0.42, 2, "t", null, false], [0.5, 1, "t", null, false], [0.018, 3, "인", "보통인부", false], [0.07, 2, "hr", null, false], [0.01, 2, "hr", null, false], [0.096, 3, "hr", null, false], [0.089, 3, "매", null, false], [40.0, 0, "m2", null, false], [0.6666666666666666, 9, "인", "철근공", true], [0.2222222222222222, 9, "인", "보통인부", true], [6.5, 1, "Kg", null, false], [3.5, 1, "ton", null, false], [1.72, 2, "m3", null, false], [37.4, 1, "", null, false], [4.34, 2, "m3", null, false], [4.0, 0, "인", "포장공", false], [862.0, 0, "", null, false], [600.0, 0, "", null, false], [0.0031, 4, "", null, false], [36.0, 0, "", null, false], [181.0, 0, "", null, false], [43.5, 1, "", null, false], [36.3, 1, "", null, false], [18.02, 2, "m3", null, false], [0.4439511653718091, 9, "인", "보통인부", true], [415.0, 0, "", null, false], [692.0, 0, "", null, false], [0.06, 2, "인", "콘크리트공", false], [0.09, 2, "hr", null, false], [0.24, 2, "인", "콘크리트공", false], [13.0, 0, "mm", null, false], [4.0, 0, "ton", null, false], [0.75, 9, "인", "철근공", true], [8.0, 0, "Kg", null, false], [5.0, 0, "인", "철근공", false], [2.6, 1, "ton", null, false], [1.923076923076923, 9, "인", "철근공", true], [0.7692307692307692, 9, "인", "보통인부", true], [0.05, 2, "인", "비계공", false], [0.07, 2, "인", "콘크리트공", false], [0.1, 1, "hr", null, false], [48.6, 1, "", null, false], [4.0, 1, "", null, false], [5.9, 1, "본", null, false], [0.05, 2, "인", "형틀목공", false], [4.5, 1, "인", "특별인부", false], [0.2576, 4, "인", "특별인부", false], [0.1287, 4, "인", "보통인부", false], [0.0833, 4, "", null, false], [701.0, 0, "", null, false], [0.1, 1, "인", "형틀목공", false], [310.0, 0, "", null, false], [5.0, 0, "m3", null, false], [0.0045, 4, "m2", null, false], [0.02, 2, "m3", null, false], [0.21, 2, "인", "건축목공", false], [0.0024, 4, "m2", null, false], [0.005, 3, "m3", null, false], [0.16, 2, "인", "건축목공", false], [0.14, 2, "인", "보통인부", false], [2.5, 1, "kg", null, false], [10.0, 0, "kg", null, false], [18.0, 0, "kg", null, false], [0.2, 1, "kg", null, false], [2.0, 0, "인", "조경공", false], [0.6666666666666666, 9, "인", "조경공", true], [0.3333333333333333, 9, "인", "보통인부", true], [71.0, 0, "", null, false], [72.0, 0, "", null, false], [33.08, 2, "m3", null, false], [588.0, 0, "", null, false], [742.0, 0, "", null, false], [3.5, 1, "m3", null, false], [48.51, 2, "m3", null, false], [0.34, 2, "", null, false], [136.0, 0, "", null, false], [9.25, 2, "", null, false], [0.43, 2, "m3", null, false], [0.13, 2, "인", "석공", false], [0.64, 2, "", null, false], [0.35, 2, "m3", null, false], [1.65, 2, "", null, false], [38.27, 2, "m3", null, false], [0.20904102430101906, 9, "인", "보통인부", true], [508.0, 0, "", null, false], [641.0, 0, "", null, false], [61.43, 2, "m3", null, false], [960.0, 0, "", null, false], [316.0, 0, "", null, false], [0.6, 2, "", null, false], [3600.0, 0, "", null, false], [16.0, 0, "", null, false], [2500.0, 0, "", null, false], [2.5, 1, "", null, false], [0.018, 3, "", null, false], [0.07, 2, "hr/m3", null, false], [0.09, 2, "인", "콘크리트공", false], [0.31, 2, "hr/m3", null, false], [0.03, 2, "인", "콘크리트공", false], [0.31, 2, "인", "보통인부", false], [0.8, 1, ":작업효율", null, false], [0.00438, 5, "m3", null, false], [0.00288, 5, "m3", null, false], [0.03, 2, "KG", null, false], [4.0, 0, "EA", null, false], [0.0525, 9, "인", "건축목공", true], [0.0475, 9, "인", "보통인부", true], [0.00384, 5, "m3", null, false], [2.0, 0, "EA", null, false], [0.08, 9, "인", "건축목공", true], [0.07, 9, "인", "보통인부", true], [77.7, 1, "m2/hr", null, false], [5.0, 1, "m3/hr", null, false], [0.24, 2, "", "배관공(수도)", false], [6.0, 0, "m", null, false], [0.12, 2, "", "보통인부", false], [0.25, 2, "", null, false], [3.5, 1, "m3/hr", null, false], [0.006, 3, "본", null, false], [500.0, 0, "m/일", null, false], [0.002, 9, "인", null, true], [3000.0, 0, "l", null, false], [20.0, 0, "km/hr", null, false], [5.0, 0, "분(준비)", null, false], [10.0, 0, "분(흡입)", null, false], [4.0, 0, "분(대기)", null, false], [5500.0, 0, "", null, false], [30.0, 0, "l", null, false], [0.39, 2, "", "배관공(수도)", false], [0.19, 2, "", "보통인부", false], [11.0, 0, "", null, false], [25.0, 0, "", null, false], [0.1, 1, "m3/m2", null, false], [0.0022222222222222222, 9, "인", "보통인부", true], [225.0, 0, "m3/일", null, false], [0.33, 2, "kg", null, false], [0.11, 2, "개", null, false], [0.021, 3, "hr/m3", null, false], [0.009, 3, "hr/m3", null, false], [1.4, 2, "", null, false], [30.0, 0, "m2", null, false], [0.443, 3, "%", null, false], [0.16666666666666666, 9, "인", "형틀목공", true], [0.06666666666666667, 9, "인", "보통인부", true], [4.5, 1, "ton/일", null, false], [3.0, 0, "인", "철근공", false], [3.5, 1, "ton/일", null, false], [1.3333333333333333, 9, "인", "철근공", true], [0.4444444444444444, 9, "인", "보통인부", true], [480.0, 0, "", null, false], [0.055, 3, "", null, false], [4.0, 0, "", "보통인부", false], [0.36, 2, "", null, false], [1.17, 2, "", null, false], [52.0, 0, "%", null, false], [63.0, 0, "", "콘크리트공", false], [31.5, 9, "인", "콘크리트공", true], [63.0, 0, "", "보통인부", false], [31.5, 9, "인", "보통인부", true], [3.0, 0, "", "철근공", false], [4.5, 1, "", null, false], [6.0, 0, "", "철근공", false], [3.5, 1, "", null, false], [1.7142857142857142, 9, "인", "철근공", true], [0.5714285714285714, 9, "인", "보통인부", true], [6.5, 1, "", null, false], [5.0, 0, "", "철근공", false], [2.0, 9, "인", "철근공", true], [0.8, 9, "인", "보통인부", true], [8.0, 1, "", null, false], [1.03, 2, "", null, false], [0.327, 3, "", null, false], [0.038, 3, "", null, false], [50.0, 0, "", null, false], [0.004, 3, "", null, false], [0.0253, 4, "", null, false], [0.00055, 5, "", null, false], [0.00838, 5, "", null, false], [55.0, 0, "m/min", null, false], [70.0, 0, "m/min", null, false], [24.0, 0, "", null, false], [0.59, 2, "본", null, false], [0.06, 2, "%", null, false], [0.054, 3, "", null, false], [0.021, 3, "", null, false], [35.0, 0, "km/hr", null, false], [10.0, 0, "km/hr", null, false], [15.0, 0, "km/hr", null, false], [1.6, 2, "", null, false], [5.5, 9, "인", null, true], [0.26, 2, "", null, false], [0.1, 2, "", null, false], [0.2, 1, "인/m3", null, false], [0.0012, 4, "개", null, false], [0.0007, 4, "개", null, false], [0.0019, 4, "개", null, false], [0.013, 3, "hr/m3", null, false], [0.034, 3, "", null, false], [0.007, 3, "", null, false], [0.009, 3, "시간/m2", null, false], [0.2, 2, "", null, false], [46.0, 0, "", null, false], [0.00106, 9, "인", "보통인부", true], [0.00376, 5, "", null, false], [0.66, 2, "인", "보통인부", false], [10.0, 0, "본", null, false], [0.066, 9, "인", "보통인부", true], [0.25, 2, "시간", null, false], [0.5, 1, "m3/본", null, false], [50.0, 0, "m", null, false], [4.0, 1, "m", null, false], [0.51, 2, "인", null, false], [2.14, 2, "인", null, false], [0.0027, 4, "m3/개소", null, false], [0.0007, 4, "m3/개소", null, false], [0.16, 2, "인/개소", "건축목공", false], [0.14, 2, "인/개소", "보통인부", false], [0.15, 2, "인/m3", "콘크리트공", false], [0.27, 2, "인/m3", "보통인부", false], [5.4, 1, "m3/ hr", null, false], [0.003, 3, "매", null, false], [79.0, 0, "%", null, false], [25.0, 0, "m2", null, false], [0.16, 9, "인", "형틀목공", true], [6.5, 1, "kg/Ton", null, false], [3.5, 1, "Ton", null, false], [0.24, 2, "인", "배관공", false], [6.0, 0, "m/본", null, false], [0.04, 9, "인", "배관공", true], [0.25, 2, "시간/본", null, false], [0.32, 2, "인", "배관공", false], [0.05333333333333334, 9, "인", "배관공", true], [0.3, 1, "시간/본", null, false], [0.075, 3, "인/m2", "작업반장", false], [0.108, 3, "인/m2", "특별인부", false], [0.108, 3, "인/m2", "보통인부", false], [0.352, 3, "시간/m2", null, false], [6.0, 1, "m3/hr", null, false], [0.05, 3, "인", "특별인부", false], [2.0, 1, "m", null, false], [0.025, 9, "인", "특별인부", true], [0.036, 3, "인", "보통인부", false], [0.018, 9, "인", "보통인부", true], [0.17, 2, "인/m2", null, false], [0.575, 3, "", null, false], [2000.0, 0, "", null, false], [120.0, 0, "", null, false], [2300.0, 0, "kg/m3", null, false], [0.3, 1, "인/ m3", null, false], [0.07, 3, "hr", null, false], [0.05, 2, "인/m2", null, false], [0.02, 2, "인/m2", null, false], [0.12, 2, "시간/m2", null, false], [0.3, 1, "인/m3", null, false], [575.0, 0, "kg/m2", null, false], [0.002, 3, "kg/m2", null, false], [0.0586, 4, "kg/m2", null, false], [0.0011, 4, "인/m2", "특별인부", false], [0.01676, 5, "인/m2", "보통인부", false], [3.0, 0, "인", "포장공", false], [3.0, 0, "인", "보통인부", false], [350.0, 0, "m/일", null, false], [3000.0, 0, "L", null, false], [0.005714285714285714, 9, "인", "보통인부", true], [0.002857142857142857, 9, "인", "특별인부", true], [0.722, 3, "", null, false], [0.9, 1, "분", null, false], [1.9, 2, "", null, false], [75.0, 0, "", null, false], [4.0, 0, "본/일", null, false], [0.7, 2, "분", null, false], [0.13, 2, "인/m2", null, false], [0.04, 2, "인/m2", null, false], [0.25, 2, "시간/m2", null, false], [0.09, 2, "인/m3", "콘크리트공", false], [0.02, 2, "인/m3", "보통인부", false], [0.31, 2, "Hr/m3", null, false], [0.042, 3, ">", null, false], [0.21, 2, ">", null, false], [0.151, 3, "<인>>", null, false], [0.116, 3, "<인>>", null, false], [1.04, 2, " m", null, false], [2.0, 1, "인", "특별인부", false], [12.0, 0, "본/일", null, false], [22.0, 0, "본/일", null, false], [0.009, 3, "", null, false], [0.018, 3, "인/m3", null, false], [0.39, 2, "인", "배관공", false], [0.065, 9, "인", "배관공", true], [0.35, 2, "시간/본", null, false], [13.0, 0, "개소", null, false], [1.7, 2, "", null, false], [10.0, 0, "입 구", null, false], [15.0, 0, "입 구", null, false], [10.0, 0, "시 점", null, false], [5.0, 1, "", null, false], [3.4, 1, "", null, false], [0.7, 2, "", null, false], [0.07, 2, "", null, false], [4500.0, 0, "", null, false], [910.0, 0, "", null, false], [710.0, 0, "", null, false], [0.9, 2, "", null, false], [0.46, 2, "", null, false], [50.0, 0, "", "포장공", false], [250.0, 9, "인", "포장공", true], [50.0, 0, "", "보통인부", false], [250.0, 9, "인", "보통인부", true], [2.0, 0, "", "철근공", false], [1.0, 9, "인", "보통인부", true], [3.0, 1, "인", "콘크리트공", false], [0.31, 2, "Kg", null, false], [0.04, 2, "개", null, false], [0.004, 3, "인", "화약취급공", false], [0.014, 3, "", null, false], [1.425, 3, "", null, false], [2.0, 1, "", null, false], [77.7, 1, "", null, false], [30.0, 0, "km/hr", null, false], [1.5, 1, "분", null, false], [2.73, 2, "", null, false], [0.15, 2, "분", null, false], [110.0, 0, "", null, false], [0.00909090909090909, 9, "인", "특별인부", true], [0.00909090909090909, 9, "인", "보통인부", true], [17.0, 0, "", null, false], [720.0, 0, "", null, false], [27.4, 1, "km(포 장)", null, false], [0.5, 2, "", null, false], [1.43, 2, "ton", null, false], [4.5, 1, "톤", null, false], [0.025, 3, "ton", null, false], [0.4, 2, "", null, false], [5.0, 9, "인", null, true], [0.004, 9, "인", "특별인부", true], [0.004, 9, "인", "보통인부", true], [3.0, 1, "(인/일)", null, false], [23.0, 0, "(m3/일)", null, false], [2.0, 0, "인", null, false], [0.055, 9, "인", null, true], [0.006666666666666667, 9, "인", null, true], [4.0, 1, "인", null, false], [0.02666666666666667, 9, "인", null, true], [0.1, 9, "인", null, true], [0.025, 9, "인", null, true], [0.11428571428571428, 9, "인", null, true], [0.02857142857142857, 9, "인", null, true], [0.08, 9, "인", null, true], [2.0, 1, "인", null, false], [0.04, 9, "인", null, true], [0.91, 2, "", null, false], [190.0, 0, "", null, false], [0.0016666666666666668, 9, "인", null, true], [40.0, 0, "ea", null, false], [0.025, 9, "인", "용접공", true], [3.0, 0, "", "특별인부", false], [0.075, 9, "인", "특별인부", true], [0.05, 9, "인", "보통인부", true], [200.0, 0, "m", null, false], [10.0, 0, "열", null, false], [4.0, 0, "", "특별인부", false], [0.02, 9, "인", "특별인부", true], [0.01, 9, "인", "보통인부", true], [360.0, 0, "", null, false], [5.2, 1, "", null, false], [0.011111111111111112, 9, "인", "특별인부", true], [0.005555555555555556, 9, "인", "보통인부", true], [0.00055, 5, "인", "특별인부", false], [0.00838, 5, "인", "보통인부", false], [12.0, 0, "분", null, false], [2.0, 0, "대", null, false], [125.0, 0, "", null, false], [33.0, 0, "", null, false], [0.008, 9, "인", null, true], [24.0, 0, "톤", null, false], [1.1, 1, "톤/회 적용", null, false]] \ No newline at end of file diff --git a/main.py b/main.py index e9f2b698c..cb2670172 100644 --- a/main.py +++ b/main.py @@ -13,6 +13,7 @@ import asyncio import logging import os import time +import threading from contextlib import asynccontextmanager from pathlib import Path from typing import Any @@ -97,6 +98,7 @@ from E03_StabSection.E03_StabSection_Router_Prob import router as e03_stab_prob_ from E06_StabRock.E06_StabRock_Router import router as e06_stab_rock_router from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router from M02_MasterTemplete.M02_Hopyo_Router import router as m02_hopyo_router +from M02_MasterTemplete import M02_San_Basis_Index as san_basis_index from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router from M02_MasterTemplete.M02_MasterTemplete_Router_Combos import router as m02_combos_router @@ -281,6 +283,8 @@ async def lifespan(app: FastAPI): cleanup_task = asyncio.create_task(cleanup_expired_sessions()) resource_task = asyncio.create_task(sample_resources_loop()) temp_cleanup_task = asyncio.create_task(cleanup_expired_temp_uploads_loop()) + # 793-3a 근거 제안 M01 값 역색인 — 뒤에서 한 번 지어 둠(M01 지문 바뀌면 다음 부름에서 다시) + threading.Thread(target=san_basis_index.warm, daemon=True).start() yield diff --git a/resources/tester/test_793_3a_san_basis.py b/resources/tester/test_793_3a_san_basis.py new file mode 100644 index 000000000..df521c8aa --- /dev/null +++ b/resources/tester/test_793_3a_san_basis.py @@ -0,0 +1,183 @@ +"""793-3a 지킴 — 근거 제안 엔진(`M02_San_Basis*`) · 실무 여덟 파일 산근 459 블록. + +판정 수 ≥ 분석 648-D2 합친 길(기초 짚음 260 · 옵션 전부 52 · 품 검산 맞음 141 · 없음 94) · +손 확인 견본(합판거푸집 넷 · 철근 Type-Ⅱ 가공 + 조립 · 레미콘 소량감률 50 · 터파기 #51 · +야면석소운반 GC001140 석재류 · 불량 · 큰돌쌓기 ÷10 근접) · 지어 넣은 값 0 · 보정 자료 = 여덟 파일 · +저장본 길(임시 폴더). 엑셀 = `helper_m02_hopyo_golden.FILES`(읽기만 · 없으면 skip) · +STmate 숨은 G 식 = 올림 길과 같은 `M02_San_Basis_Store.hidden`. +""" + +from __future__ import annotations + +import json +import time +from collections import Counter +from functools import lru_cache + +import helper_m02_hopyo_golden as golden +import pytest + +from M02_MasterTemplete import M02_Hopyo_Judge as judge +from M02_MasterTemplete import M02_Hopyo_Store as hopyo_store +from M02_MasterTemplete import M02_San_Basis as sb +from M02_MasterTemplete import M02_San_Basis_Const as bc +from M02_MasterTemplete import M02_San_Basis_Index as bi +from M02_MasterTemplete import M02_San_Basis_Store as store +from M02_MasterTemplete import M02_San_Book as san_book + +WAYS = { + "B1", + "B2", + "B3", + "B4", + "B5", + "B6", + "V", + "검산", + "검산(같은 품 여럿)", + "근접", + "근접(같은 품 여럿)", +} + + +@lru_cache(maxsize=1) +def books() -> list[tuple]: + """[(파일, HP 꼴 책 + 숨은식)] — 없는 파일이면 skip.""" + out = [] + for tag, path in golden.FILES.items(): + book = golden.read(tag) if path.exists() else None + if book is None: + pytest.skip(f"실무 엑셀 없음 {tag}") + sheets = san_book.sheets(path.name, path.read_bytes()) if book["꼴"] == "STmate" else {} + out.append((tag, {**book, "숨은식": store.hidden(book, sheets)})) + return out + + +@lru_cache(maxsize=1) +def results() -> tuple[dict, float]: + sb.ctx() + t0 = time.time() + got = {} + for tag, book in books(): + for blk in book["단산"]: + got[f"{tag}#{blk['번']}"] = store.one(book, int(blk["번"])) + return got, time.time() - t0 + + +def test_counts_at_least_d2(): + got, sec = results() + kind = Counter(r["꼴"] for r in got.values()) + tier = Counter(r["단"] for r in got.values()) + same = sum( + 1 + for r in got.values() + if r["꼴"].startswith("확정") + and "다름" not in (st := [sb.checked(s) for s in sb.chosen_units(r)]) + and "같음" in st + ) + print(f"\n블록 {len(got)} · 꼴 {dict(kind)} · 단 {dict(tier)} · 품 같음 {same} · {sec:.1f} 초") + assert len(got) == 459 + assert sum(v for k, v in kind.items() if k.startswith("확정")) >= 260 + assert kind["확정·전부"] >= 52 + assert same >= 141 + assert tier["없음"] <= 94 and kind["없음"] <= 94 + + +def _first(bid: str) -> dict: + return results()[0][bid]["근거후보"] + + +def test_plywood_four_same(): + for bid in ("북부청임도#24", "북부청사방#66", "거창#41", "동부청영월#1841"): + (c,) = _first(bid) + assert (c["기초"], c["단"], c["품검산"]) == ("GC000211", "확정", "같음"), bid + assert {c["옵션"][k] for k in ("사용횟수", "유형")} == {"4회", "보통"}, bid + assert {"높이감률", "구멍땜"} <= set(c["입력필요"]), bid # 글에 없는 감률 = 입력 필요 + + +def test_rebar_type2_cut_and_join(): + got = {c["기초"]: c for c in _first("북부청임도#72")} + assert set(got) == {"GC000206", "GC000207"} + assert got["GC000206"]["옵션"]["Type"] == got["GC000207"]["옵션"]["Type"] == "Type-Ⅱ" + assert all(c["단"] == "확정" for c in got.values()) + + +def test_remicon_small_50(): + (c,) = _first("북부청임도#21") + assert c["기초"] == "GC000198" and c["단"] == "확정" + assert c["옵션"]["소량감률"] == 50 and c["옵션길"]["소량감률"] == "B5" + + +def test_excavation_51(): + (c,) = _first("북부청임도#51") + assert (c["기초"], c["단"], c["옵션"]["Type"], c["옵션"]["깊이"]) == ( + "GC000089", + "확정", + "TypeⅢ", + "5m 이하", + ) + + +def test_rubble_carry_gc001140(): + r = results()[0]["동부청영월#1466"] + c = r["근거후보"][0] + assert r["단"] == "사람" and c["기초"] == "GC001140" + assert c["옵션"] == {"재료갈래": "석재류", "운반로": "불량"} + assert set(c["옵션길"].values()) == {"V"} + + +def test_big_stone_near_unit_basis(): + r = results()[0]["북부청임도#32"] + (c,) = r["근거후보"] + assert (r["단"], c["기초"], c["단"], c["품검산"]) == ("근접", "GF000226", "근접", "다름") + assert {t for t, _ in c["까닭"]} == {"단위 기준 차이"} and "÷10" in c["까닭"][0][1] + + +def test_no_made_up_values(): + """옵션마다 출처 꼬리표 · 입력 필요 · 0 간주 칸은 옵션에 없음 · + 없음 단은 근거후보 없이 보강 까닭만.""" + for bid, r in results()[0].items(): + for c in r["근거후보"]: + assert set(c["옵션"]) <= set(c.get("옵션길") or {}), bid + assert set((c.get("옵션길") or {}).values()) <= WAYS, bid + assert not set(c["옵션"]) & set(c["입력필요"]), bid + assert all(v is not None for v in c["옵션"].values()), bid + assert c["단"] in ("확정", "근접", "사람") + if r["단"] == "없음": + assert r["근거후보"] == [] and r["보강"]["까닭"], bid + + +def test_corpus_is_eight_files(): + """보정 자료 = 여덟 파일 상수(일반 뺌) — 뽑기 규칙을 바꾸면 다시 지어 넣음.""" + preps = [ + bc.prep(blk, book["꼴"], (book["숨은식"] or {}).get(str(blk["번"]))) + for _, book in books() + for blk in book["단산"] + ] + shipped = json.loads(bi.CORPUS.read_text(encoding="utf-8")) + assert sorted(map(json.dumps, bc.corpus(preps))) == sorted(map(json.dumps, shipped)) + + +def test_store_round_trip(tmp_path): + """임시 폴더 HP → 뒤 셈 저장본 · 목록 · 블록 하나 · 끝내기.""" + _, book = books()[0] + small = {**book, "단산": [b for b in book["단산"] if b["번"] in (21, 51)]} + row = hopyo_store.save(tmp_path, "시험.xls", small, {"id": 0, "이름": "시험"}) + key = row["키"] + assert store.solved(tmp_path, key) is None + got = store.solve_all(tmp_path, key) + assert set(got) == {"21", "51"} and store.solved(tmp_path, key) == json.loads(json.dumps(got)) + assert store.listing(tmp_path, key)["셈중"] is False + assert store.block(tmp_path, key, 51)["근거후보"][0]["기초"] == "GC000089" + with pytest.raises(Exception): + store.block(tmp_path, key, 999) + store.forget(tmp_path, key) + assert store.solved(tmp_path, key) is None + judge.forget(tmp_path, key) + + +def test_hidden_g_lines(): + book = {"꼴": "STmate", "단산": [{"번": "3", "행": [2, 4]}]} + rows = [[None] * 7, [None] * 6 + ["_x0007_L=20"], [None] * 6 + ["_x0007_"], [None] * 6 + ["x"]] + assert store.hidden(book, {store.SHEET: rows}) == {"3": ["L=20"]} + assert store.hidden({"꼴": "엑셀꼴", "단산": []}, {}) == {}