feat(M02): 일위대가 자료 틀 — 열마다 labor 문서 · SU 번호 · 서버 계산 · calc · pills 길 (39-1a)
- labor 종류 · 일위대가번 SU(도번 · 면적번과 같은 뒷자리) · 빈 문서 · 뼈대 검사
- M02_Labor_Doc: 정리 · 검사(같은 로직 두 번 · 없는 로직 · 자재 · 수량 꼴) · 기준(M01 엔진) · settle(수량 · 반영 · 합)
- [저장] · 복사 때 서버가 계산값 다시 적음 · 면적산출 저장 때 그 열 일위대가 계산값도
- 짝 M02_MasterTemplete_Labor_Calc.ts settle + 거울 시험
- 길 POST /layers/{층}/labor/{열}/calc · GET …/pills (main.py 등록)
- M01 _PER 「원/1,000본」 배 1000
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
This commit is contained in:
@@ -150,7 +150,9 @@ def combo(key: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
_PER = re.compile(r"원\s*/\s*(\d*\.?\d*)\s*([^\s·(]+)")
|
||||
_PER = re.compile(
|
||||
r"원\s*/\s*(\d[\d,]*\.?\d*|\.\d+)?\s*([^\s·(\d,.][^\s·(]*)"
|
||||
) # 「원/1,000본」 = 배 1000
|
||||
_TAIL = re.compile(r"\s*\([^()]*\)\s*$")
|
||||
|
||||
|
||||
@@ -185,7 +187,7 @@ def combo_pills(row: dict) -> dict:
|
||||
(name, unit), {"키": f"{name}|{unit}", "이름": name, "단위": unit, "로직": []}
|
||||
)
|
||||
pill["로직"].append(code)
|
||||
times = per[0][0]
|
||||
times = per[0][0].replace(",", "")
|
||||
if len(per) == 1 and times and float(times) != 1:
|
||||
pill["배"] = float(times) if "." in times else int(times)
|
||||
return {"키": row.get("키"), "이름": row.get("이름"), "물량": list(pills.values()), "조건": []}
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
"""M02 일위대가 문서 `labor/<열id>.json` — 정리 · 검사 · 계산 · 알약(PLAN 39-1a · 설계_정함 1장).
|
||||
|
||||
문서 = `{종류, 판, 열, 일위대가번, 단위, 본뜬, 담은로직: [{로직, 메모, 입력값, 수량}],
|
||||
자재: [{코드, 이름, 규격, 단위, 수량}], 계산값}` — 이름은 구조물 열 이름(따로 안 둠).
|
||||
수량 = `null`(면적산출 알약 칸 값 · 로직 알약 「이름|단위」 · 자재 알약 「자재|MT키」) | `{손: 수}`
|
||||
(손이 칸보다 앞섬) · 같은 로직 두 번 금지 · 입력값 = 그 로직 입력 이름만(빈 값 = 자동값).
|
||||
금액: 기준 = 결과단위 그대로(서버 단독 · M01 엔진) · 반영 = 기준 × 수량 ÷ 배 · 돈 아닌 로직은
|
||||
반영 없음 · 자재 기준 단가 = `master_material.element` 기본값.
|
||||
짝(CLAUDE.md 5장 ①): 수량 · 반영 · 합 `settle` ↔ `M02_MasterTemplete_Labor_Calc.ts` —
|
||||
거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store as m01
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
|
||||
mf = m01.cm.mf
|
||||
mm = mf.mp.mm
|
||||
COSTS = mf.COST_ITEMS # 노무비 · 재료비 · 경비
|
||||
MATERIAL_HEAD = "자재|"
|
||||
_MAT_COLS = ("코드", "이름", "규격", "단위")
|
||||
|
||||
|
||||
def _bad(text: str) -> store.StoreError:
|
||||
return store.StoreError(400, text)
|
||||
|
||||
|
||||
def _number(raw: Any) -> Decimal | None:
|
||||
"""칸 값 · 손 값 → Decimal — 수가 아니면 None(쉼표는 뗌)."""
|
||||
if raw is None or isinstance(raw, bool):
|
||||
return None
|
||||
try:
|
||||
got = Decimal(str(raw).replace(",", "").strip())
|
||||
except InvalidOperation:
|
||||
return None
|
||||
return got if got.is_finite() else None
|
||||
|
||||
|
||||
def _specs(logic: dict) -> list[dict]:
|
||||
"""로직 입력 꼴 — 흐름 로직은 「설계 때 정할 것」."""
|
||||
if isinstance(logic.get("흐름"), dict):
|
||||
return mf.inputs_of(logic)
|
||||
return list(logic.get("입력") or [])
|
||||
|
||||
|
||||
def _qty(value: Any, where: str) -> dict | None:
|
||||
if value is None:
|
||||
return None
|
||||
hand = _number(value.get("손")) if isinstance(value, dict) else None
|
||||
if hand is None:
|
||||
raise _bad(f"{where} · 수량은 null 또는 {{손: 수}}")
|
||||
return {"손": int(hand) if hand == hand.to_integral_value() else float(hand)}
|
||||
|
||||
|
||||
# ── 정리 · 검사 ───────────────────────────────────────────────────────
|
||||
def clean(doc: Any) -> dict[str, Any]:
|
||||
"""서버가 받는 꼴로 — 모르는 로직 · 같은 로직 두 번 · 없는 자재 · 수량 꼴이 틀리면 400 ·
|
||||
모르는 입력 이름 · 빈 입력값은 버림 · 자재 이름 · 규격 · 단위는 M01 것."""
|
||||
if not isinstance(doc, dict):
|
||||
raise _bad("일위대가 문서가 묶음 아님")
|
||||
whole = m01.loaded()[1]
|
||||
lines, seen = [], set()
|
||||
for at, one in enumerate(doc.get("담은로직") or [], start=1):
|
||||
where = f"담은 로직 {at}째"
|
||||
if not isinstance(one, dict):
|
||||
raise _bad(f"{where} · 묶음 아님")
|
||||
code = str(one.get("로직") or "")
|
||||
try:
|
||||
logic = whole.logic(code)
|
||||
except mf.FormulaError as e:
|
||||
raise _bad(f"{where} · {e}") from e
|
||||
if code in seen:
|
||||
raise _bad(f"{where} · 같은 로직을 두 번 담음 「{code}」")
|
||||
seen.add(code)
|
||||
names = {str(spec.get("이름")) for spec in _specs(logic)}
|
||||
given = one.get("입력값") or {}
|
||||
if not isinstance(given, dict):
|
||||
raise _bad(f"{where} · 입력값이 묶음 아님")
|
||||
memo = one.get("메모")
|
||||
lines.append(
|
||||
{
|
||||
"로직": code,
|
||||
"메모": str(memo) if memo else None,
|
||||
"입력값": {k: v for k, v in given.items() if k in names and v not in (None, "")},
|
||||
"수량": _qty(one.get("수량"), where),
|
||||
}
|
||||
)
|
||||
mats, codes = [], set()
|
||||
for at, one in enumerate(doc.get("자재") or [], start=1):
|
||||
where = f"자재 {at}째"
|
||||
code = str(one.get("코드") or "") if isinstance(one, dict) else ""
|
||||
try:
|
||||
row = whole.get(code) if code[:2] == mm.MARKET else None
|
||||
except mf.FormulaError:
|
||||
row = None
|
||||
if row is None:
|
||||
raise _bad(f"{where} · M01 자재품목에 없는 코드 「{code}」")
|
||||
if code in codes:
|
||||
raise _bad(f"{where} · 같은 자재를 두 번 담음 「{code}」")
|
||||
codes.add(code)
|
||||
mats.append(
|
||||
{
|
||||
"코드": code,
|
||||
**{k: row.get(k) or "" for k in _MAT_COLS[1:]},
|
||||
"수량": _qty(one.get("수량"), where),
|
||||
}
|
||||
)
|
||||
out = {
|
||||
"종류": "일위대가",
|
||||
"판": doc.get("판") or 1,
|
||||
"열": doc.get("열"),
|
||||
"단위": str(doc["단위"]) if doc.get("단위") else None,
|
||||
"본뜬": doc["본뜬"] if isinstance(doc.get("본뜬"), dict) else None,
|
||||
"담은로직": lines,
|
||||
"자재": mats,
|
||||
}
|
||||
if doc.get("일위대가번"):
|
||||
out["일위대가번"] = doc["일위대가번"]
|
||||
return out
|
||||
|
||||
|
||||
# ── 알약 · 면적 값 ────────────────────────────────────────────────────
|
||||
def material_key(code: str) -> str:
|
||||
return f"{MATERIAL_HEAD}{code}"
|
||||
|
||||
|
||||
def pills(doc: dict) -> list[dict[str, Any]]:
|
||||
"""면적산출 알약 — 로직 물량 알약(`combo_pills` 그대로) + 자재 알약 「자재|MT키」."""
|
||||
logic = combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"]
|
||||
mats = [
|
||||
{
|
||||
"키": material_key(m["코드"]),
|
||||
"이름": m.get("이름") or m["코드"],
|
||||
"단위": m.get("단위") or "",
|
||||
"자재": {k: m.get(k) or "" for k in _MAT_COLS},
|
||||
}
|
||||
for m in doc.get("자재") or []
|
||||
]
|
||||
return [*logic, *mats]
|
||||
|
||||
|
||||
def area_values(area: Any) -> dict[str, Decimal]:
|
||||
"""면적산출 문서 `연결` 의 알약 → 칸 값(식 칸 = 서버 `계산값` · 그 밖 = 칸 값) · 수만."""
|
||||
out: dict[str, Decimal] = {}
|
||||
if not isinstance(area, dict):
|
||||
return out
|
||||
calc = area.get("계산값") or {}
|
||||
for sheet in area.get("시트") or []:
|
||||
cells, got = sheet.get("칸") or {}, calc.get(sheet.get("id")) or {}
|
||||
for link in sheet.get("연결") or []:
|
||||
at = link.get("칸")
|
||||
raw = (got.get(at) or {}).get("수")
|
||||
if raw is None:
|
||||
raw = (cells.get(at) or {}).get("값")
|
||||
value = _number(raw)
|
||||
if value is not None and link.get("키"):
|
||||
out[str(link["키"])] = value
|
||||
return out
|
||||
|
||||
|
||||
def _area_of(column: Any) -> dict[str, Decimal]:
|
||||
"""지금 층 그 열 면적산출의 알약 값 — 파일이 없으면 빈 것."""
|
||||
try:
|
||||
path = store._path("area", str(column or ""))
|
||||
except store.StoreError:
|
||||
return {}
|
||||
return area_values(json.loads(path.read_bytes())) if path.is_file() else {}
|
||||
|
||||
|
||||
# ── 계산 ──────────────────────────────────────────────────────────────
|
||||
def _money(value: Any) -> dict[str, Decimal] | None:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
return {k: _number(value.get(k)) or Decimal(0) for k in (*COSTS, "계")}
|
||||
|
||||
|
||||
def reflect(base: dict[str, Decimal], qty: Decimal | None, times: Decimal) -> dict | None:
|
||||
"""반영 = 기준 × 수량 ÷ 배 — 수량이 없으면 None."""
|
||||
if qty is None:
|
||||
return None
|
||||
return {k: base[k] * qty / times for k in (*COSTS, "계")}
|
||||
|
||||
|
||||
def _sum(rows: list[dict | None]) -> dict[str, Decimal]:
|
||||
return {k: sum((r[k] for r in rows if r), Decimal(0)) for k in (*COSTS, "계")}
|
||||
|
||||
|
||||
def _pick(hand: Any, key: Any, given: dict[str, Decimal]) -> tuple[Decimal | None, str]:
|
||||
"""(수량, 출처) — 손 · 칸(알약 키) · 없음."""
|
||||
if isinstance(hand, dict) and _number(hand.get("손")) is not None:
|
||||
return _number(hand["손"]), "손"
|
||||
if key and key in given:
|
||||
return given[key], "칸"
|
||||
return None, "없음"
|
||||
|
||||
|
||||
def settle(result: dict, doc: dict, given: dict[str, Any]) -> dict[str, Any]:
|
||||
"""기준이 선 계산 답에 수량 · 반영 · 합을 채움 — 수량만 바뀌면 이것만 다시
|
||||
(짝: `Labor_Calc.ts` `settle` · 자재 = 재료비)."""
|
||||
have = {str(k): v for k, raw in given.items() if (v := _number(raw)) is not None}
|
||||
hands = {one.get("로직"): one.get("수량") for one in doc.get("담은로직") or []}
|
||||
mhands = {one.get("코드"): one.get("수량") for one in doc.get("자재") or []}
|
||||
zero = dict.fromkeys((*COSTS, "계"), Decimal(0))
|
||||
lines, mats, bases, reflects = [], [], [], []
|
||||
for line in result.get("줄") or []:
|
||||
qty, source = _pick(hands.get(line.get("로직")), line.get("알약"), have)
|
||||
base = _money(line.get("기준"))
|
||||
done = reflect(base, qty, _number(line.get("배")) or Decimal(1)) if base else None
|
||||
bases.append(base)
|
||||
reflects.append(done)
|
||||
lines.append({**line, "수량": qty, "수량출처": source, "반영": done})
|
||||
for one in result.get("자재") or []:
|
||||
qty, source = _pick(mhands.get(one.get("코드")), one.get("알약"), have)
|
||||
price = _number(one.get("기준단가"))
|
||||
cost = price * qty if price is not None and qty is not None else None
|
||||
bases.append({**zero, "재료비": price, "계": price} if price is not None else None)
|
||||
reflects.append({**zero, "재료비": cost, "계": cost} if cost is not None else None)
|
||||
mats.append({**one, "수량": qty, "수량출처": source, "반영": cost})
|
||||
return _json({"줄": lines, "자재": mats, "기준합": _sum(bases), "반영합": _sum(reflects)})
|
||||
|
||||
|
||||
def calc(doc: dict, given: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""`doc` = 정리한 문서 · `given` = {알약 키: 값}(없으면 지금 층 면적산출 파일에서) —
|
||||
줄마다 기준(서버 단독 · M01 엔진) → `settle`. 못 도는 줄은 까닭만(통째로 멈추지 않음)."""
|
||||
whole = m01.loaded()[1]
|
||||
by_logic = {
|
||||
code: pill
|
||||
for pill in combo_store.combo_pills({"담은로직": doc.get("담은로직") or []})["물량"]
|
||||
for code in pill["로직"]
|
||||
}
|
||||
lines = []
|
||||
for one in doc.get("담은로직") or []:
|
||||
code = one["로직"]
|
||||
pill = by_logic.get(code) or {}
|
||||
line = {
|
||||
"로직": code,
|
||||
"알약": pill.get("키"),
|
||||
"분모": pill.get("단위"),
|
||||
"배": pill.get("배") or 1,
|
||||
}
|
||||
try:
|
||||
logic = whole.logic(code)
|
||||
auto = {k: v for k, v in m01.mau.values(whole, logic).items() if v is not None}
|
||||
got = mf.run(whole, code, {**auto, **m01._dec(one.get("입력값") or {})})
|
||||
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
|
||||
lines.append({**line, "기준": None, "까닭": str(e) or type(e).__name__})
|
||||
continue
|
||||
if "결과" in got: # 돈 아닌 로직 — 반영 없음
|
||||
lines.append({**line, "결과": got["결과"], "기준": None, "까닭": None})
|
||||
continue
|
||||
lines.append({**line, "기준": {k: got[k] for k in (*COSTS, "계")}, "까닭": None})
|
||||
mats = []
|
||||
for one in doc.get("자재") or []:
|
||||
try:
|
||||
price, _ = mm.element(whole, one["코드"], {}, one.get("단위") or None)
|
||||
except mf.FormulaError as e:
|
||||
price, why = None, str(e)
|
||||
else:
|
||||
why = None if price is not None else "단가 없음"
|
||||
mats.append(
|
||||
{"코드": one["코드"], "알약": material_key(one["코드"]), "기준단가": price, "까닭": why}
|
||||
)
|
||||
have = _area_of(doc.get("열")) if given is None else given
|
||||
return settle(_json({"줄": lines, "자재": mats}), doc, have)
|
||||
|
||||
|
||||
def _json(value: Any) -> Any:
|
||||
"""Decimal → JSON 수(정수면 int)."""
|
||||
if isinstance(value, Decimal):
|
||||
return int(value) if value == value.to_integral_value() else float(value)
|
||||
if isinstance(value, list):
|
||||
return [_json(v) for v in value]
|
||||
if isinstance(value, dict):
|
||||
return {k: _json(v) for k, v in value.items()}
|
||||
return value
|
||||
|
||||
|
||||
def server(doc: Any) -> dict[str, Any]:
|
||||
"""[저장] · 복사 때 서버 정본 — 정리 + 지금 면적 값으로 `계산값`(브라우저 값 버림)."""
|
||||
got = clean(doc)
|
||||
return {**got, "계산값": calc(got)}
|
||||
@@ -0,0 +1,64 @@
|
||||
"""M02 일위대가 계산 · 알약 길(PLAN 39-1a · 설계_정함 1장) — `/api/m02/layers/{층}/labor/{열}/…`.
|
||||
|
||||
문서 읽기 · 쓰기는 양식 길(`GET/PUT /api/m02/layers/{층}/templates/labor/{열}`) 그대로 ·
|
||||
여기는 저장하지 않는 셈만. 층 권한 = `Router_Items._root`(읽기 권한 · `M02_Layer_Access` 한 벌).
|
||||
⚠ 등록은 `main.py`(로그인만).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Layer, Session, _call, _root
|
||||
|
||||
router = APIRouter(prefix="/api/m02", tags=["M02 MasterTemplete Labor"])
|
||||
|
||||
|
||||
class CalcBody(BaseModel):
|
||||
"""`doc` = 저장 전 화면 문서(없으면 저장본) · `수량` = {알약 키: 값}(없으면 저장된 면적산출)."""
|
||||
|
||||
doc: dict[str, Any] | None = None
|
||||
수량: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _doc(column: str, sent: dict[str, Any] | None) -> dict[str, Any]:
|
||||
got = sent if sent is not None else store.read("labor", column)["문서"]
|
||||
return labor.clean({**got, "열": column})
|
||||
|
||||
|
||||
def _calc(column: str, body: CalcBody) -> dict[str, Any]:
|
||||
doc = _doc(column, body.doc)
|
||||
return {**labor.calc(doc, body.수량), "알약": labor.pills(doc)}
|
||||
|
||||
|
||||
def _pills(column: str) -> dict[str, Any]:
|
||||
return {"열": column, "알약": labor.pills(_doc(column, None))}
|
||||
|
||||
|
||||
@router.post("/layers/{layer}/labor/{column}/calc")
|
||||
def calc_labor(
|
||||
layer: Layer,
|
||||
column: str,
|
||||
body: CalcBody,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""줄마다 기준 · 반영 · 합 + 알약(저장 안 함 · 화면은 최신 응답만)."""
|
||||
return _call(_root(session, layer, owner), _calc, column, body)
|
||||
|
||||
|
||||
@router.get("/layers/{layer}/labor/{column}/pills")
|
||||
def labor_pills(
|
||||
layer: Layer,
|
||||
column: str,
|
||||
owner: str | None = None,
|
||||
session: Session = Depends(verify_session),
|
||||
) -> dict:
|
||||
"""면적산출 알약 — 로직 물량 알약 「이름|단위」 + 자재 알약 「자재|MT키」(저장본)."""
|
||||
return _call(_root(session, layer, owner), _pills, column)
|
||||
@@ -151,8 +151,7 @@ def copy_item(
|
||||
have = store.read(kind, new_id)["문서"]
|
||||
body = {k: v for k, v in got.items() if k != "계산값"}
|
||||
body = {**body, "열": new_id, number: have.get(number)}
|
||||
if kind in store.SHEETS:
|
||||
body = store._server_values(body)
|
||||
body = store._server(kind, body)
|
||||
with store._LOCK:
|
||||
atomic_write_json(store._path(kind, new_id), body)
|
||||
ss = next(r for r in store.read_combos()["줄"] if r.get("열") == new_id)
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/* M02 일위대가 수량 · 반영 · 합(PLAN 39-1a · 설계_정함 1장) — 면적 칸 값이 바뀌면 화면이 바로 곱함.
|
||||
*
|
||||
* 짝(CLAUDE.md 5장 ①): `M02_Labor_Doc.py` `settle` 과 같은 규칙 —
|
||||
* 거울 시험 `resources/tester/test_m02_labor_calc_mirror.py`.
|
||||
* 기준(결과단위 그대로)은 서버 calc 길이 셈 · 여기는 받은 기준에
|
||||
* 수량(손이 칸보다 앞섬 · 칸 = 알약 키 값) · 반영 = 기준 × 수량 ÷ 배 · 자재 = 재료비만. */
|
||||
|
||||
export const MONEY_KEYS = ["노무비", "재료비", "경비", "계"] as const;
|
||||
export type Money = Record<(typeof MONEY_KEYS)[number], number>;
|
||||
export type QtySource = "손" | "칸" | "없음";
|
||||
/** 문서의 수량 칸 — null = 면적산출 알약 · {손} = 손 입력 */
|
||||
export type Qty = { 손: number } | null;
|
||||
|
||||
export interface CalcLine {
|
||||
로직: string;
|
||||
알약?: string | null;
|
||||
분모?: string | null;
|
||||
배: number;
|
||||
기준: Money | null;
|
||||
수량?: number | null;
|
||||
수량출처?: QtySource;
|
||||
반영?: Money | null;
|
||||
까닭?: string | null;
|
||||
결과?: unknown;
|
||||
}
|
||||
|
||||
export interface CalcMaterial {
|
||||
코드: string;
|
||||
알약?: string | null;
|
||||
기준단가: number | null;
|
||||
수량?: number | null;
|
||||
수량출처?: QtySource;
|
||||
반영?: number | null;
|
||||
까닭?: string | null;
|
||||
}
|
||||
|
||||
export interface LaborCalc {
|
||||
줄: CalcLine[];
|
||||
자재: CalcMaterial[];
|
||||
기준합?: Money;
|
||||
반영합?: Money;
|
||||
}
|
||||
|
||||
/** 수량만 보는 문서 조각 — 담은 로직 · 자재의 손 값 */
|
||||
export interface LaborQtyDoc {
|
||||
담은로직?: { 로직: string; 수량?: Qty }[];
|
||||
자재?: { 코드: string; 수량?: Qty }[];
|
||||
}
|
||||
|
||||
function num(raw: unknown): number | null {
|
||||
if (raw === null || raw === undefined || typeof raw === "boolean") return null;
|
||||
const n = Number(String(raw).replace(/,/g, "").trim());
|
||||
return String(raw).trim() !== "" && Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
function zero(): Money {
|
||||
return { 노무비: 0, 재료비: 0, 경비: 0, 계: 0 };
|
||||
}
|
||||
|
||||
function pick(
|
||||
hand: Qty | undefined,
|
||||
key: string | null | undefined,
|
||||
given: Record<string, number>,
|
||||
): [number | null, QtySource] {
|
||||
const own = hand ? num(hand.손) : null;
|
||||
if (own !== null) return [own, "손"];
|
||||
if (key && key in given) return [given[key], "칸"];
|
||||
return [null, "없음"];
|
||||
}
|
||||
|
||||
/** 반영 = 기준 × 수량 ÷ 배 — 수량이 없으면 null */
|
||||
export function reflect(base: Money, qty: number | null, times: number): Money | null {
|
||||
if (qty === null) return null;
|
||||
const out = zero();
|
||||
for (const k of MONEY_KEYS) out[k] = (base[k] * qty) / times;
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 기준이 선 계산 답 + 문서 손 값 + 알약 값(`{키: 값}`) → 수량 · 반영 · 합을 새로 채운 답 */
|
||||
export function settle(
|
||||
result: LaborCalc,
|
||||
doc: LaborQtyDoc,
|
||||
given: Record<string, unknown>,
|
||||
): LaborCalc {
|
||||
const have: Record<string, number> = {};
|
||||
for (const [k, raw] of Object.entries(given)) {
|
||||
const v = num(raw);
|
||||
if (v !== null) have[k] = v;
|
||||
}
|
||||
const hands = new Map((doc.담은로직 ?? []).map((one) => [one.로직, one.수량 ?? null]));
|
||||
const mhands = new Map((doc.자재 ?? []).map((one) => [one.코드, one.수량 ?? null]));
|
||||
const base = zero();
|
||||
const done = zero();
|
||||
const add = (sum: Money, m: Money | null): void => {
|
||||
if (m) for (const k of MONEY_KEYS) sum[k] += m[k];
|
||||
};
|
||||
const 줄 = result.줄.map((line) => {
|
||||
const [qty, source] = pick(hands.get(line.로직), line.알약, have);
|
||||
const own = line.기준 ? reflect(line.기준, qty, num(line.배) || 1) : null;
|
||||
add(base, line.기준);
|
||||
add(done, own);
|
||||
return { ...line, 수량: qty, 수량출처: source, 반영: own };
|
||||
});
|
||||
const 자재 = result.자재.map((one) => {
|
||||
const [qty, source] = pick(mhands.get(one.코드), one.알약, have);
|
||||
const price = num(one.기준단가);
|
||||
const cost = price !== null && qty !== null ? price * qty : null;
|
||||
if (price !== null) add(base, { ...zero(), 재료비: price, 계: price });
|
||||
if (cost !== null) add(done, { ...zero(), 재료비: cost, 계: cost });
|
||||
return { ...one, 수량: qty, 수량출처: source, 반영: cost };
|
||||
});
|
||||
return { 줄, 자재, 기준합: base, 반영합: done };
|
||||
}
|
||||
@@ -65,12 +65,13 @@ IN_USE = {
|
||||
# (종류, 문서 번호 칸, 머리 글자, 시트 이름) — 시트 이름이 None 이면 구조물 도면
|
||||
PARTS = (
|
||||
("structure", "도번", "SD", None),
|
||||
("labor", "일위대가번", "SU", None), # 일위대가 — `M02_Labor_Doc`(39장)
|
||||
("area", "면적번", "SA", "면적산출"),
|
||||
("material", "재료번", "SM", "재료산출"),
|
||||
)
|
||||
_KEY = {kind: key for kind, key, _, _ in PARTS}
|
||||
SHEETS = ("area", "material") # 통합문서 종류 — [저장] 때 서버가 계산값을 새로 적음
|
||||
_CODE = re.compile(r"^S[DAM](\d{8}|-\d{6})$")
|
||||
_CODE = re.compile(r"^S[DAMU](\d{8}|-\d{6})$")
|
||||
_OLD_CODE = re.compile(r"^S[DCA](\d{6})$") # PLAN 12장 6자리 — 끝에 00 을 붙여 읽음
|
||||
_BAD_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]|\.\.')
|
||||
# 도각 없는 빈 CAD 문서의 기본 도면층 — 없으면 B07 CAD 가 못 엶("Cannot read properties
|
||||
@@ -155,7 +156,8 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) ->
|
||||
구조물집계표를 저장하면 — 새 구조물 열의 빈 구조물 도면 · 면적산출 · 재료산출을 번호와 같이
|
||||
만들고 지운 열(또는 구조물이 아니게 된 열)의 세 파일을 지움(잠금 밖에서 · 안에서 부르면 죽음).
|
||||
통합문서(area · material)는 서버가 같은 TS 엔진을 Node 로 돌려 `계산값` 을 새로 적음
|
||||
(브라우저 값 버림 · 5장 ②) · 알약 `연결` 은 문서와 같이 그대로 적힘.
|
||||
(브라우저 값 버림 · 5장 ②) · 알약 `연결` 은 문서와 같이 그대로 적힘 · 일위대가(labor)는
|
||||
`M02_Labor_Doc.server` 가 정리 · `계산값` 을 새로 적음(면적산출 저장 때 그 열 일위대가도).
|
||||
번호 칸(도번 · 면적번 · 재료번)은 서버 것 — 있던 파일 번호를 그대로 둠(화면 값을 안 받음).
|
||||
표 열의 `본뜬열`(분류 더하기로 복사한 새 열)은 적지 않음 — 집계표면 아직 파일이 없는 새 열이
|
||||
원본 열의 일위대가 · 자재조합 키와 세 파일 내용을 받음(새 번호 · 그 뒤로는 열마다 따로).
|
||||
@@ -166,8 +168,7 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) ->
|
||||
origins = _take_origins(doc) if kind == "table" else {}
|
||||
if kind == "table" and name == STRUCTURE_TABLE:
|
||||
doc = tree.normalize_table(doc) # 경로 · 머리 줄 수 · 빈 폴더(표 저장 · 나무 고침 한 줄기)
|
||||
if kind in SHEETS:
|
||||
doc = _server_values(doc)
|
||||
doc = _server(kind, doc)
|
||||
with _LOCK:
|
||||
have = version_of(path.read_bytes()) if path.is_file() else ""
|
||||
if have != (version or ""):
|
||||
@@ -179,6 +180,8 @@ def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) ->
|
||||
doc = {**doc, _KEY[kind]: number} if number else doc
|
||||
atomic_write_json(path, doc)
|
||||
info = _info(kind, path)
|
||||
if kind == "area":
|
||||
_refresh_labor(name) # 면적 칸 값이 바뀌면 일위대가 계산값(목록 합계)도
|
||||
if kind == "table" and name == STRUCTURE_TABLE:
|
||||
for column, source in origins.items(): # 빈 파일 채우기 앞 — 원본 내용으로 만듦
|
||||
for part in _KEY:
|
||||
@@ -282,6 +285,17 @@ def _retired() -> list[str]:
|
||||
def _blank(kind: str, column: str) -> dict[str, Any]:
|
||||
"""빈 문서(번호는 부르는 쪽이 붙임) — 구조물 도면 = 도각 없음 · 기본 도면층 하나 /
|
||||
면적산출 · 재료산출 = 진짜 빈 시트 하나(식 없어 `계산값` 없음 · 첫 [저장] 때 서버가 적음)."""
|
||||
if kind == "labor":
|
||||
return {
|
||||
"종류": "일위대가",
|
||||
"판": 1,
|
||||
"열": column,
|
||||
"단위": None,
|
||||
"본뜬": None,
|
||||
"담은로직": [],
|
||||
"자재": [],
|
||||
"계산값": {},
|
||||
}
|
||||
if kind == "structure":
|
||||
return {
|
||||
"양식": "구조물도면",
|
||||
@@ -311,9 +325,7 @@ def _ensure(kind: str, column: str, source: str | None = None) -> bool:
|
||||
base = _blank(kind, column)
|
||||
if source and _path(kind, source).is_file():
|
||||
got = json.loads(_path(kind, source).read_bytes())
|
||||
base = {**{k: v for k, v in got.items() if k != "계산값"}, "열": column}
|
||||
if kind in SHEETS:
|
||||
base = _server_values(base)
|
||||
base = _server(kind, {**{k: v for k, v in got.items() if k != "계산값"}, "열": column})
|
||||
with _LOCK:
|
||||
if path.is_file():
|
||||
return False
|
||||
@@ -343,13 +355,13 @@ def _take_origins(doc: Any) -> dict[str, str]:
|
||||
|
||||
def create_structure(column: str) -> dict[str, Any]:
|
||||
"""구조물 열 하나의 구조물 도면 새로 — 도번 SD + 새 일련(`-` + 6자리) · 이미 있으면 409.
|
||||
면적산출 · 재료산출 파일이 없으면 빈 통합문서도 같이 만듦(같은 뒷자리)."""
|
||||
일위대가 · 면적산출 · 재료산출 파일이 없으면 빈 문서도 같이 만듦(같은 뒷자리)."""
|
||||
if column not in _column_ids():
|
||||
raise StoreError(404, f"{STRUCTURE_TABLE}에 없는 구조물 열 「{column}」")
|
||||
if not _ensure("structure", column):
|
||||
raise StoreError(409, f"이미 있는 구조물 도면 「{column}」")
|
||||
for kind in SHEETS:
|
||||
_ensure(kind, column)
|
||||
for kind in _KEY:
|
||||
_ensure(kind, column) # 도면은 위에서 만듦 · 있는 것은 그대로
|
||||
return read("structure", column)
|
||||
|
||||
|
||||
@@ -360,8 +372,7 @@ def copy_from(kind: str, column: str, source: str) -> dict[str, Any]:
|
||||
raise StoreError(404, f"없는 종류 「{kind}」")
|
||||
target = read(kind, column)["문서"]
|
||||
doc = {**read(kind, source)["문서"], **{k: target.get(k) for k in ("열", _KEY[kind])}}
|
||||
if kind in SHEETS:
|
||||
doc = _server_values({k: v for k, v in doc.items() if k != "계산값"})
|
||||
doc = _server(kind, {k: v for k, v in doc.items() if k != "계산값"})
|
||||
with _LOCK:
|
||||
atomic_write_json(_path(kind, column), doc)
|
||||
return _info(kind, _path(kind, column))
|
||||
@@ -457,6 +468,30 @@ def renumber() -> dict[str, list[str]]:
|
||||
return {"옮김": moved, "지움": gone, "바꿈": changed, "새로": made}
|
||||
|
||||
|
||||
def _server(kind: str, doc: Any) -> Any:
|
||||
"""서버가 적는 값 — 통합문서 계산값(5장 ②) · 일위대가 정리 · 계산값(`M02_Labor_Doc`)."""
|
||||
if kind in SHEETS:
|
||||
return _server_values(doc)
|
||||
if kind == "labor":
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
return labor.server(doc)
|
||||
return doc
|
||||
|
||||
|
||||
def _refresh_labor(column: str) -> None:
|
||||
"""그 열 일위대가 `계산값` 을 지금 면적 값으로 다시 적음 — 문서가 틀어졌으면 그대로 둠."""
|
||||
path = _path("labor", column)
|
||||
if not path.is_file():
|
||||
return
|
||||
try:
|
||||
doc = _server("labor", json.loads(path.read_bytes()))
|
||||
except StoreError:
|
||||
return
|
||||
with _LOCK:
|
||||
atomic_write_json(path, doc)
|
||||
|
||||
|
||||
def _server_values(doc: Any) -> dict[str, Any]:
|
||||
try:
|
||||
return spreadsheet.with_server_values(doc)
|
||||
|
||||
@@ -27,7 +27,8 @@ from config import config_system
|
||||
|
||||
LAYERS = ("system", "company", "personal", "project")
|
||||
# area = 면적산출 · material = 재료산출 — 통합문서(구조물집계표 열마다 · `A00_Common/spreadsheet/`)
|
||||
KINDS = ("table", "drawing", "structure", "area", "material")
|
||||
# labor = 일위대가(구조물 열마다 · `M02_Labor_Doc`)
|
||||
KINDS = ("table", "drawing", "structure", "labor", "area", "material")
|
||||
TEMPLATES_DIRNAME = "templates"
|
||||
INITIAL_DIRNAME = "_initial"
|
||||
MANIFEST_NAME = "manifest.json"
|
||||
@@ -168,6 +169,7 @@ _SKELETON = {
|
||||
"table": ("열",),
|
||||
"drawing": ("entities",),
|
||||
"structure": ("도면.entities",),
|
||||
"labor": ("담은로직",),
|
||||
"area": ("시트",),
|
||||
"material": ("시트",),
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ from B06_Section.B06_Section_Router_HaulPlan import (
|
||||
from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router
|
||||
from M01_MasterData.M01_MasterData_Router import router as m01_master_data_router
|
||||
from M02_MasterTemplete.M02_Labor_Router import router as m02_labor_router
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router import router as m02_master_template_router
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router_Combos import router as m02_combos_router
|
||||
from M02_MasterTemplete.M02_MasterTemplete_Router_Drawing import router as m02_drawing_router
|
||||
@@ -649,6 +650,7 @@ app.include_router(m02_layers_router, dependencies=protected)
|
||||
app.include_router(m02_items_router, dependencies=protected)
|
||||
app.include_router(m02_combos_router, dependencies=protected)
|
||||
app.include_router(m02_drawing_router, dependencies=protected)
|
||||
app.include_router(m02_labor_router, dependencies=protected)
|
||||
# 프로젝트 집계 항목 복사본(PLAN 25-2b) — 로그인만 · 프로젝트 권한은 길 안에서.
|
||||
app.include_router(m02_project_items_router, dependencies=protected)
|
||||
# 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근).
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/* M02 일위대가 수량 · 반영 · 합(`M02_MasterTemplete_Labor_Calc.ts` `settle`) 거울 헬퍼 —
|
||||
* 그 자리에서 트랜스파일해 Node 로 돌림 · stdin = {답, 문서, 값} · stdout = settle 결과 JSON. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const file = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"M02_MasterTemplete",
|
||||
"M02_MasterTemplete_Labor_Calc.ts",
|
||||
);
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
new Function("exports", "module", "require", js)(box.exports, box, require);
|
||||
|
||||
const input = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
process.stdout.write(JSON.stringify(box.exports.settle(input.답, input.문서, input.값)));
|
||||
@@ -26,7 +26,7 @@ from resources.tester.test_m02_structure import write_sample_table
|
||||
from resources.tester.test_m02_tree import Tree, _app
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
NEW = re.compile(r"^S[DAM]-\d{6}$")
|
||||
NEW = re.compile(r"^S[DAMU]-\d{6}$")
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -164,7 +164,7 @@ def test_새_번호는_옛_번호와_모양이_달라_안_겹침(world):
|
||||
doc = json.loads(path.read_bytes())
|
||||
path.write_text(json.dumps({**doc, key: f"{head}{code}"}), encoding="utf-8")
|
||||
old = {cid: _numbers(system, cid) for cid in store._column_ids()}
|
||||
assert old["rv"] == {"SD01010100", "SA01010100", "SM01010100"}
|
||||
assert old["rv"] == {"SD01010100", "SU01010100", "SA01010100", "SM01010100"}
|
||||
first = max(int(n[3:]) for nums in old.values() for n in nums if NEW.match(n)) + 1
|
||||
with store.at(system):
|
||||
got = store.read("table", store.STRUCTURE_TABLE)
|
||||
@@ -191,7 +191,7 @@ def test_새_번호는_옛_번호와_모양이_달라_안_겹침(world):
|
||||
got["문서"]["열"].append({"id": "y", "머리": ["y"], "꼴": "수"})
|
||||
store.write("table", store.STRUCTURE_TABLE, got["판"], got["문서"])
|
||||
last = f"-{first + 120:06d}" # 지운 x119 번호(first + 119) 다음
|
||||
assert _numbers(system, "y") == {f"SD{last}", f"SA{last}", f"SM{last}"}
|
||||
assert _numbers(system, "y") == {f"SD{last}", f"SU{last}", f"SA{last}", f"SM{last}"}
|
||||
|
||||
|
||||
def test_층마다_번호는_따로_파일은_열_id_로(world):
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
"""일위대가 수량 · 반영 · 합 거울 시험 — `M02_Labor_Doc.settle`(파이썬) ↔
|
||||
`M02_MasterTemplete_Labor_Calc.ts` `settle`(TS · Node) 가 같은 답(CLAUDE.md 5장 ①).
|
||||
|
||||
기준은 서버가 한 번 셈(`calc`) · 그 답에 다른 알약 값을 넣어 둘이 같은지 봄.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
TEN, THOUSAND, PLAIN, MAT = "GC000421", "GF000017", "GC000422", "MT000008"
|
||||
|
||||
|
||||
def _ts(answer: dict, doc: dict, values: dict) -> dict:
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_m02_labor_calc.cjs")],
|
||||
input=json.dumps({"답": answer, "문서": doc, "값": values}, ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
)
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def _same(a, b) -> None:
|
||||
if isinstance(a, dict):
|
||||
assert set(a) == set(b)
|
||||
for k in a:
|
||||
_same(a[k], b[k])
|
||||
elif isinstance(a, list):
|
||||
assert len(a) == len(b)
|
||||
for x, y in zip(a, b):
|
||||
_same(x, y)
|
||||
elif isinstance(a, (int, float)) and not isinstance(a, bool):
|
||||
assert b == pytest.approx(a, rel=1e-9)
|
||||
else:
|
||||
assert a == b
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"values",
|
||||
[
|
||||
{}, # 수량 없음 — 반영 null · 손만
|
||||
{"사석 수상고르기|㎡": 2.5, f"자재|{MAT}": "3"},
|
||||
{"사석 수상고르기|㎡": "1,200", "나무식재|본": 7, f"자재|{MAT}": "글"}, # 쉼표 · 수 아님
|
||||
],
|
||||
)
|
||||
def test_파이썬과_TS_가_같은_답(values):
|
||||
doc = labor.clean(
|
||||
{
|
||||
"열": "rv",
|
||||
"담은로직": [
|
||||
{"로직": TEN, "수량": None},
|
||||
{"로직": THOUSAND, "수량": {"손": 4}},
|
||||
{"로직": PLAIN, "수량": None},
|
||||
],
|
||||
"자재": [{"코드": MAT, "수량": None}],
|
||||
}
|
||||
)
|
||||
answer = labor.calc(doc, {})
|
||||
keys = {line["알약"] for line in answer["줄"]}
|
||||
assert {"사석 수상고르기|㎡", "나무식재|본"} <= keys # 알약 키가 시험 값과 맞음
|
||||
py = labor.settle(answer, doc, values)
|
||||
_same(py, _ts(answer, doc, values))
|
||||
assert py == labor.calc(doc, values) # settle 만 다시 = 처음부터 다시
|
||||
@@ -0,0 +1,213 @@
|
||||
"""일위대가 문서(`labor/<열id>.json` · PLAN 39-1a) — 정리 · 검사 · 계산 · 알약 · 저장 때 계산값.
|
||||
|
||||
로직 · 자재는 M01 마스터(읽기만) · 층은 견본 표(`write_sample_table`)를 임시 폴더에.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
|
||||
from M02_MasterTemplete import M02_Labor_Doc as labor
|
||||
from M02_MasterTemplete import M02_MasterTemplete_Store as store
|
||||
from resources.tester.test_m02_structure import write_sample_table
|
||||
|
||||
TEN = "GC000421" # 원/10㎡ — 배 10
|
||||
THOUSAND = "GF000017" # 원/1,000본 — 배 1000
|
||||
PLAIN = "GC000422" # ㎡/시간 — 돈 아닌 로직
|
||||
STONE = "GF000219" # 원/㎡ · 입력 뒷길이 · 돌 · 쌓기 · 높이 · 초과증가율
|
||||
MAT = "MT000008" # 자재품목 · 물가정보 83000
|
||||
|
||||
|
||||
def _doc(**extra):
|
||||
return {"열": "rv", "담은로직": [], "자재": [], **extra}
|
||||
|
||||
|
||||
def test_정리_모르는_입력_빈_값_버림_자재_칸은_M01_것():
|
||||
got = labor.clean(
|
||||
_doc(
|
||||
담은로직=[
|
||||
{"로직": STONE, "입력값": {"돌": "깬돌", "모름": 1, "높이": ""}, "수량": None}
|
||||
],
|
||||
자재=[{"코드": MAT, "이름": "아무 이름", "수량": {"손": "2"}}],
|
||||
단위="개소",
|
||||
)
|
||||
)
|
||||
assert got["담은로직"] == [
|
||||
{"로직": STONE, "메모": None, "입력값": {"돌": "깬돌"}, "수량": None}
|
||||
]
|
||||
assert got["자재"][0]["이름"] == "치 즐(0.2㎥)" and got["자재"][0]["수량"] == {"손": 2}
|
||||
assert (got["종류"], got["단위"], got["본뜬"]) == ("일위대가", "개소", None)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad",
|
||||
[
|
||||
_doc(담은로직=[{"로직": STONE}, {"로직": STONE}]), # 같은 로직 두 번
|
||||
_doc(담은로직=[{"로직": "GF999999"}]), # 없는 로직
|
||||
_doc(담은로직=[{"로직": STONE, "수량": {"손": "열"}}]), # 수 아님
|
||||
_doc(담은로직=[{"로직": STONE, "수량": 3}]), # 꼴 틀림
|
||||
_doc(자재=[{"코드": "LB000001"}]), # 자재품목 아님
|
||||
_doc(자재=[{"코드": MAT}, {"코드": MAT}]), # 같은 자재 두 번
|
||||
"문서 아님",
|
||||
],
|
||||
)
|
||||
def test_틀린_문서는_400(bad):
|
||||
with pytest.raises(store.StoreError) as e:
|
||||
labor.clean(bad)
|
||||
assert e.value.status == 400
|
||||
|
||||
|
||||
def test_빈_일위대가도_됨():
|
||||
got = labor.server(_doc())
|
||||
assert got["계산값"]["기준합"]["계"] == 0 and got["계산값"]["반영합"]["계"] == 0
|
||||
|
||||
|
||||
def test_PER_천_단위_쉼표는_배():
|
||||
pills = combo_store.combo_pills({"담은로직": [{"로직": THOUSAND}, {"로직": TEN}]})["물량"]
|
||||
assert {p["단위"]: p.get("배") for p in pills} == {"본": 1000, "㎡": 10}
|
||||
|
||||
|
||||
def test_반영은_기준_곱_수량_나누기_배_돈_아닌_로직은_반영_없음():
|
||||
doc = labor.clean(
|
||||
_doc(담은로직=[{"로직": TEN, "수량": {"손": 4}}, {"로직": PLAIN, "수량": None}])
|
||||
)
|
||||
got = labor.calc(doc, {})
|
||||
ten, plain = got["줄"]
|
||||
assert (ten["배"], ten["수량"], ten["수량출처"]) == (10, 4, "손")
|
||||
for k in ("노무비", "재료비", "경비", "계"):
|
||||
assert ten["반영"][k] == pytest.approx(ten["기준"][k] * 4 / 10)
|
||||
assert plain["기준"] is None and plain["반영"] is None and "결과" in plain
|
||||
assert got["반영합"]["계"] == pytest.approx(ten["반영"]["계"])
|
||||
|
||||
|
||||
def test_수량_손이_칸보다_앞섬_칸_값은_알약_키로():
|
||||
doc = labor.clean(
|
||||
_doc(
|
||||
담은로직=[{"로직": TEN, "수량": None}, {"로직": THOUSAND, "수량": {"손": 1}}],
|
||||
자재=[{"코드": MAT, "수량": None}],
|
||||
)
|
||||
)
|
||||
keys = {p["키"] for p in labor.pills(doc)}
|
||||
ten_key = next(k for k in keys if k.endswith("|㎡"))
|
||||
thousand_key = next(k for k in keys if k.endswith("|본"))
|
||||
assert f"자재|{MAT}" in keys
|
||||
got = labor.calc(doc, {ten_key: "2.5", thousand_key: 99, f"자재|{MAT}": "3"})
|
||||
ten, thousand = got["줄"]
|
||||
assert (ten["수량"], ten["수량출처"]) == (2.5, "칸")
|
||||
assert (thousand["수량"], thousand["수량출처"]) == (1, "손")
|
||||
mat = got["자재"][0]
|
||||
assert (mat["기준단가"], mat["수량"], mat["반영"]) == (83000, 3, 249000)
|
||||
nothing = labor.calc(doc, {})["줄"][0]
|
||||
assert (nothing["수량출처"], nothing["반영"]) == ("없음", None)
|
||||
|
||||
|
||||
def test_면적_값_식_칸은_계산값_그_밖은_칸_값():
|
||||
area = {
|
||||
"시트": [
|
||||
{
|
||||
"id": "s1",
|
||||
"칸": {"B6": {"값": "1,200"}, "C1": {"값": "글"}},
|
||||
"연결": [{"키": "a", "칸": "B6"}, {"키": "b", "칸": "D2"}, {"키": "c", "칸": "C1"}],
|
||||
}
|
||||
],
|
||||
"계산값": {"s1": {"D2": {"수": "7.5"}}},
|
||||
}
|
||||
got = labor.area_values(area)
|
||||
assert {k: float(v) for k, v in got.items()} == {"a": 1200, "b": 7.5}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def system(tmp_path, monkeypatch):
|
||||
write_sample_table(tmp_path)
|
||||
monkeypatch.setattr(store, "FOLDER", tmp_path)
|
||||
store.create_structure("rv")
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_구조물_만들면_빈_일위대가_같은_뒷자리(system):
|
||||
doc = store.read("labor", "rv")["문서"]
|
||||
assert doc["일위대가번"] == "SU" + store.read("structure", "rv")["문서"]["도번"][2:]
|
||||
assert doc["담은로직"] == [] and doc["자재"] == []
|
||||
|
||||
|
||||
def test_저장_때_서버가_계산값_번호는_그대로_면적_저장이_계산값_새로(system):
|
||||
have = store.read("labor", "rv")
|
||||
sent = {
|
||||
**have["문서"],
|
||||
"일위대가번": "SU-999999",
|
||||
"담은로직": [{"로직": TEN, "수량": None}],
|
||||
"계산값": {"브라우저": "값"},
|
||||
}
|
||||
store.write("labor", "rv", have["판"], sent)
|
||||
doc = store.read("labor", "rv")["문서"]
|
||||
assert doc["일위대가번"] == have["문서"]["일위대가번"]
|
||||
assert doc["계산값"]["줄"][0]["수량출처"] == "없음"
|
||||
pill = labor.pills(doc)[0]["키"]
|
||||
area = store.read("area", "rv")
|
||||
sheet = area["문서"]["시트"][0]
|
||||
sheet["칸"] = {"B6": {"값": "5"}}
|
||||
sheet["연결"] = [{"키": pill, "이름": "x", "칸": "B6", "단위": "㎡"}]
|
||||
store.write("area", "rv", area["판"], area["문서"])
|
||||
line = store.read("labor", "rv")["문서"]["계산값"]["줄"][0]
|
||||
assert (line["수량"], line["수량출처"]) == (5, "칸")
|
||||
assert line["반영"]["계"] == pytest.approx(line["기준"]["계"] * 5 / 10)
|
||||
|
||||
|
||||
def test_틀린_일위대가_저장은_400_파일_그대로(system):
|
||||
have = store.read("labor", "rv")
|
||||
with pytest.raises(store.StoreError) as e:
|
||||
store.write("labor", "rv", have["판"], {**have["문서"], "담은로직": [{"로직": "GF999999"}]})
|
||||
assert e.value.status == 400
|
||||
assert json.loads((system / "labor" / "rv.json").read_bytes())["담은로직"] == []
|
||||
|
||||
|
||||
# ── 길(`M02_Labor_Router`) ─────────────────────────────────────────────
|
||||
@pytest.fixture()
|
||||
def client(tmp_path, monkeypatch):
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from common_util.common_util_auth import verify_session
|
||||
from config import config_system
|
||||
from M02_MasterTemplete import M02_Labor_Router as labor_router
|
||||
from M02_MasterTemplete import M02_Template_Layers as layers
|
||||
|
||||
system = tmp_path / "system"
|
||||
system.mkdir()
|
||||
write_sample_table(system)
|
||||
monkeypatch.setattr(store, "FOLDER", system)
|
||||
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
|
||||
(tmp_path / "storage").mkdir()
|
||||
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage"))
|
||||
with store.at(system):
|
||||
store.create_structure("rv")
|
||||
app = FastAPI()
|
||||
app.include_router(labor_router.router)
|
||||
app.dependency_overrides[verify_session] = lambda: {
|
||||
"user_id": 42,
|
||||
"company_id": 7,
|
||||
"role": "USER",
|
||||
"is_master": False,
|
||||
}
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_길_calc_는_저장_안_한_문서와_알약_값으로_셈_알약도_같이(client):
|
||||
url = "/api/m02/layers/system/labor/rv/calc"
|
||||
sent = {"담은로직": [{"로직": TEN, "수량": None}], "자재": [{"코드": MAT, "수량": {"손": 1}}]}
|
||||
got = client.post(url, json={"doc": sent, "수량": {"사석 수상고르기|㎡": 20}}).json()
|
||||
assert (got["줄"][0]["수량출처"], got["자재"][0]["반영"]) == ("칸", 83000)
|
||||
assert {p["키"] for p in got["알약"]} == {"사석 수상고르기|㎡", f"자재|{MAT}"}
|
||||
saved = client.post(url, json={}).json() # 저장본 = 빈 일위대가
|
||||
assert saved["줄"] == [] and saved["알약"] == []
|
||||
assert client.get("/api/m02/layers/system/labor/rv/pills").json() == {"열": "rv", "알약": []}
|
||||
|
||||
|
||||
def test_길_틀린_문서_400_없는_열_404_남의_개인_층_403(client):
|
||||
url = "/api/m02/layers/{}/labor/{}/calc"
|
||||
bad = {"doc": {"담은로직": [{"로직": "GF999999"}]}}
|
||||
assert client.post(url.format("system", "rv"), json=bad).status_code == 400
|
||||
assert client.post(url.format("system", "없는열"), json={}).status_code == 404
|
||||
other = client.post(url.format("personal", "rv") + "?owner=7/99", json={})
|
||||
assert other.status_code == 403
|
||||
@@ -64,8 +64,13 @@ def client(tmp_path, monkeypatch):
|
||||
|
||||
|
||||
def _num(code: str) -> dict:
|
||||
"""세 번호 — 같은 8자리."""
|
||||
return {"도번": f"SD{code}", "면적번": f"SA{code}", "재료번": f"SM{code}"}
|
||||
"""열 번호 — 같은 뒷자리(일위대가번 SU · 39장)."""
|
||||
return {
|
||||
"도번": f"SD{code}",
|
||||
"일위대가번": f"SU{code}",
|
||||
"면적번": f"SA{code}",
|
||||
"재료번": f"SM{code}",
|
||||
}
|
||||
|
||||
|
||||
def test_새로_도번_자동_409_없는_열(client):
|
||||
|
||||
@@ -86,7 +86,9 @@ def test_집계표_저장_때_새_열_지운_열_고친_열을_맞춤_키_유지
|
||||
assert (rv["바인딩"], rv["펼침"]) == ({"종류": "revetment"}, True)
|
||||
# 번호는 세 파일과 같음 — 새 열도 [저장] 한 번에 SD · SA · SM 이 섬
|
||||
numbers = client.get("/api/m02/structures/numbers").json()
|
||||
assert {k: rows["new1"][k] for k in ("도번", "면적번", "재료번")} == numbers["new1"]
|
||||
assert {k: rows["new1"][k] for k in ("도번", "일위대가번", "면적번", "재료번")} == numbers[
|
||||
"new1"
|
||||
]
|
||||
assert rows["new1"]["도번"].startswith("SD")
|
||||
|
||||
# 또 저장해도(바뀐 것 없음) 키 · 다음 그대로
|
||||
|
||||
Reference in New Issue
Block a user