feat(B09 단가 덮개): 노임 · 기계 단가 덮개 labor_cover · machine_cover — 셈 짝 · 저장 다듬기 · 거울 시험 (PLAN 707-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,10 @@
|
||||
중기사용료 = 손료(경비 · 기준가 천원 × 손료계수 ÷ 10,000) + 운전사(노무 · 인 × 시간 노임)
|
||||
+ 연료(재료 · ℓ/hr × 적용 단가) + 잡품(재료 · 연료 금액 × % ÷ 100)
|
||||
줄 = ROUNDDOWN(단가 × 수량, 1) · 비목 합 = ROUNDDOWN(Σ, 0) → 중기목록표
|
||||
노임 · 기계 단가 덮개(707-3) = 사용자 값 `labor_cover` {노무 키: 일당} · `machine_cover`
|
||||
{기계 키: {labor, material, expense}}(셋 다 수일 때만) — 굳힌 값 대신 ·
|
||||
줄 `covered` · `book`(굳힌 값) ·
|
||||
덮은 기계 = 중기사용료 줄은 그대로 · 비목 합 · 합 = 덮개 · 그 기계 「연결 없음」 뺌
|
||||
빈칸 규칙 = 값을 낸 원천이 없으면 None(0 을 채우지 않음) · 그 칸을 쓰는 합도 None ·
|
||||
「연결 없음」 목록(`gaps`)에 탭 · 줄 키 · 까닭을 남김(목차 탭이 이동 링크로 씀).
|
||||
"""
|
||||
@@ -39,6 +43,7 @@ SOURCES = ("관급", "물가정보", "물가자료", "유통물가", "거래가
|
||||
|
||||
#: 자재 줄 사용자 표시
|
||||
SUPPLY = ("관급", "별도")
|
||||
_PARTS = ("labor", "material", "expense")
|
||||
|
||||
|
||||
def _scaled(value: float, places: int, fn) -> float:
|
||||
@@ -95,10 +100,15 @@ def driver_hour(day: float | None, factor: dict[str, Any] | None) -> float | Non
|
||||
return rounddown(day * factor["num"] / factor["den"], 0)
|
||||
|
||||
|
||||
def labor_sheet(labor: list[dict[str, Any]], gaps: list) -> list[dict[str, Any]]:
|
||||
def labor_sheet(
|
||||
labor: list[dict[str, Any]], gaps: list, cover: dict[str, Any] | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
rows = []
|
||||
for no, row in enumerate(labor, 1):
|
||||
price = num(row.get("price"))
|
||||
mine = num((cover or {}).get(row["key"]))
|
||||
extra = {} if mine is None else {"covered": True, "book": price}
|
||||
price = price if mine is None else mine
|
||||
if price is None:
|
||||
gaps.append(gap("labor", row["key"], row["name"], "노임 단가 없음"))
|
||||
rows.append(
|
||||
@@ -107,11 +117,22 @@ def labor_sheet(labor: list[dict[str, Any]], gaps: list) -> list[dict[str, Any]]
|
||||
"code": row.get("code"),
|
||||
"unit": row.get("unit") or "인",
|
||||
"price": price,
|
||||
**extra,
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def machine_cover(cover: Any) -> dict[str, dict[str, float]]:
|
||||
"""기계 덮개 — 노 · 재 · 경 셋 다 수인 줄만."""
|
||||
out = {}
|
||||
for key, cell in (cover if isinstance(cover, dict) else {}).items():
|
||||
got = {p: num((cell or {}).get(p)) if isinstance(cell, dict) else None for p in _PARTS}
|
||||
if None not in got.values():
|
||||
out[key] = got
|
||||
return out
|
||||
|
||||
|
||||
def _head(row: dict[str, Any], no: int) -> dict[str, Any]:
|
||||
return {
|
||||
"no": no,
|
||||
@@ -306,7 +327,7 @@ def basis_sheet(
|
||||
def compute_base(inputs: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]:
|
||||
"""144 탭 일곱 + 「연결 없음」 목록."""
|
||||
gaps: list[dict[str, str]] = []
|
||||
labor_rows = labor_sheet(inputs.get("labor") or [], gaps)
|
||||
labor_rows = labor_sheet(inputs.get("labor") or [], gaps, user.get("labor_cover"))
|
||||
expense = expense_sheet(inputs.get("machines") or [], gaps)
|
||||
compare = compare_sheet(inputs.get("materials") or [], user.get("materials") or {}, gaps)
|
||||
basis = basis_sheet(inputs, labor_rows)
|
||||
@@ -316,6 +337,13 @@ def compute_base(inputs: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]
|
||||
kind: by_key[key] for kind, key in (inputs.get("fuel_keys") or {}).items() if key in by_key
|
||||
}
|
||||
blocks = [machine_block(m, drivers, fuels, gaps) for m in inputs.get("machines") or []]
|
||||
covers = machine_cover(user.get("machine_cover"))
|
||||
for b in blocks:
|
||||
if b["key"] in covers: # 707-3 덮개 = 비목 합 · 합(줄은 그대로)
|
||||
b["book"] = {p: b[p] for p in (*_PARTS, "sum")}
|
||||
b.update(covers[b["key"]], covered=True)
|
||||
b["sum"] = total([b[p] for p in _PARTS])
|
||||
gaps = [g for g in gaps if not (g["tab"] == "machine_blocks" and g["key"] in covers)]
|
||||
machine = [
|
||||
{
|
||||
"no": no,
|
||||
@@ -327,6 +355,7 @@ def compute_base(inputs: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]
|
||||
"labor": b["labor"],
|
||||
"material": b["material"],
|
||||
"expense": b["expense"],
|
||||
**({"covered": True} if b.get("covered") else {}),
|
||||
}
|
||||
for no, b in enumerate(blocks, 1)
|
||||
]
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
* 재료비목록표 = 수량 × 적용 단가 = 총액 ROUND(·, 0) · 하나라도 빈칸이면 빈칸 · 수량 = 입력
|
||||
* material_qty(183-1 · 서버 역전개 총수량 · 없는 키 빈칸) ?? 입력 줄 수량(B08 총괄 합계 열)
|
||||
* 중기사용료 = 손료 + 운전사 + 연료 + 잡품 · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 → 중기목록표
|
||||
* 노임 · 기계 단가 덮개(707-3) = 사용자 값 labor_cover {노무 키: 일당} · machine_cover
|
||||
* {기계 키: {labor, material, expense}}(셋 다 수일 때만) — 굳힌 값 대신 · covered · book(굳힌 값) ·
|
||||
* 덮은 기계 = 중기사용료 줄 그대로 · 비목 합 · 합 = 덮개 · 그 기계 「연결 없음」 뺌
|
||||
* 빈칸 = 원천이 없으면 null · 그 칸을 쓰는 합도 null · 「연결 없음」 목록(gaps)에 남김
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -41,6 +44,7 @@ export const SOURCES = [
|
||||
|
||||
/** 자재 줄 사용자 표시 */
|
||||
export const SUPPLY = ["관급", "별도"] as const;
|
||||
const PARTS = ["labor", "material", "expense"] as const;
|
||||
|
||||
function scaled(value: number, places: number, fn: (x: number) => number): number {
|
||||
const a = Math.abs(value);
|
||||
@@ -101,14 +105,28 @@ const head = (row: Row, no: number): Row => ({
|
||||
source: row.source || "",
|
||||
});
|
||||
|
||||
function laborSheet(labor: Row[], gaps: Gap[]): Row[] {
|
||||
function laborSheet(labor: Row[], gaps: Gap[], cover?: Row): Row[] {
|
||||
return labor.map((row, i) => {
|
||||
const price = num(row.price);
|
||||
const book = num(row.price);
|
||||
const mine = num((cover || {})[row.key]);
|
||||
const price = mine === null ? book : mine;
|
||||
if (price === null) gaps.push(gap("labor", row.key, row.name, "노임 단가 없음"));
|
||||
return { ...head(row, i + 1), code: row.code ?? null, unit: row.unit || "인", price };
|
||||
const extra = mine === null ? {} : { covered: true, book };
|
||||
return { ...head(row, i + 1), code: row.code ?? null, unit: row.unit || "인", price, ...extra };
|
||||
});
|
||||
}
|
||||
|
||||
/** 기계 덮개 — 노 · 재 · 경 셋 다 수인 줄만 */
|
||||
export function machineCover(cover: unknown): Row {
|
||||
const out: Row = {};
|
||||
for (const [key, cell] of Object.entries(cover && typeof cover === "object" ? cover : {})) {
|
||||
const got: Row = {};
|
||||
for (const p of PARTS) got[p] = cell && typeof cell === "object" ? num(cell[p]) : null;
|
||||
if (PARTS.every((p) => got[p] !== null)) out[key] = got;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function expenseSheet(machines: Row[], gaps: Gap[]): Row[] {
|
||||
return machines.map((m, i) => {
|
||||
const price = num(m.price);
|
||||
@@ -276,8 +294,8 @@ function basisSheet(inputs: Row, laborRows: Row[]): Row {
|
||||
|
||||
/** 144 탭 일곱 + 「연결 없음」 목록 */
|
||||
export function computeBase(inputs: Row, user: Row): Row {
|
||||
const gaps: Gap[] = [];
|
||||
const laborRows = laborSheet(inputs.labor || [], gaps);
|
||||
let gaps: Gap[] = [];
|
||||
const laborRows = laborSheet(inputs.labor || [], gaps, user.labor_cover);
|
||||
const expense = expenseSheet(inputs.machines || [], gaps);
|
||||
const compare = compareSheet(inputs.materials || [], user.materials || {}, gaps);
|
||||
const basis = basisSheet(inputs, laborRows);
|
||||
@@ -287,6 +305,14 @@ export function computeBase(inputs: Row, user: Row): Row {
|
||||
for (const [kind, key] of Object.entries(inputs.fuel_keys || {}))
|
||||
if ((key as string) in byKey) fuels[kind] = byKey[key as string];
|
||||
const blocks = (inputs.machines || []).map((m: Row) => machineBlock(m, drivers, fuels, gaps));
|
||||
const covers = machineCover(user.machine_cover);
|
||||
for (const b of blocks) {
|
||||
if (!(b.key in covers)) continue; // 707-3 덮개 = 비목 합 · 합(줄은 그대로)
|
||||
b.book = { labor: b.labor, material: b.material, expense: b.expense, sum: b.sum };
|
||||
Object.assign(b, covers[b.key], { covered: true });
|
||||
b.sum = total(PARTS.map((p) => b[p]));
|
||||
}
|
||||
gaps = gaps.filter((g) => !(g.tab === "machine_blocks" && g.key in covers));
|
||||
const machine = blocks.map((b: Row, i: number) => ({
|
||||
no: i + 1,
|
||||
key: b.key,
|
||||
@@ -297,6 +323,7 @@ export function computeBase(inputs: Row, user: Row): Row {
|
||||
labor: b.labor,
|
||||
material: b.material,
|
||||
expense: b.expense,
|
||||
...(b.covered ? { covered: true } : {}),
|
||||
}));
|
||||
return {
|
||||
labor: laborRows,
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
"daega_cover": {블록 cover: {줄 cid: {qty?, labor?, material?, expense?}}}
|
||||
(702-2 일위대가 고친 칸 덮개 — 수만 · 셈 = `B09_Estimation_Calc_Labor`)
|
||||
+ 복사본 `K:…` 의 `@copy` {from · name · spec}(703-2 · 글만)
|
||||
"labor_cover": {노무 키: 일당} · "machine_cover": {기계 키: {labor,
|
||||
material, expense}}(707-3 노임 · 기계 단가 덮개 · 공사 몫 · [업데이트] 가
|
||||
책만 새로 굳혀 덮개는 남음 · 셈 = `B09_Estimation_Calc_Base`)
|
||||
|
||||
quotes/<자재 키>/ 견적서 파일(166-4 · 여러 개) — 폴더가 목록(키의 못 쓰는 글자는 %XX) ·
|
||||
읽을 때 materials[키].files = 이름들 · 사용자 값으로는 저장 안 함
|
||||
@@ -30,7 +33,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import unquote
|
||||
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import SOURCES, SUPPLY
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import SOURCES, SUPPLY, machine_cover
|
||||
from B09_Estimation.B09_Estimation_Calc_Bill import SECTIONS
|
||||
from B09_Estimation.B09_Estimation_Calc_Labor import COVER_KEYS
|
||||
|
||||
@@ -281,6 +284,11 @@ def clean_user(raw: Any) -> dict[str, Any]:
|
||||
out = {"materials": materials, "estimate": estimate, **({"bill": bill} if bill else {})}
|
||||
if cover := clean_cover(raw.get("daega_cover")):
|
||||
out["daega_cover"] = cover
|
||||
given = raw.get("labor_cover") if isinstance(raw.get("labor_cover"), dict) else {}
|
||||
if labor := {str(k)[:200]: _num(v) for k, v in given.items() if _num(v) is not None}:
|
||||
out["labor_cover"] = labor
|
||||
if machine := machine_cover(raw.get("machine_cover")):
|
||||
out["machine_cover"] = {str(k)[:200]: v for k, v in machine.items()}
|
||||
if clean_schedule and (schedule := clean_schedule(raw.get("schedule"))):
|
||||
out["schedule"] = schedule
|
||||
return out
|
||||
|
||||
@@ -165,6 +165,29 @@ def test_144_4_중기사용료_엑셀_검산():
|
||||
assert why == {"연료(중유) 단가 없음", "기계 기준가 없음", "손료 기준가 · 계수 없음"}
|
||||
|
||||
|
||||
#: 707-3 노임 · 기계 단가 덮개 — 건설기계운전사 일당 · 불도우저 시간 단가(셋 다) · 빠진 칸 = 버림
|
||||
COVER = {
|
||||
"labor_cover": {"LB000048": 283297, "LB000002": 172068, "없는 키": 1},
|
||||
"machine_cover": {"X09999": {"labor": 1, "material": 2, "expense": 3},
|
||||
"X00003": {"labor": 1, "material": None, "expense": 3}},
|
||||
} # fmt: skip
|
||||
|
||||
|
||||
def test_707_3_노임_기계_덮개():
|
||||
s = compute_base(INPUTS, COVER)
|
||||
labor = {r["key"]: r for r in s["labor"]}
|
||||
assert (labor["LB000048"]["price"], labor["LB000048"]["book"]) == (283297, 267360)
|
||||
assert labor["LB000002"]["covered"] and "covered" not in labor["LB000049"]
|
||||
assert "LB000002" not in {g["key"] for g in s["gaps"] if g["tab"] == "labor"} # 덮개 = 빈칸 아님
|
||||
hours = {d["key"]: d["hour"] for d in s["basis"]["drivers"]}
|
||||
assert hours["1048"] == 59020 # 거창 기계경비 운전사 59,020.2 → 59,020
|
||||
got = {r["key"]: (r["sum"], r["labor"], r["material"], r["expense"]) for r in s["machine"]}
|
||||
assert got["X09999"] == (6, 1, 2, 3) and got["X00003"][1] == 59020 # 반쪽 덮개 = 버림
|
||||
block = next(b for b in s["machine_blocks"] if b["key"] == "X09999")
|
||||
assert block["covered"] and block["book"]["sum"] is None
|
||||
assert "X09999" not in {g["key"] for g in s["gaps"] if g["tab"] == "machine_blocks"}
|
||||
|
||||
|
||||
def test_거울_TS_가_같은_값():
|
||||
for user in ({}, USER):
|
||||
for user in ({}, USER, COVER):
|
||||
assert _run_ts(INPUTS, user) == compute_base(INPUTS, user)
|
||||
|
||||
Reference in New Issue
Block a user