From d373b3b2b9511e2a1e204d9e8383857fd9ef4d74 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 12 Nov 2026 14:21:22 +0900 Subject: [PATCH] =?UTF-8?q?feat(821-3):=20=EB=82=B4=20=EA=B8=B0=EA=B3=84?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=20=EC=9D=BD=EA=B8=B0=20=EA=B8=B8=20?= =?UTF-8?q?=E2=80=94=20=EC=96=91=EC=8B=9D=20=EB=AA=A9=EB=A1=9D=20=C2=B7=20?= =?UTF-8?q?=EB=B9=84=EC=8A=B7=ED=95=9C=20=EA=B8=B0=EA=B3=84=20=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0(=EB=82=B4=20=EA=B8=B0=EA=B3=84=20+=20M01=20=C2=B7=20?= =?UTF-8?q?=EC=9B=90=EB=AC=B8=EB=B2=88=ED=98=B8)=20=C2=B7=20=EB=82=B4=20?= =?UTF-8?q?=ED=92=88=EB=AA=A9=20=EB=AA=A8=EC=95=84=20=EB=B3=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - M02_MyMachine_Look: 로그인 사용자용 양식 목록(M01 기계 화면 값) · 개인 층 찾기 · 바로 적은 재료비 줄 묶음 - main.py 등록 한 줄 · 시험 test_821_3_my_machine_screen 4 Co-Authored-By: Claude Opus 5.5 --- M02_MasterTemplete/M02_MyMachine_Look.py | 127 ++++++++++++++++++ main.py | 5 + .../tester/test_821_3_my_machine_screen.py | 100 ++++++++++++++ 3 files changed, 232 insertions(+) create mode 100644 M02_MasterTemplete/M02_MyMachine_Look.py create mode 100644 resources/tester/test_821_3_my_machine_screen.py diff --git a/M02_MasterTemplete/M02_MyMachine_Look.py b/M02_MasterTemplete/M02_MyMachine_Look.py new file mode 100644 index 000000000..fd6c12619 --- /dev/null +++ b/M02_MasterTemplete/M02_MyMachine_Look.py @@ -0,0 +1,127 @@ +"""M02 내 기계 화면 읽기 길(PLAN 821-3) — 양식 목록 · 비슷한 기계 찾기 · 내 품목 · 쓰기 없음. + +GET `/api/m02/my-machine-lists` = 양식 고르기(연료 · 직종 · 전력 단가 · 시간환산) — + M01 기계 화면과 같은 값(`M01_MasterData_Machine.lists` · 그 길은 시스템 관리자만). +GET `/api/m02/layers/{층}/my-machine-find?q=&owner=` = 「비슷한 기계에서 시작」 · + 「복사해 내 기계로」 — 그 층 내 기계(앞) + M01 기계(`M01_MasterData_Machine.find`) · + q 가 M01 원문번호와 같으면 그 기계 먼저. +GET `/api/m02/layers/{층}/my-items?owner=` = 내 품목 — 그 층 단가산출 수식 문서 재료비 줄 가운데 + 단가가 우리 자재에 안 이어진 것(단가 변수 연결 = 숫자 · 입력) · + (이름 · 규격 · 단위 · 값) 묶음마다 쓰는 단가산출. +권한 = 내 기계 길과 같음(`M02_MyMachine_Router._folder` — 개인 층 · 본인 · 맡은 회사 관리자 읽기). +⚠ 등록은 `main.py`(로그인만). +""" + +from __future__ import annotations + +import json +from typing import Any + +from fastapi import APIRouter, Depends + +from common_util.common_util_auth import verify_session +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_MyMachine as mym +from M02_MasterTemplete.M02_MasterTemplete_Router_Items import Session, _call +from M02_MasterTemplete.M02_MyMachine_Router import _folder + +router = APIRouter(prefix="/api/m02", tags=["M02 My Machine Look"]) +DIRECT = ("숫자", "입력") # 단가 변수 연결 — 값을 줄에 바로 적은 것 + + +def _m01m(): + from M01_MasterData import M01_MasterData_Machine as m01m # M01 store 가 scripts 를 길에 둔 뒤 + + return m01m + + +def lists() -> dict[str, Any]: + got = _m01m().lists() + return {k: got[k] for k in ("fuels", "jobs", "power", "hour")} + + +def find(q: str, limit: int = 30) -> list[dict]: + """내 기계(앞) + M01 기계 — 줄 {키, 원문번호, 이름, 규격, 구분, 값, 방식?, 시간당?, 출처}.""" + words = [w for w in q.lower().split() if w] + keep = ("키", "원문번호", "이름", "규격", "값", "방식", "시간당") + out = [ + {**{k: r[k] for k in keep if k in r}, "구분": "내 기계", "출처": "개인"} + for r in mym.rows() + if all(w in f"{r.get('이름')} {r.get('규격')} {r.get('키')}".lower() for w in words) + ] + exact = (m01.loaded()[1].numbers.get("EQ") or {}).get(q.strip()) + head = [exact] if exact is not None and not mym.mine(exact.get("키")) else [] + m01_rows = [json.loads(json.dumps(r, default=float)) for r in head] + _m01m().find(q, limit) + seen = set() + for r in m01_rows: + if r["키"] in seen: + continue + seen.add(r["키"]) + brief = {k: r[k] for k in ("키", "원문번호", "이름", "규격", "값") if k in r} + out.append({**brief, "구분": r.get("상세구분") or r.get("구분") or "", "출처": "M01"}) + return out[:limit] + + +def _items_of(doc: dict) -> list[dict]: + """수식 문서 재료비 줄 — 단가 = 바로 적은 값 변수(숫자 · 입력).""" + import master_doc as md + + body = ((doc.get("수식") or {}).get("문서")) or {} + vars_ = {md.var_id(v): v for v in body.get("변수") or [] if isinstance(v, dict)} + out = [] + for line in body.get("금액") or []: + if not isinstance(line, dict) or line.get("비목") != "재료비": + continue + var = vars_.get(str(line.get("단가") or "").strip()) + link = (var or {}).get("연결") or {} + if link.get("갈래") not in DIRECT: + continue + out.append( + { + "이름": str(line.get("이름") or var.get("이름") or ""), + "규격": str(line.get("규격") or ""), + "단위": str(line.get("단위") or ""), + "값": link.get("값"), + } + ) + return out + + +def items() -> list[dict]: + """내 품목 — (이름 · 규격 · 단위 · 값) 묶음 · 쓰는곳 [{키, 이름}] · 이름 차례.""" + from M02_MasterTemplete import M02_Sangeun_Doc as sd + + groups: dict[tuple, dict] = {} + for path in sorted((store.root() / sd.KIND).glob("SK-*.json")): + try: + doc = json.loads(path.read_bytes()) + except (OSError, ValueError): + continue + for it in _items_of(doc): + got = groups.setdefault( + (it["이름"], it["규격"], it["단위"], json.dumps(it["값"])), {**it, "쓰는곳": []} + ) + use = {"키": doc.get("키") or path.stem, "이름": doc.get("이름") or ""} + if use not in got["쓰는곳"]: + got["쓰는곳"].append(use) + return sorted(groups.values(), key=lambda g: (g["이름"], g["규격"], g["단위"])) + + +@router.get("/my-machine-lists") +def get_lists() -> dict: + return lists() + + +@router.get("/layers/{layer}/my-machine-find") +def get_find( + layer: str, q: str = "", owner: str | None = None, session: Session = Depends(verify_session) +) -> dict: + return {"rows": _call(_folder(session, layer, owner), find, q)} + + +@router.get("/layers/{layer}/my-items") +def get_items( + layer: str, owner: str | None = None, session: Session = Depends(verify_session) +) -> dict: + return {"rows": _call(_folder(session, layer, owner), items)} diff --git a/main.py b/main.py index 9f5954106..36756efcb 100644 --- a/main.py +++ b/main.py @@ -583,6 +583,11 @@ app.include_router( __import__("M02_MasterTemplete.M02_MyMachine_Router", fromlist=["router"]).router, dependencies=protected, ) +# 내 기계 화면 읽기(PLAN 821-3) — 양식 목록 · 비슷한 기계 찾기 · 내 품목 · 로그인 · 층 권한은 길 안에서 +app.include_router( + __import__("M02_MasterTemplete.M02_MyMachine_Look", fromlist=["router"]).router, + dependencies=protected, +) # 단가산출(SK) 수식 문서 길(PLAN 802-4a) — 로그인 · 층 권한은 길 안에서 app.include_router( __import__("M02_MasterTemplete.M02_Doc_Router", fromlist=["router"]).router, diff --git a/resources/tester/test_821_3_my_machine_screen.py b/resources/tester/test_821_3_my_machine_screen.py new file mode 100644 index 000000000..4a2f26f6e --- /dev/null +++ b/resources/tester/test_821_3_my_machine_screen.py @@ -0,0 +1,100 @@ +# ruff: noqa: F811 — 임시 층 fixture(world)를 test_m02_labor_ref 에서 받아 씀 +"""821-3 내 기계 화면 읽기 길(`M02_MyMachine_Look`) — 임시 storage · 쓰기 없음. + +① 양식 목록 = M01 기계 화면 목록과 같음(연료 · 직종 · 전력 · 시간환산) +② 비슷한 기계 찾기 = 내 기계(앞) + M01 · 원문번호 = 그 M01 기계 먼저 · 남의 층 내 기계 안 보임 +③ 내 품목 = 값을 바로 적은 재료비 줄만 · 같은 품목 묶음 · 쓰는 단가산출 ④ 개인 층 밖 = 404. +""" + +from __future__ import annotations + +import json + +import pytest +from fastapi import HTTPException + +from M01_MasterData import M01_MasterData_Store as m01 +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_MyMachine as mym +from M02_MasterTemplete import M02_MyMachine_Look as look +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_labor_ref import world # noqa: F401 — 같은 임시 층 + +DIG = "EQ000022" # 굴착기(무한궤도) 1.0㎥ +NAME = "서브원임시기계" + + +def _at(folder, fn, *args): + with store.at(folder): + return fn(*args) + + +def _mine(folder): + row = {"이름": NAME, "규격": "1㎾", "방식": "바로", "값": {"가격": 1000, "손료계수": 2000}} + return _at(folder, mym.create, row)["줄"] + + +def test_lists_same_as_m01(): + """① 양식 목록 = M01 기계 화면 값.""" + from M01_MasterData import M01_MasterData_Machine as m01m + + got, want = look.lists(), m01m.lists() + assert set(got) == {"fuels", "jobs", "power", "hour"} + for k in got: + assert got[k] == want[k], k + assert got["fuels"] and got["jobs"] and got["hour"] == 0.125 + + +def test_find_mine_first_and_number(world): + """② 내 기계 앞 · 원문번호 = 그 M01 기계 먼저 · 남의 층 = 내 기계 안 보임.""" + me, other = world["personal"], layers.personal_dir(7, 43) + key = _mine(me)["키"] + rows = _at(me, look.find, NAME) + assert rows[0]["키"] == key and rows[0]["출처"] == "개인" and rows[0]["값"]["손료계수"] == 2000 + assert all(r["출처"] == "M01" for r in _at(other, look.find, NAME)) + no = str(m01.loaded()[1].get(DIG)["원문번호"]) + first = _at(me, look.find, no)[0] + assert first["키"] == DIG and first["원문번호"] == no and first["출처"] == "M01" + assert first["값"]["가격"] and first["구분"] + dig = [r for r in _at(me, look.find, "굴착기") if r["출처"] == "M01"] + assert dig and len({r["키"] for r in dig}) == len(dig) # 겹침 없음 + + +def _sk(folder, key, name, lines): + """수식 문서 재료비 줄 — (이름, 연결) · 숫자 = 바로 적은 값 · 요소 = 우리 자재.""" + vars_, money = [], [] + for i, (item, link) in enumerate(lines): + vars_.append({"열쇠": f"P_x{i}", "기호": "P", "아래": f"x{i}", "위": "", "연결": link}) + money.append( + {"이름": item, "규격": "10㎜", "단위": "개", "비목": "재료비", "단가": f"P_x{i}"} + ) + money.append({"이름": "인부", "단위": "인", "비목": "노무비", "단가": "P_x0"}) + doc = {"키": key, "이름": name, "수식": {"문서": {"변수": vars_, "금액": money}}} + path = folder / "sangeun" / f"{key}.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(doc, ensure_ascii=False), encoding="utf-8") + + +def test_items_direct_only(world): + """③ 바로 적은 값 재료만 · 같은 품목 = 한 묶음 · 쓰는 단가산출 · 값 다르면 따로.""" + me = world["personal"] + _sk(me, "SK-000001", "가", [("볼트", {"갈래": "숫자", "값": 500}), + ("시멘트", {"갈래": "요소", "키": "MT004176"})]) # fmt: skip + _sk(me, "SK-000002", "나", [("볼트", {"갈래": "숫자", "값": 500}), + ("볼트", {"갈래": "입력", "값": 700})]) # fmt: skip + got = _at(me, look.items) + assert [(r["이름"], r["값"]) for r in got] == [("볼트", 500), ("볼트", 700)] + assert [u["키"] for u in got[0]["쓰는곳"]] == ["SK-000001", "SK-000002"] + assert got[1]["쓰는곳"] == [{"키": "SK-000002", "이름": "나"}] + assert got[0]["규격"] == "10㎜" and got[0]["단위"] == "개" + assert _at(layers.personal_dir(7, 43), look.items) == [] + + +def test_personal_only(world): + """④ 개인 층 밖 = 404 · 남의 개인 층 = 403(일반).""" + user = {"role": "USER", "company_id": 7, "user_id": 42} + for layer, owner, code in (("company", None, 404), ("personal", "7/43", 403)): + with pytest.raises(HTTPException) as e: + look.get_items(layer, owner, user) + assert e.value.status_code == code + assert look.get_items("personal", None, user) == {"rows": []}