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

This commit is contained in:
2026-10-31 15:24:17 +09:00
42 changed files with 2497 additions and 41 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
/* M02 일위대가 수량 · 반영 · 합(`M02_MasterTemplete_Labor_Calc.ts` `settle`) 거울 헬퍼 —
* 그 자리에서 트랜스파일해 Node 로 돌림 · stdin = {답, 문서, 값} · stdout = settle 결과 JSON. */
* 그 자리에서 트랜스파일해 Node 로 돌림 · stdin = {답, 문서, 값, 열?} · stdout = settle 결과 JSON. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
@@ -18,4 +18,4 @@ 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.값)));
process.stdout.write(JSON.stringify(box.exports.settle(input.답, input.문서, input.값, input.열)));
@@ -202,9 +202,9 @@ def test_셸_배선():
) # 239-1 · 242-1 이동 줄 빨강 곁(고름과 한때 하나)
assert "if (level) bars.setTools(level.tools);" in src # 229-1 단추 = 보조 탭 줄
assert (
"panel.replaceChildren(...banner, table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : []))"
in src
) # 203 레벨 안내 + 탭 안내 = 한 영역
"const body = [table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : [])];" in src
and "panel.replaceChildren(...banner, ...(row ? [row] : body));" in src
) # 203 레벨 안내 + 탭 안내 = 한 영역 · 703-1 창고 있으면 표 칸 + 오른쪽 서랍
lvl = (B09 / "B09_Estimation_UI_Tabs_Bill_Level.ts").read_text("utf-8")
assert "table.menu(index)" in lvl # 단추 · 키 = 우클릭 메뉴 같은 함수
assert "b09-estimation__level" not in lvl
@@ -106,7 +106,9 @@ def test_묶음_탭_단추가_보조_줄에_안내는_아래에():
def test_잇기_쉘_안내_줄_아래_도구_비움():
shell = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Shell.ts").read_text("utf-8")
assert (
"panel.replaceChildren(...banner, table.root, ...(foot ? [foot] : []))" in shell # 701-3 띠 = 표 위
"const body = [table.root, ...(fold ? [fold] : []), ...(foot ? [foot] : [])];" in shell
and "panel.replaceChildren(...banner, ...(row ? [row] : body));"
in shell # 701-3 띠 = 표 위
) # 229-1 레벨 단추 = 보조 탭 줄 · 안내 글 = 표 아래 · 203 레벨 안내 + 탭 안내 = 한 영역(맨 아래)
assert "...(hint ? [hint] : []), table.root" not in shell # 위 자리 없음
assert re.search(r"bars\.draw\(tab\);\s*bars\.setTools\(\[\]\);", shell)
+147
View File
@@ -0,0 +1,147 @@
"""703-1 B09 재료 창고 「지난 공사」 — 같은 회사 다른 공사 일위대가 → 이 공사 서고 사본(덮개째).
- 목록 = 그 공사 서고 사본 · 구조물 사본 · 고친 칸 수(그 공사 저장 덮개) · 같은 것 한 줄
- 가져옴 = 사본 폴더 통째 → `library/…~해시` · 가져온곳 · 덮개 = 그 공사 칸 · 다시 = 같은 사본
- 새 호표(`libs`) · 같은 꼴 블록이 이 공사에 먼저 있으면 그 블록 cover
- 모양 틀림 400 · 없음 404 · 다른 회사 403 · 이 공사 400
임시 층(견본 마스터 · 임시 storage) · 실제 마스터 · 프로젝트는 안 씀.
"""
import asyncio
import json
import sys
from pathlib import Path
import pytest
from fastapi import HTTPException
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B09_Estimation import B09_Estimation_Bill_Library as library # noqa: E402
from B09_Estimation import B09_Estimation_Past as past # noqa: E402
from B09_Estimation import B09_Estimation_Router as router # noqa: E402
from B09_Estimation import B09_Estimation_Store as store_b09 # noqa: E402
from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs # noqa: E402
from config import config_system # noqa: E402
from M02_MasterTemplete import M02_Labor_Ref as refs # noqa: E402
from M02_MasterTemplete import M02_MasterTemplete_Store as store # noqa: E402
from M02_MasterTemplete import M02_Template_Layers as layers # noqa: E402
from resources.tester.helper_m02_master_sample import copy_master_sample # noqa: E402
STONE = "GF000219" # 돌쌓기 메쌓기 원/㎡
@pytest.fixture()
def world(tmp_path, monkeypatch):
system = copy_master_sample(tmp_path / "master")
monkeypatch.setattr(store, "FOLDER", system)
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage"))
with store.at(system):
key = refs.create(
"precombo",
{"이름": "지난 조합", "단위": "㎡", "담은로직": [{"로직": STONE, "수량": {"손": 2}}]},
)["이름"]
a, b = (tmp_path / "storage" / "7" / "1" / n for n in ("past", "here"))
a.mkdir(parents=True)
b.mkdir(parents=True)
folder = library.import_copy(a, "precombo", "system", None, key)["폴더"]
ref = {"층": "system", "주인": None, "키": key}
cover = {
f"C:{refs.ref_id(ref)}": {"sangeun:GF#0": {"qty": 5}},
"K:x": {"@copy": {"from": "S:y"}},
}
store_b09.write_user(a, {"daega_cover": cover})
return {"key": key, "a": a, "b": b, "folder": folder}
def test_목록_서고_사본_고친_칸_수_같은_것_한_줄(world):
a = world["a"]
rows = past.rows_of(a)
assert [(r["cover"], r["name"], r["unit"], r["kind"], r["covers"]) for r in rows] == [
(f"L:{world['folder']}", "지난 조합", "㎡", "사전조합", 1)
]
# 같은 원본 · 같은 값 · 덮개 없음 = 한 줄로 셈(값 해시 다른 사본)
library.import_copy(a, "precombo", "system", None, world["key"], haul={"given": {}})
assert len(past.rows_of(a)) == 1
def test_가져옴_사본_통째_덮개째_새_호표_다시는_같은_사본(world):
a, b, folder = world["a"], world["b"], world["folder"]
ref = {"project_id": "P-A", "프로젝트": "지난"}
made = past.import_past(b, a, ref, f"L:{folder}", {"이름": "나"})
name = made["폴더"]
assert made["만듦"] and made["덮개"] == {"sangeun:GF#0": {"qty": 5}}
assert name.startswith(f"precombo@system@-@{world['key']}~") and library.NAME.match(name)
copy = library.library_dir(b) / name
src = library.library_dir(a) / folder
for f in ("일위대가.json", "단가.json"):
assert (copy / f).read_bytes() == (src / f).read_bytes()
manifest = json.loads((copy / "manifest.json").read_text("utf-8"))
assert manifest["가져온곳"] == {**ref, "사본": f"L:{folder}"} and manifest["종류"] == "precombo"
got = daega_inputs(b)
bid = got["libs"][name]
block = next(x for x in got["blocks"] if x["id"] == bid)
# 사전조합 덮개 = 참조(공사 안 같은 사전조합 한 덮개) — 덮개자리 = 그 cover
assert (block["name"], block["cover"]) == ("지난 조합", made["덮개자리"])
assert made["덮개자리"] == "C:참조|system||" + world["key"]
again = past.import_past(b, a, ref, f"L:{folder}")
assert (again["폴더"], again["만듦"]) == (name, False)
def test_같은_꼴_블록이_먼저_있으면_그_cover(world):
a, b = world["a"], world["b"]
mine = library.import_copy(b, "precombo", "system", None, world["key"])["폴더"]
name = past.import_past(b, a, {"project_id": "P-A"}, f"L:{world['folder']}")["폴더"]
got = daega_inputs(b)
assert got["libs"][name] == got["libs"][mine]
block = next(x for x in got["blocks"] if x["id"] == got["libs"][name])
assert block["cover"].startswith("C:") # 사전조합 = 같은 참조 한 덮개(화면이 빈 때만 얹음)
def test_구조물조합_사본_덮개자리_새_폴더(world):
a, b = world["a"], world["b"]
folder = library.import_copy(a, "combo", "system", None, "SS000011")["폴더"]
store_b09.write_user(a, {"daega_cover": {f"L:{folder}": {"x#0": {"qty": 2}}}})
row = next(r for r in past.rows_of(a) if r["cover"] == f"L:{folder}")
assert (row["kind"], row["covers"]) == ("구조물 조합", 1)
made = past.import_past(b, a, {"project_id": "P-A"}, f"L:{folder}")
assert made["덮개자리"] == f"L:{made['폴더']}" and made["덮개"] == {"x#0": {"qty": 2}}
got = daega_inputs(b)
block = next(x for x in got["blocks"] if x["id"] == got["libs"][made["폴더"]])
assert block["cover"] == made["덮개자리"]
def test_모양_틀림_없음(world):
a, b = world["a"], world["b"]
for bad in ("L:../x", "X:y", "S:../../z"):
with pytest.raises(ValueError):
past.import_past(b, a, {}, bad)
with pytest.raises(LookupError):
past.import_past(b, a, {}, "S:nope")
with pytest.raises(LookupError):
past.import_past(b, a, {}, "L:precombo@system@-@SC-000999")
def test_길_다른_회사_403_이_공사_400(world, monkeypatch):
rows = {
"here": {"company_id": 7, "root": world["b"]},
"far": {"company_id": 8, "root": world["a"]},
}
async def project(_session, pid):
return {"name": pid, **rows["here" if pid.startswith("0") else "far"]}
monkeypatch.setattr(router, "_project", project)
pid = router.UUID("00000000-0000-0000-0000-000000000001")
body = router.PastBody(project_id="far", cover=f"L:{world['folder']}")
with pytest.raises(HTTPException) as e:
asyncio.run(router.past_import(pid, body, {"user_id": 1}))
assert e.value.status_code == 403
same = router.PastBody(project_id=str(pid), cover="L:x")
with pytest.raises(HTTPException) as e:
asyncio.run(router.past_import(pid, same, {"user_id": 1}))
assert e.value.status_code == 400
assert not library.library_dir(world["b"]).exists() # 아무것도 안 씀
+2 -1
View File
@@ -95,6 +95,7 @@ CALC = {"줄": [{"로직": "GF1", "분모": "㎡", "배": 10, "수량": 2.09,
def test_M02_문서_calc_를_격자_줄로():
files = [GRID, "M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts",
"M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts",
"M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts"] # fmt: skip
logics = [
["GF1", {"이름": "돌쌓기 찰쌓기", "입력": [], "결과단위": "㎡"}],
@@ -134,7 +135,7 @@ def test_M02_문서_calc_를_격자_줄로():
def test_세_곳_같은_부품():
detail = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Detail.ts").read_text("utf-8")
assert "laborGrid(cur, calc ?? last, logics)" in detail # 카드 · 상세 = 같은 Labor_Detail
assert "laborGrid(cur, calc ?? last, logics, " in detail # 카드 · 상세 = 같은 Labor_Detail
m02 = (ROOT / "M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts").read_text("utf-8")
b09 = (ROOT / "B09_Estimation/B09_Estimation_UI_Tabs_Labor.ts").read_text("utf-8")
assert '"@ui/ui_template_hopyo_grid"' in m02 and '"@ui/ui_template_hopyo_grid"' in b09
+249
View File
@@ -0,0 +1,249 @@
"""704-4 규격 열 — 설계 고르기 = 열(summary_item `spec`) · 사본이 고른 열을 굳힘 ·
열 바꿈 뒤 B09 덮개 남음 · B07 한 장 판정 = 항목 + 열 · 열 지움 막음(쓰는 사본).
임시 저장소(실제 마스터 · storage 안 씀 · M01 읽기만).
"""
from __future__ import annotations
import json
import shutil
from pathlib import Path
from typing import Any
import pytest
from B07_DesignDetail.B07_DesignDetail_Router_Support_Structure import structure_sheets
from B09_Estimation.B09_Estimation_Calc_Labor import compute_labor
from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs
from common_util.common_util_summary_item import parse_summary_item
from config import config_system
from M02_MasterTemplete import M02_Item_Snapshot as snap
from M02_MasterTemplete import M02_Labor_Spec as spec
from M02_MasterTemplete import M02_Template_Layers as layers
from resources.tester.helper_m02_master_sample import SAMPLE
MS = {"layer": "system", "owner": None, "key": "SS000022", "name": "돌쌓기"}
SPEC = {
"이름": "H",
"열": ["1.5", "2.5"],
"기본": "1.5",
"수량": {"GF000219": {"1.5": 2, "2.5": 3}},
}
LABOR = {
"종류": "일위대가",
"열": "ms",
"단위": "m",
"담은로직": [
{"로직": "GF000219", "수량": {"손": 2}}, # 메쌓기 — 열마다 수량
{"로직": "GF000119", "수량": {"손": 0.5}}, # 노체포설 — 공통
],
"자재": [],
"규격": SPEC,
}
# ── summary_item spec ──────────────────────────────────────────────────────
def test_summary_item_spec_있음_없음():
assert parse_summary_item({**MS, "spec": " 2.5 "}) == {**MS, "spec": "2.5"}
assert parse_summary_item(MS) == MS # 옛 기록 = 칸 없음 그대로
with pytest.raises(ValueError, match="모르는 칸"):
parse_summary_item({**MS, "열": "2.5"})
for bad in ("", "x" * 21, 2.5):
with pytest.raises(ValueError, match="spec"):
parse_summary_item({**MS, "spec": bad})
# ── 굳히기 ─────────────────────────────────────────────────────────────────
def test_굳히기_고른_열_지운_열_옛_문서():
got = spec.freeze({**LABOR, "계산값": {"x": 1}}, "2.5")
assert got["담은로직"][0]["수량"] == {"손": 3} and got["담은로직"][1]["수량"] == {"손": 0.5}
assert got["규격"] == {"이름": "H", "열": ["2.5"], "기본": "2.5", "수량": {}}
assert "계산값" not in got
assert spec.freeze(LABOR, "지운열")["담은로직"][0]["수량"] == {"손": 2} # = 기본
old = {k: v for k, v in LABOR.items() if k != "규격"}
assert spec.freeze(old, "2.5") is old
# ── 임시 저장소 사본 ───────────────────────────────────────────────────────
@pytest.fixture
def world(tmp_path: Path, monkeypatch) -> dict[str, Any]:
system = tmp_path / "master"
system.mkdir()
shutil.copy2(SAMPLE / layers.COMBO_FILE, system / layers.COMBO_FILE)
for kind in ("table", "structure", "area"):
shutil.copytree(SAMPLE / kind, system / kind)
(system / "labor").mkdir()
(system / "labor" / "ms.json").write_text(json.dumps(LABOR), encoding="utf-8")
monkeypatch.setattr(layers, "SYSTEM_ROOT", system)
storage = tmp_path / "storage"
monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage))
root = storage / "3" / "12" / "proj"
(root / "B05_Profile/route").mkdir(parents=True)
(root / "B04_PreProcess/drainage/edits").mkdir(parents=True)
return {"system": system, "root": root}
def _save(root: Path, specs: list[str | None]) -> dict[str, list[str]]:
rows = [
{
"structure_id": f"st{n}",
"type_id": "masonry_dry",
"placement": "interval",
"chainage_m": 85 + n * 100,
"start_m": 80 + n * 100,
"end_m": 90 + n * 100,
"options": {"height_m": 2.0, "length_m": 10},
"summary_item": {**MS, **({"spec": s} if s else {})},
}
for n, s in enumerate(specs)
]
(root / "B05_Profile/route/structures.json").write_text(
json.dumps({"revision": 1, "structures": rows}, ensure_ascii=False), encoding="utf-8"
)
(root / "B04_PreProcess/drainage/edits/pipe_points.json").write_text(
'{"points": []}', encoding="utf-8"
)
return snap.sync_snapshots(root)
def _block(root: Path, owner: str) -> tuple[dict, dict]:
got = daega_inputs(root)
return got, next(b for b in got["blocks"] if b["id"] == got["owners"][owner])
def test_사본이_고른_열을_굳힘_manifest_규격(world):
root = world["root"]
assert _save(root, ["2.5"])["만듦"] == ["st0"]
folder = snap.structures_dir(root) / "st0"
doc = json.loads((folder / "일위대가.json").read_text(encoding="utf-8"))["줄"]
assert [x["수량"] for x in doc["담은로직"]] == [{"손": 3}, {"손": 0.5}]
manifest = json.loads((folder / snap.MANIFEST_NAME).read_text(encoding="utf-8"))
assert manifest["규격"] == {"이름": "H", "열": "2.5"} and manifest["원본"]["spec"] == "2.5"
assert _save(root, ["2.5"])["만듦"] == [] # 같은 항목 · 같은 열 = 그대로
def test_열_바꿈_뒤_덮개_남음(world):
root = world["root"]
_save(root, ["1.5"])
_, before = _block(root, "st0")
cids = [ln["cid"] for ln in before["lines"]]
assert before["cover"] == "S:st0" and [ln["qty"] for ln in before["lines"]] == [2, 0.5]
cover = {before["cover"]: {cids[1]: {"qty": 0.7}}} # 공통 줄 품을 고침
got = _save(root, ["2.5"])
assert got["지움"] == ["st0"] and got["만듦"] == ["st0"] # 열 바꿈 = 새 사본
after_all, after = _block(root, "st0")
assert after["cover"] == before["cover"] and [ln["cid"] for ln in after["lines"]] == cids
sheets = {"labor": [], "material": [], "machine": [], "gaps": []}
out = compute_labor(after_all, sheets, {}, cover)
lines = next(b for b in out["daega_blocks"] if b["id"] == after["id"])["lines"]
assert lines[0]["qty"] == 3 # 새 열 수량
assert lines[1]["qty"] == 0.7 and lines[1]["covered"] == ["qty"] # 덮개 그대로
assert out["daega_orphans"] == []
def test_B07_한_장_판정_항목_더하기_열(world):
root = world["root"]
_save(root, ["1.5", "1.5", "2.5"])
ids = [s["id"] for s in structure_sheets(root) if s["id"].startswith("structure_system")]
assert len(set(ids)) == 2 # 같은 열 둘 = 한 장 · 다른 열 = 따로
def test_열_지움_쓰는_사본_찾기(world):
root = world["root"]
_save(root, ["2.5", None])
assert spec.users(world["system"], {"SS000022"}, {"2.5"}) == ["proj/st0"]
assert spec.users(world["system"], {"SS000022"}, {"1.5"}) == []
assert spec.users(world["system"], {"SS000001"}, {"2.5"}) == []
def test_쓰는_열_지움_409_안_쓰는_열_됨(world):
from M02_MasterTemplete import M02_MasterTemplete_Store as store
root = world["root"]
_save(root, ["2.5"])
path = world["system"] / "labor" / "ms.json"
with store.at(world["system"]):
with pytest.raises(store.StoreError) as got:
spec.guard(path, {"규격": {**SPEC, "열": ["1.5"]}}, "ms")
assert got.value.status == 409 and "proj/st0" in str(got.value.detail)
spec.guard(path, {"규격": {**SPEC, "열": ["2.5", "3.0"]}}, "ms") # 1.5 = 안 씀
spec.guard(path, {"규격": SPEC}, "ms")
# ── 화면 자료(TS · Node) — 격자 규격 열 · 고르기 창 spec ─────────────────────
def test_격자_규격_열_수량_칸_열마다():
from resources.tester import test_704_3_hopyo_grid as g
files = [g.GRID, "M02_MasterTemplete/M02_MasterTemplete_Labor_Calc.ts",
"M02_MasterTemplete/M02_MasterTemplete_Labor_Spec.ts",
"M02_MasterTemplete/M02_MasterTemplete_Labor_Grid.ts"] # fmt: skip
doc = {**g.DOC, "규격": {"이름": "H", "열": ["1.5", "2.0"], "기본": "2.0",
"수량": {"GF1": {"1.5": 1.5, "2.0": 2.09}, "MT1": {"1.5": 4}}}} # fmt: skip
doc["자재"] = [{**doc["자재"][0], "수량": {"손": 5}}, {**doc["자재"][1], "수량": {"손": 2}}]
out = g._node(files, (
f"\nconst b = laborBlock({json.dumps(doc, ensure_ascii=False)}, {json.dumps(g.CALC, ensure_ascii=False)}, new Map());"
"\nconst grid = hopyoGrid([b]);"
"\nconsole.log(JSON.stringify({ cols: grid.columns.map((c) => [c.key, c.label, c.group]),"
" rows: grid.rows, specs: b.specs }));"
), g.STUBS) # fmt: skip
cols = [c for c in out["cols"] if c[0].startswith("qty")]
assert cols == [["qty_0", "1.5", ["H 열"]], ["qty_1", "2.0 (기본)", ["H 열"]]]
line = out["rows"][1] # 첫 구성 줄
assert line["qty_0"] == 1.5 and line["qty_1"] == 2.09
mt1 = next(r for r in out["rows"] if r["name"] == "ㄴ 큰돌")
assert mt1["qty_0"] == 4 and mt1["qty_1"] == 5 # 2.0 빈 열 = 공통 수량
t15, t20 = out["specs"]["totals"]
assert t20 == pytest.approx(112559.55) # 기본 열 = 금액 칸 합
assert t15 == pytest.approx(529950 * 1.5 / 10 + 1800) # 열 수량 · 별도계상 MT1 = 합 밖
def test_고르기_창_spec_돌려줌():
import subprocess
data = Path(__file__).resolve().parents[2] / "ui_template/ui_template_item_picker_data.ts"
body = (
data.read_text("utf-8")
+ """
const row = { key: "SS000022", 열: "ms", 경로: ["석축", "메쌓기"],
labor: { 규격: { 이름: "H", 기본: "2.0", 열: [{ 이름: "1.5", 합계: 1 }, { 이름: "2.0", 합계: 2 }] } } };
const one = { ...row, labor: { 규격: { 이름: "H", 기본: "1.5", 열: [{ 이름: "1.5", 합계: 1 }] } } };
const plain = { key: "SS000001", 열: "rv" };
const cur = { layer: "system", owner: null, key: "SS000022", name: "x", spec: "1.5" };
console.log(JSON.stringify([toSummary(row, "1.5"), toSummary(one, "1.5"), toSummary(plain, "1.5"),
startSpec(row, cur), startSpec(row, null), startSpec(row, { ...cur, spec: "지운" }), startSpec(plain, cur)]));
"""
)
got = subprocess.run(
["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript"],
input=body, capture_output=True, text=True, encoding="utf-8", timeout=60,
) # fmt: skip
assert got.returncode == 0, got.stderr
two, single, plain, *starts = json.loads(got.stdout)
assert two == {"layer": "system", "owner": None, "key": "SS000022",
"name": "석축 메쌓기 (H=1.5)", "spec": "1.5"} # fmt: skip
assert single["name"] == "석축 메쌓기" and single["spec"] == "1.5" # 열 하나 = 이름 그대로
assert "spec" not in plain # 규격 없는 옛 항목
assert starts == ["1.5", "2.0", "2.0", None]
def test_B08_B09_금액_열_수량_곱하기_단가(world):
"""B08 = 사본 1 단위 값(사본 셈 반영합) = 층 문서 그 열 셈 · B09 = 대가 줄 금액 = 열 수량 × 단가."""
from M02_MasterTemplete import M02_Item_Prices as item_prices
from M02_MasterTemplete import M02_Labor_Doc as labor
root = world["root"]
_save(root, ["2.5"])
folder = snap.structures_dir(root) / "st0"
read = lambda name: json.loads((folder / name).read_text(encoding="utf-8")) # noqa: E731
copy = item_prices.calc(read("일위대가.json"), read("단가.json"), read("입력값.json"), {}, None)
layer = labor.clean(LABOR)
want = labor.settle(labor.calc(layer, {}), layer, {}, "2.5")["반영합"]
assert copy["반영합"]["계"] == pytest.approx(want["계"]) # B08 1 단위 값 = 2.5 열
first = labor.settle(labor.calc(layer, {}), layer, {}, "1.5")["반영합"]
assert want["계"] != pytest.approx(first["계"])
got, block = _block(root, "st0")
price = {ln["key"]: {"labor": 1000.0, "material": 0.0, "expense": 0.0} for ln in block["lines"]}
sheets = {"labor": [], "material": [], "machine": [], "gaps": []}
out = compute_labor(got, sheets, price, {})
lines = next(b for b in out["daega_blocks"] if b["id"] == block["id"])["lines"]
assert [ln["amounts"]["labor"] for ln in lines] == [3000, 500] # 3 × 1000 · 공통 0.5 × 1000
@@ -0,0 +1,129 @@
"""회사에 올리기 요청 · 승인(PLAN 706-1 · `M02_Share_Request`) — 임시 storage.
① 일반 사용자 요청(구조물 항목 · 사전조합) → 장부 한 줄 · 같은 것 다시 = 409 「요청 중」.
② 회사 관리자 승인 = 회사 층 고른 폴더에 사본(원본 개인 것 그대로) · 거절 = 사유 · 요청자 알림 → 봄.
③ 권한 — 일반 승인 403 · 다른 회사 관리자는 그 요청을 못 봄(404) · 남의 회사 장부 안 봄 ·
목록은 자기 것만.
"""
from __future__ import annotations
import json
from typing import Any
from fastapi import FastAPI
from fastapi.testclient import TestClient
from common_util.common_util_auth import verify_session
from M02_MasterTemplete import M02_Labor_Router as labor_router
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_Share_Request as share
from resources.tester import test_m02_tree as base
from resources.tester.test_m02_tree import Tree
world = base.world # 같은 임시 storage 판
URL = "/api/m02/share-requests"
def _client(world: dict[str, Any]) -> TestClient:
app = FastAPI()
for one in (layers_router, items_router, labor_router, share):
app.include_router(one.router)
app.dependency_overrides[verify_session] = lambda: world["session"]
return TestClient(app)
def _as(world: dict[str, Any], role: str = "USER", user: int = 42, company: int = 7) -> None:
world["session"].update(role=role, user_id=user, company_id=company, name=f"사람{user}")
def _mine(world: dict[str, Any], client: TestClient) -> tuple[str, str]:
"""일반 사용자 42 개인 층 — 항목 「배수/sub7임시」 · 사전조합 하나 → (항목 키, 사전조합 키)."""
_as(world)
t = Tree(client, "personal")
t.do(op="add-folder", 경로=[], 이름="배수")
t.do(op="add-item", 경로=["배수"], 이름="sub7임시", 단위="m")
doc = {
"문서": {"이름": "sub7임시 사전", "구분": "C 사면안정", "상세구분": "옹벽", "담은로직": []}
}
pre = client.post("/api/m02/layers/personal/library/precombo", json=doc).json()["이름"]
return t.item(["배수", "sub7임시"])["key"], pre
def test_요청_승인_거절_알림(world):
client = _client(world)
item, pre = _mine(world, client)
got = client.post(URL, json={"종류": "item", "키": item, "글": " 회사 공용으로 "})
assert got.status_code == 200, got.text
one = got.json()
assert (one["이름"], one["경로"], one["주인"], one["글"]) == (
"sub7임시",
["배수"],
"7/42",
"회사 공용으로",
)
assert client.post(URL, json={"종류": "item", "키": item}).status_code == 409 # 요청 중
two = client.post(URL, json={"종류": "precombo", "키": pre}).json()
assert client.post(URL, json={"종류": "item", "키": "SP42-999999"}).status_code == 404
assert client.post(URL, json={"종류": "precombo", "키": "SC-999999"}).status_code == 404
mine = client.get(URL).json()
assert mine["관리"] is False and [r["id"] for r in mine["줄"]] == [one["id"], two["id"]]
_as(world, "ADMIN", user=50) # 회사 7 관리자
company = Tree(client, "company")
company.do(op="add-folder", 경로=[], 이름="공용")
boss = client.get(URL).json()
assert boss["관리"] is True and len(boss["줄"]) == 2
ok = client.post(f"{URL}/{one['id']}/approve", json={"경로": ["공용"]})
assert ok.status_code == 200, ok.text
assert ok.json()["상태"] == "승인" and ok.json()["봄"] is False
company = Tree(client, "company")
assert ["공용", "sub7임시"] in company.paths() # 고른 폴더에 사본
again = client.post(f"{URL}/{one['id']}/approve", json={"경로": []})
assert again.status_code == 409 # 이미 승인
no = client.post(f"{URL}/{two['id']}/reject", json={"사유": "이름 다시"})
assert no.json()["상태"] == "거절" and no.json()["사유"] == "이름 다시"
_as(world) # 요청자 — 원본 그대로 · 결과 둘 · 봄 뒤 0
assert ["배수", "sub7임시"] in Tree(client, "personal").paths()
rows = client.get(URL).json()["줄"]
assert [(r["상태"], r["봄"]) for r in rows] == [("승인", False), ("거절", False)]
assert client.post(f"{URL}/seen").json() == {"봄": 2}
assert client.post(f"{URL}/seen").json() == {"봄": 0}
# 거절된 것은 다시 요청할 수 있음
assert client.post(URL, json={"종류": "precombo", "키": pre}).status_code == 200
def test_사전조합_승인_폴더_자리(world):
client = _client(world)
_, pre = _mine(world, client)
rid = client.post(URL, json={"종류": "precombo", "키": pre}).json()["id"]
_as(world, "ADMIN", user=50)
tree_url = "/api/m02/layers/company/precombo-tree"
now = client.get(tree_url).json()
client.post(tree_url, json={"op": "add-folder", "경로": [], "이름": "옹벽", "판": now["판"]})
made = client.post(f"{URL}/{rid}/approve", json={"경로": ["옹벽"]}).json()["사본"]
assert client.get(tree_url).json()["자리"] == {made: ["옹벽"]}
doc = client.get(f"/api/m02/layers/company/templates/precombo/{made}").json()["문서"]
assert doc["이름"] == "sub7임시 사전"
assert doc["본뜬"]["주인"] == "7/42" # 원본 표시
def test_권한_일반_승인_403_다른_회사(world):
client = _client(world)
item, _ = _mine(world, client)
rid = client.post(URL, json={"종류": "item", "키": item}).json()["id"]
assert client.post(f"{URL}/{rid}/approve", json={}).status_code == 403 # 일반 = 승인 못 함
assert client.post(f"{URL}/{rid}/reject", json={}).status_code == 403
_as(world, "USER", user=43) # 같은 회사 다른 일반 — 남의 요청 안 보임
assert client.get(URL).json()["줄"] == []
_as(world, "ADMIN", user=90, company=9) # 다른 회사 관리자
assert client.get(URL).json()["줄"] == []
assert client.post(f"{URL}/{rid}/approve", json={}).status_code == 404 # 자기 장부에 없음
assert client.get(URL, params={"company": "7"}).json()["회사"] == "9" # 남의 회사 고르기 무시
_as(world, "SYSTEM_ADMIN", user=1, company=None)
assert len(client.get(URL, params={"company": "7"}).json()["줄"]) == 1
ledger = world["storage"] / "7" / "templates" / share.FILE
assert json.loads(ledger.read_bytes())["줄"][0]["상태"] == "요청"
@@ -69,3 +69,84 @@ def test_파이썬과_TS_가_같은_답(values):
py = labor.settle(answer, doc, values)
_same(py, _ts(answer, doc, values))
assert py == labor.calc(doc, values) # settle 만 다시 = 처음부터 다시
# ── 규격 열(704-4) — 열마다 줄 수량 · 열 없는 옛 문서는 위 시험 그대로 ──
SPEC_DOC = {
"열": "rv",
"담은로직": [
{"로직": TEN, "수량": None},
{"로직": THOUSAND, "수량": {"손": 4}},
{"로직": PLAIN, "수량": None},
],
"자재": [{"코드": MAT, "수량": {"손": 1}, "별도계상": "직접계산"}],
"규격": {
"이름": "H",
"열": ["1.5", "2.0", "2.5"],
"기본": "2.0",
"수량": {
THOUSAND: {"1.5": 3, "2.0": "4.5", "2.5": 6},
MAT: {"1.5": 0.8, "2.5": 1.4}, # 2.0 빈 열 = 공통 손 1
"없는줄": {"1.5": 9}, # 버림
},
},
}
@pytest.mark.parametrize("col", [None, "1.5", "2.0", "2.5", "모름"])
def test_규격_열_파이썬과_TS_가_같은_답(col):
doc = labor.clean(SPEC_DOC)
assert doc["규격"]["수량"] == {
THOUSAND: {"1.5": 3, "2.0": 4.5, "2.5": 6},
MAT: {"1.5": 0.8, "2.5": 1.4},
}
answer = labor.calc(doc, {})
values = {"사석 수상고르기|㎡": 2.5}
py = labor.settle(answer, doc, values, col)
_same(py, _ts(answer, doc, values) if col is None else _ts_col(answer, doc, values, col))
use = col if col in ("1.5", "2.0", "2.5") else "2.0" # 모름 = 기본
line = next(x for x in py["줄"] if x["로직"] == THOUSAND)
assert line["수량"] == {"1.5": 3, "2.0": 4.5, "2.5": 6}[use] and line["수량출처"] == "손"
mat = py["자재"][0]
assert mat["수량"] == {"1.5": 0.8, "2.0": 1, "2.5": 1.4}[use]
def _ts_col(answer: dict, doc: dict, values: dict, col: str) -> dict:
proc = subprocess.run(
["node", os.path.join(HERE, "helper_m02_labor_calc.cjs")],
input=json.dumps({"답": answer, "문서": doc, "값": values, "열": col}, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(proc.stdout)
def test_규격합_은_열마다_다시_센_반영합():
doc = labor.clean(SPEC_DOC)
out = labor.calc(doc, {"사석 수상고르기|㎡": 2.5})
sums = labor.spec_sums(doc, out)["규격합"]
assert list(sums) == ["1.5", "2.0", "2.5"]
for col, total in sums.items():
assert total == labor.settle(out, doc, {"사석 수상고르기|㎡": 2.5}, col)["반영합"]
assert sums["1.5"]["계"] < sums["2.0"]["계"] < sums["2.5"]["계"]
@pytest.mark.parametrize(
"bad",
[
{"이름": "", "열": ["1.5"]},
{"이름": "H", "열": ["1.5", "1.5"]},
{"이름": "H", "열": ["1.5"], "수량": {THOUSAND: {"1.5": "글"}}},
],
)
def test_규격_칸_꼴이_틀리면_400(bad):
with pytest.raises(labor.store.StoreError) as got:
labor.clean({**SPEC_DOC, "규격": bad})
assert got.value.status == 400
def test_열_없는_규격_은_옛_문서():
assert "규격" not in labor.clean({**SPEC_DOC, "규격": {"이름": "H", "열": []}})
assert "규격" not in labor.clean({k: v for k, v in SPEC_DOC.items() if k != "규격"})