feat(B09 단가 덮개): 노임 · 기계 단가 덮개 labor_cover · machine_cover — 셈 짝 · 저장 다듬기 · 거울 시험 (PLAN 707-3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 15:39:16 +09:00
co-authored by Claude Opus 5.5
parent 4f4348c80e
commit 39e0ee6d85
4 changed files with 96 additions and 9 deletions
+31 -2
View File
@@ -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)
]
+32 -5
View File
@@ -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,
+9 -1
View File
@@ -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
+24 -1
View File
@@ -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)