Merge origin/sub_laptop_7 into sub_laptop_8 — PLAN 707-2 · 707-3 요율 · 단가표 불러오기 · M02 낱말 · B09 Store 머리 설명 겹침 양쪽 살림(705-2 @add · 707-3 노임 · 기계 덮개)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -143,6 +143,7 @@ function comboSection(
|
||||
onEdit: (kind) => openEdit(copy, name, kind, load),
|
||||
warnOnFail: () => false, // 못 읽으면 카드에 「없음」
|
||||
labor: () => openEdit(copy, name, "inputs", load), // 일위대가 카드(161-1) [수정] = 넣을 값만
|
||||
picker: true,
|
||||
});
|
||||
handles.push(cards);
|
||||
section.append(cards.root);
|
||||
|
||||
@@ -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 COVER_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 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);
|
||||
@@ -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(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,
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""B09 경쟁사 기초 단가표 불러오기(PLAN 707-3) — 서버 단독 · 저장 안 함
|
||||
(덮개 제안 → 화면 캐시 → [저장]).
|
||||
|
||||
읽는 시트(꼴마다 첫 것) — 머리 = 「명칭」 · 「품명」 칸이 든 줄:
|
||||
노임 = 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 먼저) ·
|
||||
책 값과 같으면 덮지 않음(옛 덮개 걷음).
|
||||
못 이은 줄 = 「짝 없음 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": "경비"}
|
||||
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}]."""
|
||||
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
|
||||
out = []
|
||||
for line in rows[at + 1 :]:
|
||||
|
||||
def cell(col: int | None, line: list[Any] = line) -> Any:
|
||||
return line[col] if col is not None and col < len(line) else None
|
||||
|
||||
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"] != "%"
|
||||
if item["name"] and ok:
|
||||
out.append(item)
|
||||
return out
|
||||
return []
|
||||
|
||||
|
||||
def read_prices(name: str, data: bytes) -> dict[str, list[dict[str, Any]]]:
|
||||
"""파일 → {labor, material, machine: 줄 목록} · 시트 없음 = 빈 목록 · 셋 다 없으면 막힘."""
|
||||
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[f"{kind}_sheet"] = sheet
|
||||
if not any(out[k] for k in KINDS):
|
||||
raise ExcelError("노무비목록표 · 재료비목록표 · 중기목록표(STmate) · 노임단가 · 자재단가 · "
|
||||
"기계경비총괄표(EST) 시트가 없음") # fmt: skip
|
||||
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"))
|
||||
return [f"{_n(me['이름'])}|{_n(me['규격'])}|{unit}", f"{_n(me['이름'])}||{unit}"]
|
||||
|
||||
|
||||
def _match(book: list[dict[str, Any]], keys_of, item: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""책 줄 하나 — 단계마다 한 줄에만 맞으면 그 줄 · 여럿 · 없음이면 다음 단계."""
|
||||
for step, want in enumerate(keys_of(item)):
|
||||
hits = [r for r in book if keys_of(r)[step] == want] if want else []
|
||||
if len(hits) == 1:
|
||||
return hits[0]
|
||||
return None
|
||||
|
||||
|
||||
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 {})
|
||||
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}
|
||||
for kind in KINDS:
|
||||
items = read.get(kind) or []
|
||||
rows = [_match(base[kind], keys[kind], item) for item in items]
|
||||
hits = Counter(r["key"] for r in rows if r)
|
||||
for item, row in zip(items, rows, strict=True):
|
||||
label = " ".join(x for x in (item["name"], item["spec"]) if x)
|
||||
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")
|
||||
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}
|
||||
return {
|
||||
"patch": {
|
||||
"labor_cover": labor_cover,
|
||||
"machine_cover": machine_cover,
|
||||
"materials": materials,
|
||||
}, # fmt: skip
|
||||
"linked": linked,
|
||||
"missing": {k: v[:LIST_MAX] for k, v in missing.items()},
|
||||
"counts": {
|
||||
k: {
|
||||
"read": len(read.get(k) or []),
|
||||
"linked": sum(x["kind"] == k for x in linked),
|
||||
"covered": sum(x["kind"] == k and not x["same"] for x in linked),
|
||||
"book": len(base[k]),
|
||||
"missing": len(missing[k]),
|
||||
}
|
||||
for k in KINDS
|
||||
}, # fmt: skip
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
"""B09 경쟁사 원가계산서 요율 불러오기(PLAN 707-2) — 서버 단독 · 저장 안 함
|
||||
(제안만 → 화면 캐시 → [저장]).
|
||||
|
||||
읽는 시트 = STmate 「공사원가계산서」(율 = 비고 글 「직접노무비 x 14.5%」) ·
|
||||
EST 「원가계산서」(율 = 「요율」 칸 · 비면 산출근거 글).
|
||||
머리 = 「금액」 칸이 든 줄 · 행 이름 = 금액 칸 앞 마지막 한글 두 글자 넘는 글
|
||||
(EST 세로 머리 · 구분 칸 뺌).
|
||||
행 이름 → 원가계산서 줄 키(`ROWS` · RT 코드 = `B09_Estimation_Calc_Total` 줄 표) →
|
||||
율 덮개 `estimate.rates{줄 키: %}` ·
|
||||
손입력 칸 = 지금 칸(`B09_Estimation_Store.ESTIMATE_FIELDS`):
|
||||
safety_base = 산업안전보건관리비 글 「% + 5,349,000」(글 있고 + 없으면 0 · 글 없으면 안 실음) ·
|
||||
indirect_material · scrap = 그 줄 금액 ·
|
||||
profit_adjust = ROUNDDOWN((순공사원가 − 재료비 + 일반관리비) × 율) − 엑셀 이윤 ·
|
||||
vat_adjust = 엑셀 부가세 − ROUNDDOWN(총원가 × 율) ·
|
||||
procurement_fee_pct = 관급 조달수수료 ÷ 순자재대(소수 2~4 자리 중 수수료를 그대로 내는 첫 값).
|
||||
대조(`check`) = 엑셀 비목 합 + 실은 값으로 우리 식(`compute_total`)을 돌려 줄마다 엑셀 금액과 견줌 —
|
||||
1 원 넘게 다른 줄 = 「식 다름」(노인장기요양 · 산업안전보건관리비 A/B 등 식 꼴이 다른 비목) ·
|
||||
우리 표에 없는 행(폐기물처리 · 국가지점번호판 …) · 셈 없는 현행 비목(퇴직공제부금비 …)
|
||||
= 「짝 없음」 · 「셈 없음」.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import num, rounddown
|
||||
from B09_Estimation.B09_Estimation_Calc_Total import (
|
||||
CURRENT_ONLY,
|
||||
RATE_LINES,
|
||||
TAIL_LINES,
|
||||
compute_total,
|
||||
)
|
||||
from M02_MasterTemplete.M02_Hopyo_Excel import DRM, ExcelError
|
||||
|
||||
SHEETS = ("공사원가계산서", "원가계산서")
|
||||
#: 행 이름 앞머리(빈칸 뺀 글) → 키 — 율 줄 = 원가계산서 줄 키 · 그 밖 = 비목 합 · 대조용
|
||||
ROWS: tuple[tuple[str, str], ...] = (
|
||||
("직접재료비", "material_direct"),
|
||||
("간접재료비", "indirect_material"),
|
||||
("작업설", "scrap"),
|
||||
("직접노무비", "labor"),
|
||||
("간접노무비", "indirect_labor"),
|
||||
("산출경비", "expense"),
|
||||
("산재보험", "accident"),
|
||||
("고용보험", "employment"),
|
||||
("건강보험", "health"),
|
||||
("노인장기요양", "care"),
|
||||
("연금보험", "pension"),
|
||||
("산업안전보건관리비", "safety"),
|
||||
("기타경비", "other"),
|
||||
("환경보전비", "environment"),
|
||||
("건설기계대여금", "machine"),
|
||||
("퇴직공제", "retirement"),
|
||||
("임금채권", "wage_claim"),
|
||||
("석면", "asbestos"),
|
||||
("하도급대금", "subcontract"),
|
||||
("공사이행보증", "performance"),
|
||||
("일반관리비", "overhead"),
|
||||
("이윤", "profit"),
|
||||
("총원가", "supply_price"),
|
||||
("부가가치세", "vat"),
|
||||
("도급", "contract"),
|
||||
("관급자재대", "supply"),
|
||||
("총공사비", "grand"),
|
||||
("순공사원가", "net_cost"),
|
||||
("소계", "subtotal"),
|
||||
)
|
||||
RATE_KEYS = {key: (name, rt) for key, name, rt, _ in (*RATE_LINES, *TAIL_LINES)}
|
||||
NO_CALC = {key: (name, rt) for key, name, rt in CURRENT_ONLY}
|
||||
_PCT = re.compile(r"[x×*]\s*([\d.]+)\s*%")
|
||||
_BASE = re.compile(r"%\s*\+\s*([\d,]+)")
|
||||
_HANGUL = re.compile(r"[가-힣]")
|
||||
_FEE = re.compile(r"원자재대\s*:\s*([\d,]+).*?조달수수료\s*:\s*([\d,]+)")
|
||||
|
||||
|
||||
def flat(value: Any) -> str:
|
||||
return re.sub(r"\s+", "", str(value or ""))
|
||||
|
||||
|
||||
def read_sheets(name: str, data: bytes, wanted: tuple[str, ...]) -> dict[str, list[list[Any]]]:
|
||||
"""시트 이름 → 줄 목록(`wanted` 만 · 이름 앞뒤 빈칸 뺌) — xlsx · xls · DRM 막힘."""
|
||||
if not data:
|
||||
raise ExcelError("빈 파일")
|
||||
if b"DRMONE" in data[:256] and data[:4] not in (b"PK\x03\x04", b"\xd0\xcf\x11\xe0"):
|
||||
raise ExcelError(DRM)
|
||||
try:
|
||||
if data[:4] == b"PK\x03\x04" or name.lower().endswith(".xlsx"):
|
||||
import openpyxl
|
||||
|
||||
book = openpyxl.load_workbook(io.BytesIO(data), read_only=True, data_only=True)
|
||||
out = {
|
||||
ws.title.strip(): [list(row) for row in ws.iter_rows(values_only=True)]
|
||||
for ws in book.worksheets
|
||||
if ws.title.strip() in wanted
|
||||
}
|
||||
book.close()
|
||||
return out
|
||||
import xlrd
|
||||
|
||||
book = xlrd.open_workbook(file_contents=data, logfile=io.StringIO())
|
||||
return {
|
||||
sh.name.strip(): [sh.row_values(r) for r in range(sh.nrows)]
|
||||
for sh in book.sheets()
|
||||
if sh.name.strip() in wanted
|
||||
}
|
||||
except Exception as error: # 라이브러리마다 다른 예외 — 깨진 파일 · 엑셀 아님
|
||||
raise ExcelError("엑셀 파일을 읽지 못함 — xls · xlsx 가 아니거나 깨진 파일") from error
|
||||
|
||||
|
||||
def money(value: Any) -> float | None:
|
||||
"""칸 → 수(「80,165,010」 글도) · 아니면 None."""
|
||||
if isinstance(value, str):
|
||||
value = value.replace(",", "").strip()
|
||||
try:
|
||||
return float(value) if value else None
|
||||
except ValueError:
|
||||
return None
|
||||
return num(value)
|
||||
|
||||
|
||||
def _pct(value: Any) -> float | None:
|
||||
"""요율 칸 — 「16.5%」 글 · 엑셀 % 수(0.165) → %."""
|
||||
if isinstance(value, str):
|
||||
return money(value.replace("%", "")) if "%" in value else None
|
||||
got = num(value)
|
||||
return None if got is None else round(got * 100, 6)
|
||||
|
||||
|
||||
def _key(name: str) -> str | None:
|
||||
return next((key for head, key in ROWS if name.startswith(head)), None)
|
||||
|
||||
|
||||
def parse(rows: list[list[Any]]) -> dict[str, Any]:
|
||||
"""원가계산서 줄 → [{name, key, amount, rate, text, more}] — 머리 = 「금액」 칸 든 줄."""
|
||||
head = next((i for i, r in enumerate(rows[:12]) if "금액" in [flat(v) for v in r]), None)
|
||||
if head is None:
|
||||
raise ExcelError("원가계산서 머리(금액 칸)를 못 찾음")
|
||||
cols = [flat(v) for v in rows[head]]
|
||||
at = cols.index("금액")
|
||||
rate_col = cols.index("요율") if "요율" in cols else None
|
||||
text_col = next((cols.index(h) for h in ("산출근거", "비고") if h in cols), None)
|
||||
out = []
|
||||
for row in rows[head + 1 :]:
|
||||
names = [flat(v) for v in row[:at] if len(_HANGUL.findall(str(v or ""))) > 1]
|
||||
amount = money(row[at]) if at < len(row) else None
|
||||
if not names:
|
||||
continue
|
||||
text = str(row[text_col] or "") if text_col is not None and text_col < len(row) else ""
|
||||
rate = _pct(row[rate_col]) if rate_col is not None and rate_col < len(row) else None
|
||||
if rate is None and (m := _PCT.search(text)):
|
||||
rate = float(m.group(1))
|
||||
tail = row[max(at, text_col or 0) + 1 :]
|
||||
more = [v for v in (money(x) for x in tail) if v is not None]
|
||||
out.append({"name": names[-1], "key": _key(names[-1]), "amount": amount,
|
||||
"rate": rate, "text": text.strip(), "more": more}) # fmt: skip
|
||||
return {"rows": out}
|
||||
|
||||
|
||||
def _fee(row: dict[str, Any]) -> tuple[float, float] | None:
|
||||
"""관급 줄 → (순자재대, 조달수수료) — STmate 글 「원자재대: X (조달수수료: Y 포함)」 ·
|
||||
EST 뒤 칸 둘."""
|
||||
if m := _FEE.search(row["text"].replace("\n", " ")):
|
||||
raw, fee = (float(g.replace(",", "")) for g in m.groups())
|
||||
return raw - fee, fee
|
||||
if len(row["more"]) >= 2 and row["more"][0] > row["more"][1] > 0:
|
||||
return row["more"][0] - row["more"][1], row["more"][1]
|
||||
return None
|
||||
|
||||
|
||||
def _fee_pct(net: float, fee: float) -> float | None:
|
||||
for places in (2, 3, 4):
|
||||
pct = round(fee / net * 100, places)
|
||||
if rounddown(net * pct / 100) == fee:
|
||||
return pct
|
||||
return None
|
||||
|
||||
|
||||
def read_rates(name: str, data: bytes) -> dict[str, Any]:
|
||||
"""파일 → {kind, rows, estimate(실을 값), excel(키: 금액), notes}."""
|
||||
sheets = read_sheets(name, data, SHEETS)
|
||||
sheet = next((s for s in SHEETS if s in sheets), None)
|
||||
if sheet is None:
|
||||
raise ExcelError("「공사원가계산서」(STmate) · 「원가계산서」(EST) 시트가 없음")
|
||||
rows = parse(sheets[sheet])["rows"]
|
||||
excel: dict[str, float] = {}
|
||||
subtotals: list[float] = []
|
||||
rates: dict[str, float] = {}
|
||||
estimate: dict[str, Any] = {}
|
||||
notes: list[str] = []
|
||||
for row in rows:
|
||||
key, amount = row["key"], row["amount"]
|
||||
if key == "subtotal":
|
||||
subtotals.append(amount or 0.0)
|
||||
continue
|
||||
if key is None:
|
||||
if amount:
|
||||
notes.append(
|
||||
f"짝 없음 — 「{row['name']}」 {amount:,.0f} 원(우리 원가계산서에 줄 없음)"
|
||||
)
|
||||
continue
|
||||
if key == "safety" and key in excel: # EST A · B 두 줄 = 작은 쪽(우리 식과 같음)
|
||||
excel[key] = min(excel[key], amount or excel[key])
|
||||
continue
|
||||
excel[key] = amount if amount is not None else excel.get(key, 0.0)
|
||||
if key in RATE_KEYS or key in NO_CALC:
|
||||
if row["rate"] is not None:
|
||||
rates[key] = row["rate"]
|
||||
elif amount:
|
||||
notes.append(f"율 없음 — 「{row['name']}」 산출근거 빈칸 · 지금 율 그대로")
|
||||
if key in NO_CALC and amount:
|
||||
notes.append(
|
||||
f"셈 없음 — 「{row['name']}」 {amount:,.0f} 원 · 율 {row['rate']}% 만 실음"
|
||||
"(우리 원가계산서 줄만 · 총액에 안 듦)"
|
||||
)
|
||||
if key == "safety" and row["text"]:
|
||||
got = _BASE.search(row["text"])
|
||||
estimate["safety_base"] = float(got.group(1).replace(",", "")) if got else 0.0
|
||||
if key in ("indirect_material", "scrap") and amount:
|
||||
estimate[key] = amount
|
||||
if key == "supply" and (fee := _fee(row)):
|
||||
excel["supply_net"], excel["supply_fee"] = fee
|
||||
if (pct := _fee_pct(*fee)) is not None:
|
||||
estimate["procurement_fee_pct"] = pct
|
||||
else:
|
||||
notes.append(
|
||||
f"조달수수료 {fee[1]:,.0f} 원 — 한 율로 안 맞음(품목마다 셈) · 수수료율 손입력"
|
||||
)
|
||||
excel["material"] = subtotals[0] if subtotals else excel.get("material_direct")
|
||||
net = excel.get("net_cost") or (sum(subtotals[:3]) if len(subtotals) >= 3 else None)
|
||||
if net is not None and excel.get("overhead") is not None and "profit" in rates:
|
||||
base = net - (excel["material"] or 0.0) + excel["overhead"]
|
||||
adjust = rounddown(base * rates["profit"] / 100) - (excel.get("profit") or 0.0)
|
||||
if adjust:
|
||||
estimate["profit_adjust"] = adjust
|
||||
if excel.get("supply_price") is not None and "vat" in rates and "vat" in excel:
|
||||
adjust = excel["vat"] - rounddown(excel["supply_price"] * rates["vat"] / 100)
|
||||
if adjust:
|
||||
estimate["vat_adjust"] = adjust
|
||||
kind = "STmate" if sheet == "공사원가계산서" else "EST"
|
||||
return {"kind": kind, "sheet": sheet, "rows": rows, "rates": rates,
|
||||
"estimate": estimate, "excel": excel, "notes": notes} # fmt: skip
|
||||
|
||||
|
||||
NAMES = {"supply": "관급자재대", "supply_price": "총원가(공급가액)", "contract": "도급공사비",
|
||||
"grand": "총공사비"} # fmt: skip
|
||||
#: 대조 줄 — 엑셀 키 → 우리 셈 자리
|
||||
_OURS = {
|
||||
**{key: ("lines", key) for key in RATE_KEYS},
|
||||
"supply": ("lines", "supply"),
|
||||
"supply_price": ("subtotals", "supply_price"),
|
||||
"contract": ("subtotals", "contract"),
|
||||
"grand": ("subtotals", "grand"),
|
||||
}
|
||||
|
||||
|
||||
def check(read: dict[str, Any], estimate: dict[str, Any] | None, table: Any) -> dict[str, Any]:
|
||||
"""엑셀 비목 합 + (지금 사용자 값 위에 실은 값)으로 우리 식 → 줄마다 엑셀과 견줌."""
|
||||
excel = read["excel"]
|
||||
mine = dict(estimate or {})
|
||||
est = {**mine, **read["estimate"], "rates": {**(mine.get("rates") or {}), **read["rates"]}}
|
||||
net, fee = excel.get("supply_net", 0.0), excel.get("supply_fee", 0.0)
|
||||
if "procurement_fee_pct" not in read["estimate"] and net: # 한 율로 못 맞춘 수수료 = 그 비율째
|
||||
est["procurement_fee_pct"] = fee / net * 100
|
||||
sums = {"material": excel.get("material"), "labor": excel.get("labor"),
|
||||
"expense": excel.get("expense"), "supply_net": net} # fmt: skip
|
||||
got = compute_total(sums, est, table)
|
||||
lines = []
|
||||
for key, (part, at) in _OURS.items():
|
||||
if key not in excel:
|
||||
continue
|
||||
ours = got[part].get(at)
|
||||
same = ours is not None and abs(ours - excel[key]) < 1
|
||||
name = RATE_KEYS[key][0] if key in RATE_KEYS else NAMES[key]
|
||||
lines.append({"key": key, "name": name, "excel": excel[key], "ours": ours, "same": same})
|
||||
return {"lines": lines, "grand": got["subtotals"]["grand"], "excel_grand": excel.get("grand"),
|
||||
"differ": [ln["name"] for ln in lines if not ln["same"]]} # fmt: skip
|
||||
@@ -41,7 +41,7 @@
|
||||
지난 공사 일위대가 → 이 공사 서고 사본(덮개째) → {폴더, 만듦, key, cover, 덮개, payload} ·
|
||||
덮개 = 그 공사 저장 칸(화면 캐시 cover 가 비었을 때만 얹음 · 같은 꼴 블록이 먼저 있으면 빈 것) ·
|
||||
같은 회사만(남의 회사 403)
|
||||
경쟁사 내역 줄 불러오기(707-1) = `B09_Estimation_Router_Import`.
|
||||
경쟁사 결과물 불러오기(707-1 내역 줄 · 707-2 요율 · 707-3 단가표) = `B09_Estimation_Router_Import`.
|
||||
운반 사본 연결 = 셈 때마다 거리로 사본 다시 고름(`Bill_Library.resolve_hauls` · `fix_keys`).
|
||||
|
||||
저장 자리 = 프로젝트 안 `B09_Estimation/v1/`(`B09_Estimation_Store`) ·
|
||||
@@ -586,7 +586,7 @@ async def past_import(
|
||||
return {**made, "key": key, "cover": cover, "payload": out}
|
||||
|
||||
|
||||
# 166-4 견적서 파일 길 · 707-1 경쟁사 내역 줄 불러오기 길 — 같은 머리 · 같은 권한
|
||||
# 166-4 견적서 파일 길 · 707-1~3 경쟁사 결과물 불러오기 길 — 같은 머리 · 같은 권한
|
||||
router.include_router(quotes_router)
|
||||
router.include_router(import_router)
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""B09 경쟁사 내역 줄 불러오기 길(PLAN 707-1) — 저장 안 함(화면 캐시로 → [저장] 때 서버 정본).
|
||||
"""B09 경쟁사 결과물 불러오기 길 — 내역 줄(707-1) · 요율(707-2) · 기초 단가표(707-3) · 저장 안 함
|
||||
(화면 캐시로 → [저장] 때 서버 정본).
|
||||
|
||||
POST /api/projects/{id}/estimation/import/bill (multipart: file | file_key · user?)
|
||||
읽기 + 미리 보기 — 파일 = 프로젝트 `B09_Estimation/v1/import/{표지}/`
|
||||
@@ -10,6 +11,14 @@
|
||||
{user(새 캐시), 알림, 어긋남, 엑셀합계, payload(그 캐시로 셈)} ·
|
||||
이음 = 읽을 수 있는 층(개인 → 회사 → 시스템)에서 같은 파일 이름으로 들여온 실무자료 사전조합
|
||||
(본뜬 「HP-… 제N호표」) → 서고 사본(층 쓰기 0 · 프로젝트 사본만) · 대가 N 줄 연결.
|
||||
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}}
|
||||
file_key = 세 길 같은 표지(`_keep` · `_saved`) — 같은 파일을 다시 올리지 않음 ·
|
||||
화면은 `patch` 를 캐시에 얹고 [저장] 때 서버가 정본으로 다시 셈.
|
||||
`B09_Estimation_Router` 가 끼워 넣음(같은 머리 · 같은 권한).
|
||||
"""
|
||||
|
||||
@@ -28,17 +37,24 @@ from pydantic import BaseModel, Field
|
||||
from B08_Quantity.B08_Quantity_Router_Hydro import _project_root
|
||||
from B09_Estimation import B09_Estimation_Bill_Import as importer
|
||||
from B09_Estimation import B09_Estimation_Bill_Library as library
|
||||
from B09_Estimation import B09_Estimation_Import_Prices as import_prices
|
||||
from B09_Estimation import B09_Estimation_Import_Rates as import_rates
|
||||
from B09_Estimation import B09_Estimation_Prices as prices
|
||||
from B09_Estimation import B09_Estimation_Store as store
|
||||
from B09_Estimation.B09_Estimation_Bill_Excel import BillExcelError, read_bill
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import compute_base
|
||||
from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs
|
||||
from B09_Estimation.B09_Estimation_Rates import rate_table
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete import M02_Hopyo_Build as hopyo_build
|
||||
from M02_MasterTemplete import M02_Labor_Ref as refs
|
||||
from M02_MasterTemplete import M02_Layer_Access as access
|
||||
from M02_MasterTemplete.M02_Hopyo_Excel import ExcelError
|
||||
from M02_MasterTemplete.M02_Item_Snapshot import importer as who_of
|
||||
|
||||
router = APIRouter()
|
||||
PATH = "/{project_id}/estimation/import/bill"
|
||||
ROOT = "/{project_id}/estimation/import"
|
||||
PATH = ROOT + "/bill"
|
||||
IMPORT = "import"
|
||||
MAX_BYTES = 50 * 1024 * 1024
|
||||
_TAG = re.compile(r"^[0-9a-f]{8}$")
|
||||
@@ -190,3 +206,70 @@ async def apply_bill(
|
||||
"엑셀합계": parsed["합계"],
|
||||
"payload": out,
|
||||
}
|
||||
|
||||
|
||||
async def _file(
|
||||
file: UploadFile | None, file_key: str | None, root: Path
|
||||
) -> tuple[str, str, bytes]:
|
||||
"""올린 파일 · 표지 → (표지, 이름, 내용) — 올린 파일은 내역 줄 길과 같은 자리에 둠."""
|
||||
if file is not None:
|
||||
data = await file.read(MAX_BYTES + 1)
|
||||
if len(data) > MAX_BYTES:
|
||||
raise HTTPException(status_code=400, detail="파일이 너무 큼(50MB 넘음)")
|
||||
key = await asyncio.to_thread(_keep, root, file.filename or "", data)
|
||||
return key, store.safe_name(file.filename or "") or "내역.xlsx", data
|
||||
if not file_key:
|
||||
raise HTTPException(status_code=400, detail="file 또는 file_key 가 필요함")
|
||||
path = _saved(root, file_key)
|
||||
return file_key, path.name, path.read_bytes()
|
||||
|
||||
|
||||
@router.post(ROOT + "/rates")
|
||||
async def import_rate_sheet(
|
||||
project_id: UUID,
|
||||
file: UploadFile | None = File(None),
|
||||
file_key: str | None = Form(None),
|
||||
user: str | None = Form(None),
|
||||
) -> dict[str, Any]:
|
||||
root = await _project_root(project_id)
|
||||
key, name, data = await _file(file, file_key, root)
|
||||
draft = _draft(user)
|
||||
mine = (draft if draft is not None else await asyncio.to_thread(store.read_user, root))[
|
||||
"estimate"
|
||||
]
|
||||
try:
|
||||
read = await asyncio.to_thread(import_rates.read_rates, name, data)
|
||||
except ExcelError as error:
|
||||
raise HTTPException(400, str(error)) from error
|
||||
check = await asyncio.to_thread(import_rates.check, read, mine, rate_table())
|
||||
estimate = {**mine, **read["estimate"], "rates": {**(mine.get("rates") or {}), **read["rates"]}}
|
||||
keep = ("kind", "sheet", "rows", "rates", "notes")
|
||||
return {"file_key": key, "name": name, **{k: read[k] for k in keep}, "check": check,
|
||||
"patch": {"estimate": estimate}} # fmt: skip
|
||||
|
||||
|
||||
@router.post(ROOT + "/prices")
|
||||
async def import_price_sheets(
|
||||
project_id: UUID,
|
||||
file: UploadFile | None = File(None),
|
||||
file_key: str | None = Form(None),
|
||||
user: str | None = Form(None),
|
||||
) -> dict[str, Any]:
|
||||
from B09_Estimation import B09_Estimation_Router as main # 서로 부름 — 부를 때만
|
||||
|
||||
root = await _project_root(project_id)
|
||||
key, name, data = await _file(file, file_key, root)
|
||||
draft = _draft(user)
|
||||
mine = draft if draft is not None else await asyncio.to_thread(store.read_user, root)
|
||||
try:
|
||||
read = await asyncio.to_thread(import_prices.read_prices, name, data)
|
||||
except ExcelError as error:
|
||||
raise HTTPException(400, str(error)) from error
|
||||
keys = await main._demand(root) # 화면 단가 목록(GET)과 같은 수요 — 사본 전부
|
||||
b08 = await main._b08_for(project_id, [], True)
|
||||
book = await asyncio.to_thread(store.read_book, root)
|
||||
inputs = await asyncio.to_thread(prices.build_inputs, book, keys, b08["materials"])
|
||||
base = compute_base(inputs, {}) # 덮개 뺀 이 공사 단가 책
|
||||
out = import_prices.propose(read, base, mine)
|
||||
sheets = {k: read[f"{k}_sheet"] for k in import_prices.KINDS}
|
||||
return {"file_key": key, "name": name, "sheets": sheets, **out}
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
(702-2 일위대가 고친 칸 덮개 — 수만 · 셈 = `B09_Estimation_Calc_Labor`)
|
||||
+ 복사본 `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 = 이름들 · 사용자 값으로는 저장 안 함
|
||||
@@ -31,7 +34,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 ADD, COVER_KEYS, PARTS
|
||||
|
||||
@@ -331,6 +334,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
|
||||
|
||||
@@ -7,11 +7,18 @@
|
||||
* [불러오기] = `POST …/import/bill/apply` → 받은 원가 자료 · 새 캐시(내역 덮개 · 조달수수료율) → [저장]해야 남음
|
||||
* 어긋난 줄(엑셀 금액 ≠ 우리 금액) = 같은 창 목록(줄 · 엑셀 · 우리 · 까닭) · 없으면 창 닫고 알림
|
||||
* DRM 파일 = 서버 400 「읽을 수 없음」 알림
|
||||
*
|
||||
* 요율 · 기초 단가표 불러오기(PLAN 707-2 · 707-3) — 저장 안 함.
|
||||
* 단추 = 보조 탭 줄 도구 자리(`…_UI_TabBar` setTools) — 총괄 · 원가계산서 탭 [엑셀에서 요율 불러오기] ·
|
||||
* 노무비목록표 · 중기목록표 · 자재단가대비표 탭 [엑셀에서 단가표 불러오기]
|
||||
* 길 = `B09_Estimation_Router_Import.py` import/rates · import/prices(file | file_key · user = 화면 캐시) →
|
||||
* 확인 창(짝 표 · 식 다름 · 짝 없음 n) → [적용] = 답 `patch` 를 캐시에 얹음 · [저장] 때 서버 정본
|
||||
* 올린 파일 = 내역 줄 · 요율 · 단가표 세 길이 같은 표지(file_key · `last`) — 같은 파일 다시 안 올림
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { createCheckbox } from "@ui/ui_template_checkbox";
|
||||
import { createDataTable } from "@ui/ui_template_data_table";
|
||||
import { createDataTable, type DataColumn, type DataRow } from "@ui/ui_template_data_table";
|
||||
import { pickFiles } from "@ui/ui_template_drop";
|
||||
import {
|
||||
createButton,
|
||||
@@ -22,7 +29,9 @@ import {
|
||||
} from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { currentProjectId } from "../A00_Common/b_project_switch";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import { money, qty, text, type TabContext, type TabId } from "./B09_Estimation_UI_Tabs";
|
||||
|
||||
const won = (v: unknown): string => Number(v || 0).toLocaleString("ko-KR");
|
||||
|
||||
@@ -164,7 +173,10 @@ export function createBillImport(
|
||||
form.append("user", JSON.stringify(user()));
|
||||
showLoadingOverlay();
|
||||
post(projectId, "bill", form)
|
||||
.then((seen) => open(seen, mode))
|
||||
.then((seen) => {
|
||||
last = { key: String(seen.file_key), name: String(seen.파일) }; // 요율 · 단가표가 다시 씀
|
||||
open(seen, mode);
|
||||
})
|
||||
.catch((error: unknown) =>
|
||||
showToast(`읽지 못함 — ${error instanceof Error ? error.message : error}`, "error"),
|
||||
)
|
||||
@@ -174,3 +186,173 @@ export function createBillImport(
|
||||
button.dataset.b09Import = "";
|
||||
return button;
|
||||
}
|
||||
|
||||
/* ── 707-2 · 3 요율 · 기초 단가표 ── */
|
||||
|
||||
type Kind = "rates" | "prices";
|
||||
const TABS: Partial<Record<TabId, Kind>> = {
|
||||
total: "rates",
|
||||
cost: "rates",
|
||||
labor: "prices",
|
||||
machine: "prices",
|
||||
compare: "prices",
|
||||
};
|
||||
const LABEL: Record<Kind, string> = {
|
||||
rates: "엑셀에서 요율 불러오기",
|
||||
prices: "엑셀에서 단가표 불러오기",
|
||||
};
|
||||
const PART: Record<string, string> = { labor: "노임", material: "자재", machine: "기계" };
|
||||
|
||||
/** 마지막에 올린 파일(서버 기억 열쇠) — 같은 파일 다시 씀 */
|
||||
let last: { key: string; name: string } | null = null;
|
||||
|
||||
async function postSheet(kind: Kind, ctx: TabContext, file: File | null): Promise<Row> {
|
||||
const form = new FormData();
|
||||
if (file) form.append("file", file);
|
||||
else if (last) form.append("file_key", last.key);
|
||||
form.append("user", JSON.stringify(ctx.user));
|
||||
const url = `${API_BASE_URL}/projects/${currentProjectId()}/estimation/import/${kind}`;
|
||||
const response = await fetch(url, { method: "POST", body: form, credentials: "include" });
|
||||
const body = (await response.json().catch(() => null)) as Row | null;
|
||||
if (!response.ok) {
|
||||
if (response.status === 404 && !file) last = null; // 지난 파일 = 다시 고름
|
||||
throw new Error(typeof body?.detail === "string" ? body.detail : `HTTP ${response.status}`);
|
||||
}
|
||||
last = { key: String(body!.file_key), name: String(body!.name) };
|
||||
return body!;
|
||||
}
|
||||
|
||||
const wonOrBlank = (v: unknown): string =>
|
||||
typeof v === "number" ? Math.round(v).toLocaleString("en-US") : "";
|
||||
|
||||
function ratesView(got: Row): { table: DataRow[]; lines: string[]; columns: DataColumn[] } {
|
||||
const check: Row[] = got.check?.lines || [];
|
||||
const ours = Object.fromEntries(check.map((ln) => [ln.key, ln]));
|
||||
const table = (got.rows as Row[])
|
||||
.filter((r) => r.key && r.key !== "subtotal")
|
||||
.map((r, i) => ({
|
||||
key: String(i),
|
||||
name: r.name,
|
||||
rate: r.rate,
|
||||
excel: r.amount,
|
||||
ours: ours[r.key]?.ours ?? null,
|
||||
// 산업안전보건관리비 A · B 두 줄(EST) = 작은 쪽만 우리 식과 견줌
|
||||
same: !ours[r.key]
|
||||
? ""
|
||||
: Math.abs((r.amount ?? NaN) - ours[r.key].ours) < 1
|
||||
? "같음"
|
||||
: ours[r.key].same
|
||||
? "안 고른 쪽"
|
||||
: "식 다름",
|
||||
}));
|
||||
const grand = got.check?.grand;
|
||||
const lines = [
|
||||
`율 ${Object.keys(got.rates || {}).length} 줄 실음 · 총공사비 우리 ${wonOrBlank(grand)} / 엑셀 ${wonOrBlank(
|
||||
got.check?.excel_grand,
|
||||
)}`,
|
||||
...(got.notes as string[]),
|
||||
];
|
||||
const columns = [
|
||||
text("name", "행 이름", 180),
|
||||
qty("rate", "율(%)", 4),
|
||||
money("excel", "엑셀 금액"),
|
||||
money("ours", "우리 식"),
|
||||
text("same", "대조", 72),
|
||||
];
|
||||
return { table, lines, columns };
|
||||
}
|
||||
|
||||
function pricesView(got: Row): { table: DataRow[]; lines: string[]; columns: DataColumn[] } {
|
||||
const show = (v: unknown): string =>
|
||||
v && typeof v === "object"
|
||||
? ["labor", "material", "expense"].map((p) => wonOrBlank((v as Row)[p])).join(" / ")
|
||||
: wonOrBlank(v);
|
||||
const table = (got.linked as Row[]).map((x, i) => ({
|
||||
key: String(i),
|
||||
part: PART[x.kind] || x.kind,
|
||||
from: x.from,
|
||||
name: x.name,
|
||||
excel: show(x.excel),
|
||||
book: show(x.book),
|
||||
same: x.same ? "같음" : "덮음",
|
||||
}));
|
||||
const counts: Row = got.counts || {};
|
||||
const lines = Object.entries(PART).map(([k, name]) => {
|
||||
const c: Row = counts[k] || {};
|
||||
const miss: string[] = got.missing?.[k] || [];
|
||||
return `${name} — 읽음 ${c.read ?? 0} · 이음 ${c.linked ?? 0}(덮음 ${c.covered ?? 0}) · 이 공사 줄 ${
|
||||
c.book ?? 0
|
||||
} · 짝 없음 ${c.missing ?? 0}${miss.length ? `(${miss.slice(0, 8).join(", ")}${miss.length > 8 ? " …" : ""})` : ""}`;
|
||||
});
|
||||
const columns = [
|
||||
text("part", "갈래", 56),
|
||||
text("from", "엑셀 이름", 200),
|
||||
text("name", "이 공사 줄", 160),
|
||||
text("excel", "엑셀 단가", 150),
|
||||
text("book", "이 공사 단가", 150),
|
||||
text("same", "", 56),
|
||||
];
|
||||
return { table, lines, columns };
|
||||
}
|
||||
|
||||
function openResult(kind: Kind, ctx: TabContext, got: Row): void {
|
||||
const view = kind === "rates" ? ratesView(got) : pricesView(got);
|
||||
openModal({
|
||||
title: `${LABEL[kind]} — ${got.name}`,
|
||||
closeLabel: "닫기",
|
||||
width: "960px",
|
||||
mount: (body, close, actions) => {
|
||||
const apply = createButton({
|
||||
label: "적용",
|
||||
variant: "filled",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
ctx.setUser({ ...ctx.user, ...got.patch });
|
||||
showToast("적용 — [저장] 해야 남음", "success");
|
||||
close();
|
||||
},
|
||||
});
|
||||
const other = createButton({
|
||||
label: "다른 파일",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
close();
|
||||
pick(kind, ctx);
|
||||
},
|
||||
});
|
||||
actions.append(other, apply);
|
||||
const table = createDataTable({ columns: view.columns, rows: view.table, empty: "" });
|
||||
body.append(...view.lines.map((t) => el("p", { text: t })), table.root);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const fail = (error: unknown): void => showToast(`불러오지 못함 — ${String(error)}`, "error");
|
||||
|
||||
function pick(kind: Kind, ctx: TabContext): void {
|
||||
pickFiles(
|
||||
{ accept: ".xlsx,.xls" },
|
||||
([file]) => void postSheet(kind, ctx, file).then((got) => openResult(kind, ctx, got), fail),
|
||||
);
|
||||
}
|
||||
|
||||
/** 탭 도구 자리 단추 — 불러오기 없는 탭 = 빈 목록 */
|
||||
export function importTools(tab: TabId, ctx: TabContext): HTMLElement[] {
|
||||
const kind = TABS[tab];
|
||||
if (!kind) return [];
|
||||
const button = createButton({
|
||||
label: LABEL[kind],
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
last
|
||||
? void postSheet(kind, ctx, null).then(
|
||||
(got) => openResult(kind, ctx, got),
|
||||
() => pick(kind, ctx),
|
||||
)
|
||||
: pick(kind, ctx),
|
||||
});
|
||||
if (last) button.title = `마지막 파일 ${last.name} 로 · 다른 파일은 창에서`;
|
||||
return [button];
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ import { createCheckPanel } from "./B09_Estimation_UI_Check";
|
||||
import { createLiveCalc, markWaiting, waitingKeys, withoutWaiting } from "./B09_Estimation_UI_Live";
|
||||
import { tableOptionsOf, tabTableOf } from "./B09_Estimation_UI_Builders";
|
||||
import { createDock, linkMode } from "./B09_Estimation_UI_Dock";
|
||||
import { createBillImport } from "./B09_Estimation_UI_Import";
|
||||
import { createBillImport, importTools } from "./B09_Estimation_UI_Import";
|
||||
import { closePopups, openRowPopup } from "./B09_Estimation_UI_Popup";
|
||||
import {
|
||||
DRAFT,
|
||||
@@ -192,6 +192,7 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
const spec = tabTableOf(tab, ctx);
|
||||
if (!spec) return void panel.replaceChildren(note(LATER[tab] ?? "다음 단계에서 채움"));
|
||||
if (typeof spec === "string") return void panel.replaceChildren(note(spec));
|
||||
bars.setTools(importTools(tab, ctx)); // 707-2 · 3 엑셀에서 요율 · 단가표 불러오기
|
||||
// 303 행 누름 = 팝업(기본 · 누른 줄 곁 표 조각) · 하이퍼링크(그 탭 그 줄로)
|
||||
const goto = (link: Link | null, anchor?: Element | null): void => {
|
||||
if (!link) return;
|
||||
|
||||
@@ -132,10 +132,10 @@ export interface InputsValueHandle {
|
||||
const VALUE_TEXT = {
|
||||
title: "넣을 값",
|
||||
missing: "빠진 로직 {value}",
|
||||
edit: "넣을 값 수정",
|
||||
edit: "옵션 고치기",
|
||||
none: "넣을 값이 없음 — 단가산출 로직에 입력이 없음",
|
||||
auto: "자동값",
|
||||
unconfirmed: "확인 안 함",
|
||||
unconfirmed: "값 확인 전",
|
||||
confirmed: "✓ 확인함",
|
||||
confirmBox: "확인",
|
||||
summary: "넣을 값 {value}",
|
||||
|
||||
@@ -58,6 +58,7 @@ export const PART_WORD: Record<CardKind, string> = {
|
||||
const TEXT = {
|
||||
drawingInfo: "도면 정보",
|
||||
edit: "수정", // 카드 셋 같은 글(40-2) — 무엇을 고치는지는 툴팁
|
||||
editOptions: "옵션 고치기",
|
||||
noNumber: "도번 없음",
|
||||
noDrawing: "도면 없음",
|
||||
editTip: "{value} 수정 — 전체 편집 화면",
|
||||
@@ -108,6 +109,9 @@ export interface ItemCardsOptions {
|
||||
/** 일위대가 카드(39-5 · 몸 = 일위대가 상세 카드 갈래 `Labor_Detail` · 43-4) — 도면 다음 · 지금 층에서 읽음 ·
|
||||
* [일위대가 수정] = 이것(일위대가 상세) */
|
||||
labor?: () => void;
|
||||
/** 구조물 조합 고르기 창 · B08 도면 창(707) — 일위대가 카드 [수정] = 넣을 값만 → 글 「옵션 고치기」 ·
|
||||
* 카드 번호(SD · SU · SA) 숨김 */
|
||||
picker?: boolean;
|
||||
}
|
||||
|
||||
/** 카드 스프레드시트를 다루는 손잡이 — 알약이 씀 */
|
||||
@@ -191,6 +195,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
|
||||
save: () => Promise<void>,
|
||||
) => {
|
||||
const no = createBadge("", "accent"); // 번호 = 배지 부품
|
||||
no.hidden = !!opts.picker;
|
||||
no.classList.add("m02-struct__no");
|
||||
const name = el("span", { className: "m02-struct__title" });
|
||||
const shell = cardShell({ title, body, sub: [no, name] });
|
||||
@@ -204,7 +209,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
|
||||
);
|
||||
const help = helpButton(shell, title, CARD_HELP[kind]); // 제목 바로 옆(47-1)
|
||||
const edit = createButton({
|
||||
label: TEXT.edit,
|
||||
label: kind === "labor" && opts.picker ? TEXT.editOptions : TEXT.edit,
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
});
|
||||
@@ -340,7 +345,7 @@ export function createItemCards(opts: ItemCardsOptions): ItemCardsHandle {
|
||||
const defaults: Record<CardKind, string> = {
|
||||
structure: TEXT.edit,
|
||||
area: TEXT.edit,
|
||||
labor: TEXT.edit,
|
||||
labor: opts.picker ? TEXT.editOptions : TEXT.edit,
|
||||
};
|
||||
|
||||
const root = el("div", {
|
||||
|
||||
@@ -56,7 +56,7 @@ export function openLaborInput(opt: LaborInputOptions): void {
|
||||
const draft: LaborValues = { ...kept };
|
||||
const bad = new Set<string>();
|
||||
openModal({
|
||||
title: fill(tc("In_Title"), opt.title),
|
||||
title: fill(tc("In_Title"), opt.title.replace(/^[A-Z]{2}\d{6}\s+/, "")), // 707 번호(GF …) 숨김
|
||||
closeLabel: tc("In_Close"),
|
||||
dialogClass: "m02-modal m02-labin",
|
||||
mount: (body, close, head) => {
|
||||
|
||||
@@ -40,8 +40,10 @@ import { createText } from "@ui/ui_template_text";
|
||||
|
||||
export { withHopyo } from "./M02_MasterTemplete_Hopyo_Pick";
|
||||
|
||||
/** 구분 보이는 글(707 낱말) — 값(저장 글)은 그대로 */
|
||||
const CLASS_TEXT: Record<string, string> = { 실무자료: "실무 엑셀 일위대가" };
|
||||
const opts = (values: string[]): { value: string; text: string }[] =>
|
||||
[L("M01_All"), ...values].map((v, i) => ({ value: i ? v : "", text: v }));
|
||||
[L("M01_All"), ...values].map((v, i) => ({ value: i ? v : "", text: CLASS_TEXT[v] ?? v }));
|
||||
|
||||
type Scope = Omit<LaborRef, "키">;
|
||||
/** 덧붙일 줄 하나 — kind = 종류 고르기 값 */
|
||||
@@ -215,7 +217,11 @@ export function openLogicPicker(
|
||||
key: x.row.이름,
|
||||
tag: tc(x.kind),
|
||||
name: x.row.제목 || x.row.이름,
|
||||
sub: [x.row.이름, `${x.row.구분 || tc("Lib_Unsorted")} ${x.row.상세구분 ?? ""}`.trim()]
|
||||
// 707 번호(SC · SP 키) 숨김 — 제목이 없을 때만 이름 자리에 씀
|
||||
sub: [
|
||||
x.row.제목 ? "" : x.row.이름,
|
||||
`${CLASS_TEXT[x.row.구분 ?? ""] ?? (x.row.구분 || tc("Lib_Unsorted"))} ${x.row.상세구분 ?? ""}`.trim(),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
unit: x.row.단위 ?? "",
|
||||
|
||||
@@ -9,7 +9,7 @@ const TEXT = {
|
||||
Title: ["일위대가 조합", "Unit-price combination"],
|
||||
ReadOnly: ["읽기만 — 이 층은 고칠 수 없음", "Read only — this layer cannot be edited"],
|
||||
Sub: ["구분", "Category"],
|
||||
Detail: ["상세구분", "Detail"],
|
||||
Detail: ["세부 구분", "Detail"],
|
||||
All: ["전체", "All"],
|
||||
Empty: ["조합이 없음 — [새 조합]으로 만듦", "No combinations — press [New combination]"],
|
||||
New: ["새 조합", "New combination"],
|
||||
@@ -138,7 +138,7 @@ const TEXT = {
|
||||
Mark_Direct: ["직접계산", "Direct"],
|
||||
Mark_In: ["포함", "Included"],
|
||||
Mark_Out: ["미포함", "Excluded"],
|
||||
In_Title: ["넣을 값 — {value}", "Inputs — {value}"],
|
||||
In_Title: ["옵션 — {value}", "Inputs — {value}"],
|
||||
In_None: ["이 로직은 넣을 값이 없음", "This logic has no inputs"],
|
||||
In_Memo: ["메모", "Memo"],
|
||||
In_Layer: ["켜쌓기 적용율 (%)", "Layered-course rate (%)"],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -76,8 +76,8 @@ def _run_ts(inputs, user):
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", source + body],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
"--input-type=module-typescript"], # 글은 stdin — 명령 줄 32 K 넘김(Windows)
|
||||
input=source + body, capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
@@ -248,8 +248,8 @@ def test_거울_블록_나누기():
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", calc + body],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
"--input-type=module-typescript"], # 글은 stdin — 명령 줄 32 K 넘김(Windows)
|
||||
input=calc + body, capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert json.loads(result.stdout) == compute_estimation(SPLIT_INPUTS, USER, DAEGA.get)
|
||||
|
||||
@@ -75,8 +75,8 @@ def _rows(sheets):
|
||||
def _node(source: str) -> str:
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", source],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
"--input-type=module-typescript"], # 글은 stdin — 명령 줄 32 K 넘김(Windows)
|
||||
input=source, capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return result.stdout
|
||||
|
||||
@@ -112,8 +112,8 @@ def test_B09_표_만드는_길_한_곳_더블클릭_기본():
|
||||
calls = [
|
||||
p.name for p in _sources("B09_Estimation") if "createDataTable({" in p.read_text("utf-8")
|
||||
]
|
||||
# 303-2 팝업 창 · 707-1 불러오기 어긋난 줄 = 보기 전용(고칠 칸 없음) · 옵션 = 같은 표 짜기(Builders) ·
|
||||
# 702-1 펼침 표 = 제 표(두 번 눌러 고침)
|
||||
# 303-2 팝업 창 · 707-1 불러오기 어긋난 줄 · 707-2 · 3 확인 창 = 보기 전용(고칠 칸 없음) ·
|
||||
# 옵션 = 같은 표 짜기(Builders) · 702-1 펼침 표 = 제 표(두 번 눌러 고침)
|
||||
assert calls == [
|
||||
"B09_Estimation_UI_Import.ts",
|
||||
"B09_Estimation_UI_Popup.ts",
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""707-2 원가계산서 요율 · 707-3 기초 단가표 불러오기 — 실무 xlsx 넷(EST 거창 · STmate 영월 · 울진 대흥 ·
|
||||
울진 소광) · 이음 규칙 · 덮개 지킴(새 판 [업데이트] 뒤).
|
||||
|
||||
총액 대조 = 엑셀 비목 합 + 실은 요율 · 손입력으로 우리 식(`compute_total`) → 엑셀 총공사비:
|
||||
대흥 = 같음 · 영월 = 「국가지점번호판검증수수료」 762,000 원만 다름(우리 표에 줄 없음) ·
|
||||
거창 = 퇴직공제부금비(셈 없는 현행 비목)만큼 · 소광 = 산출근거 빈칸 율 넷 · 폐기물처리 줄.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B09_Estimation import B09_Estimation_Import_Prices as ip # noqa: E402
|
||||
from B09_Estimation import B09_Estimation_Import_Rates as ir # noqa: E402
|
||||
from B09_Estimation import B09_Estimation_Store as store # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import compute_base # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Rates import rate_table # noqa: E402
|
||||
|
||||
K = ROOT / "resources" / "knowledge" / "original" / "실무문서"
|
||||
FILES = {
|
||||
"거창": "01.산불거창(거창북상병곡)/01.내역서/00.25년_산불_거창(거창,북상,병곡,산8외)내역+수량.xlsx",
|
||||
"영월": "05. 동부청 영월/내역서/2024년 임도시설사업2차(산불진화_기번6)-영월.xlsx",
|
||||
"대흥": "2024년 간선임도(기번3-울진.대흥)(공통)/1. 설계원본/01 내역자료/2024년 간선임도사업(기번3).xlsx",
|
||||
"소광": "25년 산불진화임도(기번8)(울진소광)/"
|
||||
"2025년 산불진화임도 신설사업(기번8 울진.금강송.소광.산29외).xlsx",
|
||||
}
|
||||
|
||||
|
||||
def _file(tag):
|
||||
path = K / FILES[tag]
|
||||
if not path.is_file():
|
||||
pytest.skip(f"실무 xlsx 없음 — {tag}")
|
||||
return path.name, path.read_bytes()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def rates():
|
||||
return {tag: ir.read_rates(*_file(tag)) for tag in FILES}
|
||||
|
||||
|
||||
def test_707_2_요율_짝(rates):
|
||||
dh = rates["대흥"]
|
||||
assert dh["kind"] == "STmate" and rates["거창"]["kind"] == "EST"
|
||||
assert len(dh["rates"]) == 13 # 율 줄 10 + 일반관리비 · 이윤 · 부가세
|
||||
assert (dh["rates"]["indirect_labor"], dh["rates"]["safety"], dh["rates"]["profit"]) == (
|
||||
14.5,
|
||||
1.86,
|
||||
15.0,
|
||||
)
|
||||
assert dh["estimate"]["safety_base"] == 5349000 # 「x1.86% + 5,349,000」
|
||||
assert dh["estimate"]["procurement_fee_pct"] == 0.54 # 375,160 ÷ 69,474,220
|
||||
gc = rates["거창"] # EST 요율 칸 · 건강보험 글 「13.595%」 대신 칸 3.595
|
||||
assert (gc["rates"]["health"], gc["rates"]["care"], gc["rates"]["retirement"]) == (
|
||||
3.595,
|
||||
13.14,
|
||||
2.3,
|
||||
)
|
||||
assert gc["estimate"]["safety_base"] == 0 # 글에 + 기초액 없음
|
||||
assert any("퇴직공제부금비" in n and "셈 없음" in n for n in gc["notes"])
|
||||
sk = rates["소광"]
|
||||
assert sum("율 없음" in n for n in sk["notes"]) == 4 # 산출근거 빈칸 — 지금 율 그대로
|
||||
assert any("페기물처리" in n for n in sk["notes"])
|
||||
|
||||
|
||||
def test_707_2_총액_대조(rates):
|
||||
got = {tag: ir.check(r, {}, rate_table()) for tag, r in rates.items()}
|
||||
assert got["대흥"]["grand"] == got["대흥"]["excel_grand"] == 1201879000
|
||||
assert got["대흥"]["differ"] == []
|
||||
assert got["영월"]["excel_grand"] - got["영월"]["grand"] == 762000 # 국가지점번호판 줄 몫
|
||||
assert set(got["영월"]["differ"]) == {"도급공사비", "총공사비"}
|
||||
lines = {ln["key"]: ln for ln in got["거창"]["lines"]}
|
||||
rate_keys = ("indirect_labor", "accident", "employment", "health", "care", "pension")
|
||||
assert all(lines[k]["same"] for k in (*rate_keys, "safety", "other", "environment"))
|
||||
assert not lines["overhead"]["same"] # 퇴직공제부금비 5,603,907 이 순공사원가에 없음
|
||||
assert lines["overhead"]["excel"] - lines["overhead"]["ours"] == 448312 # = 5,603,907 × 8 %
|
||||
|
||||
|
||||
def test_707_3_단가표_읽기():
|
||||
counts = {}
|
||||
for tag in FILES:
|
||||
got = ip.read_prices(*_file(tag))
|
||||
counts[tag] = tuple(len(got[k]) for k in ip.KINDS)
|
||||
assert counts == {"거창": (17, 52, 22), "영월": (16, 55, 29), "대흥": (12, 39, 25),
|
||||
"소광": (14, 35, 24)} # fmt: skip
|
||||
|
||||
|
||||
BASE = {
|
||||
"labor": [
|
||||
{"key": "LB1", "name": "석공", "spec": "", "unit": "인", "price": 268908},
|
||||
{"key": "LB2", "name": "보통인부", "spec": "", "unit": "인", "price": 172068},
|
||||
],
|
||||
"machine": [
|
||||
{
|
||||
"key": "EQ1",
|
||||
"name": "굴착기(무한궤도)",
|
||||
"spec": "0.7㎥",
|
||||
"labor": 1,
|
||||
"material": 2,
|
||||
"expense": 3,
|
||||
},
|
||||
{
|
||||
"key": "EQ2",
|
||||
"name": "굴착기(무한궤도)",
|
||||
"spec": "0.2㎥",
|
||||
"labor": 1,
|
||||
"material": 2,
|
||||
"expense": 3,
|
||||
},
|
||||
], # fmt: skip
|
||||
"compare": [
|
||||
{"key": "MO1", "name": "휘발유", "spec": "전국평균", "unit": "L", "applied": 1863.25},
|
||||
{"key": "MT1", "name": "레미콘", "spec": "25-18-80", "unit": "㎥", "applied": 92330},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _m(name, spec, n):
|
||||
return {"name": name, "spec": spec, "unit": "hr", "labor": n, "material": n, "expense": n}
|
||||
|
||||
|
||||
READ = {
|
||||
"labor": [
|
||||
{"name": "석 공", "spec": "", "unit": "인", "price": 258935},
|
||||
{"name": "보통인부", "spec": "", "unit": "인", "price": 172068}, # 책과 같음
|
||||
{"name": "벌 목 부", "spec": "", "unit": "인", "price": 253243},
|
||||
],
|
||||
"machine": [
|
||||
_m("굴삭기(무한궤도)", "0.7㎥", 10),
|
||||
_m("굴삭기(타이어형)", "0.7㎥", 11), # 타이어 = 다른 기계
|
||||
_m("굴삭기+브레이카", "0.7㎥", 12),
|
||||
_m("굴삭기(무한궤도)", "0.2㎥", 13),
|
||||
_m("굴삭기(무한궤도)", "0.2㎥", 14), # 같은 책 줄에 둘 = 못 고름
|
||||
],
|
||||
"material": [
|
||||
{"name": "휘 발 유", "spec": "", "unit": "ℓ", "price": 1473}, # 이름 · 단위(ℓ = L)
|
||||
{"name": "레미콘(관급)", "spec": "25-18-80", "unit": "m3", "price": 92330},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_707_3_이음_규칙():
|
||||
user = {"labor_cover": {"LB2": 1}, "materials": {"MO1": {"pick": "유가", "supply": "관급"}}}
|
||||
out = ip.propose(READ, BASE, user)
|
||||
patch = out["patch"]
|
||||
assert patch["labor_cover"] == {"LB1": 258935} # 같은 값 = 옛 덮개 걷음
|
||||
assert patch["machine_cover"] == {"EQ1": {"labor": 10, "material": 10, "expense": 10}}
|
||||
assert patch["materials"]["MO1"] == {"supply": "관급", "applied": 1473} # pick 뺌
|
||||
assert "MT1" not in patch["materials"] # 「(관급)」 이름 다름 = 짝 없음
|
||||
assert "굴삭기(무한궤도) 0.2㎥(짝 여럿)" in out["missing"]["machine"]
|
||||
assert out["counts"]["machine"] == {"read": 5, "linked": 1, "covered": 1, "missing": 4,
|
||||
"book": 2} # fmt: skip
|
||||
assert out["counts"]["labor"]["linked"] == 2 and out["missing"]["labor"] == ["벌 목 부"]
|
||||
|
||||
|
||||
def test_707_3_덮개_새_판_뒤_남음(tmp_path):
|
||||
"""[업데이트] = 단가 책만 새로 굳힘 — 사용자 값의 노임 · 기계 덮개는 그대로 · 셈은 덮개."""
|
||||
cover = {"labor_cover": {"LB1": 258935, "": None},
|
||||
"machine_cover": {"EQ1": {"labor": 10, "material": 10, "expense": 10},
|
||||
"EQ2": {"labor": 1}}} # fmt: skip
|
||||
saved = store.write_user(tmp_path, cover)
|
||||
assert saved["labor_cover"] == {"LB1": 258935} # 빈 값 버림
|
||||
assert saved["machine_cover"] == {"EQ1": {"labor": 10, "material": 10, "expense": 10}}
|
||||
for price in (268908, 280000): # 옛 판 → 새 판 책
|
||||
store.write_book(tmp_path, {"rows": {"LB1": {"key": "LB1", "price": price}}})
|
||||
user = store.read_user(tmp_path)
|
||||
assert user["labor_cover"] == saved["labor_cover"]
|
||||
inputs = {"labor": [{"key": "LB1", "name": "석공", "price": price}]}
|
||||
row = compute_base(inputs, user)["labor"][0]
|
||||
assert (row["price"], row["book"], row["covered"]) == (258935, price, True)
|
||||
@@ -11,7 +11,7 @@ const TEXT = {
|
||||
Col_New: ["새 열", "New column"],
|
||||
Row_Unit: ["단위", "Unit"],
|
||||
Row_Unit_Price: ["일위대가", "Unit price"],
|
||||
Row_LaborSum: ["일위대가 합계", "Unit price total"],
|
||||
Row_LaborSum: ["일위대가 금액", "Unit price total"],
|
||||
Row_Drawing: ["도면", "Drawing"],
|
||||
Row_Area: ["면적산출", "Area"],
|
||||
Add_Suffix: [" 추가", " +"],
|
||||
|
||||
@@ -75,7 +75,7 @@ export interface ItemDetail {
|
||||
}
|
||||
|
||||
const TEXT = {
|
||||
importEdit: "이 구조물로 가져와 고침",
|
||||
importEdit: "이 구조물에서 고치기",
|
||||
noStructure:
|
||||
"구조물을 먼저 추가하면 이 항목을 그 구조물로 가져와 고칠 수 있음 — 지금은 원본을 보고 있음",
|
||||
notImported: "이 구조물에는 아직 안 가져옴 — 원본을 보고 있음",
|
||||
@@ -327,6 +327,7 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail {
|
||||
onEdit: (kind) => void edit(kind),
|
||||
warnOnFail: () => false,
|
||||
labor: () => void edit("inputs"),
|
||||
picker: true,
|
||||
});
|
||||
cardsHost.replaceChildren(cards.root);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ const TEXT = {
|
||||
loadFailed: "복사본을 못 읽음 — {value}",
|
||||
discard: "저장 안 한 고침이 있음 — 버리고 돌아갈까요?",
|
||||
outOfRange: "범위 밖 값이 있어 저장하지 못함",
|
||||
noAreaPill: "면적산출 알약 없음 — 이 복사본 일위대가에 단가산출 로직 · 자재가 없음",
|
||||
noAreaPill: "면적산출 연결 칸 없음 — 이 복사본 일위대가에 단가산출 로직 · 자재가 없음",
|
||||
drawing: "도면",
|
||||
area: "면적산출",
|
||||
inputs: "넣을 값",
|
||||
|
||||
@@ -88,9 +88,9 @@ export const ui_locales_m2 = {
|
||||
M02_PickerNone: ["없음", "None"],
|
||||
M02_PickerApply: ["적용", "Apply"],
|
||||
M02_PickerClear: ["해제", "Clear"],
|
||||
M02_PickerCopy: ["개인용 복사", "Copy to personal"],
|
||||
M02_PickerCopy: ["내 자료집에 복사", "Copy to personal"],
|
||||
M02_PickerManage: ["관리 화면 편집", "Edit in manager"],
|
||||
M02_PickerCopied: ["개인용으로 복사했음", "Copied to personal"],
|
||||
M02_PickerCopied: ["내 자료집에 복사했음", "Copied to personal"],
|
||||
M02_PickerCopyFailed: ["복사 못 함 — {value}", "Copy failed — {value}"],
|
||||
M02_TreeTitle: ["구조물 라이브러리", "Structure library"],
|
||||
M02_RootMine: ["내 것", "Mine"],
|
||||
|
||||
Reference in New Issue
Block a user