- 열 한 벌 = 키 · 구분 · 상세구분 · 이름 · 규격 · 단위 · 상하차 · 조달청 · 물가자료 · 유통물가 · 물가정보 · 거래가격 · 일반가격 · 출처 · 비고 · 면수(material_merge.COLUMNS 한 곳 · check_master 가 씀)
- 「관급」 칸 → 「조달청」(값 0 · EST 가격정보 값 안 씀) · 새 칸 「일반가격」(빈칸 · 시스템 관리자 손 입력 · 빌더가 원문번호로 이어 받음) · 상하차 = 단위 바로 뒤
- 남는 줄 지역 기준 = 전국 › 지역 없음 › 서울 › 서울 권역 › 경기 › 그 밖(값 중간) · 기준 지역은 비고 「통합: 기준 지역 …」(지역 없는 묶음은 안 적음)
- 재료 아닌 줄 삭제(요구 832) — 품질시험 수수료 60(가격정보 출처 「2026(경북)」) · 일반 도로용 블록 5(「~26.10.16」) · 치즐 2 줄은 남김 · 값 없음
- 재료_자재품목 24,092 → 17,692 줄(통합 6,335 · 재료 아닌 줄 65) · 남는 줄 키 = 830-1 키(7565ee787 이 지운 대장 키를 되살려 다시 지음 · 새 키 0) · 남는 줄 물가지 값 바뀐 것 0 · 빌더 두 번 = 같은 파일
- 가장 낮은 값 = 사급 칸(물가지 넷 + 일반가격) — master_material.SLOTS · M02_Elem_Alias.PRICES · M02 PRICE_SLOTS · 조달청 칸 안 봄 · M01 고르기 「관급」 표시 = 조달청 칸 · B09 대비표 「관급」 출처 = M01 조달청 칸(이름 그대로)
- M01 재료 화면 상하차 = 고르기(부품 createSelectField) · 새 줄 기본 「-」 · 로직 고르기 표 · 시험 계산 값 줄에 일반가격
- check_master 틀 · 로직 · 조합 0 · 단위 27 · 배 7(전과 같음) · _틀.md · _갱신절차.md 열 정의
- 시험: 새 test_830_2_material_merge · test_830_1 지움(EST 그대로 상태 = 830-2 로 끝남 · 남는 검사는 830_2 로) · 지역 줄 · 관급 이름 · 관급 칸을 보던 시험 기대값 고침(m01_api · m01_pick · masterdata_check · 814_1 · 815_1 · 827_2 · 827_3 · 750_1 · m02_hopyo_match · m02_labor_doc) · 「시멘트 전국」 검색어 시험 = xfail(830-3 연결 할 일)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
348 lines
14 KiB
Python
348 lines
14 KiB
Python
"""B09 원가 단가 한 벌 · 수요 모음(PLAN 144) — 서버 단독(M01 을 읽는 곳은 여기 하나).
|
||
|
||
원가 단가 = 프로젝트 한 벌 — 원가산출에서 한 번 굳힘(`price_book.json`) ·
|
||
값 = 사본 단가 기준 시점(795-4 · 구조물 지정 때 `단가.json` 자원 줄 · 없는 자원만 지금 M01 +
|
||
`now_keys`) ·
|
||
[단가 갱신] 때만 새로 · 대가 · 산근도 이 단가로 셈(한 직종 · 한 자재 = 한 단가).
|
||
|
||
줄 = 수요 따라(사용자 승인 ① · 10-9) — 엑셀 목록을 기본으로 깔지 않음:
|
||
연결된 사본이 부른 자원 키(`B09_Estimation_Linked` — 143-1 「연결된 사본」 원천 · 없으면 빈 표)
|
||
+ 기계가 부르는 운전사(조종원직종 → 인력 원문번호) · 연료(주연료종류 → 유가 「종류:전국평균」)
|
||
+ B08 총괄자재수량집계표 자재(M01 짝 없음 → 출처 빈칸 · 적용 단가는 사용자 입력)
|
||
· 수량 = 합계 열 · 채집석 공제 줄 거름 · 키가 MT 코드(맞춤 없는 사본 자재)면
|
||
사본 자원 줄에 수량만 얹음 · 파형강관 = 관급 기본
|
||
운전사 시간 계수 = M01 CC000336(시간환산 · 상여계수 · 휴지계수 분자 · 분모 곱).
|
||
연료 지역 = 전국평균(M01 기계경비 로직 지역 첫 값) — 다른 지역 값은 자재 적용 단가를 고쳐 씀.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
from decimal import Decimal
|
||
from typing import Any
|
||
|
||
from B08_Quantity.B08_Quantity_Material_Calc import DEDUCT_KEY
|
||
from common_util.common_util_time import utc_now_text
|
||
from M01_MasterData import M01_MasterData_Store as m01
|
||
|
||
#: 운전사 시간 노임 계수 표(건설품셈 공통 8-1-3 · 건협기술-2230)
|
||
DRIVER_TABLE = "CC000336"
|
||
DRIVER_PARTS = ("시간환산", "상여계수", "휴지계수")
|
||
#: 연료 지역 — M01 기계경비 로직 지역 고르기 첫 값
|
||
FUEL_REGION = "전국평균"
|
||
#: 자재품목 값 열 → 대비표 출처 칸 · 대비표 「관급」 = M01 「조달청」 칸(830-2)
|
||
MT_SOURCES = ("관급", "물가자료", "유통물가", "물가정보", "거래가격")
|
||
MT_COLUMN = {"관급": "조달청"}
|
||
|
||
|
||
def _f(value: Any) -> float | None:
|
||
if isinstance(value, Decimal):
|
||
return float(value)
|
||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||
return None
|
||
return float(value)
|
||
|
||
|
||
def _master():
|
||
return m01.loaded()[1]
|
||
|
||
|
||
def basis(frozen: dict[str, dict] | None = None):
|
||
"""값 원천 — 사본 단가 기준 시점 줄(795-4 · `Linked.frozen_rows`) 먼저 · 없는 키 = 지금 M01."""
|
||
if not frozen:
|
||
return _master()
|
||
from M02_MasterTemplete import M02_Sangeun_Copy as skc
|
||
|
||
return skc.Copy(_master(), skc._rows({"로직": list(frozen.values())}))
|
||
|
||
|
||
def now_keys(frozen: dict[str, dict] | None, keys: list[str]) -> list[str]:
|
||
"""사본 기준 시점에 없는 자원(795-4) — 지금 M01 값으로 넣은 키 · `frozen` None = 안 셈."""
|
||
return [] if frozen is None else [k for k in keys if k not in frozen]
|
||
|
||
|
||
def _row(master, key: str) -> dict[str, Any] | None:
|
||
return master.index.get(key[:2], {}).get(key)
|
||
|
||
|
||
def labor_input(row: dict[str, Any]) -> dict[str, Any]:
|
||
return {
|
||
"key": row["키"],
|
||
"code": str(row.get("원문번호") or ""),
|
||
"name": row.get("이름") or "",
|
||
"spec": row.get("규격") or "",
|
||
"unit": "인",
|
||
"price": _f(row.get("값")),
|
||
"source": row.get("출처") or "",
|
||
}
|
||
|
||
|
||
def machine_input(row: dict[str, Any]) -> dict[str, Any]:
|
||
v = row.get("값") or {}
|
||
return {
|
||
"key": row["키"],
|
||
"code": str(row.get("원문번호") or ""),
|
||
"name": row.get("이름") or "",
|
||
"spec": row.get("규격") or "",
|
||
"price": _f(v.get("가격")),
|
||
"loss": _f(v.get("손료계수")),
|
||
"fuel": v.get("주연료종류") or None,
|
||
"fuel_lph": _f(v.get("주연료")),
|
||
"misc_pct": _f(v.get("잡재료율")),
|
||
"crew": _f(v.get("조종원")),
|
||
"crew_code": str(v["조종원직종"]) if v.get("조종원직종") else None,
|
||
"source": (row.get("출처") or {}).get("가격") or row.get("원문") or "",
|
||
}
|
||
|
||
|
||
def material_input(row: dict[str, Any]) -> dict[str, Any]:
|
||
"""자재품목(MT) · 유가(MO) 줄 → 대비표 입력 줄."""
|
||
if row["키"].startswith("MO"):
|
||
prices = {"유가": _f(row.get("값"))}
|
||
pages = {"유가": row.get("규격") or None}
|
||
origin = "유가"
|
||
spec = f"{row.get('상세구분') or ''} {row.get('규격') or ''}".strip()
|
||
unit = (row.get("단위") or "").replace("원/", "")
|
||
else:
|
||
prices = {s: _f(row.get(MT_COLUMN.get(s, s))) for s in MT_SOURCES}
|
||
pages = {s: (row.get("면수") or {}).get(s) for s in MT_SOURCES if s != "관급"}
|
||
origin = "M01"
|
||
spec = row.get("규격") or ""
|
||
unit = row.get("단위") or ""
|
||
return {
|
||
"key": row["키"],
|
||
"name": row.get("이름") or "",
|
||
"spec": spec,
|
||
"unit": unit,
|
||
"origin": origin,
|
||
"prices": prices,
|
||
"pages": pages,
|
||
"source": row.get("출처") or "",
|
||
}
|
||
|
||
|
||
def driver_factor(master) -> dict[str, Any] | None:
|
||
table = _row(master, DRIVER_TABLE)
|
||
if not table:
|
||
return None
|
||
parts = {r.get("구분"): r for r in table.get("줄") or []}
|
||
if any(p not in parts for p in DRIVER_PARTS):
|
||
return None
|
||
top = bottom = 1
|
||
text = []
|
||
for p in DRIVER_PARTS:
|
||
top *= int(parts[p]["분자"])
|
||
bottom *= int(parts[p]["분모"])
|
||
text.append(f"{parts[p]['분자']}/{parts[p]['분모']}")
|
||
return {"num": top, "den": bottom, "text": " × ".join(text)}
|
||
|
||
|
||
def fx_inputs(master) -> list[dict[str, Any]]:
|
||
return [
|
||
{"key": key, "name": row.get("이름") or "", "unit": row.get("단위") or "",
|
||
"value": _f(row.get("값")), "date": row.get("규격") or ""}
|
||
for key, row in sorted(master.index.get("FX", {}).items())
|
||
] # fmt: skip
|
||
|
||
|
||
def _fuel_row(master, kind: str) -> dict[str, Any] | None:
|
||
return master.numbers.get("MO", {}).get(f"{kind}:{FUEL_REGION}")
|
||
|
||
|
||
def demand(master, keys: list[str]) -> dict[str, Any]:
|
||
"""수요 키 → 줄 차례(노무 · 기계 · 자재) + 연료 짝 · 찾지 못한 키."""
|
||
labor: list[str] = []
|
||
machines: list[str] = []
|
||
materials: list[str] = []
|
||
missing: list[str] = []
|
||
fuel_keys: dict[str, str] = {}
|
||
|
||
def add(bucket: list[str], key: str) -> None:
|
||
if key not in bucket:
|
||
bucket.append(key)
|
||
|
||
for key in keys:
|
||
if _row(master, key) is None:
|
||
missing.append(key)
|
||
elif key.startswith("LB"):
|
||
add(labor, key)
|
||
elif key.startswith("EQ"):
|
||
add(machines, key)
|
||
elif key.startswith(("MT", "MO")):
|
||
add(materials, key)
|
||
for key in machines:
|
||
v = _row(master, key).get("값") or {}
|
||
code = v.get("조종원직종")
|
||
driver = master.numbers.get("LB", {}).get(str(code)) if code and v.get("조종원") else None
|
||
if driver:
|
||
add(labor, driver["키"])
|
||
kind = v.get("주연료종류")
|
||
fuel = _fuel_row(master, kind) if kind and v.get("주연료") else None
|
||
if fuel:
|
||
add(materials, fuel["키"])
|
||
fuel_keys[kind] = fuel["키"]
|
||
return {
|
||
"labor": labor,
|
||
"machines": machines,
|
||
"materials": materials,
|
||
"fuel_keys": fuel_keys,
|
||
"missing": missing,
|
||
}
|
||
|
||
|
||
def _convert(master, key: str) -> dict[str, Any]:
|
||
row = _row(master, key)
|
||
if key.startswith("LB"):
|
||
return labor_input(row)
|
||
if key.startswith("EQ"):
|
||
return machine_input(row)
|
||
return material_input(row)
|
||
|
||
|
||
def refresh_book(
|
||
keys: list[str], book: dict[str, Any] | None = None, frozen: dict[str, dict] | None = None
|
||
) -> dict[str, Any]:
|
||
"""[단가 갱신] — 수요 키 전부를 지금 M01 값으로 새로 굳힘 ·
|
||
M01 원본이 지워진 키(296) = 옛 책 줄 그대로(지우지도 바꾸지도 않음) ·
|
||
`frozen` = 첫 굳힘(795-4) — 사본 단가 기준 시점 값 · 없는 자원 = 지금 값(`now_keys`)."""
|
||
master = basis(frozen)
|
||
want = demand(master, keys)
|
||
rows = {
|
||
key: _convert(master, key)
|
||
for key in [*want["labor"], *want["machines"], *want["materials"]]
|
||
}
|
||
old = (book or {}).get("rows") or {}
|
||
rows.update({key: old[key] for key in want["missing"] if key in old})
|
||
return {
|
||
"made": utc_now_text(),
|
||
"rows": rows,
|
||
"driver_factor": driver_factor(master),
|
||
"fx": fx_inputs(master),
|
||
"labor_edition": labor_editions(keys), # 703-3 미리 보기 「노임 판 전 → 후」
|
||
"now_keys": now_keys(frozen, list(rows)),
|
||
}
|
||
|
||
|
||
def labor_editions(keys: list[str]) -> dict[str, str]:
|
||
"""노임 판(703-3) — 수요 노무 줄 구분(건설업 · 산림 …)마다 M01 `인력.json` 그 구분 `판`."""
|
||
files, master = m01.loaded()
|
||
groups = (files.get("인력.json") or {}).get("구분") or {}
|
||
out: dict[str, str] = {}
|
||
for key in demand(master, keys)["labor"]:
|
||
kind = str((_row(master, key) or {}).get("구분") or "")
|
||
if (groups.get(kind) or {}).get("판"):
|
||
out[kind] = str(groups[kind]["판"])
|
||
return out
|
||
|
||
|
||
def book_diff(old: dict[str, Any] | None, new: dict[str, Any]) -> dict[str, int | bool]:
|
||
"""두 책 견줌(저장 꼴 JSON) — 새 줄 · 바뀐 줄 수 · 운전사 계수 · 환율 바뀜(203-1 · 703-3)."""
|
||
new = json.loads(json.dumps(new, ensure_ascii=False))
|
||
rows = (old or {}).get("rows") or {}
|
||
return {
|
||
"new": sum(key not in rows for key in new["rows"]),
|
||
"changed": sum(key in rows and rows[key] != row for key, row in new["rows"].items()),
|
||
"factor": (old or {}).get("driver_factor") != new["driver_factor"],
|
||
"fx": (old or {}).get("fx") != new["fx"],
|
||
}
|
||
|
||
|
||
def book_stale(book: dict[str, Any] | None, keys: list[str]) -> str | None:
|
||
"""203-1 [업데이트] 배지 — 지금 M01 로 새로 굳힌 책(`refresh_book`)과 견줌 · 다른 것 한 줄(같으면 None)."""
|
||
if book is None:
|
||
return "단가 아직 안 굳힘"
|
||
# 저장 꼴(JSON)로 견줌 · M01 원본이 지워진 줄 = 옛 줄 그대로(낡음 까닭에서 빠짐 · 296)
|
||
diff = book_diff(book, refresh_book(keys, book))
|
||
parts = [
|
||
f"새 단가 줄 {diff['new']}" if diff["new"] else "",
|
||
f"M01 에서 바뀐 단가 {diff['changed']}" if diff["changed"] else "",
|
||
"운전사 계수 바뀜" if diff["factor"] else "",
|
||
"환율 바뀜" if diff["fx"] else "",
|
||
]
|
||
return " · ".join(p for p in parts if p) or None
|
||
|
||
|
||
def extend_book(
|
||
book: dict[str, Any] | None, keys: list[str], frozen: dict[str, dict] | None = None
|
||
) -> dict[str, Any]:
|
||
"""[저장] — 책에 없는 새 줄만 더함(있는 줄 · 계수 · 환율은 안 바꿈) · 책이 없으면 새로 굳힘 ·
|
||
새 줄 값 = 사본 단가 기준 시점(795-4 · `frozen`) · 없는 자원만 지금 M01(`now_keys`)."""
|
||
if book is None:
|
||
return refresh_book(keys, None, frozen or {})
|
||
master = basis(frozen)
|
||
want = demand(master, keys)
|
||
rows = dict(book["rows"])
|
||
added = [
|
||
key for key in [*want["labor"], *want["machines"], *want["materials"]] if key not in rows
|
||
]
|
||
rows.update({key: _convert(master, key) for key in added})
|
||
late = [*(book.get("now_keys") or []), *now_keys(frozen or {}, added)]
|
||
return {**book, "rows": rows, "now_keys": list(dict.fromkeys(late))}
|
||
|
||
|
||
def b08_material_rows(b08_sheets: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||
"""B08 총괄자재수량집계표 줄(수량 있는 줄만) → 자재 입력 줄(M01 짝 없음 · 출처 빈칸)."""
|
||
rows = ((b08_sheets or {}).get("material") or {}).get("rows") or []
|
||
out = []
|
||
for row in rows:
|
||
if row.get("amount") is None or row["key"] == DEDUCT_KEY:
|
||
continue
|
||
out.append(
|
||
{
|
||
"key": f"b08:{row['key']}",
|
||
"name": row.get("group") or "",
|
||
"spec": row.get("spec") or "",
|
||
"unit": row.get("unit") or "",
|
||
"origin": "B08",
|
||
"prices": {},
|
||
"pages": {},
|
||
"source": "B08 총괄자재수량집계표",
|
||
"qty": row.get("amount"),
|
||
# 파형강관 = 관급(B08 할증 0 과 같은 뜻) — 사용자가 관급 · 별도 칸으로 바꿈
|
||
**({"supply": "관급"} if str(row["key"]).startswith("pipe_") else {}),
|
||
}
|
||
)
|
||
return out
|
||
|
||
|
||
def build_inputs(
|
||
book: dict[str, Any] | None,
|
||
keys: list[str],
|
||
b08_sheets: dict[str, Any] | None = None,
|
||
frozen: dict[str, dict] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""표 셈 입력 — 줄 = 수요 · 값 = 굳힌 책(없는 줄 = [저장] 때 넣을 값 · `fresh` 표시 ·
|
||
795-4 사본 단가 기준 시점 `frozen` · 그것도 없으면 지금 M01 · `now` 표시) ·
|
||
`now` = 사본 기준 시점에 없는 자원 키(책 `now_keys` + 안 굳힌 줄)."""
|
||
master = basis(frozen)
|
||
want = demand(master, keys)
|
||
saved = (book or {}).get("rows") or {}
|
||
late = [k for k in (book or {}).get("now_keys") or [] if k in saved]
|
||
|
||
def pick(key: str) -> dict[str, Any]:
|
||
if key in saved:
|
||
return saved[key]
|
||
if frozen is not None and key not in frozen:
|
||
late.append(key)
|
||
return {**_convert(master, key), "fresh": True}
|
||
|
||
factor = (book or {}).get("driver_factor") if book else driver_factor(master)
|
||
materials = [pick(k) for k in want["materials"]]
|
||
at = {row["key"]: i for i, row in enumerate(materials)}
|
||
for row in b08_material_rows(b08_sheets):
|
||
same = at.get(row["key"].removeprefix("b08:"))
|
||
if same is None:
|
||
materials.append(row)
|
||
else: # 같은 MT 코드 = 같은 자재 — 두 줄 안 만들고 수량만
|
||
materials[same] = {**materials[same], "qty": row["qty"]}
|
||
return {
|
||
"labor": [pick(k) for k in want["labor"]],
|
||
"machines": [pick(k) for k in want["machines"]],
|
||
"materials": materials,
|
||
"fuel_keys": want["fuel_keys"],
|
||
"driver_factor": factor,
|
||
"fx": (book or {}).get("fx") if book else fx_inputs(master),
|
||
"missing": want["missing"],
|
||
"now": list(dict.fromkeys(late)), # 위 줄들 pick 뒤 — 사전 차례대로 셈
|
||
}
|