Merge remote-tracking branch 'origin/dev' into sub_laptop_2

This commit is contained in:
2026-10-04 17:45:52 +09:00
14 changed files with 839 additions and 23 deletions
+4 -2
View File
@@ -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()), "조건": []}
+287
View File
@@ -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)}
+64
View File
@@ -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)
@@ -189,8 +189,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 };
}
+47 -12
View File
@@ -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)
+3 -1
View File
@@ -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": ("시트",),
}
+2
View File
@@ -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.값)));
+3 -3
View File
@@ -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 만 다시 = 처음부터 다시
+213
View File
@@ -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
+7 -2
View File
@@ -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):
+3 -1
View File
@@ -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")
# 또 저장해도(바뀐 것 없음) 키 · 다음 그대로