feat(B09): 유가 지역 단가를 물림 — 시도를 고르면 그 값으로 기계 연료비가 다시 섬

품셈 8-1-7 5호 「유류가격은 해당지역의 가격으로 한다」. 칸은 있었으나 시도별 값이
없어 잠겨 있었음. 오피넷 avgSidoPrice.do 스냅샷을 받아 데이터셋으로 세우고 물림.

- 안 고르면 전국평균 그대로 — 현장 소재지를 임의로 찍지 않음.
- 판에 없는 지역은 조용히 전국평균으로 눕지 않고 사유를 남김.
- 「지역 공시가」를 고를 수 있는지는 코드가 아니라 판이 정함.
- ⚠ 원천의 시도 가름이 행정구역과 다름(20=전남광주 한 줄, 07·16 없음) — 원문 그대로 둠.
- 수집 스크립트에 시도별 호출을 더함(전국평균과 두 벌로 보존).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-09 21:22:15 +09:00
co-authored by Claude Opus 5
parent 92ba0780cd
commit f39c195b52
12 changed files with 786 additions and 46 deletions
+41 -8
View File
@@ -37,8 +37,28 @@ 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], ...] = (
#: 유가 적용 범위 — 사용자 확정 ⑮. **값이 있는 것만 고를 수 있다.**
#: ⚠ 「지역 공시가」가 고를 수 있는지는 **코드가 아니라 판이 정한다** — 시도별 판이
#: 들어와 있으면 열리고, 없으면 까닭과 함께 닫힌다(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",
@@ -49,7 +69,7 @@ FUEL_SCOPES: tuple[dict[str, Any], ...] = (
"해당 지역의 가격」이라 규정하므로 자료를 받으면 고를 수 있게 됩니다."
),
},
)
)
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", ""),
},
}
@@ -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`(전국)은 **지역 선택지에서 뺀다** — 그 자리는 전국평균 판이
맡고, 두 판이 같은 이름으로 서면 어느 값으로 섰는지 못 가린다.
"""
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
+31 -2
View File
@@ -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})
+48 -8
View File
@@ -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);
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<string, string>;
machine_choices?: Record<string, string>;
misc_material_percent?: string;
fuel_region?: string;
},
): Promise<void> {
const response = await fetch(
+8 -2
View File
@@ -1213,8 +1213,14 @@ export async function renderB09Estimation(root: HTMLElement): Promise<void> {
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);
+20 -3
View File
@@ -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,
)
@@ -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",
@@ -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
}
]
}
}
}
@@ -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()
@@ -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 사용자 결정, 필요 시 재편입) |
## 사용처
@@ -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
}
]
}
}
+86
View File
@@ -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