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

This commit is contained in:
2026-11-15 09:58:26 +09:00
20 changed files with 24897 additions and 24102 deletions
@@ -1,12 +1,12 @@
"""상용 적산 자료(ESTX 2026-07) 자재 → 재료_자재품목.json — 개발용 · 배포 전 판권 구매 뒤 갱신.
ESTX 자재 표 = StuffcDat(칸 뜻: 다산소프트/ESTX_값사전_자재노무기계.md).
열 한 벌 = material_merge.COLUMNS(키 · 구분 · 상세구분 · 이름 · 규격 · 단위 · 지역 · 상하차 · 조달청 · 물가자료 · 유통물가 · 물가정보 · 거래가격 · 일반가격 · 출처 · 비고 · 면수).
열 한 벌 = material_merge.COLUMNS(키 · 구분 · 상세구분 · 이름 · 규격 · 단위 · 시도 · 시군구 · 상하차 · 조달청 · 물가자료 · 유통물가 · 물가정보 · 거래가격 · 일반가격 · 출처 · 비고 · 면수).
Cost2..5 = 물가지 넷 · Page2..5 = 면수. TC(채택) · Cost6(견적가)는 마스터 아님 — 뺌.
Cost1(가격정보 = 관급 정보)은 넣지 않음 — 「조달청」 열은 자리만 두고 비움(요구 827 · 830) · 비고에도 안 적음.
「일반가격」 = 시스템 관리자가 M01 에서 넣는 칸 · 손 값(지역 · 상하차 · 일반가격)은 원문번호로 이어 받음.
「일반가격」 = 시스템 관리자가 M01 에서 넣는 칸 · 손 값(시도 · 시군구 · 상하차 · 일반가격)은 원문번호로 이어 받음.
Cost1 출처 표기로 가린 재료 아닌 줄은 뺌(요구 832) — 「2026(경북)」 품질시험 수수료 · 「~26.10.16」 일반 도로용 블록.
줄 목록 = EST 그대로 — 지역 · 관급 · 사급 줄을 합치지 않음(요구 834) · 지역 · 상하차 칸 = material_merge.place(요구 839).
줄 목록 = EST 그대로 — 지역 · 관급 · 사급 줄을 합치지 않음(요구 834) · 시도 · 시군구 · 상하차 칸 = material_merge.place(요구 839 · 842).
0 · 미저장 = 값 없음(null). 이름·규격은 원문 그대로(띄어쓰기·오자 포함). 코드 없는 빈 줄은 뺌.
ESTX 에 대·중분류 칸이 없어 구분·상세구분은 build_자재품목_분류.py 가 품명 글자 규칙으로 채움(빌더 끝에서 부름).
조달청 줄 · 조달청 관급 값은 넣지 않음(요구 822 — EST 그대로) · update_관급단가*.py 는 지금 정본에 적용 안 함.
@@ -26,6 +26,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
import master_keys as mk # noqa: E402
import material_merge as mm # noqa: E402
import master_region as mr # noqa: E402
from _migrate_base import dump_master # noqa: E402
from compare_estx import ESTX, ROOT, SLOTS, Estx # noqa: E402
@@ -63,7 +64,7 @@ def dropped(r: dict) -> str | None:
def own_prices() -> dict[str, dict]:
"""지금 정본의 손 값(지역 · 상하차 · 일반가격) — 원문번호 → {칸: 값}."""
"""지금 정본의 손 값(시도 · 시군구 · 상하차 · 일반가격) — 원문번호 → {칸: 값}."""
if not OUT.is_file():
return {}
book = mk.load_book()["키"]
@@ -90,7 +91,8 @@ def build(rows: list[dict], own: dict | None = None) -> list[dict]:
"이름": r.get("Name", ""),
"규격": r.get("Size", ""),
"단위": r.get("Unite", ""),
mm.REGION: own.get(code, {}).get(mm.REGION),
mm.SIDO: own.get(code, {}).get(mm.SIDO),
mm.SIGUNGU: own.get(code, {}).get(mm.SIGUNGU),
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)},
@@ -154,9 +156,9 @@ def check(rows: list[dict]) -> None:
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()))
assert all(r[mm.REGION] for r in data["줄"])
assert all(mr.valid_sido(r[mm.SIDO]) and r[mm.SIGUNGU] for r in data["줄"])
print(
f" {mm.REGION}: 있음 {sum(1 for r in data['줄'] if r[mm.REGION] != mm.NONE)} · - {sum(1 for r in data['줄'] if r[mm.REGION] == mm.NONE)}"
f" {mm.SIDO}: 있음 {sum(1 for r in data['줄'] if r[mm.SIDO] != mm.NONE)} · - {sum(1 for r in data['줄'] if r[mm.SIDO] == mm.NONE)}"
)
@@ -179,8 +181,8 @@ def main():
"유류·전력은 뺌 — 유가·전기요금은 재료_유가전력(유류는 값이 비고 글뿐 · 중유는 받지 않음)",
"원문번호(다산소프트 품번) 칸 없음 — 대조는 _키대장.json 에만",
"조달청 칸 = 자리만 둠 · 값 없음 — 원문 「가격정보」(관급 정보) 값은 넣지 않음(요구 827 · 830)",
"지역 = 단위 바로 뒤 — 이름 · 규격에 적힌 지역 글을 그대로 떼어 이 칸으로(꼴 일곱 · 관급 · 사급 글은 이름에 남김 · 없음 = -)(요구 839) · 값이 아닌 표지",
"상하차 = 상차 · 하차 · -(기본 · 모름) · 지역 바로 뒤 — 이름 · 규격 끝 인도 조건 글(도착도 · 하차도 = 하차 · 채취장상차 · 하치장 상차도 · 상차포함 = 상차 · 상차제외 = -)을 떼어 이 칸으로(요구 828 · 831 · 836 · 839) · 값이 아닌 표지",
"시도 · 시군구 = 단위 바로 뒤 — 이름 · 규격에 적힌 지역 글을 떼어 행정구역 표(_행정구역.json)로 풀어 두 칸으로(시도 = 짧은 이름 · 여럿은 쉼표 · 시도만 = 시군구 「전체」 · 읍면 = 「시군구(읍면)」 · 옛 이름은 지금 이름 · 조합 이름은 비고 · 없음 = - · -)(요구 839 · 842) · 값이 아닌 표지",
"상하차 = 상차 · 하차 · -(기본 · 모름) · 시군구 바로 뒤 — 이름 · 규격 끝 인도 조건 글(도착도 · 하차도 = 하차 · 채취장상차 · 하치장 상차도 · 상차포함 = 상차 · 상차제외 = -)을 떼어 이 칸으로(요구 828 · 831 · 836 · 839) · 값이 아닌 표지",
"줄 목록 = EST 그대로 — 지역 · 관급 · 사급 · 제조사 · 조합 줄을 합치지 않음(요구 834)",
"재료 아닌 줄은 뺌 — 품질시험 수수료(가격정보 출처 「2026(경북)」) · 일반 도로용 블록(「~26.10.16」)(요구 832)",
"조달청 줄 · 조달청 관급 값은 넣지 않음 — EST 그대로(요구 822)",
@@ -303,8 +303,12 @@ def check_form(name: str, data: dict) -> list[str]:
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 book == "자재품목" and not str(row.get(mm.REGION) or "").strip():
out.append(f"{where} · 「{mm.REGION}」 빈 칸 — 모르면 「{mm.NONE}」")
if book == "자재품목" and not (
mm.mr.valid_sido(row.get(mm.SIDO)) and str(row.get(mm.SIGUNGU) or "").strip()
):
out.append(
f"{where} · 「{mm.SIDO}」 는 시도 짧은 이름(쉼표 · 전국 · -) · 「{mm.SIGUNGU}」 빈 칸 아님"
)
if kind == "품셈재료":
out += _check_linked(where, row)
if kind == "요소" and isinstance(row.get("값"), dict):
@@ -12,6 +12,7 @@ import re
import unicodedata
from decimal import Decimal
import material_merge as mmerge
import master_unit as mu
LINKED = "MP" # 품셈재료 테이블ID
@@ -45,9 +46,9 @@ def plain(s) -> str:
def label(row: dict) -> str:
"""맞대기 글 = 이름 + 지역 칸(요구 839 · 830-10) — 검색어 · 자재지역의 지역 낱말이 지역 칸과 맞대짐."""
where = row.get("지역")
return plain(row.get("이름")) + ("" if where in (None, "", "-") else plain(where))
"""맞대기 글 = 이름 + 시도 · 시군구 칸(요구 839 · 842 · 830-11) — 검색어 · 자재지역의 지역 낱말이 두 칸과 맞대짐."""
where = mmerge.where(row)
return plain(row.get("이름")) + ("" if where == mmerge.NONE else plain(where))
def plain_spec(s) -> str:
+3 -1
View File
@@ -137,7 +137,9 @@ def priced(master, item: dict, ref, env: dict, index: int | None):
"이름": row.get("이름"),
"규격": row.get("규격"),
"단위": row.get("단위"),
**{c: row[c] for c in ("지역", "상하차") if c in row}, # 830-10
**(
{"지역": mm.mmerge.where(row), "상하차": row.get("상하차")} if "시도" in row else {}
), # fmt: skip
"값칸": slot,
}
return price, source, why, extra
@@ -0,0 +1,222 @@
"""지역 글 → 시도 · 시군구 두 칸(요구 842 · PLAN 830-11) — 표 = `_행정구역.json` · 시험 = test_830_11_material_sido.
`split(글)` → (시도, 시군구, 조합 글 | None)
- 없음(-) = (-, -) · 전국 = (전국, 전체) · 시도만 = (시도, 전체) · 「전지역」 은 전체
- 시군구만 = 표로 시도를 찾음 · 시도 + 시군구 = 둘 다 · 여러 시군구 · 시도 = 쉼표(적힌 차례)
- 읍면 = 「시군구(읍면)」 꼴 · 「… 제외」 = 시군구 칸 끝에 「 제외」
- 옛 이름 · 긴 이름 · 「…지역」 = 지금 짧은 이름 · 조합 이름(「…(협)」) = 셋째(비고 몫)
- 30 자 잘린 끝 낱말 = 같은 시도 안 앞글자가 하나뿐인 시군구로 채움
- 못 풀면 ValueError(빌더가 멈춤).
"""
from __future__ import annotations
import json
import re
from functools import lru_cache
from pathlib import Path
DB = Path(__file__).resolve().parents[1] / "_행정구역.json"
NONE = "-"
NATION = "전국"
WHOLE = "전체"
EXCEPT = "제외"
_UNION = re.compile(r",?\s*[가-힣]+\(협\)")
_CUT = ",.()및 "
def short(name: str) -> str:
"""짧은 이름 — 끝 시 · 군 · 구를 뗌(남는 글이 두 자 넘을 때만) · 「송파구」 → 송파 · 「중구」 그대로."""
return name[:-1] if len(name) > 2 and name[-1] in "시군구" else name
@lru_cache(maxsize=1)
def table() -> dict:
"""{시도: [시군구 짧은 이름]} · 낱말 사전 {글: [뜻]} · 옛 이름 글 묶음."""
db = json.loads(DB.read_text(encoding="utf-8"))
sido = {s["이름"]: [short(g) for g in s["시군구"]] for s in db["시도"]}
words: dict[str, list[tuple]] = {} # 뜻 끝 「옛」 = 옛 이름으로 풂
def put(text: str, meaning: tuple) -> None:
words.setdefault(text, [])
if meaning not in words[text]:
words[text].append(meaning)
for s in db["시도"]:
for text in (s["이름"], s["정식"], s["정식"].removesuffix("도")):
put(text, ("시도", s["이름"]))
for g in s["시군구"]:
for text in {g, short(g)}:
put(text, ("시군구", s["이름"], short(g)))
put(NATION, ("시도", NATION))
for text in ("전지역", WHOLE):
put(text, ("전체",))
put(EXCEPT, ("제외",))
for kind, book in (("옛", db["옛이름"]), ("별", db["별칭"])):
for src, dst in book.items():
to, src_name = dst.split(), src.split()[-1]
mark = ("옛",) if kind == "옛" and short(src_name) != short(to[-1]) else ()
meaning = ("시도", to[0]) if len(to) == 1 else ("시군구", to[0], short(to[1]))
for text in {src_name, short(src_name)}:
put(text, meaning + mark)
return {"시도": sido, "낱말": words}
def _tokens(text: str) -> list[tuple]:
"""(종류, 뜻 | 글) 줄 — 사전 낱말은 가장 긴 것 · 시군구 뒤 괄호 · 띄움 글 = 읍면(그대로 글)."""
words = table()["낱말"]
out, i, n = [], 0, len(text)
longest = max(map(len, words))
while i < n:
ch = text[i]
if ch in "()":
out.append((ch, None))
i += 1
continue
if ch in ",.및 ":
out.append(("띄움" if ch == " " else "끊음", None))
i += 1
continue
hit = next(
(
text[i : i + k]
for k in range(min(longest, n - i), 0, -1)
if text[i : i + k] in words
),
None,
)
if hit and text[i + len(hit) :].startswith("지역"): # 충남지역 · 제주지역
out.append(("낱말", hit, words[hit]))
i += len(hit) + 2
continue
if hit:
out.append(("낱말", hit, words[hit]))
i += len(hit)
if words[hit][0][0] == "시군구" and i < n:
emd = _emd(text, i)
if emd:
out.append(("읍면", emd[0]))
i = emd[1]
continue
j = i
while j < n and text[j] not in _CUT:
j += 1
out.append(("글", text[i:j]))
i = j
return out
def _emd(text: str, i: int) -> tuple[str, int] | None:
"""시군구 바로 뒤 읍면 글 — 「(미원면 제외)」 · 「 내면」 → (글, 다음 자리) · 아니면 None."""
words = table()["낱말"]
if text[i] == "(":
depth, j = 0, i
while j < len(text):
depth += {"(": 1, ")": -1}.get(text[j], 0)
if depth == 0:
break
j += 1
return text[i + 1 : j], j + 1
if text[i] == " ":
j = i + 1
while j < len(text) and text[j] not in ",)":
j += 1
word = text[i + 1 : j].strip()
if word and word not in words and not any(w in words for w in word.split()):
return word, j
return None
def split(text) -> tuple[str, str, str | None]:
"""지역 글 → (시도, 시군구, 조합 글 | None) — 맨 위 설명."""
return _parse(text)[:3]
def is_old(text) -> bool:
"""지역 글을 옛 이름(개편 전 이름)으로 풀었나 — 보고 셈용."""
return _parse(text)[3]
def _parse(text) -> tuple[str, str, str | None, bool]:
raw = str(text or "").strip()
if raw in ("", NONE):
return NONE, NONE, None, False
unions = [u.lstrip(", ") for u in _UNION.findall(raw)]
body = _UNION.sub("", raw).strip(", ")
sidos: list[str] = []
towns: list[tuple[str, str]] = [] # (시도, 짧은 이름(읍면))
cur, cut, old, i = None, False, False, 0
tokens = _tokens(body)
while i < len(tokens):
tok = tokens[i]
if tok[0] == "낱말":
meanings = tok[2]
kinds = {m[0] for m in meanings}
if kinds == {"전체"}:
pass
elif kinds == {"제외"}:
cut = True
elif "시도" in kinds and (
cur is None or "시군구" not in kinds or not _inside(tokens, i)
):
got = next(m for m in meanings if m[0] == "시도")
cur, old = got[1], old or got[-1] == "옛"
if cur not in sidos:
sidos.append(cur)
else:
got = _town(meanings, cur, sidos, raw)
towns.append(got[1:3])
old = old or got[-1] == "옛"
elif tok[0] == "읍면" and towns:
s, g = towns[-1]
towns[-1] = (s, f"{g}({tok[1]})")
elif tok[0] == "글":
if i != len(tokens) - 1 or body.count("(") <= body.count(")"):
raise ValueError(f"지역 글 못 풂 — 「{tok[1]}」 ({raw})")
towns.append(_complete(tok[1], cur, sidos, raw))
i += 1
for s, _ in towns:
if s not in sidos:
sidos.append(s)
if not sidos:
raise ValueError(f"지역 글 못 풂 — 시도 없음 ({raw})")
names = list(dict.fromkeys(g for _, g in towns))
sgg = ",".join(names) + (f" {EXCEPT}" if cut else "") if names else WHOLE
sido = ",".join(sidos)
return sido, (WHOLE if sido == NATION else sgg), " · ".join(unions) or None, old
def _inside(tokens: list[tuple], i: int) -> bool:
"""i 번 낱말이 괄호 안인지."""
depth = 0
for t in tokens[:i]:
depth += {"(": 1, ")": -1}.get(t[0], 0)
return depth > 0
def _town(meanings: list[tuple], cur, sidos: list[str], raw: str) -> tuple:
"""시군구 낱말 → 뜻(「시군구」, 시도, 짧은 이름[, 「옛」]) — 지금 시도 › 적힌 시도 › 전국에서 하나뿐인 것."""
towns = [m for m in meanings if m[0] == "시군구"]
for pool in ([cur], sidos, None):
got = [m for m in towns if pool is None or m[1] in pool]
if len({(m[1], m[2]) for m in got}) == 1:
return got[0]
raise ValueError(f"시군구 소속 못 정함 — {[m[1:] for m in towns]} ({raw})")
def _complete(head: str, cur, sidos: list[str], raw: str) -> tuple[str, str]:
"""잘린 끝 낱말 → 앞글자가 하나뿐인 시군구(지금 시도 › 적힌 시도 › 전국)."""
sido = table()["시도"]
for pool in ([cur] if cur else [], sidos, list(sido)):
got = [(s, g) for s in pool for g in sido.get(s, []) if g.startswith(head)]
if len(got) == 1:
return got[0]
raise ValueError(f"잘린 지역 글 못 풂 — 「{head}」 ({raw})")
def valid_sido(value) -> bool:
"""시도 칸 값 — 「-」 · 「전국」 · 시도 짧은 이름(쉼표로 여럿)."""
if value in (NONE, NATION):
return True
parts = str(value or "").split(",")
return all(p in table()["시도"] for p in parts) and len(set(parts)) == len(parts)
+26 -11
View File
@@ -1,29 +1,32 @@
"""재료 자재품목 열 한 벌 · 지역 · 상하차(요구 828 · 830 · 831 · 834 · 836 · 837 · 839 · PLAN 830-10).
"""재료 자재품목 열 한 벌 · 시도 · 시군구 · 상하차(요구 828 · 830 · 831 · 834 · 836 · 837 · 839 · 842 · PLAN 830-11).
build_재료_자재품목 이 부름 · 시험 = test_830_2_material_merge.
줄 목록 = EST 그대로(지역 · 관급 · 사급 · 제조사 · 조합 줄마다 따로) — 합치지 않음.
열 한 벌 = COLUMNS — 지역 · 상하차 = 단위 바로 뒤 · 가격 열 = 조달청(관급) · 물가지 넷 · 일반가격(사급).
지역 칸 = 이름 · 규격에 적힌 지역 글 그대로(`split_region` 꼴 일곱 · 표기 안 고침 · 없음 = -) — 그 글은 이름 · 규격에서 뺌
열 한 벌 = COLUMNS — 시도 · 시군구 · 상하차 = 단위 바로 뒤 · 가격 열 = 조달청(관급) · 물가지 넷 · 일반가격(사급).
지역 글 = 이름 · 규격에 적힌 지역 글(`split_region` 꼴 일곱) — 그 글은 이름 · 규격에서 빼고
`master_region.split`(표 `_행정구역.json`)으로 시도 · 시군구 두 칸(없음 = - · -) · 조합 이름(「…(협)」)은 비고로
· 관급 · 사급 글은 이름에 남김 · 상표 · 산지 · 수종 글(「보령웅천석」 · 「농산개발(홍성)」)은 지역 아님
· EST 이름 칸 30 자에서 잘린 지역 글은 잘린 그대로 · 비고에 CUT.
· EST 이름 칸 30 자에서 잘린 지역 글은 읽히는 데까지(끝 낱말은 하나뿐인 시군구로) · 비고에 CUT.
사급 / 관급 고르기는 재료 표 열이 아님 — 단가산출 쪽 몫(요구 837).
상하차 칸 = 상차 · 하차 · -(기본 · 모름) — 이름 · 규격 끝 인도 조건 글로 채우고 그 글은 이름 · 규격에서 뺌(요구 836)
「도착도」 · 「하차도」 = 하차 · 「채취장상차(도)」 · 「하치장 · 생산공장 상차도」 · 「(상차도)」 = 상차 ·
「(상차포함)」 = 상차 · 「(상차제외)」 = -(포함 안 됨 · 같은 이름의 상차 줄과 상하차 칸으로 갈림)
조달청 칸 값은 모두 비움(EST 「가격정보」 — 요구 827 · 831) · 일반가격 = 시스템 관리자가 넣는 칸.
지역 · 상하차 · 일반가격 손 값은 빌더가 이어 받음(지역 · 상하차 「-」 = 손 값 아님 — 판독 값).
시도 · 시군구 · 상하차 · 일반가격 손 값은 빌더가 이어 받음(「-」 = 손 값 아님 — 판독 값).
"""
import re
import master_region as mr
MARKET = ("물가자료", "유통물가", "물가정보", "거래가격")
REGION = "지역" # 표지 칸 — 적힌 지역 글 그대로 · 없음 = NONE
SIDO, SIGUNGU = "시도", "시군구" # 표지 칸 — 없음 = NONE · 지역 글 = `where`
NONE = "-"
LOAD = "상하차" # 표지 칸(값 칸 아님)
LOADS = ("-", "상차", "하차") # 첫째 = 기본(모름)
GOVT = "조달청" # 관급 가격 칸 — 가장 낮은 값 고르기에 안 넣음(요구 826 · 830)
OWN = "일반가격" # 시스템 관리자 손 입력 칸(사급)
HAND = (REGION, LOAD, OWN) # 빌더가 이어 받는 손 값 칸
HAND = (SIDO, SIGUNGU, LOAD, OWN) # 빌더가 이어 받는 손 값 칸
COLUMNS = ( # 열 한 벌 · 차례(요구 830 · 831)
"키",
"구분",
@@ -31,7 +34,8 @@ COLUMNS = ( # 열 한 벌 · 차례(요구 830 · 831)
"이름",
"규격",
"단위",
REGION,
SIDO,
SIGUNGU,
LOAD,
GOVT,
*MARKET,
@@ -65,10 +69,13 @@ def split_load(row: dict) -> tuple[str, str, str, str | None]:
def place(row: dict) -> dict:
"""COLUMNS 차례의 줄 — 첫 칸(열쇠 · 키)은 그대로 · 인도 조건 글은 상하차 칸 · 지역 글은 지역 칸으로 ·
손 값(지역 · 상하차 — 「-」 아님)이 있으면 그 값 · 조달청 = 빔 · 옛 칸(관급)은 뺌."""
"""COLUMNS 차례의 줄 — 첫 칸(열쇠 · 키)은 그대로 · 인도 조건 글은 상하차 칸 · 지역 글은 시도 · 시군구 칸으로 ·
손 값(시도 · 시군구 · 상하차 — 「-」 아님)이 있으면 그 값 · 조달청 = 빔 · 옛 칸(관급 · 지역)은 뺌."""
name, spec, load, _ = split_load(row)
name, spec, region = split_region(name, spec)
if region == NONE and row.get("지역") not in (None, "", NONE):
region = row["지역"] # 옛 지역 칸(830-10) — 이름에서 이미 뗀 ESTX 밖 줄
sido, sgg, union = mr.split(region)
first = next(iter(row))
note = row.get("비고")
if (
@@ -77,10 +84,12 @@ def place(row: dict) -> dict:
and region.count("(") > region.count(")")
):
note = " · ".join(x for x in (note, CUT) if x)
note = " · ".join(x for x in (note, union) if x) or None
fix = {
"이름": name,
"규격": spec,
REGION: row.get(REGION) if row.get(REGION) not in (None, "", NONE) else region,
SIDO: row.get(SIDO) if row.get(SIDO) not in (None, "", NONE) else sido,
SIGUNGU: row.get(SIGUNGU) if row.get(SIGUNGU) not in (None, "", NONE) else sgg,
LOAD: row.get(LOAD) if row.get(LOAD) not in (None, "", NONE) else load,
GOVT: None,
"비고": note,
@@ -88,6 +97,12 @@ def place(row: dict) -> dict:
return {first: row[first], **{c: fix[c] if c in fix else row.get(c) for c in COLUMNS[1:]}}
def where(row: dict) -> str:
"""줄 지역 글 = 시도 + 시군구(「전체」 · - 뺌) — 「경북 칠곡」 · 「전국」 · 없음 = -(찾기 · 맞대기 · 후보 글)."""
got = [str(row.get(c)) for c in (SIDO, SIGUNGU) if row.get(c) not in (None, "", NONE, mr.WHOLE)]
return " ".join(got) or NONE
# ── 지역 글(요구 839 · 조사 818 2 절 꼴 일곱) ─────────────────────────────
CUT = "지역 글 잘림 — EST 이름 칸 30 자"
PLACES = frozenset(