feat(830-2): 재료 DB 항목 통합 · 관급 칸 값 비움 · 상하차 칸 — 빌더 = 정본
- 재료_자재품목 24,092 → 17,744 줄(지운 키 6,348 · 이력 안 남김 · 대장에서 뺌 · 「다음」 30719 그대로) - 통합 규칙 한 모듈 scripts/material_merge.py — 지역 · 관급/사급 꼬리표 · 제조사(-거양) · 조합 · 상하차 글을 뗀 이름 · 규격 · 단위 · 상하차가 같은 줄 한 줄 - 남는 줄 = 사급 줄 가운데 전국 › 지역 글 없음(전국으로 봄) › 서울 › 값 중간 지역 · 관급 줄만 묶음 = 앞(작은 키) 줄 · 원래 겹친 줄(덧정보 없음)은 안 합침 - 관급 칸(EST 가격정보) 값 4,552 → 0 · 칸은 남김 · 비고 「조달 …」 · 「가격정보 …」 글 뺌 - 상하차 칸(요구 828) = 상차 · 하차 · -(기본) · 관급 칸 바로 앞(자리 = material_merge.LOAD_AT 한 곳) · 도착도 = 하차 · 채취장상차 · 하치장/생산공장 상차도 · (상차도) = 상차 · 철근 HD 규격 정규화 안 함 - 남는 줄 물가지 넷 · 면수 · 출처 · 단위 · 구분 바뀐 것 0 · 빌더 두 번 돌림 = 같은 파일 - check_master 열 목록에 상하차 · 값 검사(셋 가운데 하나) · 새 오류 0(단위 27 · 배 7 전과 같음) - _틀.md · _갱신절차.md 열 한 벌 · 관급 칸 · 상하차 · 통합 한 줄씩 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -31,7 +31,7 @@
|
|||||||
| 출처 | 물가지 네 열 — 상용 적산 자료(ESTX) · `원가계산/다산소프트/2026-07-main.estx` / 나라장터 가격정보 — `원가계산/자재단가/나라장터_시설공통자재_*.json` |
|
| 출처 | 물가지 네 열 — 상용 적산 자료(ESTX) · `원가계산/다산소프트/2026-07-main.estx` / 나라장터 가격정보 — `원가계산/자재단가/나라장터_시설공통자재_*.json` |
|
||||||
| 받는 법 | ESTX = 구매 파일(배포 전 판권 구매 뒤 갱신) · 나라장터 = `collect_cost_sources.py values` (API) · 유료 물가지 직접 구독은 미정 |
|
| 받는 법 | ESTX = 구매 파일(배포 전 판권 구매 뒤 갱신) · 나라장터 = `collect_cost_sources.py values` (API) · 유료 물가지 직접 구독은 미정 |
|
||||||
| 주기 | ESTX 반기 · 나라장터 연 2회+수시 |
|
| 주기 | ESTX 반기 · 나라장터 연 2회+수시 |
|
||||||
| 빌더 | `scripts/build_재료_자재품목.py` → `scripts/build_자재품목_분류.py`(구분·상세구분 채움 · `ref/_묶음_자재품목.md` 새로 씀) |
|
| 빌더 | `scripts/build_재료_자재품목.py`(항목 통합 `scripts/material_merge.py` 포함) → `scripts/build_자재품목_분류.py`(구분·상세구분 채움 · `ref/_묶음_자재품목.md` 새로 씀) |
|
||||||
| 검사 | `scripts/compare_estx.py 자재` · `pytest resources/tester/test_자재품목_분류.py -q` · `check_master.py 틀 재료_자재품목.json` |
|
| 검사 | `scripts/compare_estx.py 자재` · `pytest resources/tester/test_자재품목_분류.py -q` · `check_master.py 틀 재료_자재품목.json` |
|
||||||
|
|
||||||
- 나라장터 스냅샷에서 마스터 줄을 뽑는 빌더 없음 — 구멍.
|
- 나라장터 스냅샷에서 마스터 줄을 뽑는 빌더 없음 — 구멍.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -109,9 +109,11 @@
|
|||||||
|
|
||||||
**마스터는 값을 고르는 곳이 아니라 프로젝트에 쓸 데이터를 모으는 곳** — 최저가·견적 고르기는 프로젝트 몫.
|
**마스터는 값을 고르는 곳이 아니라 프로젝트에 쓸 데이터를 모으는 곳** — 최저가·견적 고르기는 프로젝트 몫.
|
||||||
|
|
||||||
- `재료_자재품목` = 자재 테이블 하나 · **열 한 벌** — 키 · 구분 · 상세구분 · 이름 · 규격 · 단위 · 물가자료 · 유통물가 · 물가정보 · 거래가격 · 관급 · 출처 · 비고 · 면수 · 모든 줄이 모든 칸(없으면 `null`).
|
- `재료_자재품목` = 자재 테이블 하나 · **열 한 벌** — 키 · 구분 · 상세구분 · 이름 · 규격 · 단위 · 물가자료 · 유통물가 · 물가정보 · 거래가격 · 상하차 · 관급 · 출처 · 비고 · 면수 · 모든 줄이 모든 칸(없으면 `null`).
|
||||||
- 값 열 다섯 = 물가지 넷(물가자료 · 유통물가 · 물가정보 · 거래가격) · `관급`(조달) — 채택값·기본가·견적가 두지 않음.
|
- 값 열 다섯 = 물가지 넷(물가자료 · 유통물가 · 물가정보 · 거래가격) · `관급` — 채택값·기본가·견적가 두지 않음.
|
||||||
- `관급` = 원문 「가격정보」 칸의 조달청 관급 단가(철근 · 레미콘 · 아스콘 · H형강 등) · 기간은 `비고` 「조달 26.7」 · 「조달 ~29.3.18」.
|
- `관급` = 자리만 둠 · 값 없음(원문 「가격정보」 값은 넣지 않음).
|
||||||
|
- `상하차` = 상차 · 하차 · `-`(기본 · 모름) 가운데 하나 · 값이 아닌 표지 · 자리 = `관급` 바로 앞(`scripts/material_merge.py` `LOAD_AT`) · 이름 · 규격의 인도 조건 글(도착도 = 하차 · 채취장상차 · 하치장 상차도 = 상차)은 이 칸으로.
|
||||||
|
- 항목 통합 = 지역 · 관급/사급 · 제조사 · 조합 · 상하차 덧정보를 뗀 이름 · 규격 · 단위 · 상하차가 같은 줄은 한 줄(`scripts/material_merge.py`) · 뗀 말은 `비고` 「통합: …」.
|
||||||
- 그 밖의 줄에 남은 「가격정보」 값은 출처가 조달인지 물가지인지 원문이 안 가름 — 어느 값 열에도 넣지 않고 `비고` 「가격정보 1,485 · 2026.7」 로만(67 줄).
|
- 그 밖의 줄에 남은 「가격정보」 값은 출처가 조달인지 물가지인지 원문이 안 가름 — 어느 값 열에도 넣지 않고 `비고` 「가격정보 1,485 · 2026.7」 로만(67 줄).
|
||||||
- `구분` · `상세구분` = 원문(ESTX)에 대·중분류 칸이 없어 품명 글자 규칙으로 채움(`scripts/build_자재품목_분류.py`) · 묶음 목록은 `ref/_묶음_자재품목.md` · 못 가른 줄은 「미분류」.
|
- `구분` · `상세구분` = 원문(ESTX)에 대·중분류 칸이 없어 품명 글자 규칙으로 채움(`scripts/build_자재품목_분류.py`) · 묶음 목록은 `ref/_묶음_자재품목.md` · 못 가른 줄은 「미분류」.
|
||||||
- `면수` = 물가지 값의 원문 면수 묶음 `{ 물가자료, 유통물가, 물가정보, 거래가격 }` · 없으면 `null` · 화면에는 접어 둠.
|
- `면수` = 물가지 값의 원문 면수 묶음 `{ 물가자료, 유통물가, 물가정보, 거래가격 }` · 없으면 `null` · 화면에는 접어 둠.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
`scripts/build_자재품목_분류.py` 가 만든 표 — 손으로 고치지 않음.
|
`scripts/build_자재품목_분류.py` 가 만든 표 — 손으로 고치지 않음.
|
||||||
|
|
||||||
- 구분 24 · 상세구분 91 · 미분류 25 줄 (전체 24092 줄)
|
- 구분 24 · 상세구분 91 · 미분류 25 줄 (전체 17744 줄)
|
||||||
|
|
||||||
## 규칙
|
## 규칙
|
||||||
|
|
||||||
@@ -14,61 +14,49 @@
|
|||||||
|
|
||||||
## 구분별 상세구분 (줄 수)
|
## 구분별 상세구분 (줄 수)
|
||||||
|
|
||||||
### 관·이음관 (5399)
|
### 관·이음관 (5205)
|
||||||
|
|
||||||
PE관 1154 · 이음쇠·부속 1003 · 주철관 828 · 강관 706 · PVC관 461 · 파형강관 417 · 유리섬유·FRP관 398 · 흄관 222 · 동관·스텐관 133 · 단열이중관 77
|
PE관 1141 · 이음쇠·부속 1003 · 주철관 794 · 강관 706 · 파형강관 417 · 유리섬유·FRP관 398 · PVC관 340 · 흄관 196 · 동관·스텐관 133 · 단열이중관 77
|
||||||
|
|
||||||
### 식생·종자 (3456)
|
### 식생·종자 (3452)
|
||||||
|
|
||||||
수목 2933 · 초본·야생화 138 · 수목지주·보호 115 · 종자 105 · 잔디·지피 97 · 농약·약제 42 · 비료·토양개량 26
|
수목 2929 · 초본·야생화 138 · 수목지주·보호 115 · 종자 105 · 잔디·지피 97 · 농약·약제 42 · 비료·토양개량 26
|
||||||
|
|
||||||
### 레미콘 (3203)
|
### 볼트·앵커·철물 (963)
|
||||||
|
|
||||||
레미콘 3203
|
볼트·너트 526 · 와이어로프·클램프 193 · 앵커 189 · 철근커플러 38 · 못 17
|
||||||
|
|
||||||
### 철근·강재 (1754)
|
|
||||||
|
|
||||||
형강 717 · 철근 497 · 강판 262 · 구조용강관 132 · 철선 93 · 철망·와이어메쉬 53
|
|
||||||
|
|
||||||
### 아스콘·포장 (1695)
|
|
||||||
|
|
||||||
아스콘 1479 · 포장블록 173 · 특수포장 30 · 아스팔트 13
|
|
||||||
|
|
||||||
### 볼트·앵커·철물 (1002)
|
|
||||||
|
|
||||||
볼트·너트 526 · 와이어로프·클램프 208 · 앵커 189 · 못 41 · 철근커플러 38
|
|
||||||
|
|
||||||
### 전기·통신 (902)
|
### 전기·통신 (902)
|
||||||
|
|
||||||
케이블·전선 320 · 판넬·차단기 164 · 전선관·트레이 139 · 조명·전등 133 · 제어·계측기기 89 · 부속·기타 57
|
케이블·전선 320 · 판넬·차단기 164 · 전선관·트레이 139 · 조명·전등 133 · 제어·계측기기 89 · 부속·기타 57
|
||||||
|
|
||||||
|
### 철근·강재 (828)
|
||||||
|
|
||||||
|
형강 319 · 강판 262 · 구조용강관 132 · 철근 67 · 철선 29 · 철망·와이어메쉬 19
|
||||||
|
|
||||||
### 밸브·수도자재 (761)
|
### 밸브·수도자재 (761)
|
||||||
|
|
||||||
밸브 575 · 수도계량기·유량계 101 · 스프링클러·호스 51 · 펌프 34
|
밸브 575 · 수도계량기·유량계 101 · 스프링클러·호스 51 · 펌프 34
|
||||||
|
|
||||||
### 운송·용역 (677)
|
### 운송·용역 (664)
|
||||||
|
|
||||||
화물운임 552 · 시험·검사 62 · 폐기물 61 · 상하차 2
|
화물운임 552 · 폐기물 61 · 시험·검사 49 · 상하차 2
|
||||||
|
|
||||||
### 기계·설비 (627)
|
### 기계·설비 (627)
|
||||||
|
|
||||||
기계류 267 · 보일러·공조 173 · 놀이·운동·시설물 109 · 측정·시험기기 68 · 소방·가스 10
|
기계류 267 · 보일러·공조 173 · 놀이·운동·시설물 109 · 측정·시험기기 68 · 소방·가스 10
|
||||||
|
|
||||||
### 맨홀·수로·그레이팅 (603)
|
|
||||||
|
|
||||||
수로관 283 · 그레이팅 169 · 맨홀 151
|
|
||||||
|
|
||||||
### 시멘트·골재 (588)
|
|
||||||
|
|
||||||
골재 328 · 모르타르 189 · 시멘트 71
|
|
||||||
|
|
||||||
### 도료·방수·접착 (572)
|
### 도료·방수·접착 (572)
|
||||||
|
|
||||||
도료 258 · 접착·혼화 174 · 방수·실링 140
|
도료 258 · 접착·혼화 174 · 방수·실링 140
|
||||||
|
|
||||||
### 콘크리트제품 (494)
|
### 맨홀·수로·그레이팅 (481)
|
||||||
|
|
||||||
말뚝·파일 223 · 경계석·블록 172 · PC구조물 99
|
그레이팅 169 · 수로관 161 · 맨홀 151
|
||||||
|
|
||||||
|
### 콘크리트제품 (478)
|
||||||
|
|
||||||
|
말뚝·파일 223 · 경계석·블록 156 · PC구조물 99
|
||||||
|
|
||||||
### 건축마감 (454)
|
### 건축마감 (454)
|
||||||
|
|
||||||
@@ -82,29 +70,41 @@ PE관 1154 · 이음쇠·부속 1003 · 주철관 828 · 강관 706 · PVC관 46
|
|||||||
|
|
||||||
소모품 235 · 용접·가스 36 · 고철·기타금속 31 · 화약 13 · 용수 2
|
소모품 235 · 용접·가스 36 · 고철·기타금속 31 · 화약 13 · 용수 2
|
||||||
|
|
||||||
|
### 아스콘·포장 (291)
|
||||||
|
|
||||||
|
아스콘 131 · 포장블록 117 · 특수포장 30 · 아스팔트 13
|
||||||
|
|
||||||
### 단열·보온 (284)
|
### 단열·보온 (284)
|
||||||
|
|
||||||
단열재 284
|
단열재 284
|
||||||
|
|
||||||
### 목재 (246)
|
### 매트·토목섬유 (228)
|
||||||
|
|
||||||
각재·판재 168 · 합판 78
|
부직포·토목섬유 113 · 돌망태·사방 61 · 매트 54
|
||||||
|
|
||||||
### 매트·토목섬유 (230)
|
### 시멘트·골재 (219)
|
||||||
|
|
||||||
부직포·토목섬유 113 · 돌망태·사방 63 · 매트 54
|
모르타르 141 · 골재 55 · 시멘트 23
|
||||||
|
|
||||||
### 석재 (207)
|
### 석재 (207)
|
||||||
|
|
||||||
조경석·자연석 125 · 경계석 82
|
조경석·자연석 125 · 경계석 82
|
||||||
|
|
||||||
### 교량·구조부속 (133)
|
### 목재 (141)
|
||||||
|
|
||||||
신축이음·받침 75 · 보강재·정착 58
|
각재·판재 117 · 합판 24
|
||||||
|
|
||||||
### 거푸집·가설재 (130)
|
### 교량·구조부속 (130)
|
||||||
|
|
||||||
동바리·비계 73 · 거푸집 49 · 가설건축물 8
|
신축이음·받침 75 · 보강재·정착 55
|
||||||
|
|
||||||
|
### 거푸집·가설재 (120)
|
||||||
|
|
||||||
|
동바리·비계 63 · 거푸집 49 · 가설건축물 8
|
||||||
|
|
||||||
|
### 레미콘 (62)
|
||||||
|
|
||||||
|
레미콘 62
|
||||||
|
|
||||||
### 특수장비 (4)
|
### 특수장비 (4)
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
"""상용 적산 자료(ESTX 2026-07) 자재 → 재료_자재품목.json — 개발용 · 배포 전 판권 구매 뒤 갱신.
|
"""상용 적산 자료(ESTX 2026-07) 자재 → 재료_자재품목.json — 개발용 · 배포 전 판권 구매 뒤 갱신.
|
||||||
|
|
||||||
ESTX 자재 표 = StuffcDat(칸 뜻: 다산소프트/ESTX_값사전_자재노무기계.md).
|
ESTX 자재 표 = StuffcDat(칸 뜻: 다산소프트/ESTX_값사전_자재노무기계.md).
|
||||||
열 한 벌 = 키 · 원문번호 · 구분 · 상세구분 · 이름 · 규격 · 단위 · 물가자료 · 유통물가 · 물가정보 · 거래가격 · 관급 · 출처 · 비고 · 면수.
|
열 한 벌 = 키 · 원문번호 · 구분 · 상세구분 · 이름 · 규격 · 단위 · 물가자료 · 유통물가 · 물가정보 · 거래가격 · 상하차 · 관급 · 출처 · 비고 · 면수.
|
||||||
Cost2..5 = 물가지 넷 · Page2..5 = 면수. TC(채택) · Cost6(견적가)는 마스터 아님 — 뺌.
|
Cost2..5 = 물가지 넷 · Page2..5 = 면수. TC(채택) · Cost6(견적가)는 마스터 아님 — 뺌.
|
||||||
품명에 「관급」 인 줄의 Cost1(가격정보) = 조달청 관급 단가 — 「관급」 열 · 기간은 비고 「조달 26.7」.
|
Cost1(가격정보 = 관급 정보)은 넣지 않음 — 「관급」 열은 자리만 두고 비움(요구 827) · 비고에도 안 적음.
|
||||||
그 밖의 줄에 남은 Cost1 은 출처가 조달인지 물가지인지 원문이 안 가름 — 값 열에 넣지 않고 비고 「가격정보 ○○」 로만.
|
항목 통합(요구 819 · 827) = material_merge — 지역 · 관급/사급 · 제조사 · 조합 덧정보를 뗀 이름 · 규격 · 단위가 같은 줄을 한 줄로.
|
||||||
0 · 미저장 = 값 없음(null). 이름·규격은 원문 그대로(띄어쓰기·오자 포함). 코드 없는 빈 줄은 뺌.
|
0 · 미저장 = 값 없음(null). 이름·규격은 원문 그대로(띄어쓰기·오자 포함). 코드 없는 빈 줄은 뺌.
|
||||||
ESTX 에 대·중분류 칸이 없어 구분·상세구분은 build_자재품목_분류.py 가 품명 글자 규칙으로 채움(빌더 끝에서 부름).
|
ESTX 에 대·중분류 칸이 없어 구분·상세구분은 build_자재품목_분류.py 가 품명 글자 규칙으로 채움(빌더 끝에서 부름).
|
||||||
조달청 줄 · 조달청 관급 값은 넣지 않음(요구 822 — EST 그대로) · update_관급단가*.py 는 지금 정본에 적용 안 함.
|
조달청 줄 · 조달청 관급 값은 넣지 않음(요구 822 — EST 그대로) · update_관급단가*.py 는 지금 정본에 적용 안 함.
|
||||||
@@ -18,17 +18,16 @@ ESTX 밖 줄 = 품셈재료에서 옮긴 줄(출처 「품셈재료 …」 · PL
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import random
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).parent))
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
import master_keys as mk # noqa: E402
|
import master_keys as mk # noqa: E402
|
||||||
|
import material_merge as mm # noqa: E402
|
||||||
from _migrate_base import dump_master # noqa: E402
|
from _migrate_base import dump_master # noqa: E402
|
||||||
from compare_estx import ESTX, ROOT, SLOTS, Estx # noqa: E402
|
from compare_estx import ESTX, ROOT, SLOTS, Estx # noqa: E402
|
||||||
|
|
||||||
OUT = ROOT / "resources/master_data/재료_자재품목.json"
|
OUT = ROOT / "resources/master_data/재료_자재품목.json"
|
||||||
BASE = SLOTS[0] # Cost1 「가격정보」 — 관급이면 관급 열 · 아니면 비고
|
|
||||||
MARKET = SLOTS[1:5] # 물가지 넷 · 견적가(Cost6)는 마스터 아님
|
MARKET = SLOTS[1:5] # 물가지 넷 · 견적가(Cost6)는 마스터 아님
|
||||||
# 유류·전력 — 유가·전기요금은 재료_유가전력(유류는 값이 비고 글뿐 · 중유는 받지 않음 · 대장 「폐기」)
|
# 유류·전력 — 유가·전기요금은 재료_유가전력(유류는 값이 비고 글뿐 · 중유는 받지 않음 · 대장 「폐기」)
|
||||||
DROP = {
|
DROP = {
|
||||||
@@ -52,27 +51,16 @@ def num(v):
|
|||||||
return int(v) if float(v).is_integer() else v
|
return int(v) if float(v).is_integer() else v
|
||||||
|
|
||||||
|
|
||||||
def note(base, page, guard: str | None) -> str | None:
|
|
||||||
"""비고 — 관급이면 「조달 <기간>」 · 남은 가격정보는 「가격정보 <값> · <면수>」."""
|
|
||||||
if guard is not None:
|
|
||||||
return f"조달 {str(guard).removeprefix('조달 ')}".strip()
|
|
||||||
if base is None:
|
|
||||||
return None
|
|
||||||
return " · ".join(x for x in (f"가격정보 {base:,}", page) if x)
|
|
||||||
|
|
||||||
|
|
||||||
def build(rows: list[dict]) -> list[dict]:
|
def build(rows: list[dict]) -> list[dict]:
|
||||||
out = []
|
out = []
|
||||||
for r in sorted(rows, key=lambda r: r["Index"]):
|
for r in sorted(rows, key=lambda r: r["Index"]):
|
||||||
code = r.get("Code")
|
code = r.get("Code")
|
||||||
if not code or code in DROP:
|
if not code or code in DROP:
|
||||||
continue
|
continue
|
||||||
base, page = num(r.get("Cost1")), r.get("Page1") or None
|
|
||||||
public = "관급" in r.get("Name", "") and base is not None
|
|
||||||
면수 = {slot: r.get(f"Page{i}") or None for i, slot in enumerate(MARKET, 2)}
|
면수 = {slot: r.get(f"Page{i}") or None for i, slot in enumerate(MARKET, 2)}
|
||||||
비고 = note(base, page, page if public else None)
|
비고 = None
|
||||||
if r.get("Name") in ("VR관", "VR관(관급)") and r.get("Size") in VR_SIZES:
|
if r.get("Name") in ("VR관", "VR관(관급)") and r.get("Size") in VR_SIZES:
|
||||||
비고 = " · ".join(x for x in (비고, VR_NOTE) if x)
|
비고 = VR_NOTE
|
||||||
out.append(
|
out.append(
|
||||||
{
|
{
|
||||||
"열쇠": code,
|
"열쇠": code,
|
||||||
@@ -82,7 +70,7 @@ def build(rows: list[dict]) -> list[dict]:
|
|||||||
"규격": r.get("Size", ""),
|
"규격": r.get("Size", ""),
|
||||||
"단위": r.get("Unite", ""),
|
"단위": r.get("Unite", ""),
|
||||||
**{slot: num(r.get(f"Cost{i}")) for i, slot in enumerate(MARKET, 2)},
|
**{slot: num(r.get(f"Cost{i}")) for i, slot in enumerate(MARKET, 2)},
|
||||||
"관급": base if public else None,
|
"관급": None,
|
||||||
"출처": f"자재품목 {code}",
|
"출처": f"자재품목 {code}",
|
||||||
"비고": 비고,
|
"비고": 비고,
|
||||||
"면수": 면수 if any(면수.values()) else None,
|
"면수": 면수 if any(면수.values()) else None,
|
||||||
@@ -115,43 +103,28 @@ def prune() -> int:
|
|||||||
return len(gone)
|
return len(gone)
|
||||||
|
|
||||||
|
|
||||||
def check(rows: list[dict]) -> None:
|
def check(rows: list[dict], gone: int) -> None:
|
||||||
"""원본 대조 — 줄 수 · 값 열마다 합 · 관급·가격정보 가름 · 표본 쉰 줄."""
|
"""원본 대조 — 줄 수(원본 − 통합으로 지운 줄) · 남는 줄 전부 물가지 넷 · 면수가 원본 그 줄 것 · 관급 칸 빔."""
|
||||||
data = json.loads(OUT.read_text(encoding="utf-8"))
|
data = json.loads(OUT.read_text(encoding="utf-8"))
|
||||||
got = {r["열쇠"]: r for r in data["줄"]}
|
|
||||||
raw = {r["Code"]: r for r in rows if r.get("Code") and r["Code"] not in DROP}
|
raw = {r["Code"]: r for r in rows if r.get("Code") and r["Code"] not in DROP}
|
||||||
got = {k: r for k, r in got.items() if not str(r["출처"]).startswith(CARRY)}
|
got = [r for r in data["줄"] if not str(r["출처"]).startswith(CARRY)]
|
||||||
assert len(got) == len(raw), (len(got), len(raw))
|
assert len(got) == len(raw) - gone, (len(got), len(raw), gone)
|
||||||
print(
|
print(
|
||||||
f"줄 {len(got)} = 원본 코드 있는 줄 {len(raw)}"
|
f"줄 {len(got)} = 원본 코드 있는 줄 {len(raw)} − 통합으로 지운 줄 {gone} (유류 {len(DROP)} 뺌)"
|
||||||
f" (코드 없는 빈 줄 {sum(1 for r in rows if not r.get('Code'))} · 유류 {len(DROP)} 뺌)"
|
|
||||||
)
|
)
|
||||||
for i, slot in enumerate((*MARKET, "관급"), 2):
|
for g in got:
|
||||||
a = math.fsum(r.get(f"Cost{i}") or 0 for r in raw.values())
|
r = raw[g["출처"].removeprefix("자재품목 ")]
|
||||||
if slot == "관급": # Cost1 가운데 관급 줄만
|
|
||||||
a = math.fsum(r.get("Cost1") or 0 for r in raw.values() if "관급" in r.get("Name", ""))
|
|
||||||
b = math.fsum(r[slot] or 0 for r in got.values())
|
|
||||||
n = sum(1 for r in got.values() if r[slot] is not None)
|
|
||||||
assert abs(a - b) < 1e-3, (slot, a, b)
|
|
||||||
print(f" {slot}: 값 {n}줄 · 합 {b:,.1f} = 원본 {a:,.1f}")
|
|
||||||
left = [r for r in got.values() if str(r["비고"] or "").startswith("가격정보")]
|
|
||||||
print(
|
|
||||||
f" 비고에만 남긴 가격정보 {len(left)}줄 · 관급 기간 비고 {sum(1 for r in got.values() if str(r['비고'] or '').startswith('조달'))}줄"
|
|
||||||
)
|
|
||||||
random.seed(20260719)
|
|
||||||
for code in random.sample(sorted(raw), 50):
|
|
||||||
r, g = raw[code], got[code]
|
|
||||||
assert (g["이름"], g["규격"], g["단위"]) == (
|
|
||||||
r.get("Name", ""),
|
|
||||||
r.get("Size", ""),
|
|
||||||
r.get("Unite", ""),
|
|
||||||
)
|
|
||||||
public = "관급" in r.get("Name", "")
|
|
||||||
assert (g["관급"] or 0) == ((r.get("Cost1") or 0) if public else 0), code
|
|
||||||
for i, slot in enumerate(MARKET, 2):
|
for i, slot in enumerate(MARKET, 2):
|
||||||
assert (g[slot] or 0) == (r.get(f"Cost{i}") or 0), (code, slot)
|
assert (g[slot] or 0) == (r.get(f"Cost{i}") or 0), (g["출처"], slot)
|
||||||
assert (g["면수"] or {}).get(slot) == (r.get(f"Page{i}") or None), (code, slot)
|
assert (g["면수"] or {}).get(slot) == (r.get(f"Page{i}") or None), (g["출처"], slot)
|
||||||
print(" 표본 50줄 이름·규격·단위·값 5·면수 4 같음")
|
assert all(r["관급"] is None for r in data["줄"])
|
||||||
|
assert all(r[mm.LOAD] in mm.LOADS for r in data["줄"])
|
||||||
|
for i, slot in enumerate(MARKET, 2):
|
||||||
|
n = sum(1 for r in got if r[slot] is not None)
|
||||||
|
print(f" {slot}: 값 {n}줄 · 합 {math.fsum(r[slot] or 0 for r in got):,.1f}")
|
||||||
|
print(" 남는 줄 전부 물가지 넷 · 면수 = 원본 그 줄 · 관급 칸 빔")
|
||||||
|
load = {v: sum(1 for r in data["줄"] if r[mm.LOAD] == v) for v in mm.LOADS}
|
||||||
|
print(f" {mm.LOAD}:", " · ".join(f"{k} {n}" for k, n in load.items()))
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -169,20 +142,23 @@ def main():
|
|||||||
],
|
],
|
||||||
"비고": [
|
"비고": [
|
||||||
"다산소프트 EST 2026-07 자재 · 개발용 · 배포 전 판권 구매",
|
"다산소프트 EST 2026-07 자재 · 개발용 · 배포 전 판권 구매",
|
||||||
"값 열 다섯 = 물가지 넷(물가자료 · 유통물가 · 물가정보 · 거래가격) · 관급(조달) — 채택값·기본가 없음 · 쓰는 곳이 값을 고르고 출처를 밝힘",
|
"값 열 다섯 = 물가지 넷(물가자료 · 유통물가 · 물가정보 · 거래가격) · 관급 — 채택값·기본가 없음 · 쓰는 곳이 값을 고르고 출처를 밝힘",
|
||||||
"유류·전력은 뺌 — 유가·전기요금은 재료_유가전력(유류는 값이 비고 글뿐 · 중유는 받지 않음)",
|
"유류·전력은 뺌 — 유가·전기요금은 재료_유가전력(유류는 값이 비고 글뿐 · 중유는 받지 않음)",
|
||||||
"원문번호(다산소프트 품번) 칸 없음 — 대조는 _키대장.json 에만",
|
"원문번호(다산소프트 품번) 칸 없음 — 대조는 _키대장.json 에만",
|
||||||
"관급 = 원문 「가격정보」 칸의 조달청 관급 단가(철근 · 레미콘 · 아스콘 · H형강 등) · 기간은 비고 「조달 26.7」",
|
"관급 칸 = 자리만 둠 · 값 없음 — 원문 「가격정보」(관급 정보) 값은 넣지 않음(요구 827)",
|
||||||
|
"상하차 = 상차 · 하차 · -(기본 · 모름) — 이름 · 규격의 인도 조건 글(도착도 = 하차 · 채취장상차 · 하치장 상차도 = 상차)을 떼어 이 칸으로(요구 828) · 값이 아닌 표지",
|
||||||
|
"항목 통합 = 지역 · 관급/사급 · 제조사 · 조합 덧정보를 뗀 이름 · 규격 · 단위가 같은 줄을 한 줄로(전국 › 서울 › 값 중간 지역) · 뗀 말은 비고 「통합: …」 (scripts/material_merge.py · 요구 819 · 827)",
|
||||||
"조달청 줄 · 조달청 관급 값은 넣지 않음 — EST 그대로(요구 822)",
|
"조달청 줄 · 조달청 관급 값은 넣지 않음 — EST 그대로(요구 822)",
|
||||||
"품셈재료에서 옮긴 줄(출처 「품셈재료 …」 · 값 없음)은 ESTX 밖 — 지금 정본에서 이어 받음",
|
"품셈재료에서 옮긴 줄(출처 「품셈재료 …」 · 값 없음)은 ESTX 밖 — 지금 정본에서 이어 받음",
|
||||||
"원문 「가격정보」 칸에 시중 값이 남은 줄은 값 열에 넣지 않고 비고 「가격정보 ○○」 로만 둠 — 출처가 조달인지 물가지인지 원문이 안 가름",
|
|
||||||
"구분 · 상세구분 = 품명 글자 규칙으로 채움(scripts/build_자재품목_분류.py · 묶음 목록 ref/_묶음_자재품목.md) · 조달청 줄은 조달청 품명을 따름 · 못 가린 줄은 「미분류」",
|
"구분 · 상세구분 = 품명 글자 규칙으로 채움(scripts/build_자재품목_분류.py · 묶음 목록 ref/_묶음_자재품목.md) · 조달청 줄은 조달청 품명을 따름 · 못 가린 줄은 「미분류」",
|
||||||
"면수 = 물가지 값의 원문 면수 · 화면에는 접어 둠",
|
"면수 = 물가지 값의 원문 면수 · 화면에는 접어 둠",
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
extra = carried()
|
extra = carried()
|
||||||
dump_master(OUT, head, build(rows) + extra)
|
merged, log = mm.merge(build(rows) + extra) # 원문 차례 = 키 차례
|
||||||
check(rows)
|
dump_master(OUT, head, merged)
|
||||||
|
check(rows, sum(1 for x in log for o in x["지운"] if not str(o["출처"]).startswith(CARRY)))
|
||||||
|
print("항목 통합 묶음", len(log), "· 지운 줄", sum(len(x["지운"]) for x in log))
|
||||||
print("ESTX 밖 줄 이어 받음", len(extra))
|
print("ESTX 밖 줄 이어 받음", len(extra))
|
||||||
print("새 틀로 받음", mk.adopt()) # 열쇠 → 키 — 분류가 「키」 줄을 읽으므로 먼저
|
print("새 틀로 받음", mk.adopt()) # 열쇠 → 키 — 분류가 「키」 줄을 읽으므로 먼저
|
||||||
# adopt 의 keep_text 가 구분 · 상세구분을 한 줄에 두 번 씀 — 읽어서(뒤 값) 다시 씀
|
# adopt 의 keep_text 가 구분 · 상세구분을 한 줄에 두 번 씀 — 읽어서(뒤 값) 다시 씀
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import master_combo as mcb # noqa: E402
|
|||||||
import master_formula as mf # noqa: E402
|
import master_formula as mf # noqa: E402
|
||||||
import master_keys as mk # noqa: E402
|
import master_keys as mk # noqa: E402
|
||||||
import master_machine_files as mmf # noqa: E402
|
import master_machine_files as mmf # noqa: E402
|
||||||
|
import material_merge as mm # noqa: E402
|
||||||
|
|
||||||
# (2) 본문은 따로 모듈 — 부르는 쪽은 `cm.check_body` · `cm.DIVISIONS` 그대로
|
# (2) 본문은 따로 모듈 — 부르는 쪽은 `cm.check_body` · `cm.DIVISIONS` 그대로
|
||||||
from check_master_body import DIVISIONS, check_body # noqa: E402, F401
|
from check_master_body import DIVISIONS, check_body # noqa: E402, F401
|
||||||
@@ -119,21 +120,29 @@ LABOR_STATUS = ("공표", "산정", "미공표", "미확보", "추정")
|
|||||||
# 원문별 열 한 벌 — 모든 줄이 이 칸을 이 차례로 가짐(없으면 null)
|
# 원문별 열 한 벌 — 모든 줄이 이 칸을 이 차례로 가짐(없으면 null)
|
||||||
COLS = {
|
COLS = {
|
||||||
# 자재품목 — 원문번호(남의 품번)는 줄에 두지 않음 · 대조는 `_키대장.json` 에만
|
# 자재품목 — 원문번호(남의 품번)는 줄에 두지 않음 · 대조는 `_키대장.json` 에만
|
||||||
"자재품목": (
|
# 상하차 칸 자리 = material_merge.LOAD_AT 바로 앞(한 곳)
|
||||||
"키",
|
"자재품목": tuple(
|
||||||
"구분",
|
mm.place(
|
||||||
"상세구분",
|
dict.fromkeys(
|
||||||
"이름",
|
(
|
||||||
"규격",
|
"키",
|
||||||
"단위",
|
"구분",
|
||||||
"물가자료",
|
"상세구분",
|
||||||
"유통물가",
|
"이름",
|
||||||
"물가정보",
|
"규격",
|
||||||
"거래가격",
|
"단위",
|
||||||
"관급",
|
"물가자료",
|
||||||
"출처",
|
"유통물가",
|
||||||
"비고",
|
"물가정보",
|
||||||
"면수",
|
"거래가격",
|
||||||
|
"관급",
|
||||||
|
"출처",
|
||||||
|
"비고",
|
||||||
|
"면수",
|
||||||
|
)
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
),
|
),
|
||||||
"유가전력": (
|
"유가전력": (
|
||||||
"키",
|
"키",
|
||||||
@@ -315,6 +324,8 @@ def check_form(name: str, data: dict) -> list[str]:
|
|||||||
out.append(f"{where} · 「{ALIAS}」 이 빈 글 · 글 아님(빈 칸이면 칸을 두지 않음)")
|
out.append(f"{where} · 「{ALIAS}」 이 빈 글 · 글 아님(빈 칸이면 칸을 두지 않음)")
|
||||||
if cols and _cols_of(row) != cols:
|
if cols and _cols_of(row) != cols:
|
||||||
out.append(f"{where} · 열이 한 벌이 아님 — 「{' · '.join(cols)}」")
|
out.append(f"{where} · 열이 한 벌이 아님 — 「{' · '.join(cols)}」")
|
||||||
|
if book == "자재품목" and row.get(mm.LOAD) not in mm.LOADS:
|
||||||
|
out.append(f"{where} · 「{mm.LOAD}」 는 {' · '.join(mm.LOADS)} 가운데 하나")
|
||||||
if kind == "품셈재료":
|
if kind == "품셈재료":
|
||||||
out += _check_linked(where, row)
|
out += _check_linked(where, row)
|
||||||
if kind == "요소" and isinstance(row.get("값"), dict):
|
if kind == "요소" and isinstance(row.get("값"), dict):
|
||||||
|
|||||||
@@ -0,0 +1,251 @@
|
|||||||
|
"""재료 자재품목 항목 통합(요구 819 · 827 · 828 · PLAN 830-2) — build_재료_자재품목 이 부름.
|
||||||
|
시험 = test_830_2_material_merge.
|
||||||
|
|
||||||
|
묶음 = 이름에서 지역 · 관급/사급 꼬리표 · 제조사 꼬리(「-거양」) · 조합 이름 · 상하차 글을 뺀
|
||||||
|
이름 · 규격 · 단위 · 상하차가 같은 줄.
|
||||||
|
규격 = 앞말 「조합공통품목,」 · 규격 속 지역 괄호 뗌
|
||||||
|
단위 = M=m · m²=㎡ · Ton=톤 · ㎏=kg 표기 하나로 · 비교는 공백을 뺀 글
|
||||||
|
상하차 칸(요구 828) = 상차 · 하차 · -(기본 · 모름) — 이름 · 규격의 인도 조건 글을 떼어 이 칸으로
|
||||||
|
「도착도」 = 하차 · 「채취장상차(도)」 · 「하치장 · 생산공장 상차도」 · 「(상차도)」 = 상차
|
||||||
|
남는 줄 = 사급(관급 아닌) 줄 가운데 전국 › 지역 글 없음(전국으로 봄 · 브) › 서울 › 서울 권역 글
|
||||||
|
› 값이 중간인 지역 줄(브 · 짝수면 아래 중간)
|
||||||
|
· 남길 줄의 값 칸이 전부 비고 다른 줄에 값이 있으면 다음 차례 줄(브)
|
||||||
|
· 관급 줄만 있는 묶음 = 가장 작은 키 한 줄(브) · 나머지 줄은 지움(이력 안 남김)
|
||||||
|
덧정보가 하나도 없는 묶음(원래부터 겹친 줄 — 식생 · 종자 등)은 합치지 않음.
|
||||||
|
관급 칸 값은 모두 비움(EST 「가격정보」 — 요구 827) · 이름에서 뗀 말은 비고 「통합: …」 로.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
MARKET = ("물가자료", "유통물가", "물가정보", "거래가격")
|
||||||
|
LOAD = "상하차" # 표지 칸(값 칸 아님)
|
||||||
|
LOADS = ("-", "상차", "하차") # 첫째 = 기본(모름)
|
||||||
|
LOAD_AT = "관급" # 상하차 칸 자리 = 이 칸 바로 앞 — 자리는 여기 한 곳
|
||||||
|
CITY = set(
|
||||||
|
"""서울 부산 대구 인천 광주 대전 울산 세종 제주 청주 전주 포항 창원 춘천 충주 강릉
|
||||||
|
천안 원주 여수 구미 순천 수원 목포 금산 영천 논산 삼척 홍성 익산 청도 양양 남원
|
||||||
|
공주 군산 김천 가평 용인 포천 경주 아산 제천 여주 보령 부여 당진 청원 음성 서산
|
||||||
|
영동 진천 예산 장성 고흥 고창 광양 고령 성주 영주 안동 칠곡 상주 문경 합천 화성
|
||||||
|
경산 전국""".split()
|
||||||
|
)
|
||||||
|
SIDO = "서울|부산|대구|인천|광주|대전|울산|세종|제주|경기|강원|충북|충남|전북|전남|경북|경남|충청|전라|경상"
|
||||||
|
_CITY = "|".join(sorted(CITY, key=len, reverse=True))
|
||||||
|
UNIT = {"M": "m", "m²": "㎡", "Ton": "톤", "ton": "톤", "㎏": "kg"}
|
||||||
|
SUPPLY = ( # (꼬리표 글, 공급) — 이름 끝 · 괄호 꼴
|
||||||
|
(r"\(관급\)", "관급"),
|
||||||
|
(r"[-/ ]관급$", "관급"),
|
||||||
|
(r"(?<=\))관급$", "관급"),
|
||||||
|
(r",관급(?=\))", "관급"),
|
||||||
|
(r"\(사급\)", "사급"),
|
||||||
|
(r"-사급$", "사급"),
|
||||||
|
(r",사급$", "사급"),
|
||||||
|
)
|
||||||
|
NAME_LOAD = re.compile(r",\s*(도착도|채취장상차도|채취장상차)$") # 모 래, 서울, 도착도
|
||||||
|
SPEC_LOAD = re.compile(
|
||||||
|
r"\s*(?:\((상차도)\)|((?:하치장|생산공장)\s*상차도))$"
|
||||||
|
) # HD=10mm 하치장 상차도
|
||||||
|
COOP = re.compile(r"^조합공통품목(?:\(([^)]*)\))?,?\s*")
|
||||||
|
|
||||||
|
|
||||||
|
def load_of(text: str) -> str:
|
||||||
|
"""인도 조건 글 → 상하차 값 — 도착 = 하차 · 상차 = 상차."""
|
||||||
|
return "하차" if text.startswith("도착") else "상차"
|
||||||
|
|
||||||
|
|
||||||
|
def place(row: dict, load: str) -> dict:
|
||||||
|
"""상하차 칸을 LOAD_AT 바로 앞에 둔 줄."""
|
||||||
|
out = {}
|
||||||
|
for k, v in row.items():
|
||||||
|
if k == LOAD:
|
||||||
|
continue
|
||||||
|
if k == LOAD_AT:
|
||||||
|
out[LOAD] = load
|
||||||
|
out[k] = v
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def ws(s: str | None) -> str:
|
||||||
|
return re.sub(r"\s+", " ", s or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def nows(s: str) -> str:
|
||||||
|
return re.sub(r"\s+", "", s)
|
||||||
|
|
||||||
|
|
||||||
|
def _region(name: str, spec: str):
|
||||||
|
"""(지역 글, 지역을 뗀 이름, 지역을 뗀 규격) — 지역 꼴 일곱(조사 818)."""
|
||||||
|
m = re.match(r"^(.*?)\(([^()]+)\)-사급$", name) # 레미콘(서울)-사급
|
||||||
|
if m and m[2] in CITY:
|
||||||
|
return m[2], m[1] + "-사급", spec
|
||||||
|
m = re.match(r"^(.*?)\((SD\d+),([^()]+)\)-관급$", name) # 이형철근(SD400,제주)-관급
|
||||||
|
if m and m[3] in CITY:
|
||||||
|
return m[3], f"{m[1]}({m[2]})-관급", spec
|
||||||
|
m = re.match(r"^(.*?\(관급\))-(.+)$", name) # 레미콘(관급)-강원도(태백시)
|
||||||
|
if m:
|
||||||
|
tail = m[2]
|
||||||
|
head = re.sub(r"[(\s,].*", "", tail)
|
||||||
|
if re.match(rf"^({SIDO})", head) or head in CITY or "광역시" in head or head.endswith("도"):
|
||||||
|
return tail, m[1], spec
|
||||||
|
return None, name, spec
|
||||||
|
m = re.match(r"^(.*?),\s*([가-힣]+)\s*\(관급\)$", name)
|
||||||
|
if m and m[2] in CITY:
|
||||||
|
return m[2], m[1] + "(관급)", spec
|
||||||
|
if "(관급)" in name or name.endswith("-관급") or "-사급" in name:
|
||||||
|
return None, name, spec
|
||||||
|
parts = [p.strip() for p in name.split(",")] # 합판(내수), 서울 · 모 래, 여수, 도착도
|
||||||
|
hit = [i for i, p in enumerate(parts) if i > 0 and p in CITY]
|
||||||
|
if hit:
|
||||||
|
return parts[hit[0]], ", ".join(p for j, p in enumerate(parts) if j != hit[0]), spec
|
||||||
|
m = re.search(
|
||||||
|
rf"\((?:([가-힣]+)·)?({_CITY}|충남)(지역)?(,관급)?\)", name
|
||||||
|
) # H 형강(전국) · 모래(건조상태·전국)
|
||||||
|
if m:
|
||||||
|
keep = f"({m[1]})" if m[1] else ""
|
||||||
|
rest = "(관급)" if m[4] else ""
|
||||||
|
return m[2], name[: m.start()] + keep + rest + name[m.end() :], spec
|
||||||
|
m = re.search(rf"\s?\(({_CITY})(지역)?\)", spec) # 콘크리트근가 0.7M(제주지역)
|
||||||
|
if m:
|
||||||
|
return m[1], name, spec[: m.start()] + spec[m.end() :]
|
||||||
|
if spec in CITY: # 품질시험 현장출장비 · 포항
|
||||||
|
return spec, name, ""
|
||||||
|
return None, name, spec
|
||||||
|
|
||||||
|
|
||||||
|
def parse(row: dict) -> dict:
|
||||||
|
"""줄 → 덧정보를 뗀 이름 · 규격 · 상하차 · 묶음 열쇠 · 뗀 말."""
|
||||||
|
name, spec = row.get("이름") or "", row.get("규격") or ""
|
||||||
|
words = []
|
||||||
|
region, base, spec2 = _region(name, spec)
|
||||||
|
m = re.search(r"-거양$", base)
|
||||||
|
if m:
|
||||||
|
base = base[: m.start()]
|
||||||
|
words.append("제조사 거양")
|
||||||
|
supply = ""
|
||||||
|
for pat, kind in SUPPLY:
|
||||||
|
if re.search(pat, base):
|
||||||
|
supply = supply or kind
|
||||||
|
base = re.sub(pat, "", base)
|
||||||
|
if supply:
|
||||||
|
words.append(supply)
|
||||||
|
load = row.get(LOAD) or LOADS[0]
|
||||||
|
m = NAME_LOAD.search(base)
|
||||||
|
if m:
|
||||||
|
load = load_of(m[1])
|
||||||
|
words.append(m[1])
|
||||||
|
base = base[: m.start()]
|
||||||
|
base = re.sub(r"[\s,]+$", "", base)
|
||||||
|
m = COOP.match(spec2)
|
||||||
|
if m:
|
||||||
|
spec2 = spec2[m.end() :]
|
||||||
|
words.append(f"조합 {m[1]}" if m[1] else "조합공통품목")
|
||||||
|
m = SPEC_LOAD.search(spec2)
|
||||||
|
if m:
|
||||||
|
load = load_of(m[1] or m[2])
|
||||||
|
words.append(m[1] or m[2])
|
||||||
|
spec2 = spec2[: m.start()]
|
||||||
|
spec2 = spec2.strip()
|
||||||
|
unit = ws(row.get("단위"))
|
||||||
|
tagged = bool(region or words)
|
||||||
|
return {
|
||||||
|
"이름": base if tagged else name,
|
||||||
|
"규격": spec2 if tagged else spec,
|
||||||
|
LOAD: load,
|
||||||
|
"지역": region or "",
|
||||||
|
"공급": supply,
|
||||||
|
"뗀 말": words,
|
||||||
|
"덧정보": tagged,
|
||||||
|
"열쇠": (nows(base), nows(spec2), UNIT.get(unit, unit), load),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def price(row: dict):
|
||||||
|
v = [row[s] for s in MARKET if row.get(s) is not None]
|
||||||
|
return min(v) if v else None
|
||||||
|
|
||||||
|
|
||||||
|
def _rank(region: str) -> int:
|
||||||
|
if region == "전국":
|
||||||
|
return 0
|
||||||
|
if not region:
|
||||||
|
return 1
|
||||||
|
if region == "서울":
|
||||||
|
return 2
|
||||||
|
if region.startswith("서울"):
|
||||||
|
return 3
|
||||||
|
return 4
|
||||||
|
|
||||||
|
|
||||||
|
def _order(pool: list) -> list:
|
||||||
|
"""남길 차례 — 전국 › 지역 없음 › 서울 › 서울 권역 › 값이 중간인 지역 줄부터 가까운 차례 · 같으면 앞 줄(작은 키)."""
|
||||||
|
top = sorted(
|
||||||
|
(p for p in pool if p[1]["지역"] and _rank(p[1]["지역"]) == 4),
|
||||||
|
key=lambda p: (price(p[0]) is None, price(p[0]) or 0),
|
||||||
|
)
|
||||||
|
if top:
|
||||||
|
mid = (sum(1 for p in top if price(p[0]) is not None) - 1) // 2
|
||||||
|
mid = max(mid, 0)
|
||||||
|
top = sorted(top, key=lambda p: (abs(top.index(p) - mid), top.index(p)))
|
||||||
|
rest = sorted((p for p in pool if _rank(p[1]["지역"]) < 4), key=lambda p: _rank(p[1]["지역"]))
|
||||||
|
return rest + top
|
||||||
|
|
||||||
|
|
||||||
|
def merge(rows: list[dict]) -> tuple[list[dict], list[dict]]:
|
||||||
|
"""통합한 줄(관급 칸 비움) · 기록(덧정보를 뗀 묶음마다 남은 줄 · 지운 줄 · 기준 지역 · 「브」 규칙).
|
||||||
|
줄 차례 = 키 차례(빌더가 원문 차례로 짓고 대장 키도 그 차례) — 같은 순위 · 관급만 묶음은 앞 줄을 남김."""
|
||||||
|
groups: dict = {}
|
||||||
|
for r in rows:
|
||||||
|
p = parse(r)
|
||||||
|
groups.setdefault(p["열쇠"], []).append((r, p))
|
||||||
|
new, log = {}, []
|
||||||
|
for g in groups.values():
|
||||||
|
if not any(p["덧정보"] for _, p in g):
|
||||||
|
continue
|
||||||
|
sup = [x for x in g if x[1]["공급"] != "관급"]
|
||||||
|
rules = []
|
||||||
|
if sup:
|
||||||
|
order = _order(sup)
|
||||||
|
best = next((x for x in order if price(x[0]) is not None), order[0])
|
||||||
|
region = best[1]["지역"]
|
||||||
|
if _rank(region) == 1 and len(g) > 1:
|
||||||
|
rules.append("지역 없음 = 전국")
|
||||||
|
if _rank(region) == 4 and len({x[1]["지역"] for x in sup}) > 1:
|
||||||
|
rules.append("값 중간 지역")
|
||||||
|
if best is not order[0]:
|
||||||
|
rules.append("값 빈 줄 건너뜀")
|
||||||
|
else:
|
||||||
|
best = g[0]
|
||||||
|
region = ""
|
||||||
|
if len(g) > 1:
|
||||||
|
rules.append("관급만 = 작은 키")
|
||||||
|
words = list(dict.fromkeys(w for _, p in g for w in p["뗀 말"]))
|
||||||
|
regions = sorted({p["지역"] for _, p in g if p["지역"]})
|
||||||
|
bits = [f"기준 지역 {region or '없음'}" if sup else "관급 줄만"]
|
||||||
|
if len(g) > 1:
|
||||||
|
bits.append(f"{len(g)}줄 합침")
|
||||||
|
if len(regions) > 1 or (regions and region != regions[0]):
|
||||||
|
bits.append(f"지역 {len(regions)}곳")
|
||||||
|
if words:
|
||||||
|
bits.append("뗀 말 " + ", ".join(words))
|
||||||
|
r, info = best
|
||||||
|
note = "통합: " + " · ".join(bits)
|
||||||
|
new[id(r)] = place(
|
||||||
|
{
|
||||||
|
**r,
|
||||||
|
"이름": info["이름"],
|
||||||
|
"규격": info["규격"],
|
||||||
|
"비고": " · ".join(x for x in (r.get("비고"), note) if x),
|
||||||
|
},
|
||||||
|
info[LOAD],
|
||||||
|
)
|
||||||
|
gone = [o for o, _ in g if o is not r]
|
||||||
|
for o in gone:
|
||||||
|
new[id(o)] = None
|
||||||
|
log.append(
|
||||||
|
{"남은": new[id(r)], "원래": r, "지운": gone, "기준 지역": region, "규칙": rules}
|
||||||
|
)
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
r = new.get(id(r), place(r, r.get(LOAD) or LOADS[0]))
|
||||||
|
if r is not None:
|
||||||
|
out.append({**r, "관급": None})
|
||||||
|
return out, log
|
||||||
+17748
-24095
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user