diff --git a/B09_Estimation/B09_Estimation_Lists_Sources.py b/B09_Estimation/B09_Estimation_Lists_Sources.py index 514c1b4b..4325c6ea 100644 --- a/B09_Estimation/B09_Estimation_Lists_Sources.py +++ b/B09_Estimation/B09_Estimation_Lists_Sources.py @@ -37,19 +37,39 @@ from B09_Estimation.B09_Estimation_MachineCost import ( from B09_Estimation.B09_Estimation_PriceBook import PRICE_SLOT_COUNT, PriceKind from B09_Estimation.B09_Estimation_UnitPrice import UnitPriceBuild, cached_build -#: 유가 적용 범위 — 사용자 확정 ⑮. 값이 있는 것만 고를 수 있다. -FUEL_SCOPES: tuple[dict[str, Any], ...] = ( - {"key": "national_average", "label": "전국 공시가", "available": True}, - { - "key": "regional", - "label": "지역 공시가", - "available": False, - "why": ( - "오피넷 지역별 값을 아직 안 받아 왔습니다 — 품셈 8-1-7 5호가 「유류가격은 " - "해당 지역의 가격」이라 규정하므로 자료를 받으면 고를 수 있게 됩니다." - ), - }, -) + +#: 유가 적용 범위 — 사용자 확정 ⑮. **값이 있는 것만 고를 수 있다.** +#: ⚠ 「지역 공시가」가 고를 수 있는지는 **코드가 아니라 판이 정한다** — 시도별 판이 +#: 들어와 있으면 열리고, 없으면 까닭과 함께 닫힌다(2026-09-09 에 판이 들어옴). +def fuel_scopes() -> tuple[dict[str, Any], ...]: + from B09_Estimation.B09_Estimation_MachineOperating import load_regional_fuel_table + + table, meta = load_regional_fuel_table() + if table: + return ( + {"key": "national_average", "label": "전국 공시가", "available": True}, + { + "key": "regional", + "label": "지역 공시가", + "available": True, + "why": ( + "품셈 8-1-7 5호 「유류가격은 해당지역의 가격으로 한다」 — " + f"오피넷 시도별 판({meta.get('effective_date', '')}) 으로 섭니다." + ), + }, + ) + return ( + {"key": "national_average", "label": "전국 공시가", "available": True}, + { + "key": "regional", + "label": "지역 공시가", + "available": False, + "why": ( + "오피넷 지역별 값을 아직 안 받아 왔습니다 — 품셈 8-1-7 5호가 「유류가격은 " + "해당 지역의 가격」이라 규정하므로 자료를 받으면 고를 수 있게 됩니다." + ), + }, + ) def _money(value: Decimal | None) -> str | None: @@ -114,18 +134,23 @@ def material_price_comparison(build: UnitPriceBuild | None = None) -> dict[str, } -def base_reference_data(build: UnitPriceBuild | None = None) -> dict[str, Any]: +def base_reference_data( + build: UnitPriceBuild | None = None, fuel_region: str | None = None +) -> dict[str, Any]: """환율및기초자료 — 실무 시트 세 구획을 그대로 낸다. ① 환율 ② 인건비(운전사 3종) ③ 단가 및 재료비(유류 등) + + ⚠ `fuel_region`(시도코드) — 프로젝트가 고른 지역. **안 고르면 전국평균**이다. """ from B09_Estimation.B09_Estimation_MachineOperating import ( load_fuel_price, load_operator_wages, + load_regional_fuel_table, ) prices = build or cached_build() - fuel_price, fuel_meta = load_fuel_price() + fuel_price, fuel_meta = load_fuel_price(region=fuel_region) wages = load_operator_wages() # ⚠ **운전사 세 직종만** 싣는다. 실무 「환율및기초자료」 시트가 그 셋뿐이고 @@ -163,10 +188,18 @@ def base_reference_data(build: UnitPriceBuild | None = None) -> dict[str, Any]: "scope": fuel_meta.get("scope", ""), "effective_date": fuel_meta.get("effective_date", ""), "dataset_id": fuel_meta.get("dataset_id", ""), - "scopes": list(FUEL_SCOPES), + "scopes": list(fuel_scopes()), + # 고를 수 있는 시도 — **판에 있는 것만**. 값을 함께 실어 고르기 전에 견줄 수 있게 한다. + "region": fuel_meta.get("region", ""), + "region_name": fuel_meta.get("region_name", ""), + "regions": [ + {"code": code, "name": entry["name"], "diesel_krw_per_l": _money(entry["value"])} + for code, entry in sorted(load_regional_fuel_table()[0].items()) + ], "note": ( - "품셈 8-1-7 5호 「유류가격은 해당 지역의 가격」 — 지역 공시가를 고를 수 있게 " - "칸을 두었으나 아직 전국 공시가만 받아 와 있습니다." + "품셈 8-1-7 5호 「유류가격은 해당지역의 가격으로 한다」 — 지역을 고르면 " + "그 시도 공시가로 기계 연료비가 다시 섭니다. 안 고르면 전국 공시가입니다." ), + "region_missing": fuel_meta.get("region_missing", ""), }, } diff --git a/B09_Estimation/B09_Estimation_MachineOperating.py b/B09_Estimation/B09_Estimation_MachineOperating.py index 05d9d3f8..130dc416 100644 --- a/B09_Estimation/B09_Estimation_MachineOperating.py +++ b/B09_Estimation/B09_Estimation_MachineOperating.py @@ -289,20 +289,72 @@ def write_operating_records( return path -def load_fuel_price(oil_file: str = "oil_2026-08-14.json") -> tuple[Decimal, dict[str, str]]: - """경유 단가와 그 판의 신원. +#: 시도별 유가 판 — 품셈 8-1-7 5호 「유류가격은 **해당지역의 가격**으로 한다」. +#: ⚠ **파일이 있을 때만 지역을 고를 수 있다** — 없으면 전국평균 한 벌로 돈다(코드로 막지 않음). +REGIONAL_OIL_FILE = "oil_regional_2026-09-09.json" - ⚠ **전국평균이다.** 품셈 8-1-7 5호는 「유류가격은 **해당 지역의 가격**」이라 - 규정하므로 나중에 현장 소재지 값으로 갈아끼울 자리다 (TODO 미결 PLAN 9-6). - 지역 파라미터 자리만 뚫어 두고 지금은 전국평균을 잠정으로 쓴다. + +def load_regional_fuel_table(oil_file: str = REGIONAL_OIL_FILE) -> tuple[dict[str, dict], dict]: + """(시도코드 → {이름·값}, 판 신원). 판이 없으면 **빈 표**를 돌려준다. + + ⚠ 원문에 있는 코드 `00`(전국)은 **지역 선택지에서 뺀다** — 그 자리는 전국평균 판이 + 맡고, 두 판이 같은 이름으로 서면 어느 값으로 섰는지 못 가린다. """ - payload = _read_json(*_CATALOG_SUBPATH, oil_file) + try: + payload = _read_json(*_CATALOG_SUBPATH, oil_file) + except FileNotFoundError: + return {}, {} diesel = payload["variables"]["oil_diesel"] - return Decimal(str(diesel["value"])), { + table = { + str(record["sido_code"]): { + "name": str(record["sido_name"]), + "value": Decimal(str(record["value"])), + } + for record in diesel.get("records", []) + if str(record["sido_code"]) != "00" + } + meta = { "dataset_id": payload.get("dataset_id", ""), "effective_date": payload.get("effective_date", ""), "scope": diesel.get("scope", ""), } + return table, meta + + +def load_fuel_price( + oil_file: str = "oil_2026-08-14.json", region: str | None = None +) -> tuple[Decimal, dict[str, str]]: + """경유 단가와 그 판의 신원. `region`(시도코드)을 주면 **그 지역 값**으로 선다. + + 품셈 8-1-7 5호가 「유류가격은 **해당 지역의 가격**」이라 규정한다. + ⚠ **안 주면 전국평균**이다 — 현장 소재지를 임의로 찍지 않는다(프로젝트가 고름). + ⚠ 준 지역이 판에 없으면 **조용히 전국평균으로 눕지 않고** 그 사실을 신원에 적는다. + """ + payload = _read_json(*_CATALOG_SUBPATH, oil_file) + diesel = payload["variables"]["oil_diesel"] + meta = { + "dataset_id": payload.get("dataset_id", ""), + "effective_date": payload.get("effective_date", ""), + "scope": diesel.get("scope", ""), + "region": "", + "region_name": "", + } + if not region: + return Decimal(str(diesel["value"])), meta + + table, region_meta = load_regional_fuel_table() + picked = table.get(str(region)) + if picked is None: + meta["region"] = str(region) + meta["region_missing"] = "그 지역 값이 판에 없어 전국평균으로 섰습니다" + return Decimal(str(diesel["value"])), meta + return picked["value"], { + "dataset_id": region_meta.get("dataset_id", ""), + "effective_date": region_meta.get("effective_date", ""), + "scope": region_meta.get("scope", ""), + "region": str(region), + "region_name": picked["name"], + } def load_operator_wages(labor_file: str = "labor_const_2026-01-01.json") -> dict[str, Decimal]: @@ -342,7 +394,7 @@ def hourly_cost_of(machine_code: str, *, region: str | None = None): """기종 하나의 **시간당 사용료 3분할**을 완성해 돌려준다. 재료비 = 주연료 × 유가 + 잡재료(주연료의 %) / 노무비 = 조종원 일당 ÷ 8시간 / - 경비 = 손료. `region` 은 유가 지역값 자리 — 지금은 전국평균만 있어 무시된다. + 경비 = 손료. `region`(시도코드)을 주면 **그 지역 유가**로 선다(품셈 8-1-7 5호). """ from B09_Estimation.B09_Estimation_MachineCost import hourly_machine_cost, load_machine_catalog @@ -353,7 +405,7 @@ def hourly_cost_of(machine_code: str, *, region: str | None = None): if record is None: return hourly_machine_cost(machine) - fuel_price, _ = load_fuel_price() + fuel_price, _ = load_fuel_price(region=region) wages = load_operator_wages() liters = record.fuel_liters_per_hour diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index aef30aa5..5e83ee94 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -248,7 +248,13 @@ async def _build_for(project_id: UUID): sorted((str(k), str(v)) for k, v in (settings.get("machine_choices") or {}).items()) ) # 공구손료·잡재료 — **비어 있는 것이 기본**이라 안 넣으면 줄이 안 선다(확정 5차 작은 것 1). - return cached_build(ranges, machines, str(settings.get("misc_material_percent") or "")) + # 유가 지역 — 안 고르면 전국평균(품셈 8-1-7 5호 「해당지역의 가격」). + return cached_build( + ranges, + machines, + str(settings.get("misc_material_percent") or ""), + str(settings.get("fuel_region") or ""), + ) @router.get("/{project_id}/estimation/base-data") @@ -284,13 +290,19 @@ async def get_price_sources(project_id: UUID) -> JSONResponse: material_price_comparison, ) + from common_util.common_util_project_settings import estimation_settings + try: build = await _build_for(project_id) + root = await _project_root_of(project_id) + settings = estimation_settings(root) if root else {} return JSONResponse( content={ "status": "success", "material_comparison": material_price_comparison(build), - "base_reference": base_reference_data(build), + "base_reference": base_reference_data( + build, str(settings.get("fuel_region") or "") or None + ), } ) except Exception: @@ -432,6 +444,8 @@ class FactorChoiceBody(BaseModel): machine_choices: dict[str, str] | None = None #: 공구손료·잡재료 비율 — **빈 문자열이면 안 붙는다**(칸을 도로 비우는 길). misc_material_percent: str | None = None + #: 유가 지역(시도코드) — **빈 문자열이면 전국평균**으로 돌아간다. + fuel_region: str | None = None @router.put("/{project_id}/estimation/factors") @@ -470,6 +484,21 @@ async def put_factor_choices(project_id: UUID, body: FactorChoiceBody) -> JSONRe # 금액이 어긋난 채로 선다. return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)}) values["misc_material_percent"] = "" if percent is None else str(percent) + if body.fuel_region is not None: + from B09_Estimation.B09_Estimation_MachineOperating import load_regional_fuel_table + + region = str(body.fuel_region).strip() + table, _ = load_regional_fuel_table() + if region and region not in table: + # ⚠ 판에 없는 지역을 받아 두면 조용히 전국평균으로 서고 사용자는 지역값인 줄 안다. + return JSONResponse( + status_code=400, + content={ + "status": "error", + "message": f"유가 판에 없는 지역입니다: {region}", + }, + ) + values["fuel_region"] = region try: save_section(root, "estimation", values, replace_keys=tuple(values)) return JSONResponse(content={"status": "success", **values}) diff --git a/B09_Estimation/B09_Estimation_UI_BaseData.ts b/B09_Estimation/B09_Estimation_UI_BaseData.ts index 88d3fe49..310e7326 100644 --- a/B09_Estimation/B09_Estimation_UI_BaseData.ts +++ b/B09_Estimation/B09_Estimation_UI_BaseData.ts @@ -254,6 +254,11 @@ export interface PriceSourcesDto { effective_date: string; dataset_id: string; scopes: FuelScope[]; + /** 고를 수 있는 시도 — 판에 있는 것만. 값을 함께 실어 고르기 전에 견줄 수 있다. */ + regions: Array<{ code: string; name: string; diesel_krw_per_l: string | null }>; + region: string; + region_name: string; + region_missing?: string; note: string; }; }; @@ -346,7 +351,12 @@ function comparisonTable(slotNames: string[], rows: MaterialComparisonRow[]): HT } /** 환율및기초자료 — 실무 시트 세 구획(환율·인건비·단가 및 재료비)을 차례대로. */ -function baseReferenceSections(body: HTMLElement, data: PriceSourcesDto["base_reference"]): void { +function baseReferenceSections( + body: HTMLElement, + data: PriceSourcesDto["base_reference"], + projectId: string, + reload: () => void, +): void { body.append(head("환율및기초자료 — ① 환율")); body.append(note(data.exchange.note)); @@ -397,32 +407,61 @@ function baseReferenceSections(body: HTMLElement, data: PriceSourcesDto["base_re // ⚠ 확정 ⑮ — 전국/지역을 고르는 칸. 자료가 없는 것은 **고를 수 없게** 두고 // 까닭을 곧바로 밝힌다. 고르게만 해 두고 값이 없으면 조용히 틀린 값이 선다. + // ⚠ 시도가 들어온 뒤로는 **한 칸에서 전국과 시도를 함께** 고른다 — 범위 칸과 시도 칸을 + // 따로 두면 「지역인데 시도를 안 고른 상태」가 생겨 무슨 값으로 섰는지 흐려진다. const picker = document.createElement("div"); picker.className = "b09-hint"; picker.style.display = "flex"; picker.style.alignItems = "center"; picker.style.gap = "8px"; + picker.style.flexWrap = "wrap"; const label = document.createElement("span"); label.textContent = "유가 적용 범위"; const select = document.createElement("select"); - for (const scope of fuel.scopes) { + const national = document.createElement("option"); + national.value = ""; + national.textContent = "전국 공시가"; + national.selected = !fuel.region; + select.append(national); + for (const region of fuel.regions) { const option = document.createElement("option"); - option.value = scope.key; - option.textContent = scope.available ? scope.label : `${scope.label} (자료 없음)`; - option.disabled = !scope.available; - option.selected = scope.key === fuel.scope; + option.value = region.code; + option.textContent = `${region.name} ${region.diesel_krw_per_l ?? ""}원/L`; + option.selected = region.code === fuel.region; select.append(option); } + select.disabled = fuel.regions.length === 0; + select.addEventListener("change", () => { + void saveFactorChoices(projectId, { fuel_region: select.value }) + .then(reload) + .catch((error: Error) => body.append(note(`⚠ ${error.message}`))); + }); picker.append(label, select); body.append(picker); - for (const scope of fuel.scopes) { - if (!scope.available && scope.why) body.append(note(`⚠ ${scope.label}: ${scope.why}`)); + if (fuel.regions.length === 0) { + for (const scope of fuel.scopes) { + if (!scope.available && scope.why) body.append(note(`⚠ ${scope.label}: ${scope.why}`)); + } + } else { + body.append( + note( + fuel.region + ? `${fuel.region_name} 공시가로 서 있습니다 — 기계 연료비가 그 값으로 다시 섭니다.` + : "전국 공시가로 서 있습니다 — 현장 시도를 고르면 그 지역 값으로 바뀝니다.", + ), + ); } + if (fuel.region_missing) body.append(note(`⚠ ${fuel.region_missing}`)); body.append(note(fuel.note)); } /** 기초자료 탭 아래쪽 — A9·A10 두 장. */ -export function drawPriceSourcesSections(body: HTMLElement, data: PriceSourcesDto): void { +export function drawPriceSourcesSections( + body: HTMLElement, + data: PriceSourcesDto, + projectId: string, + reload: () => void, +): void { const comparison = data.material_comparison; body.append(head(`자재단가대비표 (${comparison.rows.length})`)); if (comparison.rows.length <= 1) { @@ -439,7 +478,7 @@ export function drawPriceSourcesSections(body: HTMLElement, data: PriceSourcesDt } for (const text of comparison.notes) body.append(note(text)); - baseReferenceSections(body, data.base_reference); + baseReferenceSections(body, data.base_reference, projectId, reload); } /** 두 표를 아직 못 받아왔을 때 — 화면을 비우지 않는다. */ @@ -520,6 +559,7 @@ export async function saveFactorChoices( range_factor_choices?: Record; machine_choices?: Record; misc_material_percent?: string; + fuel_region?: string; }, ): Promise { const response = await fetch( diff --git a/B09_Estimation/B09_Estimation_UI_Page.ts b/B09_Estimation/B09_Estimation_UI_Page.ts index 20c89fa0..a8d1dc56 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.ts +++ b/B09_Estimation/B09_Estimation_UI_Page.ts @@ -1213,8 +1213,14 @@ export async function renderB09Estimation(root: HTMLElement): Promise { drawBaseDataTab(body, baseData); // 자재단가대비표·환율및기초자료는 **따로 받아 온다** — 목록표 넷이 먼저 서고 // 두 표가 뒤따라 붙는다. 안 붙으면 위 넷도 못 보게 되는 것을 막는다. - if (priceSources) { - drawPriceSourcesSections(body, priceSources); + if (priceSources && projectId) { + drawPriceSourcesSections(body, priceSources, projectId, () => { + // 유가 지역을 바꾸면 **기계 연료비가 다시 서므로** 목록표까지 함께 새로 받는다. + factorChoices = null; + baseData = null; + priceSources = null; + drawBody(); + }); return; } drawPriceSourcesPending(body); diff --git a/B09_Estimation/B09_Estimation_UnitPrice.py b/B09_Estimation/B09_Estimation_UnitPrice.py index c9e43d18..9b498c78 100644 --- a/B09_Estimation/B09_Estimation_UnitPrice.py +++ b/B09_Estimation/B09_Estimation_UnitPrice.py @@ -229,15 +229,20 @@ def _apply_combined_misc_rate(book: PriceBook, work_item_titles: list[str]) -> i return swapped -def _add_machine_layers(book: PriceBook, machine_codes: set[str]) -> list[str]: +def _add_machine_layers( + book: PriceBook, machine_codes: set[str], fuel_region: str | None = None +) -> list[str]: """`S`(취득가) · `L`(운전사) · `M`(연료) 을 세우고 그 위에 `X` 를 올린다. 시간당 사용료를 **미리 계산해 넣지 않는다** — 층을 실제로 쌓아야 화면이 「무엇으로 이루어졌나」를 보일 수 있다(PLAN 8-13 계산 과정을 감추지 않음). + + ⚠ `fuel_region`(시도코드) — 품셈 8-1-7 5호 「유류가격은 **해당지역의 가격**」. + 안 주면 전국평균이다(현장 소재지를 임의로 찍지 않음). """ catalog = load_machine_catalog() operating = {r.machine_code: r for r in load_operating_records().records} - fuel_price, _ = load_fuel_price() + fuel_price, fuel_meta = load_fuel_price(region=fuel_region) wages = load_operator_wages() incomplete: list[str] = [] @@ -248,6 +253,13 @@ def _add_machine_layers(book: PriceBook, machine_codes: set[str]) -> list[str]: code=fuel_code, kind=PriceKind.MATERIAL, name="경유", + # ⚠ 어느 판으로 섰는지 **줄에 남긴다** — 지역 값과 전국평균은 리터당 + # 수십 원이 갈려 단가가 조용히 달라지는 자리다. + spec=( + f"{fuel_meta.get('region_name')} 공시가" + if fuel_meta.get("region_name") + else "전국 공시가" + ), unit="L", slots=_slots(fuel_price), ) @@ -523,6 +535,7 @@ def build_unit_prices( factor_choices: dict[tuple[str, str], Decimal] | None = None, machine_picks: dict[str, str] | None = None, misc_material_percent: Decimal | None = None, + fuel_region: str | None = None, ) -> UnitPriceBuild: """자원 축을 일위대가(`B`)로 조립한다. @@ -534,6 +547,8 @@ def build_unit_prices( ⚠ `misc_material_percent` — 공구손료·잡재료(산림품셈 1-2-6). **안 주면 줄이 안 선다** — 사용자 확정 5차 작은 것 1 「지금은 안 넣되 숫자 넣으면 되게 열어 둘 것」 그대로다. + + ⚠ `fuel_region` — 유가 시도코드(품셈 8-1-7 5호). **안 주면 전국평균**이다. """ from B09_Estimation.B09_Estimation_FactorChoices import ( chosen_values, @@ -637,7 +652,7 @@ def build_unit_prices( # 공식표에만 나오는 기종도 사용료 층을 세운다 — 안 세우면 공식이 붙을 데가 없다. machine_codes |= formula_machine_codes(master) machine_codes |= {row["machine_code"] for rows in capacity_rows.values() for row in rows} - build.incomplete_machines = _add_machine_layers(build.book, machine_codes) + build.incomplete_machines = _add_machine_layers(build.book, machine_codes, fuel_region) # 규격 갈래(`variant`)가 있으면 **갈래마다 따로 세운다** — 무근·철근·소형구조물은 # 품이 달라 한 일위대가로 뭉치면 어느 것도 안 맞는다. @@ -946,6 +961,7 @@ def cached_build( range_choices: tuple[tuple[str, str], ...] = (), machine_picks: tuple[tuple[str, str], ...] = (), misc_material_percent: str = "", + fuel_region: str = "", ) -> UnitPriceBuild: """조립 결과를 한 번만 만든다 — 품셈 3 MB 를 요청마다 다시 읽지 않는다. @@ -968,6 +984,7 @@ def cached_build( factor_choices=chosen_values(scan_range_factors(master), settings), machine_picks=machine_choices(settings), misc_material_percent=parse_misc_material_percent(misc_material_percent), + fuel_region=fuel_region or None, ) diff --git a/resources/data_cost_input_value/_manifest.json b/resources/data_cost_input_value/_manifest.json index 094f7c59..8c9ab0c9 100644 --- a/resources/data_cost_input_value/_manifest.json +++ b/resources/data_cost_input_value/_manifest.json @@ -58,6 +58,13 @@ "sha256": "c2d9f95ff5d6c8a6624298c2f2d43b581a7e5bbc3be64a5af15c763b804e0a7a", "size_bytes": 879 }, + { + "file": "oil_regional_2026-09-09.json", + "dataset_id": "oil_regional", + "effective_date": "2026-09-09", + "sha256": "f9c1ce162b326b324604a2681a6078cccbc11624bbdbab6f0791e4368140b659", + "size_bytes": 5249 + }, { "file": "pum_const_2026.json", "dataset_id": "pum_const", diff --git a/resources/data_cost_input_value/oil_regional_2026-09-09.json b/resources/data_cost_input_value/oil_regional_2026-09-09.json new file mode 100644 index 00000000..a2cc2352 --- /dev/null +++ b/resources/data_cost_input_value/oil_regional_2026-09-09.json @@ -0,0 +1,204 @@ +{ + "schema_version": "1.0", + "dataset_id": "oil_regional", + "effective_date": "2026-09-09", + "generated_at": "2026-09-09T21:11:38+09:00", + "note": "시도별 유가 — 품셈 8-1-7 5호 「유류가격은 해당지역의 가격으로 한다」. 코드 00(전국)은 원문 그대로 두되 지역 선택지에서는 전국평균 판이 맡는다. ⚠ 원천이 시도를 17줄로 주는데 그 가름이 행정구역과 다르다 — 코드 20 이 「전남광주」 한 줄이고 07(전남)·16(광주)은 아예 없다. 원문 이름을 그대로 두고 우리가 쪼개지 않는다.", + "sources": [ + { + "path": "resources/knowledge/original/원가계산/유가_오피넷/유가_시도별_2026-09-09.json", + "sha256": "6f1b58101fbe57be402d44582763ca1d54aea8d9bb5ee9b85b38bdeea2d4e439", + "role": "primary" + } + ], + "variables": { + "oil_gasoline": { + "unit": "KRW/L", + "scope": "regional", + "date": "2026-09-09", + "source_product_code": "B027", + "source_product_name": "휘발유", + "records": [ + { + "sido_code": "00", + "sido_name": "전국", + "value": 1858.93 + }, + { + "sido_code": "01", + "sido_name": "서울", + "value": 1905.02 + }, + { + "sido_code": "02", + "sido_name": "경기", + "value": 1855.78 + }, + { + "sido_code": "03", + "sido_name": "강원", + "value": 1870.23 + }, + { + "sido_code": "04", + "sido_name": "충북", + "value": 1866.12 + }, + { + "sido_code": "05", + "sido_name": "충남", + "value": 1864.78 + }, + { + "sido_code": "06", + "sido_name": "전북", + "value": 1859.7 + }, + { + "sido_code": "08", + "sido_name": "경북", + "value": 1852.71 + }, + { + "sido_code": "09", + "sido_name": "경남", + "value": 1854.71 + }, + { + "sido_code": "10", + "sido_name": "부산", + "value": 1842.66 + }, + { + "sido_code": "11", + "sido_name": "제주", + "value": 1889.27 + }, + { + "sido_code": "14", + "sido_name": "대구", + "value": 1831.78 + }, + { + "sido_code": "15", + "sido_name": "인천", + "value": 1845.64 + }, + { + "sido_code": "17", + "sido_name": "대전", + "value": 1836.79 + }, + { + "sido_code": "18", + "sido_name": "울산", + "value": 1838.49 + }, + { + "sido_code": "19", + "sido_name": "세종", + "value": 1855.8 + }, + { + "sido_code": "20", + "sido_name": "전남광주", + "value": 1862.28 + } + ] + }, + "oil_diesel": { + "unit": "KRW/L", + "scope": "regional", + "date": "2026-09-09", + "source_product_code": "D047", + "source_product_name": "자동차용경유", + "records": [ + { + "sido_code": "00", + "sido_name": "전국", + "value": 1843.79 + }, + { + "sido_code": "01", + "sido_name": "서울", + "value": 1886.15 + }, + { + "sido_code": "02", + "sido_name": "경기", + "value": 1838.22 + }, + { + "sido_code": "03", + "sido_name": "강원", + "value": 1859.9 + }, + { + "sido_code": "04", + "sido_name": "충북", + "value": 1851.55 + }, + { + "sido_code": "05", + "sido_name": "충남", + "value": 1850.08 + }, + { + "sido_code": "06", + "sido_name": "전북", + "value": 1846.86 + }, + { + "sido_code": "08", + "sido_name": "경북", + "value": 1838.1 + }, + { + "sido_code": "09", + "sido_name": "경남", + "value": 1837.71 + }, + { + "sido_code": "10", + "sido_name": "부산", + "value": 1825.55 + }, + { + "sido_code": "11", + "sido_name": "제주", + "value": 1869.16 + }, + { + "sido_code": "14", + "sido_name": "대구", + "value": 1815.72 + }, + { + "sido_code": "15", + "sido_name": "인천", + "value": 1829.14 + }, + { + "sido_code": "17", + "sido_name": "대전", + "value": 1824.58 + }, + { + "sido_code": "18", + "sido_name": "울산", + "value": 1826.63 + }, + { + "sido_code": "19", + "sido_name": "세종", + "value": 1841.72 + }, + { + "sido_code": "20", + "sido_name": "전남광주", + "value": 1850.47 + } + ] + } + } +} diff --git a/resources/knowledge/original/_pipeline/collect_cost_sources.py b/resources/knowledge/original/_pipeline/collect_cost_sources.py index c5163019..15e94f02 100644 --- a/resources/knowledge/original/_pipeline/collect_cost_sources.py +++ b/resources/knowledge/original/_pipeline/collect_cost_sources.py @@ -8,7 +8,7 @@ 4. 자재단가 스냅샷 : 나라장터 가격정보현황서비스 API → 자재단가/ (CSV+JSON, 수집일 파일명) 5. 환율 스냅샷 : 한국은행 ECOS API 731Y001 → 환율_한국은행ECOS/ (CSV, 연초~수집일 일별) - 6. 유가 스냅샷 : 오피넷 API → 유가_오피넷/ (JSON, 수집일) + 6. 유가 스냅샷 : 오피넷 API → 유가_오피넷/ (JSON, 수집일 — 전국평균·시도별 두 벌) 사용법: python collect_cost_sources.py docs # 1~3 문서 다운로드 (게시판 최신글 자동 탐색은 미구현 — @@ -171,6 +171,20 @@ def collect_values(): out.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8") print(f"saved {out.name}") + # 유가: 시도별 (품셈 8-1-7 5호 「유류가격은 해당지역의 가격」 — 지역 단가의 원천) + sido = {"수집일": today, "시도별": {}} + for prod in ["B027", "D047"]: # 휘발유, 자동차용경유 + j = json.loads( + fetch( + f"https://www.opinet.co.kr/api/avgSidoPrice.do?out=json&code={opinet}&prodcd={prod}", + timeout=60, + ) + ) + sido["시도별"][prod] = j.get("RESULT", {}).get("OIL", []) + out = BASE / "유가_오피넷" / f"유가_시도별_{today}.json" + out.write_text(json.dumps(sido, ensure_ascii=False, indent=1), encoding="utf-8") + print(f"saved {out.name}") + def collect_snapshot(outdir=None): key = read_g2b_key() diff --git a/resources/knowledge/original/원가계산/유가_오피넷/_meta.md b/resources/knowledge/original/원가계산/유가_오피넷/_meta.md index f182f43d..2516bfaf 100644 --- a/resources/knowledge/original/원가계산/유가_오피넷/_meta.md +++ b/resources/knowledge/original/원가계산/유가_오피넷/_meta.md @@ -9,6 +9,11 @@ | 파일 | 내용 | 수집일 | |---|---|---| | 유가_전국평균_2026-08-14.json | 전국 평균 판매가 (휘발유·경유 등 5유종) | 2026-08-14 | +| 유가_시도별_2026-09-09.json | 시도별 판매가 (`avgSidoPrice.do` — 휘발유 B027·경유 D047) | 2026-09-09 | + +⚠ **시도 가름이 행정구역과 다르다** — 원천이 17줄을 주는데 `00` 이 전국이고 `20` 이 +「전남광주」 한 줄이며 `07`(전남)·`16`(광주)은 아예 없다. **원문 이름·코드를 그대로 두고 +우리가 쪼개지 않는다.** ## 원천 현황 @@ -17,7 +22,7 @@ | 원천 | 한국석유공사 오피넷 무료 OpenAPI (`www.opinet.co.kr`) | | 갱신 주기 | **주간** (주유소 판매가 기준) | | 수집 주기 | 견적 기준일 확정 시점마다 — 내역서에 **기준일 명기** 필요 | -| 지역 파라미터 | 품셈 8-1-7의 5 "유류가격은 **해당지역의 가격**으로 한다" — 지역별 조회 지원(현재 스냅샷은 전국 평균) | +| 지역 파라미터 | 품셈 8-1-7의 5 "유류가격은 **해당지역의 가격**으로 한다" — 2026-09-09 시도별 스냅샷 수집(전국 평균 판과 **두 벌**로 보존) | | 수집 범위 | 휘발유·경유 중심. **선박용경유·중유 제외** (2026-08-14 사용자 결정, 필요 시 재편입) | ## 사용처 diff --git a/resources/knowledge/original/원가계산/유가_오피넷/유가_시도별_2026-09-09.json b/resources/knowledge/original/원가계산/유가_오피넷/유가_시도별_2026-09-09.json new file mode 100644 index 00000000..bee3305c --- /dev/null +++ b/resources/knowledge/original/원가계산/유가_오피넷/유가_시도별_2026-09-09.json @@ -0,0 +1,247 @@ +{ + "수집일": "2026-09-09", + "시도별": { + "B027": [ + { + "SIDOCD": "00", + "SIDONM": "전국", + "PRODCD": "B027", + "PRICE": 1858.93, + "DIFF": 1858.93 + }, + { + "SIDOCD": "01", + "SIDONM": "서울", + "PRODCD": "B027", + "PRICE": 1905.02, + "DIFF": -0.43 + }, + { + "SIDOCD": "02", + "SIDONM": "경기", + "PRODCD": "B027", + "PRICE": 1855.78, + "DIFF": -0.45 + }, + { + "SIDOCD": "03", + "SIDONM": "강원", + "PRODCD": "B027", + "PRICE": 1870.23, + "DIFF": -0.03 + }, + { + "SIDOCD": "04", + "SIDONM": "충북", + "PRODCD": "B027", + "PRICE": 1866.12, + "DIFF": -0.2 + }, + { + "SIDOCD": "05", + "SIDONM": "충남", + "PRODCD": "B027", + "PRICE": 1864.78, + "DIFF": -0.57 + }, + { + "SIDOCD": "06", + "SIDONM": "전북", + "PRODCD": "B027", + "PRICE": 1859.7, + "DIFF": 0.33 + }, + { + "SIDOCD": "08", + "SIDONM": "경북", + "PRODCD": "B027", + "PRICE": 1852.71, + "DIFF": -0.05 + }, + { + "SIDOCD": "09", + "SIDONM": "경남", + "PRODCD": "B027", + "PRICE": 1854.71, + "DIFF": -0.11 + }, + { + "SIDOCD": "10", + "SIDONM": "부산", + "PRODCD": "B027", + "PRICE": 1842.66, + "DIFF": -0.25 + }, + { + "SIDOCD": "11", + "SIDONM": "제주", + "PRODCD": "B027", + "PRICE": 1889.27, + "DIFF": 0.05 + }, + { + "SIDOCD": "14", + "SIDONM": "대구", + "PRODCD": "B027", + "PRICE": 1831.78, + "DIFF": -0.07 + }, + { + "SIDOCD": "15", + "SIDONM": "인천", + "PRODCD": "B027", + "PRICE": 1845.64, + "DIFF": 0.09 + }, + { + "SIDOCD": "17", + "SIDONM": "대전", + "PRODCD": "B027", + "PRICE": 1836.79, + "DIFF": -1.07 + }, + { + "SIDOCD": "18", + "SIDONM": "울산", + "PRODCD": "B027", + "PRICE": 1838.49, + "DIFF": -0.01 + }, + { + "SIDOCD": "19", + "SIDONM": "세종", + "PRODCD": "B027", + "PRICE": 1855.8, + "DIFF": 0.16 + }, + { + "SIDOCD": "20", + "SIDONM": "전남광주", + "PRODCD": "B027", + "PRICE": 1862.28, + "DIFF": -0.64 + } + ], + "D047": [ + { + "SIDOCD": "00", + "SIDONM": "전국", + "PRODCD": "D047", + "PRICE": 1843.79, + "DIFF": 1843.79 + }, + { + "SIDOCD": "01", + "SIDONM": "서울", + "PRODCD": "D047", + "PRICE": 1886.15, + "DIFF": -0.21 + }, + { + "SIDOCD": "02", + "SIDONM": "경기", + "PRODCD": "D047", + "PRICE": 1838.22, + "DIFF": -0.45 + }, + { + "SIDOCD": "03", + "SIDONM": "강원", + "PRODCD": "D047", + "PRICE": 1859.9, + "DIFF": -0.09 + }, + { + "SIDOCD": "04", + "SIDONM": "충북", + "PRODCD": "D047", + "PRICE": 1851.55, + "DIFF": -0.07 + }, + { + "SIDOCD": "05", + "SIDONM": "충남", + "PRODCD": "D047", + "PRICE": 1850.08, + "DIFF": -0.32 + }, + { + "SIDOCD": "06", + "SIDONM": "전북", + "PRODCD": "D047", + "PRICE": 1846.86, + "DIFF": 0.39 + }, + { + "SIDOCD": "08", + "SIDONM": "경북", + "PRODCD": "D047", + "PRICE": 1838.1, + "DIFF": -0.58 + }, + { + "SIDOCD": "09", + "SIDONM": "경남", + "PRODCD": "D047", + "PRICE": 1837.71, + "DIFF": -0.16 + }, + { + "SIDOCD": "10", + "SIDONM": "부산", + "PRODCD": "D047", + "PRICE": 1825.55, + "DIFF": -0.16 + }, + { + "SIDOCD": "11", + "SIDONM": "제주", + "PRODCD": "D047", + "PRICE": 1869.16, + "DIFF": 0.02 + }, + { + "SIDOCD": "14", + "SIDONM": "대구", + "PRODCD": "D047", + "PRICE": 1815.72, + "DIFF": -0.19 + }, + { + "SIDOCD": "15", + "SIDONM": "인천", + "PRODCD": "D047", + "PRICE": 1829.14, + "DIFF": -1.55 + }, + { + "SIDOCD": "17", + "SIDONM": "대전", + "PRODCD": "D047", + "PRICE": 1824.58, + "DIFF": 0.12 + }, + { + "SIDOCD": "18", + "SIDONM": "울산", + "PRODCD": "D047", + "PRICE": 1826.63, + "DIFF": -0.29 + }, + { + "SIDOCD": "19", + "SIDONM": "세종", + "PRODCD": "D047", + "PRICE": 1841.72, + "DIFF": -0.94 + }, + { + "SIDOCD": "20", + "SIDONM": "전남광주", + "PRODCD": "D047", + "PRICE": 1850.47, + "DIFF": -0.68 + } + ] + } +} \ No newline at end of file diff --git a/resources/tester/test_b09_fuel_region.py b/resources/tester/test_b09_fuel_region.py new file mode 100644 index 00000000..619d921e --- /dev/null +++ b/resources/tester/test_b09_fuel_region.py @@ -0,0 +1,86 @@ +"""유가 지역 단가 — 품셈 8-1-7 5호 「유류가격은 **해당지역의 가격**으로 한다」 (2026-09-09). + +칸은 전부터 있었는데 **값이 없어 잠겨 있던** 자리다. 오피넷 시도별 판을 받아 물렸다. + +⚠ 겨누는 것 다섯 + ① 안 고르면 **전국평균 그대로** — 현장 소재지를 임의로 찍지 않음 + ② 고르면 **그 시도 값**으로 서고, 기계 시간당 사용료가 함께 움직임 + ③ 판에 없는 지역은 **조용히 전국평균으로 눕지 않고** 그 사실을 신원에 적음 + ④ 코드 `00`(전국)은 지역 선택지에서 뺌 — 두 판이 같은 이름으로 서면 못 가림 + ⑤ 「지역 공시가」를 고를 수 있는지는 **코드가 아니라 판이 정함** +""" + +from __future__ import annotations + +import sys +from decimal import Decimal +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B09_Estimation.B09_Estimation_Lists_Sources import fuel_scopes # noqa: E402 +from B09_Estimation.B09_Estimation_MachineOperating import ( # noqa: E402 + load_fuel_price, + load_regional_fuel_table, +) +from B09_Estimation.B09_Estimation_PriceBook import PriceKind # noqa: E402 +from B09_Estimation.B09_Estimation_UnitPrice import cached_build # noqa: E402 + + +def test_안_고르면_전국평균() -> None: + price, meta = load_fuel_price() + assert meta["scope"] == "national_average" + assert meta["region"] == "" and meta["region_name"] == "" + assert price > 0 + + +def test_고르면_그_시도_값으로_선다() -> None: + table, _ = load_regional_fuel_table() + code = next(iter(sorted(table))) + price, meta = load_fuel_price(region=code) + assert price == table[code]["value"] + assert meta["scope"] == "regional" and meta["region_name"] == table[code]["name"] + + +def test_판에_없는_지역은_사실을_적는다() -> None: + """⚠ 조용히 전국평균으로 누우면 사용자는 지역값인 줄 안다.""" + national, _ = load_fuel_price() + price, meta = load_fuel_price(region="ZZ") + assert price == national + assert "판에 없어" in meta["region_missing"] + + +def test_전국_줄은_지역_선택지에서_뺀다() -> None: + table, _ = load_regional_fuel_table() + assert "00" not in table + assert len(table) >= 10 + + +def test_고를_수_있는지는_판이_정한다() -> None: + scopes = {s["key"]: s for s in fuel_scopes()} + assert scopes["national_average"]["available"] is True + # 판이 들어와 있으므로 지역도 열려 있어야 한다. + assert scopes["regional"]["available"] is bool(load_regional_fuel_table()[0]) + + +def test_기계_시간당_사용료가_지역을_따라_움직인다() -> None: + """② 값이 실제로 갈리는지 — 안 갈리면 칸만 있고 뜻이 없다.""" + table, _ = load_regional_fuel_table() + # 전국평균과 값이 다른 시도를 골라 견준다. + national, _ = load_fuel_price() + code = next(c for c, v in sorted(table.items()) if v["value"] != national) + + base = cached_build() + picked = cached_build((), (), "", code) + machine = next( + c for c, t in sorted(base.book.titles.items()) if t.kind is PriceKind.MACHINE_HOURLY + ) + diff = picked.book.resolve(machine).total - base.book.resolve(machine).total + assert diff != Decimal(0) + + fuel_title = next( + t for t in picked.book.titles.values() if t.kind is PriceKind.MATERIAL and t.name == "경유" + ) + # 어느 판으로 섰는지 줄에 남아야 한다. + assert table[code]["name"] in fuel_title.spec