diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs.py b/M02_MasterTemplete/M02_MasterTemplete_Inputs.py new file mode 100644 index 00000000..92692c6f --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs.py @@ -0,0 +1,229 @@ +"""M02 항목 선택 입력(PLAN 25-1b · 25-2a) — 입력 목록 · 항목 연결 파일 · 알약. + +항목 = 구조물집계표조합(SS) 한 줄 → 그 열의 일위대가(UA) 조합 → 담은 단가산출 로직마다 입력 전부. +로직마다 갈라 보이고 이름이 같아도 합치지 않음(로직마다 값이 다를 수 있음) · 입력 목록은 그 로직 +줄의 `입력` 그대로(하위 로직으로 넘겨지는 입력은 맨 위 로직 입력으로만 · M01 조합 미리 보기와 +같음) · 자동값 = `master_auto.values`(보기용 · 저장 안 함). + +항목 연결 파일 = 층 폴더 `inputs/<열id>.json` = `{판, 줄: [{로직, 입력, 자리, 알약?}]}` — +판은 저장마다 1 씩 늘고, 연결만 적음(값은 프로젝트 몫). 자리 면적 · 재료 = 그 시트의 알약 +(키 `입력|{로직}|{입력}`) · 선택 = 「선택 입력」 카드 · null = 안 씀. +M01 은 읽기만(`M01_MasterData_Store` · `_Store_Combo` · `master_auto`) · 권한은 부르는 쪽 몫. +""" + +from __future__ import annotations + +import json +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 M01_MasterData import M01_MasterData_Store_Combo as combo_store +from M02_MasterTemplete import M02_MasterTemplete_Store as store + +PLACES = ("면적", "재료", "선택", None) +PILL_HEAD = "입력" +_SPEC = ("이름", "설명", "단위", "고르기", "범위") + + +def _num(value: Any) -> Any: + """Decimal → JSON 수(M01 길과 같은 규칙) · 목록 · 묶음 안까지.""" + if isinstance(value, Decimal): + return int(value) if value == value.to_integral_value() else float(value) + if isinstance(value, list): + return [_num(v) for v in value] + if isinstance(value, dict): + return {k: _num(v) for k, v in value.items()} + return value + + +def pill_key(logic: str, name: str) -> str: + return f"{PILL_HEAD}|{logic}|{name}" + + +def _item(key: str) -> dict[str, Any]: + """지금 층(`store.at`)의 항목 줄 — 없으면 404.""" + row = next((r for r in store.read_combos().get("줄", []) if r.get("키") == key), None) + if row is None: + raise store.StoreError(404, f"없는 항목 「{key}」") + return row + + +def _combo_row(ua: str) -> dict[str, Any] | None: + rows = m01.read(combo_store.FILE)[0].get("줄") or [] + return next((r for r in rows if str(r.get("키")) == ua), None) + + +def combo_logics(ua: str | None) -> tuple[dict[str, Any] | None, list[dict], list[str]]: + """(조합 줄, [로직 줄 …], 빠진 로직 키) — 조합이 없거나 못 찾으면 (None, [], []).""" + if not ua: + return None, [], [] + row = _combo_row(str(ua)) + if row is None: + return None, [], [] + whole = m01.loaded()[1] + found, missing = [], [] + for one in combo_store.mcb.items(row): + code = str(one.get("로직") or "") if isinstance(one, dict) else "" + try: + found.append(whole.logic(code)) + except combo_store.cm.mf.FormulaError: + missing.append(code) + return row, found, missing + + +def inputs(key: str) -> dict[str, Any]: + """25-1b 입력 목록 — `{일위대가, 이름, 로직: [{로직, 이름, 결과단위, 입력: [...]}], 빠진로직}`. + 일위대가 없는 항목은 로직 빈 목록.""" + ua = _item(key).get("일위대가") + row, logics, missing = combo_logics(ua) + whole = m01.loaded()[1] + out = [] + for logic in logics: + auto = m01.mau.values(whole, logic) + specs = [] + for spec in logic.get("입력") or []: + one = {k: spec[k] for k in _SPEC if k in spec} + one.setdefault("설명", None) + one.setdefault("단위", None) + specs.append({**one, "자동값": auto.get(spec["이름"])}) + out.append( + { + "로직": logic.get("키"), + "이름": logic.get("이름"), + "결과단위": logic.get("결과단위"), + "입력": specs, + } + ) + return _num( + { + "일위대가": ua or None, + "이름": row.get("이름") if row else None, + "로직": out, + "빠진로직": missing, + } + ) + + +# ── 항목 연결 파일 ───────────────────────────────────── + + +def read_file(column: str) -> dict[str, Any] | None: + """지금 층의 `inputs/<열id>.json` 그대로 — 없으면 None(열 지우기 · 복사 · 스냅샷이 씀).""" + path = store.inputs_path(column) + return json.loads(path.read_bytes()) if path.is_file() else None + + +def write_file(column: str, doc: dict[str, Any]) -> None: + """지금 층에 연결 파일을 그대로 씀(복사용 — 검사 · 판 비교 없음).""" + path = store.inputs_path(column) + path.parent.mkdir(parents=True, exist_ok=True) + atomic_write_json(path, doc) + + +def _known(key: str) -> dict[str, set[str]]: + """{로직 키: {입력 이름}} — 그 항목 조합이 지금 담은 것.""" + _, logics, _ = combo_logics(_item(key).get("일위대가")) + return {str(lg.get("키")): {str(s.get("이름")) for s in lg.get("입력") or []} for lg in logics} + + +def read_links(key: str) -> dict[str, Any]: + """항목 연결 — 없으면 `{판: null, 줄: []}` · 조합이 바뀌어 사라진 로직 · 입력 줄은 + `사라짐: true`.""" + doc = read_file(_item(key)["열"]) or {"판": None, "줄": []} + known = _known(key) + rows = [] + for row in doc.get("줄") or []: + gone = str(row.get("입력")) not in known.get(str(row.get("로직")), set()) + rows.append({**row, "사라짐": True} if gone else row) + return {"판": doc.get("판"), "줄": rows} + + +def _clean_rows(key: str, rows: Any) -> list[dict[str, Any]]: + """없는 로직 · 입력 · 모르는 자리 · 같은 입력 두 번은 422 · 알약 키는 서버가 붙임.""" + if not isinstance(rows, list): + raise store.StoreError(422, "「줄」 이 목록 아님") + known = _known(key) + out, seen, errors = [], set(), [] + for at, row in enumerate(rows, start=1): + if not isinstance(row, dict): + errors.append(f"줄{at} · 묶음 아님") + continue + logic, name, place = str(row.get("로직") or ""), str(row.get("입력") or ""), row.get("자리") + if logic not in known: + errors.append(f"줄{at} · 이 항목 일위대가에 없는 로직 「{logic}」") + elif name not in known[logic]: + errors.append(f"줄{at} · 로직 {logic} 에 없는 입력 「{name}」") + elif place not in PLACES: + errors.append(f"줄{at} · 모르는 자리 「{place}」 — 면적 · 재료 · 선택 · null") + elif (logic, name) in seen: + errors.append(f"줄{at} · 같은 입력 두 번 「{logic}|{name}」") + else: + seen.add((logic, name)) + one: dict[str, Any] = {"로직": logic, "입력": name, "자리": place} + if place in ("면적", "재료"): + one["알약"] = pill_key(logic, name) + out.append(one) + if errors: + raise store.StoreError(422, {"errors": errors}) + return out + + +def write_links(key: str, version: Any, rows: Any) -> dict[str, Any]: + """항목 연결 저장 — 받은 `판` 이 지금 판(없으면 null)과 다르면 409 · 저장마다 판 + 1.""" + column = _item(key)["열"] + clean = _clean_rows(key, rows) + with store._LOCK: + have = (read_file(column) or {}).get("판") + if version != have: + raise store.StoreError(409, {"stale": [f"inputs/{column}"], "판": have}) + doc = {"판": int(have or 0) + 1, "줄": clean} + write_file(column, doc) + return doc + + +# ── 알약 — 면적산출 · 재료산출 시트 ─────────────────────── + + +def pills(key: str, place: str) -> dict[str, Any]: + """그 시트 알약 — 면적이면 조합 물량 알약(M01 `combo_inputs` · 권한 무관 읽기) + 자리를 + 그 시트로 이은 입력 알약 · 재료면 이은 입력 알약만. 입력 알약 = `{키, 이름, 단위, 로직, 입력}` — + 이름은 로직마다 갈라 「입력 · 로직 이름」(같은 이름 입력이 여러 로직에 있어도 안 합침).""" + if place not in ("면적", "재료"): + raise store.StoreError(422, f"모르는 자리 「{place}」 — 면적 · 재료") + ua = _item(key).get("일위대가") + quantity: list[dict[str, Any]] = [] + if place == "면적" and ua and _combo_row(str(ua)): + quantity = combo_store.combo_inputs(str(ua))["물량"] + _, logics, _ = combo_logics(ua) + by_key = {str(lg.get("키")): lg for lg in logics} + linked = [] + for row in read_links(key)["줄"]: + if row.get("자리") != place or row.get("사라짐"): + continue + logic = by_key[str(row["로직"])] + spec = next(s for s in logic.get("입력") or [] if s.get("이름") == row["입력"]) + short = combo_store._TAIL.sub("", str(logic.get("이름") or "")) or row["로직"] + linked.append( + { + "키": pill_key(row["로직"], row["입력"]), + "이름": f"{row['입력']} · {short}", + "단위": spec.get("단위"), + "로직": row["로직"], + "입력": row["입력"], + } + ) + return _num({"물량": quantity, "입력": linked}) + + +def copy_between(source: Path, target: Path, source_column: str, target_column: str) -> bool: + """다른 층(또는 같은 층) 열의 연결 파일을 새 열로 — 판은 1 부터 · 없으면 아무것도 안 함.""" + with store.at(source): + doc = read_file(source_column) + if doc is None: + return False + with store.at(target): + write_file(target_column, {"판": 1, "줄": doc.get("줄") or []}) + return True diff --git a/M02_MasterTemplete/M02_MasterTemplete_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Items.py index ea87928a..f535a722 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Items.py @@ -15,6 +15,7 @@ from typing import Any from common_util.common_util_json import atomic_write_json 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_Inputs as inputs_store from M02_MasterTemplete import M02_MasterTemplete_Matset as matset from M02_MasterTemplete import M02_MasterTemplete_Store as store @@ -104,7 +105,7 @@ def _fresh_id(want: str, used: set[str]) -> str: def copy_item(source: Path, target: Path, key: str, origin: dict[str, Any]) -> dict[str, Any]: """다른 층 항목(SS `key`) → `target` 층 새 열 하나 — 머리 · 단위 · 일위대가 · 바인딩 그대로 · - 도면 · 면적산출 · 재료산출 내용(알약 연결 포함) · 자재 조합 줄(새 UM) 복사 · + 도면 · 면적산출 · 재료산출 내용(알약 연결 포함) · 항목 연결 · 자재 조합 줄(새 UM) 복사 · 새 번호 · 새 SS 키 · 열 `본뜬` = `origin`(원래 층 · 주인 · 키). 돌려줌 `{key, 열, 이름}`.""" with store.at(source): @@ -153,4 +154,5 @@ def copy_item(source: Path, target: Path, key: str, origin: dict[str, Any]) -> d 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) + inputs_store.copy_between(source, target, col_id, new_id) # 항목 연결도 같이(판 1) return {"key": ss["키"], "열": new_id, "이름": " ".join(h for h in store._heads(made) if h)} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py index 9cfbe6c8..08f8ad31 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -20,6 +20,7 @@ from pydantic import BaseModel from common_util.common_util_auth import verify_session from M02_MasterTemplete import M02_MasterTemplete_Branch as branch +from M02_MasterTemplete import M02_MasterTemplete_Inputs as inputs_store from M02_MasterTemplete import M02_MasterTemplete_Items as items_store from M02_MasterTemplete import M02_MasterTemplete_Matset as matset from M02_MasterTemplete import M02_MasterTemplete_Store as store @@ -38,6 +39,11 @@ Session = dict[str, Any] NO_COMPANY = "회사부터 등록 — 회사 없이는 회사 · 개인 구조물 집계표를 만들 수 없음" +class InputLinksBody(BaseModel): + 판: int | None = None + 줄: list[Any] = [] + + class CopyItemBody(BaseModel): from_layer: Literal["system", "company"] from_owner: str | None = None @@ -145,6 +151,50 @@ def copy_item(body: CopyItemBody, session: Session = Depends(verify_session)) -> raise HTTPException(status_code=e.status, detail=e.detail) from e +# ── 항목 선택 입력(PLAN 25-1b · 25-2a) — 읽기 = 층 읽기 권한(시스템 층 = 로그인 누구나) ── + + +@router.get("/layers/{layer}/items/{key}/inputs") +def get_item_inputs( + layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) +) -> dict: + """그 항목 일위대가가 담은 로직마다 입력 전부(합치지 않음) · 자동값 — M01 길은 관리자 전용이라 + 일반 사용자는 이 길로 읽음.""" + return _call(_root(session, layer, owner), inputs_store.inputs, key) + + +@router.get("/layers/{layer}/items/{key}/input-links") +def get_input_links( + layer: Layer, key: str, owner: str | None = None, session: Session = Depends(verify_session) +) -> dict: + return _call(_root(session, layer, owner), inputs_store.read_links, key) + + +@router.put("/layers/{layer}/items/{key}/input-links") +def put_input_links( + layer: Layer, + key: str, + body: InputLinksBody, + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """본문 `{판, 줄}` — 판 다르면 409 · 없는 로직 · 입력 키 422 · 층 쓰기 권한.""" + folder = _root(session, layer, owner, write=True) + return _call(folder, inputs_store.write_links, key, body.판, body.줄) + + +@router.get("/layers/{layer}/items/{key}/pills") +def get_item_pills( + layer: Layer, + key: str, + place: Literal["면적", "재료"], + owner: str | None = None, + session: Session = Depends(verify_session), +) -> dict: + """면적산출 · 재료산출 시트 알약 — `{물량: [조합 물량], 입력: [그 시트로 이은 입력]}`.""" + return _call(_root(session, layer, owner), inputs_store.pills, key, place) + + # ── 층 길 — 옛 시스템 길과 같은 모양 ───────────────────── diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index f068b29a..4aa8d3f1 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -101,6 +101,16 @@ def _path(kind: str, name: str) -> Path: return root() / kind / f"{name}.json" +# 항목 연결(PLAN 25-1b) — 열 id 마다 `inputs/<열id>.json` · 종류 폴더(KINDS) 밖 · 읽기 · 쓰기는 +# `M02_MasterTemplete_Inputs` 몫 · 열 지우기 · 분류 더하기 복사는 여기서 같이 +INPUTS = "inputs" + + +def inputs_path(column: str) -> Path: + """그 열의 항목 연결 파일 자리 — 이름 규칙은 종류 파일과 같음.""" + return _path("structure", column).parent.parent / INPUTS / f"{column}.json" + + def _info(kind: str, path: Path) -> dict[str, Any]: raw = path.read_bytes() stamp = datetime.fromtimestamp(path.stat().st_mtime).isoformat(timespec="seconds") @@ -163,6 +173,7 @@ def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]: for column, source in origins.items(): # 빈 파일 채우기 앞 — 원본 내용으로 만듦 for part in _KEY: _ensure(part, column, source) + _copy_inputs(source, column) backfill_missing_structures() # 지우기 앞 — 같은 저장의 새 열이 지운 번호를 안 받게 prune_removed_structures() sync_combos() # 번호가 다 선 뒤 — 조합 표 도번 · 면적번 · 재료번 칸 @@ -375,13 +386,26 @@ def copy_from(kind: str, column: str, source: str) -> dict[str, Any]: return _info(kind, _path(kind, column)) +def _copy_inputs(source: str, column: str) -> None: + """분류 더하기로 복사한 새 열 — 원본 열의 항목 연결을 판 1 로(이미 있으면 그대로).""" + have, got = inputs_path(column), inputs_path(source) + if have.is_file() or not got.is_file(): + return + have.parent.mkdir(parents=True, exist_ok=True) + atomic_write_json(have, {"판": 1, "줄": json.loads(got.read_bytes()).get("줄") or []}) + + def prune_removed_structures() -> list[str]: - """집계표에서 지운 열(또는 구조물이 아니게 된 열)의 세 파일을 같이 지움 — + """집계표에서 지운 열(또는 구조물이 아니게 된 열)의 세 파일 · 항목 연결을 같이 지움 — 번호는 비워 둠(다른 열 번호를 안 당김).""" keep = set(_column_ids()) gone: list[str] = [] codes: set[str] = set() with _LOCK: + for path in sorted((root() / INPUTS).glob("[!.]*.json")): + if path.stem not in keep: + path.unlink() + gone.append(f"{INPUTS}/{path.stem}") for kind, key, _, _ in PARTS: for path in sorted((root() / kind).glob("[!.]*.json")): if path.stem not in keep: diff --git a/resources/tester/test_m02_item_inputs.py b/resources/tester/test_m02_item_inputs.py new file mode 100644 index 00000000..a72a8557 --- /dev/null +++ b/resources/tester/test_m02_item_inputs.py @@ -0,0 +1,238 @@ +"""M02 항목 선택 입력(PLAN 25-1b · 25-2a) — 입력 목록 · 항목 연결 파일 · 알약 · 권한. + +① 입력 목록은 실제 마스터(시스템 층 · 읽기만)로 — 일위대가 있는 항목 셋에서 로직 수 · 입력 수 · + 자동값이 M01 조합 미리 보기가 쓰는 것(로직 줄 `입력` · `master_auto`)과 같은지 · 로직마다 갈라 + 합치지 않는지 · 일위대가 없는 항목은 빈 목록. +② 연결 파일 쓰기는 임시 storage(회사 · 개인 층) + 견본 시스템 집계표로만 — 판 409 · 없는 키 422 · + 사라짐 표시 · 알약 · 층 권한 · 열 지우기 · 항목 복사 · 분류 더하기 복사가 파일을 같이 다룸. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import verify_session +from config import config_system +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_Inputs as inputs_store +from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router +from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_structure import write_sample_table + +TABLE = "table/구조물집계표" +UA = "UA000004" # 견본 항목 셋(rv · c4 · c1)이 쓰는 조합 — 담은 로직 여럿 +OTHER_UA = "UA000007" + + +def _app(session: dict[str, Any]) -> TestClient: + app = FastAPI() + app.include_router(layers_router.router) + app.include_router(items_router.router) + app.dependency_overrides[verify_session] = lambda: session + return TestClient(app) + + +def _m01_view(ua: str) -> list[tuple[str, list[str], dict]]: + """M01 조합 미리 보기가 그리는 것 — 담은 로직마다 (키, 입력 이름들, 자동값).""" + row = next(r for r in m01.read(combo_store.FILE)[0]["줄"] if r["키"] == ua) + whole = m01.loaded()[1] + out = [] + for one in row["담은로직"]: + try: + logic = whole.logic(one["로직"]) + except combo_store.cm.mf.FormulaError: + continue # 화면도 못 읽은 로직은 건너뜀 + auto = inputs_store._num(m01.auto(one["로직"])["값"]) + out.append((one["로직"], [s["이름"] for s in logic.get("입력") or []], auto)) + return out + + +# ── ① 입력 목록 — 실제 시스템 층(읽기만) ───────────────────── + + +def test_입력_목록은_M01_조합_미리_보기와_같음(): + client = _app({"user_id": 1, "company_id": 1, "role": "USER", "is_master": False}) + rows = [r for r in json.loads((store.FOLDER / store.COMBO_FILE).read_bytes())["줄"]] + with_ua = [r for r in rows if r.get("일위대가")] + picked, seen = [], set() + for r in with_ua: # 서로 다른 조합 셋 + if r["일위대가"] not in seen: + picked.append(r) + seen.add(r["일위대가"]) + if len(picked) == 3: + break + assert len(picked) == 3 + for r in picked: + res = client.get(f"/api/m02/layers/system/items/{r['키']}/inputs") + assert res.status_code == 200, res.text # 일반 사용자 읽기 + got = res.json() + want = _m01_view(r["일위대가"]) + assert got["일위대가"] == r["일위대가"] and got["이름"] + assert [lg["로직"] for lg in got["로직"]] == [k for k, _, _ in want] + for logic, (_, names, auto) in zip(got["로직"], want): + assert [i["이름"] for i in logic["입력"]] == names # 로직마다 · 합치지 않음 + assert {i["이름"]: i["자동값"] for i in logic["입력"]} == auto + assert all({"이름", "설명", "단위", "자동값"} <= set(i) for i in logic["입력"]) + # 이름이 같은 입력이 두 로직에 있어도 두 번 나옴 + names = [ + i["이름"] + for r in with_ua + for lg in client.get(f"/api/m02/layers/system/items/{r['키']}/inputs").json()["로직"] + for i in lg["입력"] + ] + assert len(names) > len(set(names)) + no_ua = next(r for r in rows if not r.get("일위대가")) + empty = client.get(f"/api/m02/layers/system/items/{no_ua['키']}/inputs").json() + assert empty == {"일위대가": None, "이름": None, "로직": [], "빠진로직": []} + assert client.get("/api/m02/layers/system/items/SS999999/inputs").status_code == 404 + + +# ── ② 연결 파일 — 임시 storage ───────────────────────── + + +@pytest.fixture() +def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + system = tmp_path / "system" + system.mkdir() + write_sample_table(system) + monkeypatch.setattr(store, "FOLDER", system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + store.renumber() + storage = tmp_path / "storage" + storage.mkdir() + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage)) + session: dict[str, Any] = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False} + return {"client": _app(session), "session": session, "storage": storage} + + +def _as(world: dict, role: str = "USER", user: int = 42) -> TestClient: + world["session"].update(role=role, user_id=user) + return world["client"] + + +def _put_table(client: TestClient, layer: str, edit) -> None: + url = f"/api/m02/layers/{layer}/templates/{TABLE}" + got = client.get(url).json() + edit(got["문서"]["열"]) + res = client.put(url, json={"판": got["판"], "문서": got["문서"]}) + assert res.status_code == 200, res.text + + +def _col(cid: str, ua: str | None = UA) -> dict[str, Any]: + return { + "id": cid, + "머리": ["sub4 돌쌓기", cid, None, None], + "단위": "㎡", + "꼴": "수", + "일위대가": ua, + } + + +def _key(client: TestClient, layer: str, cid: str) -> str: + items = client.get(f"/api/m02/items?layer={layer}").json() + return next(r["key"] for r in items if r["열"] == cid) + + +def test_연결_저장_다시_읽기_판_422_사라짐_알약(world): + client = _as(world) + _put_table(client, "personal", lambda cols: cols.append(_col("sub4_a"))) + key = _key(client, "personal", "sub4_a") + base = f"/api/m02/layers/personal/items/{key}" + listing = client.get(f"{base}/inputs").json() + first, second = listing["로직"][0], listing["로직"][1] + assert client.get(f"{base}/input-links").json() == {"판": None, "줄": []} + rows = [ + {"로직": first["로직"], "입력": first["입력"][0]["이름"], "자리": "면적"}, + {"로직": second["로직"], "입력": second["입력"][0]["이름"], "자리": "재료"}, + {"로직": first["로직"], "입력": first["입력"][1]["이름"], "자리": "선택"}, + ] + saved = client.put(f"{base}/input-links", json={"판": None, "줄": rows}) + assert saved.status_code == 200, saved.text + assert saved.json()["판"] == 1 + again = client.get(f"{base}/input-links").json() + assert again == saved.json() + assert again["줄"][0]["알약"] == f"입력|{first['로직']}|{first['입력'][0]['이름']}" + assert "알약" not in again["줄"][2] # 선택은 시트 알약이 아님 + file = world["storage"] / "7" / "42" / "templates" / "inputs" / "sub4_a.json" + assert json.loads(file.read_text("utf-8")) == saved.json() + # 판 다르면 409 · 없는 키 · 모르는 자리 · 두 번은 422 + assert client.put(f"{base}/input-links", json={"판": None, "줄": rows}).status_code == 409 + for bad in ( + [{"로직": "GF999999", "입력": "높이", "자리": "면적"}], + [{"로직": first["로직"], "입력": "없는입력", "자리": "면적"}], + [{"로직": first["로직"], "입력": first["입력"][0]["이름"], "자리": "옆"}], + [rows[0], rows[0]], + ): + res = client.put(f"{base}/input-links", json={"판": 1, "줄": bad}) + assert res.status_code == 422, res.text + # 알약 — 면적 = 조합 물량 + 면적으로 이은 입력 · 재료 = 재료로 이은 입력만 + area = client.get(f"{base}/pills?place=면적").json() + assert area["물량"] and [p["키"] for p in area["입력"]] == [again["줄"][0]["알약"]] + mat = client.get(f"{base}/pills?place=재료").json() + assert mat["물량"] == [] and [p["입력"] for p in mat["입력"]] == [second["입력"][0]["이름"]] + assert " · " in mat["입력"][0]["이름"] # 입력 · 로직 이름(합치지 않음) + # 일위대가가 바뀌면 옛 로직 연결은 읽을 때 사라짐 · 알약에서 빠짐 + _put_table(client, "personal", lambda cols: cols[-1].update(일위대가=OTHER_UA)) + gone = client.get(f"{base}/input-links").json()["줄"] + assert all(r.get("사라짐") for r in gone) + assert client.get(f"{base}/pills?place=면적").json()["입력"] == [] + + +def test_권한_일반_사용자_읽기_쓰기(world): + admin = _as(world, "SYSTEM_ADMIN") + _put_table(admin, "system", lambda cols: cols.append(_col("sub4_sys"))) + key = _key(admin, "system", "sub4_sys") + base = f"/api/m02/layers/system/items/{key}" + user = _as(world) + assert user.get(f"{base}/inputs").status_code == 200 + assert user.get(f"{base}/input-links").status_code == 200 + assert user.put(f"{base}/input-links", json={"판": None, "줄": []}).status_code == 403 + assert _as(world, "SYSTEM_ADMIN").put(f"{base}/input-links", json={"줄": []}).status_code == 200 + # 회사 층 — 같은 회사 일반 사용자 읽기 · 쓰기 403 · 회사 관리자 쓰기 + _put_table(_as(world, "ADMIN"), "company", lambda cols: cols.append(_col("sub4_c"))) + ckey = _key(_as(world), "company", "sub4_c") + cbase = f"/api/m02/layers/company/items/{ckey}" + assert _as(world).get(f"{cbase}/inputs").status_code == 200 + assert _as(world).put(f"{cbase}/input-links", json={"줄": []}).status_code == 403 + assert _as(world, "ADMIN").put(f"{cbase}/input-links", json={"줄": []}).status_code == 200 + # 남의 개인 층은 못 읽음 + assert _as(world).get(f"{cbase}/inputs?owner=9").status_code == 403 + + +def test_열_지우기_항목_복사_분류_복사가_연결을_같이(world): + admin = _as(world, "ADMIN") + _put_table(admin, "company", lambda cols: cols.append(_col("sub4_c"))) + key = _key(admin, "company", "sub4_c") + base = f"/api/m02/layers/company/items/{key}" + logic = admin.get(f"{base}/inputs").json()["로직"][0] + rows = [{"로직": logic["로직"], "입력": logic["입력"][0]["이름"], "자리": "면적"}] + assert admin.put(f"{base}/input-links", json={"판": None, "줄": rows}).status_code == 200 + # 항목 개인 복사 — 새 열에 연결이 판 1 로 + user = _as(world) + made = user.post( + "/api/m02/layers/personal/items/copy", json={"from_layer": "company", "key": key} + ).json() + mine = user.get(f"/api/m02/layers/personal/items/{made['key']}/input-links").json() + assert mine["판"] == 1 and [r["입력"] for r in mine["줄"]] == [rows[0]["입력"]] + # 분류 더하기(본뜬열) — 새 열이 원본 열 연결을 받음 + admin = _as(world, "ADMIN") + twin = {**_col("sub4_c2", None), "본뜬열": "sub4_c"} + _put_table(admin, "company", lambda cols: cols.append(twin)) + folder = world["storage"] / "7" / "templates" + with store.at(folder): + assert inputs_store.read_file("sub4_c2")["줄"] == inputs_store.read_file("sub4_c")["줄"] + # 열 지우기 — 연결 파일도 같이 지움 + _put_table( + admin, "company", lambda cols: cols.remove(next(c for c in cols if c["id"] == "sub4_c")) + ) + assert not (folder / "inputs" / "sub4_c.json").is_file() + assert (folder / "inputs" / "sub4_c2.json").is_file()