Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_6

This commit is contained in:
2026-09-29 23:27:22 +09:00
5 changed files with 545 additions and 2 deletions
@@ -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
@@ -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)}
@@ -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)
# ── 층 길 — 옛 시스템 길과 같은 모양 ─────────────────────
+25 -1
View File
@@ -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:
+238
View File
@@ -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()