Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_8

This commit is contained in:
2026-10-04 17:45:16 +09:00
14 changed files with 839 additions and 23 deletions
@@ -0,0 +1,21 @@
/* M02 일위대가 수량 · 반영 · 합(`M02_MasterTemplete_Labor_Calc.ts` `settle`) 거울 헬퍼 —
* 그 자리에서 트랜스파일해 Node 로 돌림 · stdin = {답, 문서, 값} · stdout = settle 결과 JSON. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const file = path.join(
__dirname,
"..",
"..",
"M02_MasterTemplete",
"M02_MasterTemplete_Labor_Calc.ts",
);
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, require);
const input = JSON.parse(fs.readFileSync(0, "utf8"));
process.stdout.write(JSON.stringify(box.exports.settle(input.답, input.문서, input.값)));
+3 -3
View File
@@ -26,7 +26,7 @@ from resources.tester.test_m02_structure import write_sample_table
from resources.tester.test_m02_tree import Tree, _app
ROOT = Path(__file__).resolve().parents[2]
NEW = re.compile(r"^S[DAM]-\d{6}$")
NEW = re.compile(r"^S[DAMU]-\d{6}$")
@pytest.fixture()
@@ -164,7 +164,7 @@ def test_새_번호는_옛_번호와_모양이_달라_안_겹침(world):
doc = json.loads(path.read_bytes())
path.write_text(json.dumps({**doc, key: f"{head}{code}"}), encoding="utf-8")
old = {cid: _numbers(system, cid) for cid in store._column_ids()}
assert old["rv"] == {"SD01010100", "SA01010100", "SM01010100"}
assert old["rv"] == {"SD01010100", "SU01010100", "SA01010100", "SM01010100"}
first = max(int(n[3:]) for nums in old.values() for n in nums if NEW.match(n)) + 1
with store.at(system):
got = store.read("table", store.STRUCTURE_TABLE)
@@ -191,7 +191,7 @@ def test_새_번호는_옛_번호와_모양이_달라_안_겹침(world):
got["문서"]["열"].append({"id": "y", "머리": ["y"], "꼴": "수"})
store.write("table", store.STRUCTURE_TABLE, got["판"], got["문서"])
last = f"-{first + 120:06d}" # 지운 x119 번호(first + 119) 다음
assert _numbers(system, "y") == {f"SD{last}", f"SA{last}", f"SM{last}"}
assert _numbers(system, "y") == {f"SD{last}", f"SU{last}", f"SA{last}", f"SM{last}"}
def test_층마다_번호는_따로_파일은_열_id_로(world):
@@ -0,0 +1,71 @@
"""일위대가 수량 · 반영 · 합 거울 시험 — `M02_Labor_Doc.settle`(파이썬) ↔
`M02_MasterTemplete_Labor_Calc.ts` `settle`(TS · Node) 가 같은 답(CLAUDE.md 5장 ①).
기준은 서버가 한 번 셈(`calc`) · 그 답에 다른 알약 값을 넣어 둘이 같은지 봄.
"""
import json
import os
import subprocess
import pytest
from M02_MasterTemplete import M02_Labor_Doc as labor
HERE = os.path.dirname(os.path.abspath(__file__))
TEN, THOUSAND, PLAIN, MAT = "GC000421", "GF000017", "GC000422", "MT000008"
def _ts(answer: dict, doc: dict, values: dict) -> dict:
proc = subprocess.run(
["node", os.path.join(HERE, "helper_m02_labor_calc.cjs")],
input=json.dumps({"답": answer, "문서": doc, "값": values}, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(proc.stdout)
def _same(a, b) -> None:
if isinstance(a, dict):
assert set(a) == set(b)
for k in a:
_same(a[k], b[k])
elif isinstance(a, list):
assert len(a) == len(b)
for x, y in zip(a, b):
_same(x, y)
elif isinstance(a, (int, float)) and not isinstance(a, bool):
assert b == pytest.approx(a, rel=1e-9)
else:
assert a == b
@pytest.mark.parametrize(
"values",
[
{}, # 수량 없음 — 반영 null · 손만
{"사석 수상고르기|㎡": 2.5, f"자재|{MAT}": "3"},
{"사석 수상고르기|㎡": "1,200", "나무식재|본": 7, f"자재|{MAT}": "글"}, # 쉼표 · 수 아님
],
)
def test_파이썬과_TS_가_같은_답(values):
doc = labor.clean(
{
"열": "rv",
"담은로직": [
{"로직": TEN, "수량": None},
{"로직": THOUSAND, "수량": {"손": 4}},
{"로직": PLAIN, "수량": None},
],
"자재": [{"코드": MAT, "수량": None}],
}
)
answer = labor.calc(doc, {})
keys = {line["알약"] for line in answer["줄"]}
assert {"사석 수상고르기|㎡", "나무식재|본"} <= keys # 알약 키가 시험 값과 맞음
py = labor.settle(answer, doc, values)
_same(py, _ts(answer, doc, values))
assert py == labor.calc(doc, values) # settle 만 다시 = 처음부터 다시
+213
View File
@@ -0,0 +1,213 @@
"""일위대가 문서(`labor/<열id>.json` · PLAN 39-1a) — 정리 · 검사 · 계산 · 알약 · 저장 때 계산값.
로직 · 자재는 M01 마스터(읽기만) · 층은 견본 표(`write_sample_table`)를 임시 폴더에.
"""
import json
import pytest
from M01_MasterData import M01_MasterData_Store_Combo as combo_store
from M02_MasterTemplete import M02_Labor_Doc as labor
from M02_MasterTemplete import M02_MasterTemplete_Store as store
from resources.tester.test_m02_structure import write_sample_table
TEN = "GC000421" # 원/10㎡ — 배 10
THOUSAND = "GF000017" # 원/1,000본 — 배 1000
PLAIN = "GC000422" # ㎡/시간 — 돈 아닌 로직
STONE = "GF000219" # 원/㎡ · 입력 뒷길이 · 돌 · 쌓기 · 높이 · 초과증가율
MAT = "MT000008" # 자재품목 · 물가정보 83000
def _doc(**extra):
return {"열": "rv", "담은로직": [], "자재": [], **extra}
def test_정리_모르는_입력_빈_값_버림_자재_칸은_M01_것():
got = labor.clean(
_doc(
담은로직=[
{"로직": STONE, "입력값": {"돌": "깬돌", "모름": 1, "높이": ""}, "수량": None}
],
자재=[{"코드": MAT, "이름": "아무 이름", "수량": {"손": "2"}}],
단위="개소",
)
)
assert got["담은로직"] == [
{"로직": STONE, "메모": None, "입력값": {"돌": "깬돌"}, "수량": None}
]
assert got["자재"][0]["이름"] == "치 즐(0.2㎥)" and got["자재"][0]["수량"] == {"손": 2}
assert (got["종류"], got["단위"], got["본뜬"]) == ("일위대가", "개소", None)
@pytest.mark.parametrize(
"bad",
[
_doc(담은로직=[{"로직": STONE}, {"로직": STONE}]), # 같은 로직 두 번
_doc(담은로직=[{"로직": "GF999999"}]), # 없는 로직
_doc(담은로직=[{"로직": STONE, "수량": {"손": "열"}}]), # 수 아님
_doc(담은로직=[{"로직": STONE, "수량": 3}]), # 꼴 틀림
_doc(자재=[{"코드": "LB000001"}]), # 자재품목 아님
_doc(자재=[{"코드": MAT}, {"코드": MAT}]), # 같은 자재 두 번
"문서 아님",
],
)
def test_틀린_문서는_400(bad):
with pytest.raises(store.StoreError) as e:
labor.clean(bad)
assert e.value.status == 400
def test_빈_일위대가도_됨():
got = labor.server(_doc())
assert got["계산값"]["기준합"]["계"] == 0 and got["계산값"]["반영합"]["계"] == 0
def test_PER_천_단위_쉼표는_배():
pills = combo_store.combo_pills({"담은로직": [{"로직": THOUSAND}, {"로직": TEN}]})["물량"]
assert {p["단위"]: p.get("배") for p in pills} == {"본": 1000, "㎡": 10}
def test_반영은_기준_곱_수량_나누기_배_돈_아닌_로직은_반영_없음():
doc = labor.clean(
_doc(담은로직=[{"로직": TEN, "수량": {"손": 4}}, {"로직": PLAIN, "수량": None}])
)
got = labor.calc(doc, {})
ten, plain = got["줄"]
assert (ten["배"], ten["수량"], ten["수량출처"]) == (10, 4, "손")
for k in ("노무비", "재료비", "경비", "계"):
assert ten["반영"][k] == pytest.approx(ten["기준"][k] * 4 / 10)
assert plain["기준"] is None and plain["반영"] is None and "결과" in plain
assert got["반영합"]["계"] == pytest.approx(ten["반영"]["계"])
def test_수량_손이_칸보다_앞섬_칸_값은_알약_키로():
doc = labor.clean(
_doc(
담은로직=[{"로직": TEN, "수량": None}, {"로직": THOUSAND, "수량": {"손": 1}}],
자재=[{"코드": MAT, "수량": None}],
)
)
keys = {p["키"] for p in labor.pills(doc)}
ten_key = next(k for k in keys if k.endswith("|㎡"))
thousand_key = next(k for k in keys if k.endswith("|본"))
assert f"자재|{MAT}" in keys
got = labor.calc(doc, {ten_key: "2.5", thousand_key: 99, f"자재|{MAT}": "3"})
ten, thousand = got["줄"]
assert (ten["수량"], ten["수량출처"]) == (2.5, "칸")
assert (thousand["수량"], thousand["수량출처"]) == (1, "손")
mat = got["자재"][0]
assert (mat["기준단가"], mat["수량"], mat["반영"]) == (83000, 3, 249000)
nothing = labor.calc(doc, {})["줄"][0]
assert (nothing["수량출처"], nothing["반영"]) == ("없음", None)
def test_면적_값_식_칸은_계산값_그_밖은_칸_값():
area = {
"시트": [
{
"id": "s1",
"칸": {"B6": {"값": "1,200"}, "C1": {"값": "글"}},
"연결": [{"키": "a", "칸": "B6"}, {"키": "b", "칸": "D2"}, {"키": "c", "칸": "C1"}],
}
],
"계산값": {"s1": {"D2": {"수": "7.5"}}},
}
got = labor.area_values(area)
assert {k: float(v) for k, v in got.items()} == {"a": 1200, "b": 7.5}
@pytest.fixture()
def system(tmp_path, monkeypatch):
write_sample_table(tmp_path)
monkeypatch.setattr(store, "FOLDER", tmp_path)
store.create_structure("rv")
return tmp_path
def test_구조물_만들면_빈_일위대가_같은_뒷자리(system):
doc = store.read("labor", "rv")["문서"]
assert doc["일위대가번"] == "SU" + store.read("structure", "rv")["문서"]["도번"][2:]
assert doc["담은로직"] == [] and doc["자재"] == []
def test_저장_때_서버가_계산값_번호는_그대로_면적_저장이_계산값_새로(system):
have = store.read("labor", "rv")
sent = {
**have["문서"],
"일위대가번": "SU-999999",
"담은로직": [{"로직": TEN, "수량": None}],
"계산값": {"브라우저": "값"},
}
store.write("labor", "rv", have["판"], sent)
doc = store.read("labor", "rv")["문서"]
assert doc["일위대가번"] == have["문서"]["일위대가번"]
assert doc["계산값"]["줄"][0]["수량출처"] == "없음"
pill = labor.pills(doc)[0]["키"]
area = store.read("area", "rv")
sheet = area["문서"]["시트"][0]
sheet["칸"] = {"B6": {"값": "5"}}
sheet["연결"] = [{"키": pill, "이름": "x", "칸": "B6", "단위": "㎡"}]
store.write("area", "rv", area["판"], area["문서"])
line = store.read("labor", "rv")["문서"]["계산값"]["줄"][0]
assert (line["수량"], line["수량출처"]) == (5, "칸")
assert line["반영"]["계"] == pytest.approx(line["기준"]["계"] * 5 / 10)
def test_틀린_일위대가_저장은_400_파일_그대로(system):
have = store.read("labor", "rv")
with pytest.raises(store.StoreError) as e:
store.write("labor", "rv", have["판"], {**have["문서"], "담은로직": [{"로직": "GF999999"}]})
assert e.value.status == 400
assert json.loads((system / "labor" / "rv.json").read_bytes())["담은로직"] == []
# ── 길(`M02_Labor_Router`) ─────────────────────────────────────────────
@pytest.fixture()
def client(tmp_path, monkeypatch):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from common_util.common_util_auth import verify_session
from config import config_system
from M02_MasterTemplete import M02_Labor_Router as labor_router
from M02_MasterTemplete import M02_Template_Layers as layers
system = tmp_path / "system"
system.mkdir()
write_sample_table(system)
monkeypatch.setattr(store, "FOLDER", system)
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
(tmp_path / "storage").mkdir()
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage"))
with store.at(system):
store.create_structure("rv")
app = FastAPI()
app.include_router(labor_router.router)
app.dependency_overrides[verify_session] = lambda: {
"user_id": 42,
"company_id": 7,
"role": "USER",
"is_master": False,
}
return TestClient(app)
def test_길_calc_는_저장_안_한_문서와_알약_값으로_셈_알약도_같이(client):
url = "/api/m02/layers/system/labor/rv/calc"
sent = {"담은로직": [{"로직": TEN, "수량": None}], "자재": [{"코드": MAT, "수량": {"손": 1}}]}
got = client.post(url, json={"doc": sent, "수량": {"사석 수상고르기|㎡": 20}}).json()
assert (got["줄"][0]["수량출처"], got["자재"][0]["반영"]) == ("칸", 83000)
assert {p["키"] for p in got["알약"]} == {"사석 수상고르기|㎡", f"자재|{MAT}"}
saved = client.post(url, json={}).json() # 저장본 = 빈 일위대가
assert saved["줄"] == [] and saved["알약"] == []
assert client.get("/api/m02/layers/system/labor/rv/pills").json() == {"열": "rv", "알약": []}
def test_길_틀린_문서_400_없는_열_404_남의_개인_층_403(client):
url = "/api/m02/layers/{}/labor/{}/calc"
bad = {"doc": {"담은로직": [{"로직": "GF999999"}]}}
assert client.post(url.format("system", "rv"), json=bad).status_code == 400
assert client.post(url.format("system", "없는열"), json={}).status_code == 404
other = client.post(url.format("personal", "rv") + "?owner=7/99", json={})
assert other.status_code == 403
+7 -2
View File
@@ -64,8 +64,13 @@ def client(tmp_path, monkeypatch):
def _num(code: str) -> dict:
"""세 번호 — 같은 8자리."""
return {"도번": f"SD{code}", "면적번": f"SA{code}", "재료번": f"SM{code}"}
"""열 번호 — 같은 뒷자리(일위대가번 SU · 39장)."""
return {
"도번": f"SD{code}",
"일위대가번": f"SU{code}",
"면적번": f"SA{code}",
"재료번": f"SM{code}",
}
def test_새로_도번_자동_409_없는_열(client):
+3 -1
View File
@@ -86,7 +86,9 @@ def test_집계표_저장_때_새_열_지운_열_고친_열을_맞춤_키_유지
assert (rv["바인딩"], rv["펼침"]) == ({"종류": "revetment"}, True)
# 번호는 세 파일과 같음 — 새 열도 [저장] 한 번에 SD · SA · SM 이 섬
numbers = client.get("/api/m02/structures/numbers").json()
assert {k: rows["new1"][k] for k in ("도번", "면적번", "재료번")} == numbers["new1"]
assert {k: rows["new1"][k] for k in ("도번", "일위대가번", "면적번", "재료번")} == numbers[
"new1"
]
assert rows["new1"]["도번"].startswith("SD")
# 또 저장해도(바뀐 것 없음) 키 · 다음 그대로