Files
Aislo/M02_MasterTemplete/M02_MyMachine.py
T
eomsangdonandClaude Opus 5.5 ebd1bc0121 feat(821-1): 내 기계(개인 층 기계 줄) 저장 · 길 · 찾기 · 시간당 · 사본 · 단가 갱신이 개인 줄 읽기
- M02_MyMachine — 층 뿌리 내기계.json · 줄 = M01 기계 줄 꼴 · 키 EQP{사용자}-#### · 원문번호 「개인 {키}」 · 바로 · 품셈(계수 짝) · 직접 세 꼴 · 같은 이름 알림 · 쓰는 곳
- 셈 색인 = M01 + 지금 층 내 기계(skc.Copy) — 단가산출 셈 · 저장 · 사본 찍기 · 단가 갱신 · 새값 표지 · 기계 찾기 · 시간당 · 사본 모으기가 읽음 · 층 모르는 길 = M01 만
- 시간당 식 = 채운 칸으로 고른 GC 식(268 · 272 · 276 · 273) · M01 기계 619 대 셈 방식 대조 차이 0
- 길 /api/m02/layers/personal/my-machines(목록 · 하나 · 만들기 · 고치기 · 지우기 · 본뜬)
- 지킴 시험 test_821_1_my_machine

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 13:36:24 +09:00

