knowledge(마스터): 재료 열 한 벌 정리 — 시중물가 → 자재품목 · 값 다섯 열 · 유가·환율도 같은 열
- 재료_시중물가.json → 재료_자재품목.json(테이블ID MT · 키 그대로) · 모든 줄이 같은 열 한 벌(키 · 원문번호 · 구분 · 상세구분 · 이름 · 규격 · 단위 · 물가자료 · 유통물가 · 물가정보 · 거래가격 · 관급 · 출처 · 비고 · 면수) - 값 묶음(시중·조달)을 풀어 다섯 값을 열로 · 관급 = 조달 값(4,552 줄 · 기간은 비고 「조달 26.7」) · 원문 가격정보만 남은 72 줄은 값 열에 안 넣고 비고 「가격정보 ○○」 · 구분·상세구분은 원문에 분류가 없어 비움 - 재료_오피넷유가 · 환율_한국은행환율도 열 한 벌(기준일 → 규격 · 유가 구분 = 전국평균/시도별 · 상세구분 = 지역) · 상품코드·지역코드 삭제 - 엔진 단가(연결된 줄의 낮은 값 · 출처 = 「<키>.<열>」) · check_master 열 검사 · M01 고르기(procure 걷어냄) · 빌더 · _틀.md · 화면 계약 · 시험 같이 고침 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JgUhN55Z3BCYhUJTjwTNfj
This commit is contained in:
@@ -129,7 +129,7 @@ export async function saveFiles(files: FileChanges[]): Promise<SaveResult> {
|
||||
return { status, detail: typeof detail === "string" ? detail : JSON.stringify(detail) };
|
||||
}
|
||||
|
||||
export type PickKind = "procure" | "price" | "job";
|
||||
export type PickKind = "price" | "job";
|
||||
|
||||
export interface PickItem {
|
||||
ref: string;
|
||||
|
||||
@@ -103,7 +103,7 @@ def files_of(group: str) -> list[dict]:
|
||||
_REF_KEY = re.compile(r"^[A-Z]{2}\d{6}$")
|
||||
_REF_FILE = {
|
||||
"LB": "인력.json",
|
||||
"MT": "재료_시중물가.json",
|
||||
"MT": "재료_자재품목.json",
|
||||
"MN": "재료_나라장터자재.json",
|
||||
"MP": "재료_품셈재료.json",
|
||||
"EQ": "기계.json",
|
||||
@@ -111,16 +111,9 @@ _REF_FILE = {
|
||||
|
||||
|
||||
def _ref_keys(row: dict) -> list[str]:
|
||||
"""줄이 가리키는 키 — 준용 · 연결 · 조달 출처(「나라장터:<키>」)."""
|
||||
put = (
|
||||
row.get("준용"),
|
||||
row.get("연결"),
|
||||
(row.get("값") or {}).get("조달", {}).get("출처")
|
||||
if isinstance(row.get("값"), dict)
|
||||
else None,
|
||||
)
|
||||
out = [v.removeprefix("나라장터:") for v in put if isinstance(v, str)]
|
||||
return [k for k in out if _REF_KEY.match(k)]
|
||||
"""줄이 가리키는 키 — 준용 · 연결."""
|
||||
put = (row.get("준용"), row.get("연결"))
|
||||
return [v for v in put if isinstance(v, str) and _REF_KEY.match(v)]
|
||||
|
||||
|
||||
def refs_of(rows_: list[dict]) -> dict[str, str]:
|
||||
@@ -301,29 +294,24 @@ def elements(group: str, q: str, limit: int) -> dict:
|
||||
return {"total": len(hits), "items": hits[: min(max(limit, 1), 200)]}
|
||||
|
||||
|
||||
_PICK = {
|
||||
"procure": "재료_나라장터자재.json",
|
||||
"price": "재료_시중물가.json",
|
||||
"job": "인력.json",
|
||||
}
|
||||
_PICK = {"price": "재료_자재품목.json", "job": "인력.json"}
|
||||
|
||||
|
||||
def _squash(text) -> str:
|
||||
return "".join(str(text or "").lower().split())
|
||||
|
||||
|
||||
def _lowest(value) -> object:
|
||||
"""시중물가 줄 — 시중 다섯 칸 · 조달 값 가운데 낮은 값 · 그 밖은 `값` 그대로."""
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
nums = [v for v in value["시중"].values() if isinstance(v, (int, float))]
|
||||
supply = value["조달"].get("값")
|
||||
return min(nums + ([supply] if supply is not None else []), default=None)
|
||||
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]
|
||||
return min(nums, default=None)
|
||||
|
||||
|
||||
def pick(kind: str, q: str, limit: int) -> dict:
|
||||
"""고르기 창 — `procure` 나라장터자재(ref = 나라장터:<키>) · `price` 시중물가(관급 조달 줄 포함 · ref = 키)
|
||||
· `job` 값 있는 공표 노임 직종(미확보 빼고 모든 구분)(ref = 키) · 이름·규격 낱말이 모두 들어간 줄."""
|
||||
"""고르기 창 — `price` 자재품목(관급 줄 포함 · ref = 키) · `job` 값 있는 공표 노임 직종
|
||||
(미확보 빼고 모든 구분)(ref = 키) · 이름·규격 낱말이 모두 들어간 줄."""
|
||||
if kind not in _PICK:
|
||||
raise StoreError(404, f"없는 고르기 「{kind}」")
|
||||
words = [_squash(w) for w in q.split()]
|
||||
@@ -333,13 +321,12 @@ def pick(kind: str, q: str, limit: int) -> dict:
|
||||
continue
|
||||
text = _squash(f"{row.get('이름')} {row.get('규격')} {' '.join(row.get('옛이름') or [])}")
|
||||
if all(w in text for w in words):
|
||||
key = str(row.get("키"))
|
||||
hits.append(
|
||||
{
|
||||
"ref": f"나라장터:{key}" if kind == "procure" else key,
|
||||
"ref": str(row.get("키")),
|
||||
**{k: row.get(k) for k in ("원문번호", "구분", "이름", "규격", "단위")},
|
||||
"값": _lowest(row.get("값")),
|
||||
"관급": kind == "price" and row["값"]["조달"].get("값") is not None,
|
||||
"값": _lowest(row),
|
||||
"관급": row.get("관급") is not None,
|
||||
}
|
||||
)
|
||||
return {"total": len(hits), "items": hits[: min(max(limit, 1), 200)]}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_Pick.ts
|
||||
* 고르기 모달 — 조달 자료(나라장터) · 가격 연결(시중물가) · 준용 직종을 이름·규격으로 찾아 고르면 ref 를 돌려줌
|
||||
* 고르기 모달 — 가격 연결(자재품목) · 준용 직종을 이름·규격으로 찾아 고르면 ref 를 돌려줌
|
||||
* ========================================================================== */
|
||||
|
||||
import { el, showToast } from "@ui/ui_template_elements";
|
||||
|
||||
@@ -21,23 +21,18 @@ import {
|
||||
buildCell,
|
||||
buildPager,
|
||||
coerce,
|
||||
deep,
|
||||
flatten,
|
||||
isScalar,
|
||||
SEP,
|
||||
show,
|
||||
withCell,
|
||||
withDeep,
|
||||
} from "./M01_MasterData_UI_Cells";
|
||||
|
||||
const SIZE = 50;
|
||||
const MARKET = "재료_시중물가.json";
|
||||
const MARKET = "재료_자재품목.json";
|
||||
const LINKED = "재료_품셈재료.json";
|
||||
const JOB = ["인력.json"];
|
||||
const NOTE = "비고"; // 끝 열(인력은 준용 앞) · 줄에 없으면 빈 칸 · 눌러 적음
|
||||
const SUPPLY = ["값", "조달"];
|
||||
const SOURCE = [...SUPPLY, "출처"];
|
||||
const NARA = "나라장터:";
|
||||
const FALLBACK: Row = { 키: "", 원문번호: "", 이름: "", 규격: "", 단위: "", 값: null, 출처: "" };
|
||||
|
||||
/** 새 줄 바탕 — 본 줄과 같은 칸 · 글 칸은 "" · 수·값 칸은 null. */
|
||||
@@ -58,9 +53,9 @@ function withNote(row: Row, text: string): Row {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 요소별 전용 칸으로 그리는 칸 — 일반 칸에서 뺌. */
|
||||
/** 요소별 전용 칸으로 그리는 칸 · 접어 두는 칸 — 일반 칸에서 뺌. */
|
||||
function hidden(file: string, col: string): boolean {
|
||||
if (file === MARKET) return col === `값${SEP}조달`;
|
||||
if (file === MARKET) return col.split(SEP)[0] === "면수"; // 면수는 접어 둠
|
||||
if (file === LINKED) return col.startsWith("연결");
|
||||
return JOB.includes(file) && col === "준용";
|
||||
}
|
||||
@@ -71,7 +66,6 @@ const fixed = (file: string, col: string): boolean =>
|
||||
(JOB.includes(file) && ["구분", "상세구분", "상태", "옛이름"].includes(col.split(SEP)[0]));
|
||||
|
||||
const HEADS: Record<string, string[]> = {
|
||||
[MARKET]: ["조달 값", "조달 기준", "M01_ProcureCol"],
|
||||
[LINKED]: ["M01_PriceCol"],
|
||||
[JOB[0]]: ["M01_JobCol"],
|
||||
};
|
||||
@@ -179,7 +173,6 @@ export function renderRows(
|
||||
const put = deleted
|
||||
? undefined
|
||||
: (next: Row): void => editRow(file, d.version, key, row, next);
|
||||
if (file === MARKET) tr.append(...marketCells(row, cur, put));
|
||||
if (file === LINKED) tr.append(linkedCell(row, cur, put));
|
||||
tr.append(
|
||||
buildCell(show(cur[NOTE]), {
|
||||
@@ -271,34 +264,7 @@ type Put = (row: Row) => void;
|
||||
const rowBtn = (text: string): HTMLButtonElement =>
|
||||
el("button", { className: "m01-master__row-btn", text, attrs: { type: "button" } });
|
||||
|
||||
/** 시중물가 — 조달 값 · 기준 · 조달 연결(「나라장터:<키>」 · 「가격정보」 줄은 원천이라 그대로). */
|
||||
function marketCells(row: Row, cur: Row, put?: Put): HTMLTableCellElement[] {
|
||||
const link = show(deep(cur, SOURCE));
|
||||
const td = buildCell(named(link), { changed: link !== show(deep(row, SOURCE)) });
|
||||
if (put && (!link || link.startsWith(NARA))) {
|
||||
const btn = rowBtn(L("M01_ProcureFind"));
|
||||
btn.addEventListener("click", () =>
|
||||
openPickModal({
|
||||
kind: "procure",
|
||||
title: L("M01_ProcureTitle"),
|
||||
current: link,
|
||||
seed: `${cur["이름"] ?? ""} ${cur["규격"] ?? ""}`.trim(),
|
||||
onPick: (ref, name) => {
|
||||
remember(ref, name);
|
||||
put(withDeep(cur, SOURCE, ref));
|
||||
},
|
||||
}),
|
||||
);
|
||||
td.append(btn);
|
||||
}
|
||||
return [
|
||||
buildCell(show(deep(cur, [...SUPPLY, "값"])), { changed: false }),
|
||||
buildCell(show(deep(cur, [...SUPPLY, "기준"])), { changed: false }),
|
||||
td,
|
||||
];
|
||||
}
|
||||
|
||||
/** 품셈재료 — 가격 연결(시중물가 키 · 후보 조건 · 없음) · 고르기 모달. */
|
||||
/** 품셈재료 — 가격 연결(자재품목 키 · 후보 조건 · 없음) · 고르기 모달. */
|
||||
function linkedCell(row: Row, cur: Row, put?: Put): HTMLTableCellElement {
|
||||
const link = cur["연결"];
|
||||
const td = buildCell(named(linkText(link)), {
|
||||
|
||||
+23980
-23980
File diff suppressed because it is too large
Load Diff
+58
-30
@@ -13,33 +13,33 @@
|
||||
- 날짜 없음 · 마스터는 항상 최신. UTF-8 · LF · 들여쓰기 2칸 · 칸 이름은 한글.
|
||||
- 그룹 = 인력 · 재료 · 기계 · 소요량 · 계수 · 환율 · 요율 · 로직
|
||||
|
||||
| 그룹 | 파일 이름 |
|
||||
|---|---|
|
||||
| 인력 | `인력.json` 하나 — 줄마다 `조사` |
|
||||
| 기계 | `기계.json` 하나 — 줄마다 `세부분류` |
|
||||
| 재료 | `재료_<원문>.json` — 시중물가 · 나라장터자재 · 오피넷유가 · 품셈재료 |
|
||||
| 환율 · 요율 | `환율_한국은행환율.json` · `요율_조달청제비율.json` |
|
||||
| 그룹 | 파일 이름 |
|
||||
| -------------------- | --------------------------------------------------------------------------------------- |
|
||||
| 인력 | `인력.json` 하나 — 줄마다 `조사` |
|
||||
| 기계 | `기계.json` 하나 — 줄마다 `세부분류` |
|
||||
| 재료 | `재료_<원문>.json` — 자재품목 · 나라장터자재 · 오피넷유가 · 품셈재료 |
|
||||
| 환율 · 요율 | `환율_한국은행환율.json` · `요율_조달청제비율.json` |
|
||||
| 소요량 · 계수 · 로직 | `<그룹>_<원문>_<NN>장_<장 제목>.json` — 예 `소요량_건설품셈_10장_창호_및_유리공사.json` |
|
||||
|
||||
- 장 파일 — 장 번호는 두 자리 · 장 제목은 원문 본문 폴더 이름 그대로 · 부문은 이름에 넣지 않음.
|
||||
- 장 파일 머리 — `부문`(건설품셈만 · 공통 · 토목 · 건축 · 기계설비 · 유지관리) · `차례`(그룹 안 목록 차례: 산림 1~14장 → 건설 공통 → 토목 → 건축 → 기계설비 → 유지관리).
|
||||
- 원문 이름 = 산림품셈 · 건설품셈 · 건설노임 · 제조노임 · 엔지니어링노임 · 측량노임 · 건설사업관리노임 · SW노임 · 산림노임 · 준용대상 · 나라장터자재 · 오피넷유가 · 시중물가 · 한국은행환율 · 조달청제비율 · 품셈재료 · 자체
|
||||
- 원문 이름 = 산림품셈 · 건설품셈 · 건설노임 · 제조노임 · 엔지니어링노임 · 측량노임 · 건설사업관리노임 · SW노임 · 산림노임 · 준용대상 · 나라장터자재 · 오피넷유가 · 자재품목 · 한국은행환율 · 조달청제비율 · 품셈재료 · 자체
|
||||
|
||||
## 3. 키
|
||||
|
||||
- 모든 요소 줄 · 표 · 로직에 `키` = 테이블ID(영문 대문자 2) + 6자리 일련번호 — `LB000123`. 특수문자·한글 없음.
|
||||
- 한 번 준 키는 안 바뀜 · 지운 줄의 키는 다시 쓰지 않음. 키는 전체에서 하나.
|
||||
- 원문이 준 번호·옛 열쇠(직종 `1018` · 기계 `0201-0070` · `13-4-1 메쌓기` · 시중물가 코드)는 `원문번호` 칸 — 같은 테이블 안에서 하나.
|
||||
- 원문이 준 번호·옛 열쇠(직종 `1018` · 기계 `0201-0070` · `13-4-1 메쌓기` · 자재품목 코드)는 `원문번호` 칸 — 같은 테이블 안에서 하나.
|
||||
- 대장 `_키대장.json` = 키 ↔ 원문번호 ↔ 파일 · 테이블별 `다음` 번호. 새 줄은 대장의 다음 번호를 받음(M01 저장 · 빌더).
|
||||
|
||||
| ID | 테이블 | ID | 테이블 |
|
||||
|---|---|---|---|
|
||||
| LB | 인력 | QF · QC | 소요량 산림 · 건설 |
|
||||
| MT | 재료 시중물가 | CF · CC | 계수 산림 · 건설 |
|
||||
| MN | 재료 나라장터 | FX | 환율 |
|
||||
| MO | 재료 유가 | RT | 요율 |
|
||||
| MP | 재료 품셈재료 | GF · GC · GX | 로직 산림 · 건설 · 자체 |
|
||||
| EQ | 기계 | | |
|
||||
| ID | 테이블 | ID | 테이블 |
|
||||
| --- | ------------- | ------------ | ----------------------- |
|
||||
| LB | 인력 | QF · QC | 소요량 산림 · 건설 |
|
||||
| MT | 재료 자재품목 | CF · CC | 계수 산림 · 건설 |
|
||||
| MN | 재료 나라장터 | FX | 환율 |
|
||||
| MO | 재료 유가 | RT | 요율 |
|
||||
| MP | 재료 품셈재료 | GF · GC · GX | 로직 산림 · 건설 · 자체 |
|
||||
| EQ | 기계 | | |
|
||||
|
||||
- 빌더는 옛 모양(`열쇠` · 원문별 파일)으로 쓰고 끝에 `scripts/master_keys.py` 의 `adopt` 를 부름 — 키·원문번호·한 테이블·장 파일 이름으로 받음. 옛 이름으로 읽기는 `old_view`.
|
||||
|
||||
@@ -56,10 +56,35 @@
|
||||
"그룹": "인력",
|
||||
"비고": ["열 한 벌 · 상태 · 준용 · 새 조사 등록 …"],
|
||||
"구분": {
|
||||
"건설업": { "원문": "건설노임", "기관": "대한건설협회", "판": "2026-01-01", "공표일": "2025-12-31", "원천": [{ "경로": "resources/knowledge/original/원가계산/…", "해시": "…", "역할": "primary" }], "상세구분": [], "비고": ["…"] }
|
||||
"건설업": {
|
||||
"원문": "건설노임",
|
||||
"기관": "대한건설협회",
|
||||
"판": "2026-01-01",
|
||||
"공표일": "2025-12-31",
|
||||
"원천": [
|
||||
{ "경로": "resources/knowledge/original/원가계산/…", "해시": "…", "역할": "primary" }
|
||||
],
|
||||
"상세구분": [],
|
||||
"비고": ["…"]
|
||||
}
|
||||
},
|
||||
"줄": [
|
||||
{ "키": "LB000018", "원문번호": "1018", "구분": "건설업", "상세구분": null, "이름": "포설공", "규격": "", "단위": "원/일", "값": 221015, "출처": "건설노임 1018", "일시간": 8, "상태": "공표", "옛이름": [], "비고": null, "준용": null }
|
||||
{
|
||||
"키": "LB000018",
|
||||
"원문번호": "1018",
|
||||
"구분": "건설업",
|
||||
"상세구분": null,
|
||||
"이름": "포설공",
|
||||
"규격": "",
|
||||
"단위": "원/일",
|
||||
"값": 221015,
|
||||
"출처": "건설노임 1018",
|
||||
"일시간": 8,
|
||||
"상태": "공표",
|
||||
"옛이름": [],
|
||||
"비고": null,
|
||||
"준용": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -81,12 +106,19 @@
|
||||
|
||||
재료 값:
|
||||
|
||||
- `재료_시중물가` `값` = `{ "시중": { 가격정보 · 물가자료 · 유통물가 · 물가정보 · 거래가격, "면수": {…} }, "조달": { "값", "출처", "기준" } }` — 기본가·채택값 · 견적가 두지 않음. 조달 값이 없으면 비움.
|
||||
- 조달 `출처` = 「가격정보」(시중물가 원천의 조달청 관급 단가 · `기준` = 원천 면수 글) · 「나라장터:<키>」(값 안 베낌 · 이름·규격·단위가 온전히 같은 짝만 · 애매하면 사람이 화면에서 이음).
|
||||
- `재료_나라장터자재` = 조달 자료 — 머리 `자료` 에 밝힘.
|
||||
**마스터는 값을 고르는 곳이 아니라 프로젝트에 쓸 데이터를 모으는 곳** — 최저가·견적 고르기는 프로젝트 몫.
|
||||
|
||||
- `재료_자재품목` = 자재 테이블 하나 · **열 한 벌** — 키 · 원문번호 · 구분 · 상세구분 · 이름 · 규격 · 단위 · 물가자료 · 유통물가 · 물가정보 · 거래가격 · 관급 · 출처 · 비고 · 면수 · 모든 줄이 모든 칸(없으면 `null`).
|
||||
- 값 열 다섯 = 물가지 넷(물가자료 · 유통물가 · 물가정보 · 거래가격) · `관급`(조달) — 채택값·기본가·견적가 두지 않음.
|
||||
- `관급` = 원문 「가격정보」 칸의 조달청 관급 단가(철근 · 레미콘 · 아스콘 · H형강 등) · 기간은 `비고` 「조달 26.7」 · 「조달 ~29.3.18」.
|
||||
- 그 밖의 줄에 남은 「가격정보」 값은 출처가 조달인지 물가지인지 원문이 안 가름 — 어느 값 열에도 넣지 않고 `비고` 「가격정보 1,825 · 2026.7」 로만(72 줄).
|
||||
- `구분` · `상세구분` = 원문(ESTX)에 대·중분류 칸이 없어 비움 — 지어내지 않음.
|
||||
- `면수` = 물가지 값의 원문 면수 묶음 `{ 물가자료, 유통물가, 물가정보, 거래가격 }` · 없으면 `null` · 화면에는 접어 둠.
|
||||
- `재료_오피넷유가` · `환율_한국은행환율` 도 열 한 벌 — 키 · 원문번호 · 구분 · 상세구분 · 이름 · 규격 · 단위 · 값 · 출처 · 비고. 유가 `구분` = 전국평균 · 시도별 · `상세구분` = 지역 이름 · `규격` = 기준일. 환율은 `규격` = 기준일 · 구분·상세구분 비움.
|
||||
- `재료_나라장터자재` = 조달 자료(관급 단가) — 머리 `자료` 에 밝힘 · 자재품목과 잇지 않음 · 로직 호표가 키로 바로 가리킴.
|
||||
- `재료_품셈재료` = 품셈이 부르는 재료 목록 — `{ 키, 원문번호, 이름, 규격, 단위, 요구절: [출처 모양], 연결 }` · 값 칸 없음.
|
||||
- `연결` = 시중물가 키 하나 · 후보 조건 `{ 이름, 규격, 지역: "입력" }`(지역이 갈리는 레미콘·골재·아스콘) · `null`(아직 못 이음).
|
||||
- 단가 = 연결된 줄의 시중·조달 값 가운데 낮은 값 · 호표 결과 줄에 `출처`(그 값의 칸). 후보 조건 = 품명 뿌리가 `이름` 으로 끝남 · 이름에 로직 입력 `자재지역` 글자 · 단위 같음 · 규격이 둘 이상 갈리면 멈춤 「후보 여럿」 · 그 지역 줄이 없으면 가까운 시군을 고르지 않고 멈춤 「지역 값 없음」.
|
||||
- `연결` = 자재품목 키 하나 · 후보 조건 `{ 이름, 규격, 지역: "입력" }`(지역이 갈리는 레미콘·골재·아스콘) · `null`(아직 못 이음).
|
||||
- 단가 = 연결된 줄의 값 열 다섯 가운데 낮은 값 · 호표 결과 줄에 `출처`(`<키>.<열>`) — **시험 계산용**. 후보 조건 = 품명 뿌리가 `이름` 으로 끝남 · 이름에 로직 입력 `자재지역` 글자 · 단위 같음 · 규격이 둘 이상 갈리면 멈춤 「후보 여럿」 · 그 지역 줄이 없으면 가까운 시군을 고르지 않고 멈춤 「지역 값 없음」.
|
||||
|
||||
## 6. 표형 요소 (소요량 · 계수)
|
||||
|
||||
@@ -101,16 +133,14 @@
|
||||
"출처": "산림품셈 13-4-1",
|
||||
"조건": { "뒷길이": "수", "돌": "고르기", "쌓기": "고르기" },
|
||||
"값칸": { "석공": "인", "보통인부": "인" },
|
||||
"줄": [
|
||||
{ "뒷길이": 35, "돌": "견치돌", "쌓기": "골쌓기", "석공": 0.5, "보통인부": 0.4 }
|
||||
],
|
||||
"줄": [{ "뒷길이": 35, "돌": "견치돌", "쌓기": "골쌓기", "석공": 0.5, "보통인부": 0.4 }],
|
||||
"주": ["① …"]
|
||||
}
|
||||
```
|
||||
|
||||
- 건설품셈 표 · 로직의 원문번호 앞에 부문 — 「건축 1-1-1 계수」 · 「공통 8 기계 시간당 경비」.
|
||||
- 조건 종류 — `수`(같은 값) · `고르기`(글자) · `범위`(줄에 `[아래, 위]` · 끝 열림은 `null` · 포함 여부는 `범위규칙` 에 「초과·이하」처럼).
|
||||
- `범위규칙` 첫 마디 = 엔진이 읽는 이름 — 「초과·이하」 · 「이상·미만」 · 「이하」(위 끝만) · 「이상·이하」(양 끝 포함). 뒤 마디(` · ` 뒤)는 원문 설명. 「이하」 · 「이상·이하」는 여러 줄이 맞으면 위 끝이 가장 작은 줄.
|
||||
- `범위규칙` 첫 마디 = 엔진이 읽는 이름 — 「초과·이하」 · 「이상·미만」 · 「이하」(위 끝만) · 「이상·이하」(양 끝 포함). 뒤 마디(`·` 뒤)는 원문 설명. 「이하」 · 「이상·이하」는 여러 줄이 맞으면 위 끝이 가장 작은 줄.
|
||||
- 원문 「-」 칸은 줄에서 그 값 칸을 뺌. 줄 전체가 「-」 면 줄을 뺌.
|
||||
- 원문에 없는 조건 칸은 줄에서 뺌 — 빈 조건 칸은 그 조건에 무엇이든 맞음.
|
||||
- 값 칸에 범위(「80 ∼ 100」)는 `[아래, 위]` — 로직은 설계자 입력 칸으로 받음(기본값 두지 않음).
|
||||
@@ -141,9 +171,7 @@
|
||||
{ "이름": "돌", "고르기": ["견치돌", "깬돌", "깬잡석", "호박돌 및 야면석"] }
|
||||
],
|
||||
"중간": [{ "이름": "증가율", "식": "…" }],
|
||||
"호표": [
|
||||
{ "종류": "인력", "요소": "LB000002", "단위": "인", "수량": "…", "비목": "노무비" }
|
||||
],
|
||||
"호표": [{ "종류": "인력", "요소": "LB000002", "단위": "인", "수량": "…", "비목": "노무비" }],
|
||||
"덧줄": [{ "이름": "잡재료비", "식": "노무비 * 0.05", "비목": "재료비", "출처": "…" }],
|
||||
"끝수": "원 미만 버림"
|
||||
}
|
||||
|
||||
@@ -14,26 +14,26 @@
|
||||
|
||||
## 2. 읽기
|
||||
|
||||
| 길 | 받음 | 줌 |
|
||||
|---|---|---|
|
||||
| `GET /groups` | | `{groups: [{group, files, rows}]}` — 그룹 차례는 `_틀.md` 2장 |
|
||||
| `GET /groups/{group}/files` | | `{files: [{file, book, chapter, edition, rows, version, order}]}` — 머리 `차례` 순 |
|
||||
| `GET /subs` | `file` | `{file, version, slot, subs: [{name, book, details}]}` — 인력 `구분` · 기계 `세부분류`(머리에 등록된 것) · 그 밖은 빈 목록 |
|
||||
| `GET /rows` | `file` · `page`(1) · `size`(50) · `q` · `sub` · `detail` | `{file, version, total, page, size, rows: [줄…]}` |
|
||||
| `GET /tables` | `file` · `q` | `{file, version, tables: [{키, 원문번호, 이름, 기준, 출처, 조건, 값칸, count}]}` |
|
||||
| `GET /table` | `file` · `key` | `{file, version, table: {표 하나 통째}}` |
|
||||
| `GET /logics` | `book` · `chapter` · `q` · `blocked`(0/1) | `{logics: [{file, book, chapter, 키, 원문번호, 이름, 결과단위, 출처, blocked, reasons}]}` |
|
||||
| `GET /logic` | `key`(`book` 은 안 씀) | `{file, version, logic: {로직 한 줄 통째}, blocked, reasons, prices: {요소: 요약 \| null}}` |
|
||||
| `GET /elements` | `group` · `q` · `limit`(50 · 200까지) | `{total, items: [{ref, file, 원문번호, 이름, 규격, 단위, 값, 값칸}]}` |
|
||||
| 길 | 받음 | 줌 |
|
||||
| --------------------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `GET /groups` | | `{groups: [{group, files, rows}]}` — 그룹 차례는 `_틀.md` 2장 |
|
||||
| `GET /groups/{group}/files` | | `{files: [{file, book, chapter, edition, rows, version, order}]}` — 머리 `차례` 순 |
|
||||
| `GET /subs` | `file` | `{file, version, slot, subs: [{name, book, details}]}` — 인력 `구분` · 기계 `세부분류`(머리에 등록된 것) · 그 밖은 빈 목록 |
|
||||
| `GET /rows` | `file` · `page`(1) · `size`(50) · `q` · `sub` · `detail` | `{file, version, total, page, size, rows: [줄…]}` |
|
||||
| `GET /tables` | `file` · `q` | `{file, version, tables: [{키, 원문번호, 이름, 기준, 출처, 조건, 값칸, count}]}` |
|
||||
| `GET /table` | `file` · `key` | `{file, version, table: {표 하나 통째}}` |
|
||||
| `GET /logics` | `book` · `chapter` · `q` · `blocked`(0/1) | `{logics: [{file, book, chapter, 키, 원문번호, 이름, 결과단위, 출처, blocked, reasons}]}` |
|
||||
| `GET /logic` | `key`(`book` 은 안 씀) | `{file, version, logic: {로직 한 줄 통째}, blocked, reasons, prices: {요소: 요약 \| null}}` |
|
||||
| `GET /elements` | `group` · `q` · `limit`(50 · 200까지) | `{total, items: [{ref, file, 원문번호, 이름, 규격, 단위, 값, 값칸}]}` |
|
||||
|
||||
- `rows` 는 요소·로직 파일은 `줄`, 표형 파일(소요량·계수)은 `표` 를 줄로 봄.
|
||||
- `q` = 찾기 — `키`·`원문번호`·`이름`·`옛이름` 에 든 글(대소문자 무시).
|
||||
- `sub` = 인력 `구분`·기계 `세부분류` 거름 · `detail` = 인력 `상세구분` 거름 · 빈 글 = 전체.
|
||||
- `chapter` = 「부문 NN장 장 제목」(`공통 03장 토공사` · `13장 구조물`) · 장 파일 아니면 "".
|
||||
- `blocked` = 로직 검사(`check_master.py 로직`)에 걸림 · `reasons` = 걸린 까닭 글.
|
||||
- `prices` = 호표 `요소` → `{ref, 이름, 규격, 단위, 값}`(단가 자동 칸) · 없는 요소는 `null` · `{이름}` 이 낀 원문번호·하위 로직 줄은 빠짐(계산 때 정해짐). 품셈재료는 `값` = 연결을 따라간 낮은 값 · `출처` = 그 값의 칸(`<시중물가 키>.시중.<칸>` · `….조달` · `<나라장터자재 키>`) · 자재지역 후보는 `값` null(계산 때 정해짐).
|
||||
- `prices` = 호표 `요소` → `{ref, 이름, 규격, 단위, 값}`(단가 자동 칸) · 없는 요소는 `null` · `{이름}` 이 낀 원문번호·하위 로직 줄은 빠짐(계산 때 정해짐). 품셈재료는 `값` = 연결을 따라간 낮은 값(시험 계산용) · `출처` = 그 값의 열(`<자재품목 키>.<열>`) · 자재지역 후보는 `값` null(계산 때 정해짐).
|
||||
- `elements` = 요소 찾기 창 — 그룹 전체에서 `q` 찾기 · `ref` = 식에 넣는 키 · 표형 그룹은 `값칸` 이 옴.
|
||||
- `pick` = 고르기 창 — `procure` ref `나라장터:<키>` · `price` ref 키 · `job` = `인력.json` 값 있는 공표 직종(구분 「미확보」 빼고) ref 키.
|
||||
- `pick` = 고르기 창 — `price` = `재료_자재품목.json`(값 = 값 열 다섯 가운데 낮은 값 · `관급` = 관급 값 있음) ref 키 · `job` = `인력.json` 값 있는 공표 직종(구분 「미확보」 빼고) ref 키.
|
||||
|
||||
## 3. 시험 계산
|
||||
|
||||
@@ -53,13 +53,13 @@
|
||||
- 화면은 조작을 캐시에 쌓고 [저장] 한 번에 보냄 — 서버는 이때만 파일에 씀.
|
||||
- 순서: 모든 `version` 대조 → 모두 적용(메모리) → 검사 → 모두 씀. 하나라도 걸리면 아무것도 안 씀.
|
||||
|
||||
| 답 | 뜻 | 몸 |
|
||||
|---|---|---|
|
||||
| 200 | 씀 | `{files: [{file, version}]}` — 새 판본 |
|
||||
| 답 | 뜻 | 몸 |
|
||||
| --- | ------------------------------- | -------------------------------------------------- |
|
||||
| 200 | 씀 | `{files: [{file, version}]}` — 새 판본 |
|
||||
| 409 | 낡은 화면 — 그 사이 파일이 바뀜 | `{detail: {stale: [file…]}}` → 다시 읽고 다시 고침 |
|
||||
| 422 | 검사 걸림 | `{detail: {errors: [글…]}}` |
|
||||
| 404 | 없는 파일·키 | `{detail}` |
|
||||
| 400 | 틀린 `op` · 고친 줄의 키를 바꿈 | `{detail}` |
|
||||
| 422 | 검사 걸림 | `{detail: {errors: [글…]}}` |
|
||||
| 404 | 없는 파일·키 | `{detail}` |
|
||||
| 400 | 틀린 `op` · 고친 줄의 키를 바꿈 | `{detail}` |
|
||||
|
||||
- 검사 = `check_master.py` 의 틀(고친 파일) + 로직 변수(모든 로직 파일, 고친 뒤 마스터 기준). **고친 뒤 새로 생긴 걸림**만 막음 — 원래 있던 걸림은 막지 않음. 원문번호 겹침(테이블 안)도 걸림.
|
||||
- 요소를 지워 어떤 로직이 가리키던 것이 사라지면 로직 변수 검사에 걸림(422).
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
"""상용 적산 자료(ESTX 2026-07) 자재 → 재료_시중물가.json — 개발용 · 배포 전 판권 구매 뒤 갱신.
|
||||
|
||||
ESTX 자재 표 = StuffcDat(칸 뜻: 다산소프트/ESTX_값사전_자재노무기계.md).
|
||||
값 = 시중{가격정보·물가자료·유통물가·물가정보·거래가격 + 면수} · 조달{값 · 출처 · 기준}.
|
||||
Cost1..5 = 시중 다섯 · Page1..5 = 면수. TC(채택) · Cost6(견적가)는 마스터 아님 — 뺌.
|
||||
품명에 「관급」 인 줄의 Cost1 = 조달청 관급 단가 — 조달 묶음으로 옮김(기준 = Page1 「조달 26.7」 · 「~26.5.26」).
|
||||
나라장터 시설공통자재와 이름·규격·단위가 온전히 같고 서로 하나뿐인 줄은 조달 출처 「나라장터:<열쇠>」 — 값은 베끼지 않음.
|
||||
0 · 미저장 = 값 없음(null). 이름·규격은 원문 그대로(띄어쓰기·오자 포함). 코드 없는 빈 줄은 뺌.
|
||||
ESTX 에 대·중분류 칸이 없어 분류는 두지 않음.
|
||||
|
||||
쓰기: ./venv/Scripts/python.exe resources/master_data/scripts/build_재료_시중물가.py
|
||||
쓴 뒤 원본과 대조.
|
||||
"""
|
||||
|
||||
import collections
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
import master_keys as mk # noqa: E402
|
||||
from _migrate_base import dump_master # noqa: E402
|
||||
from compare_estx import ESTX, ROOT, SLOTS, Estx, bare_spec, base_name # noqa: E402
|
||||
|
||||
OUT = ROOT / "resources/master_data/재료_시중물가.json"
|
||||
NARA = ROOT / "resources/master_data/재료_나라장터자재.json"
|
||||
MARKET = SLOTS[:5] # 견적가(Cost6)는 마스터 아님
|
||||
_NUM = re.compile(r"\d+(?:\.\d+)?")
|
||||
_UNIT = {"ea": "개", "ton": "톤", "t": "톤", "l": "ℓ", "리터": "ℓ", "set": "조", "세트": "조"}
|
||||
|
||||
|
||||
def num(v):
|
||||
if not v:
|
||||
return None
|
||||
return int(v) if float(v).is_integer() else v
|
||||
|
||||
|
||||
def _base(s) -> str:
|
||||
s = unicodedata.normalize("NFKC", str(s or "")).lower()
|
||||
return re.sub(r"\s+", "", re.sub(r"[φΦ∅⌀]", "ø", s))
|
||||
|
||||
|
||||
def _spec(s) -> str:
|
||||
return _base(s).replace("×", "x").replace("*", "x")
|
||||
|
||||
|
||||
def _unit(s) -> str:
|
||||
s = _base(s).replace("ℓ", "l")
|
||||
return _UNIT.get(s, s)
|
||||
|
||||
|
||||
def _nara_spec(n: dict) -> str:
|
||||
"""나라장터 규격 = 「품명, 규격」 — 앞 품명을 뗌."""
|
||||
head, _, tail = n["규격"].partition(",")
|
||||
return tail if tail and _base(head) == _base(n["이름"]) else n["규격"]
|
||||
|
||||
|
||||
def nara_links(rows: list[dict], nara: list[dict]) -> dict[str, str]:
|
||||
"""시중물가 열쇠 → 나라장터 열쇠 — 품명 뿌리 · 규격 수 모음이 맞는 짝 가운데
|
||||
이름·규격·단위가 온전히 같고 서로 하나뿐인 것만(관급 줄 · 애매한 것은 사람이 화면에서)."""
|
||||
by = collections.defaultdict(list)
|
||||
for e in rows:
|
||||
if e.get("Code") and "관급" not in e.get("Name", ""):
|
||||
by[base_name(e.get("Name"))].append(e)
|
||||
pairs = []
|
||||
for n in nara:
|
||||
if not n.get("값"):
|
||||
continue
|
||||
nums = sorted(_NUM.findall(bare_spec(n["이름"], n["규격"])))
|
||||
for e in by.get(base_name(n["이름"]), []):
|
||||
if (
|
||||
nums == sorted(_NUM.findall(bare_spec(e.get("Name"), e.get("Size"))))
|
||||
and _base(n["이름"]) == _base(e.get("Name"))
|
||||
and _spec(_nara_spec(n)) == _spec(e.get("Size"))
|
||||
and _unit(n["단위"]) == _unit(e.get("Unite"))
|
||||
):
|
||||
pairs.append((n["열쇠"], e["Code"]))
|
||||
cn = collections.Counter(n for n, _ in pairs)
|
||||
ce = collections.Counter(e for _, e in pairs)
|
||||
return {e: n for n, e in pairs if cn[n] == 1 and ce[e] == 1}
|
||||
|
||||
|
||||
def build(rows: list[dict], links: dict[str, str]) -> list[dict]:
|
||||
out = []
|
||||
for r in sorted(rows, key=lambda r: r["Index"]):
|
||||
code = r.get("Code")
|
||||
if not code:
|
||||
continue
|
||||
market = {slot: num(r.get(f"Cost{i}")) for i, slot in enumerate(MARKET, 1)}
|
||||
market["면수"] = {slot: r.get(f"Page{i}") or None for i, slot in enumerate(MARKET, 1)}
|
||||
supply = {"값": None, "출처": None, "기준": None}
|
||||
if "관급" in r.get("Name", "") and market["가격정보"] is not None:
|
||||
supply = {
|
||||
"값": market["가격정보"],
|
||||
"출처": "가격정보",
|
||||
"기준": market["면수"]["가격정보"],
|
||||
}
|
||||
market["가격정보"] = market["면수"]["가격정보"] = None
|
||||
elif code in links:
|
||||
supply["출처"] = f"나라장터:{links[code]}"
|
||||
out.append(
|
||||
{
|
||||
"열쇠": code,
|
||||
"이름": r.get("Name", ""),
|
||||
"규격": r.get("Size", ""),
|
||||
"단위": r.get("Unite", ""),
|
||||
"값": {"시중": market, "조달": supply},
|
||||
"출처": f"시중물가 {code}",
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def check(rows: list[dict]) -> 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")}
|
||||
assert len(got) == len(data["줄"]) == len(raw), (len(got), len(raw))
|
||||
print(
|
||||
f"줄 {len(got)} = 원본 코드 있는 줄 {len(raw)} (코드 없는 빈 줄 {len(rows) - len(raw)} 뺌)"
|
||||
)
|
||||
for i, slot in enumerate(MARKET, 1):
|
||||
a = math.fsum(r.get(f"Cost{i}") or 0 for r in raw.values())
|
||||
b = math.fsum(r["값"]["시중"][slot] or 0 for r in got.values())
|
||||
if slot == "가격정보":
|
||||
b += math.fsum(r["값"]["조달"]["값"] 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}")
|
||||
supply = collections.Counter(
|
||||
(r["값"]["조달"]["출처"] or "없음").split(":")[0] for r in got.values()
|
||||
)
|
||||
print(f" 조달: {dict(supply)}")
|
||||
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", ""),
|
||||
)
|
||||
moved = g["값"]["조달"]["출처"] == "가격정보"
|
||||
for i, slot in enumerate(MARKET, 1):
|
||||
have = g["값"]["조달"]["값"] if moved and i == 1 else g["값"]["시중"][slot]
|
||||
page = g["값"]["조달"]["기준"] if moved and i == 1 else g["값"]["시중"]["면수"][slot]
|
||||
assert (have or 0) == (r.get(f"Cost{i}") or 0), (code, slot)
|
||||
assert page == (r.get(f"Page{i}") or None), (code, slot)
|
||||
print(" 표본 50줄 이름·규격·단위·값 5·면수 5 같음")
|
||||
|
||||
|
||||
def main():
|
||||
rows = Estx().table("StuffcDat")
|
||||
head = {
|
||||
"그룹": "재료",
|
||||
"원문": "시중물가",
|
||||
"판": "2026-07",
|
||||
"원천": [
|
||||
{
|
||||
"경로": ESTX.relative_to(ROOT).as_posix(),
|
||||
"해시": hashlib.sha256(ESTX.read_bytes()).hexdigest(),
|
||||
"역할": "primary",
|
||||
}
|
||||
],
|
||||
"비고": [
|
||||
"다산소프트 EST 2026-07 자재 · 개발용 · 배포 전 판권 구매",
|
||||
"값 = 시중(물가지 다섯 · 면수) · 조달(값 · 출처 · 기준) — 기본가·채택값 없음 · 쓰는 곳이 값을 고르고 출처를 밝힘",
|
||||
"조달 출처 「가격정보」 = ESTX 가격정보 칸의 조달청 관급 단가 · 「나라장터:<키>」 = 재료_나라장터자재 줄로 이음(값 안 베낌)",
|
||||
],
|
||||
}
|
||||
nara = mk.old_view(NARA.name)["줄"]
|
||||
dump_master(OUT, head, build(rows, nara_links(rows, nara)))
|
||||
check(rows)
|
||||
print("새 틀로 받음", mk.adopt()) # 열쇠 → 키 · 나라장터 연결 → 키
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,139 @@
|
||||
"""상용 적산 자료(ESTX 2026-07) 자재 → 재료_자재품목.json — 개발용 · 배포 전 판권 구매 뒤 갱신.
|
||||
|
||||
ESTX 자재 표 = StuffcDat(칸 뜻: 다산소프트/ESTX_값사전_자재노무기계.md).
|
||||
열 한 벌 = 키 · 원문번호 · 구분 · 상세구분 · 이름 · 규격 · 단위 · 물가자료 · 유통물가 · 물가정보 · 거래가격 · 관급 · 출처 · 비고 · 면수.
|
||||
Cost2..5 = 물가지 넷 · Page2..5 = 면수. TC(채택) · Cost6(견적가)는 마스터 아님 — 뺌.
|
||||
품명에 「관급」 인 줄의 Cost1(가격정보) = 조달청 관급 단가 — 「관급」 열 · 기간은 비고 「조달 26.7」.
|
||||
그 밖의 줄에 남은 Cost1 은 출처가 조달인지 물가지인지 원문이 안 가름 — 값 열에 넣지 않고 비고 「가격정보 ○○」 로만.
|
||||
0 · 미저장 = 값 없음(null). 이름·규격은 원문 그대로(띄어쓰기·오자 포함). 코드 없는 빈 줄은 뺌.
|
||||
ESTX 에 대·중분류 칸이 없어 구분·상세구분은 비움(지어내지 않음).
|
||||
|
||||
쓰기: ./venv/Scripts/python.exe resources/master_data/scripts/build_재료_자재품목.py
|
||||
쓴 뒤 원본과 대조.
|
||||
"""
|
||||
|
||||
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
|
||||
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)는 마스터 아님
|
||||
|
||||
|
||||
def num(v):
|
||||
if not v:
|
||||
return None
|
||||
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 build(rows: list[dict]) -> list[dict]:
|
||||
out = []
|
||||
for r in sorted(rows, key=lambda r: r["Index"]):
|
||||
code = r.get("Code")
|
||||
if not code:
|
||||
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)}
|
||||
out.append(
|
||||
{
|
||||
"열쇠": code,
|
||||
"구분": None,
|
||||
"상세구분": None,
|
||||
"이름": r.get("Name", ""),
|
||||
"규격": r.get("Size", ""),
|
||||
"단위": r.get("Unite", ""),
|
||||
**{slot: num(r.get(f"Cost{i}")) for i, slot in enumerate(MARKET, 2)},
|
||||
"관급": base if public else None,
|
||||
"출처": f"자재품목 {code}",
|
||||
"비고": note(base, page, page if public else None),
|
||||
"면수": 면수 if any(면수.values()) else None,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def check(rows: list[dict]) -> 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")}
|
||||
assert len(got) == len(data["줄"]) == len(raw), (len(got), len(raw))
|
||||
print(
|
||||
f"줄 {len(got)} = 원본 코드 있는 줄 {len(raw)} (코드 없는 빈 줄 {len(rows) - len(raw)} 뺌)"
|
||||
)
|
||||
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 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 같음")
|
||||
|
||||
|
||||
def main():
|
||||
rows = Estx().table("StuffcDat")
|
||||
head = {
|
||||
"그룹": "재료",
|
||||
"원문": "자재품목",
|
||||
"판": "2026-07",
|
||||
"원천": [
|
||||
{
|
||||
"경로": ESTX.relative_to(ROOT).as_posix(),
|
||||
"해시": hashlib.sha256(ESTX.read_bytes()).hexdigest(),
|
||||
"역할": "primary",
|
||||
}
|
||||
],
|
||||
"비고": [
|
||||
"다산소프트 EST 2026-07 자재 · 개발용 · 배포 전 판권 구매",
|
||||
"값 열 다섯 = 물가지 넷(물가자료 · 유통물가 · 물가정보 · 거래가격) · 관급(조달) — 채택값·기본가 없음 · 쓰는 곳이 값을 고르고 출처를 밝힘",
|
||||
"관급 = 원문 「가격정보」 칸의 조달청 관급 단가(철근 · 레미콘 · 아스콘 · H형강 등) · 기간은 비고 「조달 26.7」",
|
||||
"원문 「가격정보」 칸에 시중 값이 남은 72 줄은 값 열에 넣지 않고 비고 「가격정보 ○○」 로만 둠 — 출처가 조달인지 물가지인지 원문이 안 가름",
|
||||
"구분 · 상세구분 = 원문에 대·중분류 칸이 없어 비움(지어내지 않음)",
|
||||
"면수 = 물가지 값의 원문 면수 · 화면에는 접어 둠",
|
||||
],
|
||||
}
|
||||
dump_master(OUT, head, build(rows))
|
||||
check(rows)
|
||||
print("새 틀로 받음", mk.adopt()) # 열쇠 → 키
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -43,7 +43,7 @@ BOOKS = (
|
||||
"준용대상",
|
||||
"나라장터자재",
|
||||
"오피넷유가",
|
||||
"시중물가",
|
||||
"자재품목",
|
||||
"한국은행환율",
|
||||
"조달청제비율",
|
||||
"품셈재료",
|
||||
@@ -89,6 +89,39 @@ LABOR_COLS = (
|
||||
"준용",
|
||||
)
|
||||
LABOR_STATUS = ("공표", "산정", "미공표", "미확보")
|
||||
# 원문별 열 한 벌 — 모든 줄이 이 칸을 이 차례로 가짐(없으면 null)
|
||||
COLS = {
|
||||
"자재품목": (
|
||||
"키",
|
||||
"원문번호",
|
||||
"구분",
|
||||
"상세구분",
|
||||
"이름",
|
||||
"규격",
|
||||
"단위",
|
||||
"물가자료",
|
||||
"유통물가",
|
||||
"물가정보",
|
||||
"거래가격",
|
||||
"관급",
|
||||
"출처",
|
||||
"비고",
|
||||
"면수",
|
||||
),
|
||||
"오피넷유가": (
|
||||
"키",
|
||||
"원문번호",
|
||||
"구분",
|
||||
"상세구분",
|
||||
"이름",
|
||||
"규격",
|
||||
"단위",
|
||||
"값",
|
||||
"출처",
|
||||
"비고",
|
||||
),
|
||||
}
|
||||
COLS["한국은행환율"] = COLS["오피넷유가"]
|
||||
|
||||
|
||||
def load(names: list[str] | None = None, folder: Path = MASTER) -> dict[str, dict]:
|
||||
@@ -181,13 +214,16 @@ def check_form(name: str, data: dict) -> list[str]:
|
||||
out.append(f"{where} · {merged} 「{row.get(merged)}」 이 머리에 없음")
|
||||
if division and not number.startswith(division + " "):
|
||||
out.append(f"{where} · 원문번호 앞에 부문 「{division}」 없음")
|
||||
for need in ROW_KEYS[kind]:
|
||||
cols = COLS.get(data.get("원문"))
|
||||
for need in cols or ROW_KEYS[kind]:
|
||||
if need not in row:
|
||||
out.append(f"{where} · 칸 「{need}」 없음")
|
||||
if "출처" in row and not _source_ok(row["출처"]):
|
||||
out.append(f"{where} · 출처 모양 「{row['출처']}」")
|
||||
if data.get("그룹") == "인력":
|
||||
out += _check_labor(where, row)
|
||||
if cols and tuple(row) != cols:
|
||||
out.append(f"{where} · 열이 한 벌이 아님 — 「{' · '.join(cols)}」")
|
||||
if kind == "품셈재료":
|
||||
out += _check_linked(where, row)
|
||||
if kind == "요소" and isinstance(row.get("값"), dict):
|
||||
@@ -214,7 +250,7 @@ def _check_labor(where: str, row: dict) -> list[str]:
|
||||
|
||||
|
||||
def _check_linked(where: str, row: dict) -> list[str]:
|
||||
"""품셈재료 — 값 칸 없음 · 요구절은 출처 모양 · 연결 = 시중물가 키 · 후보 조건 · null."""
|
||||
"""품셈재료 — 값 칸 없음 · 요구절은 출처 모양 · 연결 = 자재품목 키 · 후보 조건 · null."""
|
||||
out = [f"{where} · 값 칸을 두지 않음"] if "값" in row else []
|
||||
need = row.get("요구절")
|
||||
if not (isinstance(need, list) and need and all(_source_ok(x) for x in need)):
|
||||
@@ -232,22 +268,17 @@ def _check_linked(where: str, row: dict) -> list[str]:
|
||||
|
||||
|
||||
def check_links(whole: mf.Master) -> list[str]:
|
||||
"""끊긴 키 — 품셈재료 연결 · 시중물가 조달 연결 · 인력 준용이 가리키는 줄."""
|
||||
"""끊긴 키 — 품셈재료 연결 · 인력 준용이 가리키는 줄."""
|
||||
out = []
|
||||
for key, row in whole.index.get("MP", {}).items():
|
||||
link = row.get("연결")
|
||||
if isinstance(link, str) and link not in whole.index.get("MT", {}):
|
||||
out.append(f"{whole.label(key)} · 연결 「{link}」 이 시중물가에 없음")
|
||||
out.append(f"{whole.label(key)} · 연결 「{link}」 이 자재품목에 없음")
|
||||
for key, row in whole.index.get("LB", {}).items():
|
||||
for col in ("준용",):
|
||||
ref = row.get(col)
|
||||
if ref not in (None, "") and str(ref) not in whole.index.get("LB", {}):
|
||||
out.append(f"{whole.label(key)} · {col} 「{ref}」 없는 인력 키")
|
||||
nara = whole.index.get("MN", {})
|
||||
for key, row in whole.index.get("MT", {}).items():
|
||||
source = str(row["값"]["조달"].get("출처") or "")
|
||||
if source.startswith("나라장터:") and source.split(":", 1)[1] not in nara:
|
||||
out.append(f"{whole.label(key)} · 조달 「{source}」 이 나라장터자재에 없음")
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -669,4 +669,4 @@ def find_loops(graph: dict[str, set]) -> list[list[str]]:
|
||||
|
||||
|
||||
# 품셈재료 단가 — 서로 부르는 모듈이라 끝에서 읽음
|
||||
from master_material import LINKED, REGION, element # noqa: E402, F401
|
||||
from master_material import LINKED, REGION, SLOTS, element # noqa: E402, F401
|
||||
|
||||
@@ -18,8 +18,8 @@ KEY = re.compile(r"[A-Z]{2}\d{6}")
|
||||
# (그룹, 원문) → 테이블ID · 인력·기계는 원문 구분 없이 한 테이블
|
||||
TABLES = {
|
||||
("인력", None): "LB",
|
||||
("재료", "시중물가"): "MT",
|
||||
("재료", "나라장터자재"): "MN",
|
||||
("재료", "자재품목"): "MT",
|
||||
("재료", "나라장터자재"): "MN", # 자재품목과 잇지 않음 — 로직 호표가 키로 바로 가리킴
|
||||
("재료", "오피넷유가"): "MO",
|
||||
("재료", "품셈재료"): "MP",
|
||||
("기계", None): "EQ",
|
||||
@@ -54,7 +54,10 @@ def load_book(path: Path = BOOK) -> dict:
|
||||
|
||||
def save_book(book: dict, path: Path = BOOK) -> None:
|
||||
"""키 한 줄씩 — 머리 「다음」 뒤에 키 차례대로."""
|
||||
lines = ["{", ' "다음": ' + json.dumps(book["다음"], ensure_ascii=False) + ",", ' "키": {']
|
||||
lines = ["{", ' "다음": ' + json.dumps(book["다음"], ensure_ascii=False) + ","]
|
||||
if book.get("폐기"):
|
||||
lines.append(' "폐기": ' + json.dumps(book["폐기"], ensure_ascii=False) + ",")
|
||||
lines.append(' "키": {')
|
||||
items = sorted(book["키"].items())
|
||||
for i, (key, v) in enumerate(items):
|
||||
comma = "," if i < len(items) - 1 else ""
|
||||
@@ -187,7 +190,7 @@ def _old_ref(book: dict, text: str) -> str:
|
||||
|
||||
|
||||
def _convert(book: dict, row: dict, names: dict) -> dict:
|
||||
"""줄 속 옛 참조 — 준용 · 후보 글(키 옆 이름) · 품셈재료 연결 · 시중물가 조달 나라장터 연결."""
|
||||
"""줄 속 옛 참조 — 준용 · 후보 글(키 옆 이름) · 품셈재료 연결."""
|
||||
for col in ("준용",):
|
||||
value = row.get(col)
|
||||
if isinstance(value, str) and ":" in value:
|
||||
@@ -202,13 +205,6 @@ def _convert(book: dict, row: dict, names: dict) -> dict:
|
||||
link = row.get("연결")
|
||||
if isinstance(link, str) and not KEY.fullmatch(link):
|
||||
row["연결"] = lookup(book).get(("MT", link), link)
|
||||
value = row.get("값")
|
||||
supply = value.get("조달") if isinstance(value, dict) else None
|
||||
if isinstance(supply, dict) and str(supply.get("출처") or "").startswith("나라장터:"):
|
||||
code = supply["출처"].split(":", 1)[1]
|
||||
if not KEY.fullmatch(code): # 묶음은 새로 만듦 — 옛 줄과 나눠 쓰지 않게
|
||||
source = "나라장터:" + lookup(book).get(("MN", code), code)
|
||||
row["값"] = {**value, "조달": {**supply, "출처": source}}
|
||||
return row
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""품셈재료 단가 — 연결을 따라 시중물가 줄의 시중·조달 값 가운데 낮은 값 (`_틀.md` 4장 재료 값).
|
||||
"""품셈재료 단가 — 연결을 따라 자재품목 줄의 값 열 다섯 가운데 낮은 값 (`_틀.md` 4장 재료 값).
|
||||
|
||||
⚠ 낮은 값 고르기는 **시험 계산용**. 최저가·견적 고르기는 프로젝트 몫 — 마스터는 값을 모으기만 함.
|
||||
|
||||
`master_formula` 가 요소 값을 풀 때 부름. 서로 부르는 모듈이라 `master_formula` 는 끝에서 읽음.
|
||||
"""
|
||||
@@ -11,7 +13,7 @@ import unicodedata
|
||||
from decimal import Decimal
|
||||
|
||||
LINKED = "MP" # 품셈재료 테이블ID
|
||||
MARKET = ("가격정보", "물가자료", "유통물가", "물가정보", "거래가격")
|
||||
SLOTS = ("물가자료", "유통물가", "물가정보", "거래가격", "관급") # 자재품목 값 열
|
||||
REGION = "자재지역" # 로직 입력 — 「지역」 은 유가 고르기가 씀
|
||||
_UNITS = {"ea": "개", "ton": "톤", "t": "톤", "l": "ℓ", "리터": "ℓ", "set": "조", "세트": "조"}
|
||||
|
||||
@@ -32,21 +34,9 @@ def plain_unit(s) -> str:
|
||||
|
||||
|
||||
def _offers(master: mf.Master, key: str) -> list[tuple[Decimal, str]]:
|
||||
"""시중물가 줄 하나의 (값, 출처) — 시중 다섯 칸 · 조달(나라장터 연결은 그 줄 값)."""
|
||||
value = master.get(key)["값"]
|
||||
out = [
|
||||
(value["시중"][slot], f"{key}.시중.{slot}")
|
||||
for slot in MARKET
|
||||
if value["시중"].get(slot) is not None
|
||||
]
|
||||
supply = value["조달"]
|
||||
if supply.get("값") is not None:
|
||||
out.append((supply["값"], f"{key}.조달"))
|
||||
elif str(supply.get("출처") or "").startswith("나라장터:"):
|
||||
ref = supply["출처"].split(":", 1)[1]
|
||||
if master.get(ref).get("값") is not None:
|
||||
out.append((master.get(ref)["값"], ref))
|
||||
return out
|
||||
"""자재품목 줄 하나의 (값, 출처) — 값이 있는 열마다 하나 · 출처는 그 열 이름."""
|
||||
row = master.get(key)
|
||||
return [(row[slot], f"{key}.{slot}") for slot in SLOTS if row.get(slot) is not None]
|
||||
|
||||
|
||||
def _market_index(master: mf.Master) -> list[tuple[str, str, str, str, str]]:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,44 +15,45 @@
|
||||
}
|
||||
],
|
||||
"비고": [
|
||||
"시도별 유가 — 품셈 8-1-7 5호 「유류가격은 해당지역의 가격으로 한다」. 코드 00(전국)은 원문 그대로 두되 지역 선택지에서는 전국평균 판이 맡는다. ⚠ 원천이 시도를 17줄로 주는데 그 가름이 행정구역과 다르다 — 코드 20 이 「전남광주」 한 줄이고 07(전남)·16(광주)은 아예 없다. 원문 이름을 그대로 두고 우리가 쪼개지 않는다."
|
||||
"시도별 유가 — 품셈 8-1-7 5호 「유류가격은 해당지역의 가격으로 한다」. 코드 00(전국)은 원문 그대로 두되 지역 선택지에서는 전국평균 판이 맡는다. ⚠ 원천이 시도를 17줄로 주는데 그 가름이 행정구역과 다르다 — 코드 20 이 「전남광주」 한 줄이고 07(전남)·16(광주)은 아예 없다. 원문 이름을 그대로 두고 우리가 쪼개지 않는다.",
|
||||
"규격 = 기준일 · 구분 = 전국평균/시도별 · 상세구분 = 지역 이름"
|
||||
],
|
||||
"줄": [
|
||||
{ "키": "MO000001", "원문번호": "휘발유:전국평균", "이름": "휘발유", "규격": "", "단위": "원/L", "값": 1863.25, "출처": "오피넷유가 B027", "구분": "전국평균", "지역": "전국평균", "기준일": "2026-08-14", "상품코드": "B027" },
|
||||
{ "키": "MO000002", "원문번호": "경유:전국평균", "이름": "자동차용경유", "규격": "", "단위": "원/L", "값": 1846.39, "출처": "오피넷유가 D047", "구분": "전국평균", "지역": "전국평균", "기준일": "2026-08-14", "상품코드": "D047" },
|
||||
{ "키": "MO000003", "원문번호": "휘발유:전국", "이름": "휘발유", "규격": "", "단위": "원/L", "값": 1858.93, "출처": "오피넷유가 B027", "구분": "시도별", "지역": "전국", "지역코드": "00", "기준일": "2026-09-09", "상품코드": "B027" },
|
||||
{ "키": "MO000004", "원문번호": "휘발유:서울", "이름": "휘발유", "규격": "", "단위": "원/L", "값": 1905.02, "출처": "오피넷유가 B027", "구분": "시도별", "지역": "서울", "지역코드": "01", "기준일": "2026-09-09", "상품코드": "B027" },
|
||||
{ "키": "MO000005", "원문번호": "휘발유:경기", "이름": "휘발유", "규격": "", "단위": "원/L", "값": 1855.78, "출처": "오피넷유가 B027", "구분": "시도별", "지역": "경기", "지역코드": "02", "기준일": "2026-09-09", "상품코드": "B027" },
|
||||
{ "키": "MO000006", "원문번호": "휘발유:강원", "이름": "휘발유", "규격": "", "단위": "원/L", "값": 1870.23, "출처": "오피넷유가 B027", "구분": "시도별", "지역": "강원", "지역코드": "03", "기준일": "2026-09-09", "상품코드": "B027" },
|
||||
{ "키": "MO000007", "원문번호": "휘발유:충북", "이름": "휘발유", "규격": "", "단위": "원/L", "값": 1866.12, "출처": "오피넷유가 B027", "구분": "시도별", "지역": "충북", "지역코드": "04", "기준일": "2026-09-09", "상품코드": "B027" },
|
||||
{ "키": "MO000008", "원문번호": "휘발유:충남", "이름": "휘발유", "규격": "", "단위": "원/L", "값": 1864.78, "출처": "오피넷유가 B027", "구분": "시도별", "지역": "충남", "지역코드": "05", "기준일": "2026-09-09", "상품코드": "B027" },
|
||||
{ "키": "MO000009", "원문번호": "휘발유:전북", "이름": "휘발유", "규격": "", "단위": "원/L", "값": 1859.7, "출처": "오피넷유가 B027", "구분": "시도별", "지역": "전북", "지역코드": "06", "기준일": "2026-09-09", "상품코드": "B027" },
|
||||
{ "키": "MO000010", "원문번호": "휘발유:경북", "이름": "휘발유", "규격": "", "단위": "원/L", "값": 1852.71, "출처": "오피넷유가 B027", "구분": "시도별", "지역": "경북", "지역코드": "08", "기준일": "2026-09-09", "상품코드": "B027" },
|
||||
{ "키": "MO000011", "원문번호": "휘발유:경남", "이름": "휘발유", "규격": "", "단위": "원/L", "값": 1854.71, "출처": "오피넷유가 B027", "구분": "시도별", "지역": "경남", "지역코드": "09", "기준일": "2026-09-09", "상품코드": "B027" },
|
||||
{ "키": "MO000012", "원문번호": "휘발유:부산", "이름": "휘발유", "규격": "", "단위": "원/L", "값": 1842.66, "출처": "오피넷유가 B027", "구분": "시도별", "지역": "부산", "지역코드": "10", "기준일": "2026-09-09", "상품코드": "B027" },
|
||||
{ "키": "MO000013", "원문번호": "휘발유:제주", "이름": "휘발유", "규격": "", "단위": "원/L", "값": 1889.27, "출처": "오피넷유가 B027", "구분": "시도별", "지역": "제주", "지역코드": "11", "기준일": "2026-09-09", "상품코드": "B027" },
|
||||
{ "키": "MO000014", "원문번호": "휘발유:대구", "이름": "휘발유", "규격": "", "단위": "원/L", "값": 1831.78, "출처": "오피넷유가 B027", "구분": "시도별", "지역": "대구", "지역코드": "14", "기준일": "2026-09-09", "상품코드": "B027" },
|
||||
{ "키": "MO000015", "원문번호": "휘발유:인천", "이름": "휘발유", "규격": "", "단위": "원/L", "값": 1845.64, "출처": "오피넷유가 B027", "구분": "시도별", "지역": "인천", "지역코드": "15", "기준일": "2026-09-09", "상품코드": "B027" },
|
||||
{ "키": "MO000016", "원문번호": "휘발유:대전", "이름": "휘발유", "규격": "", "단위": "원/L", "값": 1836.79, "출처": "오피넷유가 B027", "구분": "시도별", "지역": "대전", "지역코드": "17", "기준일": "2026-09-09", "상품코드": "B027" },
|
||||
{ "키": "MO000017", "원문번호": "휘발유:울산", "이름": "휘발유", "규격": "", "단위": "원/L", "값": 1838.49, "출처": "오피넷유가 B027", "구분": "시도별", "지역": "울산", "지역코드": "18", "기준일": "2026-09-09", "상품코드": "B027" },
|
||||
{ "키": "MO000018", "원문번호": "휘발유:세종", "이름": "휘발유", "규격": "", "단위": "원/L", "값": 1855.8, "출처": "오피넷유가 B027", "구분": "시도별", "지역": "세종", "지역코드": "19", "기준일": "2026-09-09", "상품코드": "B027" },
|
||||
{ "키": "MO000019", "원문번호": "휘발유:전남광주", "이름": "휘발유", "규격": "", "단위": "원/L", "값": 1862.28, "출처": "오피넷유가 B027", "구분": "시도별", "지역": "전남광주", "지역코드": "20", "기준일": "2026-09-09", "상품코드": "B027" },
|
||||
{ "키": "MO000020", "원문번호": "경유:전국", "이름": "자동차용경유", "규격": "", "단위": "원/L", "값": 1843.79, "출처": "오피넷유가 D047", "구분": "시도별", "지역": "전국", "지역코드": "00", "기준일": "2026-09-09", "상품코드": "D047" },
|
||||
{ "키": "MO000021", "원문번호": "경유:서울", "이름": "자동차용경유", "규격": "", "단위": "원/L", "값": 1886.15, "출처": "오피넷유가 D047", "구분": "시도별", "지역": "서울", "지역코드": "01", "기준일": "2026-09-09", "상품코드": "D047" },
|
||||
{ "키": "MO000022", "원문번호": "경유:경기", "이름": "자동차용경유", "규격": "", "단위": "원/L", "값": 1838.22, "출처": "오피넷유가 D047", "구분": "시도별", "지역": "경기", "지역코드": "02", "기준일": "2026-09-09", "상품코드": "D047" },
|
||||
{ "키": "MO000023", "원문번호": "경유:강원", "이름": "자동차용경유", "규격": "", "단위": "원/L", "값": 1859.9, "출처": "오피넷유가 D047", "구분": "시도별", "지역": "강원", "지역코드": "03", "기준일": "2026-09-09", "상품코드": "D047" },
|
||||
{ "키": "MO000024", "원문번호": "경유:충북", "이름": "자동차용경유", "규격": "", "단위": "원/L", "값": 1851.55, "출처": "오피넷유가 D047", "구분": "시도별", "지역": "충북", "지역코드": "04", "기준일": "2026-09-09", "상품코드": "D047" },
|
||||
{ "키": "MO000025", "원문번호": "경유:충남", "이름": "자동차용경유", "규격": "", "단위": "원/L", "값": 1850.08, "출처": "오피넷유가 D047", "구분": "시도별", "지역": "충남", "지역코드": "05", "기준일": "2026-09-09", "상품코드": "D047" },
|
||||
{ "키": "MO000026", "원문번호": "경유:전북", "이름": "자동차용경유", "규격": "", "단위": "원/L", "값": 1846.86, "출처": "오피넷유가 D047", "구분": "시도별", "지역": "전북", "지역코드": "06", "기준일": "2026-09-09", "상품코드": "D047" },
|
||||
{ "키": "MO000027", "원문번호": "경유:경북", "이름": "자동차용경유", "규격": "", "단위": "원/L", "값": 1838.1, "출처": "오피넷유가 D047", "구분": "시도별", "지역": "경북", "지역코드": "08", "기준일": "2026-09-09", "상품코드": "D047" },
|
||||
{ "키": "MO000028", "원문번호": "경유:경남", "이름": "자동차용경유", "규격": "", "단위": "원/L", "값": 1837.71, "출처": "오피넷유가 D047", "구분": "시도별", "지역": "경남", "지역코드": "09", "기준일": "2026-09-09", "상품코드": "D047" },
|
||||
{ "키": "MO000029", "원문번호": "경유:부산", "이름": "자동차용경유", "규격": "", "단위": "원/L", "값": 1825.55, "출처": "오피넷유가 D047", "구분": "시도별", "지역": "부산", "지역코드": "10", "기준일": "2026-09-09", "상품코드": "D047" },
|
||||
{ "키": "MO000030", "원문번호": "경유:제주", "이름": "자동차용경유", "규격": "", "단위": "원/L", "값": 1869.16, "출처": "오피넷유가 D047", "구분": "시도별", "지역": "제주", "지역코드": "11", "기준일": "2026-09-09", "상품코드": "D047" },
|
||||
{ "키": "MO000031", "원문번호": "경유:대구", "이름": "자동차용경유", "규격": "", "단위": "원/L", "값": 1815.72, "출처": "오피넷유가 D047", "구분": "시도별", "지역": "대구", "지역코드": "14", "기준일": "2026-09-09", "상품코드": "D047" },
|
||||
{ "키": "MO000032", "원문번호": "경유:인천", "이름": "자동차용경유", "규격": "", "단위": "원/L", "값": 1829.14, "출처": "오피넷유가 D047", "구분": "시도별", "지역": "인천", "지역코드": "15", "기준일": "2026-09-09", "상품코드": "D047" },
|
||||
{ "키": "MO000033", "원문번호": "경유:대전", "이름": "자동차용경유", "규격": "", "단위": "원/L", "값": 1824.58, "출처": "오피넷유가 D047", "구분": "시도별", "지역": "대전", "지역코드": "17", "기준일": "2026-09-09", "상품코드": "D047" },
|
||||
{ "키": "MO000034", "원문번호": "경유:울산", "이름": "자동차용경유", "규격": "", "단위": "원/L", "값": 1826.63, "출처": "오피넷유가 D047", "구분": "시도별", "지역": "울산", "지역코드": "18", "기준일": "2026-09-09", "상품코드": "D047" },
|
||||
{ "키": "MO000035", "원문번호": "경유:세종", "이름": "자동차용경유", "규격": "", "단위": "원/L", "값": 1841.72, "출처": "오피넷유가 D047", "구분": "시도별", "지역": "세종", "지역코드": "19", "기준일": "2026-09-09", "상품코드": "D047" },
|
||||
{ "키": "MO000036", "원문번호": "경유:전남광주", "이름": "자동차용경유", "규격": "", "단위": "원/L", "값": 1850.47, "출처": "오피넷유가 D047", "구분": "시도별", "지역": "전남광주", "지역코드": "20", "기준일": "2026-09-09", "상품코드": "D047" }
|
||||
{ "키": "MO000001", "원문번호": "휘발유:전국평균", "구분": "전국평균", "상세구분": "전국평균", "이름": "휘발유", "규격": "2026-08-14", "단위": "원/L", "값": 1863.25, "출처": "오피넷유가 B027", "비고": null },
|
||||
{ "키": "MO000002", "원문번호": "경유:전국평균", "구분": "전국평균", "상세구분": "전국평균", "이름": "자동차용경유", "규격": "2026-08-14", "단위": "원/L", "값": 1846.39, "출처": "오피넷유가 D047", "비고": null },
|
||||
{ "키": "MO000003", "원문번호": "휘발유:전국", "구분": "시도별", "상세구분": "전국", "이름": "휘발유", "규격": "2026-09-09", "단위": "원/L", "값": 1858.93, "출처": "오피넷유가 B027", "비고": null },
|
||||
{ "키": "MO000004", "원문번호": "휘발유:서울", "구분": "시도별", "상세구분": "서울", "이름": "휘발유", "규격": "2026-09-09", "단위": "원/L", "값": 1905.02, "출처": "오피넷유가 B027", "비고": null },
|
||||
{ "키": "MO000005", "원문번호": "휘발유:경기", "구분": "시도별", "상세구분": "경기", "이름": "휘발유", "규격": "2026-09-09", "단위": "원/L", "값": 1855.78, "출처": "오피넷유가 B027", "비고": null },
|
||||
{ "키": "MO000006", "원문번호": "휘발유:강원", "구분": "시도별", "상세구분": "강원", "이름": "휘발유", "규격": "2026-09-09", "단위": "원/L", "값": 1870.23, "출처": "오피넷유가 B027", "비고": null },
|
||||
{ "키": "MO000007", "원문번호": "휘발유:충북", "구분": "시도별", "상세구분": "충북", "이름": "휘발유", "규격": "2026-09-09", "단위": "원/L", "값": 1866.12, "출처": "오피넷유가 B027", "비고": null },
|
||||
{ "키": "MO000008", "원문번호": "휘발유:충남", "구분": "시도별", "상세구분": "충남", "이름": "휘발유", "규격": "2026-09-09", "단위": "원/L", "값": 1864.78, "출처": "오피넷유가 B027", "비고": null },
|
||||
{ "키": "MO000009", "원문번호": "휘발유:전북", "구분": "시도별", "상세구분": "전북", "이름": "휘발유", "규격": "2026-09-09", "단위": "원/L", "값": 1859.7, "출처": "오피넷유가 B027", "비고": null },
|
||||
{ "키": "MO000010", "원문번호": "휘발유:경북", "구분": "시도별", "상세구분": "경북", "이름": "휘발유", "규격": "2026-09-09", "단위": "원/L", "값": 1852.71, "출처": "오피넷유가 B027", "비고": null },
|
||||
{ "키": "MO000011", "원문번호": "휘발유:경남", "구분": "시도별", "상세구분": "경남", "이름": "휘발유", "규격": "2026-09-09", "단위": "원/L", "값": 1854.71, "출처": "오피넷유가 B027", "비고": null },
|
||||
{ "키": "MO000012", "원문번호": "휘발유:부산", "구분": "시도별", "상세구분": "부산", "이름": "휘발유", "규격": "2026-09-09", "단위": "원/L", "값": 1842.66, "출처": "오피넷유가 B027", "비고": null },
|
||||
{ "키": "MO000013", "원문번호": "휘발유:제주", "구분": "시도별", "상세구분": "제주", "이름": "휘발유", "규격": "2026-09-09", "단위": "원/L", "값": 1889.27, "출처": "오피넷유가 B027", "비고": null },
|
||||
{ "키": "MO000014", "원문번호": "휘발유:대구", "구분": "시도별", "상세구분": "대구", "이름": "휘발유", "규격": "2026-09-09", "단위": "원/L", "값": 1831.78, "출처": "오피넷유가 B027", "비고": null },
|
||||
{ "키": "MO000015", "원문번호": "휘발유:인천", "구분": "시도별", "상세구분": "인천", "이름": "휘발유", "규격": "2026-09-09", "단위": "원/L", "값": 1845.64, "출처": "오피넷유가 B027", "비고": null },
|
||||
{ "키": "MO000016", "원문번호": "휘발유:대전", "구분": "시도별", "상세구분": "대전", "이름": "휘발유", "규격": "2026-09-09", "단위": "원/L", "값": 1836.79, "출처": "오피넷유가 B027", "비고": null },
|
||||
{ "키": "MO000017", "원문번호": "휘발유:울산", "구분": "시도별", "상세구분": "울산", "이름": "휘발유", "규격": "2026-09-09", "단위": "원/L", "값": 1838.49, "출처": "오피넷유가 B027", "비고": null },
|
||||
{ "키": "MO000018", "원문번호": "휘발유:세종", "구분": "시도별", "상세구분": "세종", "이름": "휘발유", "규격": "2026-09-09", "단위": "원/L", "값": 1855.8, "출처": "오피넷유가 B027", "비고": null },
|
||||
{ "키": "MO000019", "원문번호": "휘발유:전남광주", "구분": "시도별", "상세구분": "전남광주", "이름": "휘발유", "규격": "2026-09-09", "단위": "원/L", "값": 1862.28, "출처": "오피넷유가 B027", "비고": null },
|
||||
{ "키": "MO000020", "원문번호": "경유:전국", "구분": "시도별", "상세구분": "전국", "이름": "자동차용경유", "규격": "2026-09-09", "단위": "원/L", "값": 1843.79, "출처": "오피넷유가 D047", "비고": null },
|
||||
{ "키": "MO000021", "원문번호": "경유:서울", "구분": "시도별", "상세구분": "서울", "이름": "자동차용경유", "규격": "2026-09-09", "단위": "원/L", "값": 1886.15, "출처": "오피넷유가 D047", "비고": null },
|
||||
{ "키": "MO000022", "원문번호": "경유:경기", "구분": "시도별", "상세구분": "경기", "이름": "자동차용경유", "규격": "2026-09-09", "단위": "원/L", "값": 1838.22, "출처": "오피넷유가 D047", "비고": null },
|
||||
{ "키": "MO000023", "원문번호": "경유:강원", "구분": "시도별", "상세구분": "강원", "이름": "자동차용경유", "규격": "2026-09-09", "단위": "원/L", "값": 1859.9, "출처": "오피넷유가 D047", "비고": null },
|
||||
{ "키": "MO000024", "원문번호": "경유:충북", "구분": "시도별", "상세구분": "충북", "이름": "자동차용경유", "규격": "2026-09-09", "단위": "원/L", "값": 1851.55, "출처": "오피넷유가 D047", "비고": null },
|
||||
{ "키": "MO000025", "원문번호": "경유:충남", "구분": "시도별", "상세구분": "충남", "이름": "자동차용경유", "규격": "2026-09-09", "단위": "원/L", "값": 1850.08, "출처": "오피넷유가 D047", "비고": null },
|
||||
{ "키": "MO000026", "원문번호": "경유:전북", "구분": "시도별", "상세구분": "전북", "이름": "자동차용경유", "규격": "2026-09-09", "단위": "원/L", "값": 1846.86, "출처": "오피넷유가 D047", "비고": null },
|
||||
{ "키": "MO000027", "원문번호": "경유:경북", "구분": "시도별", "상세구분": "경북", "이름": "자동차용경유", "규격": "2026-09-09", "단위": "원/L", "값": 1838.1, "출처": "오피넷유가 D047", "비고": null },
|
||||
{ "키": "MO000028", "원문번호": "경유:경남", "구분": "시도별", "상세구분": "경남", "이름": "자동차용경유", "규격": "2026-09-09", "단위": "원/L", "값": 1837.71, "출처": "오피넷유가 D047", "비고": null },
|
||||
{ "키": "MO000029", "원문번호": "경유:부산", "구분": "시도별", "상세구분": "부산", "이름": "자동차용경유", "규격": "2026-09-09", "단위": "원/L", "값": 1825.55, "출처": "오피넷유가 D047", "비고": null },
|
||||
{ "키": "MO000030", "원문번호": "경유:제주", "구분": "시도별", "상세구분": "제주", "이름": "자동차용경유", "규격": "2026-09-09", "단위": "원/L", "값": 1869.16, "출처": "오피넷유가 D047", "비고": null },
|
||||
{ "키": "MO000031", "원문번호": "경유:대구", "구분": "시도별", "상세구분": "대구", "이름": "자동차용경유", "규격": "2026-09-09", "단위": "원/L", "값": 1815.72, "출처": "오피넷유가 D047", "비고": null },
|
||||
{ "키": "MO000032", "원문번호": "경유:인천", "구분": "시도별", "상세구분": "인천", "이름": "자동차용경유", "규격": "2026-09-09", "단위": "원/L", "값": 1829.14, "출처": "오피넷유가 D047", "비고": null },
|
||||
{ "키": "MO000033", "원문번호": "경유:대전", "구분": "시도별", "상세구분": "대전", "이름": "자동차용경유", "규격": "2026-09-09", "단위": "원/L", "값": 1824.58, "출처": "오피넷유가 D047", "비고": null },
|
||||
{ "키": "MO000034", "원문번호": "경유:울산", "구분": "시도별", "상세구분": "울산", "이름": "자동차용경유", "규격": "2026-09-09", "단위": "원/L", "값": 1826.63, "출처": "오피넷유가 D047", "비고": null },
|
||||
{ "키": "MO000035", "원문번호": "경유:세종", "구분": "시도별", "상세구분": "세종", "이름": "자동차용경유", "규격": "2026-09-09", "단위": "원/L", "값": 1841.72, "출처": "오피넷유가 D047", "비고": null },
|
||||
{ "키": "MO000036", "원문번호": "경유:전남광주", "구분": "시도별", "상세구분": "전남광주", "이름": "자동차용경유", "규격": "2026-09-09", "단위": "원/L", "값": 1850.47, "출처": "오피넷유가 D047", "비고": null }
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,11 +9,12 @@
|
||||
"역할": "primary"
|
||||
}
|
||||
],
|
||||
"비고": ["규격 = 기준일 · 구분 · 상세구분은 두되 비움"],
|
||||
"줄": [
|
||||
{ "키": "FX000001", "원문번호": "0000001", "이름": "USD", "규격": "", "단위": "원/USD", "값": 1414.9, "출처": "한국은행환율 0000001", "기준일": "2026-08-14" },
|
||||
{ "키": "FX000002", "원문번호": "0000002", "이름": "JPY100", "규격": "", "단위": "원/JPY100", "값": 887.33, "출처": "한국은행환율 0000002", "기준일": "2026-08-14" },
|
||||
{ "키": "FX000003", "원문번호": "0000003", "이름": "EUR", "규격": "", "단위": "원/EUR", "값": 1631.87, "출처": "한국은행환율 0000003", "기준일": "2026-08-14" },
|
||||
{ "키": "FX000004", "원문번호": "0000012", "이름": "GBP", "규격": "", "단위": "원/GBP", "값": 1908.7, "출처": "한국은행환율 0000012", "기준일": "2026-08-14" },
|
||||
{ "키": "FX000005", "원문번호": "0000053", "이름": "CNY", "규격": "", "단위": "원/CNY", "값": 209.75, "출처": "한국은행환율 0000053", "기준일": "2026-08-14" }
|
||||
{ "키": "FX000001", "원문번호": "0000001", "구분": null, "상세구분": null, "이름": "USD", "규격": "2026-08-14", "단위": "원/USD", "값": 1414.9, "출처": "한국은행환율 0000001", "비고": null },
|
||||
{ "키": "FX000002", "원문번호": "0000002", "구분": null, "상세구분": null, "이름": "JPY100", "규격": "2026-08-14", "단위": "원/JPY100", "값": 887.33, "출처": "한국은행환율 0000002", "비고": null },
|
||||
{ "키": "FX000003", "원문번호": "0000003", "구분": null, "상세구분": null, "이름": "EUR", "규격": "2026-08-14", "단위": "원/EUR", "값": 1631.87, "출처": "한국은행환율 0000003", "비고": null },
|
||||
{ "키": "FX000004", "원문번호": "0000012", "구분": null, "상세구분": null, "이름": "GBP", "규격": "2026-08-14", "단위": "원/GBP", "값": 1908.7, "출처": "한국은행환율 0000012", "비고": null },
|
||||
{ "키": "FX000005", "원문번호": "0000053", "구분": null, "상세구분": null, "이름": "CNY", "규격": "2026-08-14", "단위": "원/CNY", "값": 209.75, "출처": "한국은행환율 0000053", "비고": null }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -273,9 +273,8 @@ def test_main_은_시스템관리자만() -> None:
|
||||
assert "app.include_router(m01_master_data_router, dependencies=system_admin_only)" in main
|
||||
|
||||
|
||||
def test_고르기_나라장터_시중물가_직종(client: TestClient) -> None:
|
||||
nara = _get(client, "/api/m01/pick", kind="procure", q="육각볼트 M6*20")
|
||||
assert nara["total"] >= 1 and nara["items"][0]["ref"].startswith("나라장터:")
|
||||
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=5)
|
||||
assert market["items"] and market["items"][0]["관급"] and market["items"][0]["값"] > 0
|
||||
job = _get(client, "/api/m01/pick", kind="job", q="보통인부")
|
||||
@@ -305,7 +304,7 @@ def test_구분_목록(client: TestClient) -> None:
|
||||
"건설품셈",
|
||||
"산림품셈",
|
||||
]
|
||||
assert _get(client, "/api/m01/subs", file="재료_시중물가.json")["subs"] == []
|
||||
assert _get(client, "/api/m01/subs", file="재료_자재품목.json")["subs"] == []
|
||||
|
||||
|
||||
def test_못_이은_줄만_거름(client: TestClient) -> None:
|
||||
@@ -318,7 +317,7 @@ def test_품셈재료_단가는_연결의_낮은_값과_출처(client: TestClien
|
||||
key = key_of("GC:기계설비 13-2-4 강판 전기아크용접 전기아크용접(V형)")
|
||||
one = _get(client, "/api/m01/logic", key=key)
|
||||
power = one["prices"][key_of("MP:전력")]
|
||||
assert power["값"] > 0 and power["출처"].startswith(key_of("MT:M0005250064") + ".")
|
||||
assert power["값"] > 0 and power["출처"] == key_of("MT:M0005250064") + ".물가자료"
|
||||
|
||||
|
||||
def test_하위_거름과_참조_이름(client: TestClient) -> None:
|
||||
@@ -335,18 +334,18 @@ def test_하위_거름과_참조_이름(client: TestClient) -> None:
|
||||
assert keyed and all(linked["refs"][r["연결"]] for r in keyed)
|
||||
|
||||
|
||||
def test_시중물가_저장은_띄어쓰기를_안_바꿈(client: TestClient) -> None:
|
||||
market = "재료_시중물가.json"
|
||||
def test_자재품목_저장은_띄어쓰기를_안_바꿈(client: TestClient) -> None:
|
||||
market = "재료_자재품목.json"
|
||||
old = (store.FOLDER / market).read_text(encoding="utf-8").split("\n")
|
||||
rows = _get(client, "/api/m01/rows", file=market, size=1)
|
||||
row = rows["rows"][0]
|
||||
row["값"]["시중"]["가격정보"] = 1
|
||||
row["물가자료"] = 1
|
||||
change = {"op": "edit", "key": row["키"], "row": row}
|
||||
body = {"files": [{"file": market, "version": rows["version"], "changes": [change]}]}
|
||||
assert client.post("/api/m01/save", json=body).status_code == 200
|
||||
new = (store.FOLDER / market).read_text(encoding="utf-8").split("\n")
|
||||
diff = [(a, b) for a, b in zip(old, new) if a != b]
|
||||
assert len(new) == len(old) and len(diff) == 1
|
||||
assert diff[0][1].replace('"가격정보": 1,', '"가격정보": X,').count("{ ") == diff[0][0].count(
|
||||
assert diff[0][1].replace('"물가자료": 1,', '"물가자료": X,').count("{ ") == diff[0][0].count(
|
||||
"{ "
|
||||
)
|
||||
|
||||
@@ -435,8 +435,9 @@ def test_토목_보링_심도_가산():
|
||||
for row in files["인력.json"]["줄"]:
|
||||
if row["값"] is None: # 관리자가 채울 값 — 시험용 가짜 값(파일엔 안 씀)
|
||||
row["값"] = Decimal(1000)
|
||||
fake = {"시중": {"물가자료": Decimal(1000)}, "조달": {"값": None, "출처": None}}
|
||||
files["재료_시중물가.json"]["줄"].append({"키": "MT999999", "원문번호": "가짜", "값": fake})
|
||||
files["재료_자재품목.json"]["줄"].append(
|
||||
{"키": "MT999999", "원문번호": "가짜", "물가자료": Decimal(1000)}
|
||||
)
|
||||
for row in files["재료_품셈재료.json"]["줄"]:
|
||||
row["연결"] = row["연결"] or "MT999999" # 못 이은 재료 — 시험용 가짜 연결
|
||||
given = {"층": "점토층", "규격": "BX", "깊이": 35, "심도가산율": 5}
|
||||
@@ -669,25 +670,26 @@ def test_노임_갈래_파일_원문_대조():
|
||||
|
||||
|
||||
def test_품셈재료_연결_낮은_값과_출처(whole):
|
||||
"""연결 하나 — 시중 다섯 칸 · 조달 가운데 낮은 값 · 출처 = 그 칸."""
|
||||
row = whole.get("MT:M0005250064")["값"]
|
||||
low = min(v for k, v in row["시중"].items() if k != "면수" and v is not None)
|
||||
"""연결 하나 — 값 열 다섯 가운데 낮은 값 · 출처 = 「<키>.<열>」."""
|
||||
row = whole.get("MT:M0005250064")
|
||||
low = min(row[s] for s in mm.SLOTS if row.get(s) is not None)
|
||||
value, source = mf.element(whole, "MP:전력", {})
|
||||
assert value == low and source.startswith(whole.get("MT:M0005250064")["키"] + ".시중.")
|
||||
assert value == low and source.split(".") == [row["키"], "물가자료"]
|
||||
|
||||
|
||||
def test_시중물가_조달_묶음():
|
||||
"""관급 줄 가격정보 → 조달 · 나라장터 짝은 값 없이 연결만 · 채택·견적가 칸 없음."""
|
||||
master = cm.master()
|
||||
steel = master.get("MT:M0005251695")["값"]
|
||||
assert steel["조달"] == {"값": Decimal(1228830), "출처": "가격정보", "기준": "26.6.12~"}
|
||||
assert (
|
||||
steel["시중"]["가격정보"] is None and "채택" not in steel and "견적가" not in steel["시중"]
|
||||
)
|
||||
bolt = master.get("MT:M0005252462")
|
||||
nara = master.get("MN:10023398")
|
||||
assert bolt["값"]["조달"] == {"값": None, "출처": "나라장터:" + nara["키"], "기준": None}
|
||||
assert (nara["값"], nara["키"]) in mm._offers(master, bolt["키"])
|
||||
def test_자재품목_열_한_벌():
|
||||
"""모든 줄이 같은 열 한 벌 · 관급은 조달 값 · 기간은 비고 · 남은 가격정보는 비고에만."""
|
||||
data = cm.load(["재료_자재품목.json"])["재료_자재품목.json"]
|
||||
rows = data["줄"]
|
||||
assert {tuple(r) for r in rows} == {cm.COLS["자재품목"]}
|
||||
steel = next(r for r in rows if r["원문번호"] == "M0005251695")
|
||||
assert steel["관급"] == Decimal(1228830) and steel["비고"] == "조달 26.6.12~"
|
||||
assert all(steel[s] is None for s in ("물가자료", "유통물가", "물가정보", "거래가격"))
|
||||
left = [r for r in rows if str(r["비고"] or "").startswith("가격정보")]
|
||||
assert len(left) == 72 and all(r["관급"] is None for r in left)
|
||||
oil = next(r for r in rows if r["원문번호"] == "M0003039885") # 휘발유
|
||||
assert oil["비고"] == "가격정보 1,825 · 2026.7" and mm._offers(cm.master(), oil["키"]) == []
|
||||
assert {r["구분"] for r in rows} == {None} and {r["상세구분"] for r in rows} == {None}
|
||||
|
||||
|
||||
def test_품셈재료_자재지역_후보(whole):
|
||||
|
||||
Reference in New Issue
Block a user