feat(830-11): 재료 지역 열 → 시도 · 시군구 두 칸 · 행정구역 표 따로(_행정구역.json)

- 행정구역 표 = 시도 17(짧은 이름) + 전국 · 시도별 시군구 229(자치 227 + 제주 행정시 2 · 2026-07-01 인천 제물포 · 영종 · 검단 반영) · 옛 이름(강원도 · 전라북도 · 청원 · 마산 · 진해 · 연기 · 인천 남구 · 중구 · 동구 · 경북 군위) · 별칭(영종도 · 용유도 · 세종시 · 제주도)
- 풀이 master_region.split — 시도만 = 시군구 「전체」 · 시군구만 = 시도 찾음 · 읍면 = 시군구(읍면) · 여러 시도 = 쉼표 · 제외 조건 = 끝에 「 제외」 · 조합 이름 = 비고 · 잘린 끝 낱말 = 하나뿐인 시군구(동두 → 동두천 · 금 → 금천)
- 지역 글 242 종 모두 풀림(못 푼 것 0) · 옛 이름 바꾼 줄 648 · 줄 · 키 · 값 그대로(24,027 · 비고만 조합 121 줄)
- 쓰는 곳: 지역 글 = material_merge.where(시도 + 시군구) — M01 찾기(label · 검색어 · 자재지역) · 표 찾기 · 후보 글 · 불러오기(M02_Mat_Place · Hopyo_Match · Hopyo_Material — 가져온 지역 낱말도 짧은 이름으로 · 시군구가 있으면 그것으로 맞댐)
- M01 화면: 시도 = 고르기(목록 밖 값 「대전,충남」 은 끝에) · 시군구 = 글 · 새 줄 - · -
- 로직 1,520 시험 계산 전후 다름 0 · check_master 틀 · 본문 · 로직 · 조합 · 목차 0 · 단위 27 · 배 7(전과 같음)
- 빌더 adopt 가 로직 장 파일 「차례」 를 하나씩 미는 것은 이번 일 밖 — 되돌림(로직 파일 손대지 않음)
- 새 지킴 test_830_11_material_sido · 낡은 시험 고침: test_830_10 · test_830_2(열 · 지역 기대를 두 칸으로 · 830-11 새 겹침 38 묶음 = 청원 → 청주 4 · 조합 비고 34) · test_m02_hopyo_match(강원도(강릉시) → 강원 강릉)
- 틀 문서 _틀.md 11 절 행정구역 표

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-15 09:56:54 +09:00
co-authored by Claude Opus 5.5
parent 1f0a2812c9
commit b9a8311c6a
20 changed files with 24897 additions and 24102 deletions
+6 -4
View File
@@ -487,15 +487,16 @@ def pick(kind: str, q: str, limit: int) -> dict:
row.get("값") is None or row.get("구분") == "미확보" or row.get("상태") == "추정"
):
continue
text = _squash(f"{row.get('이름')} {row.get('규격')} {row.get('지역') or ''} {' '.join(row.get('옛이름') or [])}") # fmt: skip
text = _squash(f"{row.get('이름')} {row.get('규격')} {cm.mm.where(row)} {' '.join(row.get('옛이름') or [])}") # fmt: skip
if all(w in text or w == _squash(row.get("키")) for w in words):
hits.append(
{
"ref": str(row.get("키")),
**{
k: row.get(k)
for k in ("원문번호", "구분", "이름", "규격", "단위", "지역", "상하차")
}, # fmt: skip
for k in ("원문번호", "구분", "이름", "규격", "단위", "상하차")
}
| {"지역": cm.mm.where(row)}, # fmt: skip
"값": _lowest(row),
"관급": row.get(cm.mm.GOVT) is not None,
}
@@ -507,7 +508,8 @@ def _market_brief(row: dict) -> dict:
"""후보 한 줄 — 값 열 다섯을 다 주고 `값` 에 시험 계산이 쓸 낮은 값(없으면 null)."""
return {
"ref": str(row.get("키")),
**{k: row.get(k) for k in ("구분", "상세구분", "이름", "규격", "단위", "지역", "상하차")},
**{k: row.get(k) for k in ("구분", "상세구분", "이름", "규격", "단위", "상하차")},
"지역": cm.mm.where(row), # 시도 + 시군구(830-11)
"값들": {s: row.get(s) for s in cm.mf.SLOTS},
"값": _lowest(row),
"관급": row.get(cm.mm.GOVT) is not None,
+2 -2
View File
@@ -1,4 +1,4 @@
"""찾기 맞춤 — 낱말(띄어 나눔)마다 키 · 원문번호 · 이름 · 옛이름 · 별칭 · 지역(재료 · 830-10) 가운데 하나에 들면 맞음.
"""찾기 맞춤 — 낱말(띄어 나눔)마다 키 · 원문번호 · 이름 · 옛이름 · 별칭 · 시도 · 시군구(재료 · 830-11) 가운데 하나에 들면 맞음.
띄어쓰기 · 대소문자 무시 · 별칭 = 설계자가 로직을 찾는 다른 이름(쉼표로 여럿 · 208-1).
짝: `ui_template/ui_template_logic_find.ts` `matchLogic`(화면 쪽) ·
@@ -7,7 +7,7 @@
from __future__ import annotations
FIELDS = ("키", "원문번호", "이름", "옛이름", "별칭", "지역")
FIELDS = ("키", "원문번호", "이름", "옛이름", "별칭", "시도", "시군구")
def squash(text) -> str:
+18 -9
View File
@@ -45,21 +45,30 @@ const FALLBACK: Row = { 키: "", 원문번호: "", 이름: "", 규격: "", 단
// 자재품목 상하차(830-2 · 요구 828) = 고르기 칸 · 값 셋 = material_merge.LOADS(첫째 = 기본 · 모름)
const LOAD = "상하차";
const LOADS = ["-", "상차", "하차"];
// 자재품목 시도(830-11 · 요구 842) = 고르기 칸 · 값 = _행정구역.json 시도 짧은 이름(시험이 대조) · 시군구 = 글 칸
const SIDO = "시도";
const SIDOS =
"- 전국 서울 부산 대구 인천 광주 대전 울산 세종 경기 강원 충북 충남 전북 전남 경북 경남 제주".split(
" ",
);
const PICKS: Record<string, string[]> = { [LOAD]: LOADS, [SIDO]: SIDOS };
/** 상하차 칸 — 고르기(부품 상자) · 노랑(고침) · onPick 없으면 잠금. */
/** 상하차 · 시도 칸 — 고르기(부품 상자) · 노랑(고침) · onPick 없으면 잠금 · 목록 밖 값(「대전,충남」)은 맨 끝에 둠. */
function loadCell(
col: string,
value: unknown,
changed: boolean,
onPick?: (value: string) => void,
): HTMLTableCellElement {
const td = el("td");
td.classList.toggle("is-changed", changed);
const now = String(value ?? PICKS[col][0]);
const field = createSelectField({
options: LOADS.map((v) => ({ value: v, text: v })),
value: String(value ?? LOADS[0]),
options: [...new Set([...PICKS[col], now])].map((v) => ({ value: v, text: v })),
value: now,
compact: true,
disabled: !onPick,
ariaLabel: LOAD,
ariaLabel: col,
onChange: (v) => onPick?.(v),
});
td.append(field.root);
@@ -171,8 +180,8 @@ export function renderRows(
const onEdit = (text: string): void =>
setAdd(file, i, withCell(row, c, coerce(text, flat[c])));
tr.append(
file === MARKET && c === LOAD
? loadCell(flat[c], true, onEdit)
file === MARKET && c in PICKS
? loadCell(c, flat[c], true, onEdit)
: buildCell(show(flat[c]), { changed: true, onEdit }),
);
}
@@ -210,8 +219,8 @@ export function renderRows(
editRow(file, d.version, key, row, withCell(cur, c, coerce(text, orig[c])))
: undefined;
tr.append(
file === MARKET && c === LOAD
? loadCell(now[c], changed, onEdit)
file === MARKET && c in PICKS
? loadCell(c, now[c], changed, onEdit)
: buildCell(show(now[c]), { changed, onEdit }),
);
}
@@ -250,7 +259,7 @@ export function renderRows(
const add = createButton({ label: L("M01_RowAdd"), variant: "ghost" });
add.addEventListener("click", () => {
const fresh = blank(d.rows[0] ?? FALLBACK);
if (file === MARKET) Object.assign(fresh, { [LOAD]: LOADS[0], 지역: LOADS[0] }); // 모름 = -(830-10)
if (file === MARKET) Object.assign(fresh, { [LOAD]: "-", [SIDO]: "-", 시군구: "-" }); // 모름 = -(830-11)
const col = ["구분", "세부분류"].find((c) => c in fresh);
addRow(file, d.version, sub && col ? { ...fresh, [col]: sub } : fresh);
});
+1 -1
View File
@@ -234,7 +234,7 @@ def _targets(whole_id: int) -> dict[str, Any]:
"이름": name,
"규격": str(row.get("규격") or ""),
"단위": str(row.get("단위") or ""),
"지역": str(row.get("지역") or place.NONE), # 830-10 지역 · 상하차 칸
"지역": place.mmerge.where(row), # 830-11 시도 + 시군구 · 상하차 칸
"상하차": str(row.get("상하차") or place.NONE),
"값": place.value(row),
"plain": mm.plain(name),
+12 -4
View File
@@ -56,14 +56,20 @@ def _nfkc(text: Any) -> str:
@lru_cache(maxsize=1)
def _places() -> frozenset[str]:
"""곳 낱말 — M01 레미콘 줄 지역 칸의 지역 · 시군 낱말(자료에서 · 「홍천군」 · 「미원면」)."""
"""곳 낱말 — M01 레미콘 줄 시도 · 시군구 칸의 지역 · 시군 낱말(자료에서 · 「홍천」 · 「미원면」)."""
names = (one["지역"] for one in hm.targets()["자재"] if one["이름"].startswith("레미콘"))
words = {w for n in names for w in re.findall(r"[가-힣]{2,}", _nfkc(n))}
return frozenset(words - {"레미콘", "조합공통품목", *SUPPLY})
return frozenset(words - {"레미콘", "조합공통품목", "제외", "전체", *SUPPLY})
def _region(word: str) -> bool:
return word.startswith(REGIONS) or word in NO_REGION or word in _places()
places = _places()
return (
word.startswith(REGIONS)
or word in NO_REGION
or word in places
or place.mmerge.mr.short(word) in places
)
def _paren_words(name: str) -> set[str]:
@@ -202,7 +208,9 @@ def _wants(line: dict[str, Any]) -> tuple[str | None, list[str]]:
supply = (
"관급" if clean(line)[1] else "사급" if (line.get("단가") or {}).get("재료비") else None
)
return supply, [w for w in words if _region(w) and w not in NO_REGION]
return supply, [
place.norm(w) for w in words if _region(w) and w not in NO_REGION
] # 830-11 짧은 이름
def _hit(r: dict[str, Any], regions: list[str]) -> bool:
+25 -6
View File
@@ -1,8 +1,10 @@
"""불러오기 재료 지역 · 상하차 한 벌(PLAN 830-10 · 요구 839) — 서버 단독 · 읽기만.
"""불러오기 재료 지역 · 상하차 한 벌(PLAN 830-10 · 830-11 · 요구 839 · 842) — 서버 단독 · 읽기만.
- `line(이름, 규격)` = 가져온 줄 글(경쟁사 이름 · 규격 — 지역 · 인도 조건 글이 그대로 옴)을 M01 재료 표 빌더와
같은 규칙(`material_merge.split_load` · `split_region`)으로 읽음 → {이름, 규격, 지역, 상하차} · 글 없음 = -.
- 우리 줄 = M01 재료 표 지역 · 상하차 칸(이름 글로 읽지 않음).
같은 규칙(`material_merge.split_load` · `split_region` · `master_region.split`)으로 읽음 →
{이름, 규격, 지역(「경북 칠곡」 꼴 · `material_merge.where`), 상하차} · 글 없음 = - · 못 푸는 지역 글은 그대로.
- 우리 줄 = M01 재료 표 시도 · 시군구 · 상하차 칸(이름 글로 읽지 않음) · 지역 글 = `material_merge.where`.
- `norm(낱말)` = 가져온 글의 지역 낱말 → 짧은 이름(「경상북도」 → 경북 · 「칠곡군」 → 칠곡).
- `rank(우리 줄, 바라는 것, 가운데 값)` = 같은 품목 줄 여럿에서 고르는 차례 —
상하차가 줄 글과 같음(글 없으면 -) › 값 있는 줄(M01 검색어 고르기와 같음) › 지역: 줄 지역 › 전국(지역 모름 - 포함) › 서울 › 경기 ›
그 밖(값이 가운데 값에 가까운 줄) — 조달청 지역 규칙과 같은 차례(요구 830).
@@ -19,7 +21,7 @@ from M01_MasterData import M01_MasterData_Store as m01 # noqa: F401 — scripts
import material_merge as mmerge # noqa: E402
NONE = mmerge.NONE
NATION = ("전국", "전지역", NONE)
NATION = ("전국", "전지역", mmerge.mr.WHOLE, NONE)
PRICES = ("물가자료", "유통물가", "물가정보", "거래가격", "일반가격") # 사급 값 칸(조달청 뺌)
@@ -27,9 +29,23 @@ def line(name: Any, spec: Any) -> dict[str, str]:
"""가져온 줄 → {이름, 규격(지역 · 인도 조건 글 뺌), 지역, 상하차}."""
n, s, load, _ = mmerge.split_load({"이름": str(name or ""), "규격": str(spec or "")})
n, s, where = mmerge.split_region(n, s)
try:
sido, sgg, _ = mmerge.mr.split(where)
where = mmerge.where({mmerge.SIDO: sido, mmerge.SIGUNGU: sgg})
except ValueError:
pass # 표에 없는 글 — 그대로
return {"이름": n, "규격": s, "지역": where, "상하차": load}
def norm(word: str) -> str:
"""지역 낱말 → 짧은 이름(시군구가 있으면 그것 · 시도만이면 시도) — 못 풀면 그대로."""
try:
sido, sgg, _ = mmerge.mr.split(word)
except ValueError:
return word
return sido if sgg in (mmerge.mr.WHOLE, NONE) else sgg
def words(where: Any) -> list[str]:
"""지역 글 → 한글 낱말(전국류 뺌) — 「경상북도(칠곡군)」 → 경상북도 · 칠곡군."""
return [w for w in re.findall(r"[가-힣]{2,}", str(where or "")) if w not in NATION]
@@ -41,7 +57,10 @@ def hit(where: Any, want: Any) -> bool:
return False
if str(where) == str(want):
return True
mine = words(want) if isinstance(want, str) else list(want)
if isinstance(want, str): # 「경북 포항」 → 포항(시군구가 있으면 그것만) · 「경북」 → 경북
mine = words(want.split(" ", 1)[-1])
else:
mine = words(" ".join(norm(w) for w in want))
return any(a[:2] == b[:2] for a in mine for b in words(where))
@@ -60,7 +79,7 @@ def middle(rows: list[dict]) -> Any:
def rank(row: dict, want_where: Any, want_load: str, mid: Any) -> tuple:
"""고르는 차례 열쇠(작을수록 먼저) — (상하차 다름, 값 없음, 지역 차례, 가운데 값과의 거리)."""
where = str(row.get("지역") or NONE)
where = str(row.get("지역") or mmerge.where(row))
if hit(where, want_where):
tier = 0
elif where in NATION or not words(where):
+3 -2
View File
@@ -357,6 +357,7 @@ def _pub(one: dict, why: str) -> tuple:
got = {"키": one.get("자재"), **{k: str(one.get(k) or "") for k in ("이름", "규격", "단위")}}
row = ea.row(ea.MARKET, got["키"]) if got["키"] else {}
got |= {
k: str(one.get(k) or row.get(k) or "") for k in ("지역", "상하차")
} # 830-10 다시 고르기 창
"지역": str(one.get("지역") or (hm.place.mmerge.where(row) if row else "")),
"상하차": str(one.get("상하차") or row.get("상하차") or ""),
} # 830-10 다시 고르기 창 · 830-11 시도 + 시군구
return tuple((got | ({"단서": why} if why else {})).items())
+13 -2
View File
@@ -109,12 +109,13 @@
**마스터는 값을 고르는 곳이 아니라 프로젝트에 쓸 데이터를 모으는 곳** — 최저가·견적 고르기는 프로젝트 몫.
- `재료_자재품목` = 자재 테이블 하나 · **열 한 벌** — 키 · 구분 · 상세구분 · 이름 · 규격 · 단위 · 상하차 · 조달청 · 물가자료 · 유통물가 · 물가정보 · 거래가격 · 일반가격 · 출처 · 비고 · 면수 · 모든 줄이 모든 칸(없으면 `null`) · 차례 = `scripts/material_merge.py` `COLUMNS`.
- `재료_자재품목` = 자재 테이블 하나 · **열 한 벌** — 키 · 구분 · 상세구분 · 이름 · 규격 · 단위 · 시도 · 시군구 · 상하차 · 조달청 · 물가자료 · 유통물가 · 물가정보 · 거래가격 · 일반가격 · 출처 · 비고 · 면수 · 모든 줄이 모든 칸(없으면 `null`) · 차례 = `scripts/material_merge.py` `COLUMNS`.
- 값 열 여섯 = `조달청`(관급) · 물가지 넷(물가자료 · 유통물가 · 물가정보 · 거래가격) · `일반가격`(사급) — 채택값·기본가·견적가 두지 않음.
- `조달청` = 자리만 둠 · 값 없음(원문 「가격정보」 값은 넣지 않음) · 가장 낮은 값 고르기에 넣지 않음.
- `일반가격` = 시스템 관리자가 M01 에서 손으로 넣는 칸 · 빌더가 지금 정본 값을 이어 받음.
- 가장 낮은 값 = 사급 칸(물가지 넷 + 일반가격) 가운데 · `scripts/master_material.py` `SLOTS`.
- `상하차` = 상차 · 하차 · `-`(기본 · 모름) 가운데 하나 · 값이 아닌 표지 · 자리 = 단위 바로 뒤 · 이름 · 규격 끝 인도 조건 글(도착도 · 하차도 = 하차 · 채취장상차 · 하치장 상차도 = 상차)은 이 칸으로 옮기고 이름 · 규격에서 뺌 · 빌더가 손 값을 이어 받음.
- `시도` · `시군구` = 지역 두 칸 · 값이 아닌 표지 · 자리 = 단위 바로 뒤 · 이름 · 규격에 적힌 지역 글을 떼어 11 절 행정구역 표로 풂(`scripts/material_merge.py` `place` · `scripts/master_region.py`) — 시도 = 짧은 이름(여럿은 쉼표) · `전국` · `-`(없음) · 시군구 = 짧은 이름(여럿은 쉼표) · 시도만 = `전체` · 읍면 = `시군구(읍면)` · 제외 조건 = 끝에 ` 제외` · 없음 = `-` · 조합 이름(「…(협)」)은 `비고` · 빌더가 손 값을 이어 받음.
- `상하차` = 상차 · 하차 · `-`(기본 · 모름) 가운데 하나 · 값이 아닌 표지 · 자리 = 시군구 바로 뒤 · 이름 · 규격 끝 인도 조건 글(도착도 · 하차도 = 하차 · 채취장상차 · 하치장 상차도 = 상차)은 이 칸으로 옮기고 이름 · 규격에서 뺌 · 빌더가 손 값을 이어 받음.
- 줄 목록 = EST 그대로 — 지역 · 관급 · 사급 · 제조사 · 조합 줄을 합치지 않음 · 이름 글은 원문 그대로(인도 조건 글만 뺌).
- 재료 아닌 줄은 두지 않음 — 품질시험 수수료 · 일반 도로용 블록.
- `구분` · `상세구분` = 원문(ESTX)에 대·중분류 칸이 없어 품명 글자 규칙으로 채움(`scripts/build_자재품목_분류.py`) · 묶음 목록은 `ref/_묶음_자재품목.md` · 못 가른 줄은 「미분류」.
@@ -319,3 +320,13 @@
- `이름` = 글자 · 빈 이름 안 됨 · 60자까지(`master_combo.MAX_이름`) · 같은 파일 안에서 안 겹침.
- 검사(`check_master.py 조합` · 엔진 `scripts/master_combo.py`) — 없는 로직 키 · 단가산출 로직이 아닌 키 · 같은 로직 두 번 · 빈 조합 · **조합이 조합을 담음**(1단만) · 메모가 글자·`null` 아님 · 이름이 글자 아니거나 없음·너무 김·겹침.
- 「이 로직을 쓰는 조합」 은 로직 줄에 칸으로 박지 않고 물을 때 훑어 찾음(`master_combo.using`) — 조합은 관리자가 그때그때 만드는 것이라 박아 두면 낡음. 소요량 「용도」 와 결은 같되 자리가 다름.
## 11. 행정구역 표
`_행정구역.json` 하나 — 재료 `시도` · `시군구` 칸을 푸는 표(밑줄 파일 · 마스터 표로 안 읽힘). 풀이 = `scripts/master_region.py` `split`.
- 머리 = `그룹`(행정구역) · `판`(행정구역 기준일) · `원천` · `비고`.
- `시도` = `{ 이름(짧은 이름), 정식, 시군구: [정식 이름] }` 17 · 차례 = 서울 · 부산 · 대구 · 인천 · 광주 · 대전 · 울산 · 세종 · 경기 · 강원 · 충북 · 충남 · 전북 · 전남 · 경북 · 경남 · 제주 · 그 밖 값 = `전국` · `전체`.
- 시군구 짧은 이름 = 끝 시 · 군 · 구를 뗀 이름(두 글자가 안 되면 그대로 — 「송파」 · 「중구」) · 같은 이름이 여러 시도에 있으면(고성 · 동구 · 중구 · 강서) 같은 줄의 시도로 가름.
- `시군구수` = 시도별 시군구 수(검산) · `옛이름` = 개편 전 이름 → 지금 「시도」 · 「시도 시군구」 · `별칭` = 다른 표기 → 지금.
- 행정구역이 바뀌면 이 표만 고치고 재료 빌더를 다시 돌림 · M01 화면 시도 고르기 목록(`M01_MasterData_UI_Rows.ts` `SIDOS`)도 같이(시험이 대조).
+386
View File
@@ -0,0 +1,386 @@
{
"그룹": "행정구역",
"판": "2026-07-01",
"원천": "행정안전부 행정구역 현황(시도 · 시군구) — 2026-07-01 인천 행정체제 개편(제물포구 · 영종구 · 검단구)까지",
"비고": [
"재료 자재품목 시도 · 시군구 칸을 푸는 표(요구 842 · PLAN 830-11) — 풀이 = scripts/master_region.py",
"시도 = 짧은 이름 17 + 「전국」 · 시군구 = 정식 이름(짧은 이름 = 끝 시 · 군 · 구를 뗀 이름 · 두 글자가 안 되면 그대로 — 「송파」 · 「중구」)",
"세종 = 시군구 없음(단층) · 제주 = 행정시 둘",
"옛이름 = 개편 전 이름 → 지금 「시도」 · 「시도 시군구」 · 별칭 = 다른 표기 → 지금(옛 이름 아님)",
"시도 수 · 시도별 시군구 수 = 시군구수 칸(검산)"
],
"전국": "전국",
"전체": "전체",
"시도": [
{
"이름": "서울",
"정식": "서울특별시",
"시군구": [
"종로구",
"중구",
"용산구",
"성동구",
"광진구",
"동대문구",
"중랑구",
"성북구",
"강북구",
"도봉구",
"노원구",
"은평구",
"서대문구",
"마포구",
"양천구",
"강서구",
"구로구",
"금천구",
"영등포구",
"동작구",
"관악구",
"서초구",
"강남구",
"송파구",
"강동구"
]
},
{
"이름": "부산",
"정식": "부산광역시",
"시군구": [
"중구",
"서구",
"동구",
"영도구",
"부산진구",
"동래구",
"남구",
"북구",
"해운대구",
"사하구",
"금정구",
"강서구",
"연제구",
"수영구",
"사상구",
"기장군"
]
},
{
"이름": "대구",
"정식": "대구광역시",
"시군구": [
"중구",
"동구",
"서구",
"남구",
"북구",
"수성구",
"달서구",
"달성군",
"군위군"
]
},
{
"이름": "인천",
"정식": "인천광역시",
"시군구": [
"제물포구",
"영종구",
"미추홀구",
"연수구",
"남동구",
"부평구",
"계양구",
"서구",
"검단구",
"강화군",
"옹진군"
]
},
{
"이름": "광주",
"정식": "광주광역시",
"시군구": [
"동구",
"서구",
"남구",
"북구",
"광산구"
]
},
{
"이름": "대전",
"정식": "대전광역시",
"시군구": [
"동구",
"중구",
"서구",
"유성구",
"대덕구"
]
},
{
"이름": "울산",
"정식": "울산광역시",
"시군구": [
"중구",
"남구",
"동구",
"북구",
"울주군"
]
},
{
"이름": "세종",
"정식": "세종특별자치시",
"시군구": []
},
{
"이름": "경기",
"정식": "경기도",
"시군구": [
"수원시",
"성남시",
"의정부시",
"안양시",
"부천시",
"광명시",
"평택시",
"동두천시",
"안산시",
"고양시",
"과천시",
"구리시",
"남양주시",
"오산시",
"시흥시",
"군포시",
"의왕시",
"하남시",
"용인시",
"파주시",
"이천시",
"안성시",
"김포시",
"화성시",
"광주시",
"양주시",
"포천시",
"여주시",
"연천군",
"가평군",
"양평군"
]
},
{
"이름": "강원",
"정식": "강원특별자치도",
"시군구": [
"춘천시",
"원주시",
"강릉시",
"동해시",
"태백시",
"속초시",
"삼척시",
"홍천군",
"횡성군",
"영월군",
"평창군",
"정선군",
"철원군",
"화천군",
"양구군",
"인제군",
"고성군",
"양양군"
]
},
{
"이름": "충북",
"정식": "충청북도",
"시군구": [
"청주시",
"충주시",
"제천시",
"보은군",
"옥천군",
"영동군",
"증평군",
"진천군",
"괴산군",
"음성군",
"단양군"
]
},
{
"이름": "충남",
"정식": "충청남도",
"시군구": [
"천안시",
"공주시",
"보령시",
"아산시",
"서산시",
"논산시",
"계룡시",
"당진시",
"금산군",
"부여군",
"서천군",
"청양군",
"홍성군",
"예산군",
"태안군"
]
},
{
"이름": "전북",
"정식": "전북특별자치도",
"시군구": [
"전주시",
"군산시",
"익산시",
"정읍시",
"남원시",
"김제시",
"완주군",
"진안군",
"무주군",
"장수군",
"임실군",
"순창군",
"고창군",
"부안군"
]
},
{
"이름": "전남",
"정식": "전라남도",
"시군구": [
"목포시",
"여수시",
"순천시",
"나주시",
"광양시",
"담양군",
"곡성군",
"구례군",
"고흥군",
"보성군",
"화순군",
"장흥군",
"강진군",
"해남군",
"영암군",
"무안군",
"함평군",
"영광군",
"장성군",
"완도군",
"진도군",
"신안군"
]
},
{
"이름": "경북",
"정식": "경상북도",
"시군구": [
"포항시",
"경주시",
"김천시",
"안동시",
"구미시",
"영주시",
"영천시",
"상주시",
"문경시",
"경산시",
"의성군",
"청송군",
"영양군",
"영덕군",
"청도군",
"고령군",
"성주군",
"칠곡군",
"예천군",
"봉화군",
"울진군",
"울릉군"
]
},
{
"이름": "경남",
"정식": "경상남도",
"시군구": [
"창원시",
"진주시",
"통영시",
"사천시",
"김해시",
"밀양시",
"거제시",
"양산시",
"의령군",
"함안군",
"창녕군",
"고성군",
"남해군",
"하동군",
"산청군",
"함양군",
"거창군",
"합천군"
]
},
{
"이름": "제주",
"정식": "제주특별자치도",
"시군구": [
"제주시",
"서귀포시"
]
}
],
"시군구수": {
"서울": 25,
"부산": 16,
"대구": 9,
"인천": 11,
"광주": 5,
"대전": 5,
"울산": 5,
"세종": 0,
"경기": 31,
"강원": 18,
"충북": 11,
"충남": 15,
"전북": 14,
"전남": 22,
"경북": 22,
"경남": 18,
"제주": 2
},
"옛이름": {
"강원도": "강원",
"전라북도": "전북",
"충북 청원군": "충북 청주",
"경남 마산시": "경남 창원",
"경남 진해시": "경남 창원",
"충남 연기군": "세종",
"제주 북제주군": "제주 제주시",
"제주 남제주군": "제주 서귀포시",
"인천 남구": "인천 미추홀구",
"인천 중구": "인천 제물포구",
"인천 동구": "인천 제물포구",
"경북 군위군": "대구 군위군"
},
"별칭": {
"제주도": "제주",
"세종시": "세종",
"서울시": "서울",
"인천 영종도": "인천 영종구",
"인천 용유도": "인천 영종구"
}
}
@@ -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(
File diff suppressed because it is too large Load Diff
+14 -12
View File
@@ -1,6 +1,7 @@
"""830-10 지킴 — 재료 표 지역 칸 분리 · 상하차 재분석 · M01 로직이 같은 줄 · 불러오기가 지역 칸으로 고름(요구 839).
- 지역 칸 = 단위 바로 뒤 · 상하차 앞 · 빈 칸 없음(모름 = -) · 이름 · 규격에 지역 글 0 · 인도 조건 글 0
(830-11 부터 시도 · 시군구 두 칸 — 지역 글 = `material_merge.where` · 두 칸 규칙은 test_830_11_material_sido)
- 꼴 일곱 판독(빌더 = `material_merge.split_region`) · 지역 아닌 업체 · 산지 · 수종 글은 그대로
- 상하차: (상차포함) = 상차 · (상차제외) = - · 같은 이름 · 규격 둘이 상하차 칸으로 갈림
- 줄 · 키 = 24,027 · 지역 칸만 다른 줄 겹침 없음(이름 · 규격 · 단위 · 지역 · 상하차 같은 줄 = 830-9 때 겹침뿐)
@@ -34,9 +35,9 @@ def rows():
def test_지역_칸_자리_빈칸_없음(rows):
cols = list(mm.COLUMNS)
assert cols[cols.index("단위") + 1 : cols.index("단위") + 3] == ["지역", "상하차"]
assert len(rows) == 24027 and all(str(r["지역"]).strip() for r in rows)
got = Counter(r["지역"] != "-" for r in rows)
assert cols[cols.index("단위") + 1 : cols.index("단위") + 4] == ["시도", "시군구", "상하차"]
assert len(rows) == 24027 and all(str(r["시군구"]).strip() for r in rows)
got = Counter(r["시도"] != "-" for r in rows)
assert got[True] == 6604 and got[False] == 17423
@@ -67,9 +68,9 @@ def test_꼴_일곱_판독(name, spec, want):
def test_이름_규격에_지역_인도_조건_글_0(rows):
assert not [r for r in rows if mm.split_region(r["이름"], r["규격"])[2] != "-"]
assert not [r for r in rows if mm.split_load(r)[3]]
assert {r["지역"] for r in rows if r["이름"] == "레미콘-사급"} >= {"서울", "포항"}
assert {mm.where(r) for r in rows if r["이름"] == "레미콘-사급"} >= {"서울", "경북 포항"}
cut = [r for r in rows if mm.CUT in str(r["비고"] or "")]
assert len(cut) == 30 and all(r["지역"].count("(") > r["지역"].count(")") for r in cut)
assert len(cut) == 30 and all(r["시도"] != "-" for r in cut)
def test_상하차_재분석(rows):
@@ -86,11 +87,12 @@ def test_상하차_재분석(rows):
def test_지역만_다른_줄_겹침_없음(rows):
keys = Counter((r["이름"], r["규격"], r["단위"], r["지역"], r["상하차"]) for r in rows)
keys = Counter((r["이름"], r["규격"], r["단위"], mm.where(r), r["상하차"]) for r in rows)
dup = {k: n for k, n in keys.items() if n > 1}
assert (
all(k[3] == "-" for k in dup) and sum(dup.values()) == 866
) # EST 에서 온 겹침(830-9 와 같음)
old = sum(n for k, n in dup.items() if k[3] == "-")
assert old == 866 # EST 에서 온 겹침(830-9 와 같음)
# 830-11 — 청원 → 청주(4 묶음) · 조합 이름이 비고로 간 대구 · 제주 레미콘(34 묶음) · 줄은 그대로
assert sum(1 for k in dup if k[3] != "-") == 38
def test_로직이_같은_줄():
@@ -124,9 +126,9 @@ def test_불러오기가_지역_칸으로_고름():
pick = lambda name, spec: ea.row(
ea.MARKET, ea.one({"이름": name, "규격": spec}, ea.exact(name, spec))
) # noqa: E731, E501
assert pick("모르타르, 부산", "1:3")["지역"] == "부산" # 줄 지역
assert pick("모르타르", "1:3")["지역"] == "서울" # 전국 없음 → 서울
assert pick("레미콘(포항)-사급", "25-18-120")["지역"] == "포항"
assert mm.where(pick("모르타르, 부산", "1:3")) == "부산" # 줄 지역
assert mm.where(pick("모르타르", "1:3")) == "서울" # 전국 없음 → 서울
assert mm.where(pick("레미콘(포항)-사급", "25-18-120")) == "경북 포항"
got = hm._exact({"이름": "레미콘(서울)-사급", "규격": "25-18-120"}, "자재", hm.targets())
assert [(x["이름"], x["지역"]) for x in got] == [("레미콘-사급", "서울")] # 옛 온 이름 같음
row = lambda where, v=1: {"지역": where, "상하차": "-", "물가자료": v} # noqa: E731
@@ -0,0 +1,108 @@
"""830-11 지킴 — 재료 지역 = 시도 · 시군구 두 칸 · 행정구역 DB(`_행정구역.json`) · 요구 842.
- 두 칸 자리(단위 뒤 · 상하차 앞) · 규칙 ㉠ ~ ㉩ 보기 한 줄씩(`master_region.split`)
- 못 푸는 지역 0 · 줄마다 시군구가 그 줄 시도 소속(틀린 소속 0)
- DB 검산(시도 17 · 시도별 시군구 수) · M01 화면 시도 고르기 목록 = DB 시도
- 찾기 · 불러오기가 두 칸을 봄(「포항」 · 「경상북도(칠곡군)」)
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
for p in (ROOT, ROOT / "resources/master_data/scripts"):
if str(p) not in sys.path:
sys.path.insert(0, str(p))
import master_material as mmat # noqa: E402
import master_region as mr # noqa: E402
import material_merge as mm # noqa: E402
MASTER = ROOT / "resources/master_data"
@pytest.fixture(scope="module")
def rows():
return json.loads((MASTER / "재료_자재품목.json").read_text(encoding="utf-8"))["줄"]
@pytest.mark.parametrize(
"text,want",
[
("-", ("-", "-", None)), # ㉠
("전국", ("전국", "전체", None)), # ㉡
("부산광역시 전지역", ("부산", "전체", None)), # ㉢
("전주", ("전북", "전주", None)), # ㉣
("경상남도(거제시,통영시,고성군)", ("경남", "거제,통영,고성", None)), # ㉤
("강원도(홍천군 내면)", ("강원", "홍천(내면)", None)), # ㉥
("충청북도(청주시(미원면 제외))", ("충북", "청주(미원면 제외)", None)),
("세종시,충남(공주시,천안시,아산시)", ("세종,충남", "공주,천안,아산", None)), # ㉦
("대전충남", ("대전,충남", "전체", None)),
("대구광역시(전지역),대구경북레미콘(협)", ("대구", "전체", "대구경북레미콘(협)")), # ㉧
(
"서울,인천,경기(강남,강동,송파구 제외)",
("서울,인천,경기", "강남,강동,송파 제외", None),
), # ㉨
(
"서울,인천(계양부평)경기(연천양주동두",
("서울,인천,경기", "계양,부평,연천,양주,동두천", None),
), # ㉩
("청원", ("충북", "청주", None)), # 옛 이름
("인천(영종및용유도)", ("인천", "영종", None)),
],
)
def test_규칙_보기(text, want):
assert mr.split(text) == want
def test_옛_이름():
assert mr.is_old("청원") and mr.is_old("강원도(강릉시)") and mr.is_old("전라북도")
assert not mr.is_old("대구광역시(군위군)") and not mr.is_old("경상북도(칠곡군)")
def test_두_칸_못_푸는_지역_0_틀린_소속_0(rows):
cols = list(mm.COLUMNS)
assert cols[cols.index("단위") + 1 : cols.index("단위") + 3] == ["시도", "시군구"]
assert "지역" not in cols and len(rows) == 24027
db = mr.table()["시도"]
for r in rows:
sido, sgg = r["시도"], r["시군구"]
assert mr.valid_sido(sido), r["키"]
if sido in ("-", "전국"):
assert sgg == ("-" if sido == "-" else "전체"), r["키"]
continue
if sgg == "전체":
continue
names = re.sub(r"\([^)]*\)?| 제외$", "", sgg).split(",")
assert all(any(n in db[s] for s in sido.split(",")) for n in names), r["키"]
assert sum(r["시도"] != "-" for r in rows) == 6604
union = [r for r in rows if "(협)" in str(r["비고"] or "")]
assert len(union) == 121 and all(r["시도"] in ("대구", "제주") for r in union)
def test_행정구역_DB_검산():
db = json.loads(mr.DB.read_text(encoding="utf-8"))
names = [s["이름"] for s in db["시도"]]
assert len(names) == 17 and len(set(names)) == 17
for s in db["시도"]:
short = [mr.short(g) for g in s["시군구"]]
assert len(short) == db["시군구수"][s["이름"]] == len(set(short)), s["이름"]
assert sum(db["시군구수"].values()) == 229 # 자치 시군구 227 + 제주 행정시 2
ui = (ROOT / "M01_MasterData/M01_MasterData_UI_Rows.ts").read_text(encoding="utf-8")
assert re.search(r'const SIDOS =\s*"([^"]+)"', ui)[1].split() == ["-", "전국", *names]
def test_찾기_불러오기가_두_칸을_봄(rows):
from M02_MasterTemplete import M02_Mat_Place as place
one = next(r for r in rows if r["시군구"] == "포항")
assert "경북포항" in mmat.label(one) and mm.where(one) == "경북 포항"
got = place.line("레미콘(관급)-경상북도(칠곡군)", "25-18-120")
assert (got["이름"], got["지역"]) == ("레미콘(관급)", "경북 칠곡")
assert place.hit("경북 칠곡", got["지역"]) and not place.hit("경북 포항", got["지역"])
@@ -30,7 +30,8 @@ COLS = (
"이름",
"규격",
"단위",
"지역",
"시도",
"시군구",
"상하차",
"조달청",
"물가자료",
@@ -76,8 +77,10 @@ def test_조달청_빔_일반가격_빔(rows):
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]
sa = {r["지역"] for r in rows if r["이름"] == "레미콘-사급"} # 지역은 지역 칸(요구 839)
assert {"서울", "포항"} <= sa
sa = {
mm.where(r) for r in rows if r["이름"] == "레미콘-사급"
} # 지역은 시도 · 시군구 칸(839 · 842)
assert {"서울", "경북 포항"} <= sa
assert not [r for r in rows if "통합:" in str(r["비고"] or "")]
@@ -100,7 +103,7 @@ 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 [r for r in rebar if r["상하차"] == "상차"]
assert {r["상하차"] for r in rows if r["이름"] == "모 래" and r["지역"] != "-"} >= {"하차"}
assert {r["상하차"] for r in rows if r["이름"] == "모 래" and r["시도"] != "-"} >= {"하차"}
assert mm.split_load({"이름": "모 래, 서울, 도착도", "규격": ""}) == (
"모 래, 서울",
"",
+1 -1
View File
@@ -139,7 +139,7 @@ def test_전국_지역_줄글_전국품목():
rows, tags = hmat.sure(hmat.clean(named)[0])
assert (rows[0]["one"]["이름"], rows[0]["one"]["지역"]) == (
"레미콘(관급)",
"강원도(강릉시)",
"강원 강릉", # 830-11 시도 · 시군구
) and tags == [hmat.REGION_TAG]
assert hmat.national(named) is None
rows, tags = hmat.sure(hmat.clean(H_BEAM)[0]) # (전국) 품목 있음 = 그것