Merge branch 'sub_laptop_3' of C:/Aislo_wt/sub_laptop_3 into main_laptop_1
This commit is contained in:
@@ -30,8 +30,9 @@ DRIVER_TABLE = "CC000336"
|
||||
DRIVER_PARTS = ("시간환산", "상여계수", "휴지계수")
|
||||
#: 연료 지역 — M01 기계경비 로직 지역 고르기 첫 값
|
||||
FUEL_REGION = "전국평균"
|
||||
#: 자재품목 값 열 → 대비표 출처 칸
|
||||
#: 자재품목 값 열 → 대비표 출처 칸 · 대비표 「관급」 = M01 「조달청」 칸(830-2)
|
||||
MT_SOURCES = ("관급", "물가자료", "유통물가", "물가정보", "거래가격")
|
||||
MT_COLUMN = {"관급": "조달청"}
|
||||
|
||||
|
||||
def _f(value: Any) -> float | None:
|
||||
@@ -103,7 +104,7 @@ def material_input(row: dict[str, Any]) -> dict[str, Any]:
|
||||
spec = f"{row.get('상세구분') or ''} {row.get('규격') or ''}".strip()
|
||||
unit = (row.get("단위") or "").replace("원/", "")
|
||||
else:
|
||||
prices = {s: _f(row.get(s)) for s in MT_SOURCES}
|
||||
prices = {s: _f(row.get(MT_COLUMN.get(s, s))) for s in MT_SOURCES}
|
||||
pages = {s: (row.get("면수") or {}).get(s) for s in MT_SOURCES if s != "관급"}
|
||||
origin = "M01"
|
||||
spec = row.get("규격") or ""
|
||||
|
||||
@@ -468,7 +468,7 @@ _PICK = {"price": "재료_자재품목.json", "job": "인력.json"}
|
||||
|
||||
|
||||
def _lowest(row: dict) -> object:
|
||||
"""자재품목 줄 — 값 열 다섯 가운데 낮은 값(시험 계산용) · 값 칸이 있는 줄은 그 값 그대로."""
|
||||
"""자재품목 줄 — 사급 값 열 다섯(조달청 뺌) 가운데 낮은 값(시험 계산용) · 값 칸이 있는 줄은 그 값 그대로."""
|
||||
if "값" in row:
|
||||
return row["값"]
|
||||
nums = [row[s] for s in cm.mf.SLOTS if row.get(s) is not None]
|
||||
@@ -494,7 +494,7 @@ def pick(kind: str, q: str, limit: int) -> dict:
|
||||
"ref": str(row.get("키")),
|
||||
**{k: row.get(k) for k in ("원문번호", "구분", "이름", "규격", "단위")},
|
||||
"값": _lowest(row),
|
||||
"관급": row.get("관급") is not None,
|
||||
"관급": row.get(cm.mm.GOVT) is not None,
|
||||
}
|
||||
)
|
||||
return {"total": len(hits), "items": hits[: min(max(limit, 1), 200)]}
|
||||
@@ -507,7 +507,7 @@ def _market_brief(row: dict) -> dict:
|
||||
**{k: row.get(k) for k in ("구분", "상세구분", "이름", "규격", "단위")},
|
||||
"값들": {s: row.get(s) for s in cm.mf.SLOTS},
|
||||
"값": _lowest(row),
|
||||
"관급": row.get("관급") is not None,
|
||||
"관급": row.get(cm.mm.GOVT) is not None,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -191,8 +191,8 @@ function goButton(target: MasterJump): HTMLElement {
|
||||
return go;
|
||||
}
|
||||
|
||||
/** 자재품목 값 칸 넷 — 위 가로 줄(관급은 뺌) */
|
||||
const SLOTS = ["물가자료", "유통물가", "물가정보", "거래가격"];
|
||||
/** 자재품목 사급 값 칸 다섯 — 위 가로 줄(조달청은 뺌 · 830-2) */
|
||||
const SLOTS = ["물가자료", "유통물가", "물가정보", "거래가격", "일반가격"];
|
||||
const MARKET_FILE = "재료_자재품목.json";
|
||||
const marketRows = new Map<string, Promise<Row | null>>();
|
||||
/** 자재품목 줄 하나(값 칸 넷) — 키로 찾음 · 한 번만 물음 */
|
||||
|
||||
@@ -27,6 +27,7 @@ const COLS: Record<PickTableKind, string[]> = {
|
||||
"유통물가",
|
||||
"물가정보",
|
||||
"거래가격",
|
||||
"일반가격",
|
||||
],
|
||||
기계: ["원문번호", "구분", "상세구분", "이름", "규격", "값›가격"],
|
||||
};
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
withCell,
|
||||
} from "./M01_MasterData_UI_Cells";
|
||||
import { createEmpty } from "@ui/ui_template_empty";
|
||||
import { createSelectField } from "@ui/ui_template_input";
|
||||
import { guessRows, pageKeepFirst, watchFit } from "@ui/ui_template_pager";
|
||||
|
||||
/** 표 칸 위에 오는 도구줄(단추 줄 + 간격) 높이 — 첫 줄 수 어림에만 씀 */
|
||||
@@ -41,6 +42,29 @@ const NOTE = "비고"; // 끝 열(인력은 준용 앞) · 줄에 없으면 빈
|
||||
const ALIAS = "별칭";
|
||||
const ALIASED = ["인력.json", "기계.json", MARKET, "재료_유가전력.json"];
|
||||
const FALLBACK: Row = { 키: "", 원문번호: "", 이름: "", 규격: "", 단위: "", 값: null, 출처: "" };
|
||||
// 자재품목 상하차(830-2 · 요구 828) = 고르기 칸 · 값 셋 = material_merge.LOADS(첫째 = 기본 · 모름)
|
||||
const LOAD = "상하차";
|
||||
const LOADS = ["-", "상차", "하차"];
|
||||
|
||||
/** 상하차 칸 — 고르기(부품 상자) · 노랑(고침) · onPick 없으면 잠금. */
|
||||
function loadCell(
|
||||
value: unknown,
|
||||
changed: boolean,
|
||||
onPick?: (value: string) => void,
|
||||
): HTMLTableCellElement {
|
||||
const td = el("td");
|
||||
td.classList.toggle("is-changed", changed);
|
||||
const field = createSelectField({
|
||||
options: LOADS.map((v) => ({ value: v, text: v })),
|
||||
value: String(value ?? LOADS[0]),
|
||||
compact: true,
|
||||
disabled: !onPick,
|
||||
ariaLabel: LOAD,
|
||||
onChange: (v) => onPick?.(v),
|
||||
});
|
||||
td.append(field.root);
|
||||
return td;
|
||||
}
|
||||
|
||||
/** 새 줄 바탕 — 본 줄과 같은 칸 · 글 칸은 "" · 수·값 칸은 null. */
|
||||
function blank(sample: Row): Row {
|
||||
@@ -144,11 +168,12 @@ export function renderRows(
|
||||
const tr = el("tr", { className: "is-new" });
|
||||
tr.append(actionCell(L("M01_RowRemove"), () => removeAdd(file, i)));
|
||||
for (const c of cols) {
|
||||
const onEdit = (text: string): void =>
|
||||
setAdd(file, i, withCell(row, c, coerce(text, flat[c])));
|
||||
tr.append(
|
||||
buildCell(show(flat[c]), {
|
||||
changed: true,
|
||||
onEdit: (text) => setAdd(file, i, withCell(row, c, coerce(text, flat[c]))),
|
||||
}),
|
||||
file === MARKET && c === LOAD
|
||||
? loadCell(flat[c], true, onEdit)
|
||||
: buildCell(show(flat[c]), { changed: true, onEdit }),
|
||||
);
|
||||
}
|
||||
for (const col of aliased ? [ALIAS, NOTE] : [NOTE])
|
||||
@@ -179,14 +204,15 @@ export function renderRows(
|
||||
);
|
||||
for (const c of cols) {
|
||||
const editable = !deleted && isScalar(orig[c]) && !fixed(file, c);
|
||||
const changed = !same(now[c], orig[c]);
|
||||
const onEdit = editable
|
||||
? (text: string): void =>
|
||||
editRow(file, d.version, key, row, withCell(cur, c, coerce(text, orig[c])))
|
||||
: undefined;
|
||||
tr.append(
|
||||
buildCell(show(now[c]), {
|
||||
changed: !same(now[c], orig[c]),
|
||||
onEdit: editable
|
||||
? (text) =>
|
||||
editRow(file, d.version, key, row, withCell(cur, c, coerce(text, orig[c])))
|
||||
: undefined,
|
||||
}),
|
||||
file === MARKET && c === LOAD
|
||||
? loadCell(now[c], changed, onEdit)
|
||||
: buildCell(show(now[c]), { changed, onEdit }),
|
||||
);
|
||||
}
|
||||
const put = deleted
|
||||
@@ -224,6 +250,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) fresh[LOAD] = LOADS[0];
|
||||
const col = ["구분", "세부분류"].find((c) => c in fresh);
|
||||
addRow(file, d.version, sub && col ? { ...fresh, [col]: sub } : fresh);
|
||||
});
|
||||
|
||||
@@ -22,7 +22,13 @@ from M01_MasterData import M01_MasterData_Store as m01
|
||||
import master_alias as am # noqa: E402 — M01 store 가 scripts 경로를 넣음
|
||||
|
||||
MARKET = "MT"
|
||||
PRICES = ("물가자료", "유통물가", "물가정보", "거래가격", "관급") # 자재 값 칸 다섯
|
||||
PRICES = (
|
||||
"물가자료",
|
||||
"유통물가",
|
||||
"물가정보",
|
||||
"거래가격",
|
||||
"일반가격",
|
||||
) # 자재 사급 값 칸 다섯(조달청 뺌)
|
||||
BASE_REGION = "서울"
|
||||
_CODE = re.compile(r"\bM\d{10}\b")
|
||||
LOCAL = "M0000000" # 공사 직접 번호(파일마다 다른 품목 · 거창 치즐 = 우리 표 Air Hammer) = 코드로 안 이음
|
||||
|
||||
@@ -99,7 +99,7 @@ export interface CalcMaterial {
|
||||
코드: string;
|
||||
알약?: string | null;
|
||||
기준단가: number | null;
|
||||
/** M01 자재품목 값 열 다섯(물가자료 · 유통물가 · 물가정보 · 거래가격 · 관급 · 빈 값 null · 43-3) */
|
||||
/** M01 자재품목 사급 값 열 다섯(물가자료 · 유통물가 · 물가정보 · 거래가격 · 일반가격 · 빈 값 null · 43-3) */
|
||||
가격?: Record<string, number | null>;
|
||||
/** 기준단가가 된 열 이름 — 없으면 null */
|
||||
기준칸?: string | null;
|
||||
|
||||
@@ -570,4 +570,4 @@ export function logicTable(data: LogicTableData, on?: RowHandlers): HTMLElement
|
||||
}
|
||||
|
||||
/** M01 자재품목 값 열 다섯(이름 그대로 · 43-3) — 서버 calc 답 `가격` 열쇠와 같음 */
|
||||
export const PRICE_SLOTS = ["물가자료", "유통물가", "물가정보", "거래가격", "관급"] as const;
|
||||
export const PRICE_SLOTS = ["물가자료", "유통물가", "물가정보", "거래가격", "일반가격"] as const;
|
||||
|
||||
@@ -31,12 +31,12 @@
|
||||
| 출처 | 물가지 네 열 — 상용 적산 자료(ESTX) · `원가계산/다산소프트/2026-07-main.estx` / 나라장터 가격정보 — `원가계산/자재단가/나라장터_시설공통자재_*.json` |
|
||||
| 받는 법 | ESTX = 구매 파일(배포 전 판권 구매 뒤 갱신) · 나라장터 = `collect_cost_sources.py values` (API) · 유료 물가지 직접 구독은 미정 |
|
||||
| 주기 | ESTX 반기 · 나라장터 연 2회+수시 |
|
||||
| 빌더 | `scripts/build_재료_자재품목.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` |
|
||||
|
||||
- 나라장터 스냅샷에서 마스터 줄을 뽑는 빌더 없음 — 구멍.
|
||||
|
||||
## 관급 (자재품목 「관급」 열)
|
||||
## 조달청 (자재품목 「조달청」 열)
|
||||
|
||||
- 지금 정본에는 적용 안 함 — 요구 822(재료 = EST 그대로).
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -109,10 +109,14 @@
|
||||
|
||||
**마스터는 값을 고르는 곳이 아니라 프로젝트에 쓸 데이터를 모으는 곳** — 최저가·견적 고르기는 프로젝트 몫.
|
||||
|
||||
- `재료_자재품목` = 자재 테이블 하나 · **열 한 벌** — 키 · 구분 · 상세구분 · 이름 · 규격 · 단위 · 물가자료 · 유통물가 · 물가정보 · 거래가격 · 관급 · 출처 · 비고 · 면수 · 모든 줄이 모든 칸(없으면 `null`).
|
||||
- 값 열 다섯 = 물가지 넷(물가자료 · 유통물가 · 물가정보 · 거래가격) · `관급`(조달) — 채택값·기본가·견적가 두지 않음.
|
||||
- `관급` = 원문 「가격정보」 칸의 조달청 관급 단가(철근 · 레미콘 · 아스콘 · H형강 등) · 기간은 `비고` 「조달 26.7」 · 「조달 ~29.3.18」.
|
||||
- 그 밖의 줄에 남은 「가격정보」 값은 출처가 조달인지 물가지인지 원문이 안 가름 — 어느 값 열에도 넣지 않고 `비고` 「가격정보 1,485 · 2026.7」 로만(67 줄).
|
||||
- `재료_자재품목` = 자재 테이블 하나 · **열 한 벌** — 키 · 구분 · 상세구분 · 이름 · 규격 · 단위 · 상하차 · 조달청 · 물가자료 · 유통물가 · 물가정보 · 거래가격 · 일반가격 · 출처 · 비고 · 면수 · 모든 줄이 모든 칸(없으면 `null`) · 차례 = `scripts/material_merge.py` `COLUMNS`.
|
||||
- 값 열 여섯 = `조달청`(관급) · 물가지 넷(물가자료 · 유통물가 · 물가정보 · 거래가격) · `일반가격`(사급) — 채택값·기본가·견적가 두지 않음.
|
||||
- `조달청` = 자리만 둠 · 값 없음(원문 「가격정보」 값은 넣지 않음) · 가장 낮은 값 고르기에 넣지 않음.
|
||||
- `일반가격` = 시스템 관리자가 M01 에서 손으로 넣는 칸 · 빌더가 지금 정본 값을 이어 받음.
|
||||
- 가장 낮은 값 = 사급 칸(물가지 넷 + 일반가격) 가운데 · `scripts/master_material.py` `SLOTS`.
|
||||
- `상하차` = 상차 · 하차 · `-`(기본 · 모름) 가운데 하나 · 값이 아닌 표지 · 자리 = 단위 바로 뒤 · 이름 · 규격의 인도 조건 글(도착도 · 하차도 = 하차 · 채취장상차 · 하치장 상차도 = 상차)은 이 칸으로.
|
||||
- 항목 통합 = 지역 · 관급/사급 · 제조사 · 조합 · 상하차 덧정보를 뗀 이름 · 규격 · 단위 · 상하차가 같은 줄은 한 줄(`scripts/material_merge.py`) · 남는 줄 = 전국 › 서울 › 경기 › 그 밖(값 중간) · 뗀 말 · 기준 지역은 `비고` 「통합: …」.
|
||||
- 재료 아닌 줄은 두지 않음 — 품질시험 수수료 · 일반 도로용 블록.
|
||||
- `구분` · `상세구분` = 원문(ESTX)에 대·중분류 칸이 없어 품명 글자 규칙으로 채움(`scripts/build_자재품목_분류.py`) · 묶음 목록은 `ref/_묶음_자재품목.md` · 못 가른 줄은 「미분류」.
|
||||
- `면수` = 물가지 값의 원문 면수 묶음 `{ 물가자료, 유통물가, 물가정보, 거래가격 }` · 없으면 `null` · 화면에는 접어 둠.
|
||||
- **원문번호 칸 없음** — 원문(ESTX)이 준 품번 `M0005250505` 는 남의 번호라 이름 몫을 못 함 · 대조는 `_키대장.json` 에만 둠(조달청에서 새로 더한 줄의 조달청 번호도 같음). 로직·연결은 키로 적음.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
`scripts/build_자재품목_분류.py` 가 만든 표 — 손으로 고치지 않음.
|
||||
|
||||
- 구분 24 · 상세구분 91 · 미분류 25 줄 (전체 24092 줄)
|
||||
- 구분 24 · 상세구분 91 · 미분류 25 줄 (전체 17692 줄)
|
||||
|
||||
## 규칙
|
||||
|
||||
@@ -14,61 +14,49 @@
|
||||
|
||||
## 구분별 상세구분 (줄 수)
|
||||
|
||||
### 관·이음관 (5399)
|
||||
### 관·이음관 (5205)
|
||||
|
||||
PE관 1154 · 이음쇠·부속 1003 · 주철관 828 · 강관 706 · PVC관 461 · 파형강관 417 · 유리섬유·FRP관 398 · 흄관 222 · 동관·스텐관 133 · 단열이중관 77
|
||||
PE관 1141 · 이음쇠·부속 1003 · 주철관 794 · 강관 706 · 파형강관 417 · 유리섬유·FRP관 398 · PVC관 340 · 흄관 196 · 동관·스텐관 133 · 단열이중관 77
|
||||
|
||||
### 식생·종자 (3456)
|
||||
### 식생·종자 (3452)
|
||||
|
||||
수목 2933 · 초본·야생화 138 · 수목지주·보호 115 · 종자 105 · 잔디·지피 97 · 농약·약제 42 · 비료·토양개량 26
|
||||
수목 2929 · 초본·야생화 138 · 수목지주·보호 115 · 종자 105 · 잔디·지피 97 · 농약·약제 42 · 비료·토양개량 26
|
||||
|
||||
### 레미콘 (3203)
|
||||
### 볼트·앵커·철물 (963)
|
||||
|
||||
레미콘 3203
|
||||
|
||||
### 철근·강재 (1754)
|
||||
|
||||
형강 717 · 철근 497 · 강판 262 · 구조용강관 132 · 철선 93 · 철망·와이어메쉬 53
|
||||
|
||||
### 아스콘·포장 (1695)
|
||||
|
||||
아스콘 1479 · 포장블록 173 · 특수포장 30 · 아스팔트 13
|
||||
|
||||
### 볼트·앵커·철물 (1002)
|
||||
|
||||
볼트·너트 526 · 와이어로프·클램프 208 · 앵커 189 · 못 41 · 철근커플러 38
|
||||
볼트·너트 526 · 와이어로프·클램프 193 · 앵커 189 · 철근커플러 38 · 못 17
|
||||
|
||||
### 전기·통신 (902)
|
||||
|
||||
케이블·전선 320 · 판넬·차단기 164 · 전선관·트레이 139 · 조명·전등 133 · 제어·계측기기 89 · 부속·기타 57
|
||||
|
||||
### 철근·강재 (828)
|
||||
|
||||
형강 319 · 강판 262 · 구조용강관 132 · 철근 67 · 철선 29 · 철망·와이어메쉬 19
|
||||
|
||||
### 밸브·수도자재 (761)
|
||||
|
||||
밸브 575 · 수도계량기·유량계 101 · 스프링클러·호스 51 · 펌프 34
|
||||
|
||||
### 운송·용역 (677)
|
||||
|
||||
화물운임 552 · 시험·검사 62 · 폐기물 61 · 상하차 2
|
||||
|
||||
### 기계·설비 (627)
|
||||
|
||||
기계류 267 · 보일러·공조 173 · 놀이·운동·시설물 109 · 측정·시험기기 68 · 소방·가스 10
|
||||
|
||||
### 맨홀·수로·그레이팅 (603)
|
||||
### 운송·용역 (617)
|
||||
|
||||
수로관 283 · 그레이팅 169 · 맨홀 151
|
||||
|
||||
### 시멘트·골재 (588)
|
||||
|
||||
골재 328 · 모르타르 189 · 시멘트 71
|
||||
화물운임 552 · 폐기물 61 · 시험·검사 2 · 상하차 2
|
||||
|
||||
### 도료·방수·접착 (572)
|
||||
|
||||
도료 258 · 접착·혼화 174 · 방수·실링 140
|
||||
|
||||
### 콘크리트제품 (494)
|
||||
### 맨홀·수로·그레이팅 (481)
|
||||
|
||||
말뚝·파일 223 · 경계석·블록 172 · PC구조물 99
|
||||
그레이팅 169 · 수로관 161 · 맨홀 151
|
||||
|
||||
### 콘크리트제품 (474)
|
||||
|
||||
말뚝·파일 223 · 경계석·블록 152 · PC구조물 99
|
||||
|
||||
### 건축마감 (454)
|
||||
|
||||
@@ -82,29 +70,41 @@ PE관 1154 · 이음쇠·부속 1003 · 주철관 828 · 강관 706 · PVC관 46
|
||||
|
||||
소모품 235 · 용접·가스 36 · 고철·기타금속 31 · 화약 13 · 용수 2
|
||||
|
||||
### 아스콘·포장 (291)
|
||||
|
||||
아스콘 131 · 포장블록 117 · 특수포장 30 · 아스팔트 13
|
||||
|
||||
### 단열·보온 (284)
|
||||
|
||||
단열재 284
|
||||
|
||||
### 목재 (246)
|
||||
### 매트·토목섬유 (228)
|
||||
|
||||
각재·판재 168 · 합판 78
|
||||
부직포·토목섬유 113 · 돌망태·사방 61 · 매트 54
|
||||
|
||||
### 매트·토목섬유 (230)
|
||||
### 시멘트·골재 (218)
|
||||
|
||||
부직포·토목섬유 113 · 돌망태·사방 63 · 매트 54
|
||||
모르타르 140 · 골재 55 · 시멘트 23
|
||||
|
||||
### 석재 (207)
|
||||
|
||||
조경석·자연석 125 · 경계석 82
|
||||
|
||||
### 교량·구조부속 (133)
|
||||
### 목재 (141)
|
||||
|
||||
신축이음·받침 75 · 보강재·정착 58
|
||||
각재·판재 117 · 합판 24
|
||||
|
||||
### 거푸집·가설재 (130)
|
||||
### 교량·구조부속 (130)
|
||||
|
||||
동바리·비계 73 · 거푸집 49 · 가설건축물 8
|
||||
신축이음·받침 75 · 보강재·정착 55
|
||||
|
||||
### 거푸집·가설재 (120)
|
||||
|
||||
동바리·비계 63 · 거푸집 49 · 가설건축물 8
|
||||
|
||||
### 레미콘 (62)
|
||||
|
||||
레미콘 62
|
||||
|
||||
### 특수장비 (4)
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""상용 적산 자료(ESTX 2026-07) 자재 → 재료_자재품목.json — 개발용 · 배포 전 판권 구매 뒤 갱신.
|
||||
|
||||
ESTX 자재 표 = StuffcDat(칸 뜻: 다산소프트/ESTX_값사전_자재노무기계.md).
|
||||
열 한 벌 = 키 · 원문번호 · 구분 · 상세구분 · 이름 · 규격 · 단위 · 물가자료 · 유통물가 · 물가정보 · 거래가격 · 관급 · 출처 · 비고 · 면수.
|
||||
열 한 벌 = material_merge.COLUMNS(키 · 구분 · 상세구분 · 이름 · 규격 · 단위 · 상하차 · 조달청 · 물가자료 · 유통물가 · 물가정보 · 거래가격 · 일반가격 · 출처 · 비고 · 면수).
|
||||
Cost2..5 = 물가지 넷 · Page2..5 = 면수. TC(채택) · Cost6(견적가)는 마스터 아님 — 뺌.
|
||||
품명에 「관급」 인 줄의 Cost1(가격정보) = 조달청 관급 단가 — 「관급」 열 · 기간은 비고 「조달 26.7」.
|
||||
그 밖의 줄에 남은 Cost1 은 출처가 조달인지 물가지인지 원문이 안 가름 — 값 열에 넣지 않고 비고 「가격정보 ○○」 로만.
|
||||
Cost1(가격정보 = 관급 정보)은 넣지 않음 — 「조달청」 열은 자리만 두고 비움(요구 827 · 830) · 비고에도 안 적음.
|
||||
「일반가격」 = 시스템 관리자가 M01 에서 넣는 칸 — 지금 정본 값을 원문번호로 이어 받음(다시 지어도 안 사라짐).
|
||||
Cost1 출처 표기로 가린 재료 아닌 줄은 뺌(요구 832) — 「2026(경북)」 품질시험 수수료 · 「~26.10.16」 일반 도로용 블록.
|
||||
항목 통합(요구 819 · 827) = material_merge — 지역 · 관급/사급 · 제조사 · 조합 덧정보를 뗀 이름 · 규격 · 단위가 같은 줄을 한 줄로.
|
||||
0 · 미저장 = 값 없음(null). 이름·규격은 원문 그대로(띄어쓰기·오자 포함). 코드 없는 빈 줄은 뺌.
|
||||
ESTX 에 대·중분류 칸이 없어 구분·상세구분은 build_자재품목_분류.py 가 품명 글자 규칙으로 채움(빌더 끝에서 부름).
|
||||
조달청 줄 · 조달청 관급 값은 넣지 않음(요구 822 — EST 그대로) · update_관급단가*.py 는 지금 정본에 적용 안 함.
|
||||
@@ -18,17 +20,16 @@ ESTX 밖 줄 = 품셈재료에서 옮긴 줄(출처 「품셈재료 …」 · PL
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
import sys
|
||||
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
|
||||
from _migrate_base import dump_master # noqa: E402
|
||||
from compare_estx import ESTX, ROOT, SLOTS, Estx # noqa: E402
|
||||
|
||||
OUT = ROOT / "resources/master_data/재료_자재품목.json"
|
||||
BASE = SLOTS[0] # Cost1 「가격정보」 — 관급이면 관급 열 · 아니면 비고
|
||||
MARKET = SLOTS[1:5] # 물가지 넷 · 견적가(Cost6)는 마스터 아님
|
||||
# 유류·전력 — 유가·전기요금은 재료_유가전력(유류는 값이 비고 글뿐 · 중유는 받지 않음 · 대장 「폐기」)
|
||||
DROP = {
|
||||
@@ -41,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)}
|
||||
@@ -52,27 +55,33 @@ def num(v):
|
||||
return int(v) if float(v).is_integer() else v
|
||||
|
||||
|
||||
def note(base, page, guard: str | None) -> str | None:
|
||||
"""비고 — 관급이면 「조달 <기간>」 · 남은 가격정보는 「가격정보 <값> · <면수>」."""
|
||||
if guard is not None:
|
||||
return f"조달 {str(guard).removeprefix('조달 ')}".strip()
|
||||
if base is None:
|
||||
return None
|
||||
return " · ".join(x for x in (f"가격정보 {base:,}", page) if x)
|
||||
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 build(rows: list[dict]) -> list[dict]:
|
||||
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
|
||||
base, page = num(r.get("Cost1")), r.get("Page1") or None
|
||||
public = "관급" in r.get("Name", "") and base is not None
|
||||
면수 = {slot: r.get(f"Page{i}") or None for i, slot in enumerate(MARKET, 2)}
|
||||
비고 = note(base, page, page if public else None)
|
||||
비고 = None
|
||||
if r.get("Name") in ("VR관", "VR관(관급)") and r.get("Size") in VR_SIZES:
|
||||
비고 = " · ".join(x for x in (비고, VR_NOTE) if x)
|
||||
비고 = VR_NOTE
|
||||
out.append(
|
||||
{
|
||||
"열쇠": code,
|
||||
@@ -81,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)},
|
||||
"관급": base if public else None,
|
||||
mm.OWN: own.get(code),
|
||||
"출처": f"자재품목 {code}",
|
||||
"비고": 비고,
|
||||
"면수": 면수 if any(면수.values()) else None,
|
||||
@@ -115,43 +126,31 @@ def prune() -> int:
|
||||
return len(gone)
|
||||
|
||||
|
||||
def check(rows: list[dict]) -> None:
|
||||
"""원본 대조 — 줄 수 · 값 열마다 합 · 관급·가격정보 가름 · 표본 쉰 줄."""
|
||||
def check(rows: list[dict], gone: int) -> None:
|
||||
"""원본 대조 — 줄 수(원본 − 재료 아닌 줄 − 통합으로 지운 줄) · 남는 줄 전부 물가지 넷 · 면수가 원본 그 줄 것 · 조달청 칸 빔."""
|
||||
data = json.loads(OUT.read_text(encoding="utf-8"))
|
||||
got = {r["열쇠"]: r for r in data["줄"]}
|
||||
raw = {r["Code"]: r for r in rows if r.get("Code") and r["Code"] not in DROP}
|
||||
got = {k: r for k, r in got.items() if not str(r["출처"]).startswith(CARRY)}
|
||||
assert len(got) == len(raw), (len(got), len(raw))
|
||||
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)
|
||||
print(
|
||||
f"줄 {len(got)} = 원본 코드 있는 줄 {len(raw)}"
|
||||
f" (코드 없는 빈 줄 {sum(1 for r in rows if not r.get('Code'))} · 유류 {len(DROP)} 뺌)"
|
||||
f"줄 {len(got)} = 원본 코드 있는 줄 {len(raw)} − 재료 아닌 줄 {out} − 통합으로 지운 줄 {gone}"
|
||||
f" (유류 {len(DROP)} 뺌)"
|
||||
)
|
||||
for i, slot in enumerate((*MARKET, "관급"), 2):
|
||||
a = math.fsum(r.get(f"Cost{i}") or 0 for r in raw.values())
|
||||
if slot == "관급": # Cost1 가운데 관급 줄만
|
||||
a = math.fsum(r.get("Cost1") or 0 for r in raw.values() if "관급" in r.get("Name", ""))
|
||||
b = math.fsum(r[slot] or 0 for r in got.values())
|
||||
n = sum(1 for r in got.values() if r[slot] is not None)
|
||||
assert abs(a - b) < 1e-3, (slot, a, b)
|
||||
print(f" {slot}: 값 {n}줄 · 합 {b:,.1f} = 원본 {a:,.1f}")
|
||||
left = [r for r in got.values() if str(r["비고"] or "").startswith("가격정보")]
|
||||
print(
|
||||
f" 비고에만 남긴 가격정보 {len(left)}줄 · 관급 기간 비고 {sum(1 for r in got.values() if str(r['비고'] or '').startswith('조달'))}줄"
|
||||
)
|
||||
random.seed(20260719)
|
||||
for code in random.sample(sorted(raw), 50):
|
||||
r, g = raw[code], got[code]
|
||||
assert (g["이름"], g["규격"], g["단위"]) == (
|
||||
r.get("Name", ""),
|
||||
r.get("Size", ""),
|
||||
r.get("Unite", ""),
|
||||
)
|
||||
public = "관급" in r.get("Name", "")
|
||||
assert (g["관급"] or 0) == ((r.get("Cost1") or 0) if public else 0), code
|
||||
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), (code, slot)
|
||||
assert (g["면수"] or {}).get(slot) == (r.get(f"Page{i}") or None), (code, slot)
|
||||
print(" 표본 50줄 이름·규격·단위·값 5·면수 4 같음")
|
||||
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[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(" 남는 줄 전부 물가지 넷 · 면수 = 원본 그 줄 · 조달청 칸 빔 · 열 한 벌")
|
||||
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()))
|
||||
|
||||
|
||||
def main():
|
||||
@@ -169,20 +168,24 @@ def main():
|
||||
],
|
||||
"비고": [
|
||||
"다산소프트 EST 2026-07 자재 · 개발용 · 배포 전 판권 구매",
|
||||
"값 열 다섯 = 물가지 넷(물가자료 · 유통물가 · 물가정보 · 거래가격) · 관급(조달) — 채택값·기본가 없음 · 쓰는 곳이 값을 고르고 출처를 밝힘",
|
||||
"값 열 여섯 = 조달청(관급) · 물가지 넷(물가자료 · 유통물가 · 물가정보 · 거래가격) · 일반가격(시스템 관리자 손 입력) — 채택값·기본가·견적가 없음 · 가장 낮은 값 고르기는 조달청 칸을 안 봄",
|
||||
"유류·전력은 뺌 — 유가·전기요금은 재료_유가전력(유류는 값이 비고 글뿐 · 중유는 받지 않음)",
|
||||
"원문번호(다산소프트 품번) 칸 없음 — 대조는 _키대장.json 에만",
|
||||
"관급 = 원문 「가격정보」 칸의 조달청 관급 단가(철근 · 레미콘 · 아스콘 · H형강 등) · 기간은 비고 「조달 26.7」",
|
||||
"조달청 칸 = 자리만 둠 · 값 없음 — 원문 「가격정보」(관급 정보) 값은 넣지 않음(요구 827 · 830)",
|
||||
"상하차 = 상차 · 하차 · -(기본 · 모름) · 단위 바로 뒤 — 이름 · 규격의 인도 조건 글(도착도 · 하차도 = 하차 · 채취장상차 · 하치장 상차도 = 상차)을 떼어 이 칸으로(요구 828 · 831) · 값이 아닌 표지",
|
||||
"항목 통합 = 지역 · 관급/사급 · 제조사 · 조합 덧정보를 뗀 이름 · 규격 · 단위가 같은 줄을 한 줄로(전국 › 서울 › 경기 › 그 밖 지역 값 중간) · 뗀 말은 비고 「통합: …」 (scripts/material_merge.py · 요구 819 · 827 · 831)",
|
||||
"재료 아닌 줄은 뺌 — 품질시험 수수료(가격정보 출처 「2026(경북)」) · 일반 도로용 블록(「~26.10.16」)(요구 832)",
|
||||
"조달청 줄 · 조달청 관급 값은 넣지 않음 — EST 그대로(요구 822)",
|
||||
"품셈재료에서 옮긴 줄(출처 「품셈재료 …」 · 값 없음)은 ESTX 밖 — 지금 정본에서 이어 받음",
|
||||
"원문 「가격정보」 칸에 시중 값이 남은 줄은 값 열에 넣지 않고 비고 「가격정보 ○○」 로만 둠 — 출처가 조달인지 물가지인지 원문이 안 가름",
|
||||
"구분 · 상세구분 = 품명 글자 규칙으로 채움(scripts/build_자재품목_분류.py · 묶음 목록 ref/_묶음_자재품목.md) · 조달청 줄은 조달청 품명을 따름 · 못 가린 줄은 「미분류」",
|
||||
"면수 = 물가지 값의 원문 면수 · 화면에는 접어 둠",
|
||||
],
|
||||
}
|
||||
extra = carried()
|
||||
dump_master(OUT, head, build(rows) + extra)
|
||||
check(rows)
|
||||
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))
|
||||
print("ESTX 밖 줄 이어 받음", len(extra))
|
||||
print("새 틀로 받음", mk.adopt()) # 열쇠 → 키 — 분류가 「키」 줄을 읽으므로 먼저
|
||||
# adopt 의 keep_text 가 구분 · 상세구분을 한 줄에 두 번 씀 — 읽어서(뒤 값) 다시 씀
|
||||
|
||||
@@ -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["키"]))
|
||||
|
||||
@@ -33,6 +33,7 @@ import master_combo as mcb # noqa: E402
|
||||
import master_formula as mf # noqa: E402
|
||||
import master_keys as mk # noqa: E402
|
||||
import master_machine_files as mmf # noqa: E402
|
||||
import material_merge as mm # noqa: E402
|
||||
|
||||
# (2) 본문은 따로 모듈 — 부르는 쪽은 `cm.check_body` · `cm.DIVISIONS` 그대로
|
||||
from check_master_body import DIVISIONS, check_body # noqa: E402, F401
|
||||
@@ -119,22 +120,7 @@ LABOR_STATUS = ("공표", "산정", "미공표", "미확보", "추정")
|
||||
# 원문별 열 한 벌 — 모든 줄이 이 칸을 이 차례로 가짐(없으면 null)
|
||||
COLS = {
|
||||
# 자재품목 — 원문번호(남의 품번)는 줄에 두지 않음 · 대조는 `_키대장.json` 에만
|
||||
"자재품목": (
|
||||
"키",
|
||||
"구분",
|
||||
"상세구분",
|
||||
"이름",
|
||||
"규격",
|
||||
"단위",
|
||||
"물가자료",
|
||||
"유통물가",
|
||||
"물가정보",
|
||||
"거래가격",
|
||||
"관급",
|
||||
"출처",
|
||||
"비고",
|
||||
"면수",
|
||||
),
|
||||
"자재품목": mm.COLUMNS, # 열 한 벌 · 차례 = material_merge 한 곳
|
||||
"유가전력": (
|
||||
"키",
|
||||
"원문번호",
|
||||
@@ -315,6 +301,8 @@ def check_form(name: str, data: dict) -> list[str]:
|
||||
out.append(f"{where} · 「{ALIAS}」 이 빈 글 · 글 아님(빈 칸이면 칸을 두지 않음)")
|
||||
if cols and _cols_of(row) != cols:
|
||||
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 kind == "품셈재료":
|
||||
out += _check_linked(where, row)
|
||||
if kind == "요소" and isinstance(row.get("값"), dict):
|
||||
|
||||
@@ -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 = (
|
||||
"구분",
|
||||
"상세구분",
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
"""재료 자재품목 항목 통합(요구 819 · 827 · 828 · 830 ~ 832 · PLAN 830-2) — build_재료_자재품목 이 부름.
|
||||
시험 = test_830_2_material_merge.
|
||||
|
||||
묶음 = 이름에서 지역 · 관급/사급 꼬리표 · 제조사 꼬리(「-거양」) · 조합 이름 · 상하차 글을 뺀
|
||||
이름 · 규격 · 단위 · 상하차가 같은 줄.
|
||||
규격 = 앞말 「조합공통품목,」 · 규격 속 지역 괄호 뗌
|
||||
단위 = M=m · m²=㎡ · Ton=톤 · ㎏=kg 표기 하나로 · 비교는 공백을 뺀 글
|
||||
열 한 벌 = COLUMNS — 상하차 = 단위 바로 뒤 · 가격 열 = 조달청(관급) · 물가지 넷 · 일반가격(사급).
|
||||
상하차 칸(요구 828 · 831) = 상차 · 하차 · -(기본 · 모름) — 이름 · 규격의 인도 조건 글을 떼어 이 칸으로
|
||||
「도착도」 · 「하차도」 = 하차 · 「채취장상차(도)」 · 「하치장 · 생산공장 상차도」 · 「(상차도)」 = 상차
|
||||
남는 줄 = 사급(관급 아닌) 줄 가운데 전국 › 지역 글 없음(전국으로 봄) › 서울 › 서울 권역 글 › 경기
|
||||
› 그 밖 지역(요구 830 · 831 — 조달청과 같은 차례) · 같은 차례 여럿(경기 · 그 밖)이면 값이 중간인 줄
|
||||
(짝수면 아래 중간 · 다시 지어도 같은 줄)
|
||||
· 남길 줄의 값 칸이 전부 비고 다른 줄에 값이 있으면 다음 차례 줄(브)
|
||||
· 관급 줄만 있는 묶음 = 가장 작은 키 한 줄(브) · 나머지 줄은 지움(이력 안 남김)
|
||||
덧정보가 하나도 없는 묶음(원래부터 겹친 줄 — 식생 · 종자 등)은 합치지 않음.
|
||||
조달청 칸 값은 모두 비움(EST 「가격정보」 — 요구 827 · 831) · 일반가격 = 시스템 관리자가 넣는 칸(빌더가 이어 받음).
|
||||
이름에서 뗀 말은 비고 「통합: …」 로.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
MARKET = ("물가자료", "유통물가", "물가정보", "거래가격")
|
||||
LOAD = "상하차" # 표지 칸(값 칸 아님)
|
||||
LOADS = ("-", "상차", "하차") # 첫째 = 기본(모름)
|
||||
GOVT = "조달청" # 관급 가격 칸 — 가장 낮은 값 고르기에 안 넣음(요구 826 · 830)
|
||||
OWN = "일반가격" # 시스템 관리자 손 입력 칸(사급)
|
||||
COLUMNS = ( # 열 한 벌 · 차례(요구 830 · 831)
|
||||
"키",
|
||||
"구분",
|
||||
"상세구분",
|
||||
"이름",
|
||||
"규격",
|
||||
"단위",
|
||||
LOAD,
|
||||
GOVT,
|
||||
*MARKET,
|
||||
OWN,
|
||||
"출처",
|
||||
"비고",
|
||||
"면수",
|
||||
)
|
||||
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:
|
||||
"""줄 → 덧정보를 뗀 이름 · 규격 · 상하차 · 묶음 열쇠 · 뗀 말."""
|
||||
name, spec = row.get("이름") or "", row.get("규격") or ""
|
||||
words = []
|
||||
region, base, spec2 = _region(name, spec)
|
||||
m = re.search(r"-거양$", base)
|
||||
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),
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
+17697
-24095
File diff suppressed because it is too large
Load Diff
@@ -22,7 +22,7 @@ CASES = {
|
||||
"덧줄": {"덮개": {"pct:소모자재#0": {"qty": 5}}},
|
||||
"별도계상": {"자재표시": {PLY: {"별도계상": "별도계상"}}},
|
||||
"단위 환산": {"단위": "10㎡"},
|
||||
"재료 고름": {"고름": {PLY: "MT000020"}},
|
||||
"재료 고름": {"고름": {PLY: "MT000037"}}, # 830-2 — 지역 줄(부산) 통합 · 다른 품목 줄을 고름
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -166,7 +166,9 @@ 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"
|
||||
assert (
|
||||
whole.get(ply)["이름"] == "합판(내수)" and whole.get(nail)["규격"] == "N 25"
|
||||
) # 830-2 지역 통합
|
||||
assert link.m01("재료", {"이름": "접착제", "규격": "", "단위": "kg"}) == (None, None)
|
||||
|
||||
|
||||
|
||||
@@ -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": "결속선(철선)", "MT000175": "결속선(철선)"}):
|
||||
assert link.m01("재료", dict(line)) == ("MT000174", None) # 같은 품목 지역 줄 = 서울
|
||||
with aliases("MT", {"MT000174": "결속선(철선)"}): # 830-2 — 지역 줄 통합 뒤 한 줄
|
||||
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,7 +114,12 @@ 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
|
||||
assert {c["키"] for c in found["후보"][:2]} == {"MT015181", "MT015282"}
|
||||
pipes = {
|
||||
"MT015181",
|
||||
"MT015282",
|
||||
"MT015356",
|
||||
} # 830-2 — 관급 줄 이름에서 「(관급)」 뗌 · 800 파형강관 셋
|
||||
assert {c["키"] for c in found["후보"][:2]} <= pipes
|
||||
assert all(c.get("단서") == "단서" for c in found["후보"][:2])
|
||||
assert found["단서"][0] == "산근 머리 — 파형강관부설 Φ800mm"
|
||||
with mf.clues_on(clues, "철근가공조립(현장) TYPE Ⅰ"):
|
||||
@@ -197,4 +202,5 @@ def test_real_files_carry_clues():
|
||||
and not line.get("단가")
|
||||
and not any((line.get("값묶음") or {}).values())
|
||||
)
|
||||
assert {c["키"] for c in line["자원"]["재료찾기"]["후보"][:2]} == {"MT015181", "MT015282"}
|
||||
top = {c["키"] for c in line["자원"]["재료찾기"]["후보"][:2]}
|
||||
assert len(top) == 2 and top <= {"MT015181", "MT015282", "MT015356"} # 830-2 통합 뒤
|
||||
|
||||
@@ -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["이름"])
|
||||
gov = next(c for c in found["후보"] if "파형강관" in c["이름"]) # 830-2 이름에 관급 없음
|
||||
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 "품목: 파형강관(관급)" in _var(now["문서"], e["기호"])["설명"]
|
||||
assert f"품목: {gov['이름']} {gov['규격']}" in _var(now["문서"], e["기호"])["설명"]
|
||||
# 이음 없는 줄 목록(가져온 식 머리 [이음 고르기]) = 종 열쇠(같은 파일 물음)
|
||||
assert any(
|
||||
u.get("종") == kind for u in imp.unlinked(store.read("sangeun", plain["이름"])["문서"])
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
"""830-1 재료 표 = EST 그대로(요구 822) — ESTX 원본을 빌더 build() 로 지은 기준 표와 정본 대조.
|
||||
|
||||
조달청 줄 · 조달청 관급 값 없음 · EST 줄 키 그대로 · 키대장 = 표 · 열 한 벌 그대로.
|
||||
기준과 달라도 되는 것(㉯) = 품셈재료에서 옮긴 줄(출처 「품셈재료 …」) · 분류가 단 비고 「이름 없음 …」.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "resources/master_data/scripts"))
|
||||
|
||||
import build_자재품목_분류 as cls # noqa: E402
|
||||
import build_재료_자재품목 as builder # noqa: E402
|
||||
import compare_estx as ce # noqa: E402
|
||||
import master_keys as mk # noqa: E402
|
||||
|
||||
MASTER = ROOT / "resources/master_data"
|
||||
COLS = (
|
||||
"키",
|
||||
"구분",
|
||||
"상세구분",
|
||||
"이름",
|
||||
"규격",
|
||||
"단위",
|
||||
"물가자료",
|
||||
"유통물가",
|
||||
"물가정보",
|
||||
"거래가격",
|
||||
"관급",
|
||||
"출처",
|
||||
"비고",
|
||||
"면수",
|
||||
)
|
||||
VALUES = ("물가자료", "유통물가", "물가정보", "거래가격", "관급")
|
||||
SAME = ("이름", "규격", "단위", *VALUES, "면수", "출처")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def t():
|
||||
if not ce.ESTX.is_file():
|
||||
pytest.skip("ESTX 원본 없음")
|
||||
book = mk.load_book()
|
||||
by = {v["원문번호"]: k for k, v in book["키"].items() if k.startswith("MT")}
|
||||
base = {by[r["열쇠"]]: r for r in builder.build(ce.Estx().table("StuffcDat"))}
|
||||
rows = json.loads((MASTER / "재료_자재품목.json").read_text(encoding="utf-8"))["줄"]
|
||||
return base, {r["키"]: r for r in rows}, rows, book
|
||||
|
||||
|
||||
def test_EST_줄_키_그대로_다섯_값_같음(t):
|
||||
base, cur, _, _ = t
|
||||
assert len(base) == 23973 and set(base) <= set(cur)
|
||||
assert [(k, c) for k in base for c in SAME if base[k][c] != cur[k][c]] == []
|
||||
note = [k for k in base if base[k]["비고"] != cur[k]["비고"]]
|
||||
assert all(cur[k]["비고"] == cls.NOTE_EMPTY and not cur[k]["이름"] for k in note)
|
||||
|
||||
|
||||
def test_조달청_줄_값_없음(t):
|
||||
_, cur, _, _ = t
|
||||
assert not [k for k, r in cur.items() if "조달청" in str(r["출처"])]
|
||||
assert not [k for k, r in cur.items() if str(r["비고"] or "").startswith("관급:")]
|
||||
assert all("관급" in r["이름"] for r in cur.values() if r["관급"] is not None)
|
||||
|
||||
|
||||
def test_EST_밖_줄은_품셈재료만(t):
|
||||
base, cur, _, _ = t
|
||||
extra = [r for k, r in cur.items() if k not in base]
|
||||
assert len(extra) == 119
|
||||
assert all(str(r["출처"]).startswith(builder.CARRY) for r in extra)
|
||||
assert all(r[c] is None for r in extra for c in VALUES)
|
||||
|
||||
|
||||
def test_키대장_표와_맞음_번호_재사용_없음(t):
|
||||
_, cur, _, book = t
|
||||
mt = {k for k, v in book["키"].items() if v["파일"] == "재료_자재품목.json"}
|
||||
assert mt == set(cur)
|
||||
assert book["다음"]["MT"] > max(int(k[2:]) for k in cur)
|
||||
assert not set(cur) & set(book.get("폐기", {}))
|
||||
|
||||
|
||||
def test_열_한_벌(t):
|
||||
_, _, rows, _ = t
|
||||
assert {tuple(r) for r in rows} == {COLS}
|
||||
@@ -0,0 +1,168 @@
|
||||
"""830-2 재료 표 — 항목 통합 · 가격 열 재정의(요구 827 ~ 832) 지킴.
|
||||
|
||||
열 한 벌(상하차 = 단위 뒤 · 조달청 · 물가지 넷 · 일반가격) · 조달청 값 0 · 이름에 덧정보 없음 ·
|
||||
지역 기준 전국 › 서울 › 경기 › 그 밖 · 관급 사급 한 줄 · 철근 상차 줄 따로 · 재료 아닌 줄 0 ·
|
||||
치즐 값 없음 · 남는 줄 키 · 물가지 값 = EST 원본 그 줄 · 빌더 = 정본 · 가장 낮은 값이 조달청 칸을 안 봄.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "resources/master_data/scripts"))
|
||||
|
||||
import build_자재품목_분류 as cls # noqa: E402
|
||||
import build_재료_자재품목 as builder # noqa: E402
|
||||
import compare_estx as ce # noqa: E402
|
||||
import master_keys as mk # noqa: E402
|
||||
import master_material as mmat # noqa: E402
|
||||
import material_merge as mm # noqa: E402
|
||||
|
||||
MASTER = ROOT / "resources/master_data"
|
||||
COLS = (
|
||||
"키",
|
||||
"구분",
|
||||
"상세구분",
|
||||
"이름",
|
||||
"규격",
|
||||
"단위",
|
||||
"상하차",
|
||||
"조달청",
|
||||
"물가자료",
|
||||
"유통물가",
|
||||
"물가정보",
|
||||
"거래가격",
|
||||
"일반가격",
|
||||
"출처",
|
||||
"비고",
|
||||
"면수",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def rows():
|
||||
return json.loads((MASTER / "재료_자재품목.json").read_text(encoding="utf-8"))["줄"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def raw():
|
||||
if not ce.ESTX.is_file():
|
||||
pytest.skip("ESTX 원본 없음")
|
||||
return ce.Estx().table("StuffcDat")
|
||||
|
||||
|
||||
def test_열_한_벌_차례(rows):
|
||||
assert mm.COLUMNS == COLS
|
||||
assert {tuple(r) for r in rows} == {COLS}
|
||||
|
||||
|
||||
def test_상하차_세_값(rows):
|
||||
got = {r["상하차"] for r in rows}
|
||||
assert got == {"-", "상차", "하차"}
|
||||
|
||||
|
||||
def test_조달청_빔_일반가격_빔(rows):
|
||||
assert all(r["조달청"] is None for r in rows)
|
||||
assert all(
|
||||
r["일반가격"] is None for r in 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_재료_아닌_줄_0_치즐_남고_값_없음(rows):
|
||||
names = {r["이름"] for r in rows}
|
||||
gone = ("골재시험", "콘크리트시험", "흙의물리시험", "아스팔트시험", "품질시험 현장출장비")
|
||||
assert not names & set(gone)
|
||||
assert not names & {"네오스톤블록", "네오스톤슬라브", "앤틱스톤블록"}
|
||||
chisel = [r for r in rows if r["키"] in ("MT000009", "MT000011")] # 품셈(2016) 값 줄 둘
|
||||
assert [r["이름"].replace(" ", "") for r in chisel] == ["치즐(0.4㎥)", "치즐(0.7㎥)"]
|
||||
assert all(r[c] is None for r in chisel for c in (mm.GOVT, *mm.MARKET, mm.OWN))
|
||||
assert not [
|
||||
r
|
||||
for r in rows
|
||||
if "가격정보" in str(r["비고"] or "") or "품셈(2016)" in str(r["비고"] or "")
|
||||
]
|
||||
|
||||
|
||||
def test_철근_상차도_줄_따로(rows):
|
||||
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 region == "광주" # 그 밖 = 값 중간
|
||||
|
||||
|
||||
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):
|
||||
book = mk.load_book()["키"]
|
||||
code = {r["키"]: book[r["키"]]["원문번호"] for r in rows}
|
||||
src = {r["Code"]: r for r in raw if r.get("Code")}
|
||||
est = [r for r in rows if not str(r["출처"]).startswith(builder.CARRY)]
|
||||
assert len(est) == 17573 and len(rows) == 17692
|
||||
for r in est:
|
||||
s = src[code[r["키"]]]
|
||||
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]
|
||||
same = [
|
||||
c for c in COLS if c not in ("키", "구분", "상세구분", "비고")
|
||||
] # 분류 두 칸 = 분류기 몫
|
||||
for r, b in zip(rows, built):
|
||||
assert [r[c] for c in same] == [b[c] for c in same]
|
||||
assert r["비고"] == b["비고"] or (r["비고"] == cls.NOTE_EMPTY and not r["이름"])
|
||||
|
||||
|
||||
def test_가장_낮은_값은_조달청_안_봄():
|
||||
assert mm.GOVT not in mmat.SLOTS and mm.OWN in mmat.SLOTS
|
||||
row = {"키": "MT999999", mm.GOVT: 1, "물가자료": 50, mm.OWN: 40}
|
||||
|
||||
class M:
|
||||
def get(self, key):
|
||||
return row
|
||||
|
||||
assert mmat._lowest(M(), "MT999999") == (40, "MT999999.일반가격")
|
||||
@@ -342,11 +342,14 @@ 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)
|
||||
# 조달청 값이 아직 안 이어진 줄도 섞여 있음 — 이어진 줄이 하나라도 있으면 됨
|
||||
assert any(i["관급"] and i["값"] > 0 for i in market["items"])
|
||||
market = _get(client, "/api/m01/pick", kind="price", q="H형강", limit=50)
|
||||
# 830-2 — 조달청 칸은 아직 빔(관급 표시 없음) · 값 = 사급 칸 가운데 낮은 값
|
||||
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"]
|
||||
cement = _get(client, "/api/m01/pick", kind="price", q="시멘트")[
|
||||
"items"
|
||||
] # 830-2 이름에 지역 없음
|
||||
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]
|
||||
@@ -386,6 +389,10 @@ 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"))
|
||||
@@ -514,7 +521,7 @@ def test_재료_고르기_후보_다섯_값(client: TestClient) -> None:
|
||||
row.update({"구분": "돌망태붙임", "상세구분": "돌망태", "이름": "돌망태", "규격": "1x1x1"})
|
||||
for slot in store.cm.mf.SLOTS:
|
||||
row[slot] = None
|
||||
priced["물가정보"], priced["관급"] = 20000, 18000
|
||||
priced["물가정보"], priced["일반가격"], priced["조달청"] = 20000, 18000, 100
|
||||
market.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
got = _get(client, "/api/m01/materials", sub="돌망태붙임")
|
||||
@@ -526,9 +533,9 @@ def test_재료_고르기_후보_다섯_값(client: TestClient) -> None:
|
||||
"유통물가": None,
|
||||
"물가정보": 20000,
|
||||
"거래가격": None,
|
||||
"관급": 18000,
|
||||
"일반가격": 18000,
|
||||
}
|
||||
assert first["값"] == 18000 and first["관급"] is True
|
||||
assert first["값"] == 18000 and first["관급"] is True # 조달청 100 은 낮은 값에 안 듦
|
||||
assert second["ref"] == blank["키"] and second["값"] is None
|
||||
|
||||
|
||||
|
||||
@@ -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 부산", "㎡"))
|
||||
assert busan["출처"].startswith("MT000020") and busan["단가"] == 11488
|
||||
busan = _line(client, _sample("합판(내수) 12t 부산", "㎡")) # 830-2 지역 줄 통합 — 한 줄
|
||||
assert busan["출처"].startswith("MT000018") and busan["단가"] == 11354
|
||||
assert "단위경고" not in seoul
|
||||
|
||||
|
||||
def test_고름은_그_줄만_그_품목으로_셈(client) -> None:
|
||||
row = _sample("합판(내수) 12t 서울", "㎡")
|
||||
got = _line(client, row, {"0": "MT000019"})
|
||||
assert got["출처"].startswith("MT000019") and got["단가"] == 11556
|
||||
got = _line(client, row, {"0": "MT000037"})
|
||||
assert got["출처"].startswith("MT000037") and got["단가"] == 8465
|
||||
assert _line(client, row)["단가"] == 11354 # 요청마다 — 저장 안 함
|
||||
text = _line(client, row, {"0": "MT000019"}, "text")
|
||||
text = _line(client, row, {"0": "MT000037"}, "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): "MT000019"}}).json()[
|
||||
lines = client.post("/api/m01/calc", json={**body, "고름": {str(idx): "MT000037"}}).json()[
|
||||
"lines"
|
||||
]
|
||||
assert lines[idx]["품목"]["키"] == "MT000019" and "인천" in lines[idx]["품목"]["이름"]
|
||||
assert lines[idx]["품목"]["키"] == "MT000037" and "준내수" in lines[idx]["품목"]["이름"]
|
||||
|
||||
|
||||
def test_목록_검색은_띄어쓰기를_무시(client) -> None:
|
||||
|
||||
@@ -123,24 +123,17 @@ H_BEAM = {"이름": "H 형강", "규격": "SS275, 100*100*6*8mm", "단위": "Ton
|
||||
|
||||
|
||||
def test_전국_지역_줄글_전국품목():
|
||||
"""830-2 — 재료 표가 지역 · 관급/사급 줄을 한 줄로 통합 → 지역 글이 있든 없든 그 한 줄로 바로 이음
|
||||
(「(전국)」 · 지역 자리표 품목이 안 생김)."""
|
||||
from M02_MasterTemplete import M02_Hopyo_Material as hmat
|
||||
|
||||
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:])
|
||||
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
|
||||
paid = {**REMICON, "규격": "", "갈래": "자재", "단가": {"재료비": 90000}}
|
||||
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 형강(서울)"
|
||||
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 == []
|
||||
|
||||
@@ -85,7 +85,9 @@ def test_반영은_기준_곱_수량_나누기_배_돈_아닌_로직은_반영_
|
||||
ten, plain = got["줄"]
|
||||
assert (ten["배"], ten["수량"], ten["수량출처"]) == (10, 4, "손")
|
||||
for k in ("노무비", "재료비", "경비", "계"):
|
||||
assert ten["반영"][k] == pytest.approx(ten["기준"][k] * 4 / 10, abs=0.3) # 0.1 원 버림(751-3)
|
||||
assert ten["반영"][k] == pytest.approx(
|
||||
ten["기준"][k] * 4 / 10, abs=0.3
|
||||
) # 0.1 원 버림(751-3)
|
||||
assert plain["기준"] is None and plain["반영"] is None and "결과" in plain
|
||||
assert 0 <= ten["반영"]["계"] - got["반영합"]["계"] < 3 # 반영합 = 비목마다 1 원 버림(751-3)
|
||||
|
||||
@@ -116,7 +118,7 @@ def test_수량_손이_칸보다_앞섬_칸_값은_알약_키로():
|
||||
mat = got["자재"][0]
|
||||
assert (mat["기준단가"], mat["수량"], mat["반영"]) == (83000, 3, 249000)
|
||||
# 43-3 — M01 값 열 다섯(빈 것 null) · 기준단가가 된 열 이름
|
||||
assert list(mat["가격"]) == ["물가자료", "유통물가", "물가정보", "거래가격", "관급"]
|
||||
assert list(mat["가격"]) == ["물가자료", "유통물가", "물가정보", "거래가격", "일반가격"]
|
||||
assert mat["기준칸"] == "물가정보" and mat["가격"]["물가정보"] == 83000
|
||||
nothing = labor.calc(doc, {})["줄"][0]
|
||||
assert (nothing["수량출처"], nothing["반영"]) == ("없음", None)
|
||||
|
||||
@@ -724,16 +724,13 @@ def test_전력은_계약종별로_고름(whole):
|
||||
|
||||
|
||||
def test_자재품목_열_한_벌():
|
||||
"""모든 줄이 같은 열 한 벌(원문번호 없음) · 관급은 EST 원문 값(요구 822) · 기간은 비고 · 남은 가격정보는 비고에만."""
|
||||
"""모든 줄이 같은 열 한 벌(원문번호 없음) · 조달청 칸 빔 · 가격정보 비고 없음(요구 827 · 830 ~ 832)."""
|
||||
data = cm.load(["재료_자재품목.json"])["재료_자재품목.json"]
|
||||
rows = {r["키"]: r for r in data["줄"]}
|
||||
assert {tuple(r) for r in rows.values()} == {cm.COLS["자재품목"]}
|
||||
assert "원문번호" not in cm.COLS["자재품목"] # 남의 품번 — 대조는 대장에만
|
||||
steel = rows["MT001658"]
|
||||
assert steel["관급"] == Decimal(1228830) and steel["비고"].startswith("조달 26.")
|
||||
assert all(steel[s] is None for s in ("물가자료", "유통물가", "물가정보", "거래가격"))
|
||||
left = [r for r in rows.values() if str(r["비고"] or "").startswith("가격정보")]
|
||||
assert len(left) == 67 and all(r["관급"] is None for r in left)
|
||||
assert all(r["조달청"] is None for r in rows.values())
|
||||
assert not [r for r in rows.values() if str(r["비고"] or "").startswith(("가격정보", "조달 "))]
|
||||
# 유류 다섯 줄(값이 비고 글뿐)은 뗌 — 대장에 폐기로 남음
|
||||
book = mk.load_book()
|
||||
fuel = [f"MT{i:06d}" for i in range(1, 8)] # 6·7 = 전력 — 재료_유가전력으로 감
|
||||
@@ -769,14 +766,16 @@ def test_품셈재료_자재지역_후보(whole):
|
||||
mf.element(linked, "MP900003", {})
|
||||
with pytest.raises(mf.FormulaError, match="지역 값 없음"):
|
||||
mf.element(linked, "MP900003", {"자재지역": "울진"})
|
||||
with pytest.raises(mf.FormulaError, match="후보 여럿"):
|
||||
with pytest.raises(
|
||||
mf.FormulaError, match="지역 값 없음"
|
||||
): # 830-2 지역 줄 통합 — 레미콘 지역 줄 없음
|
||||
mf.element(linked, "MP900004", {"자재지역": "울진"})
|
||||
value, source = mf.element(linked, "MP900003", {"자재지역": "수원"})
|
||||
assert source.startswith("MT") and value > 0
|
||||
with pytest.raises(mf.FormulaError, match="지역 값 없음"): # 830-2 — 모래 지역 줄도 통합됨
|
||||
mf.element(linked, "MP900003", {"자재지역": "수원"})
|
||||
# PLAN 7-4b 뒤 절반 — 호표에 조건 묶음을 바로 박아도 같은 길(품셈재료 간접 없이)
|
||||
cond = {"이름": "모래", "규격": "", "지역": "입력"}
|
||||
value2, source2 = mf.element(whole, cond, {"자재지역": "수원"}, "㎥")
|
||||
assert value2 == value and source2 == source
|
||||
with pytest.raises(mf.FormulaError, match="지역 값 없음"):
|
||||
mf.element(whole, cond, {"자재지역": "수원"}, "㎥")
|
||||
|
||||
|
||||
# ── 재료 고르기 조건 (`_틀.md` 7장) ────────────────────────────────────
|
||||
@@ -791,7 +790,7 @@ def _pipes() -> mf.Master:
|
||||
"규격": "D300 B종",
|
||||
"단위": "m",
|
||||
"물가자료": None,
|
||||
"관급": None,
|
||||
"일반가격": None,
|
||||
},
|
||||
{
|
||||
"키": "MT900002",
|
||||
@@ -801,7 +800,7 @@ def _pipes() -> mf.Master:
|
||||
"규격": "D 300 B종",
|
||||
"단위": "m",
|
||||
"물가자료": Decimal(7000),
|
||||
"관급": Decimal(6500),
|
||||
"일반가격": Decimal(6500),
|
||||
},
|
||||
{
|
||||
"키": "MT900003",
|
||||
@@ -811,7 +810,7 @@ def _pipes() -> mf.Master:
|
||||
"규격": "D600 B종",
|
||||
"단위": "m",
|
||||
"물가자료": Decimal(12000),
|
||||
"관급": None,
|
||||
"일반가격": None,
|
||||
},
|
||||
{
|
||||
"키": "MT900004",
|
||||
@@ -821,7 +820,7 @@ def _pipes() -> mf.Master:
|
||||
"규격": "D300",
|
||||
"단위": "m",
|
||||
"물가자료": Decimal(9000),
|
||||
"관급": None,
|
||||
"일반가격": None,
|
||||
},
|
||||
]
|
||||
logic = {
|
||||
@@ -896,7 +895,7 @@ def test_재료_고르기_시험계산은_대표_줄_없으면_값_있는_첫_
|
||||
cond = {"구분": "관·수로", "상세구분": "흄관", "규격": "D300"}
|
||||
assert mm.pick(whole, cond, {}) == "MT900002" # MT900001 은 값이 없어 건너뜀
|
||||
value, source = mf.element(whole, cond, {})
|
||||
assert value == 6500 and source == "MT900002.관급" # 값 열 다섯 가운데 낮은 값
|
||||
assert value == 6500 and source == "MT900002.일반가격" # 사급 값 열 다섯 가운데 낮은 값
|
||||
assert mm.pick(whole, {**cond, "대표": "MT900001"}, {}) == "MT900001"
|
||||
with pytest.raises(mf.FormulaError, match="후보 없음"):
|
||||
mm.pick(whole, {"구분": "없는구분"}, {})
|
||||
@@ -1001,7 +1000,9 @@ 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())
|
||||
assert cm.check_links(whole) == []
|
||||
# 옛 자료가 가리키던 줄 — 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.master().index.get("MP") is None # 살아 있는 마스터엔 이제 없음
|
||||
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ def test_boq_xlsx_download_reimports(work):
|
||||
assert Source(r["saved_stc"]).files["PRJLST.DBF"] == Source(str(STC)).files["PRJLST.DBF"]
|
||||
|
||||
|
||||
def test_code_and_split_and_jaje_api(work):
|
||||
def test_code_and_split_api(work):
|
||||
r = A.api_code_change(
|
||||
A.CodeChgReq(source_path=str(STC), mapping={"M00001": "M99991"}, target_folder_name="cc")
|
||||
)
|
||||
@@ -68,10 +68,5 @@ def test_code_and_split_and_jaje_api(work):
|
||||
assert p.get(0, "K_ORD") == "1" and p.get(0, "TCOST2") == p.get(0, "TCOST1") == _prj(
|
||||
str(STC)
|
||||
).get(0, "TCOST1")
|
||||
x = A.api_jaje_xlsx(A.SrcReq(source_path=str(STC))).body
|
||||
r3 = A.api_jaje_import(
|
||||
A.JajeImportReq(source_path=str(STC), xlsx=A.FileIn(b64=base64.b64encode(x).decode()))
|
||||
)
|
||||
assert r3["count"] == 0
|
||||
with pytest.raises(Exception):
|
||||
A.api_jb_read(A.JbReq(path=str(STC))) # 설치 폴더 JB_* 만
|
||||
|
||||
@@ -19,15 +19,12 @@ from conv_xls_apply import import_boq, register # noqa: E402
|
||||
from conv_xls_boq import boq_from_xlsx, fill_from_costn, load_book, read_initial # noqa: E402
|
||||
from conv_xls_out import ( # noqa: E402
|
||||
export_boq_xlsx,
|
||||
export_jaje_xlsx,
|
||||
import_jaje_xlsx,
|
||||
)
|
||||
from conv_xls_res import comp_from_san, comp_from_sheets, costn_from_lists, memos_from_sheet # noqa: E402
|
||||
from dbf_rw import DbfTable # noqa: E402
|
||||
from smt_parser import extract_costn_memos, parse_dbf_bytes # noqa: E402
|
||||
from stc_project import write_output # noqa: E402
|
||||
from stc_read import Source # noqa: E402
|
||||
from stc_save import apply_save # noqa: E402
|
||||
|
||||
DOCS = ROOT / "resources/knowledge/original/실무문서"
|
||||
SO = (
|
||||
@@ -196,26 +193,6 @@ def test_own_boq_xlsx_roundtrip(stc):
|
||||
assert not bad, f"{tb} {bad[:5]}"
|
||||
|
||||
|
||||
def test_jaje_xlsx_roundtrip_and_edit(tmp_path):
|
||||
import openpyxl
|
||||
|
||||
files, _xl, _tb = _pair("봉화")
|
||||
x = export_jaje_xlsx(files)
|
||||
assert import_jaje_xlsx(files, x) == []
|
||||
p = tmp_path / "j.xlsx"
|
||||
p.write_bytes(x)
|
||||
wb = openpyxl.load_workbook(p)
|
||||
ws = wb.worksheets[0]
|
||||
code = ws.cell(2, 1).value
|
||||
old = float(ws.cell(2, 10).value or 0)
|
||||
ws.cell(2, 10).value = old + 100
|
||||
wb.save(p)
|
||||
ups = import_jaje_xlsx(files, p.read_bytes())
|
||||
assert ups == [{"CODE": code, "AMT_61": old + 100, "MAMT1": old + 100, "TAMT1": old + 100}]
|
||||
out, log = apply_save(files, {"costn_updates": ups})
|
||||
assert {x["field"] for x in log if x["table"] == "COSTN.DBF"} <= {"AMT_61", "MAMT1", "TAMT1"}
|
||||
|
||||
|
||||
# 영월 BDQTY 는 복호 칸이 어긋남(UNIT1 깨짐 · TPRPT 전부 4) — 비교 제외
|
||||
@pytest.mark.parametrize("name", ["봉화", "대흥", "울진", "영덕변경"])
|
||||
def test_comp_rows_follow_xls_2_bdrec(name):
|
||||
|
||||
@@ -212,7 +212,7 @@
|
||||
- [x] F4-24 엑셀 → 내역 불러오기 — wM_From_XLS · 현재: 구현(conv_xls_boq · 원본 엑셀 5권 → STC 내역 행 전 칸 일치(test_stmate_conv_xls))
|
||||
- [x] F4-25 엑셀 역등록(CONV_XLS_2_ST·INITIAL)·엑셀 → BDQTY — 24번 §3 · 현재: 구현(REA 37 — ST_P_XLS 0x5f1078 PRPT「속성」·PRIN「대상」·PROUT「저장」 = PR/RR 줄만 · 원본 BDQTY 대조 4건 일치)
|
||||
- [x] F4-30 BDQTY.DBF 구성표 복호 읽기 — STC_난독화_상태 · 현재: 구현(bdqty.py 자리별 회전·더하기 표 283자리 · 사용자 승인 · 글자 칸 제외)
|
||||
- [x] F4-26 자재 기준자료 XLS 입출력 — wM_JajeXLS · 현재: 구현(REA 37 — 원본 = 재료비 수량집계표 엑셀 내보내기만(TWM_JAJEXLS) · 주요건설자재현황 시트 · 웹 들여오기는 원본에 없음 · 파일 이름 기본값 미확인)
|
||||
- [x] F4-26 자재 기준자료 XLS 입출력 — wM_JajeXLS · 현재: 구현(REA 37 — 원본 = 재료비 수량집계표 엑셀 내보내기만(TWM_JAJEXLS) · 주요건설자재현황 시트(conv_jaje·scr_jaje) · 원본에 없는 「자재 기준자료 엑셀 내보내기·불러오기」 걷음 · 파일 이름 기본값 미확인)
|
||||
- [x] F4-27 기준 BOQ·코드 변경 엑셀 왕복 — 24번 §3 · ST_U_ChgCode · 현재: 구현(stc_codechg 내역 8표 엑셀 왕복 · 코드 변경 되돌리면 7건 원래 바이트)
|
||||
- [x] F4-28 구버전 → Win8 자료 변환 — ST_U_Convert ST_ConvToWin8 · 현재: 구현(REA 37 — 판 가리기(Chk_Data 0x459280) · 7.x/DOS COSTN·BDQTY·PRJLST·KONGJONG·BOQ·Start8x.Ini 대응 · B→X 조건 = 단위 X · 합성 자료 시험 · 간접비 이관·RATE·SYSINFO·IFORM 미확인(틀 유지))
|
||||
- [x] F4-29 자동백업·자료 복원 — wM_D_Restore · wM_ST_Restore · 현재: 구현(api_file 자동 백업·복원)
|
||||
|
||||
@@ -21,9 +21,7 @@ from conv_xls_apply import import_boq, register
|
||||
from conv_xls_out import (
|
||||
export_boq_xlsx,
|
||||
export_code_xlsx,
|
||||
export_jaje_xlsx,
|
||||
import_code_xlsx,
|
||||
import_jaje_xlsx,
|
||||
)
|
||||
from conv_xml import xml_to_project
|
||||
from conv_xml_cfg import apply_settings, options, validate
|
||||
@@ -118,11 +116,6 @@ class SrcReq(BaseModel):
|
||||
table: str = ""
|
||||
|
||||
|
||||
class JajeImportReq(BaseModel):
|
||||
source_path: str
|
||||
xlsx: FileIn
|
||||
|
||||
|
||||
class CodeChgReq(BaseModel):
|
||||
source_path: str
|
||||
mapping: dict[str, str] = {}
|
||||
@@ -256,20 +249,6 @@ def api_boq_xlsx(req: SrcReq):
|
||||
return _xlsx(_guard(export_boq_xlsx, s.files, req.table), f"{s.path.stem}_기준BOQ.xlsx")
|
||||
|
||||
|
||||
@router.post("/api/conv/jaje-xlsx")
|
||||
def api_jaje_xlsx(req: SrcReq):
|
||||
s = _src(req.source_path)
|
||||
return _xlsx(_guard(export_jaje_xlsx, s.files), f"{s.path.stem}_자재기준자료.xlsx")
|
||||
|
||||
|
||||
@router.post("/api/conv/jaje-import")
|
||||
def api_jaje_import(req: JajeImportReq):
|
||||
"""자재 기준자료 엑셀 → costn_updates(바뀐 칸만) — 화면이 /api/save 로 넘김."""
|
||||
s = _src(req.source_path)
|
||||
ups = _guard(import_jaje_xlsx, s.files, _blob(**req.xlsx.model_dump()))
|
||||
return {"costn_updates": ups, "count": len(ups)}
|
||||
|
||||
|
||||
@router.post("/api/conv/code-xlsx")
|
||||
def api_code_xlsx(req: SrcReq):
|
||||
s = _src(req.source_path)
|
||||
|
||||
@@ -17,7 +17,7 @@ import io
|
||||
import re
|
||||
|
||||
from conv_xls_boq import COLS, INITIAL, LEVEL_NAME, _numv, load_book
|
||||
from smt_parser import parse_dbf_bytes, parse_ini_bytes
|
||||
from smt_parser import parse_dbf_bytes
|
||||
from stc_save import STRUCT_RE, pick_boq_table
|
||||
|
||||
BOQ_HEAD = [
|
||||
@@ -146,63 +146,6 @@ def export_boq_xlsx(files: dict, table: str = "") -> bytes:
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
# ── 자재 기준자료 ──
|
||||
JAJE_HEAD = ["코드", "명 칭", "규 격", "단위"]
|
||||
|
||||
|
||||
def export_jaje_xlsx(files: dict) -> bytes:
|
||||
"""COSTN M 코드 → 자재 기준자료 xlsx(출처 5칸 · 적용 단가)."""
|
||||
ini = parse_ini_bytes(files.get("START8X.INI") or files.get("STINI.MEM") or b"")
|
||||
src = [ini.get(f"JMOK{i}", f"출처{i}") for i in range(1, 6)]
|
||||
wb = _wb()
|
||||
ws = wb.active
|
||||
ws.title = "자재기준자료"
|
||||
ws.append(JAJE_HEAD + src + ["적용 단가", "BQCODE", "JDCODE"])
|
||||
for r in parse_dbf_bytes(files["COSTN.DBF"])[1]:
|
||||
if not r["CODE"].strip().startswith("M"):
|
||||
continue
|
||||
ws.append(
|
||||
[r["CODE"].strip(), r["NAME"], r["SIZE"], r["UNIT"].strip()]
|
||||
+ [_numv(r.get(f"AMT_{i}1")) for i in range(1, 7)]
|
||||
+ [r.get("BQCODE", "").strip(), r.get("JDCODE", "").strip()]
|
||||
)
|
||||
buf = io.BytesIO()
|
||||
wb.save(buf)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def import_jaje_xlsx(files: dict, src) -> list[dict]:
|
||||
"""자재 기준자료 xlsx → 바뀐 칸만 담은 costn_updates(없는 코드는 새 행)."""
|
||||
ws = load_book(src).worksheets[0]
|
||||
cur = {r["CODE"].strip(): r for r in parse_dbf_bytes(files["COSTN.DBF"])[1]}
|
||||
head = [str(v or "").strip() for v in next(ws.iter_rows(max_row=1, values_only=True))]
|
||||
fields = (
|
||||
["CODE", "NAME", "SIZE", "UNIT"] + [f"AMT_{i}1" for i in range(1, 7)] + ["BQCODE", "JDCODE"]
|
||||
)
|
||||
out = []
|
||||
for r in ws.iter_rows(min_row=2, values_only=True):
|
||||
if not r or not r[0]:
|
||||
continue
|
||||
vals = dict(zip(fields, r[: len(fields)]))
|
||||
code = str(vals["CODE"]).strip()
|
||||
old = cur.get(code)
|
||||
upd = {"CODE": code}
|
||||
for k, v in vals.items():
|
||||
if k == "CODE" or k not in fields[: len(head)]:
|
||||
continue
|
||||
o = old.get(k) if old else None
|
||||
if k.startswith("AMT_"):
|
||||
if old is None or abs(_numv(o) - _numv(v)) > 1e-9:
|
||||
upd[k] = _numv(v)
|
||||
elif old is None or str(o or "").strip() != str(v or "").strip():
|
||||
upd[k] = "" if v is None else str(v)
|
||||
if "AMT_61" in upd: # 적용 단가 = 1차 재료비 단가
|
||||
upd["MAMT1"] = upd["TAMT1"] = upd["AMT_61"]
|
||||
if len(upd) > 1:
|
||||
out.append(upd)
|
||||
return out
|
||||
|
||||
|
||||
# ── 코드 변경표 ──
|
||||
def export_code_xlsx(files: dict) -> bytes:
|
||||
wb = _wb()
|
||||
@@ -234,8 +177,6 @@ def level_label(code: str) -> str:
|
||||
__all__ = [
|
||||
"INITIAL",
|
||||
"export_boq_xlsx",
|
||||
"export_jaje_xlsx",
|
||||
"import_jaje_xlsx",
|
||||
"export_code_xlsx",
|
||||
"import_code_xlsx",
|
||||
"level_label",
|
||||
|
||||
@@ -152,27 +152,6 @@
|
||||
`${pname(ctx)}_기준BOQ.xlsx`,
|
||||
);
|
||||
},
|
||||
async jajeXlsx(ctx) {
|
||||
download(
|
||||
await req("/api/conv/jaje-xlsx", { source_path: curPath(ctx) }, true),
|
||||
`${pname(ctx)}_자재기준자료.xlsx`,
|
||||
);
|
||||
},
|
||||
async jajeImport(ctx) {
|
||||
const f = await pickFile(".xlsx");
|
||||
const r = await req("/api/conv/jaje-import", {
|
||||
source_path: curPath(ctx),
|
||||
xlsx: { b64: f.b64 },
|
||||
});
|
||||
const rows = ctx.dbfData.costn_rows || (ctx.dbfData.costn_rows = []);
|
||||
for (const u of r.costn_updates) {
|
||||
const row = rows.find((x) => String(x.CODE).trim() === u.CODE);
|
||||
if (row) Object.assign(row, u);
|
||||
else rows.push(Object.assign({}, u));
|
||||
}
|
||||
app().cascadeAndRender && app().cascadeAndRender();
|
||||
alert(`[자재 기준자료 불러오기] 바뀐 자재 ${r.count}건 — [저장]으로 반영`);
|
||||
},
|
||||
async codeXlsx(ctx) {
|
||||
download(
|
||||
await req("/api/conv/code-xlsx", { source_path: curPath(ctx) }, true),
|
||||
@@ -236,8 +215,6 @@
|
||||
["Act_ConvXlsBoq", "엑셀 → 내역 불러오기 (wM_From_XLS)", "xlsBoq"],
|
||||
["Act_ConvXlsRegister", "엑셀 역등록 — 새 공사 (CONV_XLS_2_ST)", "xlsRegister"],
|
||||
["Act_ConvBoqXlsx", "기준 BOQ 엑셀로 내보내기 (MK_BSCBOQ_EXL)", "boqXlsx"],
|
||||
["Act_ConvJajeXlsx", "자재 기준자료 엑셀로 내보내기 (wM_JajeXLS)", "jajeXlsx"],
|
||||
["Act_ConvJajeImport", "자재 기준자료 엑셀 불러오기 (wM_JajeXLS)", "jajeImport"],
|
||||
["Act_ConvCodeXlsx", "코드 변경표 엑셀로 내보내기 (ST_U_ChgCode)", "codeXlsx"],
|
||||
["Act_ConvCodeChange", "코드 일괄 변경 (ST_U_ChgCode)", "codeChange"],
|
||||
["Act_ConvSplitChange", "분리형 변경 STC 만들기 — 차수 인계", "splitChange"],
|
||||
|
||||
Reference in New Issue
Block a user