feat(830-2): 재료 DB 가격 열 재정의 · 항목 통합 이어 감 — 조달청 · 일반가격 · 상하차 · 재료 아닌 줄 삭제

- 열 한 벌 = 키 · 구분 · 상세구분 · 이름 · 규격 · 단위 · 상하차 · 조달청 · 물가자료 · 유통물가 · 물가정보 · 거래가격 · 일반가격 · 출처 · 비고 · 면수(material_merge.COLUMNS 한 곳 · check_master 가 씀)
- 「관급」 칸 → 「조달청」(값 0 · EST 가격정보 값 안 씀) · 새 칸 「일반가격」(빈칸 · 시스템 관리자 손 입력 · 빌더가 원문번호로 이어 받음) · 상하차 = 단위 바로 뒤
- 남는 줄 지역 기준 = 전국 › 지역 없음 › 서울 › 서울 권역 › 경기 › 그 밖(값 중간) · 기준 지역은 비고 「통합: 기준 지역 …」(지역 없는 묶음은 안 적음)
- 재료 아닌 줄 삭제(요구 832) — 품질시험 수수료 60(가격정보 출처 「2026(경북)」) · 일반 도로용 블록 5(「~26.10.16」) · 치즐 2 줄은 남김 · 값 없음
- 재료_자재품목 24,092 → 17,692 줄(통합 6,335 · 재료 아닌 줄 65) · 남는 줄 키 = 830-1 키(7565ee787 이 지운 대장 키를 되살려 다시 지음 · 새 키 0) · 남는 줄 물가지 값 바뀐 것 0 · 빌더 두 번 = 같은 파일
- 가장 낮은 값 = 사급 칸(물가지 넷 + 일반가격) — master_material.SLOTS · M02_Elem_Alias.PRICES · M02 PRICE_SLOTS · 조달청 칸 안 봄 · M01 고르기 「관급」 표시 = 조달청 칸 · B09 대비표 「관급」 출처 = M01 조달청 칸(이름 그대로)
- M01 재료 화면 상하차 = 고르기(부품 createSelectField) · 새 줄 기본 「-」 · 로직 고르기 표 · 시험 계산 값 줄에 일반가격
- check_master 틀 · 로직 · 조합 0 · 단위 27 · 배 7(전과 같음) · _틀.md · _갱신절차.md 열 정의
- 시험: 새 test_830_2_material_merge · test_830_1 지움(EST 그대로 상태 = 830-2 로 끝남 · 남는 검사는 830_2 로) · 지역 줄 · 관급 이름 · 관급 칸을 보던 시험 기대값 고침(m01_api · m01_pick · masterdata_check · 814_1 · 815_1 · 827_2 · 827_3 · 750_1 · m02_hopyo_match · m02_labor_doc) · 「시멘트 전국」 검색어 시험 = xfail(830-3 연결 할 일)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-14 18:36:51 +09:00
co-authored by Claude Opus 5.5
parent a189680bd8
commit 64affbb00e
31 changed files with 18145 additions and 18066 deletions
@@ -1,9 +1,11 @@
"""상용 적산 자료(ESTX 2026-07) 자재 → 재료_자재품목.json — 개발용 · 배포 전 판권 구매 뒤 갱신.
ESTX 자재 표 = StuffcDat(칸 뜻: 다산소프트/ESTX_값사전_자재노무기계.md).
열 한 벌 = 키 · 원문번호 · 구분 · 상세구분 · 이름 · 규격 · 단위 · 물가자료 · 유통물가 · 물가정보 · 거래가격 · 상하차 · 관급 · 출처 · 비고 · 면수.
열 한 벌 = material_merge.COLUMNS(키 · 구분 · 상세구분 · 이름 · 규격 · 단위 · 상하차 · 조달청 · 물가자료 · 유통물가 · 물가정보 · 거래가격 · 일반가격 · 출처 · 비고 · 면수).
Cost2..5 = 물가지 넷 · Page2..5 = 면수. TC(채택) · Cost6(견적가)는 마스터 아님 — 뺌.
Cost1(가격정보 = 관급 정보)은 넣지 않음 — 「관급」 열은 자리만 두고 비움(요구 827) · 비고에도 안 적음.
Cost1(가격정보 = 관급 정보)은 넣지 않음 — 「조달청」 열은 자리만 두고 비움(요구 827 · 830) · 비고에도 안 적음.
「일반가격」 = 시스템 관리자가 M01 에서 넣는 칸 — 지금 정본 값을 원문번호로 이어 받음(다시 지어도 안 사라짐).
Cost1 출처 표기로 가린 재료 아닌 줄은 뺌(요구 832) — 「2026(경북)」 품질시험 수수료 · 「~26.10.16」 일반 도로용 블록.
항목 통합(요구 819 · 827) = material_merge — 지역 · 관급/사급 · 제조사 · 조합 덧정보를 뗀 이름 · 규격 · 단위가 같은 줄을 한 줄로.
0 · 미저장 = 값 없음(null). 이름·규격은 원문 그대로(띄어쓰기·오자 포함). 코드 없는 빈 줄은 뺌.
ESTX 에 대·중분류 칸이 없어 구분·상세구분은 build_자재품목_분류.py 가 품명 글자 규칙으로 채움(빌더 끝에서 부름).
@@ -40,6 +42,8 @@ DROP = {
"M0005250065": "전력(주택용저압)",
}
CARRY = "품셈재료" # 출처 머리 — ESTX 밖 줄(7-4b)
# 뺄 줄 — 관급 아닌 줄의 Cost1 출처 표기(Page1) → 까닭(요구 832)
NOT_MATERIAL = {"2026(경북)": "품질시험 수수료", "~26.10.16": "일반 도로용 블록"}
# 7-4b 덧말 — VR관 1본 길이(D300~D1200 · 이름 「VR관」 · 「VR관(관급)」)
VR_NOTE = "1본 = 2.5m(표준 유효길이 D300~D1200 공통 — PLAN 7-4b 확인 · 출처: https://kr-con.co.kr/product/index.asp?pIdx=product3)"
VR_SIZES = {f"D={d}mm" for d in (300, 400, 450, 500, 600, 700, 800, 900, 1000, 1100, 1200)}
@@ -51,11 +55,28 @@ def num(v):
return int(v) if float(v).is_integer() else v
def build(rows: list[dict]) -> list[dict]:
def dropped(r: dict) -> str | None:
"""재료 아닌 줄 까닭 — 관급 아닌 줄의 가격정보 출처 표기로 가림(요구 832)."""
if r.get("Cost1") and "관급" not in r.get("Name", ""):
return NOT_MATERIAL.get(r.get("Page1"))
return None
def own_prices() -> dict[str, object]:
"""지금 정본의 일반가격(손 입력) — 원문번호 → 값."""
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}
def build(rows: list[dict], own: dict | None = None) -> list[dict]:
own = own or {}
out = []
for r in sorted(rows, key=lambda r: r["Index"]):
code = r.get("Code")
if not code or code in DROP:
if not code or code in DROP or dropped(r):
continue
면수 = {slot: r.get(f"Page{i}") or None for i, slot in enumerate(MARKET, 2)}
비고 = None
@@ -69,8 +90,10 @@ def build(rows: list[dict]) -> list[dict]:
"이름": r.get("Name", ""),
"규격": r.get("Size", ""),
"단위": r.get("Unite", ""),
mm.LOAD: mm.LOADS[0],
mm.GOVT: None,
**{slot: num(r.get(f"Cost{i}")) for i, slot in enumerate(MARKET, 2)},
"관급": None,
mm.OWN: own.get(code),
"출처": f"자재품목 {code}",
"비고": 비고,
"면수": 면수 if any(면수.values()) else None,
@@ -104,25 +127,28 @@ def prune() -> int:
def check(rows: list[dict], gone: int) -> 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) - gone, (len(got), len(raw), gone)
assert len(got) == len(raw) - out - gone, (len(got), len(raw), out, gone)
print(
f"줄 {len(got)} = 원본 코드 있는 줄 {len(raw)} − 통합으로 지운 줄 {gone} (유류 {len(DROP)} 뺌)"
f"줄 {len(got)} = 원본 코드 있는 줄 {len(raw)} − 재료 아닌 줄 {out} − 통합으로 지운 줄 {gone}"
f" (유류 {len(DROP)} 뺌)"
)
for g in got:
r = raw[g["출처"].removeprefix("자재품목 ")]
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)
assert all(r["관급"] is None for r in data["줄"])
assert all(r[mm.GOVT] is None for r in data["줄"])
assert all(tuple(r)[1:] == mm.COLUMNS[1:] for r in data["줄"]) # 첫 칸 = 열쇠(adopt 전)
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(" 남는 줄 전부 물가지 넷 · 면수 = 원본 그 줄 · 관급 칸 빔")
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()))
@@ -142,12 +168,13 @@ def main():
],
"비고": [
"다산소프트 EST 2026-07 자재 · 개발용 · 배포 전 판권 구매",
"값 열 다섯 = 물가지 넷(물가자료 · 유통물가 · 물가정보 · 거래가격) · 관급 — 채택값·기본가 없음 · 쓰는 곳이 값을 고르고 출처를 밝힘",
"값 열 여섯 = 조달청(관급) · 물가지 넷(물가자료 · 유통물가 · 물가정보 · 거래가격) · 일반가격(시스템 관리자 손 입력) — 채택값·기본가·견적가 없음 · 가장 낮은 값 고르기는 조달청 칸을 안 봄",
"유류·전력은 뺌 — 유가·전기요금은 재료_유가전력(유류는 값이 비고 글뿐 · 중유는 받지 않음)",
"원문번호(다산소프트 품번) 칸 없음 — 대조는 _키대장.json 에만",
"관급 칸 = 자리만 둠 · 값 없음 — 원문 「가격정보」(관급 정보) 값은 넣지 않음(요구 827)",
"상하차 = 상차 · 하차 · -(기본 · 모름) — 이름 · 규격의 인도 조건 글(도착도 = 하차 · 채취장상차 · 하치장 상차도 = 상차)을 떼어 이 칸으로(요구 828) · 값이 아닌 표지",
"항목 통합 = 지역 · 관급/사급 · 제조사 · 조합 덧정보를 뗀 이름 · 규격 · 단위가 같은 줄을 한 줄로(전국 › 서울 › 값 중간 지역) · 뗀 말은 비고 「통합: …」 (scripts/material_merge.py · 요구 819 · 827)",
"조달청 칸 = 자리만 둠 · 값 없음 — 원문 「가격정보」(관급 정보) 값은 넣지 않음(요구 827 · 830)",
"상하차 = 상차 · 하차 · -(기본 · 모름) · 단위 바로 뒤 — 이름 · 규격의 인도 조건 글(도착도 · 하차도 = 하차 · 채취장상차 · 하치장 상차도 = 상차)을 떼어 이 칸으로(요구 828 · 831) · 값이 아닌 표지",
"항목 통합 = 지역 · 관급/사급 · 제조사 · 조합 덧정보를 뗀 이름 · 규격 · 단위가 같은 줄을 한 줄로(전국 › 서울 › 경기 › 그 밖 지역 값 중간) · 뗀 말은 비고 「통합: …」 (scripts/material_merge.py · 요구 819 · 827 · 831)",
"재료 아닌 줄은 뺌 — 품질시험 수수료(가격정보 출처 「2026(경북)」) · 일반 도로용 블록(「~26.10.16」)(요구 832)",
"조달청 줄 · 조달청 관급 값은 넣지 않음 — EST 그대로(요구 822)",
"품셈재료에서 옮긴 줄(출처 「품셈재료 …」 · 값 없음)은 ESTX 밖 — 지금 정본에서 이어 받음",
"구분 · 상세구분 = 품명 글자 규칙으로 채움(scripts/build_자재품목_분류.py · 묶음 목록 ref/_묶음_자재품목.md) · 조달청 줄은 조달청 품명을 따름 · 못 가린 줄은 「미분류」",
@@ -155,7 +182,7 @@ def main():
],
}
extra = carried()
merged, log = mm.merge(build(rows) + extra) # 원문 차례 = 키 차례
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))
@@ -69,8 +69,18 @@ OVERRIDE = {
NO_GROUP = "자재품목 묶음에 짝이 없음 — 관리자가 화면에서 이음"
NO_ROW = "자재품목에 그 묶음 줄이 없음"
NO_UNIT = "자재품목 그 묶음에 같은 단위·이름 줄이 없음"
UNIT = {"㎏": "kg", "톤": "ton", "Ton": "ton", "M": "m", "m²": "㎡", "L": "ℓ", "EA": "개", "set": "SET", "Set": "SET"}
PRICE = ("물가자료", "유통물가", "물가정보", "거래가격", "관급")
UNIT = {
"㎏": "kg",
"톤": "ton",
"Ton": "ton",
"M": "m",
"m²": "㎡",
"L": "ℓ",
"EA": "개",
"set": "SET",
"Set": "SET",
}
PRICE = ("물가자료", "유통물가", "물가정보", "거래가격", "일반가격")
NOTE = re.compile(r"\s*·?\s*고르기 없음\([^)]*\)")
FIELD = re.compile(r'("연결": (?:null|"[^"]*"|\{[^{}]*\}))(, "고르기": (?:null|\{[^{}]*\}))?')
NOTE_FIELD = re.compile(r'(, "비고": )(null|"(?:[^"\\]|\\.)*")')
@@ -115,7 +125,9 @@ def 고르기(row: dict, market: list[dict]) -> tuple[dict | None, str]:
got = _단위(row, cond, narrow, market)
if got[0]:
return got
cond = {k: v for k, v in cond.items() if k != "규격"} # 규격까지 좁히면 단위 맞는 줄이 없음 — 규격 없이 다시
cond = {
k: v for k, v in cond.items() if k != "규격"
} # 규격까지 좁히면 단위 맞는 줄이 없음 — 규격 없이 다시
return _단위(row, cond, hits, market)
@@ -133,7 +145,9 @@ def _단위(row: dict, cond: dict, hits: list[dict], market: list[dict]) -> tupl
if u(first["단위"]) == want:
return cond, ""
root = re.sub(r"\s+", "", re.split(r"[(,\-]", str(row["이름"]))[0])
same = [r for r in hits if u(r["단위"]) == want and val(r) and root in re.sub(r"\s+", "", r["이름"])]
same = [
r for r in hits if u(r["단위"]) == want and val(r) and root in re.sub(r"\s+", "", r["이름"])
]
if not same:
return None, NO_UNIT
best = min(same, key=lambda r: (min(val(r)), r["키"]))
+1 -24
View File
@@ -120,30 +120,7 @@ LABOR_STATUS = ("공표", "산정", "미공표", "미확보", "추정")
# 원문별 열 한 벌 — 모든 줄이 이 칸을 이 차례로 가짐(없으면 null)
COLS = {
# 자재품목 — 원문번호(남의 품번)는 줄에 두지 않음 · 대조는 `_키대장.json` 에만
# 상하차 칸 자리 = material_merge.LOAD_AT 바로 앞(한 곳)
"자재품목": tuple(
mm.place(
dict.fromkeys(
(
"키",
"구분",
"상세구분",
"이름",
"규격",
"단위",
"물가자료",
"유통물가",
"물가정보",
"거래가격",
"관급",
"출처",
"비고",
"면수",
)
),
None,
)
),
"자재품목": mm.COLUMNS, # 열 한 벌 · 차례 = material_merge 한 곳
"유가전력": (
"키",
"원문번호",
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""재료_자재품목.json 다섯 값 칸이 모두 빈 줄 가운데 로직이 실제로 가리키는 줄 — 읽기 전용 분석.
· 「빈 줄」 = 물가자료·유통물가·물가정보·거래가격·관급 다섯 칸이 모두 없음
· 「빈 줄」 = 조달청·물가자료·유통물가·물가정보·거래가격·일반가격 여섯 칸이 모두 없음
· 「로직이 가리킴」 = 로직 호표 요소가 그 키를 바로 적음 · 재료 고르기 조건(구분·상세구분·규격)의
후보 줄 · 대표 줄로 콕 집음 · 품셈재료 연결(문자열 키 · 지역 조건)을 따라간 줄
· 지역 조건(모래·자갈 등)은 실제 프로젝트 자재지역을 모르므로 지역을 안 가리고 이름·단위만 맞춤
@@ -24,7 +24,7 @@ sys.path.insert(0, str(HERE))
import check_master as cm # noqa: E402
import master_material as mm # noqa: E402
PRICE = ("물가자료", "유통물가", "물가정보", "거래가격", "관급")
PRICE = (*mm.SLOTS, mm.GOVT)
# 두 ref 표의 키 목록 — 로직이 지금도 부르는지 다시 잣대질할 때 씀 (`_관리자값_미확보.md`·`_없는_재료.md`)
ADMIN_KEYS = (
@@ -17,7 +17,14 @@ import master_unit as mu
LINKED = "MP" # 품셈재료 테이블ID
MARKET = "MT" # 자재품목 테이블ID
PRICED = "MO" # 유가전력 테이블ID — 값 한 칸
SLOTS = ("물가자료", "유통물가", "물가정보", "거래가격", "관급") # 자재품목 값 열
SLOTS = (
"물가자료",
"유통물가",
"물가정보",
"거래가격",
"일반가격",
) # 사급 값 열 — 가장 낮은 값 고르기 몫
GOVT = "조달청" # 관급 값 열 — 가장 낮은 값 고르기에 안 넣음(요구 826 · 830)
PICK_KEYS = (
"구분",
"상세구분",
+63 -36
View File
@@ -1,18 +1,21 @@
"""재료 자재품목 항목 통합(요구 819 · 827 · 828 · PLAN 830-2) — build_재료_자재품목 이 부름.
"""재료 자재품목 항목 통합(요구 819 · 827 · 828 · 830 ~ 832 · PLAN 830-2) — build_재료_자재품목 이 부름.
시험 = test_830_2_material_merge.
묶음 = 이름에서 지역 · 관급/사급 꼬리표 · 제조사 꼬리(「-거양」) · 조합 이름 · 상하차 글을 뺀
이름 · 규격 · 단위 · 상하차가 같은 줄.
규격 = 앞말 「조합공통품목,」 · 규격 속 지역 괄호 뗌
단위 = M=m · m²=㎡ · Ton=톤 · ㎏=kg 표기 하나로 · 비교는 공백을 뺀 글
상하차 칸(요구 828) = 상차 · 하차 · -(기본 · 모름) — 이름 · 규격의 인도 조건 글을 떼어 이 칸으로
「도착도」 = 하차 · 「채취장상차(도)」 · 「하치장 · 생산공장 상차도」 · 「(상차도)」 = 상차
남는 줄 = 사급(관급 아닌) 줄 가운데 전국 › 지역 글 없음(전국으로 봄 · 브) › 서울 › 서울 권역 글
› 값이 중간인 지역 줄(브 · 짝수면 아래 중간)
열 한 벌 = COLUMNS — 상하차 = 단위 바로 뒤 · 가격 열 = 조달청(관급) · 물가지 넷 · 일반가격(사급).
상하차 칸(요구 828 · 831) = 상차 · 하차 · -(기본 · 모름) — 이름 · 규격의 인도 조건 글을 떼어 이 칸으로
「도착도」 · 「하차도」 = 하차 · 「채취장상차(도)」 · 「하치장 · 생산공장 상차도」 · 「(상차도)」 = 상차
남는 줄 = 사급(관급 아닌) 줄 가운데 전국 › 지역 글 없음(전국으로 봄) › 서울 › 서울 권역 글 › 경기
› 그 밖 지역(요구 830 · 831 — 조달청과 같은 차례) · 같은 차례 여럿(경기 · 그 밖)이면 값이 중간인 줄
(짝수면 아래 중간 · 다시 지어도 같은 줄)
· 남길 줄의 값 칸이 전부 비고 다른 줄에 값이 있으면 다음 차례 줄(브)
· 관급 줄만 있는 묶음 = 가장 작은 키 한 줄(브) · 나머지 줄은 지움(이력 안 남김)
덧정보가 하나도 없는 묶음(원래부터 겹친 줄 — 식생 · 종자 등)은 합치지 않음.
관급 칸 값은 모두 비움(EST 「가격정보」 — 요구 827) · 이름에서 뗀 말은 비고 「통합: …」 로.
조달청 칸 값은 모두 비움(EST 「가격정보」 — 요구 827 · 831) · 일반가격 = 시스템 관리자가 넣는 칸(빌더가 이어 받음).
이름에서 뗀 말은 비고 「통합: …」 로.
"""
import re
@@ -20,7 +23,24 @@ import re
MARKET = ("물가자료", "유통물가", "물가정보", "거래가격")
LOAD = "상하차" # 표지 칸(값 칸 아님)
LOADS = ("-", "상차", "하차") # 첫째 = 기본(모름)
LOAD_AT = "관급" # 상하차 칸 자리 = 이 칸 바로 앞 — 자리는 여기 한 곳
GOVT = "조달청" # 관급 가격 칸 — 가장 낮은 값 고르기에 안 넣음(요구 826 · 830)
OWN = "일반가격" # 시스템 관리자 손 입력 칸(사급)
COLUMNS = ( # 열 한 벌 · 차례(요구 830 · 831)
"키",
"구분",
"상세구분",
"이름",
"규격",
"단위",
LOAD,
GOVT,
*MARKET,
OWN,
"출처",
"비고",
"면수",
)
GYEONGGI = {"경기", "수원", "용인", "포천", "가평", "여주", "화성"}
CITY = set(
"""서울 부산 대구 인천 광주 대전 울산 세종 제주 청주 전주 포항 창원 춘천 충주 강릉
천안 원주 여수 구미 순천 수원 목포 금산 영천 논산 삼척 홍성 익산 청도 양양 남원
@@ -40,7 +60,7 @@ SUPPLY = ( # (꼬리표 글, 공급) — 이름 끝 · 괄호 꼴
(r"-사급$", "사급"),
(r",사급$", "사급"),
)
NAME_LOAD = re.compile(r",\s*(도착도|채취장상차도|채취장상차)$") # 모 래, 서울, 도착도
NAME_LOAD = re.compile(r",\s*(도착도|하차도|채취장상차도|채취장상차)$") # 모 래, 서울, 도착도
SPEC_LOAD = re.compile(
r"\s*(?:\((상차도)\)|((?:하치장|생산공장)\s*상차도))$"
) # HD=10mm 하치장 상차도
@@ -48,19 +68,16 @@ COOP = re.compile(r"^조합공통품목(?:\(([^)]*)\))?,?\s*")
def load_of(text: str) -> str:
"""인도 조건 글 → 상하차 값 — 도착 = 하차 · 상차 = 상차."""
return "하차" if text.startswith("도착") else "상차"
"""인도 조건 글 → 상하차 값 — 도착 · 하차 = 하차 · 상차 = 상차."""
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
"""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
@@ -163,6 +180,9 @@ def price(row: dict):
return min(v) if v else None
RANKS = ("전국", "지역 없음", "서울", "서울 권역", "경기", "그 밖")
def _rank(region: str) -> int:
if region == "전국":
return 0
@@ -172,25 +192,30 @@ def _rank(region: str) -> int:
return 2
if region.startswith("서울"):
return 3
return 4
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:
"""남길 차례 — 전국 › 지역 없음 › 서울 › 서울 권역 › 값이 중간인 지역 줄부터 가까운 차례 · 같으면 앞 줄(작은 키)."""
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
"""남길 차례 — 전국 › 지역 없음 › 서울 › 서울 권역 › 경기 › 그 밖 · 경기 · 그 밖은 값 중간부터 ·
그 위 차례는 앞 줄(작은 키)부터."""
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:
@@ -208,8 +233,10 @@ def merge(rows: list[dict]) -> tuple[list[dict], list[dict]]:
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 _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:
@@ -219,7 +246,7 @@ def merge(rows: list[dict]) -> tuple[list[dict], list[dict]]:
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 "관급 줄만"]
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]):
@@ -227,7 +254,7 @@ def merge(rows: list[dict]) -> tuple[list[dict], list[dict]]:
if words:
bits.append("뗀 말 " + ", ".join(words))
r, info = best
note = "통합: " + " · ".join(bits)
note = "통합: " + " · ".join(b for b in bits if b)
new[id(r)] = place(
{
**r,
@@ -247,5 +274,5 @@ def merge(rows: list[dict]) -> tuple[list[dict], list[dict]]:
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})
out.append(r)
return out, log