- 로직 상세 보기 첫 칸 「시트」(기본) — 이름 | 식 | 값 | 비목 · 근거 · 옵션(시험 값) · 변수 · 계산 · 자원 · 율 · 덧줄 · 단계 · 끝수 · 소계 · 합계 · 상태줄 지금 · 고치기 전 - 식 글 = 이름 꼴(M02 단가산출 시트와 같은 번역 · 새 서버 M01_MasterData_Sheet.py · POST /api/m01/logic/sheet · /logic/sheet/check · 저장 때와 같은 엔진 검사 · 쓰기 없음) - 식 고침 = 시스템 관리자만 · 되는 식만 줄 사본에 · 캐시 → 기존 [저장](개정 확인 · 서버 검사 422) · [원래로] - 식 칸 몸 = 공용 A00_Common/sheet_formula_cell.ts — M02 단가산출 시트도 같은 것을 씀(동작 그대로) - 자체 로직(GX) 편집기 식 날 입력칸 → 읽기만(시트 식 칸으로 갈음) - 시험: test_794_1_m01_sheet 새로 · test_790_1 식 칸 문구 자리를 공용 파일로 옮김(식 칸 몸이 공용으로 감) · test_717_2b · test_722 LogicLab.ts 줄 상한 627 → 637(시트 이음 10 줄 · 셈 · 고침은 시트 파일로 뺌 · 700 아래) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
235 lines
12 KiB
Python
235 lines
12 KiB
Python
# ruff: noqa: E501
|
||
"""794-1 M01 관리자 줄 식 시트 — 로직 상세 첫 보기 「시트」 · 식 칸 = M02 단가산출 시트와 한 벌.
|
||
|
||
- 정적: 시트 화면 = 부품 상자만(날 button · input 0) · 식 칸 몸 = 공용 `sheet_formula_cell`(M02 도 같은 것) ·
|
||
고침 = 관리자만 · 줄 사본 → 캐시(touch) → 기존 [저장] 길(시트는 `/save` 안 부름) · 보기 첫 칸 = 시트 ·
|
||
자체 로직(GX) 편집기 식 칸 = 읽기만(날 입력칸 갈음)
|
||
- 서버(사본 폴더): 이름 꼴 식 글 · 칠 때마다 검사(없는 이름 · 아래 줄 · 괄호 · 빈 식 · 길이 · 없는 자리) ·
|
||
검사 = 쓰기 없음 · 공구손료 식 1 → 2 % 저장(개정 확인 409 → 200) 뒤 셈 51,802 · 틀린 식 저장 422 · 시스템 관리자가 아니면 403
|
||
- 모델(node strip-types): 줄 갈래 · 줄 금액 = 호표 → 덧줄 차례 · 고친 줄 · 끝수 · 단계 · 식 칸 고침
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import copy
|
||
import hashlib
|
||
import json
|
||
import re
|
||
import shutil
|
||
import subprocess
|
||
from pathlib import Path
|
||
|
||
import pytest
|
||
from fastapi import Depends, FastAPI
|
||
from fastapi.testclient import TestClient
|
||
|
||
from common_util.common_util_auth import require_system_admin, verify_session
|
||
from M01_MasterData import M01_MasterData_Router as m01_router
|
||
from M01_MasterData import M01_MasterData_Sheet as line_sheet
|
||
from M01_MasterData import M01_MasterData_Store as store
|
||
|
||
ROOT = Path(__file__).resolve().parents[2]
|
||
M01 = ROOT / "M01_MasterData"
|
||
SHEET = M01 / "M01_MasterData_UI_LogicLab_Sheet.ts"
|
||
MODEL = M01 / "logic" / "M01_Logic_Sheet_Model.ts"
|
||
LAB = M01 / "M01_MasterData_UI_LogicLab.ts"
|
||
CELL = ROOT / "A00_Common" / "sheet_formula_cell.ts"
|
||
REAL = store.FOLDER
|
||
KEY = "GC000211" # 합판거푸집 설치 및 해체
|
||
INPUTS = {"자재지역": "포항", "유형": "보통", "사용횟수": "6회", "높이감률": 0, "지붕슬래브": "아님",
|
||
"폼타이": "아님", "폼타이규격": "12.7㎜", "측압": "3t/㎡", "구멍땜": 0} # fmt: skip
|
||
TOOL_1 = "(형틀목공 × [노임 형틀목공] + 보통인부 × [노임 보통인부]) × 1 / 100"
|
||
TOOL_2 = TOOL_1.replace("× 1 / 100", "× 2 / 100")
|
||
IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE)
|
||
|
||
|
||
def _text(p: Path) -> str:
|
||
return p.read_text("utf-8")
|
||
|
||
|
||
def _hashes(folder: Path) -> dict[str, str]:
|
||
return {p.name: hashlib.md5(p.read_bytes()).hexdigest() for p in sorted(folder.glob("*.json"))}
|
||
|
||
|
||
# ── 정적 ─────────────────────────────────────────────────────────────
|
||
def test_정적_부품_상자만_관리자만_저장은_기존_길() -> None:
|
||
for p in (SHEET, MODEL, CELL):
|
||
s = _text(p)
|
||
assert "<button" not in s and "<input" not in s and "document.createElement" not in s
|
||
assert "sessionStorage." not in s and len(s.splitlines()) < 700
|
||
assert "fetch(" not in _text(MODEL) and "document." not in _text(MODEL)
|
||
sheet = _text(SHEET)
|
||
for part in (
|
||
"createCalcSheetRow",
|
||
"createFormulaCell",
|
||
'"/logic/sheet"',
|
||
'"/logic/sheet/check"',
|
||
):
|
||
assert part in sheet, part
|
||
assert "/save" not in sheet # 저장 = 상세 [저장](기존 길)
|
||
assert sheet.count("opt.admin &&") >= 2 # [식 고침] · 식 칸 = 관리자만
|
||
lab = _text(LAB)
|
||
assert "const sheet = createLabSheet({\n admin," in lab.replace("\r\n", "\n")
|
||
assert "sheet: sheet.root," in lab and "onAnswer: sheet.answer," in lab
|
||
assert "edited: () => (touch(), drawEditor(), drawCalc())" in lab # 캐시(touch) → [저장]
|
||
assert "setLabFormula(d.row, part, at, text)" in sheet and "opt.edited();" in sheet
|
||
assert len(lab.splitlines()) <= 637
|
||
view = _text(M01 / "M01_MasterData_UI_LogicLab_View.ts")
|
||
assert (
|
||
'host.dataset.view = "sheet";' in view
|
||
and '{ value: "sheet", label: tl("View_Sheet") }' in view
|
||
)
|
||
css = _text(M01 / "M01_MasterData_UI_LogicLab_Style.css")
|
||
assert '[data-view="sheet"] [data-block]:not([data-block="sheet"])' in css
|
||
edit = _text(M01 / "M01_MasterData_UI_Logic_Edit.ts")
|
||
assert "textBox(item.수량" not in edit and "textBox(item.식" not in edit # GX 날 식 칸 갈음
|
||
m02 = _text(ROOT / "M02_MasterTemplete" / "M02_MasterTemplete_Sangeun_SheetFormula.ts")
|
||
assert "return createFormulaCell({" in m02 # M02 시트도 같은 식 칸
|
||
main = _text(ROOT / "main.py")
|
||
assert "app.include_router(m01_master_data_router, dependencies=system_admin_only)" in main
|
||
|
||
|
||
# ── 서버 ─────────────────────────────────────────────────────────────
|
||
@pytest.fixture
|
||
def folder(tmp_path: Path, monkeypatch) -> Path:
|
||
master = tmp_path / "master"
|
||
master.mkdir()
|
||
for path in REAL.glob("*.json"):
|
||
shutil.copy(path, master / path.name)
|
||
monkeypatch.setattr(store, "FOLDER", master)
|
||
return master
|
||
|
||
|
||
@pytest.fixture
|
||
def client(folder: Path) -> TestClient:
|
||
app = FastAPI()
|
||
app.include_router(m01_router.router)
|
||
return TestClient(app)
|
||
|
||
|
||
def _post(client: TestClient, url: str, body: dict, want: int = 200) -> dict:
|
||
res = client.post(url, json=body)
|
||
assert res.status_code == want, res.text
|
||
return res.json()
|
||
|
||
|
||
def test_이름_꼴_검사_쓰기_없음(client: TestClient, folder: Path) -> None:
|
||
before, real = _hashes(folder), _hashes(REAL)
|
||
one = client.get("/api/m01/logic", params={"key": KEY}).json()
|
||
row = one["logic"]
|
||
names = _post(client, "/api/m01/logic/sheet", {"row": row})
|
||
assert names["덧줄"][0] == TOOL_1
|
||
assert names["중간"][3] == "[합판거푸집설치및해체일당].형틀목공 / 시공량"
|
||
assert (
|
||
len(names["호표"]) == len(row["호표"])
|
||
and {"글": "시공량", "갈래": "중간"} in names["이름표"]
|
||
)
|
||
ask = lambda part, at, text: _post( # noqa: E731
|
||
client, "/api/m01/logic/sheet/check", {"row": row, "갈래": part, "차례": at, "식": text}
|
||
)
|
||
good = ask("덧줄", 0, TOOL_2)
|
||
assert good["되나"] and good["식"] == "(형틀목공 * LB000007 + 보통인부 * LB000002) * 2 / 100"
|
||
assert good["글"] == TOOL_2
|
||
assert ask("덧줄", 0, "형틀목공 × [노임 없음]")["까닭"] == ["없는 이름 「[노임 없음]」"]
|
||
below = ask("중간", 0, "시공량 × 2") # 시공량 = 아래 줄
|
||
assert not below["되나"] and "시공량" in below["까닭"][0]
|
||
assert not ask("중간", 2, "(1 +")["되나"]
|
||
assert ask("호표", 0, " ")["까닭"] == ["식이 빔"]
|
||
assert not ask("호표", 0, "1" + " + 1" * 200)["되나"] # 길이 상한
|
||
assert ask("덧줄", 9, "1")["까닭"] == ["없는 자리 「덧줄 9」"]
|
||
assert ask("호표", 0, "형틀목공 × 2")["되나"]
|
||
assert row == client.get("/api/m01/logic", params={"key": KEY}).json()["logic"] # 줄 안 바뀜
|
||
assert _hashes(folder) == before and _hashes(REAL) == real
|
||
|
||
|
||
def test_저장_길_개정_확인_다시_셈_틀린_식_422(client: TestClient, folder: Path) -> None:
|
||
real = _hashes(REAL)
|
||
one = client.get("/api/m01/logic", params={"key": KEY}).json()
|
||
row = copy.deepcopy(one["logic"])
|
||
got = line_sheet.check(row, "덧줄", 0, TOOL_2)
|
||
row["덧줄"][0]["식"] = got["식"]
|
||
body = {"files": [{"file": one["file"], "version": one["version"],
|
||
"changes": [{"op": "edit", "key": KEY, "row": row}]}]} # fmt: skip
|
||
assert client.post("/api/m01/save", json=body).status_code == 409 # 정본 = 개정 확인
|
||
_post(client, "/api/m01/save", {**body, "개정확인": True})
|
||
saved = client.get("/api/m01/logic", params={"key": KEY}).json()
|
||
assert saved["logic"]["덧줄"][0]["식"] == got["식"]
|
||
sums = _post(client, "/api/m01/calc", {"key": KEY, "inputs": INPUTS})["sums"]
|
||
assert sums["계"] == 51802 and sums["경비"] == 765 # _분석_650-H2 4 장 공구손료 2 %
|
||
bad = copy.deepcopy(saved["logic"])
|
||
bad["덧줄"][0]["식"] = "형틀목공 * 없는이름"
|
||
body = {"files": [{"file": saved["file"], "version": saved["version"],
|
||
"changes": [{"op": "edit", "key": KEY, "row": bad}]}], "개정확인": True} # fmt: skip
|
||
before = _hashes(folder)
|
||
assert client.post("/api/m01/save", json=body).status_code == 422
|
||
assert _hashes(folder) == before and _hashes(REAL) == real
|
||
|
||
|
||
def test_시스템_관리자가_아니면_403(folder: Path) -> None:
|
||
app = FastAPI()
|
||
app.include_router(
|
||
m01_router.router, dependencies=[Depends(verify_session), Depends(require_system_admin)]
|
||
)
|
||
app.dependency_overrides[verify_session] = lambda: {"role": "USER", "company_id": 1}
|
||
client = TestClient(app)
|
||
for url, body in (("/api/m01/logic/sheet", {"row": {}}),
|
||
("/api/m01/logic/sheet/check", {"row": {}, "갈래": "중간", "차례": 0, "식": "1"})): # fmt: skip
|
||
assert client.post(url, json=body).status_code == 403
|
||
|
||
|
||
# ── 모델 ─────────────────────────────────────────────────────────────
|
||
ROW = {
|
||
"키": "GX1", "원문번호": "1-1", "이름": "견본", "결과단위": "원/㎡", "출처": "견본 1-1", "비고": "한 줄\n둘째 줄",
|
||
"입력": [{"이름": "폭", "단위": "m"}],
|
||
"중간": [{"이름": "넓이", "식": "폭 * 2"}],
|
||
"호표": [{"종류": "인력", "요소": "LB000002", "이름": "보통인부", "단위": "인", "수량": "넓이 * 0.1", "비목": "노무비", "번지": "A01"}],
|
||
"덧줄": [{"이름": "공구손료", "식": "노무비 * 1 / 100", "비목": "경비"}],
|
||
"단계": [{"이름": "노무 소계", "단": 1, "줄": ["A01"]}],
|
||
"끝수": {"대상": "비목", "방법": "버림", "자리": 0},
|
||
} # fmt: skip
|
||
|
||
|
||
def _run() -> dict:
|
||
src = IMPORTS.sub("", _text(MODEL))
|
||
body = (
|
||
f"\nconst R = {json.dumps(ROW, ensure_ascii=False)};"
|
||
"\nconst now = structuredClone(R);"
|
||
"\nconst put = setLabFormula(now, '호표', 0, '넓이 * 0.2');"
|
||
"\nconst miss = setLabFormula(now, '덧줄', 5, '1');"
|
||
"\nconst answer = { ok: true, middle: { 넓이: 6 }, sums: { 노무비: 1000, 재료비: 0, 경비: 10, 계: 1010 },"
|
||
" lines: [{ 금액: 1000 }, { 금액: 10.5 }] };"
|
||
"\nconst items = labItems({ row: now, original: R, values: { 폭: '3' },"
|
||
" names: { 중간: ['폭 × 2'], 호표: ['넓이 × 0.2'], 덧줄: ['노무비 × 1 / 100'] }, answer });"
|
||
"\nconst bare = labItems({ row: R, original: null, values: {}, names: null, answer: { ok: false, reason: 'x' } });"
|
||
"\nconsole.log(JSON.stringify({ put, miss, items, bare, cut: cutText('글자 끝수') }));"
|
||
)
|
||
result = subprocess.run(
|
||
["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript", "-"],
|
||
input=src + body, capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||
) # fmt: skip
|
||
assert result.returncode == 0, result.stderr
|
||
return json.loads(result.stdout)
|
||
|
||
|
||
def test_모델_줄_갈래_값_고친_줄() -> None:
|
||
got = _run()
|
||
assert got["put"] is True and got["miss"] is False
|
||
kinds = [(x["kind"], x["이름"]) for x in got["items"]]
|
||
assert kinds == [("근거", "1-1"), ("근거", ""), ("근거", ""), ("옵션", "폭"), ("계산", "넓이"),
|
||
("자원", "보통인부 (인)"), ("율", "공구손료"), ("단계", "노무 소계"), ("근거", "끝수"),
|
||
("소계", "노무비"), ("소계", "재료비"), ("소계", "경비"), ("합계", "계")] # fmt: skip
|
||
by = {(x["kind"], x["이름"]): x for x in got["items"]}
|
||
assert by[("옵션", "폭")]["값"] == "3"
|
||
assert by[("계산", "넓이")]["값"] == 6 and not by[("계산", "넓이")]["changed"]
|
||
res = by[("자원", "보통인부 (인)")]
|
||
assert res["값"] == 1000 and res["비목"] == "노" and res["번지"] == "A01" and res["changed"]
|
||
assert res["식"] == "넓이 × 0.2" and res["part"] == "호표" and res["at"] == 0
|
||
assert by[("율", "공구손료")]["값"] == 10.5 and by[("율", "공구손료")]["part"] == "덧줄"
|
||
assert by[("단계", "노무 소계")]["식"] == "A01"
|
||
assert by[("근거", "끝수")]["식"] == "비목 · 버림 · 0 자리"
|
||
assert by[("합계", "계")]["값"] == 1010
|
||
bare = {(x["kind"], x["이름"]): x for x in got["bare"]}
|
||
assert bare[("계산", "넓이")]["식"] == "폭 × 2" # 이름 꼴 전 = 엔진 식 「*」 → 「×」
|
||
assert bare[("자원", "보통인부 (인)")]["값"] is None and ("합계", "계") not in bare
|
||
assert got["cut"] == "글자 끝수"
|