feat(719-4 · 719-3): B09 단가산출근거 탭 = 공용 계산서 카드 · 이 공사 산근 덮개
- 719-4 근거 탭 = 산근 블록마다 카드 · 공용 calcSheetRows 묶음 꼴(인력 · 재료 · 경비(장비 한 줄 + 중기사용료 펼침 넉 줄) · 하위 = 산근 n호표 한 줄) · 서버 줄 덧칸 sheet{쓰는입력 · 기준단위 · 환산근거}(master_sheet_line 한 벌) · 블록 기준단위 · 입력 · 중간값 글은 고치기 펼침 때 · 옛 풀이표는 곁 창에만
- 719-3 산근 덮개 sg_cover {산근 키: {「n:이름」: {qty · 노 · 재 · 경 단가}}} · 셈 짝 B09_Estimation_Sangeun_Cover.py ↔ .ts(서버 풀이 뒤 덧씌움 · 하위 산근 이어짐 · % 덧줄 밑수 비례 · 총계 · 목록표 · 빈칸 목록 · raw 로 두 번 덮어도 같음 · 짝 잃음 sg_orphans 알림)
- 고침 표(702 꼴) = 품 · 단가 두 번 눌러 고침 · 고친 칸 색 · 원래 값 칸 · 「이 공사에서 고침」 꼬리표 · 우클릭 · 카드 단추 [단가산출 값으로] · 고친 뒤 보던 자리 지킴
- 저장 다듬기 sg_cover · [업데이트] 알림 고친 칸 수에 산근 칸 · 셈 길(compute_sheets) · 화면 짝(computeSheets) 둘 다 덮개
- 금액 전후 같음 — 덮개 없음 = 서버 풀이 그대로 · 덧칸 뺀 옛 풀이와 13 블록 같음 · 일위대가 합 검증_랩탑메인 13,662,560 · 검증_sub5 10,149,562 그대로
- 시험 새로 test_719_3_sangeun_cover(8 · 거울 · 대가 · 원가계산서 · 업데이트 뒤 지킴) · test_719_4_sangeun_sheet(2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -79,6 +79,7 @@ from B09_Estimation import B09_Estimation_Past as past
|
||||
from B09_Estimation import B09_Estimation_Prices as prices
|
||||
from B09_Estimation import B09_Estimation_Refresh as refresh
|
||||
from B09_Estimation import B09_Estimation_Sangeun as sangeun
|
||||
from B09_Estimation import B09_Estimation_Sangeun_Cover as sg_cover
|
||||
from B09_Estimation import B09_Estimation_Store as store
|
||||
from B09_Estimation.B09_Estimation_Bill_Items import bill_items, hopyo_prices
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import compute_base
|
||||
@@ -159,6 +160,7 @@ def compute_sheets(
|
||||
memo = 두 번 셈의 앞 산근 풀이(`Sangeun.collect`)."""
|
||||
base = compute_base(inputs, user)
|
||||
sg = sangeun.build(root, base, user, memo) # 145-1 — 굳힌 단가로 풀이(+ 166-2 손 줄 로직)
|
||||
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(
|
||||
inputs.get("daega"), {**base, "gaps": []}, by_key, user.get("daega_cover")
|
||||
@@ -362,7 +364,8 @@ async def refresh_prices(
|
||||
await asyncio.to_thread(store.write_book, root, book)
|
||||
out = await payload(project_id, root, draft)
|
||||
diff = prices.book_diff(old, book)
|
||||
cells = refresh.cover_cells((draft or out.get("user") or {}).get("daega_cover"))
|
||||
kept = draft or out.get("user") or {}
|
||||
cells = refresh.cover_cells(kept.get("daega_cover")) + refresh.cover_cells(kept.get("sg_cover"))
|
||||
out["notes"] = [
|
||||
f"업데이트 — 단가 줄 {diff['new'] + diff['changed']} 바뀜 · 구조물 사본 "
|
||||
f"{len(again['다시'])} 다시 가져옴 · 고친 칸 {cells} 그대로",
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
텍스트 수식(155-5) = `B09_Estimation_Sangeun_Text`(같은 B09 단가) — `head`(머리 · 입력 · 중간) ·
|
||||
줄 `text`(줄마다 · 중기 = 노 · 재 · 경 세 줄) · `foot`(계) · % 덧줄 = 규격 「노무비의 %」 ·
|
||||
수량 = 율 · 단위 %(149-4 `pct_extras`).
|
||||
계산서 줄 덧칸(719-4) = 줄 `sheet`{쓰는입력 · 기준단위 · 환산근거} · 블록 `기준단위` —
|
||||
화면 공용 계산서(`B09_Estimation_UI_Tabs_Sangeun_Sheet`) ·
|
||||
덮개(719-3) = `B09_Estimation_Sangeun_Cover`(풀이 뒤 덧씌움).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -55,8 +58,11 @@ from M02_MasterTemplete.M02_Item_Snapshot import (
|
||||
from M02_MasterTemplete.M02_Item_Source import LABOR_FILE, PRICE_FILE, read_json
|
||||
|
||||
mf = item_prices.mf
|
||||
import master_sheet_line as msl # noqa: E402 — 계산서 줄 덧칸(714-1) · scripts 길은 `m01` 이 둠
|
||||
|
||||
PARTS = ("labor", "material", "expense")
|
||||
COST_PART = {"노무비": "labor", "재료비": "material", "경비": "expense"}
|
||||
PCT_OF = {**COST_PART, "인건비": "people"} # % 덧줄 밑수 → 덮개 다시 셈(719-3)
|
||||
DEPTH = 4 # 하위 산근 깊이 상한
|
||||
_Q = re.compile(r"Q\d*") # 작업량 중간값 이름(산림품셈 09장 토공 · 07장 집재 …)
|
||||
NO_SOURCE = "조합 밖 토공 · 운반 · 사토 · 준비공은 산근 없음"
|
||||
@@ -370,6 +376,7 @@ class Solver:
|
||||
for n, item in enumerate(row.get("호표", [])):
|
||||
lines.append(self._line(whole, item, n, env, times, rate, depth))
|
||||
self._extras(whole, row, env, lines)
|
||||
_annotate(whole, row, env, lines)
|
||||
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
|
||||
return self._stop(code, name, str(e) or type(e).__name__)
|
||||
sums = block_totals(lines)
|
||||
@@ -388,6 +395,7 @@ class Solver:
|
||||
"why": why,
|
||||
"head": head,
|
||||
"foot": foot,
|
||||
"기준단위": msl._per_text(msl.mbs.result_per(row.get("결과단위"))) or None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -475,7 +483,8 @@ class Solver:
|
||||
spec = goods.get("규격") or ""
|
||||
ref = {"tab": "compare", "key": key}
|
||||
return {**base, **zero, "spec": spec, "kind": "material", "ref": ref,
|
||||
"price": {part: price}, part: _money(price, q), "why": why} # fmt: skip
|
||||
"price": {part: price}, part: _money(price, q), "why": why,
|
||||
"_goods": goods} # fmt: skip
|
||||
|
||||
def _logic_line(self, whole, item, env, q, base, zero, depth) -> dict[str, Any]:
|
||||
call = call_of(whole, item, env)
|
||||
@@ -510,6 +519,8 @@ class Solver:
|
||||
"unit": "식", "ref": None, **dict.fromkeys(PARTS, 0.0)} # fmt: skip
|
||||
if "율" in pct:
|
||||
line.update(spec=pct.get("규격") or extra["식"], qty=float(pct["율"]), unit="%")
|
||||
if of := PCT_OF.get(str(pct.get("규격") or "").removesuffix("의 %")):
|
||||
line["of"] = of # 719-3 덮개 다시 셈 밑수(`Sangeun_Cover`)
|
||||
if any(v is None for v in sums.values()):
|
||||
lines.append({**line, part: None, "price": {}, "why": "밑수 빈칸"})
|
||||
continue
|
||||
@@ -527,6 +538,22 @@ class Solver:
|
||||
lines.append({**line, "price": {part: value}, part: rounddown(value, 1), "why": None})
|
||||
|
||||
|
||||
def _annotate(whole: Any, row: dict, env: dict, lines: list[dict[str, Any]]) -> None:
|
||||
"""719-4 계산서 줄 덧칸 `sheet`{쓰는입력 · 기준단위 · 환산근거} — `master_sheet_line` 한 벌
|
||||
(M01 `/text` · M02 `/logic-lib/sheet` 와 같은 뜻) · 묶음 · 장비 = 줄 `kind` 로 화면이 가름."""
|
||||
per = msl.mbs.result_per(row.get("결과단위"))
|
||||
items = [*row.get("호표", []), *row.get("덧줄", [])]
|
||||
for item, ln in zip(items, lines):
|
||||
goods = ln.pop("_goods", None) or {}
|
||||
exprs = [item["식"]] if "식" in item else [item.get("수량"), item.get("요소")]
|
||||
seen = {"품목": goods, "단위": item.get("단위")}
|
||||
try:
|
||||
got = msl._fields(whole, row, exprs, dict(env), per, seen, None)
|
||||
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError):
|
||||
got = {}
|
||||
ln["sheet"] = got
|
||||
|
||||
|
||||
# ── 모음 ──────────────────────────────────────────────────────────────
|
||||
LISTED = ("no", "key", "logic", "name", "spec", "spec_raw", "unit", "sum", *PARTS, "uses")
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""B09 산근 덮개(PLAN 719-3) — 이 공사 단가산출근거 고친 칸(품 · 단가)을 서버 풀이 뒤에 덧씌움.
|
||||
|
||||
⚠ 짝 = `B09_Estimation_Sangeun_Cover.ts`(같은 식 · 차례 · 끝수) — 한쪽을 고치면 다른 쪽도 ·
|
||||
거울 시험 `resources/tester/test_719_3_sangeun_cover.py`.
|
||||
조작 중 = 화면이 TS 짝으로 바로 셈 · [저장] = 서버가 다시 풀이(`Sangeun.build`) 뒤 이 파일로 정본.
|
||||
|
||||
덮개 = 사용자 값 `sg_cover` {산근 키: {줄 열쇠 「n:이름」: {qty?, labor?, material?, expense?}}} —
|
||||
qty = 1 단위 품 · 비목 = 단가 · 있는 칸만 셈 값 대신 · 줄이 없거나 이름이 다르면 짝 잃음
|
||||
(`sg_orphans` · 알림만 · 덮개는 남음) · 산근 키 = 로직 + 입력값 → [업데이트] 뒤에도 같은 블록.
|
||||
고칠 칸(`fields`) = 노무 · 자재 · 중기 · 빈 줄 = 품 + 쓰는 비목 단가 · 하위 산근 = 품 ·
|
||||
% 덧줄(`of` 있는 것) = 율.
|
||||
다시 셈 = 덮개가 닿은 블록과 그 블록을 부르는 블록만(안 닿은 블록 = 서버 값 그대로) —
|
||||
줄 금액 ROUNDDOWN(단가 × 품, 1) · 하위 산근 줄 단가 = 다시 센 그 블록 비목 합 ·
|
||||
% 덧줄 = 서버 값 × (지금 밑수 ÷ 처음 밑수) × (지금 율 ÷ 처음 율) · 처음 밑수 · 율이 0 · 빈칸이면
|
||||
밑수 × 율 ÷ 100(밑수 = 앞 줄 그 비목 합 · `people` = 노무 줄 노무비 합) ·
|
||||
총계 = 비목마다 ROUNDDOWN(Σ, 0) · 합계 = 셋 합 · 목록표(`sg_list`) · 빈칸 목록(`sg_gaps`) 같이.
|
||||
율 식 밖 덧줄(`of` 없음 · 기계손료 · 조건식) = 서버 값 그대로.
|
||||
ponytail: 인건비 % 덧줄 = 노무 줄 합 비례(식이 일부 직종만 부르면 어긋남 · 저장 뒤도 같은 값)
|
||||
고친 줄 = `base`(덮기 전 {qty, 고칠 비목 단가}) · `covered`(고친 칸) ·
|
||||
목록표 줄 `covered`(고친 줄 수) · 다시 센 블록 · 목록 줄 `raw` = 덮기 전 값(두 번 덮어도 같음 —
|
||||
서버 답이 덮인 채 오면 화면 짝이 벗기고 다시 덮음).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from typing import Any
|
||||
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import gap, num, rounddown, total
|
||||
|
||||
PARTS = ("labor", "material", "expense")
|
||||
COVER_KEYS = ("qty", *PARTS)
|
||||
DEPTH = 8 # 하위 산근 깊이 상한(서버 풀이 4 보다 넉넉히)
|
||||
|
||||
|
||||
def line_id(n: int, line: dict[str, Any]) -> str:
|
||||
"""덮개 줄 열쇠 — 블록 안 차례 + 이름(로직 개정으로 줄이 밀리면 짝 잃음)."""
|
||||
return f"{n}:{line.get('name') or ''}"
|
||||
|
||||
|
||||
def fields(line: dict[str, Any]) -> list[str]:
|
||||
"""고칠 수 있는 칸 — 품(qty) + 단가 비목."""
|
||||
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 []
|
||||
|
||||
|
||||
def _money(price: float | None, qty: float | None) -> float | None:
|
||||
return None if price is None or qty is None else rounddown(price * qty, 1)
|
||||
|
||||
|
||||
def _base(lines: list[dict[str, Any]], n: int, of: str) -> float | None:
|
||||
"""% 덧줄 밑수 — 앞 줄(0 … n-1) 비목 합 · people = 노무 줄 노무비 합."""
|
||||
if of == "people":
|
||||
return total([ln.get("labor") for ln in lines[:n] if ln.get("kind") == "labor"])
|
||||
return total([ln.get(of) for ln in lines[:n]])
|
||||
|
||||
|
||||
def _settle(block: dict[str, Any], mine: dict[str, Any], by: dict[str, dict[str, Any]]) -> None:
|
||||
"""블록 한 장 다시 셈 — 덮개 칸 · 하위 산근 새 합 · % 덧줄 · 총계."""
|
||||
first = copy.deepcopy(block["lines"])
|
||||
for n, ln in enumerate(block["lines"]):
|
||||
cell = mine.get(line_id(n, ln)) or {}
|
||||
can = fields(ln)
|
||||
keys = [k for k in can if num(cell.get(k)) is not None]
|
||||
qty0, price0 = ln.get("qty"), dict(ln.get("price") or {})
|
||||
if keys:
|
||||
ln["base"] = {"qty": qty0, **{p: price0.get(p) for p in can if p != "qty"}}
|
||||
ln["covered"] = keys
|
||||
qty = num(cell["qty"]) if "qty" in keys else qty0
|
||||
kind = ln.get("kind")
|
||||
if kind == "sangeun":
|
||||
sub = by.get((ln.get("ref") or {}).get("key")) or {}
|
||||
price = {p: sub.get(p) for p in PARTS}
|
||||
ln.update(qty=qty, price=price, **{p: _money(price[p], qty) for p in PARTS})
|
||||
ln["why"] = (
|
||||
None if sub.get("sum") is not None else f"하위 산근 {sub.get('no', '')} 빈칸"
|
||||
)
|
||||
elif kind == "extra":
|
||||
of = ln.get("of")
|
||||
part = next(iter(price0), None) or next((p for p in PARTS if ln.get(p) is None), None)
|
||||
if not of or part is None:
|
||||
continue
|
||||
now, then, value0 = _base(block["lines"], n, of), _base(first, n, of), price0.get(part)
|
||||
if now is None or qty is None:
|
||||
value = None
|
||||
elif value0 is not None and then and qty0:
|
||||
value = value0 * (now / then) * (qty / qty0)
|
||||
else:
|
||||
value = now * qty / 100
|
||||
ln.update(qty=qty, price={part: value}, **{part: _money(value, 1)})
|
||||
ln["why"] = None if value is not None else "밑수 빈칸"
|
||||
else:
|
||||
price = {**price0, **{p: num(cell[p]) for p in keys if p != "qty"}}
|
||||
for p in can:
|
||||
if p != "qty":
|
||||
ln[p] = _money(price.get(p), qty)
|
||||
ln.update(qty=qty, price=price)
|
||||
if all(ln.get(p) is not None for p in PARTS):
|
||||
ln["why"] = None
|
||||
for part in PARTS:
|
||||
s = total([ln.get(part) for ln in block["lines"]])
|
||||
block[part] = None if s is None else rounddown(s, 0)
|
||||
block["sum"] = total([block[p] for p in PARTS])
|
||||
block["why"] = next((ln["why"] for ln in block["lines"] if ln.get("why")), None)
|
||||
|
||||
|
||||
def _orphans(by: dict[str, dict[str, Any]], cover: dict[str, Any]) -> list[dict[str, str]]:
|
||||
out = []
|
||||
for key, lines in cover.items():
|
||||
block = by.get(key)
|
||||
for lid in lines if isinstance(lines, dict) else {}:
|
||||
n, _, name = str(lid).partition(":")
|
||||
ln = (
|
||||
block["lines"][int(n)]
|
||||
if block and n.isdigit() and int(n) < len(block["lines"])
|
||||
else None
|
||||
)
|
||||
if not ln or (ln.get("name") or "") != name or not fields(ln):
|
||||
out.append({"key": key, "line": str(lid)})
|
||||
return out
|
||||
|
||||
|
||||
def _bare(row: dict[str, Any]) -> dict[str, Any]:
|
||||
"""덮인 목록 줄 → 덮기 전 값(`raw`)."""
|
||||
if "raw" not in row:
|
||||
return row
|
||||
return {**{k: v for k, v in row.items() if k not in ("raw", "covered")}, **row["raw"]}
|
||||
|
||||
|
||||
def _gaps(blocks: list[dict[str, Any]]) -> list[dict[str, str]]:
|
||||
return [
|
||||
gap("sg", b["key"], f"산근 {b['no']} {b['name']}", b["why"] or "단가 빈칸")
|
||||
for b in blocks
|
||||
if b["sum"] is None
|
||||
]
|
||||
|
||||
|
||||
def cover_sangeun(sg: dict[str, Any], cover: Any) -> dict[str, Any]:
|
||||
"""산근 답(`Sangeun.collect` 꼴) + 덮개 → 새 답(받은 답 안 고침) · 덮개 없으면 그대로 ·
|
||||
이미 덮인 답(서버 셈 답) = 블록 · 목록 줄 `raw`(덮기 전 값)로 벗긴 뒤 다시 덮음."""
|
||||
cover = cover if isinstance(cover, dict) else {}
|
||||
worn = sg.get("sg") or []
|
||||
blocks = [b["raw"] if "raw" in b else b for b in worn]
|
||||
orphans = _orphans({b["key"]: b for b in blocks}, cover)
|
||||
if not any(cover.get(b["key"]) for b in blocks):
|
||||
if not any("raw" in b for b in worn):
|
||||
return {**sg, "sg_orphans": orphans}
|
||||
rows = [_bare(r) for r in sg.get("sg_list") or []]
|
||||
return {
|
||||
**sg,
|
||||
"sg": blocks,
|
||||
"sg_list": rows,
|
||||
"sg_gaps": _gaps(blocks),
|
||||
"sg_orphans": orphans,
|
||||
}
|
||||
out = copy.deepcopy(blocks)
|
||||
by = {b["key"]: b for b in out}
|
||||
hit: dict[str, bool] = {}
|
||||
|
||||
def touch(key: str, depth: int) -> bool:
|
||||
if key in hit:
|
||||
return hit[key]
|
||||
block = by.get(key)
|
||||
if block is None or depth > DEPTH:
|
||||
return False
|
||||
hit[key] = False # 돎 막음
|
||||
subs = [
|
||||
touch(ln["ref"]["key"], depth + 1)
|
||||
for ln in block["lines"]
|
||||
if ln.get("kind") == "sangeun" and (ln.get("ref") or {}).get("key")
|
||||
]
|
||||
hit[key] = bool(cover.get(key)) or any(subs)
|
||||
if hit[key]:
|
||||
raw = copy.deepcopy(block)
|
||||
_settle(block, cover.get(key) or {}, by)
|
||||
block["raw"] = raw
|
||||
return hit[key]
|
||||
|
||||
for b in out:
|
||||
touch(b["key"], 0)
|
||||
listing = []
|
||||
for row in map(_bare, sg.get("sg_list") or []):
|
||||
b = by.get(row["key"])
|
||||
if b is None or not hit.get(row["key"]):
|
||||
listing.append(row)
|
||||
continue
|
||||
n = sum(1 for ln in b["lines"] if ln.get("covered"))
|
||||
sums = {k: b[k] for k in (*PARTS, "sum")}
|
||||
raw = {k: row.get(k) for k in (*PARTS, "sum")}
|
||||
listing.append({**row, **sums, **({"covered": n} if n else {}), "raw": raw})
|
||||
return {**sg, "sg": out, "sg_list": listing, "sg_gaps": _gaps(out), "sg_orphans": orphans}
|
||||
@@ -0,0 +1,185 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_Sangeun_Cover.ts
|
||||
* B09 산근 덮개(PLAN 719-3) — 이 공사 단가산출근거 고친 칸(품 · 단가)을 서버 풀이 뒤에 덧씌움.
|
||||
*
|
||||
* ⚠ 짝 = `B09_Estimation_Sangeun_Cover.py`(같은 식 · 같은 차례 · 같은 끝수) — 한쪽을 고치면 다른 쪽도 ·
|
||||
* 거울 시험 `resources/tester/test_719_3_sangeun_cover.py`.
|
||||
* 조작 중 = 이 파일이 바로 셈 · [저장] = 서버가 다시 풀이(`Sangeun.build`) 뒤 파이썬 짝으로 정본.
|
||||
*
|
||||
* 덮개 = 사용자 값 `sg_cover` {산근 키: {줄 열쇠 「n:이름」: {qty?, labor?, material?, expense?}}} —
|
||||
* 있는 칸만 셈 값 대신 · 줄이 없거나 이름이 다르면 짝 잃음(`sg_orphans` · 알림만)
|
||||
* 고칠 칸(`fields`) = 노무 · 자재 · 중기 · 빈 줄 = 품 + 쓰는 비목 단가 · 하위 산근 = 품 · % 덧줄(`of`) = 율
|
||||
* 다시 셈 = 덮개가 닿은 블록과 그 블록을 부르는 블록만 — 줄 금액 ROUNDDOWN(단가 × 품, 1) ·
|
||||
* 하위 산근 줄 단가 = 다시 센 그 블록 비목 합 · % 덧줄 = 서버 값 × 밑수 비 × 율 비(처음이 0 · 빈칸이면
|
||||
* 밑수 × 율 ÷ 100) · 총계 = 비목마다 ROUNDDOWN(Σ, 0) · 목록표 · 빈칸 목록 같이
|
||||
* · 율 식 밖 덧줄(`of` 없음) = 서버 값 그대로
|
||||
* 고친 줄 = `base`(덮기 전 값) · `covered`(고친 칸) · 다시 센 블록 · 목록 줄 `raw` = 덮기 전 값
|
||||
* (두 번 덮어도 같음 — 서버 답이 덮인 채 오면 벗기고 다시 덮음)
|
||||
* ========================================================================== */
|
||||
|
||||
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;
|
||||
const DEPTH = 8;
|
||||
|
||||
/** 덮개 줄 열쇠 — 블록 안 차례 + 이름 */
|
||||
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"];
|
||||
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 [];
|
||||
}
|
||||
|
||||
const money = (price: Num | undefined, qty: Num | undefined): Num =>
|
||||
price === null || price === undefined || qty === null || qty === undefined
|
||||
? null
|
||||
: rounddown(price * qty, 1);
|
||||
|
||||
function baseOf(lines: Row[], n: number, of: string): Num {
|
||||
if (of === "people")
|
||||
return total(
|
||||
lines
|
||||
.slice(0, n)
|
||||
.filter((l) => l.kind === "labor")
|
||||
.map((l) => l.labor ?? null),
|
||||
);
|
||||
return total(lines.slice(0, n).map((l) => l[of] ?? null));
|
||||
}
|
||||
|
||||
function settle(block: Row, mine: Row, by: Map<string, Row>): void {
|
||||
const first: Row[] = JSON.parse(JSON.stringify(block.lines));
|
||||
(block.lines as Row[]).forEach((ln, n) => {
|
||||
const cell: Row = mine[lineId(n, ln)] || {};
|
||||
const can = fields(ln);
|
||||
const keys = can.filter((k) => num(cell[k]) !== null);
|
||||
const qty0: Num = ln.qty ?? null;
|
||||
const price0: Row = { ...(ln.price || {}) };
|
||||
if (keys.length) {
|
||||
ln.base = {
|
||||
qty: qty0,
|
||||
...Object.fromEntries(can.filter((p) => p !== "qty").map((p) => [p, price0[p] ?? null])),
|
||||
};
|
||||
ln.covered = keys;
|
||||
}
|
||||
const qty: Num = keys.includes("qty") ? num(cell.qty) : qty0;
|
||||
if (ln.kind === "sangeun") {
|
||||
const sub: Row = by.get((ln.ref || {}).key) || {};
|
||||
const price = Object.fromEntries(PARTS.map((p) => [p, sub[p] ?? null]));
|
||||
Object.assign(ln, { qty, price }, ...PARTS.map((p) => ({ [p]: money(price[p], qty) })));
|
||||
ln.why = (sub.sum ?? null) !== null ? null : `하위 산근 ${sub.no ?? ""} 빈칸`;
|
||||
} else if (ln.kind === "extra") {
|
||||
const of: string | undefined = ln.of;
|
||||
const part =
|
||||
Object.keys(price0)[0] ?? PARTS.find((p) => (ln[p] ?? null) === null) ?? undefined;
|
||||
if (!of || part === undefined) return;
|
||||
const now = baseOf(block.lines, n, of);
|
||||
const then = baseOf(first, n, of);
|
||||
const value0: Num = price0[part] ?? null;
|
||||
let value: Num;
|
||||
if (now === null || qty === null) value = null;
|
||||
else if (value0 !== null && then && qty0)
|
||||
value = value0 * (now / then) * ((qty as number) / qty0);
|
||||
else value = (now * (qty as number)) / 100;
|
||||
Object.assign(ln, { qty, price: { [part]: value }, [part]: money(value, 1) });
|
||||
ln.why = value !== null ? null : "밑수 빈칸";
|
||||
} else {
|
||||
const price: Row = {
|
||||
...price0,
|
||||
...Object.fromEntries(keys.filter((p) => p !== "qty").map((p) => [p, num(cell[p])])),
|
||||
};
|
||||
for (const p of can) if (p !== "qty") ln[p] = money(price[p] ?? null, qty);
|
||||
Object.assign(ln, { qty, price });
|
||||
if (PARTS.every((p) => (ln[p] ?? null) !== null)) ln.why = null;
|
||||
}
|
||||
});
|
||||
for (const part of PARTS) {
|
||||
const s = total((block.lines as Row[]).map((l) => l[part] ?? null));
|
||||
block[part] = s === null ? null : rounddown(s, 0);
|
||||
}
|
||||
block.sum = total(PARTS.map((p) => block[p]));
|
||||
block.why = (block.lines as Row[]).find((l) => l.why)?.why ?? null;
|
||||
}
|
||||
|
||||
function orphansOf(by: Map<string, Row>, cover: Row): Row[] {
|
||||
const out: Row[] = [];
|
||||
for (const [key, lines] of Object.entries(cover)) {
|
||||
const block = by.get(key);
|
||||
const ids = lines && typeof lines === "object" ? Object.keys(lines) : [];
|
||||
for (const lid of ids) {
|
||||
const at = lid.indexOf(":");
|
||||
const n = at < 0 ? lid : lid.slice(0, at);
|
||||
const name = at < 0 ? "" : lid.slice(at + 1);
|
||||
const ok = /^\d+$/.test(n) && block && Number(n) < block.lines.length;
|
||||
const ln: Row | null = ok ? block!.lines[Number(n)] : null;
|
||||
if (!ln || (ln.name || "") !== name || !fields(ln).length) out.push({ key, line: lid });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 산근 답(sg · sg_list · sg_gaps …) + 덮개 → 새 답(받은 답은 안 고침) · 덮개 없으면 받은 값 그대로 */
|
||||
const bare = (row: Row): Row => {
|
||||
if (!("raw" in row)) return row;
|
||||
const { raw, covered: _covered, ...rest } = row;
|
||||
return { ...rest, ...raw };
|
||||
};
|
||||
|
||||
const gapsOf = (blocks: Row[]): Row[] =>
|
||||
blocks
|
||||
.filter((b) => b.sum === null)
|
||||
.map((b) => gap("sg", b.key, `산근 ${b.no} ${b.name}`, b.why || "단가 빈칸"));
|
||||
|
||||
/** 이미 덮인 답(서버 [저장] · 셈 답) = 블록 · 목록 줄 `raw`(덮기 전 값)로 벗긴 뒤 다시 덮음 */
|
||||
export function coverSangeun(sg: Row, cover: unknown): Row {
|
||||
const covers: Row = cover && typeof cover === "object" ? (cover as Row) : {};
|
||||
const worn: Row[] = sg.sg || [];
|
||||
const blocks: Row[] = worn.map((b) => ("raw" in b ? b.raw : b));
|
||||
const orphans = orphansOf(new Map(blocks.map((b) => [b.key, b])), covers);
|
||||
if (!blocks.some((b) => covers[b.key] && Object.keys(covers[b.key]).length)) {
|
||||
if (!worn.some((b) => "raw" in b)) return { ...sg, sg_orphans: orphans };
|
||||
const rows = ((sg.sg_list || []) as Row[]).map(bare);
|
||||
return { ...sg, sg: blocks, sg_list: rows, sg_gaps: gapsOf(blocks), sg_orphans: orphans };
|
||||
}
|
||||
const out: Row[] = JSON.parse(JSON.stringify(blocks));
|
||||
const by = new Map(out.map((b) => [b.key as string, b]));
|
||||
const hit = new Map<string, boolean>();
|
||||
const touch = (key: string, depth: number): boolean => {
|
||||
if (hit.has(key)) return hit.get(key)!;
|
||||
const block = by.get(key);
|
||||
if (!block || depth > DEPTH) return false;
|
||||
hit.set(key, false); // 돎 막음
|
||||
const subs = (block.lines as Row[])
|
||||
.filter((l) => l.kind === "sangeun" && (l.ref || {}).key)
|
||||
.map((l) => touch(l.ref.key, depth + 1));
|
||||
const mine: Row = covers[key] || {};
|
||||
const got = Object.keys(mine).length > 0 || subs.some(Boolean);
|
||||
hit.set(key, got);
|
||||
if (got) {
|
||||
const raw = JSON.parse(JSON.stringify(block));
|
||||
settle(block, mine, by);
|
||||
block.raw = raw;
|
||||
}
|
||||
return got;
|
||||
};
|
||||
for (const b of out) touch(b.key, 0);
|
||||
const listing = ((sg.sg_list || []) as Row[]).map(bare).map((row) => {
|
||||
const b = by.get(row.key);
|
||||
if (!b || !hit.get(row.key)) return row;
|
||||
const n = (b.lines as Row[]).filter((l) => l.covered).length;
|
||||
return {
|
||||
...row,
|
||||
...Object.fromEntries([...PARTS, "sum"].map((k) => [k, b[k]])),
|
||||
...(n ? { covered: n } : {}),
|
||||
raw: Object.fromEntries([...PARTS, "sum"].map((k) => [k, row[k] ?? null])),
|
||||
};
|
||||
});
|
||||
return { ...sg, sg: out, sg_list: listing, sg_gaps: gapsOf(out), sg_orphans: orphans };
|
||||
}
|
||||
@@ -12,6 +12,8 @@
|
||||
— 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본)
|
||||
"daega_cover": {블록 cover: {줄 cid: {qty?, labor?, material?, expense?}}}
|
||||
(702-2 일위대가 고친 칸 덮개 — 수만 · 셈 = `B09_Estimation_Calc_Labor`)
|
||||
"sg_cover": {산근 키: {줄 「n:이름」: {qty?, labor?, material?, expense?}}}
|
||||
(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,
|
||||
@@ -353,6 +355,8 @@ 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
|
||||
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
|
||||
|
||||
@@ -314,3 +314,25 @@ html.is-bm-route .b09-estimation > .b09-estimation__tabs:first-child {
|
||||
color: var(--color-danger);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
/* 719-4 단가산출근거 = 산근 카드(공용 계산서 줄 세로) · 719-3 이 공사에서 고친 줄 = 고친 칸 색 */
|
||||
.b09-sg-sheets {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-12);
|
||||
overflow: auto;
|
||||
overflow-anchor: none; /* 카드를 갈아 끼워도 보던 자리(고치기 펼침) */
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.b09-sg-sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b09-sg-sheet .is-edited {
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--color-warning) 22%, var(--color-canvas));
|
||||
}
|
||||
|
||||
@@ -54,6 +54,8 @@ import {
|
||||
} from "./B09_Estimation_UI_Tabs";
|
||||
import { attachRowMenu, openMenuAt, type BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit";
|
||||
import { jumpRange, jumpTo, levelBar, pickedClass } from "./B09_Estimation_UI_Tabs_Bill_Level";
|
||||
import { coverSangeun } from "./B09_Estimation_Sangeun_Cover";
|
||||
import { renderSangeunSheets } from "./B09_Estimation_UI_Tabs_Sangeun_Sheet";
|
||||
import {
|
||||
isGroupTab,
|
||||
lineHint,
|
||||
@@ -100,10 +102,14 @@ const SERVER_ONLY = ["sg", "sg_list", "sg_gaps", "sg_notes"] as const;
|
||||
/** 브라우저 셈 — 서버 짝과 같은 식(조작 중) · 산근은 서버 답 그대로(+ 「연결 없음」) */
|
||||
export function computeSheets(payload: Row, user: Row): Row {
|
||||
const server: Row = payload.sheets || {};
|
||||
const kept: Row = {
|
||||
...Object.fromEntries(SERVER_ONLY.map((k) => [k, server[k] ?? []])),
|
||||
sg_hand: server.sg_hand ?? {},
|
||||
};
|
||||
// 719-3 산근 고친 칸 덮개 = 서버 풀이 뒤 덧씌움(짝 `B09_Estimation_Sangeun_Cover.py`)
|
||||
const kept: Row = coverSangeun(
|
||||
{
|
||||
...Object.fromEntries(SERVER_ONLY.map((k) => [k, server[k] ?? []])),
|
||||
sg_hand: server.sg_hand ?? {},
|
||||
},
|
||||
user.sg_cover,
|
||||
);
|
||||
// 대가(145-2) — 산근 단가는 서버 답 목록 그대로 · 내역(145-3)은 대가 단가로 블록마다
|
||||
const sg = Object.fromEntries((kept.sg_list as Row[]).map((r) => [r.key, r]));
|
||||
const labor = computeLabor(
|
||||
@@ -189,6 +195,8 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
if (!projectId) return void panel.replaceChildren(note("프로젝트를 먼저 고르세요."));
|
||||
if (!payload) return void panel.replaceChildren(note("원가 자료를 불러오는 중…"));
|
||||
const ctx: TabContext = { sheets, payload, user, setUser };
|
||||
// 719-4 단가산출근거 = 공용 계산서 카드(옛 표 꼴은 곁 창에만)
|
||||
if (tab === "sg") return void panel.replaceChildren(...renderSangeunSheets(ctx, focus));
|
||||
const spec = tabTableOf(tab, ctx);
|
||||
if (!spec) return void panel.replaceChildren(note(LATER[tab] ?? "다음 단계에서 채움"));
|
||||
if (typeof spec === "string") return void panel.replaceChildren(note(spec));
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
* 노무비 · 재료비 · 경비 — 머리(제 n 호표 · 출처 · 입력 · 중간 Q) → 줄(중기 = 노 · 재 · 경
|
||||
* 세 줄) → 총계(계) · 줄 누름 = 단가를 낸 탭 · 줄(노무비목록표 · 중기목록표 · 자재단가대비표 ·
|
||||
* 하위 산근)
|
||||
* 719-4 — 단가산출근거 탭 본 화면 = 공용 계산서 카드(`…_UI_Tabs_Sangeun_Sheet`) · 풀이표는 곁 창(행 누름
|
||||
* 팝업)에만 씀
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DataRow } from "@ui/ui_template_data_table";
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
/* =============================================================================
|
||||
* B09_Estimation_UI_Tabs_Sangeun_Sheet.ts
|
||||
* B09 단가산출근거 탭(PLAN 719-4 · 719-3) — 산근 블록마다 카드 한 장 = 공용 「단가산출 계산서」(714-2
|
||||
* `calcSheetRows` 묶음 꼴 · M01 「인력·재료·경비」 · M02 와 같은 꼴) + 품 · 단가 고침 표(702 꼴).
|
||||
*
|
||||
* 값 = 서버 풀이(`B09_Estimation_Sangeun`) + 이 공사 고친 칸 덮개(`sg_cover` · 짝 `…_Sangeun_Cover`) —
|
||||
* 화면은 받은 줄을 계산서 줄 꼴로 옮기기만(셈 없음)
|
||||
* 계산서 = 노무비 · 재료비 · 경비 묶음 → 공용 부품이 인력 · 재료 · 경비(장비) · 하위로 다시 묶음 ·
|
||||
* 중기 줄 = 장비 한 줄(노 · 재 · 경) + 펼침(중기사용료 블록 손료 · 운전사 · 연료 · 잡품) ·
|
||||
* 하위 산근 = 「산근 n호표」 한 줄 · 묶음 머리 = 기준단위 · 환산 근거(서버 줄 덧칸 `sheet`)
|
||||
* 고침(719-3) = [품 · 단가 고치기] 표에서 두 번 눌러 고침 → `sg_cover` 캐시 · 짝이 바로 다시 셈 ·
|
||||
* [저장] 때 서버가 정본으로 다시 셈 · 고친 칸 색(`is-edited`) + 「원래 값」 칸 + 「이 공사에서 고침」 ·
|
||||
* 우클릭 · 카드 단추 [단가산출 값으로] = 덮개 지움(셈 값 = 단가산출 값) · 빈칸 입력도 되돌림
|
||||
* 옛 표 꼴(`buildSg`)은 곁 창(행 누름 팝업)에만 남음
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBanner } from "@ui/ui_template_banner";
|
||||
import { createCard } from "@ui/ui_template_card";
|
||||
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
|
||||
import {
|
||||
cellColumn,
|
||||
createDataTable,
|
||||
fillViewport,
|
||||
type DataRow,
|
||||
} from "@ui/ui_template_data_table";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { attachHoverInfo } from "@ui/ui_template_hover_info";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { calcSheetRows, type SheetGroup, type SheetLine } from "../A00_Common/calc_sheet";
|
||||
import type { Row } from "./B09_Estimation_Calc_Base";
|
||||
import { fields, lineId } from "./B09_Estimation_Sangeun_Cover";
|
||||
import { money, parseNumber, qty, text, type TabContext } from "./B09_Estimation_UI_Tabs";
|
||||
import { attachRowMenu } from "./B09_Estimation_UI_Tabs_Bill_Edit";
|
||||
|
||||
const PARTS = ["labor", "material", "expense"] as const;
|
||||
const COST: Record<string, string> = { labor: "노무비", material: "재료비", expense: "경비" };
|
||||
export const TAG = "이 공사에서 고침";
|
||||
const BACK = "단가산출 값";
|
||||
const LABEL: Record<string, string> = {
|
||||
qty: "품",
|
||||
labor: "노무비",
|
||||
material: "재료비",
|
||||
expense: "경비",
|
||||
};
|
||||
const FIELD: Record<string, string> = {
|
||||
qty: "qty",
|
||||
labor_price: "labor",
|
||||
material_price: "material",
|
||||
expense_price: "expense",
|
||||
};
|
||||
|
||||
const fmt = (v: unknown): string =>
|
||||
typeof v === "number" ? v.toLocaleString("ko-KR", { maximumFractionDigits: 4 }) : "빈칸";
|
||||
|
||||
/* ── 순수 — 산근 블록 → 계산서 묶음 ── */
|
||||
/** 줄이 쓰는 비목 */
|
||||
export function partsOf(ln: Row): string[] {
|
||||
if (ln.kind === "labor") return ["labor"];
|
||||
if (ln.kind === "machine" || ln.kind === "sangeun") return [...PARTS];
|
||||
const own = Object.keys(ln.price || {}).filter((p) => (PARTS as readonly string[]).includes(p));
|
||||
if (own.length) return own;
|
||||
const blank = PARTS.filter((p) => (ln[p] ?? null) === null);
|
||||
return blank.length ? blank : ["material"];
|
||||
}
|
||||
|
||||
/** 고친 줄 원래 값 글 — 「품 0.12 · 노무비 268,908」 */
|
||||
export const baseText = (ln: Row): string =>
|
||||
((ln.covered || []) as string[]).map((k) => `${LABEL[k]} ${fmt(ln.base?.[k])}`).join(" · ");
|
||||
|
||||
function lineText(ln: Row, part: string): string {
|
||||
const back = ln.covered?.length ? ` (원래 ${baseText(ln)})` : "";
|
||||
if (ln.kind === "extra")
|
||||
return ln.unit === "%"
|
||||
? `${String(ln.spec || "").replace("%", `${fmt(ln.qty)}%`)}${back} = `
|
||||
: `${ln.spec || ""}${back} = `;
|
||||
return `${fmt(ln.price?.[part] ?? null)} * ${fmt(ln.qty)}${back} = `;
|
||||
}
|
||||
|
||||
/** 산근 블록 → 공용 계산서 묶음(노무비 · 재료비 · 경비) · 계 */
|
||||
export function sheetGroups(
|
||||
block: Row,
|
||||
machines: Row[],
|
||||
noOf: Record<string, number>,
|
||||
): SheetGroup[] {
|
||||
const byKey = new Map(machines.map((m) => [m.key as string, m]));
|
||||
return PARTS.map((part): SheetGroup => {
|
||||
const lines: SheetLine[] = [];
|
||||
for (const ln of (block.lines || []) as Row[]) {
|
||||
if (!partsOf(ln).includes(part)) continue;
|
||||
const sub = ln.kind === "sangeun" ? noOf[ln.ref?.key] : undefined;
|
||||
const name = `${ln.name || ""}${sub ? ` (산근 ${sub}호표)` : ""}${ln.covered?.length ? ` — ${TAG}` : ""}`;
|
||||
const m = ln.kind === "machine" ? byKey.get(ln.ref?.key) : undefined;
|
||||
lines.push({
|
||||
이름: name,
|
||||
글: lineText(ln, part),
|
||||
금액: ln[part] ?? null,
|
||||
...(ln.kind === "machine" ? { 묶음: "경비", 장비: true } : {}),
|
||||
...(ln.kind === "sangeun" ? { 묶음: "하위" } : {}),
|
||||
쓰는입력: ln.sheet?.쓰는입력 ?? [],
|
||||
기준단위: ln.sheet?.기준단위 ?? null,
|
||||
환산근거: ln.sheet?.환산근거 ?? "",
|
||||
펼침: m
|
||||
? {
|
||||
키: m.key,
|
||||
이름: `${m.name}${m.spec ? ` ${m.spec}` : ""}`,
|
||||
단위: m.unit || "시간",
|
||||
줄: ((m.lines || []) as Row[]).map((x) => ({
|
||||
비목: COST[x.kind] ?? x.kind,
|
||||
이름: x.name,
|
||||
글: `${fmt(x.price)} * ${fmt(x.qty)}${x.unit === "%" ? "%" : ""} = `,
|
||||
금액: x.amount ?? null,
|
||||
})),
|
||||
계: m.sum ?? null,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
return { 비목: COST[part], 소계: block[part] ?? null, 줄: lines };
|
||||
}).filter((g) => g.줄.length);
|
||||
}
|
||||
/* ── 순수 끝 ── */
|
||||
|
||||
const opened = new Set<string>(); // 고침 표 · 입력 펼친 블록(다시 그려도 그대로)
|
||||
let lastTop = 0; // 고친 뒤 다시 그려도 보던 자리
|
||||
|
||||
function setCover(
|
||||
ctx: TabContext,
|
||||
key: string,
|
||||
lid: string,
|
||||
field: string,
|
||||
value: number | null,
|
||||
): void {
|
||||
const all: Row = JSON.parse(JSON.stringify(ctx.user.sg_cover || {}));
|
||||
const lines: Row = all[key] || {};
|
||||
const cell: Row = { ...(lines[lid] || {}) };
|
||||
if (value === null) delete cell[field];
|
||||
else cell[field] = value;
|
||||
if (Object.keys(cell).length) lines[lid] = cell;
|
||||
else delete lines[lid];
|
||||
if (Object.keys(lines).length) all[key] = lines;
|
||||
else delete all[key];
|
||||
ctx.setUser({ ...ctx.user, sg_cover: all });
|
||||
}
|
||||
|
||||
function clearLines(ctx: TabContext, key: string, lids: string[]): void {
|
||||
const all: Row = JSON.parse(JSON.stringify(ctx.user.sg_cover || {}));
|
||||
for (const lid of lids) delete (all[key] || {})[lid];
|
||||
if (all[key] && !Object.keys(all[key]).length) delete all[key];
|
||||
ctx.setUser({ ...ctx.user, sg_cover: all });
|
||||
}
|
||||
|
||||
/** 품 · 단가 고침 표(702 꼴) */
|
||||
function editTable(ctx: TabContext, block: Row): HTMLElement {
|
||||
const lines = (block.lines || []) as Row[];
|
||||
const covered = (i: number, field: string | undefined): boolean =>
|
||||
!!field && ((lines[i]?.covered || []) as string[]).includes(field);
|
||||
const rows: DataRow[] = lines.map((ln, n) => ({
|
||||
key: lineId(n, ln),
|
||||
name: ln.name || "",
|
||||
spec: ln.spec || "",
|
||||
qty: ln.qty,
|
||||
unit: ln.unit || "",
|
||||
labor_price: partsOf(ln).includes("labor") ? (ln.price?.labor ?? null) : "",
|
||||
material_price: partsOf(ln).includes("material") ? (ln.price?.material ?? null) : "",
|
||||
expense_price: partsOf(ln).includes("expense") ? (ln.price?.expense ?? null) : "",
|
||||
amount: PARTS.some((p) => (ln[p] ?? null) === null)
|
||||
? null
|
||||
: PARTS.reduce((s, p) => s + (ln[p] as number), 0),
|
||||
base: ln.covered?.length ? baseText(ln) : "",
|
||||
note: ln.covered?.length ? TAG : ln.why || "",
|
||||
}));
|
||||
const columns = [
|
||||
text("name", "품명", 200),
|
||||
text("spec", "규격", 140),
|
||||
qty("qty", "품 · 수량"),
|
||||
text("unit", "단위", 48),
|
||||
money("labor_price", "노무비", ["단가"], 1),
|
||||
money("material_price", "재료비", ["단가"], 1),
|
||||
money("expense_price", "경비", ["단가"], 1),
|
||||
money("amount", "금액", undefined, 1),
|
||||
text("base", "원래 값", 160),
|
||||
text("note", "비고", 140),
|
||||
];
|
||||
const table = createDataTable({
|
||||
columns,
|
||||
rows,
|
||||
total: { name: "총 계", amount: block.sum },
|
||||
frozen: 1,
|
||||
empty: "",
|
||||
editOnDouble: true,
|
||||
onCellClick: () => undefined, // 줄 표식(data-i) — 두 번 눌러 고침 · 우클릭 · 풍선 자리
|
||||
editable: (i, key) => !!FIELD[key] && fields(lines[i] || {}).includes(FIELD[key]),
|
||||
onEdit: (i, key, value) => {
|
||||
const ln = lines[i];
|
||||
const field = FIELD[key];
|
||||
const v = parseNumber(value);
|
||||
if (!ln || !field || v === undefined) return;
|
||||
const was = covered(i, field)
|
||||
? ln.base?.[field]
|
||||
: field === "qty"
|
||||
? ln.qty
|
||||
: ln.price?.[field];
|
||||
if (v === null || v === was) {
|
||||
if (covered(i, field)) setCover(ctx, block.key, lineId(i, ln), field, null);
|
||||
return;
|
||||
}
|
||||
setCover(ctx, block.key, lineId(i, ln), field, v);
|
||||
},
|
||||
cellClass: (i, key) => (covered(i, FIELD[key]) ? "is-edited" : null),
|
||||
});
|
||||
table.root.dataset.b09SgEdit = block.key;
|
||||
attachHoverInfo(
|
||||
table.root,
|
||||
(cell) => {
|
||||
const field = FIELD[columns[cellColumn(cell as HTMLTableCellElement)]?.key ?? ""];
|
||||
const i = Number(cell.parentElement?.dataset.i);
|
||||
if (!covered(i, field)) return null;
|
||||
return `${TAG} — ${BACK} ${fmt(lines[i].base?.[field])} · 우클릭 [${BACK}으로]`;
|
||||
},
|
||||
"tr[data-i] td",
|
||||
);
|
||||
attachRowMenu(table.root, (i): MapContextMenuItem[] =>
|
||||
lines[i]?.covered?.length
|
||||
? [[`${BACK}으로`, () => clearLines(ctx, block.key, [lineId(i, lines[i])])]]
|
||||
: [],
|
||||
);
|
||||
return table.root;
|
||||
}
|
||||
|
||||
function blockCard(ctx: TabContext, block: Row, noOf: Record<string, number>): HTMLElement {
|
||||
let card: HTMLElement | null = null;
|
||||
const edited = ((block.lines || []) as Row[])
|
||||
.map((ln, n) => (ln.covered?.length ? lineId(n, ln) : null))
|
||||
.filter((x): x is string => !!x);
|
||||
const sheet = el("div", { className: "b09-sg-sheet", attrs: { "data-sg-sheet": block.key } });
|
||||
const groups = sheetGroups(block, ctx.sheets.machine_blocks || [], noOf);
|
||||
const rows = groups.length
|
||||
? calcSheetRows({
|
||||
묶음: groups,
|
||||
계: block.sum ?? null,
|
||||
sumLabel: `계 (원/${block.unit || "식"})`,
|
||||
bundle: true,
|
||||
})
|
||||
: [createText(block.why || "줄 없음", "warning")];
|
||||
for (const r of rows)
|
||||
if (r.firstElementChild?.textContent?.includes(TAG)) r.classList.add("is-edited");
|
||||
sheet.append(...rows);
|
||||
const heads = ((block.head || []) as string[]).slice(1).map((line) => createText(line, "hint"));
|
||||
const edit = opened.has(block.key) ? editTable(ctx, block) : null;
|
||||
heads.forEach((h) => (h.hidden = !opened.has(block.key)));
|
||||
const toggle = createButton({
|
||||
label: opened.has(block.key) ? "고치기 닫기" : "품 · 단가 고치기",
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
if (opened.has(block.key)) opened.delete(block.key);
|
||||
else opened.add(block.key);
|
||||
card?.replaceWith(blockCard(ctx, block, noOf)); // 그 카드만 다시(값 · 캐시 그대로)
|
||||
},
|
||||
});
|
||||
const back = edited.length
|
||||
? [
|
||||
createButton({
|
||||
label: `고친 칸 ${edited.length}줄 모두 ${BACK}으로`,
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => clearLines(ctx, block.key, edited),
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
const meta = [
|
||||
block.spec,
|
||||
`1 ${block.unit || "식"} 당`,
|
||||
block.source,
|
||||
(block.uses || []).length ? `쓰는 곳 ${(block.uses as string[]).join(" · ")}` : "",
|
||||
edited.length ? `${TAG} ${edited.length}줄` : "",
|
||||
].filter(Boolean);
|
||||
card = createCard({
|
||||
title: `제 ${block.no} 호표 — ${block.name || ""}`,
|
||||
actions: [...back, toggle],
|
||||
pad: 12,
|
||||
body: [
|
||||
createText(meta.join(" · "), "muted"),
|
||||
...heads,
|
||||
sheet,
|
||||
...(edit
|
||||
? [
|
||||
edit,
|
||||
createText(
|
||||
`품 · 단가 칸을 두 번 눌러 고침 · 색 칸 = ${TAG}(이 공사만 · [업데이트] 뒤에도 남음) · 우클릭 [${BACK}으로] · [저장] 때 서버가 다시 셈`,
|
||||
"muted",
|
||||
),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
card.dataset.sgBlock = block.key;
|
||||
return card;
|
||||
}
|
||||
|
||||
/** 단가산출근거 탭 판 — focus = 목록표에서 온 산근 키(그 카드로 스크롤) */
|
||||
export function renderSangeunSheets(ctx: TabContext, focus?: string): HTMLElement[] {
|
||||
const blocks = (ctx.sheets.sg || []) as Row[];
|
||||
if (!blocks.length)
|
||||
return [
|
||||
createText(((ctx.sheets.sg_notes as string[]) || []).join(" · ") || "산근 없음", "muted", 14),
|
||||
];
|
||||
const noOf: Record<string, number> = Object.fromEntries(blocks.map((b) => [b.key, b.no]));
|
||||
const orphans = (ctx.sheets.sg_orphans || []) as Row[];
|
||||
const banner = orphans.length
|
||||
? [
|
||||
createBanner({
|
||||
tone: "warning",
|
||||
text: `짝 잃은 고친 칸 ${orphans.length} — 로직이 바뀌어 줄을 못 찾음(덮개는 남음 · 값은 단가산출 값)`,
|
||||
}),
|
||||
]
|
||||
: [];
|
||||
const list = el("div", {
|
||||
className: "b09-sg-sheets",
|
||||
children: blocks.map((b) => blockCard(ctx, b, noOf)),
|
||||
});
|
||||
let placed = false; // 첫 높이 맞춤 뒤 한 번 — 목록표에서 온 블록 · 아니면 보던 자리
|
||||
fillViewport(list, () => {
|
||||
if (placed) return;
|
||||
placed = true;
|
||||
const target = focus && list.querySelector(`[data-sg-block="${CSS.escape(focus)}"]`);
|
||||
if (target) target.scrollIntoView({ block: "start" });
|
||||
else list.scrollTop = lastTop;
|
||||
});
|
||||
list.addEventListener("scroll", () => (lastTop = list.scrollTop), { passive: true });
|
||||
const notes = ((ctx.sheets.sg_notes as string[]) || []).join(" · ");
|
||||
return [...banner, list, createText(notes, "hint")];
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"""719-3 B09 산근 덮개 — 이 공사 단가산출근거 고친 칸(품 · 단가) · 거울(`Sangeun_Cover.py` ↔ `.ts`).
|
||||
|
||||
- 고침 없음 = 서버 풀이 그대로(금액 전후 같음)
|
||||
- 품 · 단가 고침 = 줄 금액 ROUNDDOWN(단가 × 품, 1) · 총계 · 목록표 · 고친 줄 base · covered
|
||||
- 하위 산근 고침 = 부르는 블록 산근 줄 단가 · 총계로 이어짐 · % 덧줄(인건비의 %) = 밑수 비례
|
||||
- 줄 이름 다름 · 없는 블록 = 짝 잃음(알림만) · 저장 다듬기에 남음
|
||||
- 덮개 → 설계내역서 손 줄 단가 · 원가계산서 직접비 · [업데이트](새 단가 판) 뒤 덮개 지킴
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
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_Prices as prices # noqa: E402
|
||||
from B09_Estimation import B09_Estimation_Router as router_mod # noqa: E402
|
||||
from B09_Estimation import B09_Estimation_Sangeun as sangeun # noqa: E402
|
||||
from B09_Estimation import B09_Estimation_Store as store # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import compute_base, rounddown # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Sangeun_Cover import cover_sangeun, line_id # noqa: E402
|
||||
|
||||
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
|
||||
CODES = ["GF000169", "GC001018", "GC000337", "GF000103"] # 하위 산근 · 인건비 % · 중기 · 중기
|
||||
GIVEN = {"GF000169": {"콘크리트량": 0.2, "거푸집면적": 1.5, "연결철근량": 0.01, "비닐면적": 0.5}}
|
||||
|
||||
|
||||
def _user(cover=None):
|
||||
rows = [
|
||||
{"key": f"hand:t{i}", "name": f"시험 {c}", "spec": "", "unit": "식", "qty": 10,
|
||||
"ref": {"tab": "logic", "code": c, "given": GIVEN.get(c, {})}}
|
||||
for i, c in enumerate(CODES)
|
||||
] # fmt: skip
|
||||
return {"bill": {"v": 2, "rows": rows}, **({"sg_cover": cover} if cover else {})}
|
||||
|
||||
|
||||
def _inputs():
|
||||
return prices.build_inputs(None, sangeun.hand_resource_keys(_user()))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def sg():
|
||||
sheets = compute_base(_inputs(), {})
|
||||
return sangeun.collect([], sheets, sangeun.hand_sources(_user()))
|
||||
|
||||
|
||||
def _block(got, code):
|
||||
return next(b for b in got["sg"] if b["logic"] == code)
|
||||
|
||||
|
||||
def _at(block, kind):
|
||||
return next((n, ln) for n, ln in enumerate(block["lines"]) if ln["kind"] == kind)
|
||||
|
||||
|
||||
def _ts(sg, cover):
|
||||
base = IMPORTS.sub("", (ROOT / "B09_Estimation/B09_Estimation_Calc_Base.ts").read_text("utf-8"))
|
||||
calc = IMPORTS.sub(
|
||||
"", (ROOT / "B09_Estimation/B09_Estimation_Sangeun_Cover.ts").read_text("utf-8")
|
||||
)
|
||||
body = (
|
||||
f"\nconst sg = {json.dumps(sg, ensure_ascii=False)};"
|
||||
f"\nconst c = {json.dumps(cover, ensure_ascii=False)};"
|
||||
"\nconsole.log(JSON.stringify(coverSangeun(sg, c)));"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-"],
|
||||
input=base + calc + body, capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def _same(a, b):
|
||||
if isinstance(a, dict) and isinstance(b, dict):
|
||||
return a.keys() == b.keys() and all(_same(a[k], b[k]) for k in a)
|
||||
if isinstance(a, list) and isinstance(b, list):
|
||||
return len(a) == len(b) and all(_same(x, y) for x, y in zip(a, b))
|
||||
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
|
||||
return abs(a - b) < 1e-9
|
||||
return a == b
|
||||
|
||||
|
||||
def _cover(sg):
|
||||
"""품 · 단가 · 하위 산근 · 율 · 중기 단가 고침 한 벌 + 짝 잃은 칸 둘."""
|
||||
sub = _block(sg, "GF000155")
|
||||
n_l, labor = _at(sub, "labor")
|
||||
people = _block(sg, "GC001018")
|
||||
n_p, first = _at(people, "labor")
|
||||
n_x, _extra = _at(people, "extra")
|
||||
net = _block(sg, "GC000337")
|
||||
n_m, machine = _at(net, "machine")
|
||||
n_t, mat = _at(net, "material")
|
||||
parent = _block(sg, "GF000169")
|
||||
n_r, remicon = _at(parent, "material") # 단가 빈칸(대비표에 없음) → 덮개로 채움
|
||||
return {
|
||||
parent["key"]: {line_id(n_r, remicon): {"material": 90000}},
|
||||
sub["key"]: {line_id(n_l, labor): {"qty": labor["qty"] * 2}},
|
||||
people["key"]: {line_id(n_p, first): {"labor": 300000}, line_id(n_x, _extra): {"qty": 5}},
|
||||
net["key"]: {
|
||||
line_id(n_m, machine): {"expense": 20000},
|
||||
line_id(n_t, mat): {"material": 4500},
|
||||
},
|
||||
"SG:없는블록": {"0:x": {"qty": 1}},
|
||||
net["key"] + "x": {"0:다른이름": {"qty": 1}},
|
||||
}
|
||||
|
||||
|
||||
def test_고침_없음_그대로(sg):
|
||||
got = cover_sangeun(sg, {})
|
||||
assert got["sg"] is sg["sg"] and got["sg_list"] is sg["sg_list"] and got["sg_orphans"] == []
|
||||
only_orphans = cover_sangeun(sg, {"SG:없는블록": {"0:x": {"qty": 1}}})
|
||||
assert only_orphans["sg"] == sg["sg"] and only_orphans["sg_orphans"] == [
|
||||
{"key": "SG:없는블록", "line": "0:x"}
|
||||
]
|
||||
|
||||
|
||||
def test_품_단가_고침_줄_금액_총계(sg):
|
||||
before = copy.deepcopy(sg)
|
||||
got = cover_sangeun(sg, _cover(sg))
|
||||
assert sg == before # 받은 답 안 고침
|
||||
net0, net = _block(sg, "GC000337"), _block(got, "GC000337")
|
||||
n, m = _at(net, "machine")
|
||||
assert (
|
||||
m["covered"] == ["expense"]
|
||||
and m["base"]["expense"] == _at(net0, "machine")[1]["price"]["expense"]
|
||||
)
|
||||
assert (
|
||||
m["expense"] == rounddown(20000 * m["qty"], 1)
|
||||
and m["labor"] == _at(net0, "machine")[1]["labor"]
|
||||
)
|
||||
_n, t = _at(net, "material")
|
||||
assert t["material"] == rounddown(4500 * t["qty"], 1)
|
||||
for part in ("labor", "material", "expense"):
|
||||
assert net[part] == rounddown(sum(ln[part] for ln in net["lines"]), 0)
|
||||
assert net["sum"] == net["labor"] + net["material"] + net["expense"] != net0["sum"]
|
||||
row = next(r for r in got["sg_list"] if r["key"] == net["key"])
|
||||
assert row["sum"] == net["sum"] and row["covered"] == 2
|
||||
untouched = _block(got, "GF000103")
|
||||
assert untouched == _block(sg, "GF000103") # 덮개 안 닿은 블록 = 서버 값
|
||||
|
||||
|
||||
def test_하위_산근_이어짐_덧줄_비례(sg):
|
||||
got = cover_sangeun(sg, _cover(sg))
|
||||
sub, parent = _block(got, "GF000155"), _block(got, "GF000169")
|
||||
line = next(ln for ln in parent["lines"] if ln["ref"] and ln["ref"]["key"] == sub["key"])
|
||||
assert line["price"]["labor"] == sub["labor"] != _block(sg, "GF000155")["labor"]
|
||||
assert line["labor"] == rounddown(sub["labor"] * line["qty"], 1)
|
||||
assert parent["labor"] > _block(sg, "GF000169")["labor"]
|
||||
assert _block(sg, "GF000169")["sum"] is None and parent["sum"] is not None # 빈칸 채움
|
||||
assert parent["why"] is None and parent["key"] not in {g["key"] for g in got["sg_gaps"]}
|
||||
people0, people = _block(sg, "GC001018"), _block(got, "GC001018")
|
||||
n, extra = _at(people, "extra")
|
||||
assert extra["of"] == "people" and extra["qty"] == 5
|
||||
base0 = sum(ln["labor"] for ln in people0["lines"][:n] if ln["kind"] == "labor")
|
||||
base1 = sum(ln["labor"] for ln in people["lines"][:n] if ln["kind"] == "labor")
|
||||
value0 = _at(people0, "extra")[1]["price"]["expense"]
|
||||
assert extra["price"]["expense"] == pytest.approx(
|
||||
value0 * base1 / base0 * 5 / extra["base"]["qty"]
|
||||
)
|
||||
|
||||
|
||||
def test_짝_잃음(sg):
|
||||
got = cover_sangeun(sg, _cover(sg))
|
||||
assert {o["key"] for o in got["sg_orphans"]} == {
|
||||
"SG:없는블록",
|
||||
_block(sg, "GC000337")["key"] + "x",
|
||||
}
|
||||
|
||||
|
||||
def test_거울_파이썬_TS(sg):
|
||||
cover = _cover(sg)
|
||||
assert _same(cover_sangeun(sg, cover), _ts(sg, cover))
|
||||
assert _same(cover_sangeun(sg, {}), _ts(sg, {}))
|
||||
|
||||
|
||||
def test_저장_다듬기_남음(sg):
|
||||
cover = _cover(sg)
|
||||
clean = store.clean_user({"sg_cover": cover})["sg_cover"]
|
||||
assert clean == cover
|
||||
|
||||
|
||||
def test_덮개_대가_원가계산서_업데이트_뒤_지킴(sg, tmp_path):
|
||||
inputs = _inputs()
|
||||
plain = router_mod.compute_sheets(tmp_path, copy.deepcopy(inputs), _user())
|
||||
net = _block(sg, "GC000337")
|
||||
n, mat = _at(net, "material")
|
||||
cover = {net["key"]: {line_id(n, mat): {"qty": mat["qty"] + 1}}}
|
||||
covered = router_mod.compute_sheets(tmp_path, copy.deepcopy(inputs), _user(cover))
|
||||
gap = rounddown(rounddown(mat["price"]["material"] * (mat["qty"] + 1), 1) - mat["material"], 0)
|
||||
row0 = next(r for r in plain["sg_list"] if r["key"] == net["key"])
|
||||
row1 = next(r for r in covered["sg_list"] if r["key"] == net["key"])
|
||||
assert row1["material"] - row0["material"] in (gap, gap + 1, gap - 1)
|
||||
hand = next(r for r in covered["bill"]["rows"] if r["key"] == "hand:t2")
|
||||
hand0 = next(r for r in plain["bill"]["rows"] if r["key"] == "hand:t2")
|
||||
assert hand["material"] - hand0["material"] == (row1["material"] - row0["material"]) * 10
|
||||
m0, m1 = plain["total"]["sums"]["material"], covered["total"]["sums"]["material"]
|
||||
assert m1 - m0 == hand["material"] - hand0["material"]
|
||||
# [업데이트] = 새 단가 판 — 산근 키 같음 · 덮개 칸 그대로 · 밑 값 = 새 단가
|
||||
newer = copy.deepcopy(inputs)
|
||||
for row in newer.get("materials") or []:
|
||||
if row.get("key") == mat["ref"]["key"]:
|
||||
for k in ("price", "applied", "lowest"):
|
||||
if isinstance(row.get(k), (int, float)):
|
||||
row[k] = row[k] * 2
|
||||
again = router_mod.compute_sheets(tmp_path, newer, _user(cover))
|
||||
line = _block(again, "GC000337")["lines"][n]
|
||||
assert line["covered"] == ["qty"] and line["qty"] == mat["qty"] + 1
|
||||
assert line["base"]["qty"] == mat["qty"] and again["sg_orphans"] == []
|
||||
|
||||
|
||||
def test_덮인_답_다시_덮음_되돌림(sg):
|
||||
"""서버 답이 덮인 채 옴([저장] · 셈 답) — 화면 짝이 벗기고 다시 덮음 · 덮개 지우면 처음 값."""
|
||||
cover = _cover(sg)
|
||||
once = cover_sangeun(sg, cover)
|
||||
assert cover_sangeun(once, cover) == once # 두 번 덮어도 같음(원래 값 = 처음 값)
|
||||
back = cover_sangeun(once, {})
|
||||
assert back["sg"] == sg["sg"] and back["sg_list"] == sg["sg_list"]
|
||||
assert back["sg_gaps"] == sg["sg_gaps"]
|
||||
assert _same(cover_sangeun(once, cover), _ts(once, cover))
|
||||
assert _same(back, _ts(once, {}))
|
||||
@@ -0,0 +1,106 @@
|
||||
"""719-4 B09 단가산출근거 = 공용 계산서 꼴 — 서버 줄 덧칸 · 화면 묶음 옮김(셈 없음) · 금액 같음.
|
||||
|
||||
- 서버 산근 줄 `sheet`{쓰는입력 · 기준단위 · 환산근거}(714-1 `master_sheet_line` 한 벌) ·
|
||||
블록 `기준단위`
|
||||
- 화면 `sheetGroups` = 노무비 · 재료비 · 경비 묶음(소계 = 블록 비목 총계 · 줄 금액 = 서버 값) →
|
||||
공용 `bundleLines` = 인력 · 재료 · 경비(장비 한 줄 + 펼침 = 중기사용료 넉 줄) · 하위(산근 n호표)
|
||||
- 덧칸만 더함 — 줄 값 · 총계 · 목록표는 덧칸 빼면 옛 풀이와 같음(`text` 줄 셈 그대로)
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
for p in (ROOT, ROOT / "resources" / "tester"):
|
||||
if str(p) not in sys.path:
|
||||
sys.path.insert(0, str(p))
|
||||
|
||||
from test_719_3_sangeun_cover import _inputs, _user # noqa: E402
|
||||
|
||||
from B09_Estimation import B09_Estimation_Sangeun as sangeun # noqa: E402
|
||||
from B09_Estimation.B09_Estimation_Calc_Base import compute_base # noqa: E402
|
||||
|
||||
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def made():
|
||||
sheets = compute_base(_inputs(), {})
|
||||
return sheets, sangeun.collect([], sheets, sangeun.hand_sources(_user()))
|
||||
|
||||
|
||||
def _ts(sg, machines):
|
||||
files = [
|
||||
"B09_Estimation/B09_Estimation_Calc_Base.ts",
|
||||
"A00_Common/calc_sheet.ts",
|
||||
"B09_Estimation/B09_Estimation_UI_Tabs_Sangeun_Sheet.ts",
|
||||
]
|
||||
src = "".join(IMPORTS.sub("", (ROOT / f).read_text("utf-8")) for f in files)
|
||||
body = (
|
||||
"\nconst currentLanguageIndex = 0;"
|
||||
f"\nconst sg = {json.dumps(sg, ensure_ascii=False)};"
|
||||
f"\nconst machines = {json.dumps(machines, ensure_ascii=False)};"
|
||||
"\nconst noOf = Object.fromEntries(sg.map((b) => [b.key, b.no]));"
|
||||
"\nconsole.log(JSON.stringify(sg.map((b) => {"
|
||||
"\n const groups = sheetGroups(b, machines, noOf);"
|
||||
"\n return { key: b.key, groups, bundles: bundleLines(groups) };"
|
||||
"\n})));"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-"],
|
||||
input=src + body, capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return {x["key"]: x for x in json.loads(result.stdout)}
|
||||
|
||||
|
||||
def test_서버_줄_덧칸(made):
|
||||
_sheets, sg = made
|
||||
for b in sg["sg"]:
|
||||
assert "기준단위" in b
|
||||
for ln in b["lines"]:
|
||||
assert set(ln["sheet"]) >= {"쓰는입력", "기준단위", "환산근거"}, ln
|
||||
assert "_goods" not in ln
|
||||
net = next(b for b in sg["sg"] if b["logic"] == "GC000337")
|
||||
assert net["기준단위"] == "㎡"
|
||||
machine = next(ln for ln in net["lines"] if ln["kind"] == "machine")
|
||||
assert "크레인" in machine["sheet"]["쓰는입력"]
|
||||
|
||||
|
||||
def test_화면_묶음_옮김_금액_그대로(made):
|
||||
sheets, sg = made
|
||||
got = _ts(sg["sg"], sheets["machine_blocks"])
|
||||
for b in sg["sg"]:
|
||||
groups = got[b["key"]]["groups"]
|
||||
cost = {"노무비": "labor", "재료비": "material", "경비": "expense"}
|
||||
for g in groups:
|
||||
part = cost[g["비목"]]
|
||||
assert g["소계"] == b[part]
|
||||
assert [x["금액"] for x in g["줄"]] == [
|
||||
ln[part] for ln in b["lines"] if part in _parts(ln)
|
||||
]
|
||||
net = next(b for b in sg["sg"] if b["logic"] == "GC000337")
|
||||
bundles = {x["묶음"]: x for x in got[net["key"]]["bundles"]}
|
||||
assert set(bundles) == {"인력", "재료", "경비"}
|
||||
equip = next(x for x in bundles["경비"]["줄"] if "몫" in x)
|
||||
assert len(equip["몫"]) == 3 # 장비 한 줄 = 노 · 재 · 경
|
||||
unfold = equip["몫"][0]["줄"]["펼침"]
|
||||
assert unfold["단위"] == "시간" and len(unfold["줄"]) == 4 # 손료 · 운전사 · 연료 · 잡품
|
||||
parent = next(b for b in sg["sg"] if b["logic"] == "GF000169")
|
||||
sub = {x["묶음"]: x for x in got[parent["key"]]["bundles"]}["하위"]
|
||||
assert all("산근" in x["이름"] and "호표" in x["이름"] for x in sub["줄"])
|
||||
|
||||
|
||||
def _parts(ln):
|
||||
if ln["kind"] == "labor":
|
||||
return ["labor"]
|
||||
if ln["kind"] in ("machine", "sangeun"):
|
||||
return ["labor", "material", "expense"]
|
||||
own = [p for p in ln.get("price") or {} if p in ("labor", "material", "expense")]
|
||||
return own or [p for p in ("labor", "material", "expense") if ln.get(p) is None] or ["material"]
|
||||
Reference in New Issue
Block a user