diff --git a/resources/master_data/_틀.md b/resources/master_data/_틀.md index 3ac69d64..5235db6f 100644 --- a/resources/master_data/_틀.md +++ b/resources/master_data/_틀.md @@ -10,56 +10,87 @@ ## 2. 파일 -- 이름 = `그룹_원문[_장].json` — 날짜 없음 · 마스터는 항상 최신. 예: `인력_건설노임.json` · `소요량_산림품셈_13장.json` · `로직_산림품셈_13장.json` +- 날짜 없음 · 마스터는 항상 최신. UTF-8 · LF · 들여쓰기 2칸 · 칸 이름은 한글. - 그룹 = 인력 · 재료 · 기계 · 소요량 · 계수 · 환율 · 요율 · 로직 -- 원문 이름 = 산림품셈 · 건설품셈 · 건설노임 · 제조노임 · 엔지니어링노임 · 측량노임 · 건설사업관리노임 · SW노임 · 산림노임 · 준용대상 · 나라장터자재 · 오피넷유가 · 시중물가 · 한국은행환율 · 조달청제비율 · 품셈재료 · 자체 -- UTF-8 · LF · 들여쓰기 2칸 · 칸 이름은 한글. -## 3. 출처 +| 그룹 | 파일 이름 | +|---|---| +| 인력 | `인력.json` 하나 — 줄마다 `조사` | +| 기계 | `기계.json` 하나 — 줄마다 `세부분류` | +| 재료 | `재료_<원문>.json` — 시중물가 · 나라장터자재 · 오피넷유가 · 품셈재료 | +| 환율 · 요율 | `환율_한국은행환율.json` · `요율_조달청제비율.json` | +| 소요량 · 계수 · 로직 | `<그룹>_<원문>_장_<장 제목>.json` — 예 `소요량_건설품셈_10장_창호_및_유리공사.json` | + +- 장 파일 — 장 번호는 두 자리 · 장 제목은 원문 본문 폴더 이름 그대로 · 부문은 이름에 넣지 않음. +- 장 파일 머리 — `부문`(건설품셈만 · 공통 · 토목 · 건축 · 기계설비 · 유지관리) · `차례`(그룹 안 목록 차례: 산림 1~14장 → 건설 공통 → 토목 → 건축 → 기계설비 → 유지관리). +- 원문 이름 = 산림품셈 · 건설품셈 · 건설노임 · 제조노임 · 엔지니어링노임 · 측량노임 · 건설사업관리노임 · SW노임 · 산림노임 · 준용대상 · 나라장터자재 · 오피넷유가 · 시중물가 · 한국은행환율 · 조달청제비율 · 품셈재료 · 자체 + +## 3. 키 + +- 모든 요소 줄 · 표 · 로직에 `키` = 테이블ID(영문 대문자 2) + 6자리 일련번호 — `LB000123`. 특수문자·한글 없음. +- 한 번 준 키는 안 바뀜 · 지운 줄의 키는 다시 쓰지 않음. 키는 전체에서 하나. +- 원문이 준 번호·옛 열쇠(직종 `1018` · 기계 `0201-0070` · `13-4-1 메쌓기` · 시중물가 코드)는 `원문번호` 칸 — 같은 테이블 안에서 하나. +- 대장 `_키대장.json` = 키 ↔ 원문번호 ↔ 파일 · 테이블별 `다음` 번호. 새 줄은 대장의 다음 번호를 받음(M01 저장 · 빌더). + +| ID | 테이블 | ID | 테이블 | +|---|---|---|---| +| LB | 인력 | QF · QC | 소요량 산림 · 건설 | +| MT | 재료 시중물가 | CF · CC | 계수 산림 · 건설 | +| MN | 재료 나라장터 | FX | 환율 | +| MO | 재료 유가 | RT | 요율 | +| MP | 재료 품셈재료 | GF · GC · GX | 로직 산림 · 건설 · 자체 | +| EQ | 기계 | | | + +- 빌더는 옛 모양(`열쇠` · 원문별 파일)으로 쓰고 끝에 `scripts/master_keys.py` 의 `adopt` 를 부름 — 키·원문번호·한 테이블·장 파일 이름으로 받음. 옛 이름으로 읽기는 `old_view`. + +## 4. 출처 - 한 칸 · `원문 이름 + 번호` — 「산림품셈 13-4-1」 · 「건설품셈 공통 8-4-1」(건설은 부문 필수) · 「건설노임 1018」 · 「자체」 - [주] 는 뒤에 붙임 — 「산림품셈 13-4-4 주⑩」 -- 판(기준일)은 파일 머리에 한 번. +- 판(기준일)은 파일 머리에 한 번 · 인력·기계는 갈래 묶음 안에. -## 4. 요소 파일 +## 5. 요소 파일 ```json { "그룹": "인력", - "원문": "건설노임", - "판": "2026-01-01", + "조사": { + "건설업": { "원문": "건설노임", "판": "2026-01-01", "비고": ["…"] } + }, "줄": [ - { "열쇠": "1018", "이름": "포설공", "규격": "", "단위": "원/일", "값": 0, "출처": "건설노임 1018" } + { "키": "LB000018", "원문번호": "1018", "조사": "건설업", "이름": "포설공", "규격": "", "단위": "원/일", "값": 0, "출처": "건설노임 1018" } ] } ``` -- `열쇠` = 원문이 준 번호(직종 번호 · 물품 번호 · 기계 분류번호 `0201-0070`). 없으면 이름. +- 인력 `조사` = 건설업 · 제조업 · 엔지니어링 · 측량 · 건설사업관리 · SW · 산림 · 준용대상 · 조사별 원문·판·비고는 머리 `조사` 묶음. +- 기계 `세부분류` = 건설품셈 · 산림품셈 · 세부분류별 원문·판·주는 머리 `세부분류` 묶음. - 값이 여럿인 요소(기계)는 `값` 을 묶음으로 — `{ "가격": …, "상각비계수": …, "정비비계수": …, "관리비계수": …, "손료계수": …, "주연료": …, "주연료종류": …, "잡재료율": …, "조종원": …, "조종원직종": … }` · `손료계수` = 원문 「계」. - 묶음 요소는 `단위` · `출처` 도 같은 모양의 묶음. - 원문에 없는 값을 지어 넣지 않음. 원문 「-」 = 칸을 비움(`null`). -- `준용` = 값 없는 요소에 관리자가 고르는 유사 요소 열쇠(같은 원문 열쇠 또는 `그룹:원문:열쇠`) — 준용 값을 미리 채우지 않음. 공표 노임에 이름이 온전히 같은 직종이 없으면 `인력_준용대상.json`. -- `통합` = 건설노임 원문 「통합·명칭변경 직종」 목록의 새 직종 `그룹:원문:열쇠` · 갈리면 null + `후보`. `후보` = 비슷한 이름 글(값 안 씀). +- `준용` = 값 없는 요소에 관리자가 고르는 유사 요소 키 — 준용 값을 미리 채우지 않음. 공표 노임에 이름이 온전히 같은 직종이 없으면 조사 「준용대상」. +- `통합` = 건설노임 원문 「통합·명칭변경 직종」 목록의 새 직종 키 · 갈리면 null + `후보`. `후보` = 비슷한 이름 글(「키 이름」 · 값 안 씀). - `산정` = 건설노임 미공표 직종의 원문 4-라 값 `{ 값, 근거, 마지막공표[, 비고] }` — 원 미만 처리 안 함. - 엔진 차례 = 값 → 준용 → 통합 → 산정 · 호표 출처에 「준용: <이름>」 · 「통합: <이름>」 · 「산정: …」. -- 원문 이름 대신 번호로 적은 값(`조종원직종` = 노임 번호 「1048」)은 원문 이름을 `비고` 칸에. +- 원문 이름 대신 번호로 적은 값(`조종원직종` = 노임 원문번호 「1048」)은 원문 이름을 `비고` 칸에. 재료 값: - `재료_시중물가` `값` = `{ "시중": { 가격정보 · 물가자료 · 유통물가 · 물가정보 · 거래가격, "면수": {…} }, "조달": { "값", "출처", "기준" } }` — 기본가·채택값 · 견적가 두지 않음. 조달 값이 없으면 비움. -- 조달 `출처` = 「가격정보」(시중물가 원천의 조달청 관급 단가 · `기준` = 원천 면수 글) · 「나라장터:<열쇠>」(값 안 베낌 · 이름·규격·단위가 온전히 같은 짝만 · 애매하면 사람이 화면에서 이음). +- 조달 `출처` = 「가격정보」(시중물가 원천의 조달청 관급 단가 · `기준` = 원천 면수 글) · 「나라장터:<키>」(값 안 베낌 · 이름·규격·단위가 온전히 같은 짝만 · 애매하면 사람이 화면에서 이음). - `재료_나라장터자재` = 조달 자료 — 머리 `자료` 에 밝힘. -- `재료_품셈재료` = 품셈이 부르는 재료 목록 — `{ 열쇠, 이름, 규격, 단위, 요구절: [출처 모양], 연결 }` · 값 칸 없음. -- `연결` = 시중물가 열쇠 하나 · 후보 조건 `{ 이름, 규격, 지역: "입력" }`(지역이 갈리는 레미콘·골재·아스콘) · `null`(아직 못 이음). +- `재료_품셈재료` = 품셈이 부르는 재료 목록 — `{ 키, 원문번호, 이름, 규격, 단위, 요구절: [출처 모양], 연결 }` · 값 칸 없음. +- `연결` = 시중물가 키 하나 · 후보 조건 `{ 이름, 규격, 지역: "입력" }`(지역이 갈리는 레미콘·골재·아스콘) · `null`(아직 못 이음). - 단가 = 연결된 줄의 시중·조달 값 가운데 낮은 값 · 호표 결과 줄에 `출처`(그 값의 칸). 후보 조건 = 품명 뿌리가 `이름` 으로 끝남 · 이름에 로직 입력 `자재지역` 글자 · 단위 같음 · 규격이 둘 이상 갈리면 멈춤 「후보 여럿」 · 그 지역 줄이 없으면 가까운 시군을 고르지 않고 멈춤 「지역 값 없음」. -## 5. 표형 요소 (소요량 · 계수) +## 6. 표형 요소 (소요량 · 계수) 원문 표 하나 = 표 하나. 머리가 여러 단이어도 **조건 칸 + 값 칸의 긴 줄**로 폄. 표들은 파일 머리 아래 `표` 칸에 목록으로 둠. ```json { - "열쇠": "13-4-1 메쌓기", + "키": "QF000041", + "원문번호": "13-4-1 메쌓기", "이름": "메쌓기(인력)", "기준": "1㎡", "출처": "산림품셈 13-4-1", @@ -72,8 +103,7 @@ } ``` -- 건설품셈 표 · 로직의 열쇠 앞에 부문 — 「건축 1-1-1 계수」 · 「공통 8 기계 시간당 경비」. -- 열쇠는 같은 그룹 · 원문 안에서 파일 사이에도 하나. +- 건설품셈 표 · 로직의 원문번호 앞에 부문 — 「건축 1-1-1 계수」 · 「공통 8 기계 시간당 경비」. - 조건 종류 — `수`(같은 값) · `고르기`(글자) · `범위`(줄에 `[아래, 위]` · 끝 열림은 `null` · 포함 여부는 `범위규칙` 에 「초과·이하」처럼). - `범위규칙` 첫 마디 = 엔진이 읽는 이름 — 「초과·이하」 · 「이상·미만」 · 「이하」(위 끝만) · 「이상·이하」(양 끝 포함). 뒤 마디(` · ` 뒤)는 원문 설명. 「이하」 · 「이상·이하」는 여러 줄이 맞으면 위 끝이 가장 작은 줄. - 원문 「-」 칸은 줄에서 그 값 칸을 뺌. 줄 전체가 「-」 면 줄을 뺌. @@ -86,15 +116,17 @@ - 수는 원문 그대로(쉼표만 뺌). 단위 환산하지 않음. - `주` 는 원문 글 그대로 — 계산에 쓰는 수가 들어 있으면 따로 계수 표로도 올림. -## 6. 로직 파일 +## 7. 로직 파일 ```json { "그룹": "로직", "원문": "산림품셈", + "차례": 11, "줄": [ { - "열쇠": "13-4-1 메쌓기", + "키": "GF000123", + "원문번호": "13-4-1 메쌓기", "이름": "돌쌓기 메쌓기(인력)", "결과단위": "원/㎡", "출처": "산림품셈 13-4-1", @@ -105,7 +137,7 @@ ], "중간": [{ "이름": "증가율", "식": "…" }], "호표": [ - { "종류": "인력", "요소": "인력:건설노임:…", "단위": "인", "수량": "…", "비목": "노무비" } + { "종류": "인력", "요소": "LB000002", "단위": "인", "수량": "…", "비목": "노무비" } ], "덧줄": [{ "이름": "잡재료비", "식": "노무비 * 0.05", "비목": "재료비", "출처": "…" }], "끝수": "원 미만 버림" @@ -115,7 +147,7 @@ ``` - `호표` 줄 = 실무 일위대가표 한 줄(종류 · 이름·규격 · 단위 · 수량 · 단가 · 금액). 단가는 적지 않음 — `요소` 에서 옴. 금액 = 수량 × 단가. -- `종류` = 인력 · 재료 · 기계 · 로직(하위 로직의 결과를 단가로 씀). +- `종류` = 인력 · 재료 · 기계 · 로직(하위 로직의 결과를 단가로 씀) — 요소 키의 테이블ID 그룹과 같음. - `비목` = 노무비 · 재료비 · 경비. `덧줄` 식에서 비목 합을 이름으로 씀. - `결과단위` 가 돈이 아닌 로직(㎥/시간)은 `호표` 없이 `중간` + `결과` 식. - `종류` 가 로직이고 `비목` 칸이 없는 호표 줄은 불러온 결과를 비목별로 나눠 얹음. @@ -123,19 +155,20 @@ - 입력에 원문 범위가 있으면 `범위: [아래, 위]` · 기본값 두지 않음. - `소유` = 공용 · 개인(뒤 단계 자리). -## 7. 변수 · 식 +## 8. 변수 · 식 -- 요소 — `그룹:원문:열쇠` · 값 묶음은 `.칸` — `기계:건설품셈:0201-0070.주연료` · 둘째 쌍점 뒤는 모두 열쇠(`재료:오피넷유가:경유:전국평균`). -- 열쇠에 `{이름}` — 입력·중간 값을 끼움 — `기계:건설품셈:{기계}.가격` -- 표 찾기 — `찾기(소요량:산림품셈:13-4-1 메쌓기, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공` +- 요소 — 키 `LB000123` · 값 묶음은 `.칸` — `EQ000045.주연료`. +- 입력·중간 값으로 요소를 고르면 `ID:원문번호` 에 `{이름}` 을 끼움 — `EQ:{기계}.가격` · `MO:경유:{지역}` · `LB:{조종원직종}`. +- 표 찾기 — `찾기(QF000041, 뒷길이=뒷길이, 돌=돌, 쌓기=쌓기).석공` - 맞는 줄이 여럿이면 찾는 값 칸이 있는 줄만 봄. 값 칸·조건 이름에 괄호·빈칸이 있으면 작은따옴표 — `.'시공량(m)'` · `'규준틀 높이'=높이` - 찾기·로직 인자의 글자 값은 작은따옴표 — `구분='V(다짐속도,km/hr)'` · 따옴표 없으면 이름(입력·중간). -- 로직 부르기 — `로직(건설품셈:공통 8 굴착기(무한궤도) 시간당 경비, 기계='0201-0070')` · 돈 로직의 결과는 비목 묶음 `.노무비` `.재료비` `.경비` `.계` · 돈 아닌 로직은 수 하나. +- 로직 부르기 — `로직(GC000871, 기계='0201-0070')` · 돈 로직의 결과는 비목 묶음 `.노무비` `.재료비` `.경비` `.계` · 돈 아닌 로직은 수 하나. - 입력 · 중간 · 비목 합은 이름 그대로. - 후보 조건 품셈재료를 쓰는 로직(부르는 로직 포함)은 입력 `자재지역` 을 두고 하위 로직에 `자재지역=자재지역` 으로 넘김 — `지역` 은 유가 고르기. - 쓸 수 있는 것 — `+ - * /` · 괄호 · 비교 `< <= > >= == !=` · `그리고` `또는` · `찾기` · `로직` · `만약(조건, 참, 거짓)` · `올림` `버림` `반올림`(자리) · `최소` `최대`. 그 밖은 쓰지 않음. - 로직이 돌고 도는 참조를 만들지 않음. +- 사람이 읽는 자리(M01 화면 · 검사 알림)는 키 옆에 이름. -## 8. 검사 +## 9. 검사 -- 고친 뒤 `scripts/check_master.py` — 틀 · 본문 대조 · 로직 검사. 로직은 `계산` 으로 손 계산과 맞댐. +- 고친 뒤 `scripts/check_master.py` — 틀 · 본문 대조 · 로직 검사 · 키(모양 · 겹침 · 원문번호 겹침 · 대장 · 끊긴 키). 로직은 `계산` 으로 손 계산과 맞댐. diff --git a/resources/master_data/_화면_계약.md b/resources/master_data/_화면_계약.md index ba10f380..7dfc49f4 100644 --- a/resources/master_data/_화면_계약.md +++ b/resources/master_data/_화면_계약.md @@ -8,32 +8,34 @@ - 봉투 칸 이름은 영문 · 줄·표·로직 알맹이는 파일 그대로(한글 칸). - 수는 JSON 수로 옴. - `version` = 파일 판본(내용 해시). 읽을 때 받아 두고 저장 때 그대로 돌려줌. -- `file` = 파일 이름(`소요량_건설품셈_기계설비7장.json`). 밑줄 파일·하위 폴더는 없음. -- 없는 파일·열쇠 = 404 · 틀린 요청 = 400. +- `file` = 파일 이름(`소요량_건설품셈_07장_위생기구설비공사.json` · `인력.json`). 밑줄 파일·하위 폴더는 없음. +- `key` = 키(`LB000123`) — 전체에서 하나. 줄의 원문 번호는 `원문번호` 칸. +- 없는 파일·키 = 404 · 틀린 요청 = 400. ## 2. 읽기 | 길 | 받음 | 줌 | |---|---|---| | `GET /groups` | | `{groups: [{group, files, rows}]}` — 그룹 차례는 `_틀.md` 2장 | -| `GET /groups/{group}/files` | | `{files: [{file, book, chapter, edition, rows, version}]}` | +| `GET /groups/{group}/files` | | `{files: [{file, book, chapter, edition, rows, version, order}]}` — 머리 `차례` 순 | | `GET /rows` | `file` · `page`(1) · `size`(50) · `q` | `{file, version, total, page, size, rows: [줄…]}` | -| `GET /tables` | `file` · `q` | `{file, version, tables: [{열쇠, 이름, 기준, 출처, 조건, 값칸, count}]}` | +| `GET /tables` | `file` · `q` | `{file, version, tables: [{키, 원문번호, 이름, 기준, 출처, 조건, 값칸, count}]}` | | `GET /table` | `file` · `key` | `{file, version, table: {표 하나 통째}}` | -| `GET /logics` | `book` · `chapter` · `q` · `blocked`(0/1) | `{logics: [{file, book, chapter, 열쇠, 이름, 결과단위, 출처, blocked, reasons}]}` | -| `GET /logic` | `book` · `key` | `{file, version, logic: {로직 한 줄 통째}, blocked, reasons, prices: {요소: 요약 \| null}}` | -| `GET /elements` | `group` · `q` · `limit`(50 · 200까지) | `{total, items: [{ref, file, 이름, 규격, 단위, 값, 값칸}]}` | +| `GET /logics` | `book` · `chapter` · `q` · `blocked`(0/1) | `{logics: [{file, book, chapter, 키, 원문번호, 이름, 결과단위, 출처, blocked, reasons}]}` | +| `GET /logic` | `key`(`book` 은 안 씀) | `{file, version, logic: {로직 한 줄 통째}, blocked, reasons, prices: {요소: 요약 \| null}}` | +| `GET /elements` | `group` · `q` · `limit`(50 · 200까지) | `{total, items: [{ref, file, 원문번호, 이름, 규격, 단위, 값, 값칸}]}` | - `rows` 는 요소·로직 파일은 `줄`, 표형 파일(소요량·계수)은 `표` 를 줄로 봄. -- `q` = 이름 찾기 — `열쇠`·`이름` 에 든 글(대소문자 무시). -- `chapter` = 파일 이름의 장(`공통3장` · `13장` · `01장`) · 없으면 "". +- `q` = 찾기 — `키`·`원문번호`·`이름` 에 든 글(대소문자 무시). +- `chapter` = 「부문 NN장 장 제목」(`공통 03장 토공사` · `13장 구조물`) · 장 파일 아니면 "". - `blocked` = 로직 검사(`check_master.py 로직`)에 걸림 · `reasons` = 걸린 까닭 글. -- `prices` = 호표 `요소` → `{ref, 이름, 규격, 단위, 값}`(단가 자동 칸) · 없는 요소는 `null` · `{이름}` 이 낀 열쇠·하위 로직 줄은 빠짐(계산 때 정해짐). 품셈재료는 `값` = 연결을 따라간 낮은 값 · `출처` = 그 값의 칸(`재료:시중물가:<열쇠>.시중.<칸>` · `….조달` · `재료:나라장터자재:<열쇠>`) · 자재지역 후보는 `값` null(계산 때 정해짐). -- `elements` = 요소 찾기 창 — 그룹 전체에서 `q` 찾기 · `ref` = 식에 넣는 `그룹:원문:열쇠` · 표형 그룹은 `값칸` 이 옴. +- `prices` = 호표 `요소` → `{ref, 이름, 규격, 단위, 값}`(단가 자동 칸) · 없는 요소는 `null` · `{이름}` 이 낀 원문번호·하위 로직 줄은 빠짐(계산 때 정해짐). 품셈재료는 `값` = 연결을 따라간 낮은 값 · `출처` = 그 값의 칸(`<시중물가 키>.시중.<칸>` · `….조달` · `<나라장터자재 키>`) · 자재지역 후보는 `값` null(계산 때 정해짐). +- `elements` = 요소 찾기 창 — 그룹 전체에서 `q` 찾기 · `ref` = 식에 넣는 키 · 표형 그룹은 `값칸` 이 옴. +- `pick` = 고르기 창 — `procure` ref `나라장터:<키>` · `price` ref 키 · `job` = `인력.json` 값 있는 공표 직종(준용대상 빼고) ref 키. ## 3. 시험 계산 -`POST /calc` 받음 `{book, key, inputs: {이름: 값}, row?, file?}` +`POST /calc` 받음 `{key, inputs: {이름: 값}, row?, file?}` — 새 로직은 `key` "" - 됨 — `{ok: true, lines: [{이름, 단위, 수량, 단가, 금액, 비목: {비목: 금액}, 출처?}], sums: {노무비, 재료비, 경비, 계}, middle: {이름: 값}}` · `출처` = 품셈재료 줄의 단가 칸 - 돈 아닌 로직 — `{ok: true, result, middle}` @@ -44,7 +46,7 @@ `POST /save` 받음 `{files: [{file, version, changes: [{op, key, row}]}]}` -- `op` — `edit`(열쇠 `key` 줄을 `row` 로 바꿈) · `add`(`row` 더함 · `key` 안 씀) · `delete`(`key` 줄 지움). +- `op` — `edit`(키 `key` 줄을 `row` 로 바꿈 · 키는 못 바꿈) · `add`(`row` 더함 · 키는 서버가 `_키대장.json` 의 다음 번호로 줌) · `delete`(`key` 줄 지움). - 요소 파일 = `줄` 한 줄 · 표형 파일 = `표` 하나 · 로직 파일 = 로직 한 줄. `row` 는 통째(칸 일부만 보내지 않음). - 화면은 조작을 캐시에 쌓고 [저장] 한 번에 보냄 — 서버는 이때만 파일에 씀. - 순서: 모든 `version` 대조 → 모두 적용(메모리) → 검사 → 모두 씀. 하나라도 걸리면 아무것도 안 씀. @@ -54,9 +56,9 @@ | 200 | 씀 | `{files: [{file, version}]}` — 새 판본 | | 409 | 낡은 화면 — 그 사이 파일이 바뀜 | `{detail: {stale: [file…]}}` → 다시 읽고 다시 고침 | | 422 | 검사 걸림 | `{detail: {errors: [글…]}}` | -| 404 | 없는 파일·열쇠 | `{detail}` | -| 400 | 틀린 `op` · 더할 열쇠가 이미 있음 · 고친 줄의 열쇠가 딴 줄과 겹침 | `{detail}` | +| 404 | 없는 파일·키 | `{detail}` | +| 400 | 틀린 `op` · 고친 줄의 키를 바꿈 | `{detail}` | -- 검사 = `check_master.py` 의 틀(고친 파일) + 로직 변수(모든 로직 파일, 고친 뒤 마스터 기준). **고친 뒤 새로 생긴 걸림**만 막음 — 원래 있던 걸림은 막지 않음. +- 검사 = `check_master.py` 의 틀(고친 파일) + 로직 변수(모든 로직 파일, 고친 뒤 마스터 기준). **고친 뒤 새로 생긴 걸림**만 막음 — 원래 있던 걸림은 막지 않음. 원문번호 겹침(테이블 안)도 걸림. - 요소를 지워 어떤 로직이 가리키던 것이 사라지면 로직 변수 검사에 걸림(422). - 쓰기는 UTF-8 · LF · 들여쓰기 2칸. diff --git a/resources/master_data/scripts/_build_mat_price.py b/resources/master_data/scripts/_build_mat_price.py index 5bb40fc3..1b59856c 100644 --- a/resources/master_data/scripts/_build_mat_price.py +++ b/resources/master_data/scripts/_build_mat_price.py @@ -23,6 +23,7 @@ from __future__ import annotations import json from pathlib import Path +import master_keys as mk from _migrate_base import dump_master ROOT = Path(__file__).resolve().parents[3] @@ -57,7 +58,7 @@ def notice_day(row: dict) -> str: def build() -> dict: raw = json.loads(RAW.read_text(encoding="utf-8")) - master = json.loads(MASTER.read_text(encoding="utf-8")) + master = mk.old_view(MASTER.name) records = master["줄"] history: dict[str, list[dict]] = {} @@ -99,3 +100,4 @@ if __name__ == "__main__": master = result["master"] dump_master(MASTER, {k: v for k, v in master.items() if k != "줄"}, master["줄"]) print(f"고친 줄 {result['repaired']} · 못 고친 줄 {result['unrecovered']}") + print("새 틀로 받음", mk.adopt()) diff --git a/resources/master_data/scripts/_migrate_base.py b/resources/master_data/scripts/_migrate_base.py index 88313003..4f31165e 100644 --- a/resources/master_data/scripts/_migrate_base.py +++ b/resources/master_data/scripts/_migrate_base.py @@ -3,7 +3,7 @@ 돌리기: `./venv/Scripts/python.exe resources/master_data/scripts/_migrate_base.py` 옛 파일은 `old/` 에서 읽기만 함. 새 파일은 `resources/master_data/` 첫 층에 씀. 값은 바꾸지 않음. -`dump_master` 는 `_build_mat_price.py` 도 씀. +`dump_master` 는 `_build_mat_price.py` 도 씀. 한 번 돌린 옮기기 — 지금 틀(키 · 인력.json)은 뒤에 `_migrate_keys.py` 로 옮김. """ from __future__ import annotations diff --git a/resources/master_data/scripts/build_계수_산림품셈_01장_추가.py b/resources/master_data/scripts/build_계수_산림품셈_01장_추가.py index dc34c3c9..3e3a5729 100644 --- a/resources/master_data/scripts/build_계수_산림품셈_01장_추가.py +++ b/resources/master_data/scripts/build_계수_산림품셈_01장_추가.py @@ -14,7 +14,7 @@ import re from pathlib import Path HERE = Path(__file__).resolve().parent -TARGET = HERE.parent / "계수_산림품셈_01장.json" +TARGET = HERE.parent / "계수_산림품셈_01장_적용기준.json" # 한 번 돌림 — 이미 들어감 BODY = HERE.parents[2] / "resources/knowledge/original/원가계산/산림_표준품셈/본문/제01장_적용기준" SRC = "산림품셈 " @@ -599,7 +599,7 @@ def main() -> None: text = TARGET.read_bytes().decode("utf-8") crlf = "\r\n" in text text = text.replace("\r\n", "\n") - if '"열쇠": "1-2-2 ' in text: + if '"원문번호": "1-2-2 ' in text: raise SystemExit("이미 추가됨") A, T = from_1_02(), from_text() front = [ diff --git a/resources/master_data/scripts/build_기계_건설품셈.py b/resources/master_data/scripts/build_기계_건설품셈.py index 3edb8e18..ebf14ee2 100644 --- a/resources/master_data/scripts/build_기계_건설품셈.py +++ b/resources/master_data/scripts/build_기계_건설품셈.py @@ -2,6 +2,7 @@ 본문 md(확정) 표를 읽어 기계 한 줄로 합침. 값은 md 에서만 · 계산·추정 없음. 실행: python resources/master_data/scripts/build_기계_건설품셈.py [--dry] +옛 모양(열쇠)으로 쓰고 `master_keys.adopt` 가 기계.json(키 · 세부분류 건설품셈)으로 받음. """ import json @@ -10,6 +11,9 @@ import sys from pathlib import Path REPO = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(Path(__file__).parent)) +import master_keys as mk # noqa: E402 + DIR = ( REPO / "resources/knowledge/original/원가계산/건설공사_표준품셈/본문/01_공통부문/제08장_건설기계" @@ -653,3 +657,4 @@ if __name__ == "__main__": with open(OUT, "w", encoding="utf8", newline="\n") as f: f.write(text) print("씀", OUT) + print("새 틀로 받음", mk.adopt(OUT.parent)) diff --git a/resources/master_data/scripts/build_소요량_건설품셈_공통2장.py b/resources/master_data/scripts/build_소요량_건설품셈_공통2장.py index 01d8e118..cd492b54 100644 --- a/resources/master_data/scripts/build_소요량_건설품셈_공통2장.py +++ b/resources/master_data/scripts/build_소요량_건설품셈_공통2장.py @@ -12,6 +12,7 @@ import re import sys from pathlib import Path +import master_keys as mk from _계수_공통2장 import lift_table ROOT = Path(__file__).resolve().parents[3] @@ -681,6 +682,7 @@ if __name__ == "__main__": if dup: raise SystemExit(f"열쇠 겹침 {dup}") OUT.write_text(dump(tables), encoding="utf-8", newline="\n") + print("새 틀로 받음", mk.adopt()) # 열쇠 → 키 · 장 파일 이름 for f, (a, b, _) in count.items(): print(f"{f} · 옮긴 표 {a} · 계수 표 {b}") print("합계", sum(a for a, _, _ in count.values()), sum(b for _, b, _ in count.values())) diff --git a/resources/master_data/scripts/build_소요량_건설품셈_공통3장.py b/resources/master_data/scripts/build_소요량_건설품셈_공통3장.py index 49f8da04..d159f0b5 100644 --- a/resources/master_data/scripts/build_소요량_건설품셈_공통3장.py +++ b/resources/master_data/scripts/build_소요량_건설품셈_공통3장.py @@ -12,6 +12,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) +import master_keys as mk # noqa: E402 from _md표_읽기 import ( # noqa: E402 LOSE, PCT, @@ -607,3 +608,4 @@ if __name__ == "__main__": f() write() print(len(TABLES), "표") + print("새 틀로 받음", mk.adopt()) # 열쇠 → 키 · 장 파일 이름 diff --git a/resources/master_data/scripts/build_소요량_건설품셈_기계설비.py b/resources/master_data/scripts/build_소요량_건설품셈_기계설비.py index 45c2ddd8..375ebc50 100644 --- a/resources/master_data/scripts/build_소요량_건설품셈_기계설비.py +++ b/resources/master_data/scripts/build_소요량_건설품셈_기계설비.py @@ -9,6 +9,7 @@ import re import sys import _토목_읽기 as base +import master_keys as mk from _설비_계수 import rows_of from _설비_읽기 import DIV, read_chapter from _설비_표 import convert @@ -58,3 +59,4 @@ if __name__ == "__main__": for w in sys.argv[1:] or ["7", "8", "9", "10", "11", "12", "13"]: o = build(int(w), f"소요량_건설품셈_기계설비{w}장.json") print(w, len(o.tables), "표") + print("새 틀로 받음", mk.adopt()) # 열쇠 → 키 · 장 파일 이름 diff --git a/resources/master_data/scripts/build_소요량_건설품셈_토목.py b/resources/master_data/scripts/build_소요량_건설품셈_토목.py index 1197217c..a9e427a9 100644 --- a/resources/master_data/scripts/build_소요량_건설품셈_토목.py +++ b/resources/master_data/scripts/build_소요량_건설품셈_토목.py @@ -8,6 +8,7 @@ import re import sys +import master_keys as mk from _md표_읽기 import N, R, join from _토목_계수 import lift from _토목_손 import SPECIAL @@ -163,3 +164,4 @@ if __name__ == "__main__": }[w] o = build(ch[0], ch[1], SPECIAL.get(w, {})) print(w, len(o.tables), "표") + print("새 틀로 받음", mk.adopt()) # 열쇠 → 키 · 장 파일 이름 diff --git a/resources/master_data/scripts/build_인력_노임.py b/resources/master_data/scripts/build_인력_노임.py index c6d98447..258ef0c6 100644 --- a/resources/master_data/scripts/build_인력_노임.py +++ b/resources/master_data/scripts/build_인력_노임.py @@ -8,6 +8,7 @@ 이음 = 원문 회차의 전체직종 평균을 그대로 이음(미공표직종_추적.md 4절) · 산정값은 같은 md 5절과 대조. 쓰기: ./venv/Scripts/python.exe resources/master_data/scripts/build_인력_노임.py +옛 모양(원문별 파일 · 열쇠)으로 쓰고 끝에 `master_keys.adopt` 가 인력.json(키 · 조사)으로 받음. """ import hashlib @@ -19,6 +20,7 @@ from decimal import ROUND_HALF_UP, Decimal from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) +import master_keys as mk # noqa: E402 from _migrate_base import dump_master # noqa: E402 ROOT = Path(__file__).resolve().parents[3] @@ -289,14 +291,14 @@ def main(): assert {b: len(v[2]) for b, v in books.items()} == expect # 이름 → ref (새 다섯 + 건설·제조 공표) - wage = json.loads((MD / "인력_건설노임.json").read_text(encoding="utf-8")) - make = json.loads((MD / "인력_제조노임.json").read_text(encoding="utf-8")) + wage = mk.old_view("인력_건설노임.json") + make = mk.old_view("인력_제조노임.json") pool = {r["이름"]: f"인력:{b}:{r['열쇠']}" for b, (_, _, rows) in books.items() for r in rows} old_pool = {r["이름"]: f"인력:{d['원문']}:{r['열쇠']}" for d in (wage, make) for r in d["줄"]} merges = merge_list() alias_path = MD / "인력_준용대상.json" - alias = json.loads(alias_path.read_text(encoding="utf-8")) + alias = mk.old_view(alias_path.name) moved, kept = {}, [] for r in alias["줄"]: name = r["이름"] @@ -318,7 +320,7 @@ def main(): r["후보"] = "비슷한 이름: " + " · ".join(near) kept.append(r) alias["비고"] = ( - "값을 확인 못 한 직종(공표 노임에 이름이 온전히 같은 직종 없음) — 이름·단위만 · 준용 = 관리자가 고르는 공표 직종 열쇠(`인력:원문:열쇠`)" + "값을 확인 못 한 직종(공표 노임에 이름이 온전히 같은 직종 없음) — 이름·단위만 · 준용 = 관리자가 고르는 공표 직종 키(`LB000123`)" " · 통합 = 건설노임 원문 「통합·명칭변경 직종」 목록의 새 직종(엔진이 그 값을 씀) · 후보 = 비슷한 이름(값 안 씀) · 비고 = 이 직종을 요구한 품셈 절" ) head = {k: v for k, v in alias.items() if k != "줄"} @@ -345,6 +347,7 @@ def main(): ) estimate(wage) + print("새 틀로 받음", mk.adopt()) # ── 건설노임 미공표 산정 ───────────────────────────────────────────── diff --git a/resources/master_data/scripts/build_재료_시중물가.py b/resources/master_data/scripts/build_재료_시중물가.py index 25c3ed70..31a382ed 100644 --- a/resources/master_data/scripts/build_재료_시중물가.py +++ b/resources/master_data/scripts/build_재료_시중물가.py @@ -23,6 +23,7 @@ import unicodedata from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) +import master_keys as mk # noqa: E402 from _migrate_base import dump_master # noqa: E402 from compare_estx import ESTX, ROOT, SLOTS, Estx, bare_spec, base_name # noqa: E402 @@ -169,12 +170,13 @@ def main(): "비고": [ "다산소프트 EST 2026-07 자재 · 개발용 · 배포 전 판권 구매", "값 = 시중(물가지 다섯 · 면수) · 조달(값 · 출처 · 기준) — 기본가·채택값 없음 · 쓰는 곳이 값을 고르고 출처를 밝힘", - "조달 출처 「가격정보」 = ESTX 가격정보 칸의 조달청 관급 단가 · 「나라장터:<열쇠>」 = 재료_나라장터자재 줄로 이음(값 안 베낌)", + "조달 출처 「가격정보」 = ESTX 가격정보 칸의 조달청 관급 단가 · 「나라장터:<키>」 = 재료_나라장터자재 줄로 이음(값 안 베낌)", ], } - nara = json.loads(NARA.read_text(encoding="utf-8"))["줄"] + nara = mk.old_view(NARA.name)["줄"] dump_master(OUT, head, build(rows, nara_links(rows, nara))) check(rows) + print("새 틀로 받음", mk.adopt()) # 열쇠 → 키 · 나라장터 연결 → 키 if __name__ == "__main__": diff --git a/resources/master_data/scripts/compare_estx.py b/resources/master_data/scripts/compare_estx.py index cce7e268..48f0ed2b 100644 --- a/resources/master_data/scripts/compare_estx.py +++ b/resources/master_data/scripts/compare_estx.py @@ -122,9 +122,10 @@ def labor(estx: Estx, files: dict): heads = [r["Name"] for r in rows if r.get("Name") and not r.get("Unite")] print("## 노임 — ESTX 머리줄", heads) ours = {} - for fname, tag in (("인력_건설노임.json", "건설"), ("인력_제조노임.json", "제조")): - for r in files[fname]["줄"]: - ours.setdefault((tag, norm(r["이름"])), r) + for survey, tag in (("건설업", "건설"), ("제조업", "제조")): + for r in files["인력.json"]["줄"]: + if r["조사"] == survey: + ours.setdefault((tag, norm(r["이름"])), r) theirs = {} for r in rows: if r.get("Unite") != "인": @@ -230,7 +231,7 @@ def material(estx: Estx, files: dict): for r in files["재료_품셈재료.json"]["줄"]: if r.get("연결"): continue - mine = {base_name(r["이름"]), base_name(r["열쇠"])} + mine = {base_name(r["이름"]), base_name(r["원문번호"])} hits = [e for m in mine for e in by_name.get(m, [])] if hits: have.append((r, hits)) @@ -243,15 +244,15 @@ def material(estx: Estx, files: dict): f"### 재료_품셈재료 못 이음 {len(have) + len(near) + len(none)} — ESTX 품명 같음 {len(have)} · 품명 끝 같음 {len(near)} · 없음 {len(none)}" ) for r, hits in near: - print(f" 비슷 {r['열쇠']} | {sorted({e['Name'] for e in hits})[:4]}") + print(f" 비슷 {r['원문번호']} | {sorted({e['Name'] for e in hits})[:4]}") for r, hits in have: s = bare_spec(r["이름"], r.get("규격")) same = [e for e in hits if s and spec_match(s, bare_spec(e["Name"], e.get("Size")))] h = (same or hits)[0] print( - f" 있음 {r['열쇠']} [{r.get('단위')}] | ESTX {len(hits)}줄 규격같음 {len(same)} | 예 {h['Name']} {h.get('Size')} {h.get('Unite')} {h['TC']:.0f}" + f" 있음 {r['원문번호']} [{r.get('단위')}] | ESTX {len(hits)}줄 규격같음 {len(same)} | 예 {h['Name']} {h.get('Size')} {h.get('Unite')} {h['TC']:.0f}" ) - print(" 없음", [r["열쇠"] for r, _ in none]) + print(" 없음", [r["원문번호"] for r, _ in none]) # ── (3) 기계경비 ────────────────────────────────────────────────────── @@ -284,7 +285,7 @@ def mach_key(name, size) -> str: def machine_cost(master: mf.Master, mid: str, region="전국평균"): """로직으로 계산 — 고르기 밖 기계는 메모리에서만 고르기에 더함.""" for key in LOGICS: - row = master.logic("건설품셈:" + key) + row = master.logic("GC:" + key) spec = next(i for i in row["입력"] if i["이름"] == "기계") added = mid not in spec["고르기"] if added: @@ -292,7 +293,7 @@ def machine_cost(master: mf.Master, mid: str, region="전국평균"): try: given = {"기계": mid, "지역": region} given = {k: v for k, v in given.items() if any(i["이름"] == k for i in row["입력"])} - return key, mf.run(master, "건설품셈:" + key, given) + return key, mf.run(master, "GC:" + key, given) except (mf.FormulaError, ArithmeticError, KeyError, TypeError): continue finally: @@ -306,17 +307,17 @@ def machine(estx: Estx, files: dict, master: mf.Master): for r in estx.table("MachTitleDat"): if r.get("Unite") == "hr" and r.get("TC"): est.setdefault(mach_key(r.get("Name"), r.get("Size")), r) - ours = files["기계_건설품셈.json"]["줄"] + ours = [m for m in files["기계.json"]["줄"] if m["세부분류"] == "건설품셈"] pairs = [(m, est[k]) for m in ours if (k := mach_key(m["이름"], m["규격"])) in est] print(f"## 기계 — 우리 {len(ours)} · ESTX 시간당 {len(est)} · 기종·규격 짝 {len(pairs)}") out = [] for m, e in pairs: - key, res = machine_cost(master, m["열쇠"]) + key, res = machine_cost(master, m["원문번호"]) if not res: continue out.append((m, e, key, res)) print(f" 로직 계산됨 {len(out)}") - first = [x for x in out if x[0]["열쇠"].startswith(FIRST)] + first = [x for x in out if x[0]["원문번호"].startswith(FIRST)] for label, group in (("임도 기계", first), ("전체", out)): print(f"### {label}") print(" 계:", spread([rate(e["TC"], res["계"]) for m, e, k, res in group])) @@ -340,18 +341,18 @@ def machine(estx: Estx, files: dict, master: mf.Master): e.get("GC") and abs(rate(e["GC"], res["재료비"])) > 5 ): print( - f" {m['열쇠']} {m['이름']} {m['규격']} {k} | ESTX 재 {e.get('GC', 0):.0f} 경 {e['JC']:.0f} | 우리 재 {res['재료비']:.0f} 경 {res['경비']:.0f}" + f" {m['원문번호']} {m['이름']} {m['규격']} {k} | ESTX 재 {e.get('GC', 0):.0f} 경 {e['JC']:.0f} | 우리 재 {res['재료비']:.0f} 경 {res['경비']:.0f}" ) for m, e, k, res in first: print( - f" {m['열쇠']} {m['이름']} {m['규격']} | ESTX {e['TC']:.0f} (노 {e.get('NC', 0):.0f} 재 {e.get('GC', 0):.0f} 경 {e.get('JC', 0):.0f})" + f" {m['원문번호']} {m['이름']} {m['규격']} | ESTX {e['TC']:.0f} (노 {e.get('NC', 0):.0f} 재 {e.get('GC', 0):.0f} 경 {e.get('JC', 0):.0f})" f" | 우리 {res['계']:.0f} (노 {res['노무비']:.0f} 재 {res['재료비']:.0f} 경 {res['경비']:.0f}) {rate(e['TC'], res['계']):+.1f}%" ) return out # ── (4) 일위대가 ────────────────────────────────────────────────────── -# (ESTX 제목 코드, 로직 열쇠, 입력[, 단위 환산 배수 — 우리 값 × 배수 = ESTX 단위]) +# (ESTX 제목 코드, 로직 원문번호, 입력[, 단위 환산 배수 — 우리 값 × 배수 = ESTX 단위]) REGION = {"지역": "전국평균", "보정작업": "해당 없음"} PAIRS = [ ( @@ -456,14 +457,15 @@ def with_estx_materials(master: mf.Master, estx: Estx, code: str): """ESTX 일위대가의 자재 단가를 품명으로 우리 빈 재료(값 없음)에만 임시로 넣은 사본 — 파일에 안 씀.""" temp = copy.copy(master) temp.index = {k: dict(v) for k, v in master.index.items()} + temp.numbers = {k: dict(v) for k, v in master.numbers.items()} items = [ d for d in estx.details("HeadMainDat").get(code, []) if str(d.get("Code", "")).startswith("M") ] put = [] - for book in (("재료", "자체"), ("재료", "나라장터자재")): - rows = temp.index.get(book, {}) + for tid in ("MN",): + rows = temp.index.get(tid, {}) for key, row in list(rows.items()): if row.get("값") is not None: continue @@ -472,6 +474,7 @@ def with_estx_materials(master: mf.Master, estx: Estx, code: str): ) if hit: rows[key] = {**row, "값": Decimal(str(hit["TU"]))} + temp.numbers[tid][str(row["원문번호"])] = rows[key] put.append(f"{key}={hit['TU']:.0f}") return temp, put @@ -484,11 +487,11 @@ def unit_price(estx: Estx, master: mf.Master): h = heads[code] k = Decimal(scale[0]) if scale else Decimal(1) temp, put = with_estx_materials(master, estx, code) - row = temp.logic("건설품셈:" + key) + row = temp.logic("GC:" + key) given = {k: v for k, v in given.items() if any(i["이름"] == k for i in row.get("입력", []))} given = {k: Decimal(str(v)) if isinstance(v, (int, float)) else v for k, v in given.items()} try: - res = mf.run(temp, "건설품셈:" + key, given) + res = mf.run(temp, "GC:" + key, given) res = {c: res[c] * k for c in ("계", *mf.COST_ITEMS)} except (mf.FormulaError, ArithmeticError, KeyError, TypeError) as e: print(f" {code} {h['Name']} {h.get('Size')} | {key} | 멈춤: {str(e)[:120]}") diff --git a/resources/master_data/scripts/master_keys.py b/resources/master_data/scripts/master_keys.py index 52acfbd9..ebf6551c 100644 --- a/resources/master_data/scripts/master_keys.py +++ b/resources/master_data/scripts/master_keys.py @@ -8,6 +8,7 @@ from __future__ import annotations import json import re +from decimal import Decimal from pathlib import Path MASTER = Path(__file__).resolve().parent.parent @@ -62,12 +63,18 @@ def save_book(book: dict, path: Path = BOOK) -> None: path.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n") -def issue(book: dict, tid: str, number: str, file: str) -> str: - """(테이블ID, 원문번호) 의 키 — 대장에 있으면 그 키 · 없으면(원문번호 빈 새 줄 포함) 다음 번호를 줌.""" +def lookup(book: dict) -> dict: + """(테이블ID, 원문번호) → 키 — 대장에서 한 번 세워 둠(`finish` 가 걷음).""" index = book.setdefault("_찾기", {}) if not index: for key, v in book["키"].items(): index[(key[:2], v["원문번호"])] = key + return index + + +def issue(book: dict, tid: str, number: str, file: str) -> str: + """(테이블ID, 원문번호) 의 키 — 대장에 있으면 그 키 · 없으면 다음 번호(원문번호 빈 새 줄 포함).""" + index = lookup(book) key = index.get((tid, number)) if number else None if key: book["키"][key]["파일"] = file @@ -97,3 +104,254 @@ def stamp( key = row.pop("키", None) or issue(book, tid, number, file) out.append({"키": key, "원문번호": number, **(extra or {}), **row}) return out + + +# ── 빌더 잇기 — 빌더는 옛 모양(「열쇠」 · 원문별 파일 이름)으로 쓰고 adopt 가 새 틀로 받음 ── +LABOR = { + "건설노임": "건설업", + "제조노임": "제조업", + "엔지니어링노임": "엔지니어링", + "측량노임": "측량", + "건설사업관리노임": "건설사업관리", + "SW노임": "SW", + "산림노임": "산림", + "준용대상": "준용대상", +} +MERGED = { + "인력": ("인력.json", "조사", LABOR), + "기계": ("기계.json", "세부분류", {"건설품셈": "건설품셈", "산림품셈": "산림품셈"}), +} +DIVISIONS = ("공통", "토목", "건축", "기계설비", "유지관리") +OLD_CHAPTER = re.compile(r"^(소요량|계수|로직)_(산림품셈|건설품셈)_(\D*)(\d+)장\.json$") +CHAPTER = re.compile(r"^(소요량|계수|로직)_(산림품셈|건설품셈|자체)_(\d\d)장_.+\.json$") +_BODY = MASTER.parents[1] / "resources/knowledge/original/원가계산" +_OLDREF = re.compile(r"(인력|재료|기계|소요량|계수|로직|환율|요율):([^:\s·]+):(.+?)(?=\s·\s|$)") +_STR = re.compile(r'"(?:[^"\\]|\\.)*"') + + +def title(book: str, division: str, number: int) -> str: + """원문 장 제목 — 본문 폴더 이름 그대로(「제07장_지붕_및_홈통공사」 → 「지붕_및_홈통공사」).""" + base = _BODY / "산림_표준품셈/본문" + if book == "건설품셈": + index = DIVISIONS.index(division) + 1 + base = _BODY / "건설공사_표준품셈/본문" / f"{index:02d}_{division}부문" + (hit,) = base.glob(f"제{number:02d}장_*") + return hit.name.split("_", 1)[1] + + +def chapter_file(group: str, book: str, division: str, number: int) -> str: + return f"{group}_{book}_{number:02d}장_{title(book, division, number)}.json" + + +def _items(data: dict) -> str: + return "표" if data.get("그룹") in ("소요량", "계수") else "줄" + + +def _read(path: Path, exact: bool = True) -> dict: + text = path.read_text(encoding="utf-8") + return json.loads(text, parse_float=Decimal, parse_int=Decimal) if exact else json.loads(text) + + +def old_view(name: str, folder: Path = MASTER) -> dict: + """옛 파일 이름으로 읽기(빌더용 · 수는 float) — 줄의 「열쇠」 = 원문번호 · 키·갈래 칸 뺌. + 준용·통합·연결 같은 참조는 키 그대로.""" + m = re.match(r"^(인력|기계)_(.+)\.json$", name) + if m: + file, slot, labels = MERGED[m.group(1)] + data = _read(folder / file, exact=False) + label = labels[m.group(2)] + head = {"그룹": m.group(1), **data[slot][label]} + rows = [r for r in data["줄"] if r.get(slot) == label] + else: + c = OLD_CHAPTER.match(name) + path = folder / (chapter_file(c[1], c[2], c[3], int(c[4])) if c else name) + data = _read(path, exact=False) + head, rows, slot = ( + {k: v for k, v in data.items() if k != _items(data)}, + data[_items(data)], + "", + ) + drop = ("키", "원문번호", slot) + out = [{"열쇠": r["원문번호"], **{k: v for k, v in r.items() if k not in drop}} for r in rows] + return {**head, _items(head): out} + + +def _old_ref(book: dict, text: str) -> str: + """옛 참조 `그룹:원문:열쇠` → 키(대장에 없으면 그대로 — check_master 가 잡음).""" + group, src, number = (text.split(":", 2) + ["", ""])[:3] + try: + tid = table_id(group, src) + except KeyError: + return text + return lookup(book).get((tid, number), text) + + +def _convert(book: dict, row: dict, names: dict) -> dict: + """줄 속 옛 참조 — 준용·통합 · 후보 글(키 옆 이름) · 품셈재료 연결 · 시중물가 조달 나라장터 연결.""" + for col in ("준용", "통합"): + value = row.get(col) + if isinstance(value, str) and ":" in value: + row[col] = _old_ref(book, value) + if isinstance(row.get("후보"), str): + + def one(m): + key = _old_ref(book, m.group(0)) + return f"{key} {names.get(key, m.group(3))}" if KEY.fullmatch(key) else key + + row["후보"] = _OLDREF.sub(one, row["후보"]) + link = row.get("연결") + if isinstance(link, str) and not KEY.fullmatch(link): + row["연결"] = lookup(book).get(("MT", link), link) + value = row.get("값") + supply = value.get("조달") if isinstance(value, dict) else None + if isinstance(supply, dict) and str(supply.get("출처") or "").startswith("나라장터:"): + code = supply["출처"].split(":", 1)[1] + if not KEY.fullmatch(code): # 묶음은 새로 만듦 — 옛 줄과 나눠 쓰지 않게 + source = "나라장터:" + lookup(book).get(("MN", code), code) + row["값"] = {**value, "조달": {**supply, "출처": source}} + return row + + +def _store(): + import sys + + sys.path.insert(0, str(MASTER.parents[1])) + from M01_MasterData import M01_MasterData_Store as st + + return st + + +def dump_rows(head: dict, rows: list[dict]) -> str: + """요소 파일 — 머리는 들여쓰기 2칸 · 줄은 한 줄씩(길면 펼침).""" + st = _store() + out = ["{"] + [f" {st._atom(k)}: {st.dump(v, 2)}," for k, v in head.items()] + out.append(' "줄": [') + for i, row in enumerate(rows): + line = st._flat(row) + body = line if len(line) <= 450 else st.dump(row, 4, expand=True) + out.append(" " + body + ("," if i < len(rows) - 1 else "")) + return "\n".join(out + [" ]", "}"]) + "\n" + + +def keep_text(text: str, old: dict, new: dict) -> str | None: + """글 모양 그대로 — 「열쇠」 칸 · 바뀐 글 · 머리 부문·차례만 갈음 · 읽어 맞대어 다르면 None.""" + rows_old, rows_new = old[_items(old)], new[_items(new)] + spots = list(re.finditer(r'"열쇠": ("(?:[^"\\]|\\.)*"|[-\d.]+)', text)) + if len(spots) != len(rows_new): + return None + parts, at = [], 0 + for m, row in zip(spots, rows_new): + number = json.dumps(row["원문번호"], ensure_ascii=False) + parts += [text[at : m.start()], f'"키": "{row["키"]}", "원문번호": {number}'] + at = m.end() + text = "".join(parts) + text[at:] + swap = {} + for a_row, b_row in zip(rows_old, rows_new): + for k in b_row: + if k in a_row and a_row[k] != b_row[k]: + for a, b in zip(_strings(a_row[k]), _strings(b_row[k])): + if a != b: + swap[json.dumps(a, ensure_ascii=False)] = json.dumps(b, ensure_ascii=False) + text = _STR.sub(lambda m: swap.get(m.group(0), m.group(0)), text) + add = "".join( + f" {json.dumps(k, ensure_ascii=False)}: {_store()._atom(new[k])},\n" + for k in ("부문", "차례") + if k in new and k not in old + ) + if add: + text = re.sub(r'(\n "원문": "[^"]*",\n)', lambda m: m.group(1) + add, text, count=1) + same = json.loads(text, parse_float=Decimal, parse_int=Decimal) == new + return text if same else None + + +def _strings(v): + if isinstance(v, str): + yield v + elif isinstance(v, dict): + for x in v.values(): + yield from _strings(x) + elif isinstance(v, list): + for x in v: + yield from _strings(x) + + +def order_chapters(folder: Path = MASTER) -> None: + """장 파일 머리 「차례」 — 그룹마다 산림 1~14장 → 건설 공통 · 토목 · 건축 · 기계설비 · 유지관리.""" + found = {} + for path in folder.glob("*.json"): + m = CHAPTER.match(path.name) + if m: + data = _read(path) + division = data.get("부문") or "" + rank = (m[2] != "산림품셈", DIVISIONS.index(division) if division else -1, int(m[3])) + found.setdefault(m[1], []).append((rank, path.name, data.get("차례"))) + for items in found.values(): + for i, (_, name, had) in enumerate(sorted(items), start=1): + if had != i: + path = folder / name + text = re.sub( + r'"차례": \d+', f'"차례": {i}', path.read_text(encoding="utf-8"), count=1 + ) + path.write_text(text, encoding="utf-8", newline="\n") + + +def adopt(folder: Path = MASTER) -> list[str]: + """빌더가 옛 모양으로 쓴 파일 → 새 틀(키 · 원문번호 · 한 테이블 · 장 파일 이름) · 옛 파일은 지움. + 키는 대장의 (테이블ID, 원문번호) 키 · 없으면 다음 번호. 로직은 키로 적어 넣으므로 받지 않음.""" + book = load_book(folder / BOOK.name) + names = {} + if (folder / "인력.json").is_file(): + names = {r["키"]: r.get("이름") for r in _read(folder / "인력.json")["줄"]} + done = [] + for path in sorted(folder.glob("*.json")): + if path.name.startswith("_"): + continue + data = _read(path) + rows = data.get(_items(data)) or [] + if not rows or "열쇠" not in rows[0]: + continue + group, src = data["그룹"], data["원문"] + if group == "로직": + raise SystemExit(f"{path.name} · 로직은 키로 적음 — 옛 모양은 받지 않음") + c = OLD_CHAPTER.match(path.name) + target = chapter_file(c[1], c[2], c[3], int(c[4])) if c else path.name + target = MERGED[group][0] if group in MERGED else target + if c and c[3]: # 건설품셈 원문번호 앞에 부문(「기계설비 7-1-1 …」) + head = c[3] + " " + rows = [ + r if str(r["열쇠"]).startswith(head) else {**r, "열쇠": head + r["열쇠"]} + for r in rows + ] + data[_items(data)] = rows + stamped = [ + _convert(book, r, names) for r in stamp(book, table_id(group, src), rows, target) + ] + if group in MERGED: + file, slot, labels = MERGED[group] + label = labels[src] + whole = _read(folder / file) if (folder / file).is_file() else {"그룹": group} + heads = whole.setdefault(slot, {}) + heads[label] = {k: v for k, v in data.items() if k not in ("그룹", "줄")} + stamped = [ + {"키": r["키"], "원문번호": r["원문번호"], slot: label, **r} for r in stamped + ] + keep = [r for r in whole.get("줄", []) if r.get(slot) != label] + rows_all = sorted(keep + stamped, key=lambda r: r["키"]) + text = dump_rows({k: v for k, v in whole.items() if k != "줄"}, rows_all) + else: + head = {"그룹": group, "원문": src} + if c and c[3]: + head["부문"] = c[3] + if c: + head["차례"] = Decimal(0) # order_chapters 가 채움 + rest = {k: v for k, v in data.items() if k not in head} + new = {**head, **rest, _items(data): stamped} + old_text = path.read_text(encoding="utf-8") + text = keep_text(old_text, data, new) or _store().dump(new) + (folder / target).write_text(text, encoding="utf-8", newline="\n") + if target != path.name: + path.unlink() + done.append(target) + save_book(finish(book), folder / BOOK.name) + order_chapters(folder) + return done diff --git a/resources/master_data/재료_시중물가.json b/resources/master_data/재료_시중물가.json index 7318bdb6..7783ad1a 100644 --- a/resources/master_data/재료_시중물가.json +++ b/resources/master_data/재료_시중물가.json @@ -12,7 +12,7 @@ "비고": [ "다산소프트 EST 2026-07 자재 · 개발용 · 배포 전 판권 구매", "값 = 시중(물가지 다섯 · 면수) · 조달(값 · 출처 · 기준) — 기본가·채택값 없음 · 쓰는 곳이 값을 고르고 출처를 밝힘", - "조달 출처 「가격정보」 = ESTX 가격정보 칸의 조달청 관급 단가 · 「나라장터:<열쇠>」 = 재료_나라장터자재 줄로 이음(값 안 베낌)" + "조달 출처 「가격정보」 = ESTX 가격정보 칸의 조달청 관급 단가 · 「나라장터:<키>」 = 재료_나라장터자재 줄로 이음(값 안 베낌)" ], "줄": [ { "키": "MT000001", "원문번호": "M0003039885", "이름": "휘 발 유", "규격": "", "단위": "ℓ", "값": {"시중": {"가격정보": 1825, "물가자료": null, "유통물가": null, "물가정보": null, "거래가격": null, "면수": {"가격정보": "2026.7", "물가자료": null, "유통물가": null, "물가정보": null, "거래가격": null}}, "조달": {"값": null, "출처": null, "기준": null}}, "출처": "시중물가 M0003039885" },