Merge branch 'sub_laptop_3' of C:/Aislo_wt/sub_laptop_3 into main_laptop_1

This commit is contained in:
2026-11-14 19:57:23 +09:00
16 changed files with 14032 additions and 1603 deletions
+1 -1
View File
@@ -31,7 +31,7 @@
| 출처 | 물가지 네 열 — 상용 적산 자료(ESTX) · `원가계산/다산소프트/2026-07-main.estx` / 나라장터 가격정보 — `원가계산/자재단가/나라장터_시설공통자재_*.json` |
| 받는 법 | ESTX = 구매 파일(배포 전 판권 구매 뒤 갱신) · 나라장터 = `collect_cost_sources.py values` (API) · 유료 물가지 직접 구독은 미정 |
| 주기 | ESTX 반기 · 나라장터 연 2회+수시 |
| 빌더 | `scripts/build_재료_자재품목.py`(항목 통합 `scripts/material_merge.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` |
- 나라장터 스냅샷에서 마스터 줄을 뽑는 빌더 없음 — 구멍.
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -114,8 +114,8 @@
- `조달청` = 자리만 둠 · 값 없음(원문 「가격정보」 값은 넣지 않음) · 가장 낮은 값 고르기에 넣지 않음.
- `일반가격` = 시스템 관리자가 M01 에서 손으로 넣는 칸 · 빌더가 지금 정본 값을 이어 받음.
- 가장 낮은 값 = 사급 칸(물가지 넷 + 일반가격) 가운데 · `scripts/master_material.py` `SLOTS`.
- `상하차` = 상차 · 하차 · `-`(기본 · 모름) 가운데 하나 · 값이 아닌 표지 · 자리 = 단위 바로 뒤 · 이름 · 규격의 인도 조건 글(도착도 · 하차도 = 하차 · 채취장상차 · 하치장 상차도 = 상차)은 이 칸으로.
- 항목 통합 = 지역 · 관급/사급 · 제조사 · 조합 · 상하차 덧정보를 뗀 이름 · 규격 · 단위 · 상하차가 같은 줄은 한 줄(`scripts/material_merge.py`) · 남는 줄 = 전국 › 서울 › 경기 › 그 밖(값 중간) · 뗀 말 · 기준 지역은 `비고` 「통합: …」.
- `상하차` = 상차 · 하차 · `-`(기본 · 모름) 가운데 하나 · 값이 아닌 표지 · 자리 = 단위 바로 뒤 · 이름 · 규격 끝 인도 조건 글(도착도 · 하차도 = 하차 · 채취장상차 · 하치장 상차도 = 상차)은 이 칸으로 옮기고 이름 · 규격에서 뺌 · 빌더가 손 값을 이어 받음.
- 줄 목록 = EST 그대로 — 지역 · 관급 · 사급 · 제조사 · 조합 줄을 합치지 않음 · 이름 글은 원문 그대로(인도 조건 글만 뺌).
- 재료 아닌 줄은 두지 않음 — 품질시험 수수료 · 일반 도로용 블록.
- `구분` · `상세구분` = 원문(ESTX)에 대·중분류 칸이 없어 품명 글자 규칙으로 채움(`scripts/build_자재품목_분류.py`) · 묶음 목록은 `ref/_묶음_자재품목.md` · 못 가른 줄은 「미분류」.
- `면수` = 물가지 값의 원문 면수 묶음 `{ 물가자료, 유통물가, 물가정보, 거래가격 }` · 없으면 `null` · 화면에는 접어 둠.
@@ -2,7 +2,7 @@
`scripts/build_자재품목_분류.py` 가 만든 표 — 손으로 고치지 않음.
- 구분 24 · 상세구분 91 · 미분류 25 줄 (전체 17692 줄)
- 구분 24 · 상세구분 91 · 미분류 25 줄 (전체 24027 줄)
## 규칙
@@ -14,26 +14,34 @@
## 구분별 상세구분 (줄 수)
### 관·이음관 (5205)
### 관·이음관 (5399)
PE관 1141 · 이음쇠·부속 1003 · 주철관 794 · 강관 706 · 파형강관 417 · 유리섬유·FRP관 398 · PVC관 340 · 흄관 196 · 동관·스텐관 133 · 단열이중관 77
PE관 1154 · 이음쇠·부속 1003 · 주철관 828 · 강관 706 · PVC관 461 · 파형강관 417 · 유리섬유·FRP관 398 · 흄관 222 · 동관·스텐관 133 · 단열이중관 77
### 식생·종자 (3452)
### 식생·종자 (3456)
수목 2929 · 초본·야생화 138 · 수목지주·보호 115 · 종자 105 · 잔디·지피 97 · 농약·약제 42 · 비료·토양개량 26
수목 2933 · 초본·야생화 138 · 수목지주·보호 115 · 종자 105 · 잔디·지피 97 · 농약·약제 42 · 비료·토양개량 26
### 볼트·앵커·철물 (963)
### 레미콘 (3203)
볼트·너트 526 · 와이어로프·클램프 193 · 앵커 189 · 철근커플러 38 · 못 17
레미콘 3203
### 철근·강재 (1754)
형강 717 · 철근 497 · 강판 262 · 구조용강관 132 · 철선 93 · 철망·와이어메쉬 53
### 아스콘·포장 (1695)
아스콘 1479 · 포장블록 173 · 특수포장 30 · 아스팔트 13
### 볼트·앵커·철물 (1002)
볼트·너트 526 · 와이어로프·클램프 208 · 앵커 189 · 못 41 · 철근커플러 38
### 전기·통신 (902)
케이블·전선 320 · 판넬·차단기 164 · 전선관·트레이 139 · 조명·전등 133 · 제어·계측기기 89 · 부속·기타 57
### 철근·강재 (828)
형강 319 · 강판 262 · 구조용강관 132 · 철근 67 · 철선 29 · 철망·와이어메쉬 19
### 밸브·수도자재 (761)
밸브 575 · 수도계량기·유량계 101 · 스프링클러·호스 51 · 펌프 34
@@ -46,17 +54,21 @@ PE관 1141 · 이음쇠·부속 1003 · 주철관 794 · 강관 706 · 파형강
화물운임 552 · 폐기물 61 · 시험·검사 2 · 상하차 2
### 맨홀·수로·그레이팅 (603)
수로관 283 · 그레이팅 169 · 맨홀 151
### 시멘트·골재 (587)
골재 328 · 모르타르 188 · 시멘트 71
### 도료·방수·접착 (572)
도료 258 · 접착·혼화 174 · 방수·실링 140
### 맨홀·수로·그레이팅 (481)
### 콘크리트제품 (490)
그레이팅 169 · 수로관 161 · 맨홀 151
### 콘크리트제품 (474)
말뚝·파일 223 · 경계석·블록 152 · PC구조물 99
말뚝·파일 223 · 경계석·블록 168 · PC구조물 99
### 건축마감 (454)
@@ -70,41 +82,29 @@ PE관 1141 · 이음쇠·부속 1003 · 주철관 794 · 강관 706 · 파형강
소모품 235 · 용접·가스 36 · 고철·기타금속 31 · 화약 13 · 용수 2
### 아스콘·포장 (291)
아스콘 131 · 포장블록 117 · 특수포장 30 · 아스팔트 13
### 단열·보온 (284)
단열재 284
### 매트·토목섬유 (228)
### 목재 (246)
부직포·토목섬유 113 · 돌망태·사방 61 · 매트 54
각재·판재 168 · 합판 78
### 시멘트·골재 (218)
### 매트·토목섬유 (230)
모르타르 140 · 골재 55 · 시멘트 23
부직포·토목섬유 113 · 돌망태·사방 63 · 매트 54
### 석재 (207)
조경석·자연석 125 · 경계석 82
### 목재 (141)
### 교량·구조부속 (133)
각재·판재 117 · 합판 24
신축이음·받침 75 · 보강재·정착 58
### 교량·구조부속 (130)
### 거푸집·가설재 (130)
신축이음·받침 75 · 보강재·정착 55
### 거푸집·가설재 (120)
동바리·비계 63 · 거푸집 49 · 가설건축물 8
### 레미콘 (62)
레미콘 62
동바리·비계 73 · 거푸집 49 · 가설건축물 8
### 특수장비 (4)
@@ -4,9 +4,9 @@ ESTX 자재 표 = StuffcDat(칸 뜻: 다산소프트/ESTX_값사전_자재노무
열 한 벌 = material_merge.COLUMNS(키 · 구분 · 상세구분 · 이름 · 규격 · 단위 · 상하차 · 조달청 · 물가자료 · 유통물가 · 물가정보 · 거래가격 · 일반가격 · 출처 · 비고 · 면수).
Cost2..5 = 물가지 넷 · Page2..5 = 면수. TC(채택) · Cost6(견적가)는 마스터 아님 — 뺌.
Cost1(가격정보 = 관급 정보)은 넣지 않음 — 「조달청」 열은 자리만 두고 비움(요구 827 · 830) · 비고에도 안 적음.
「일반가격」 = 시스템 관리자가 M01 에서 넣는 칸 — 지금 정본 값을 원문번호로 이어 받음(다시 지어도 안 사라짐).
「일반가격」 = 시스템 관리자가 M01 에서 넣는 칸 · 손 값(상하차 · 일반가격)은 원문번호로 이어 받음.
Cost1 출처 표기로 가린 재료 아닌 줄은 뺌(요구 832) — 「2026(경북)」 품질시험 수수료 · 「~26.10.16」 일반 도로용 블록.
항목 통합(요구 819 · 827) = material_merge — 지역 · 관급/사급 · 제조사 · 조합 덧정보를 뗀 이름 · 규격 · 단위가 같은 줄을 한 줄로.
줄 목록 = EST 그대로 — 지역 · 관급 · 사급 줄을 합치지 않음(요구 834) · 상하차 칸 = material_merge.place.
0 · 미저장 = 값 없음(null). 이름·규격은 원문 그대로(띄어쓰기·오자 포함). 코드 없는 빈 줄은 뺌.
ESTX 에 대·중분류 칸이 없어 구분·상세구분은 build_자재품목_분류.py 가 품명 글자 규칙으로 채움(빌더 끝에서 부름).
조달청 줄 · 조달청 관급 값은 넣지 않음(요구 822 — EST 그대로) · update_관급단가*.py 는 지금 정본에 적용 안 함.
@@ -62,13 +62,13 @@ def dropped(r: dict) -> str | None:
return None
def own_prices() -> dict[str, object]:
"""지금 정본의 일반가격(손 입력) — 원문번호 → 값."""
def own_prices() -> dict[str, dict]:
"""지금 정본의 손 값(상하차 · 일반가격) — 원문번호 → {칸: 값}."""
if not OUT.is_file():
return {}
book = mk.load_book()["키"]
rows = json.loads(OUT.read_text(encoding="utf-8"))["줄"]
return {book[r["키"]]["원문번호"]: r[mm.OWN] for r in rows if r.get(mm.OWN) is not None}
return {book[r["키"]]["원문번호"]: {c: r.get(c) for c in mm.HAND} for r in rows}
def build(rows: list[dict], own: dict | None = None) -> list[dict]:
@@ -90,10 +90,10 @@ def build(rows: list[dict], own: dict | None = None) -> list[dict]:
"이름": r.get("Name", ""),
"규격": r.get("Size", ""),
"단위": r.get("Unite", ""),
mm.LOAD: mm.LOADS[0],
mm.LOAD: own.get(code, {}).get(mm.LOAD),
mm.GOVT: None,
**{slot: num(r.get(f"Cost{i}")) for i, slot in enumerate(MARKET, 2)},
mm.OWN: own.get(code),
mm.OWN: own.get(code, {}).get(mm.OWN),
"출처": f"자재품목 {code}",
"비고": 비고,
"면수": 면수 if any(면수.values()) else None,
@@ -126,19 +126,20 @@ def prune() -> int:
return len(gone)
def check(rows: list[dict], gone: int) -> None:
"""원본 대조 — 줄 수(원본 − 재료 아닌 줄 − 통합으로 지운 줄) · 남는 줄 전부 물가지 넷 · 면수가 원본 그 줄 것 · 조달청 칸 빔."""
def check(rows: list[dict]) -> None:
"""원본 대조 — 줄 수(원본 − 재료 아닌 줄) · 줄마다 이름 · 규격(인도 조건 글 뺌) · 단위 · 물가지 넷 · 면수가 원본 그 줄 것 · 조달청 칸 빔."""
data = json.loads(OUT.read_text(encoding="utf-8"))
raw = {r["Code"]: r for r in rows if r.get("Code") and r["Code"] not in DROP}
out = sum(1 for r in raw.values() if dropped(r))
got = [r for r in data["줄"] if not str(r["출처"]).startswith(CARRY)]
assert len(got) == len(raw) - out - gone, (len(got), len(raw), out, gone)
assert len(got) == len(raw) - out, (len(got), len(raw), out)
print(
f"줄 {len(got)} = 원본 코드 있는 줄 {len(raw)} − 재료 아닌 줄 {out} − 통합으로 지운 줄 {gone}"
f" (유류 {len(DROP)} 뺌)"
f"줄 {len(got)} = 원본 코드 있는 줄 {len(raw)} − 재료 아닌 줄 {out} (유류 {len(DROP)} 뺌)"
)
for g in got:
r = raw[g["출처"].removeprefix("자재품목 ")]
name, spec, _, _ = mm.split_load({"이름": r.get("Name", ""), "규격": r.get("Size", "")})
assert (g["이름"], g["규격"], g["단위"]) == (name, spec, r.get("Unite", "")), g["출처"]
for i, slot in enumerate(MARKET, 2):
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), (g["출처"], slot)
@@ -148,7 +149,7 @@ def check(rows: list[dict], gone: int) -> None:
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(" 남는 줄 전부 물가지 넷 · 면수 = 원본 그 줄 · 조달청 칸 빔 · 열 한 벌")
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()))
@@ -172,8 +173,8 @@ def main():
"유류·전력은 뺌 — 유가·전기요금은 재료_유가전력(유류는 값이 비고 글뿐 · 중유는 받지 않음)",
"원문번호(다산소프트 품번) 칸 없음 — 대조는 _키대장.json 에만",
"조달청 칸 = 자리만 둠 · 값 없음 — 원문 「가격정보」(관급 정보) 값은 넣지 않음(요구 827 · 830)",
"상하차 = 상차 · 하차 · -(기본 · 모름) · 단위 바로 뒤 — 이름 · 규격의 인도 조건 글(도착도 · 하차도 = 하차 · 채취장상차 · 하치장 상차도 = 상차)을 떼어 이 칸으로(요구 828 · 831) · 값이 아닌 표지",
"항목 통합 = 지역 · 관급/사급 · 제조사 · 조합 덧정보를 뗀 이름 · 규격 · 단위가 같은 줄을 한 줄로(전국 › 서울 › 경기 › 그 밖 지역 값 중간) · 뗀 말은 비고 「통합: …」 (scripts/material_merge.py · 요구 819 · 827 · 831)",
"상하차 = 상차 · 하차 · -(기본 · 모름) · 단위 바로 뒤 — 이름 · 규격 끝 인도 조건 글(도착도 · 하차도 = 하차 · 채취장상차 · 하치장 상차도 = 상차)을 떼어 이 칸으로(요구 828 · 831 · 836) · 값이 아닌 표지",
"줄 목록 = EST 그대로 — 지역 · 관급 · 사급 · 제조사 · 조합 줄을 합치지 않음(요구 834)",
"재료 아닌 줄은 뺌 — 품질시험 수수료(가격정보 출처 「2026(경북)」) · 일반 도로용 블록(「~26.10.16」)(요구 832)",
"조달청 줄 · 조달청 관급 값은 넣지 않음 — EST 그대로(요구 822)",
"품셈재료에서 옮긴 줄(출처 「품셈재료 …」 · 값 없음)은 ESTX 밖 — 지금 정본에서 이어 받음",
@@ -182,10 +183,10 @@ def main():
],
}
extra = carried()
merged, log = mm.merge(build(rows, own_prices()) + extra) # 원문 차례 = 키 차례
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))
dump_master(
OUT, head, [mm.place(r) for r in build(rows, own_prices()) + extra]
) # 원문 차례 = 키 차례
check(rows)
print("ESTX 밖 줄 이어 받음", len(extra))
print("새 틀로 받음", mk.adopt()) # 열쇠 → 키 — 분류가 「키」 줄을 읽으므로 먼저
# adopt 의 keep_text 가 구분 · 상세구분을 한 줄에 두 번 씀 — 읽어서(뒤 값) 다시 씀
+31 -242
View File
@@ -1,21 +1,13 @@
"""재료 자재품목 항목 통합(요구 819 · 827 · 828 · 830 ~ 832 · PLAN 830-2) — build_재료_자재품목 이 부름.
시험 = test_830_2_material_merge.
"""재료 자재품목 열 한 벌 · 상하차(요구 828 · 830 · 831 · 834 · 836 · 837 · PLAN 830-9).
build_재료_자재품목 이 부름 · 시험 = test_830_2_material_merge.
묶음 = 이름에서 지역 · 관급/사급 꼬리표 · 제조사 꼬리(「-거양」) · 조합 이름 · 상하차 글을 뺀
이름 · 규격 · 단위 · 상하차가 같은 줄.
규격 = 앞말 「조합공통품목,」 · 규격 속 지역 괄호 뗌
단위 = M=m · m²=㎡ · Ton=톤 · ㎏=kg 표기 하나로 · 비교는 공백을 뺀 글
줄 목록 = EST 그대로(지역 · 관급 · 사급 · 제조사 · 조합 글이 든 원래 이름 · 줄마다 따로) — 합치지 않음.
열 한 벌 = COLUMNS — 상하차 = 단위 바로 뒤 · 가격 열 = 조달청(관급) · 물가지 넷 · 일반가격(사급).
상하차 칸(요구 828 · 831) = 상차 · 하차 · -(기본 · 모름) — 이름 · 규격의 인도 조건 글을 떼어 이 칸으로
사급 / 관급 고르기는 재료 표 열이 아님 — 단가산출 쪽 몫(요구 837).
상하차 칸 = 상차 · 하차 · -(기본 · 모름) — 이름 · 규격 끝 인도 조건 글로 채우고 그 글은 이름 · 규격에서 뺌(요구 836)
「도착도」 · 「하차도」 = 하차 · 「채취장상차(도)」 · 「하치장 · 생산공장 상차도」 · 「(상차도)」 = 상차
남는 줄 = 사급(관급 아닌) 줄 가운데 전국 › 지역 글 없음(전국으로 봄) › 서울 › 서울 권역 글 › 경기
› 그 밖 지역(요구 830 · 831 — 조달청과 같은 차례) · 같은 차례 여럿(경기 · 그 밖)이면 값이 중간인 줄
(짝수면 아래 중간 · 다시 지어도 같은 줄)
· 남길 줄의 값 칸이 전부 비고 다른 줄에 값이 있으면 다음 차례 줄(브)
· 관급 줄만 있는 묶음 = 가장 작은 키 한 줄(브) · 나머지 줄은 지움(이력 안 남김)
덧정보가 하나도 없는 묶음(원래부터 겹친 줄 — 식생 · 종자 등)은 합치지 않음.
조달청 칸 값은 모두 비움(EST 「가격정보」 — 요구 827 · 831) · 일반가격 = 시스템 관리자가 넣는 칸(빌더가 이어 받음).
이름에서 뗀 말은 비고 「통합: …」 로.
조달청 칸 값은 모두 비움(EST 「가격정보」 — 요구 827 · 831) · 일반가격 = 시스템 관리자가 넣는 칸.
상하차 · 일반가격 손 값은 빌더가 이어 받음.
"""
import re
@@ -25,6 +17,7 @@ LOAD = "상하차" # 표지 칸(값 칸 아님)
LOADS = ("-", "상차", "하차") # 첫째 = 기본(모름)
GOVT = "조달청" # 관급 가격 칸 — 가장 낮은 값 고르기에 안 넣음(요구 826 · 830)
OWN = "일반가격" # 시스템 관리자 손 입력 칸(사급)
HAND = (LOAD, OWN) # 빌더가 이어 받는 손 값 칸
COLUMNS = ( # 열 한 벌 · 차례(요구 830 · 831)
"키",
"구분",
@@ -40,239 +33,35 @@ COLUMNS = ( # 열 한 벌 · 차례(요구 830 · 831)
"비고",
"면수",
)
GYEONGGI = {"경기", "수원", "용인", "포천", "가평", "여주", "화성"}
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:
"""COLUMNS 차례의 줄 — 첫 칸(열쇠 · 키)은 그대로 · 상하차 = load · 조달청 = 빔 · 옛 칸(관급)은 뺌."""
first = next(iter(row))
out = {first: row[first]}
for c in COLUMNS[1:]:
out[c] = load if c == LOAD else None if c == GOVT else row.get(c)
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:
"""줄 → 덧정보를 뗀 이름 · 규격 · 상하차 · 묶음 열쇠 · 뗀 말."""
def split_load(row: dict) -> tuple[str, str, str, str | None]:
"""(인도 조건 글을 뺀 이름, 규격, 상하차 값, 뺀 글) — 도착 · 하차 = 하차 · 상차 = 상차 · 글 없음 = -."""
name, spec = row.get("이름") or "", row.get("규격") or ""
words = []
region, base, spec2 = _region(name, spec)
m = re.search(r"-거양$", base)
m = NAME_LOAD.search(name)
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),
name, text = name[: m.start()], m[1]
else:
m = SPEC_LOAD.search(spec)
if not m:
return name, spec, LOADS[0], None
spec, text = spec[: m.start()], m[1] or m[2]
return name, spec, "하차" if text.startswith(("도착", "하차")) else "상차", text
def place(row: dict) -> dict:
"""COLUMNS 차례의 줄 — 첫 칸(열쇠 · 키)은 그대로 · 인도 조건 글은 상하차 칸으로 ·
손 값(상하차)이 있으면 그 값 · 조달청 = 빔 · 옛 칸(관급)은 뺌."""
name, spec, load, _ = split_load(row)
first = next(iter(row))
fix = {
"이름": name,
"규격": spec,
LOAD: row.get(LOAD) or load,
GOVT: None,
}
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
RANKS = ("전국", "지역 없음", "서울", "서울 권역", "경기", "그 밖")
def _rank(region: str) -> int:
if region == "전국":
return 0
if not region:
return 1
if region == "서울":
return 2
if region.startswith("서울"):
return 3
if region in GYEONGGI or region.startswith("경기"):
return 4
return 5
def _middle(pool: list) -> list:
"""값이 중간인 줄부터 가까운 차례(짝수면 아래 중간 · 값 빈 줄은 끝) — 다시 지어도 같은 차례."""
pool = sorted(pool, key=lambda p: (price(p[0]) is None, price(p[0]) or 0))
mid = max((sum(1 for p in pool if price(p[0]) is not None) - 1) // 2, 0)
return sorted(pool, key=lambda p: (abs(pool.index(p) - mid), pool.index(p)))
def _order(pool: list) -> list:
"""남길 차례 — 전국 › 지역 없음 › 서울 › 서울 권역 › 경기 › 그 밖 · 경기 · 그 밖은 값 중간부터 ·
그 위 차례는 앞 줄(작은 키)부터."""
out = []
for rank in range(len(RANKS)):
same = [p for p in pool if _rank(p[1]["지역"]) == rank]
out += _middle(same) if rank >= 4 else same
return out
def merge(rows: list[dict]) -> tuple[list[dict], list[dict]]:
"""통합한 줄(조달청 칸 비움 · COLUMNS 차례) · 기록(덧정보를 뗀 묶음마다 남은 줄 · 지운 줄 · 기준 지역 · 「브」 규칙).
줄 차례 = 키 차례(빌더가 원문 차례로 짓고 대장 키도 그 차례) — 같은 순위 · 관급만 묶음은 앞 줄을 남김."""
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:
rules.append(RANKS[_rank(region)])
if sum(1 for x in sup if _rank(x[1]["지역"]) == _rank(region)) > 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 regions else ""] 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(b for b in bits if b)
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)
return out, log
return {first: row[first], **{c: fix[c] if c in fix else row.get(c) for c in COLUMNS[1:]}}
File diff suppressed because it is too large Load Diff
@@ -166,9 +166,7 @@ def test_oil_power_material_links() -> None:
assert got[dim.OPTION]["연결"]["값"] == "일반용(갑)Ⅰ 저압"
ply, _ = link.m01("재료", {"이름": "합판(내수)", "규격": "12t×1220×2440mm", "단위": "㎡"})
nail, _ = link.m01("재료", {"이름": "철못", "규격": "N 25", "단위": "kg"})
assert (
whole.get(ply)["이름"] == "합판(내수)" and whole.get(nail)["규격"] == "N 25"
) # 830-2 지역 통합
assert whole.get(ply)["이름"] == "합판(내수), 서울" and whole.get(nail)["규격"] == "N 25"
assert link.m01("재료", {"이름": "접착제", "규격": "", "단위": "kg"}) == (None, None)
+2 -2
View File
@@ -123,8 +123,8 @@ def test_alias_machine() -> None:
def test_alias_material() -> None:
line = {"이름": "결속선(철선)", "규격": "#20, 0.9M/M", "단위": "kg"}
assert link.m01("재료", dict(line))[0] is None # 별칭 전 = 못 이음(낱말 순서 다름)
with aliases("MT", {"MT000174": "결속선(철선)"}): # 830-2 — 지역 줄 통합 뒤 한 줄
assert link.m01("재료", dict(line)) == ("MT000174", None)
with aliases("MT", {"MT000174": "결속선(철선)", "MT000175": "결속선(철선)"}):
assert link.m01("재료", dict(line)) == ("MT000174", None) # 같은 품목 지역 줄 = 서울
assert hrow.m01_picks("재료", line)[0]["키"] == "MT000174"
assert units.Units()._M({"묶음": "M", **line})["단위"] == {"자재": "MT000174"}
assert link.m01("재료", {**line, "규격": "#8"})[0] is None # 규격 다름 = 안 이음
@@ -114,12 +114,7 @@ def test_clue_head_and_boq_apart():
key, mark, found = _go("파형강관", "별도계상", "M")
assert key is None and mark == f"{sl.NOTE}{sl.APART}" # 관급 · 일반 둘 = 사람 몫 · 이음 비움
assert found["판정"] == mf.NO_SPEC
pipes = {
"MT015181",
"MT015282",
"MT015356",
} # 830-2 — 관급 줄 이름에서 「(관급)」 뗌 · 800 파형강관 셋
assert {c["키"] for c in found["후보"][:2]} <= pipes
assert {c["키"] for c in found["후보"][:2]} == {"MT015181", "MT015282"}
assert all(c.get("단서") == "단서" for c in found["후보"][:2])
assert found["단서"][0] == "산근 머리 — 파형강관부설 Φ800mm"
with mf.clues_on(clues, "철근가공조립(현장) TYPE Ⅰ"):
@@ -202,5 +197,4 @@ def test_real_files_carry_clues():
and not line.get("단가")
and not any((line.get("값묶음") or {}).values())
)
top = {c["키"] for c in line["자원"]["재료찾기"]["후보"][:2]}
assert len(top) == 2 and top <= {"MT015181", "MT015282", "MT015356"} # 830-2 통합 뒤
assert {c["키"] for c in line["자원"]["재료찾기"]["후보"][:2]} == {"MT015181", "MT015282"}
+2 -2
View File
@@ -157,7 +157,7 @@ def test_apart_item_zero(world) -> None:
kind = mp.kind_of(line["자원"])
found = line["자원"]["재료찾기"]
assert found["판정"] == "규격 없음 — 고르기" and line["이음"] is None
gov = next(c for c in found["후보"] if "파형강관" in c["이름"]) # 830-2 이름에 관급 없음
gov = next(c for c in found["후보"] if "관급" in c["이름"])
fp, name = mp.fingerprint(me, hp)
plain = fh.make(me, hp, PIPE_BLOCK)
mp.write(me, fp, name, {kind: gov})
@@ -179,7 +179,7 @@ def test_apart_item_zero(world) -> None:
assert plain["이름"] in mp.apply_made(fp, {kind: gov}, {kind})["고침"]
now = skpath.read(plain["이름"])
assert _entry(now["문서"], kind)["고름"]["키"] == gov["키"] and now["셈"] == money
assert f"품목: {gov['이름']} {gov['규격']}" in _var(now["문서"], e["기호"])["설명"]
assert "품목: 파형강관(관급)" in _var(now["문서"], e["기호"])["설명"]
# 이음 없는 줄 목록(가져온 식 머리 [이음 고르기]) = 종 열쇠(같은 파일 물음)
assert any(
u.get("종") == kind for u in imp.unlinked(store.read("sangeun", plain["이름"])["문서"])
+35 -56
View File
@@ -1,8 +1,9 @@
"""830-2 재료 표 — 항목 통합 · 가격 열 재정의(요구 827 ~ 832) 지킴.
"""830-2 · 830-9 재료 표 — 가격 열 재정의(요구 827 ~ 832) · 항목 목록 = EST 그대로(요구 834) 지킴.
열 한 벌(상하차 = 단위 뒤 · 조달청 · 물가지 넷 · 일반가격) · 조달청 값 0 · 이름에 덧정보 없음 ·
지역 기준 전국 › 서울 › 경기 › 그 밖 · 관급 사급 한 줄 · 철근 상차 줄 따로 · 재료 아닌 줄 0 ·
치즐 값 없음 · 남는 줄 키 · 물가지 값 = EST 원본 그 줄 · 빌더 = 정본 · 가장 낮은 값이 조달청 칸을 안 봄.
열 한 벌(상하차 = 단위 뒤 · 조달청 · 물가지 넷 · 일반가격) · 조달청 값 0 · 일반가격 빈칸 ·
이름 EST 그대로(지역 · 관급 · 사급 줄 따로) · 상하차 = 인도 조건 글 판독 · 그 글은 이름에서 뺌(요구 836) ·
사급 / 관급 열 없음(요구 837) · 재료 아닌 줄 0 ·
치즐 값 없음 · 줄 · 키 · 이름 · 규격 · 단위 · 물가지 값 = EST 원본 그 줄 · 빌더 = 정본 · 가장 낮은 값이 조달청 칸을 안 봄.
"""
import json
@@ -55,7 +56,7 @@ def raw():
def test_열_한_벌_차례(rows):
assert mm.COLUMNS == COLS
assert mm.COLUMNS == COLS and "사급/관급" not in COLS
assert {tuple(r) for r in rows} == {COLS}
@@ -71,12 +72,11 @@ def test_조달청_빔_일반가격_빔(rows):
) # 처음엔 전부 빈칸 — 손 입력이 생기면 이 줄을 고침
def test_이름에_덧정보_없음(rows):
names = [r["이름"] for r in rows]
assert not [n for n in names if "관급" in n or "사급" in n]
assert not [n for n in names if mm.NAME_LOAD.search(n)]
remi = [r for r in rows if r["이름"].startswith("레미콘")]
assert remi and not [r for r in remi if "(" in r["이름"] or "," in r["이름"]]
def test_이름_EST_그대로_지역_관급_사급_줄_따로(rows):
remi = [r["이름"] for r in rows if r["이름"].startswith("레미콘")]
assert [n for n in remi if "관급" in n] and [n for n in remi if "사급" in n]
assert "레미콘(서울)-사급" in remi and "레미콘(포항)-사급" in remi
assert not [r for r in rows if "통합:" in str(r["비고"] or "")]
def test_재료_아닌_줄_0_치즐_남고_값_없음(rows):
@@ -94,61 +94,40 @@ def test_재료_아닌_줄_0_치즐_남고_값_없음(rows):
]
def test_철근_상차도_줄_따로(rows):
def test_상하차_인도_조건_글_칸으로_이름에서_뺌(rows):
assert not [r for r in rows if mm.split_load(r)[3]] # 이름 · 규격에 인도 조건 글 0
rebar = [r for r in rows if r["이름"].startswith("이형철근") and r["규격"].startswith("HD=")]
assert rebar and all(r["상하차"] == "상차" for r in rebar)
assert not [r for r in rebar if "상차도" in r["규격"]]
def test_지역_기준_전국_서울_경기_그밖():
def kept(names):
rows = [
{"열쇠": str(i), "이름": n, "규격": "25-21-150", "단위": "㎥", "물가자료": p}
for i, (n, p) in enumerate(names)
]
out, log = mm.merge(rows)
assert len(out) == 1
return log[0]["기준 지역"], out[0]["비고"]
base = [("레미콘(부산)-사급", 100), ("레미콘(수원)-사급", 200), ("레미콘(서울)-사급", 300)]
assert kept(base + [("레미콘(전국)-사급", 400)])[0] == "전국"
assert kept(base)[0] == "서울"
region, note = kept(base[:2])
assert region == "수원" and "기준 지역 수원" in note # 경기
region, _ = kept(
[("레미콘(부산)-사급", 100), ("레미콘(대구)-사급", 300), ("레미콘(광주)-사급", 200)]
assert [r for r in rebar if r["상하차"] == "상차"]
assert {r["상하차"] for r in rows if r["이름"].startswith("모 래, ")} >= {"하차"}
assert mm.split_load({"이름": "모 래, 서울, 도착도", "규격": ""}) == (
"모 래, 서울",
"",
"하차",
"도착도",
)
assert region == "광주" # 그 밖 = 값 중간
assert mm.split_load({"이름": "이형철근", "규격": "HD=10mm 하치장 상차도"})[1:3] == (
"HD=10mm",
"상차",
)
assert mm.split_load({"이름": "모래", "규격": "세척사"}) == ("모래", "세척사", "-", None)
def test_관급_사급_한_줄_조달청_빔():
rows = [
{
"열쇠": "1",
"이름": "VR관(관급)",
"규격": "D=300mm",
"단위": "본",
"물가자료": None,
mm.GOVT: 5,
},
{"열쇠": "2", "이름": "VR관", "규격": "D=300mm", "단위": "본", "물가자료": 7},
]
out, _ = mm.merge(rows)
assert [(r["이름"], r["물가자료"], r[mm.GOVT]) for r in out] == [("VR관", 7, None)]
def test_빌더_정본_키_물가지_원본_그대로(rows, raw):
def test_빌더_정본_줄_키_EST_그대로(rows, raw):
book = mk.load_book()["키"]
code = {r["키"]: book[r["키"]]["원문번호"] for r in rows}
src = {r["Code"]: r for r in raw if r.get("Code")}
src = {r["Code"]: r for r in raw if r.get("Code") and r["Code"] not in builder.DROP}
est = [r for r in rows if not str(r["출처"]).startswith(builder.CARRY)]
assert len(est) == 17573 and len(rows) == 17692
assert len(est) == 23908 and len(rows) == 24027 # 830-1 24,092 − 재료 아닌 줄 65
assert [code[r["키"]] for r in est] == [
c for c, s in sorted(src.items(), key=lambda x: x[1]["Index"]) if not builder.dropped(s)
]
for r in est:
s = src[code[r["키"]]]
name, spec, _, _ = mm.split_load({"이름": s.get("Name", ""), "규격": s.get("Size", "")})
assert (r["이름"], r["규격"], r["단위"]) == (name, spec, s.get("Unite", ""))
assert [r[c] or 0 for c in mm.MARKET] == [s.get(f"Cost{i}") or 0 for i in range(2, 6)]
assert not builder.dropped(s)
built, _ = mm.merge(builder.build(raw) + builder.carried()) # 다시 지어도 같은 표
assert [code[r["키"]] for r in rows] == [r["열쇠"] for r in built]
built = [mm.place(r) for r in builder.build(raw, builder.own_prices()) + builder.carried()]
assert [code[r["키"]] for r in rows] == [r["열쇠"] for r in built] # 다시 지어도 같은 표
same = [
c for c in COLS if c not in ("키", "구분", "상세구분", "비고")
] # 분류 두 칸 = 분류기 몫
+3 -9
View File
@@ -342,14 +342,12 @@ def test_main_은_시스템관리자만() -> None:
def test_고르기_자재품목_직종(client: TestClient) -> None:
assert client.get("/api/m01/pick", params={"kind": "procure"}).status_code == 404
market = _get(client, "/api/m01/pick", kind="price", q="H형강", limit=50)
# 830-2 — 조달청 칸은 아직 빔(관급 표시 없음) · 값 = 사급 칸 가운데 낮은 값
market = _get(client, "/api/m01/pick", kind="price", q="H형강 전국", limit=50)
# 830-2 · 830-9 — 조달청 칸은 아직 빔(관급 표시 없음) · 값 = 사급 칸 가운데 낮은 값
assert market["items"] and not any(i["관급"] for i in market["items"])
assert any(i["값"] and i["값"] > 0 for i in market["items"])
# 키로도 찾음(찾기 칸 글 「이름 · 규격 · 코드」) · 이름 낱말 AND 는 그대로
cement = _get(client, "/api/m01/pick", kind="price", q="시멘트")[
"items"
] # 830-2 이름에 지역 없음
cement = _get(client, "/api/m01/pick", kind="price", q="시멘트 전국")["items"]
code = cement[0]["ref"]
by_code = _get(client, "/api/m01/pick", kind="price", q=code.lower())["items"]
assert [i["ref"] for i in by_code] == [code]
@@ -389,10 +387,6 @@ def test_구분_목록(client: TestClient) -> None:
assert steel["details"]
@pytest.mark.xfail(
strict=True,
reason="830-3 연결 할 일 — 검색어 「시멘트 전국」 의 지역 낱말이 통합 뒤 이름에 없음",
)
def test_재료_고르기_줄_단가는_검색어_첫째_자재품목의_낮은_값(client: TestClient) -> None:
"""옛 대표 키 줄이 기본 검색어로 바뀜(PLAN 8-3) — 값은 검색어 첫째(옛 대표) 줄의 낮은 값."""
one = _get(client, "/api/m01/logic", key=key_of("GC000628"))
+8 -8
View File
@@ -54,17 +54,17 @@ def _line(client: TestClient, row: dict, picks: dict | None = None, path: str =
def test_검색어로_찾고_지역_낱말이_맞는_줄이_먼저(client) -> None:
seoul = _line(client, _sample("합판(내수) 12t 서울", "㎡"))
assert seoul["출처"].startswith("MT000018") and seoul["단가"] == 11354 # 값 열 가운데 낮은 값
busan = _line(client, _sample("합판(내수) 12t 부산", "㎡")) # 830-2 지역 줄 통합 — 한 줄
assert busan["출처"].startswith("MT000018") and busan["단가"] == 11354
busan = _line(client, _sample("합판(내수) 12t 부산", "㎡"))
assert busan["출처"].startswith("MT000020") and busan["단가"] == 11488
assert "단위경고" not in seoul
def test_고름은_그_줄만_그_품목으로_셈(client) -> None:
row = _sample("합판(내수) 12t 서울", "㎡")
got = _line(client, row, {"0": "MT000037"})
assert got["출처"].startswith("MT000037") and got["단가"] == 8465
got = _line(client, row, {"0": "MT000019"})
assert got["출처"].startswith("MT000019") and got["단가"] == 11556
assert _line(client, row)["단가"] == 11354 # 요청마다 — 저장 안 함
text = _line(client, row, {"0": "MT000037"}, "text")
text = _line(client, row, {"0": "MT000019"}, "text")
assert text["ok"] is True # 읽는 식도 같은 몸으로 돎
@@ -137,12 +137,12 @@ def test_재료_줄마다_품목_칸(client) -> None:
i for i, x in enumerate(lines) if x.get("품목", {}).get("이름", "").startswith("합판(내수)")
)
first = lines[idx]["품목"]
assert first["이름"] == "합판(내수)" and first["값칸"]
assert first["이름"] == "합판(내수), 서울" and first["값칸"]
assert {"키", "이름", "규격", "단위", "값칸"} <= set(first)
lines = client.post("/api/m01/calc", json={**body, "고름": {str(idx): "MT000037"}}).json()[
lines = client.post("/api/m01/calc", json={**body, "고름": {str(idx): "MT000019"}}).json()[
"lines"
]
assert lines[idx]["품목"]["키"] == "MT000037" and "준내수" in lines[idx]["품목"]["이름"]
assert lines[idx]["품목"]["키"] == "MT000019" and "인천" in lines[idx]["품목"]["이름"]
def test_목록_검색은_띄어쓰기를_무시(client) -> None:
+18 -11
View File
@@ -123,17 +123,24 @@ H_BEAM = {"이름": "H 형강", "규격": "SS275, 100*100*6*8mm", "단위": "Ton
def test_전국_지역_줄글_전국품목():
"""830-2 — 재료 표가 지역 · 관급/사급 줄을 한 줄로 통합 → 지역 글이 있든 없든 그 한 줄로 바로 이음
(「(전국)」 · 지역 자리표 품목이 안 생김)."""
from M02_MasterTemplete import M02_Hopyo_Material as hmat
for line in (REMICON, {**REMICON, "비고": "강원도 강릉시"}):
rows, tags = hmat.sure(hmat.clean(line)[0])
one = rows[0]["one"]
assert (one["자재"], one["이름"], one["규격"]) == ("MT004258", "레미콘", "25-21-120")
assert tags == [] and hmat.national(line) is None
rows, tags = hmat.sure(hmat.clean(REMICON)[0]) # 지역 낱말 없음 · (전국) 품목 없음
assert rows[0]["one"] == {
"전국": True,
"이름": "레미콘(전국)-관급",
"규격": "25-21-120",
"단위": "㎥",
}
assert tags == [hmat.NATIONAL_TAG] and all("자재" in r["one"] for r in rows[1:])
paid = {**REMICON, "규격": "", "갈래": "자재", "단가": {"재료비": 90000}}
assert hmat.national(paid) is None
for line in (H_BEAM, {**H_BEAM, "비고": "서울"}):
rows, tags = hmat.sure(hmat.clean(line)[0])
assert rows[0]["one"]["자재"] == "MT001694" and tags == []
assert hmat.national(paid)["이름"] == "레미콘(전국)-사급" # 재료비 줄 = 사급 꼬리
named = {**REMICON, "비고": "강원도 강릉시"} # 줄 글에 지역 = 그 지역(원문)
rows, tags = hmat.sure(hmat.clean(named)[0])
assert rows[0]["one"]["이름"] == "레미콘(관급)-강원도(강릉시)" and tags == [hmat.REGION_TAG]
assert hmat.national(named) is None
rows, tags = hmat.sure(hmat.clean(H_BEAM)[0]) # (전국) 품목 있음 = 그것
assert rows[0]["one"]["자재"] == "MT001694" and tags == []
assert hmat.national(H_BEAM) is None
seoul = hmat.sure(hmat.clean({**H_BEAM, "비고": "서울"})[0])[0][0]["one"]
assert seoul["이름"] == "H 형강(서울)"
+7 -10
View File
@@ -766,16 +766,15 @@ def test_품셈재료_자재지역_후보(whole):
mf.element(linked, "MP900003", {})
with pytest.raises(mf.FormulaError, match="지역 값 없음"):
mf.element(linked, "MP900003", {"자재지역": "울진"})
with pytest.raises(
mf.FormulaError, match="지역 값 없음"
): # 830-2 지역 줄 통합 — 레미콘 지역 줄 없음
# 830-9 — 울진 레미콘 줄은 관급 줄뿐(19 줄) · 조달청 칸이 비어 값 있는 후보 0
with pytest.raises(mf.FormulaError, match="지역 값 없음"):
mf.element(linked, "MP900004", {"자재지역": "울진"})
with pytest.raises(mf.FormulaError, match="지역 값 없음"): # 830-2 — 모래 지역 줄도 통합됨
mf.element(linked, "MP900003", {"자재지역": "수원"})
value, source = mf.element(linked, "MP900003", {"자재지역": "수원"})
assert source.startswith("MT") and value > 0
# PLAN 7-4b 뒤 절반 — 호표에 조건 묶음을 바로 박아도 같은 길(품셈재료 간접 없이)
cond = {"이름": "모래", "규격": "", "지역": "입력"}
with pytest.raises(mf.FormulaError, match="지역 값 없음"):
mf.element(whole, cond, {"자재지역": "수원"}, "㎥")
value2, source2 = mf.element(whole, cond, {"자재지역": "수원"}, "㎥")
assert value2 == value and source2 == source
# ── 재료 고르기 조건 (`_틀.md` 7장) ────────────────────────────────────
@@ -1000,9 +999,7 @@ def test_품셈재료_고르기_칸이_옛_파일에_있음():
rows = whole.index["MP"]
assert rows and all("고르기" in row for row in rows.values())
assert any(isinstance(row["고르기"], dict) for row in rows.values())
# 옛 자료가 가리키던 줄 — 830-2 통합으로 이름의 「(관급)」 이 빠졌거나(커플링밴드) 합쳐짐(시멘트벽돌)
errs = cm.check_links(whole)
assert len(errs) == 4 and all("커플링밴드" in e or "MT005769" in e for e in errs)
assert cm.check_links(whole) == []
assert cm.master().index.get("MP") is None # 살아 있는 마스터엔 이제 없음