refactor(841-1): B09 단가 책 · 바탕 셈 걷음 — B 걸음

- 단가 책 = 자재만(자재단가대비표 · 4 자재대 · 관급자재대) · 노무 · 기계 · 운전사 계수(CC000336 세 줄) · 연료 · 환율 줄 걷음
- 바탕 셈(py · ts 짝) 운전사 시간 노임 · 중기사용료(손료 · 운전사 · 연료 · 잡품 재현) · 경비목록표 · 노임 · 기계 덮개 걷음
- 노무비목록표 · 중기목록표 = 대가 · 산근 줄 엔진 단가(프로젝트 사본 M02 값) 나열 · 다시 안 셈(price_lists)
- 경비목록표 · 중기사용료 · 환율및기초자료 탭 = 「—」 한 줄 + 까닭 한 줄(탭은 둠)
- [단가 갱신] = 프로젝트 사본 자원 단가 줄(단가.json LB · MT · EQ · MO · FX)을 지금 M01 로 + 자재 단가 책(새 파일 B09_Estimation_Copy_Prices.py · 사용자가 누를 때만) · 미리 보기 = 덮개로 같은 셈(파일 안 씀)
- 산근 · 대가 단가 고친 칸 걷음(품만) · 단가표 불러오기(707-3) 노임 · 기계 덮개 걷음(자재만)
- 낡은 시험은 C 걸음에서 정리

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-15 16:02:11 +09:00
co-authored by Claude Opus 5.5
parent 30c062bd48
commit 193ce92806
19 changed files with 300 additions and 795 deletions
+15 -188
View File
@@ -5,26 +5,20 @@
조작 중은 화면 짝이 바로 셈 · [저장] 때 서버가 이 파일로 정본을 다시 셈.
실무 엑셀(기번3 공통 xlsx) 탭 꼴 · 끝수 = 엑셀 자리 그대로:
노무비목록표 · 경비목록표 = 굳힌 단가 그대로(사용자 고침 없음 · [단가 갱신] 때만 새로)
환율및기초자료 = 환율 · 운전사 시간 노임 = ROUNDDOWN(일당 × 분자 ÷ 분모, 0)
(분자 · 분모 = M01 CC000336 1/8 × 16/12 × 25/20 — 서버가 넣어 줌)
노무비목록표 · 중기목록표 = 대가 · 산근 줄 엔진 단가(사본 값 · 서버가 `inputs.labor` ·
`inputs.machines` 로 넣어 줌 · `B09_Estimation_Supply.price_lists`) 나열 — 다시 안 셈(841-1)
경비목록표 · 중기사용료 · 환율및기초자료 = 걷음(841-1 · B09 가 기계 시간당 · 운전사 노임을
다시 셈하던 곳 · 값 = M02 단가산출 그대로) — 빈 표
자재단가대비표 = 출처 단가 중 최저(0 · 빈칸 뺌 · 견적 포함 = 엑셀 MIN) 자동 ·
적용 차례 = 고른 출처(pick · 견적이 높아도) › 사용자 적용 단가 › 최저
· 견적 = 사용자 손입력(quote · quote_page · 견적서 파일은 `B09_Estimation_Router_Quotes`)
· 관급 · 별도계상 = 사용자 표시(없으면 입력 줄 기본 · B08 파형강관 = 관급)
· 관급 · 별도계상 = 사용자 표시(없으면 입력 줄 기본 · 로직 자재 = M02 자재표시 · B08 파형강관 = 관급)
→ 재료비목록표 단가 = 적용 단가
재료비목록표 = 수량 × 적용 단가 = 총액 ROUND(·, 0)
(엑셀 재료비수량금액집계표 G 열 자리 · 수량 · 단가 하나라도 빈칸이면 빈칸) · 수량 =
입력 `material_qty`(183-1 · 서버 1차 셈 역전개 총수량 = 재료비수량금액집계표 수량 ·
없는 키 빈칸) ·
`material_qty` 가 없으면 입력 줄 수량(B08 총괄 합계 열)
중기사용료 = 손료(경비 · 기준가 천원 × 손료계수 ÷ 10,000) + 운전사(노무 · 인 × 시간 노임)
+ 연료(재료 · ℓ/hr × 적용 단가) + 잡품(재료 · 연료 금액 × % ÷ 100)
줄 = ROUNDDOWN(단가 × 수량, 1) · 비목 합 = ROUNDDOWN(Σ, 0) → 중기목록표
노임 · 기계 단가 덮개(707-3) = 사용자 값 `labor_cover` {노무 키: 일당} · `machine_cover`
{기계 키: {labor, material, expense}}(셋 다 수일 때만) — 굳힌 값 대신 ·
줄 `covered` · `book`(굳힌 값) ·
덮은 기계 = 중기사용료 줄은 그대로 · 비목 합 · 합 = 덮개 · 그 기계 「연결 없음」 뺌
빈칸 규칙 = 값을 낸 원천이 없으면 None(0 을 채우지 않음) · 그 칸을 쓰는 합도 None ·
「연결 없음」 목록(`gaps`)에 탭 · 줄 키 · 까닭을 남김(목차 탭이 이동 링크로 씀).
"""
@@ -93,22 +87,10 @@ def gap(tab: str, key: str, name: str, why: str) -> dict[str, str]:
return {"tab": tab, "key": key, "name": name, "why": why}
def driver_hour(day: float | None, factor: dict[str, Any] | None) -> float | None:
"""운전사 시간 노임 = ROUNDDOWN(일당 × 분자 ÷ 분모, 0)."""
if day is None or not factor or not factor.get("den"):
return None
return rounddown(day * factor["num"] / factor["den"], 0)
def labor_sheet(
labor: list[dict[str, Any]], gaps: list, cover: dict[str, Any] | None = None
) -> list[dict[str, Any]]:
def labor_sheet(labor: list[dict[str, Any]], gaps: list) -> 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(
@@ -117,22 +99,11 @@ def labor_sheet(
"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,
@@ -143,17 +114,6 @@ def _head(row: dict[str, Any], no: int) -> dict[str, Any]:
}
def expense_sheet(machines: list[dict[str, Any]], gaps: list) -> list[dict[str, Any]]:
"""경비목록표 — 기계 손료 기준가(천원)."""
rows = []
for no, m in enumerate(machines, 1):
price = num(m.get("price"))
if price is None:
gaps.append(gap("expense", m["key"], m["name"], "기계 기준가 없음"))
rows.append({**_head(m, no), "unit": "천원", "price": price})
return rows
def lowest(prices: dict[str, Any]) -> tuple[float | None, str | None]:
"""출처 단가 중 최저 — 0 · 빈칸 뺌 · 같으면 앞 칸."""
best: tuple[float | None, str | None] = (None, None)
@@ -219,155 +179,22 @@ def material_sheet(
return rows
def _line(name, spec, qty, unit, kind, price, ref, pct=False) -> dict[str, Any]:
if price is None or qty is None:
amount = None
elif pct:
amount = rounddown(price * qty / 100, 1)
else:
amount = rounddown(price * qty, 1)
return {
"name": name,
"spec": spec,
"qty": qty,
"unit": unit,
"kind": kind,
"price": price,
"amount": amount,
"ref": ref,
}
def machine_block(
m: dict[str, Any],
drivers: dict[str, dict[str, Any]],
fuels: dict[str, dict[str, Any]],
gaps: list,
) -> dict[str, Any]:
"""중기사용료 한 블록 — 손료 · 운전사 · 연료 · 잡품 줄 + 비목 합."""
lines = []
loss = num(m.get("loss"))
coef = loss / 10000 if loss is not None else None
price = num(m.get("price"))
lines.append(_line(m["name"], m.get("spec") or "", coef, "천원", "expense", price,
{"tab": "expense", "key": m["key"]})) # fmt: skip
crew = num(m.get("crew"))
if crew:
code = str(m.get("crew_code") or "")
driver = drivers.get(code) or {}
hour = driver.get("hour")
if hour is None:
gaps.append(gap("machine_blocks", m["key"], m["name"], "운전사 노임 없음"))
lines.append(_line(driver.get("name") or "운전사", "", crew, "인", "labor", hour,
{"tab": "basis", "key": code})) # fmt: skip
lph = num(m.get("fuel_lph"))
if lph:
kind = str(m.get("fuel") or "")
fuel = fuels.get(kind) or {}
fprice = fuel.get("applied")
if fprice is None:
gaps.append(gap("machine_blocks", m["key"], m["name"], f"연료({kind}) 단가 없음"))
fuel_line = _line(fuel.get("name") or kind, fuel.get("spec") or "", lph, "ℓ", "material",
fprice, {"tab": "compare", "key": fuel.get("key") or ""}) # fmt: skip
lines.append(fuel_line)
misc = num(m.get("misc_pct"))
if misc:
lines.append(_line("잡품", "주연료의 %", misc, "%", "material", fuel_line["amount"],
None, pct=True)) # fmt: skip
if price is None or coef is None:
gaps.append(gap("machine_blocks", m["key"], m["name"], "손료 기준가 · 계수 없음"))
sums: dict[str, float | None] = {}
for part in ("labor", "material", "expense"):
s = total([ln["amount"] for ln in lines if ln["kind"] == part])
sums[part] = None if s is None else rounddown(s, 0)
return {
"key": m["key"],
"name": m["name"],
"spec": m.get("spec") or "",
"unit": "시간",
"lines": lines,
"labor": sums["labor"],
"material": sums["material"],
"expense": sums["expense"],
"sum": total([sums["labor"], sums["material"], sums["expense"]]),
}
def basis_sheet(
inputs: dict[str, Any], labor_rows: list[dict[str, Any]]
) -> dict[str, list[dict[str, Any]]]:
"""환율및기초자료 — 환율 · 운전사 시간 노임(기계가 부르는 직종만)."""
factor = inputs.get("driver_factor")
by_code = {str(row.get("code")): row for row in labor_rows if row.get("code") is not None}
codes: list[str] = []
for m in inputs.get("machines") or []:
code = str(m.get("crew_code")) if m.get("crew_code") else None
if code and num(m.get("crew")) and code not in codes:
codes.append(code)
drivers = []
for code in codes:
row = by_code.get(code) or {}
day = row.get("price")
drivers.append(
{
"key": code,
"labor_key": row.get("key"),
"name": row.get("name") or f"직종 {code}",
"day": day,
"factor": (factor or {}).get("text") or "",
"hour": driver_hour(day, factor),
}
)
fx = [
{"key": f["key"], "name": f.get("name") or "", "unit": f.get("unit") or "",
"value": num(f.get("value")), "date": f.get("date") or ""}
for f in inputs.get("fx") or []
] # fmt: skip
return {"fx": fx, "drivers": drivers}
def compute_base(inputs: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]:
"""144 탭 일곱 + 「연결 없음」 목록."""
"""144 탭 + 「연결 없음」 목록 — 841-1 경비목록표 · 중기사용료 · 환율및기초자료 = 빈 표."""
gaps: list[dict[str, str]] = []
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)
drivers = {d["key"]: d for d in basis["drivers"]}
by_key = {row["key"]: row for row in compare}
fuels = {
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,
"key": b["key"],
"name": b["name"],
"spec": b["spec"],
"unit": b["unit"],
"sum": b["sum"],
"labor": b["labor"],
"material": b["material"],
"expense": b["expense"],
**({"covered": True} if b.get("covered") else {}),
}
for no, b in enumerate(blocks, 1)
]
{**_head(m, no), "unit": "시간", **{p: num(m.get(p)) for p in _PARTS},
"sum": total([num(m.get(p)) for p in _PARTS])}
for no, m in enumerate(inputs.get("machines") or [], 1)
] # fmt: skip
return {
"labor": labor_rows,
"expense": expense,
"basis": basis,
"labor": labor_sheet(inputs.get("labor") or [], gaps),
"expense": [],
"basis": {"fx": [], "drivers": []},
"compare": compare,
"material": material_sheet(compare, inputs.get("material_qty")),
"machine_blocks": blocks,
"machine_blocks": [],
"machine": machine,
"gaps": gaps,
}
+16 -173
View File
@@ -6,15 +6,12 @@
* 거울 시험 `resources/tester/test_144_b09_base_calc.py`.
* 조작 중은 이 파일이 바로 셈 · [저장] 때 서버가 파이썬 짝으로 정본을 다시 셈.
*
* 노무비 · 경비목록표 = 굳힌 단가 · 환율및기초자료 = 운전사 시간 노임 ROUNDDOWN(일당 × 분자 ÷ 분모, 0)
* 노무비목록표 · 중기목록표 = 대가 · 산근 줄 엔진 단가(사본 값 · 서버가 inputs.labor · machines 로 줌) 나열 ·
* 경비목록표 · 중기사용료 · 환율및기초자료 = 걷음(841-1 · 값 = M02 단가산출 그대로) — 빈 표
* 자재단가대비표 = 출처 중 최저 자동(견적 포함) · 적용 = 고른 출처 › 사용자 단가 › 최저 ·
* 관급 · 별도 표시 → 재료비목록표
* 재료비목록표 = 수량 × 적용 단가 = 총액 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)에 남김
* ========================================================================== */
@@ -44,7 +41,7 @@ export const SOURCES = [
/** 자재 줄 사용자 표시 */
export const SUPPLY = ["관급", "별도"] as const;
const COVER_PARTS = ["labor", "material", "expense"] 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);
@@ -92,11 +89,6 @@ export const gap = (tab: string, key: string, name: string, why: string): Gap =>
why,
});
export function driverHour(day: Num, factor: Row | null | undefined): Num {
if (day === null || !factor || !factor.den) return null;
return rounddown((day * factor.num) / factor.den, 0);
}
const head = (row: Row, no: number): Row => ({
no,
key: row.key,
@@ -105,33 +97,11 @@ const head = (row: Row, no: number): Row => ({
source: row.source || "",
});
function laborSheet(labor: Row[], gaps: Gap[], cover?: Row): Row[] {
function laborSheet(labor: Row[], gaps: Gap[]): Row[] {
return labor.map((row, i) => {
const book = num(row.price);
const mine = num((cover || {})[row.key]);
const price = mine === null ? book : mine;
const price = num(row.price);
if (price === null) gaps.push(gap("labor", row.key, row.name, "노임 단가 없음"));
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 COVER_PARTS) got[p] = cell && typeof cell === "object" ? num(cell[p]) : null;
if (COVER_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);
if (price === null) gaps.push(gap("expense", m.key, m.name, "기계 기준가 없음"));
return { ...head(m, i + 1), unit: "천원", price };
return { ...head(row, i + 1), code: row.code ?? null, unit: row.unit || "인", price };
});
}
@@ -192,148 +162,21 @@ function materialSheet(compare: Row[], qtyOf?: Row | null): Row[] {
});
}
function line(
name: string,
spec: string,
qty: Num,
unit: string,
kind: string,
price: Num,
ref: Row | null,
pct = false,
): Row {
let amount: Num;
if (price === null || qty === null) amount = null;
else if (pct) amount = rounddown((price * qty) / 100, 1);
else amount = rounddown(price * qty, 1);
return { name, spec, qty, unit, kind, price, amount, ref };
}
function machineBlock(m: Row, drivers: Row, fuels: Row, gaps: Gap[]): Row {
const lines: Row[] = [];
const loss = num(m.loss);
const coef = loss !== null ? loss / 10000 : null;
const price = num(m.price);
lines.push(
line(m.name, m.spec || "", coef, "천원", "expense", price, { tab: "expense", key: m.key }),
);
const crew = num(m.crew);
if (crew) {
const code = String(m.crew_code || "");
const driver: Row = drivers[code] || {};
const hour: Num = driver.hour ?? null;
if (hour === null) gaps.push(gap("machine_blocks", m.key, m.name, "운전사 노임 없음"));
lines.push(
line(driver.name || "운전사", "", crew, "인", "labor", hour, { tab: "basis", key: code }),
);
}
const lph = num(m.fuel_lph);
if (lph) {
const kind = String(m.fuel || "");
const fuel: Row = fuels[kind] || {};
const fprice: Num = fuel.applied ?? null;
if (fprice === null) gaps.push(gap("machine_blocks", m.key, m.name, `연료(${kind}) 단가 없음`));
const fuelLine = line(fuel.name || kind, fuel.spec || "", lph, "ℓ", "material", fprice, {
tab: "compare",
key: fuel.key || "",
});
lines.push(fuelLine);
const misc = num(m.misc_pct);
if (misc)
lines.push(line("잡품", "주연료의 %", misc, "%", "material", fuelLine.amount, null, true));
}
if (price === null || coef === null)
gaps.push(gap("machine_blocks", m.key, m.name, "손료 기준가 · 계수 없음"));
const sums: Record<string, Num> = {};
for (const part of ["labor", "material", "expense"]) {
const s = total(lines.filter((ln) => ln.kind === part).map((ln) => ln.amount));
sums[part] = s === null ? null : rounddown(s, 0);
}
return {
key: m.key,
name: m.name,
spec: m.spec || "",
unit: "시간",
lines,
labor: sums.labor,
material: sums.material,
expense: sums.expense,
sum: total([sums.labor, sums.material, sums.expense]),
};
}
function basisSheet(inputs: Row, laborRows: Row[]): Row {
const factor = inputs.driver_factor;
const byCode: Row = {};
for (const row of laborRows)
if (row.code !== null && row.code !== undefined) byCode[String(row.code)] = row;
const codes: string[] = [];
for (const m of inputs.machines || []) {
const code = m.crew_code ? String(m.crew_code) : null;
if (code && num(m.crew) && !codes.includes(code)) codes.push(code);
}
const drivers = codes.map((code) => {
const row: Row = byCode[code] || {};
const day: Num = row.price ?? null;
return {
key: code,
labor_key: row.key ?? null,
name: row.name || `직종 ${code}`,
day,
factor: (factor || {}).text || "",
hour: driverHour(day, factor),
};
});
const fx = (inputs.fx || []).map((f: Row) => ({
key: f.key,
name: f.name || "",
unit: f.unit || "",
value: num(f.value),
date: f.date || "",
}));
return { fx, drivers };
}
/** 144 탭 일곱 + 「연결 없음」 목록 */
/** 144 탭 + 「연결 없음」 목록 — 841-1 경비목록표 · 중기사용료 · 환율및기초자료 = 빈 표(짝 `compute_base`) */
export function computeBase(inputs: Row, user: Row): Row {
let gaps: Gap[] = [];
const laborRows = laborSheet(inputs.labor || [], gaps, user.labor_cover);
const expense = expenseSheet(inputs.machines || [], gaps);
const gaps: Gap[] = [];
const compare = compareSheet(inputs.materials || [], user.materials || {}, gaps);
const basis = basisSheet(inputs, laborRows);
const drivers: Row = Object.fromEntries(basis.drivers.map((d: Row) => [d.key, d]));
const byKey: Row = Object.fromEntries(compare.map((row) => [row.key, row]));
const fuels: 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(COVER_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,
name: b.name,
spec: b.spec,
unit: b.unit,
sum: b.sum,
labor: b.labor,
material: b.material,
expense: b.expense,
...(b.covered ? { covered: true } : {}),
}));
const machine = (inputs.machines || []).map((m: Row, i: number) => {
const parts: Row = Object.fromEntries(PARTS.map((p) => [p, num(m[p])]));
return { ...head(m, i + 1), unit: "시간", ...parts, sum: total(PARTS.map((p) => parts[p])) };
});
return {
labor: laborRows,
expense,
basis,
labor: laborSheet(inputs.labor || [], gaps),
expense: [],
basis: { fx: [], drivers: [] },
compare,
material: materialSheet(compare, inputs.material_qty),
machine_blocks: blocks,
machine_blocks: [],
machine,
gaps,
};
@@ -0,0 +1,92 @@
"""B09 [단가 갱신] = 프로젝트 사본 자원 단가 줄 갱신(PLAN 841-1 · 요구 854 · 866) — 서버 단독.
B09 금액 = 프로젝트 사본(구조물 사본 · 서고 사본)을 M02 엔진으로 셈한 값(B09 단가 책으로 다시 안 셈).
그래서 사본 값을 지금 M01 로 올리는 일 = 사본 `단가.json` 자원 줄(LB · MT · EQ · MO · FX)만 지금 M01 줄로
(로직 줄 · 일위대가 · 입력값 · 고른 자재 · 덮개 그대로 · M01 에서 지워진 키 = 마지막 값 그대로) —
M02 [단가 갱신](`M02_Sangeun_Copy.refresh`)과 같은 뜻 · 사용자가 누를 때만(프로젝트 안은 자동 아님).
미리 보기 = `OVER` 덮개(파일 안 씀 · 사본 읽는 곳이 `bundle` 로 읽음)로 같은 셈.
"""
from __future__ import annotations
from contextvars import ContextVar
from pathlib import Path
from typing import Any
from B09_Estimation import B09_Estimation_Bill_Library as library
from common_util.common_util_json import atomic_write_json
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_Item_Prices as item_prices
from M02_MasterTemplete import M02_Sangeun_Copy as skc
from M02_MasterTemplete.M02_Item_Snapshot import linked_snapshots, structures_dir
from M02_MasterTemplete.M02_Item_Source import PRICE_FILE, read_json
#: 미리 보기 덮개 {사본 폴더 글: 새 `단가.json`} — 없으면 파일
OVER: ContextVar[dict[str, dict] | None] = ContextVar("b09_copy_prices", default=None)
def bundle(folder: Path) -> dict[str, Any]:
"""사본 `단가.json` — 미리 보기 덮개가 있으면 그것."""
over = OVER.get()
if over and str(folder) in over:
return over[str(folder)]
return read_json(folder / PRICE_FILE) or {}
def folders(root: str | Path) -> list[Path]:
"""연결된 구조물 사본 + 서고 사본 폴더."""
base = structures_dir(root)
out = [base / one["폴더"] for one in linked_snapshots(root).values()]
out += [folder for folder, _manifest in library.copies(root)]
return list(dict.fromkeys(out))
def _shown(row: dict[str, Any]) -> Any:
"""미리 보기 값 — 노임 · 유가 = 값 · 기계 = 가격(천원) · 자재 = 출처 값 묶음."""
value = row.get("값")
if isinstance(value, dict):
return value.get("가격")
if value is not None:
return value
return {k: row.get(k) for k in ("조달청", "물가자료", "유통물가", "물가정보", "거래가격")}
def refreshed(root: str | Path) -> tuple[dict[str, dict], list[dict[str, Any]]]:
"""({바뀐 사본 폴더 글: 새 `단가.json`}, 바뀐 자원 [{키, 이름, 전, 후}]) — 파일 안 씀."""
whole = skc.whole_()
news: dict[str, dict] = {}
changed: dict[str, dict[str, Any]] = {}
for folder in folders(root):
data = read_json(folder / PRICE_FILE)
if not isinstance(data, dict):
continue
out, hit = {}, False
for name, part in data.items():
slot = m01.items_key(part) if isinstance(part, dict) else ""
rows = part.get(slot) if slot else None
if not isinstance(rows, list):
out[name] = part
continue
fresh = []
for row in rows:
key = str((row or {}).get("키") or "")
live = whole.index.get(key[:2], {}).get(key) if key[:2] in skc.RES else None
new = item_prices.to_json(live) if live is not None else row
if new != row:
hit = True
changed.setdefault(
key,
{"키": key, "이름": new.get("이름"), "전": _shown(row), "후": _shown(new)},
)
fresh.append(new)
out[name] = {**part, slot: fresh}
if hit:
news[str(folder)] = out
return news, list(changed.values())
def write(news: dict[str, dict]) -> int:
"""새 `단가.json` 을 사본 폴더에 씀 — 쓴 사본 수."""
for folder, data in news.items():
atomic_write_json(Path(folder) / PRICE_FILE, data)
return len(news)
+23 -79
View File
@@ -2,65 +2,45 @@
(덮개 제안 → 화면 캐시 → [저장]).
읽는 시트(꼴마다 첫 것) — 머리 = 「명칭」 · 「품명」 칸이 든 줄:
노임 = STmate 「노무비목록표」 · EST 「노임단가」 → 단가(「적용단가」 › 「단가」)
자재 = STmate 「재료비목록표」 › 「자재단가대비표」 · EST 「자재단가」 → 적용 단가(단위 % 줄 뺌)
기계 = STmate 「중기목록표」(= 「중기사용료」 블록 합계) ·
EST 「기계경비총괄표」(= 「기계경비」 합) → 시간 단가 노무비 · 재료비 · 경비
이음 = 이 공사 단가 책 줄(바탕 셈 `compute_base` 노무비목록표 · 중기목록표 · 자재단가대비표)에
이름 · 규격 · 단위로 — 705-3 이음 낱말 그대로(노임 = `M02_Hopyo_Shares.trade` 직종 이름 ·
기계 = 이름 + 규격 같음 › `eq_key` 갈래 · 크기 · 자재 = 「별도계상」 글 뗀 이름 · 규격 · 단위 ›
이름 · 단위) · 한 줄에만 맞을 때만 · 기계 갈래 · 크기는 표지 글(타이어 · 브레이커 · 할증 …)
까지 · 한 책 줄에 엑셀 줄 여럿 = 「짝 여럿」(못 고름).
덮개 = 노임 `labor_cover` · 기계 `machine_cover`(새 · `B09_Estimation_Calc_Base`) ·
자재 = 지금 덮개 `materials[키].applied`(고른 출처 `pick` 은 뺌 — 적용 차례가 pick 먼저) ·
(841-1 노임 · 기계 단가표 걷음 — B09 노무 · 기계 단가 = M02 사본 값 · 덮지 않음)
이음 = 이 공사 자재단가대비표 줄에 이름 · 규격 · 단위로 — 705-3 이음 낱말 그대로(「별도계상」 글 뗀
이름 · 규격 · 단위 › 이름 · 단위) · 한 줄에만 맞을 때만 · 한 책 줄에 엑셀 줄 여럿 = 「짝 여럿」.
덮개 = 자재 = 지금 덮개 `materials[키].applied`(고른 출처 `pick` 은 뺌 — 적용 차례가 pick 먼저) ·
책 값과 같으면 덮지 않음(옛 덮개 걷음).
못 이은 줄 = 「짝 없음 n」(이름 목록).
"""
from __future__ import annotations
import re
import unicodedata
from collections import Counter
from typing import Any
from B09_Estimation.B09_Estimation_Import_Rates import flat, money, read_sheets
from M02_MasterTemplete import M02_Hopyo_Material as hmat
from M02_MasterTemplete import M02_Hopyo_Shares as shares
from M02_MasterTemplete.M02_Hopyo_Excel import ExcelError
KINDS = {
"labor": ("노무비목록표", "노임단가"),
"material": ("재료비목록표", "자재단가대비표", "자재단가"),
"machine": ("중기목록표", "기계경비총괄표"),
}
PARTS = ("labor", "material", "expense")
_MACHINE = {"labor": "노무비", "material": "재료비", "expense": "경비"}
#: 841-1 노임 · 기계 단가 덮개 걷음(B09 노무 · 기계 단가 = M02 사본 값) — 자재만
KINDS = {"material": ("재료비목록표", "자재단가대비표", "자재단가")}
LIST_MAX = 40
#: 같은 갈래 · 크기라도 다른 기계 — 표지 글이 같아야 이음
_MARK = re.compile(r"타이어|브레|할증|암석|\(암\)|집게|진동|탑재|크레인|살수")
def _n(text: Any) -> str:
return flat(unicodedata.normalize("NFKC", str(text or ""))).lower()
def table(rows: list[list[Any]], machine: bool) -> list[dict[str, Any]]:
"""목록 시트 → [{name, spec, unit, price | labor · material · expense}]."""
def table(rows: list[list[Any]]) -> list[dict[str, Any]]:
"""목록 시트 → [{name, spec, unit, price}]."""
for at, row in enumerate(rows[:12]):
cols = [flat(v) for v in row]
name = next((cols.index(h) for h in ("명칭", "품명") if h in cols), None)
if name is None:
continue
spec, unit = (cols.index(h) if h in cols else None for h in ("규격", "단위"))
if machine:
price = {p: cols.index(h) for p, h in _MACHINE.items() if h in cols}
if len(price) < 3:
continue
else:
price = next((cols.index(h) for h in ("적용단가", "단가") if h in cols), None)
if price is None:
continue
price = next((cols.index(h) for h in ("적용단가", "단가") if h in cols), None)
if price is None:
continue
out = []
for line in rows[at + 1 :]:
@@ -69,12 +49,8 @@ def table(rows: list[list[Any]], machine: bool) -> list[dict[str, Any]]:
item = {"name": str(cell(name) or "").strip(), "spec": str(cell(spec) or "").strip(),
"unit": str(cell(unit) or "").strip()} # fmt: skip
if machine:
item.update({p: money(cell(c)) for p, c in price.items()})
ok = None not in (item[p] for p in PARTS)
else:
item["price"] = money(cell(price))
ok = item["price"] is not None and item["unit"] != "%"
item["price"] = money(cell(price))
ok = item["price"] is not None and item["unit"] != "%"
if item["name"] and ok:
out.append(item)
return out
@@ -82,31 +58,18 @@ def table(rows: list[list[Any]], machine: bool) -> list[dict[str, Any]]:
def read_prices(name: str, data: bytes) -> dict[str, list[dict[str, Any]]]:
"""파일 → {labor, material, machine: 줄 목록} · 시트 없음 = 빈 목록 · 셋 다 없으면 막힘."""
"""파일 → {material: 줄 목록} · 시트 없음 = 막힘."""
sheets = read_sheets(name, data, tuple(s for names in KINDS.values() for s in names))
out = {}
for kind, names in KINDS.items():
sheet = next((s for s in names if s in sheets), None)
out[kind] = table(sheets[sheet], kind == "machine") if sheet else []
out[kind] = table(sheets[sheet]) if sheet else []
out[f"{kind}_sheet"] = sheet
if not any(out[k] for k in KINDS):
raise ExcelError("노무비목록표 · 재료비목록표 · 중기목록표(STmate) · 노임단가 · 자재단가 · "
"기계경비총괄표(EST) 시트가 없음") # fmt: skip
raise ExcelError("재료비목록표 · 자재단가대비표(STmate) · 자재단가(EST) 시트가 없음")
return out
def _labor_keys(row: dict[str, Any]) -> list[str]:
return [_n(shares.trade(row.get("name")) or row.get("name"))]
def _machine_keys(row: dict[str, Any]) -> list[str]:
"""[이름 + 규격 같음, 갈래 · 크기 + 표지 글(타이어 · 브레이커 · 할증 · 암 …)]."""
text = f"{row.get('name') or ''} {row.get('spec') or ''}"
kind = shares.eq_key(text)
marks = "+".join(sorted(set(_MARK.findall(_n(text)))))
return [_n(text), f"{kind}#{marks}" if kind else ""]
def _material_keys(row: dict[str, Any]) -> list[str]:
me, _ = hmat.clean({"이름": row.get("name"), "규격": row.get("spec")})
unit = _n(row.get("unit"))
@@ -123,18 +86,13 @@ def _match(book: list[dict[str, Any]], keys_of, item: dict[str, Any]) -> dict[st
def _same(a: Any, b: Any) -> bool:
if isinstance(a, dict):
return all(_same(a[p], (b or {}).get(p)) for p in PARTS)
return a is not None and b is not None and abs(a - b) < 0.5
def propose(read: dict[str, Any], sheets: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]:
"""읽은 단가표 + 이 공사 단가 책(덮개 뺀 바탕 셈) → 덮개 patch · 이은 줄 · 짝 없음."""
base = {"labor": sheets.get("labor") or [], "machine": sheets.get("machine") or [],
"material": sheets.get("compare") or []} # fmt: skip
keys = {"labor": _labor_keys, "machine": _machine_keys, "material": _material_keys}
labor_cover = dict(user.get("labor_cover") or {})
machine_cover = dict(user.get("machine_cover") or {})
base = {"material": sheets.get("compare") or []}
keys = {"material": _material_keys}
materials = {k: dict(v) for k, v in (user.get("materials") or {}).items()}
linked: list[dict[str, Any]] = []
missing: dict[str, list[str]] = {k: [] for k in KINDS}
@@ -147,30 +105,16 @@ def propose(read: dict[str, Any], sheets: dict[str, Any], user: dict[str, Any])
if row is None or hits[row["key"]] > 1: # 한 책 줄에 엑셀 줄 여럿 = 못 고름
missing[kind].append(label if row is None else f"{label}(짝 여럿)")
continue
if kind == "machine":
got = {p: item[p] for p in PARTS}
book = {p: row.get(p) for p in PARTS}
else:
got, book = item["price"], row.get("price" if kind == "labor" else "applied")
got, book = item["price"], row.get("applied")
linked.append({"kind": kind, "key": row["key"], "name": row.get("name") or "",
"from": label, "excel": got, "book": book,
"same": _same(got, book)}) # fmt: skip
same = linked[-1]["same"] # 책 값 = 엑셀 → 옛 덮개 걷음(값 = 책)
if kind in ("labor", "machine"):
cover = labor_cover if kind == "labor" else machine_cover
cover.pop(row["key"], None)
if not same:
cover[row["key"]] = got
else:
drop = ("pick", "applied") if same else ("pick",)
cell = {k: v for k, v in materials.get(row["key"], {}).items() if k not in drop}
materials[row["key"]] = cell if same else {**cell, "applied": got}
drop = ("pick", "applied") if same else ("pick",)
cell = {k: v for k, v in materials.get(row["key"], {}).items() if k not in drop}
materials[row["key"]] = cell if same else {**cell, "applied": got}
return {
"patch": {
"labor_cover": labor_cover,
"machine_cover": machine_cover,
"materials": materials,
}, # fmt: skip
"patch": {"materials": materials},
"linked": linked,
"missing": {k: v[:LIST_MAX] for k, v in missing.items()},
"counts": {
@@ -38,6 +38,7 @@ from B09_Estimation import B09_Estimation_Labor_Rule as rule
from B09_Estimation import B09_Estimation_Sangeun as sg
from B09_Estimation import B09_Estimation_Sangeun_Text as st
from B09_Estimation.B09_Estimation_Bill_Items import LAYER_WORD
from B09_Estimation.B09_Estimation_Copy_Prices import bundle
from B09_Estimation.B09_Estimation_Supply import supply_mark
from M02_MasterTemplete import M02_Item_Prices as item_prices
from M02_MasterTemplete import M02_Labor_Doc as labor_doc
@@ -50,7 +51,7 @@ from M02_MasterTemplete.M02_Item_Snapshot import (
linked_snapshots,
structures_dir,
)
from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json
from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, read_json
mf = item_prices.mf
@@ -414,7 +415,7 @@ class Builder:
labor = read_json(folder / LABOR_FILE)
if not isinstance(labor, dict):
return None
prices = read_json(folder / PRICE_FILE) or {}
prices = bundle(folder) # 841-1 미리 보기 덮개
values = read_json(folder / VALUES_FILE) or {}
picks = read_json(folder / PICKS_FILE) or {}
whole = item_prices.load_prices(prices)
@@ -472,7 +473,7 @@ class Builder:
doc = copies.get(refs.ref_id(ref)) if isinstance(ref, dict) else None
if not isinstance(doc, dict):
return None
whole = item_prices.load_prices(read_json(folder / PRICE_FILE) or {})
whole = item_prices.load_prices(bundle(folder))
if manifest["종류"] == "sangeun": # 내 자료집 단가산출(719-2)
return self.sangeun(whole, ref, doc, copies, 1)
return self.precombo(whole, ref, doc, copies, 1)
+5 -4
View File
@@ -15,6 +15,7 @@ from __future__ import annotations
from pathlib import Path
from typing import Any
from B09_Estimation.B09_Estimation_Copy_Prices import bundle
from M02_MasterTemplete import M02_Item_Prices as item_prices
from M02_MasterTemplete import M02_Labor_Doc as labor_doc
from M02_MasterTemplete import M02_Labor_Fixed as fixed
@@ -24,7 +25,7 @@ from M02_MasterTemplete.M02_Item_Snapshot import (
linked_snapshots,
structures_dir,
)
from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json
from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, read_json
#: 원가 단가 목록에 오르는 자원 표
RESOURCE_TABLES = ("LB", "EQ", "MT", "MO")
@@ -37,7 +38,7 @@ def used_keys(folder: Path) -> list[str]:
labor = read_json(folder / LABOR_FILE)
if not isinstance(labor, dict):
return []
snapshot = item_prices.load_prices(read_json(folder / PRICE_FILE) or {})
snapshot = item_prices.load_prices(bundle(folder))
values = read_json(folder / VALUES_FILE) or {}
picks = read_json(folder / PICKS_FILE) or {}
seen: list[str] = []
@@ -110,8 +111,8 @@ def frozen_rows(project_root: str | Path) -> dict[str, dict[str, Any]]:
out: dict[str, dict[str, Any]] = {}
for folder in _folders(project_root):
bundle = read_json(folder / PRICE_FILE) or {}
for name, data in bundle.items():
got = bundle(folder) # 841-1 미리 보기 덮개
for name, data in got.items():
if name.startswith("_") or not isinstance(data, dict):
continue
for row in data.get("줄") or data.get("표") or []:
+19 -132
View File
@@ -1,18 +1,17 @@
"""B09 원가 단가 한 벌 · 수요 모음(PLAN 144) — 서버 단독(M01 을 읽는 곳은 여기 하나).
"""B09 자재 단가 책 · 수요 모음(PLAN 144 · 841-1) — 서버 단독(M01 을 읽는 곳은 여기 하나).
원가 단가 = 프로젝트 한 벌 — 원가산출에서 한 번 굳힘(`price_book.json`) ·
841-1 — 노무 · 기계 · 운전사 · 연료 · 환율은 책에서 걷음(B09 금액 = 사본을 M02 엔진으로 셈한 값 ·
`B09_Estimation_Copy_Prices`).
책에 남는 것 = 자재단가대비표(4 자재대 · 관급자재대 단가 고르기) 자재 줄.
자재 단가 = 프로젝트 한 벌 — 원가산출에서 한 번 굳힘(`price_book.json`) ·
값 = 사본 단가 기준 시점(795-4 · 구조물 지정 때 `단가.json` 자원 줄 · 없는 자원만 지금 M01 +
`now_keys`) ·
[단가 갱신] 때만 새로 · 대가 · 산근도 이 단가로 셈(한 직종 · 한 자재 = 한 단가).
`now_keys`) · [단가 갱신] 때만 새로.
줄 = 수요 따라(사용자 승인 ① · 10-9) — 엑셀 목록을 기본으로 깔지 않음:
연결된 사본이 부른 자원 키(`B09_Estimation_Linked` — 143-1 「연결된 사본」 원천 · 없으면 빈 표)
+ 기계가 부르는 운전사(조종원직종 → 인력 원문번호) · 연료(주연료종류 → 유가 「종류:전국평균」)
연결된 사본이 부른 자재 키(`B09_Estimation_Linked` — 143-1 「연결된 사본」 원천 · 없으면 빈 표)
+ B08 총괄자재수량집계표 자재(M01 짝 없음 → 출처 빈칸 · 적용 단가는 사용자 입력)
· 수량 = 합계 열 · 채집석 공제 줄 거름 · 키가 MT 코드(맞춤 없는 사본 자재)면
사본 자원 줄에 수량만 얹음 · 파형강관 = 관급 기본
운전사 시간 계수 = M01 CC000336(시간환산 · 상여계수 · 휴지계수 분자 · 분모 곱).
연료 지역 = 전국평균(M01 기계경비 로직 지역 첫 값) — 다른 지역 값은 자재 적용 단가를 고쳐 씀.
"""
from __future__ import annotations
@@ -25,11 +24,6 @@ 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 = {"관급": "조달청"}
@@ -65,36 +59,6 @@ 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"):
@@ -121,82 +85,22 @@ def material_input(row: dict[str, Any]) -> dict[str, Any]:
}
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] = []
"""수요 키 → 자재 줄 차례 · 찾지 못한 키(841-1 노무 · 기계 = 사본 엔진 값 · 책 밖)."""
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 not key.startswith(("MT", "MO")):
continue
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,
}
elif key not in materials:
materials.append(key)
return {"materials": materials, "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)
return material_input(_row(master, key))
def refresh_book(
@@ -207,17 +111,12 @@ def refresh_book(
`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"]]
}
rows = {key: _convert(master, key) for key in 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)),
}
@@ -228,7 +127,7 @@ def labor_editions(keys: list[str]) -> dict[str, str]:
files, master = m01.loaded()
groups = (files.get("인력.json") or {}).get("구분") or {}
out: dict[str, str] = {}
for key in demand(master, keys)["labor"]:
for key in [k for k in keys if k.startswith("LB") and _row(master, k)]:
kind = str((_row(master, key) or {}).get("구분") or "")
if (groups.get(kind) or {}).get("판"):
out[kind] = str(groups[kind]["판"])
@@ -236,14 +135,12 @@ def labor_editions(keys: list[str]) -> dict[str, str]:
def book_diff(old: dict[str, Any] | None, new: dict[str, Any]) -> dict[str, int | bool]:
"""두 책 견줌(저장 꼴 JSON) — 새 줄 · 바뀐 줄 수 · 운전사 계수 · 환율 바뀜(203-1 · 703-3)."""
"""두 책 견줌(저장 꼴 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"],
}
@@ -256,8 +153,6 @@ def book_stale(book: dict[str, Any] | None, keys: list[str]) -> str | None:
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
@@ -272,9 +167,7 @@ def extend_book(
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
]
added = [key for key in 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))}
@@ -326,7 +219,6 @@ def build_inputs(
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):
@@ -336,12 +228,7 @@ def build_inputs(
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 뒤 — 사전 차례대로 셈
}
+14 -12
View File
@@ -1,5 +1,6 @@
"""B09 [단가 갱신] · [재생성](PLAN 795-4 · 옛 [업데이트] 703-3) — 사본은 만든 때 그대로 ·
[단가 갱신](기본) = 단가 책만 지금 M01 값 · 미리 보기 `price_preview` = 바뀐 자원 · 총괄 전 → 후 ·
"""B09 [단가 갱신] · [재생성](PLAN 795-4 · 옛 [업데이트] 703-3) — 사본 로직은 만든 때 그대로 ·
[단가 갱신](기본 · 841-1) = 프로젝트 사본 자원 단가 줄(`B09_Estimation_Copy_Prices`) + 자재 단가 책만
지금 M01 값 · 미리 보기 `price_preview` = 바뀐 자원 · 총괄 전 → 후 ·
[재생성](사용자가 따로) = 아래 옛 [업데이트] — 단가 책 다시 굳힘 + 원본이 바뀐 구조물 사본 [재적용].
미리 보기(`preview`) = 바뀌는 단가 줄(새 줄 + 바뀐 줄 · 지금 수요) · 다시 가져올 구조물 사본
@@ -34,12 +35,16 @@ def _value(row: dict[str, Any] | None) -> Any:
def price_preview(
old: dict[str, Any] | None, new: dict[str, Any], before: dict, after: dict, cells: int
old: dict[str, Any] | None,
new: dict[str, Any],
before: dict,
after: dict,
cells: int,
copies: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""[단가 갱신] 미리 보기(795-4) — {할일, 바뀐자원 [{키, 이름, 전, 후}], 계수, 환율, 전, 후
(총괄 grand · 빈 줄 있으면 None), 대가 {전, 후}(일위대가 블록 합), 고친칸, 노임판} ·
사본(로직)은 안 바뀜."""
diff = prices.book_diff(old, new)
"""[단가 갱신] 미리 보기(795-4 · 841-1) — {할일, 바뀐자원 [{키, 이름, 전, 후}](사본 자원 줄 +
자재 단가 책), 전, 후(총괄 grand · 빈 줄 있으면 None), 대가 {전, 후}(일위대가 블록 합), 고친칸,
노임판} · 사본(로직)은 안 바뀜."""
was = json.loads(json.dumps((old or {}).get("rows") or {}, ensure_ascii=False))
now = json.loads(json.dumps(new["rows"], ensure_ascii=False))
rows = [
@@ -54,11 +59,10 @@ def price_preview(
def daega(sheets: dict) -> float:
return sum(float(b.get("sum") or 0) for b in sheets.get("daega_blocks") or [])
seen = {r["키"] for r in copies or []}
return {
"할일": "단가갱신",
"바뀐자원": rows,
"계수": diff["factor"],
"환율": diff["fx"],
"바뀐자원": [*(copies or []), *(r for r in rows if r["키"] not in seen)],
"전": grand(before),
"후": grand(after),
"대가": {"전": daega(before), "후": daega(after)},
@@ -138,8 +142,6 @@ def preview(
"단가줄": diff["new"] + diff["changed"],
"새줄": diff["new"],
"바뀐줄": diff["changed"],
"계수": diff["factor"],
"환율": diff["fx"],
"사본": copies + lib_names,
"단가산출": sk_changes(root, copies, libs),
"고친칸": cover_cells(cover),
+18 -7
View File
@@ -73,6 +73,7 @@ from B08_Quantity.B08_Quantity_Router_Material import once as material_once
from B08_Quantity.B08_Quantity_Router_Milestone import read_data as read_milestone
from B08_Quantity.B08_Quantity_Router_Milestone import site_info
from B09_Estimation import B09_Estimation_Bill_Library as library
from B09_Estimation import B09_Estimation_Copy_Prices as copy_prices
from B09_Estimation import B09_Estimation_Daega_Paste as daega_paste
from B09_Estimation import B09_Estimation_Daega_Personal as personal
from B09_Estimation import B09_Estimation_Linked as linked
@@ -99,7 +100,7 @@ from B09_Estimation.B09_Estimation_Summary import (
totals,
used_materials,
)
from B09_Estimation.B09_Estimation_Supply import supply_defaults
from B09_Estimation.B09_Estimation_Supply import price_lists, supply_defaults
from common_util.common_util_auth import verify_session
from common_util.common_util_project_settings import reset_section
from common_util.common_util_storage import resolve_stored_project_path
@@ -166,8 +167,10 @@ def compute_sheets(
화면 짝 = `B09_Estimation_UI_Shell.computeSheets`(산근만 서버 답 그대로) ·
memo = 두 번 셈의 앞 산근 풀이(`Sangeun.collect`)."""
supply = supply_defaults(inputs, user)
base = compute_base(inputs, user)
sg = sangeun.build(root, user, memo, supply) # 145-1 M02 엔진 값으로 풀이(841-1 · 166-2 손 줄)
blocks = (inputs.get("daega") or {}).get("blocks") or []
inputs["labor"], inputs["machines"] = price_lists(blocks, sg["sg"]) # 841-1 엔진 단가 나열
base = compute_base(inputs, user)
sg = sg_cover.cover_sangeun(sg, user.get("sg_cover")) # 719-3 이 공사 산근 고친 칸
by_key = {row["key"]: row for row in sg["sg_list"]}
labor = compute_labor(
@@ -368,8 +371,8 @@ async def refresh_prices(
body: dict[str, Any] | None = Body(None),
session: dict[str, Any] = Depends(verify_session),
) -> dict[str, Any]:
"""[단가 갱신](795-4) — 단가 책 다시 굳힘 · 사본 · 덮개 그대로 ·
job 「재생성」 = 원본이 바뀐 구조물 사본 재적용(703-3) 먼저."""
"""[단가 갱신](795-4 · 841-1) — 프로젝트 사본 자원 단가 줄 + 자재 단가 책을 지금 M01 값으로 ·
사본 로직 · 덮개 그대로 · job 「재생성」 = 원본이 바뀐 구조물 사본 재적용(703-3) 먼저."""
root = await _project_root(project_id)
raw = (body or {}).get("user")
draft = store.clean_user(raw) if isinstance(raw, dict) else None # 296 화면 캐시(저장 안 함)
@@ -377,6 +380,8 @@ async def refresh_prices(
again = {"다시": [], "막힘": []}
if regen:
again = await asyncio.to_thread(refresh.reapply_changed, root, importer(session))
news, changed = await asyncio.to_thread(copy_prices.refreshed, root)
await asyncio.to_thread(copy_prices.write, news)
keys = await _demand(root, draft)
old = await asyncio.to_thread(store.read_book, root)
book = await asyncio.to_thread(prices.refresh_book, keys, old)
@@ -387,7 +392,8 @@ async def refresh_prices(
cells = refresh.cover_cells(kept.get("daega_cover")) + refresh.cover_cells(kept.get("sg_cover"))
head = f"재생성 — 구조물 사본 {len(again['다시'])} 다시 가져옴 · " if regen else "단가 갱신 — "
out["notes"] = [
f"{head}단가 줄 {diff['new'] + diff['changed']} 바뀜 · 고친 칸 {cells} 그대로",
f"{head}사본 {len(news)} · 자원 {len(changed)} · 자재 단가 줄 "
f"{diff['new'] + diff['changed']} 바뀜 · 고친 칸 {cells} 그대로",
*(f"재적용 못 함 {why}" for why in again["막힘"]),
*(out.get("notes") or []),
]
@@ -409,10 +415,15 @@ async def refresh_preview(
if (body or {}).get("job") == refresh.REGEN:
return await asyncio.to_thread(refresh.preview, root, book, keys, user.get("daega_cover"))
new = await asyncio.to_thread(prices.refresh_book, keys, book)
news, changed = await asyncio.to_thread(copy_prices.refreshed, root)
before = await payload(project_id, root, draft, True, keys)
after = await payload(project_id, root, draft, True, keys, new)
token = copy_prices.OVER.set(news) # 841-1 사본 새 단가 = 덮개로만(파일 안 씀)
try:
after = await payload(project_id, root, draft, True, keys, new)
finally:
copy_prices.OVER.reset(token)
cells = refresh.cover_cells(user.get("daega_cover")) + refresh.cover_cells(user.get("sg_cover"))
return refresh.price_preview(book, new, before["sheets"], after["sheets"], cells)
return refresh.price_preview(book, new, before["sheets"], after["sheets"], cells, changed)
@router.post("/{project_id}/estimation/calc")
@@ -19,9 +19,8 @@
POST /api/projects/{id}/estimation/import/rates (multipart: file | file_key · user?)
원가계산서 요율(707-2) → {file_key, name, kind, rows, rates, notes, check, patch{estimate}}
POST /api/projects/{id}/estimation/import/prices (multipart: file | file_key · user?)
노임 · 자재 · 기계 단가표(707-3) → 이 공사 단가 책에 이음 →
{file_key, name, sheets, counts, linked, missing,
patch{labor_cover, machine_cover, materials}}
자재 단가표(707-3 · 841-1 노임 · 기계 걷음) → 이 공사 자재단가대비표에 이음 →
{file_key, name, sheets, counts, linked, missing, patch{materials}}
file_key = 세 길 같은 표지(`_keep` · `_saved`) — 같은 파일을 다시 올리지 않음 ·
.estx(EST 원본 · 707-5 `B09_Estimation_Estx`) · .STC(STmate 원본 · 707-6 `B09_Estimation_Stc`) =
이름 끝으로 갈림 — 내역 줄 · 단가표 같은 그릇 · 요율 = 400 「같은 공사 엑셀에서」 ·
+4 -3
View File
@@ -43,6 +43,7 @@ from typing import Any
from B08_Quantity.B08_Quantity_Haul_Cycle import fill_given
from B09_Estimation import B09_Estimation_Sangeun_Sum as ss
from B09_Estimation.B09_Estimation_Calc_Base import SUPPLY, gap, num, rounddown, total
from B09_Estimation.B09_Estimation_Copy_Prices import bundle
from B09_Estimation.B09_Estimation_Store import ref_rows
from M01_MasterData import M01_MasterData_Store as m01
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
@@ -55,7 +56,7 @@ from M02_MasterTemplete.M02_Item_Snapshot import (
linked_snapshots,
structures_dir,
)
from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json
from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, read_json
mf = item_prices.mf
import master_sheet_line as msl # noqa: E402 — 계산서 줄 덧칸(714-1) · scripts 길은 `m01` 이 둠
@@ -109,7 +110,7 @@ def rows_of(folder: Path, whole: Any = None) -> list[dict[str, Any]]:
if not isinstance(labor, dict):
return []
if whole is None:
whole = item_prices.load_prices(read_json(folder / PRICE_FILE) or {})
whole = item_prices.load_prices(bundle(folder))
values = read_json(folder / VALUES_FILE) or {}
picks = read_json(folder / PICKS_FILE) or {}
copies = labor.get("참조사본") or {}
@@ -630,7 +631,7 @@ def collect(
return copy.deepcopy(memo["sg"])
solver = Solver(supply)
for owner, folder in entries:
whole = item_prices.load_prices(read_json(folder / PRICE_FILE) or {})
whole = item_prices.load_prices(bundle(folder))
for src in rows_of(folder, whole):
solver.add(whole, src, owner)
hand_keys: dict[str, str] = {}
+6 -16
View File
@@ -4,10 +4,10 @@
거울 시험 `resources/tester/test_719_3_sangeun_cover.py`.
조작 중 = 화면이 TS 짝으로 바로 셈 · [저장] = 서버가 다시 풀이(`Sangeun.build`) 뒤 이 파일로 정본.
덮개 = 사용자 값 `sg_cover` {산근 키: {줄 열쇠 「n:이름」: {qty?, labor?, material?, expense?}}} —
qty = 1 단위 품 · 비목 = 단가 · 있는 칸만 셈 값 대신 · 줄이 없거나 이름이 다르면 짝 잃음
덮개 = 사용자 값 `sg_cover` {산근 키: {줄 열쇠 「n:이름」: {qty?}}} —
qty = 1 단위 품(841-1 단가 칸 걷음 · 옛 단가 칸은 안 읽음) · 있는 칸만 셈 값 대신 · 줄이 없거나 이름이 다르면 짝 잃음
(`sg_orphans` · 알림만 · 덮개는 남음) · 산근 키 = 로직 + 입력값 → [업데이트] 뒤에도 같은 블록.
고칠 칸(`fields`) = 노무 · 자재 · 중기 · 빈 줄 = 품 + 쓰는 비목 단가 · 하위 산근 = 품 ·
고칠 칸(`fields`) = 노무 · 자재 · 중기 · 빈 줄 · 하위 산근 = 품 ·
% 덧줄(`of` 있는 것) = 율.
다시 셈 = 덮개가 닿은 블록과 그 블록을 부르는 블록만(안 닿은 블록 = 서버 값 그대로) —
줄 금액 ROUNDDOWN(단가 × 품, 1) · 하위 산근 줄 단가 = 다시 센 그 블록 비목 합 ·
@@ -29,7 +29,7 @@ from typing import Any
from B09_Estimation.B09_Estimation_Calc_Base import gap, num, rounddown, total
PARTS = ("labor", "material", "expense")
COVER_KEYS = ("qty", *PARTS)
COVER_KEYS = ("qty",) # 841-1 품만
DEPTH = 8 # 하위 산근 깊이 상한(서버 풀이 4 보다 넉넉히)
@@ -39,21 +39,11 @@ def line_id(n: int, line: dict[str, Any]) -> str:
def fields(line: dict[str, Any]) -> list[str]:
"""고칠 수 있는 칸 — 품(qty) + 단가 비목."""
"""고칠 수 있는 칸 — 품(qty)만(841-1 단가 칸 걷음 · 단가 = M02 사본 값) · % 덧줄 = 율."""
kind = line.get("kind")
if kind == "sangeun":
return ["qty"]
if kind == "extra":
return ["qty"] if line.get("of") else []
if kind == "machine":
return ["qty", *PARTS]
if kind == "labor":
return ["qty", "labor"]
if kind == "material":
return ["qty", *[p for p in PARTS if p in (line.get("price") or {})]]
if kind == "blank":
return ["qty", *[p for p in PARTS if line.get(p) is None]]
return []
return ["qty"] if kind in ("sangeun", "machine", "labor", "material", "blank") else []
def _money(price: float | None, qty: float | None) -> float | None:
@@ -8,7 +8,7 @@
*
* 덮개 = 사용자 값 `sg_cover` {산근 키: {줄 열쇠 「n:이름」: {qty?, labor?, material?, expense?}}} —
* 있는 칸만 셈 값 대신 · 줄이 없거나 이름이 다르면 짝 잃음(`sg_orphans` · 알림만)
* 고칠 칸(`fields`) = 노무 · 자재 · 중기 · 빈 줄 = 품 + 쓰는 비목 단가 · 하위 산근 = 품 · % 덧줄(`of`) = 율
* 고칠 칸(`fields`) = 품만(841-1 단가 칸 걷음) · % 덧줄(`of`) = 율
* 다시 셈 = 덮개가 닿은 블록과 그 블록을 부르는 블록만 — 줄 금액 ROUNDDOWN(단가 × 품, 1) ·
* 하위 산근 줄 단가 = 다시 센 그 블록 비목 합 · % 덧줄 = 서버 값 × 밑수 비 × 율 비(처음이 0 · 빈칸이면
* 밑수 × 율 ÷ 100) · 총계 = 비목마다 ROUNDDOWN(Σ, 0) · 목록표 · 빈칸 목록 같이
@@ -20,7 +20,7 @@
import { gap, num, rounddown, total, type Num, type Row } from "./B09_Estimation_Calc_Base";
const PARTS = ["labor", "material", "expense"] as const;
export const SG_COVER_KEYS = ["qty", ...PARTS] as const;
export const SG_COVER_KEYS = ["qty"] as const; // 841-1 품만
const DEPTH = 8;
/** 덮개 줄 열쇠 — 블록 안 차례 + 이름 */
@@ -29,13 +29,9 @@ export const lineId = (n: number, line: Row): string => `${n}:${line.name || ""}
/** 고칠 수 있는 칸 — 품(qty) + 단가 비목 */
export function fields(line: Row): string[] {
const kind = line.kind;
if (kind === "sangeun") return ["qty"];
// 841-1 단가 칸 걷음 — 품(qty)만 · % 덧줄 = 율(짝 `fields`)
if (kind === "extra") return line.of ? ["qty"] : [];
if (kind === "machine") return ["qty", ...PARTS];
if (kind === "labor") return ["qty", "labor"];
if (kind === "material") return ["qty", ...PARTS.filter((p) => p in (line.price || {}))];
if (kind === "blank") return ["qty", ...PARTS.filter((p) => (line[p] ?? null) === null)];
return [];
return ["sangeun", "machine", "labor", "material", "blank"].includes(kind) ? ["qty"] : [];
}
const money = (price: Num | undefined, qty: Num | undefined): Num =>
+4 -12
View File
@@ -1,7 +1,7 @@
"""B09 원가 저장 자리(PLAN 144-1) — 프로젝트 안 `B09_Estimation/v1/`.
price_book.json 굳힌 단가 한 벌 {"made": 시각, "rows": {키: 입력 줄},
"driver_factor": …, "fx": […]}
price_book.json 굳힌 자재 단가 한 벌 {"made": 시각, "rows": {키: 입력 줄}}(841-1 자재만 ·
옛 책의 노무 · 기계 줄 · 계수 · 환율은 안 읽음)
— [단가 갱신] 때만 M01 로 새로 ·
[저장] 때 책에 없는 새 줄만 더함(있는 줄 안 바꿈)
user_values.json 사용자 값 {"materials": {자재 키: {applied?, supply?, quote?, quote_page?,
@@ -12,13 +12,10 @@
— 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본)
"daega_cover": {블록 cover: {줄 cid: {qty?, labor?, material?, expense?}}}
(702-2 일위대가 고친 칸 덮개 — 수만 · 셈 = `B09_Estimation_Calc_Labor`)
"sg_cover": {산근 키: {줄 「n:이름」: {qty?, labor?, material?, expense?}}}
"sg_cover": {산근 키: {줄 「n:이름」: {qty?}}}(841-1 단가 칸 걷음 · 품만)
(719-3 산근 고친 칸 — 셈 = `B09_Estimation_Sangeun_Cover`)
+ 복사본 `K:…` 의 `@copy` {from · name · spec}(703-2 · 글만)
+ 붙인 줄 `@add` [블록 줄 fixed · pct](705-2 · `clean_added`)
"labor_cover": {노무 키: 일당} · "machine_cover": {기계 키: {labor,
material, expense}}(707-3 노임 · 기계 단가 덮개 · 공사 몫 · [업데이트] 가
책만 새로 굳혀 덮개는 남음 · 셈 = `B09_Estimation_Calc_Base`)
quotes/<자재 키>/ 견적서 파일(166-4 · 여러 개) — 폴더가 목록(키의 못 쓰는 글자는 %XX) ·
읽을 때 materials[키].files = 이름들 · 사용자 값으로는 저장 안 함
@@ -36,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, machine_cover
from B09_Estimation.B09_Estimation_Calc_Base import SOURCES, SUPPLY
from B09_Estimation.B09_Estimation_Calc_Bill import SECTIONS
from B09_Estimation.B09_Estimation_Calc_Labor import ADD, COVER_KEYS, PARTS
@@ -357,11 +354,6 @@ def clean_user(raw: Any) -> dict[str, Any]:
out["daega_cover"] = cover
if cover := clean_cover(raw.get("sg_cover")): # 719-3 산근 고친 칸(같은 꼴 · 줄 「n:이름」)
out["sg_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
+25 -1
View File
@@ -1,4 +1,6 @@
"""B09 자재 관급 · 별도 표시(PLAN 841-1) — M02 자재표시를 대비표 기본 표시로 · 서버 단독 · 순수 함수.
"""B09 자재 관급 · 별도 표시 · 사본 단가 목록(PLAN 841-1) — 서버 단독 · 순수 함수.
자재 표시 = M02 자재표시를 대비표 기본 표시로 · 노무비 · 중기목록 = 엔진 단가 나열(`price_lists`).
M02 일위대가 · 단가산출 셈은 재료 줄을 자재표시(자재구분 관급 · 별도계상 — 빈 칸 = 별도계상)로
합에서 뺌(`M02_Labor_Doc.apart`) → B09 도 같은 표시로 대가 · 산근 안 0 원 · 4 자재대 · 관급자재대로
@@ -12,6 +14,8 @@ from typing import Any
from M02_MasterTemplete import M02_Labor_Doc as labor_doc
PARTS = ("labor", "material", "expense")
def supply_mark(one: Any) -> str | None:
"""M02 자재표시(841-1) → 대비표 기본 표시 — 관급 · 별도(별도계상 · M02 기본) · None(직접계산)."""
@@ -48,3 +52,23 @@ def supply_defaults(inputs: dict[str, Any], user: dict[str, Any]) -> Any:
return None if got == "사급" else got or default.get(key, marks.get(key, "별도"))
return supply
def price_lists(blocks: list[dict[str, Any]], sg_blocks: list[dict[str, Any]]) -> tuple[list, list]:
"""노무비목록표 · 중기목록표 입력(841-1) — 대가 입력 줄 `own` · 산근 줄 `price` = M02 엔진 단가
(사본 값) 나열 · 다시 안 셈 · 수량 있는 줄만. ponytail: 키 하나 = 처음 본 단가(사본마다 시점이 달라도)."""
labor: dict[str, dict] = {}
machine: dict[str, dict] = {}
for block in [*blocks, *sg_blocks]:
for ln in block.get("lines") or []:
kind = ln.get("kind")
key = ln.get("key") or (ln.get("ref") or {}).get("key")
if kind not in ("labor", "machine") or not key or not ln.get("qty"):
continue
price = ln.get("own") or ln.get("price") or {}
head = {"key": key, "name": ln.get("name") or "", "spec": ln.get("spec") or ""}
if kind == "labor" and price.get("labor") is not None:
labor.setdefault(key, {**head, "unit": "인", "price": price["labor"]})
elif kind == "machine" and None not in [price.get(p) for p in PARTS]:
machine.setdefault(key, {**head, **{p: price[p] for p in PARTS}})
return list(labor.values()), list(machine.values())
+4 -5
View File
@@ -2,7 +2,7 @@
* B09_Estimation_UI_Check.ts
* B09 좌측 「데이터 점검」 묶음(PLAN 203-1) — [단가 갱신] · 연결 점검(옛 「연결 점검」 탭 내용).
*
* [단가 갱신](795-4 · 옛 [업데이트]) = 자원 단가만 지금 M01 값 · 서버가 낡았다고 보면(`payload.stale` 까닭 한 줄)
* [단가 갱신](795-4 · 옛 [업데이트] · 841-1) = 프로젝트 사본 자원 단가 · 자재 단가만 지금 M01 값 · 서버가 낡았다고 보면(`payload.stale` 까닭 한 줄)
* 단추에 점 배지(`is-pending`) · 굳힌 시각 · 까닭 = 공용 호버
* 미리 보기 고리 = 누름을 먼저 잡아 POST …/estimation/prices/preview(화면 캐시) →
* 「총괄 금액 전 → 후 · 바뀐 자원」 창 → [적용] = 원래 단추 일(단가만 · 사본 · 덮개 그대로) ·
@@ -68,7 +68,7 @@ export function refreshTip(payload: Row | null): string {
const made = payload?.book?.made;
const when = made ? `단가 굳힘 ${dateTimeText(String(made))}` : "단가 아직 안 굳힘";
const stale = payload?.stale;
return `${when} · 누르면 미리 보기 → [적용] = 자원 단가만 지금 M01 값 · 사본(로직) · 고친 칸 그대로 · 로직까지 새로 = 미리 보기의 [재생성]${stale ? ` · 단가 갱신 가능 — ${stale}` : ""}`;
return `${when} · 누르면 미리 보기 → [적용] = 프로젝트 사본 자원 단가 · 자재 단가만 지금 M01 값 · 사본(로직) · 고친 칸 그대로 · 로직까지 새로 = 미리 보기의 [재생성]${stale ? ` · 단가 갱신 가능 — ${stale}` : ""}`;
}
/** [재생성] 미리 보기 글 줄(703-3 옛 [업데이트]) */
@@ -77,8 +77,7 @@ export function previewLines(got: Row): string[] {
const sks: Row[] = got.단가산출 || [];
const edition = got.노임판 || {};
return [
`바뀌는 단가 줄 ${got.단가줄 ?? 0}(새 줄 ${got.새줄 ?? 0} · 값 바뀜 ${got.바뀐줄 ?? 0})` +
`${got.계수 ? " · 운전사 계수 바뀜" : ""}${got.환율 ? " · 환율 바뀜" : ""}`,
`바뀌는 자재 단가 줄 ${got.단가줄 ?? 0}(새 줄 ${got.새줄 ?? 0} · 값 바뀜 ${got.바뀐줄 ?? 0})`,
`다시 가져올 사본 ${copies.length}${copies.length ? ` — ${copies.map((c) => c.이름).join(" · ")}` : ""}`,
...(sks.length
? [`M02 단가산출 바뀜 ${sks.length} — ${sks.map((s) => `${s.이름}(${s.판})`).join(" · ")}`]
@@ -105,7 +104,7 @@ export function priceLines(got: Row): string[] {
const shown = 30;
return [
`총괄 금액 ${won(got.전)} → ${won(got.후)} 원 · 대가 합 ${won(got.대가?.전)} → ${won(got.대가?.후)} 원`,
`바뀐 자원 ${rows.length}${got.계수 ? " · 운전사 계수 바뀜" : ""}${got.환율 ? " · 환율 바뀜" : ""}`,
`바뀐 자원 ${rows.length}(사본 자원 단가 · 자재 단가)`,
...rows
.slice(0, shown)
.map((r) => `· ${r.이름 || r.키} ${priceText(r.전)} → ${priceText(r.후)}`),
+4 -6
View File
@@ -16,7 +16,7 @@
*
* 요율 · 기초 단가표 불러오기(PLAN 707-2 · 707-3) — 저장 안 함.
* 단추 = 보조 탭 줄 도구 자리(`…_UI_TabBar` setTools) — 총괄 · 원가계산서 탭 [엑셀에서 요율 불러오기] ·
* 노무비목록표 · 중기목록표 · 자재단가대비표 탭 [엑셀에서 단가표 불러오기]
* 자재단가대비표 탭 [엑셀에서 단가표 불러오기](841-1 노임 · 기계 걷음)
* 길 = `B09_Estimation_Router_Import.py` import/rates · import/prices(file | file_key · user = 화면 캐시) →
* 확인 창(짝 표 · 식 다름 · 짝 없음 n) → [적용] = 답 `patch` 를 캐시에 얹음 · [저장] 때 서버 정본
* 올린 파일 = 내역 줄 · 요율 · 단가표 세 길이 같은 표지(file_key · `last`) — 같은 파일 다시 안 올림
@@ -65,7 +65,7 @@ async function post(projectId: string, path: string, body: BodyInit, json = fals
/** 받은 캐시 = 지금 캐시 + 내역 덮개 · 원가계산서 값(조달수수료율 · 요율) · 단가표 덮개(707-7) */
function merged(user: Row, made: Row): Row {
const keep = ["labor_cover", "machine_cover", "materials"].filter((k) => k in made);
const keep = ["materials"].filter((k) => k in made); // 841-1 노임 · 기계 덮개 걷음
return {
...user,
bill: made.bill,
@@ -227,7 +227,7 @@ export function createBillImport(
later ? `원가계산서 요율 — ${later}` : "원가계산서 요율 — 율 덮개",
{ checked: !later, disabled: !!later },
);
const prices = createCheckbox("단가표 — 노임 · 자재 · 기계 단가를 이 공사 단가 책에 덮음", {
const prices = createCheckbox("단가표 — 자재 단가를 이 공사 자재단가대비표에 덮음", {
checked: true,
});
const take = createCheckbox("넘겨받기 — 불러온 수량을 씀(수량 산출 값은 곁에)", {
@@ -330,9 +330,7 @@ type Kind = "rates" | "prices";
const TABS: Partial<Record<TabId, Kind>> = {
total: "rates",
cost: "rates",
labor: "prices",
machine: "prices",
compare: "prices",
compare: "prices", // 841-1 노임 · 기계 단가 덮개 걷음 — 자재단가대비표만
};
const LABEL: Record<Kind, string> = {
rates: "엑셀에서 요율 불러오기",
+37 -134
View File
@@ -12,23 +12,22 @@
* · 「견적서」 칸(견적 묶음 아래 · 183-2) 누름 = 파일 모달(`B09_Estimation_UI_Tabs_Base_Quote`)(166-4)
* 견적서 글자 = 견적 단가 수기 줄만(185-5 · 나머지 빈칸 · 빈칸 누름 = 안 엶)
* · 출처 묶음 = 단가 칸 하나 · 출처 뜻(M01 면수 · 유가 기준일)은 단가 칸 호버(185-4 `sourceInfo`)
* 이동 = 재료비목록표 줄 → 대비표 · 중기목록표 줄 → 중기사용료 블록 · 블록 줄 → 경비 · 기초 · 대비표
* 이동 = 재료비목록표 줄 → 대비표
* 841-1 노무비목록표 · 중기목록표 = 프로젝트 사본(M02) 엔진 단가 나열 · 경비목록표 · 중기사용료 ·
* 환율및기초자료 = 「—」 한 줄 + 까닭 한 줄(B09 가 기계 시간당 · 운전사 노임을 따로 셈하던 탭)
* ========================================================================== */
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
import type { DataRow } from "@ui/ui_template_data_table";
import { SOURCES, type Row } from "./B09_Estimation_Calc_Base";
import { formatPrice } from "../common_util/common_util_money";
import { dateText } from "../common_util/common_util_time";
import { openQuotes } from "./B09_Estimation_UI_Tabs_Base_Quote";
import {
blockLevel,
money,
parseNumber,
qty,
text,
type TabBuild,
type TabId,
type TabTable,
} from "./B09_Estimation_UI_Tabs";
@@ -51,69 +50,34 @@ function priceList(rows: Row[], unitPrice: string, hint: string): TabTable {
};
}
export const buildLabor: TabBuild = ({ sheets, payload }) =>
/** 841-1 B09 가 따로 셈하던 탭 — 「—」 한 줄 + 까닭 한 줄(값 = M02 단가산출 그대로) */
const GONE =
"M02 단가산출 값을 그대로 씀 — B09 가 따로 셈하지 않음(기계 시간당 · 운전사 노임 포함)";
const gone = (columns: TabTable["columns"]): TabTable => ({
columns,
rows: [{ key: "-", name: "—" }],
hint: GONE,
});
export const buildLabor: TabBuild = ({ sheets }) =>
priceList(
sheets.labor || [],
"단가(원/인)",
`이 공사 단가 책(${madeText(payload)}) 기준 · 새 노임은 [단가 갱신]`,
"대가 · 산근이 쓰는 노임 = 프로젝트 사본(M02) 값 나열 · 새 값은 [단가 갱신]",
);
export const buildExpense: TabBuild = ({ sheets, payload }) =>
priceList(
sheets.expense || [],
"기준가(천원)",
`이 공사 단가 책(${madeText(payload)}) 기준 · 새 노임은 [단가 갱신]`,
);
export const buildExpense: TabBuild = () => gone(priceList([], "기준가(천원)", "").columns);
function madeText(payload: Row): string {
return payload.book?.made ? `${dateText(String(payload.book.made))} 정함` : "아직 안 정함";
}
export const buildBasis: TabBuild = ({ sheets }): TabTable => {
const basis: Row = sheets.basis || { fx: [], drivers: [] };
const rows: DataRow[] = [
...basis.fx.map((f: Row) => ({
part: "1. 환율",
key: f.key,
name: f.name,
spec: f.date,
unit: f.unit,
day: null,
factor: "",
value: f.value,
})),
...basis.drivers.map((d: Row) => ({
part: "2. 인건비",
key: d.key,
name: d.name,
spec: `직종 ${d.key}`,
unit: "원/시간",
day: d.day,
factor: d.factor,
value: d.hour,
labor_key: d.labor_key,
})),
];
return {
columns: [
text("part", "구분", 90),
text("name", "명칭", 160),
text("spec", "규격 · 기준일", 120),
text("unit", "단위", 80),
money("day", "일당(원)"),
text("factor", "시간 환산", 160),
money("value", "값", undefined, 2),
],
rows,
frozen: 2,
mergeSame: ["part"],
hint: "인건비 줄을 누르면 노무비목록표로 이동",
link: (i) => {
const row = rows[i];
return row?.labor_key ? { tab: "labor", key: String(row.labor_key) } : null;
},
};
};
export const buildBasis: TabBuild = (): TabTable =>
gone([
text("part", "구분", 90),
text("name", "명칭", 160),
text("spec", "규격 · 기준일", 120),
text("unit", "단위", 80),
money("day", "일당(원)"),
text("factor", "시간 환산", 160),
money("value", "값", undefined, 2),
]);
// 841-1 기본 표시(M02 자재표시)가 있어도 끌 수 있게 — 별도 다음 = 사급(표시 끔)
const SUPPLY_NEXT: Record<string, string | null> = { "": "관급", 관급: "별도", 별도: "사급" };
@@ -326,79 +290,18 @@ export const buildMachine: TabBuild = ({ sheets }): TabTable => {
],
rows,
frozen: 2,
hint: LINK_HINT,
link: (i) => (rows[i] ? { tab: "machine_blocks", key: String(rows[i].key) } : null),
hint: "대가 · 산근이 쓰는 기계 시간당 = 프로젝트 사본(M02 단가산출) 값 나열 · 새 값은 [단가 갱신]",
};
};
const KIND_TAB: Record<string, TabId> = {
expense: "expense",
labor: "basis",
material: "compare",
};
export const buildMachineBlocks: TabBuild = ({ sheets }): TabTable => {
const rows: DataRow[] = [];
const links: Array<{ tab: TabId; key: string } | null> = [];
(sheets.machine_blocks || []).forEach((b: Row, n: number) => {
rows.push({
key: b.key,
name: `제 ${n + 1} 호표 ${b.name}`,
no: n + 1,
title: b.name, // 171-3 묶음 카드 머리
spec: b.spec,
unit: b.unit,
});
links.push(null);
for (const ln of b.lines) {
const at = (part: string): number | null => (ln.kind === part ? ln.price : null);
const amt = (part: string): number | null => (ln.kind === part ? ln.amount : null);
rows.push({
key: `${b.key}|${ln.name}`,
name: ln.name,
spec: ln.spec,
qty: ln.qty,
unit: ln.unit,
sum_price: ln.price,
sum: ln.amount,
labor_price: at("labor"),
labor: amt("labor"),
material_price: at("material"),
material: amt("material"),
expense_price: at("expense"),
expense: amt("expense"),
});
links.push(ln.ref?.key ? { tab: KIND_TAB[ln.kind] ?? "compare", key: ln.ref.key } : null);
}
rows.push({
key: `${b.key}|합계`,
name: "합 계",
sum: b.sum,
labor: b.labor,
material: b.material,
expense: b.expense,
});
links.push(null);
});
const pair = (key: string, label: string) => [
money(`${key}_price`, "단가", [label], 3),
money(key, "금액", [label], 1),
];
return {
columns: [
text("name", "명칭", 220),
text("spec", "규격", 120),
qty("qty", "수량"),
text("unit", "단위", 56),
...pair("sum", "합계"),
...pair("labor", "노무비"),
...pair("material", "재료비"),
...pair("expense", "경비"),
],
rows,
frozen: 1,
hint: LINK_HINT,
link: (i) => links[i] ?? null,
level: blockLevel("name", (i) => !String(rows[i]?.key).includes("|")),
};
};
export const buildMachineBlocks: TabBuild = (): TabTable =>
gone([
text("name", "명칭", 220),
text("spec", "규격", 120),
qty("qty", "수량"),
text("unit", "단위", 56),
money("sum", "금액", ["합계"], 1),
money("labor", "금액", ["노무비"], 1),
money("material", "금액", ["재료비"], 1),
money("expense", "금액", ["경비"], 1),
]);
@@ -4,8 +4,8 @@
*
* 값 = 일위대가표와 같은 셈(`sheets.daega_blocks` · `B09_Estimation_Calc_Labor`) — 펼친 값 = 일위대가표 값
* 줄 = 호표 줄(품명 · 규격 · 품 · 수량 · 단위 · 노 · 재 · 경 단가 · 금액 · 품셈 값) · 부른 대가 = 그 아래 「ㄴ」(3단까지)
* 고친 칸 덮개(702-2) = 품 · 수량 · 단가 칸 두 번 눌러 고침 → 사용자 값 `daega_cover`
* {블록 cover: {줄 cid: {qty · labor · material · expense}}} · 캐시에 바로 · 셈 짝이 바로 다시 셈 ·
* 고친 칸 덮개(702-2) = 품 칸 두 번 눌러 고침 → 사용자 값 `daega_cover`(841-1 단가 칸 걷음)
* {블록 cover: {줄 cid: {qty}}} · 캐시에 바로 · 셈 짝이 바로 다시 셈 ·
* [저장] 때 서버가 덮개 입력값으로 정본을 다시 셈 · 노임 새 판 · [재적용] 뒤에도 남음(702-4)
* 고친 칸 = 칸 색(`is-edited`) · 「품셈 값」 칸 = 셈 값 · 우클릭 [품셈 값으로] · 빈칸 입력 = 되돌림
* M02 단가산출 블록 줄(`sk` · 719-2) = 되돌림 [단가산출 값으로](셈 값 = 단가산출 값) · 곁 글 = 품셈 값(`poom`)
@@ -37,13 +37,8 @@ import {
} from "../M02_MasterTemplete/M02_MasterTemplete_Hopyo_Relink";
import { relinkRows, type PasteCols } from "../M02_MasterTemplete/M02_MasterTemplete_Hopyo_Api";
/** 고칠 칸 → 덮개 칸 */
const FIELD: Record<string, string> = {
qty: "qty",
labor_price: "labor",
material_price: "material",
expense_price: "expense",
};
/** 고칠 칸 → 덮개 칸 — 841-1 품만(단가 = M02 사본 값 · 고치지 않음) */
const FIELD: Record<string, string> = { qty: "qty" };
const LABEL: Record<string, string> = { qty: "품", labor: "노", material: "재", expense: "경" };
/** 부른 대가 펼침 깊이 */
const DEPTH = 3;