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:
@@ -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", ""),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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<string, string>;
|
||||
machine_choices?: Record<string, string>;
|
||||
misc_material_percent?: string;
|
||||
fuel_region?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const response = await fetch(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user