364 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""M02 내 기계(PLAN 821-1 · 요구 795 · 요구서_796 3 · 7 장) — 개인 층 기계 줄 목록 · 서버 단독.
저장 = 층 뿌리 `내기계.json` {줄: [줄], 다음: 수} — 줄 = M01 기계 줄 꼴
{키 `EQP{사용자}-####`, 원문번호 「개인 {키}」, 원문 · 구분 · 출처 = 개인, 상세구분 · 이름, 규격,
단위, 값, 비고, 방식 바로|품셈|직접, 시간당?(직접 {노무비, 재료비, 경비}), 넣은날, 고친날, 본뜬?}
값 칸 이름 = M01 기계 줄과 같음 · 바로 = 손료계수 한 칸 · 품셈 = 비율 다섯 → 계수 넷(저장 때 서버가
계수 짝 `master_machine_coef` 로 다시 셈) · 직접 = 시간당 값만.
셈 색인 `whole()` = 지금 층(`store.root()`) 줄을 M01 위에 얹은 `skc.Copy`(키 앞 두 글자 EQ ·
원문번호로도 찾음) — 바로 = 상각비계수 ← 손료계수 · 정비 · 관리 0(식은 M01 기계와 같은 GC 식) ·
내 기계 없는 층 = M01 그대로. 단가산출 사본 · [단가 갱신] · 「단가새값」(`M02_Sangeun_Copy`) ·
기계 찾기(`M02_Machine_Find`) · 시간당(`M02_Machine_Hourly`) · 사본 모으기(`M02_Item_Prices.collect`)가
이 색인을 읽음 — 층을 모르는 길(`/api/m02/logic-lib/machine-hourly` 등)은 M01 만.
식 고름(채운 칸) = 연료 · 조종원 둘 다 GC000268 · 연료만 GC000272 · 조종원만 GC000276 · 없음 GC000273.
전력 칸은 담기만(M01 기계경비 식에 전력 줄 없음).
권한(본인 개인 층 · 맡은 회사 관리자)은 길(`M02_MyMachine_Router`) 몫.
"""
from __future__ import annotations
import json
import re
from datetime import datetime
from decimal import Decimal
from pathlib import Path
from typing import Any
from common_util.common_util_json import atomic_write_json
from M01_MasterData import M01_MasterData_Store as m01
from M02_MasterTemplete import M02_MasterTemplete_Store as store
FILE = "내기계.json"
PREFIX, NO = "EQP", "개인 "
FIND = re.compile(rf"{NO}({PREFIX}\d+-\d+)") # 문서 안 기계 입력 「개인 EQP…」
EASY, BOOK, DIRECT = "바로", "품셈", "직접"
COSTS = ("노무비", "재료비", "경비")
RATIOS = ("내용시간", "연간표준가동시간", "상각비율", "정비비율", "연간관리비율")
COEFS = ("상각비계수", "정비비계수", "관리비계수")
NUMS = ("가격", *RATIOS, *COEFS, "손료계수", "주연료", "잡재료율", "조종원", "전력")
UNITS = {
"가격": "천원", "내용시간": "시간", "연간표준가동시간": "시간", "상각비율": "", "정비비율": "",
"연간관리비율": "", **{k: "10^-7/시간" for k in (*COEFS, "손료계수")}, "주연료": "ℓ/hr",
"주연료종류": "", "잡재료율": "주연료의 %", "조종원": "인/일", "조종원직종": "", "전력": "kWh/hr",
} # fmt: skip
LOGIC = {(True, True): "GC000268", (True, False): "GC000272",
(False, True): "GC000276", (False, False): "GC000273"} # fmt: skip
def mine(key: Any) -> bool:
return str(key or "").startswith(PREFIX)
def _now() -> str:
return datetime.now().isoformat(timespec="seconds")
def _bad(text: str, status: int = 400) -> store.StoreError:
return store.StoreError(status, text)
# ── 저장 ──────────────────────────────────────────────────────────────
def _path(folder: Path | None = None) -> Path:
return Path(folder or store.root()) / FILE
def _read(folder: Path | None = None) -> dict[str, Any]:
try:
got = json.loads(_path(folder).read_bytes())
except (OSError, ValueError):
got = {}
rows = [r for r in got.get("줄") or [] if isinstance(r, dict) and mine(r.get("키"))]
return {"줄": rows, "다음": int(got.get("다음") or 1)}
def rows(folder: Path | None = None) -> list[dict]:
"""지금 층 내 기계 줄(저장 꼴)."""
return _read(folder)["줄"]
# ── 셈 색인 ──────────────────────────────────────────────────────────
def engine(row: dict) -> dict:
"""셈 꼴 — 바로 = 상각비계수 ← 손료계수 · 정비 · 관리 0 · 빈 연료 · 조종원 칸 = 0 · 수 = Decimal."""
vals = dict(row.get("값") or {})
if row.get("방식") == EASY:
vals |= {"상각비계수": vals.get("손료계수") or 0, "정비비계수": 0, "관리비계수": 0}
if row.get("방식") != DIRECT:
vals = {"주연료": 0, "잡재료율": 0, "조종원": 0, "주연료종류": "", "조종원직종": ""} | vals
raw = json.dumps({**row, "값": vals}, ensure_ascii=False)
return json.loads(raw, parse_float=Decimal, parse_int=Decimal)
_MEMO: dict[str, tuple] = {}
def whole(base: Any = None) -> Any:
"""M01 색인 + 지금 층 내 기계 줄 — 파일 · M01 이 그대로면 같은 객체(셈 메모가 붙음)."""
from M02_MasterTemplete import M02_Sangeun_Copy as skc
base = m01.loaded()[1] if base is None else base
path = _path()
try:
st = path.stat()
except OSError:
return base
sig = (st.st_mtime_ns, st.st_size)
got = _MEMO.get(str(path))
if got and got[0] == sig and got[1] is base:
return got[2]
own = [engine(r) for r in rows(path.parent)]
out = skc.Copy(base, own, ("내기계", sig)) if own else base
if len(_MEMO) > 16: # ponytail: 통째 비움 · 층이 많아지면 LRU 로
_MEMO.clear()
_MEMO[str(path)] = (sig, base, out)
return out
def added(index: Any) -> list[dict]:
"""`whole()` 이 얹은 내 기계 셈 줄(사본 모으기 `collect` 가 마스터 밖 줄로 담음)."""
return [r for k, r in (getattr(index, "index", {}).get("EQ") or {}).items() if mine(k)]
def keys_in(doc: Any) -> list[str]:
"""문서 안 기계 입력 「개인 EQP…」 → 내 기계 키(줄 쌓기 `로직(GC…, 기계='개인 …')` · 사본 찍기)."""
return sorted(set(FIND.findall(json.dumps(doc, ensure_ascii=False, default=str))))
def logic_key(row: dict) -> str:
vals = row.get("값") or {}
return LOGIC[(float(vals.get("주연료") or 0) > 0, float(vals.get("조종원") or 0) > 0)]
def logic_for(code: str) -> dict | None:
"""내 기계 원문번호 → 시간당 경비 식(채운 칸으로 고름) · 내 기계 아님 = None."""
index = whole()
row = (index.numbers.get("EQ") or {}).get(code)
if row is None or not mine(row.get("키")) or row.get("방식") == DIRECT:
return None
return index.logic(logic_key(row))
def hourly(row: dict, index: Any) -> dict[str, Any]:
"""`M02_Machine_Hourly.hourly` 꼴 — 직접 = 그 값 · 아니면 고른 GC 식(입력 기본값 · 기계 = 원문번호)."""
head = {"이름": row.get("이름"), "규격": row.get("규격"), "원문번호": row.get("원문번호")}
if row.get("방식") == DIRECT:
sums = {k: float((row.get("시간당") or {}).get(k) or 0) for k in COSTS}
return {"ok": True, **head, "로직": None, "직접": True, **sums, "계": sum(sums.values())}
import master_cut as mcut
mf = m01.cm.mf
logic = index.logic(logic_key(row))
given = {i["이름"]: (i.get("고르기") or [""])[0] for i in logic.get("입력") or []}
given["기계"] = str(row.get("원문번호"))
try:
got = mcut.engine(
index, logic["키"], mf.run(index, logic["키"], mf.fill_empty(logic, given))
)
except (mf.FormulaError, ArithmeticError, KeyError, TypeError, ValueError) as e:
return {"ok": False, "reason": str(e) or type(e).__name__}
sums = {k: float(got[k]) for k in (*COSTS, "계")}
return {"ok": True, **head, "로직": logic["키"], **sums}
# ── 검사 ──────────────────────────────────────────────────────────────
def _num(value: Any, name: str) -> float | None:
if value in (None, ""):
return None
try:
got = float(value)
except (TypeError, ValueError):
raise _bad(f"「{name}」 은 수") from None
if got < 0 or got != got:
raise _bad(f"「{name}」 은 0 이상")
return int(got) if got == int(got) else got
def _coef(vals: dict) -> dict:
"""품셈 방식 — 비율 다섯 → 계수 넷(계수 짝 `master_machine_coef` · 821-2)."""
import master_machine_coef as mmc
got = mmc.coef_of(vals)
if got is None:
raise _bad("품셈 방식 — 내용시간 · 연간표준가동시간은 0 보다 큼")
return got
def clean(raw: Any) -> dict[str, Any]:
"""받은 줄 → 저장 꼴 몸(키 · 날 빼고) — 틀리면 400."""
if not isinstance(raw, dict):
raise _bad("내 기계 줄이 묶음 아님")
name, spec = str(raw.get("이름") or "").strip(), str(raw.get("규격") or "").strip()
if not name:
raise _bad("이름 없음")
how = raw.get("방식") or EASY
if how not in (EASY, BOOK, DIRECT):
raise _bad("방식은 바로 · 품셈 · 직접")
given = raw.get("값") if isinstance(raw.get("값"), dict) else {}
out: dict[str, Any] = {"이름": name, "규격": spec, "방식": how}
if how == DIRECT:
hour = raw.get("시간당") if isinstance(raw.get("시간당"), dict) else {}
out["시간당"] = {k: _num(hour.get(k), k) or 0 for k in COSTS}
vals: dict[str, Any] = {}
else:
vals = {k: got for k in NUMS if (got := _num(given.get(k), k)) is not None}
need = ("가격", "손료계수") if how == EASY else ("가격", *RATIOS)
if lost := [k for k in need if not vals.get(k)]:
raise _bad(f"빈 칸 — {' · '.join(lost)}")
drop = (*RATIOS, *COEFS) if how == EASY else ()
vals = {k: v for k, v in vals.items() if k not in drop}
if how == BOOK:
vals |= _coef(vals)
vals |= _kinds(given, vals)
out["값"] = vals
out["단위"] = {k: UNITS[k] for k in vals if k in UNITS}
out["출처"] = {k: "개인" for k in vals}
if note := str(raw.get("비고") or "").strip():
out["비고"] = note
return out
def _kinds(given: dict, vals: dict) -> dict:
"""주연료종류 = M01 유가 이름 · 조종원직종 = M01 인력 원문번호(쓸 때만 · 고르기 목록 밖 = 400)."""
index, out = m01.loaded()[1], {}
fuel, job = str(given.get("주연료종류") or ""), str(given.get("조종원직종") or "")
if vals.get("주연료"):
if f"{fuel}:전국평균" not in (index.numbers.get("MO") or {}):
raise _bad(f"주연료종류 「{fuel}」 — 유가 목록에서 고름")
out["주연료종류"] = fuel
if vals.get("조종원"):
if job not in (index.numbers.get("LB") or {}):
raise _bad(f"조종원직종 「{job}」 — 인력 목록에서 고름")
out["조종원직종"] = job
return out
# ── 만들기 · 고치기 · 지우기 · 읽기 ─────────────────────────────────────
def _seed(key: str) -> dict:
"""「비슷한 기계에서 시작」 — M01 · 내 기계 줄 값 복사(품셈 비율 있으면 품셈 · 없으면 바로)."""
try:
src = whole().get(key)
except m01.cm.mf.FormulaError:
raise _bad(f"없는 기계 「{key}」", 404) from None
if not str(src.get("키")).startswith("EQ"):
raise _bad(f"「{key}」 는 기계 아님")
if mine(key):
got = next(r for r in rows() if r["키"] == key)
return {k: got[k] for k in ("이름", "규격", "방식", "값", "시간당", "비고") if k in got}
vals = json.loads(json.dumps(src.get("값") or {}, default=float))
how = BOOK if all(vals.get(k) for k in RATIOS) else EASY
return {"이름": src.get("이름"), "규격": src.get("규격"), "방식": how, "값": vals}
def _user() -> str:
"""개인 층(`storage/{회사}/{사용자}/templates`) 사용자 번호 — 그 밖 층 = 404."""
from M02_MasterTemplete import M02_Template_Layers as layers
here = store.root().resolve()
user, company = here.parent.name, here.parent.parent.name
if not (user.isdigit() and company.isdigit()) or here != layers.personal_dir(company, user):
raise _bad("내 기계는 개인 층만", 404)
return user
def create(raw: Any = None, base: str | None = None) -> dict[str, Any]:
_user()
seed = _seed(base) if base else {}
raw = raw if isinstance(raw, dict) else {}
body = clean({**seed, **raw, "값": {**seed.get("값", {}), **(raw.get("값") or {})}})
data = _read()
key = f"{PREFIX}{_user()}-{data['다음']:04d}"
when = _now()
row = {"키": key, "원문번호": f"{NO}{key}", "원문": "개인", "구분": "개인", "상세구분": body["이름"],
**body, "넣은날": when, "고친날": when, **({"본뜬": base} if base else {})} # fmt: skip
atomic_write_json(_path(), {"줄": [*data["줄"], row], "다음": data["다음"] + 1})
return answer(row)
def update(key: str, raw: Any) -> dict[str, Any]:
_user()
data = _read()
old = next((r for r in data["줄"] if r["키"] == key), None)
if old is None:
raise _bad(f"없는 내 기계 「{key}」", 404)
body = clean(raw)
keep = {k: old[k] for k in ("키", "원문번호", "원문", "구분", "넣은날", "본뜬") if k in old}
row = {**keep, "상세구분": body["이름"], **body, "고친날": _now()}
atomic_write_json(_path(), {**data, "줄": [row if r["키"] == key else r for r in data["줄"]]})
return answer(row)
def delete(key: str) -> dict[str, Any]:
"""지움 — 이미 만든 단가산출은 사본의 마지막 값 그대로(「지운키」)."""
_user()
data = _read()
if not any(r["키"] == key for r in data["줄"]):
raise _bad(f"없는 내 기계 「{key}」", 404)
used = uses().get(key, [])
atomic_write_json(_path(), {**data, "줄": [r for r in data["줄"] if r["키"] != key]})
return {"지움": key, "쓰는곳": used}
def uses() -> dict[str, list[dict]]:
"""{내 기계 키: [{키, 이름}]} — 이 층 단가산출 가운데 사본 단가 줄에 그 키를 가진 것."""
from M02_MasterTemplete import M02_Sangeun_Copy as skc
from M02_MasterTemplete import M02_Sangeun_Doc as sd
out: dict[str, list[dict]] = {}
for path in sorted((store.root() / sd.KIND).glob("SK-*.json")):
raw = path.read_bytes()
if PREFIX.encode() not in raw:
continue
try:
doc = json.loads(raw)
except ValueError:
continue
for row in (doc.get(skc.KEY) or {}).get("단가") or []:
if mine(row.get("키")):
out.setdefault(str(row["키"]), []).append(
{"키": doc.get("키"), "이름": doc.get("이름")}
)
return out
def same(name: Any, spec: Any, skip: str = "") -> list[dict]:
"""같은 이름 · 규격 알림 — M01 기계 · 이 층 내 기계(표기 정규화 `M02_Machine_Find.norm`)."""
from M02_MasterTemplete.M02_Machine_Find import norm
want = (norm(name), norm(spec))
return [
{
"키": r["키"],
"이름": r.get("이름"),
"규격": r.get("규격"),
"출처": "개인" if mine(r["키"]) else "M01",
} # fmt: skip
for r in (whole().index.get("EQ") or {}).values()
if r["키"] != skip and (norm(r.get("이름")), norm(r.get("규격"))) == want
]
def answer(row: dict, used: list | None = None) -> dict[str, Any]:
out = {
"줄": row,
"시간당": hourly(engine(row), whole()),
"같은이름": same(row["이름"], row["규격"], row["키"]),
}
return out | ({"쓰는곳": used} if used is not None else {})
def one(key: str) -> dict[str, Any]:
got = next((r for r in rows() if r["키"] == key), None)
if got is None:
raise _bad(f"없는 내 기계 「{key}」", 404)
return answer(got, uses().get(key, []))
def listing() -> dict[str, Any]:
"""목록 — 줄마다 시간당 · 쓰는곳수(넣은 차례)."""
index, used = whole(), uses()
return {
"줄": [
{**r, "시간당": hourly(engine(r), index), "쓰는곳수": len(used.get(r["키"], []))}
for r in rows()
]
}