From 189fc1d6dc1389e250af5eaa15c1fa68c72083a5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 00:02:54 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(b09):=20=EC=9E=AC=EB=A3=8C=EB=B9=84?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=ED=91=9C=20=EC=88=98=EB=9F=89=20=C2=B7=20?= =?UTF-8?q?=EC=B4=9D=EC=95=A1=20=EC=97=B4=20=E2=80=94=20B08=20=EC=B4=9D?= =?UTF-8?q?=EA=B4=84=20=ED=95=A9=EA=B3=84=20=C3=97=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?=EB=8B=A8=EA=B0=80=20ROUND=200=20=C2=B7=20=EA=B3=B5=EC=A0=9C=20?= =?UTF-8?q?=EC=A4=84=20=EA=B1=B0=EB=A6=84=20=C2=B7=20MT=20=ED=95=A9?= =?UTF-8?q?=EC=B9=A8(166-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3 --- B09_Estimation/B09_Estimation_Calc_Base.py | 14 +++- B09_Estimation/B09_Estimation_Calc_Base.ts | 17 +++-- B09_Estimation/B09_Estimation_Prices.py | 14 +++- B09_Estimation/B09_Estimation_UI_Tabs_Base.ts | 8 ++- .../tester/test_166_3_b09_material_qty.py | 67 +++++++++++++++++++ 5 files changed, 109 insertions(+), 11 deletions(-) create mode 100644 resources/tester/test_166_3_b09_material_qty.py diff --git a/B09_Estimation/B09_Estimation_Calc_Base.py b/B09_Estimation/B09_Estimation_Calc_Base.py index 972268937..1e821a325 100644 --- a/B09_Estimation/B09_Estimation_Calc_Base.py +++ b/B09_Estimation/B09_Estimation_Calc_Base.py @@ -10,6 +10,8 @@ (분자 · 분모 = M01 CC000336 1/8 × 16/12 × 25/20 — 서버가 넣어 줌) 자재단가대비표 = 출처 단가 중 최저(0 · 빈칸 뺌) 자동 · 사용자 적용 단가가 있으면 그 값 · 관급 · 별도계상 = 사용자 표시 → 재료비목록표 단가 = 적용 단가 + 재료비목록표 = 수량(B08 총괄자재수량집계표 합계 열) × 적용 단가 = 총액 ROUND(·, 0) + (엑셀 재료비수량금액집계표 G 열 자리 · 수량 · 단가 하나라도 빈칸이면 빈칸) 중기사용료 = 손료(경비 · 기준가 천원 × 손료계수 ÷ 10,000) + 운전사(노무 · 인 × 시간 노임) + 연료(재료 · ℓ/hr × 적용 단가) + 잡품(재료 · 연료 금액 × % ÷ 100) 줄 = ROUNDDOWN(단가 × 수량, 1) · 비목 합 = ROUNDDOWN(Σ, 0) → 중기목록표 @@ -163,15 +165,21 @@ def compare_sheet( "applied": applied, "applied_from": applied_from if applied is not None else None, "supply": supply, + "qty": num(m.get("qty")), } ) return rows def material_sheet(compare: list[dict[str, Any]]) -> list[dict[str, Any]]: - """재료비목록표 — 단가 = 대비표 적용 단가.""" - keep = ("no", "key", "name", "spec", "unit", "supply", "origin") - return [{**{k: row[k] for k in keep}, "price": row["applied"]} for row in compare] + """재료비목록표 — 단가 = 대비표 적용 단가 · 총액 = ROUND(수량 × 단가, 0).""" + keep = ("no", "key", "name", "spec", "unit", "supply", "origin", "qty") + rows = [] + for row in compare: + price, q = row["applied"], row["qty"] + amount = None if price is None or q is None else round_half(q * price, 0) + rows.append({**{k: row[k] for k in keep}, "price": price, "amount": amount}) + return rows def _line(name, spec, qty, unit, kind, price, ref, pct=False) -> dict[str, Any]: diff --git a/B09_Estimation/B09_Estimation_Calc_Base.ts b/B09_Estimation/B09_Estimation_Calc_Base.ts index 4213f70a3..a93f53147 100644 --- a/B09_Estimation/B09_Estimation_Calc_Base.ts +++ b/B09_Estimation/B09_Estimation_Calc_Base.ts @@ -8,6 +8,7 @@ * * 노무비 · 경비목록표 = 굳힌 단가 · 환율및기초자료 = 운전사 시간 노임 ROUNDDOWN(일당 × 분자 ÷ 분모, 0) * 자재단가대비표 = 출처 중 최저 자동 · 사용자 적용 단가 앞섬 · 관급 · 별도 표시 → 재료비목록표 + * 재료비목록표 = 수량(B08 총괄 합계 열) × 적용 단가 = 총액 ROUND(·, 0) · 하나라도 빈칸이면 빈칸 * 중기사용료 = 손료 + 운전사 + 연료 + 잡품 · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 → 중기목록표 * 빈칸 = 원천이 없으면 null · 그 칸을 쓰는 합도 null · 「연결 없음」 목록(gaps)에 남김 * ========================================================================== */ @@ -138,16 +139,22 @@ function compareSheet(materials: Row[], user: Row, gaps: Gap[]): Row[] { applied, applied_from: applied !== null ? appliedFrom : null, supply, + qty: num(m.qty), }; }); } function materialSheet(compare: Row[]): Row[] { - const keep = ["no", "key", "name", "spec", "unit", "supply", "origin"]; - return compare.map((row) => ({ - ...Object.fromEntries(keep.map((k) => [k, row[k]])), - price: row.applied, - })); + const keep = ["no", "key", "name", "spec", "unit", "supply", "origin", "qty"]; + return compare.map((row) => { + const price: Num = row.applied; + const q: Num = row.qty; + return { + ...Object.fromEntries(keep.map((k) => [k, row[k]])), + price, + amount: price === null || q === null ? null : roundHalf(q * price, 0), + }; + }); } function line( diff --git a/B09_Estimation/B09_Estimation_Prices.py b/B09_Estimation/B09_Estimation_Prices.py index 2ac4ab7be..198287b39 100644 --- a/B09_Estimation/B09_Estimation_Prices.py +++ b/B09_Estimation/B09_Estimation_Prices.py @@ -7,6 +7,7 @@ 연결된 사본이 부른 자원 키(`B09_Estimation_Linked` — 143-1 「연결된 사본」 원천 · 없으면 빈 표) + 기계가 부르는 운전사(조종원직종 → 인력 원문번호) · 연료(주연료종류 → 유가 「종류:전국평균」) + B08 총괄자재수량집계표 자재(M01 짝 없음 → 출처 빈칸 · 적용 단가는 사용자 입력) + · 수량 = 합계 열 · 채집석 공제 줄 거름 · 키가 MT 코드(맞춤 없는 사본 자재)면 사본 자원 줄에 수량만 얹음 운전사 시간 계수 = M01 CC000336(시간환산 · 상여계수 · 휴지계수 분자 · 분모 곱). 연료 지역 = 전국평균(M01 기계경비 로직 지역 첫 값) — 다른 지역 값은 자재 적용 단가를 고쳐 씀. """ @@ -17,6 +18,7 @@ from datetime import datetime from decimal import Decimal from typing import Any +from B08_Quantity.B08_Quantity_Material_Calc import DEDUCT_KEY from M01_MasterData import M01_MasterData_Store as m01 #: 운전사 시간 노임 계수 표(건설품셈 공통 8-1-3 · 건협기술-2230) @@ -212,7 +214,7 @@ def b08_material_rows(b08_sheets: dict[str, Any] | None) -> list[dict[str, Any]] rows = ((b08_sheets or {}).get("material") or {}).get("rows") or [] out = [] for row in rows: - if row.get("amount") is None: + if row.get("amount") is None or row["key"] == DEDUCT_KEY: continue out.append( { @@ -244,10 +246,18 @@ def build_inputs( return {**_convert(master, key), "fresh": True} factor = (book or {}).get("driver_factor") if book else driver_factor(master) + materials = [pick(k) for k in want["materials"]] + at = {row["key"]: i for i, row in enumerate(materials)} + for row in b08_material_rows(b08_sheets): + same = at.get(row["key"].removeprefix("b08:")) + if same is None: + materials.append(row) + else: # 같은 MT 코드 = 같은 자재 — 두 줄 안 만들고 수량만 + materials[same] = {**materials[same], "qty": row["qty"]} return { "labor": [pick(k) for k in want["labor"]], "machines": [pick(k) for k in want["machines"]], - "materials": [pick(k) for k in want["materials"]] + b08_material_rows(b08_sheets), + "materials": materials, "fuel_keys": want["fuel_keys"], "driver_factor": factor, "fx": (book or {}).get("fx") if book else fx_inputs(master), diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts index fef27bb1f..4567f9a5f 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts @@ -2,6 +2,7 @@ * B09_Estimation_UI_Tabs_Base.ts * B09 바탕 단가 탭 일곱(PLAN 144-2~4) — 노무비 · 경비목록표 · 환율및기초자료 · 자재단가대비표 · * 재료비목록표 · 중기사용료 · 중기목록표(실무 엑셀 꼴 · 값 = `B09_Estimation_Calc_Base`). + * 재료비목록표 = 엑셀 꼴 + 수량(B08 합계) · 총액 열(166-3) * * 고칠 칸 = 자재단가대비표 적용 단가(빈칸으로 지우면 최저 자동) · 관급 / 별도 칸 누름 = 표시 바꿈 * 이동 = 재료비목록표 줄 → 대비표 · 중기목록표 줄 → 중기사용료 블록 · 블록 줄 → 경비 · 기초 · 대비표 @@ -179,13 +180,18 @@ export const buildMaterial: TabBuild = ({ sheets }): TabTable => { text("name", "명칭", 200), text("spec", "규격", 160), text("unit", "단위", 56), + qty("qty", "수량", 3), money("price", "단가"), + money("amount", "총액"), text("supply", "관급 · 별도", 80), text("origin", "비고", 90), ], rows, + total: rows.length + ? { name: "계", amount: rows.reduce((sum, r) => sum + (Number(r.amount) || 0), 0) } + : null, frozen: 2, - hint: `단가 = 자재단가대비표 적용 단가 · ${LINK_HINT}`, + hint: `수량 = B08 총괄자재수량집계표 합계 열(할증 포함 · 사본 자원 줄은 빈칸) · 단가 = 자재단가대비표 적용 단가 · 총액 = ROUND(수량 × 단가, 0) · 계 = 빈칸 뺀 합 · ${LINK_HINT}`, link: (i) => (rows[i] ? { tab: "compare", key: String(rows[i].key) } : null), }; }; diff --git a/resources/tester/test_166_3_b09_material_qty.py b/resources/tester/test_166_3_b09_material_qty.py new file mode 100644 index 000000000..9c674c0b7 --- /dev/null +++ b/resources/tester/test_166_3_b09_material_qty.py @@ -0,0 +1,67 @@ +"""166-3 재료비목록표 = B08 총괄 합계 수량 × 적용 단가 = 총액 ROUND 0 · 공제 줄 거름 · MT 합침 · 거울.""" + +import sys +from pathlib import Path + +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_Prices as prices # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Base import compute_base # noqa: E402 +from resources.tester.test_144_b09_base_calc import _run_ts # noqa: E402 + +MATERIALS = [ + {"key": "MT1", "name": "부직포", "prices": {"물가자료": 1234.5}, "qty": 10.25}, + {"key": "MT2", "name": "수량 없는 사본 줄", "prices": {"물가자료": 500}}, + {"key": "b08:cement", "name": "시멘트", "prices": {}, "qty": 3.333}, + {"key": "b08:sand", "name": "모래", "prices": {}, "qty": 7}, +] +INPUTS = {"labor": [], "machines": [], "materials": MATERIALS} +USER = {"materials": {"b08:cement": {"applied": 7000.5}}} + + +def test_총액_ROUND_0_빈칸(): + rows = {r["key"]: r for r in compute_base(INPUTS, USER)["material"]} + assert (rows["MT1"]["qty"], rows["MT1"]["price"], rows["MT1"]["amount"]) == ( + 10.25, + 1234.5, + 12654, + ) # 12,653.6 → 반 올림 + assert rows["b08:cement"]["amount"] == 23333 # 3.333 × 7000.5 = 23,332.67 + assert rows["MT2"]["qty"] is None and rows["MT2"]["amount"] is None # 수량 빈칸 + assert rows["b08:sand"]["price"] is None and rows["b08:sand"]["amount"] is None # 단가 빈칸 + + +def test_거울_TS(): + for user in ({}, USER): + assert _run_ts(INPUTS, user) == compute_base(INPUTS, user) + + +def test_공제_거름_MT_합침(monkeypatch): + sheets = { + "material": { + "rows": [ + {"key": "cement", "group": "시멘트", "unit": "포", "amount": 12.5}, + {"key": "picked_deduct", "group": "채집석 공제", "unit": "㎥", "amount": 4}, + {"key": "MT1", "group": "부직포", "unit": "㎡", "amount": 30.1}, + {"key": "MT9", "group": "사본에 없는 MT", "unit": "개", "amount": 2}, + {"key": "empty", "group": "수량 없음", "amount": None}, + ] + } + } + keys = [r["key"] for r in prices.b08_material_rows(sheets)] + assert keys == ["b08:cement", "b08:MT1", "b08:MT9"] + + monkeypatch.setattr(prices, "_master", lambda: None) + monkeypatch.setattr( + prices, + "demand", + lambda master, keys: {"labor": [], "machines": [], "materials": ["MT1", "MO1"], + "fuel_keys": {}, "missing": []}, + ) # fmt: skip + book = {"rows": {"MT1": {"key": "MT1", "name": "부직포"}, "MO1": {"key": "MO1"}}} + got = prices.build_inputs(book, [], sheets)["materials"] + assert [r["key"] for r in got] == ["MT1", "MO1", "b08:cement", "b08:MT9"] + assert got[0]["qty"] == 30.1 and "qty" not in book["rows"]["MT1"] # 책은 안 바꿈 + assert got[2]["qty"] == 12.5 From 9ec85bdd4358f904f15b616917700d1f8ecaa853 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 00:07:47 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat(b09):=20=EC=9E=90=EC=9E=AC=EB=8B=A8?= =?UTF-8?q?=EA=B0=80=EB=8C=80=EB=B9=84=ED=91=9C=20=EA=B2=AC=EC=A0=81=20?= =?UTF-8?q?=EC=97=B4=20=C2=B7=20=EC=A0=81=EC=9A=A9=20=EC=B6=9C=EC=B2=98=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=E2=80=94=20=EC=B5=9C=EC=A0=80=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=EC=97=90=20=EA=B2=AC=EC=A0=81=20=ED=8F=AC?= =?UTF-8?q?=ED=95=A8=20=C2=B7=20=EA=B3=A0=EB=A5=B8=20=EC=B6=9C=EC=B2=98=20?= =?UTF-8?q?=EC=95=9E=EC=84=AC(166-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3 --- B09_Estimation/B09_Estimation_Calc_Base.py | 18 ++-- B09_Estimation/B09_Estimation_Calc_Base.ts | 22 +++- B09_Estimation/B09_Estimation_UI_Tabs_Base.ts | 54 ++++++++-- .../B09_Estimation_UI_Tabs_Base_Quote.ts | 100 ++++++++++++++++++ 4 files changed, 175 insertions(+), 19 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_UI_Tabs_Base_Quote.ts diff --git a/B09_Estimation/B09_Estimation_Calc_Base.py b/B09_Estimation/B09_Estimation_Calc_Base.py index 1e821a325..88218def0 100644 --- a/B09_Estimation/B09_Estimation_Calc_Base.py +++ b/B09_Estimation/B09_Estimation_Calc_Base.py @@ -8,7 +8,9 @@ 노무비목록표 · 경비목록표 = 굳힌 단가 그대로(사용자 고침 없음 · [단가 갱신] 때만 새로) 환율및기초자료 = 환율 · 운전사 시간 노임 = ROUNDDOWN(일당 × 분자 ÷ 분모, 0) (분자 · 분모 = M01 CC000336 1/8 × 16/12 × 25/20 — 서버가 넣어 줌) - 자재단가대비표 = 출처 단가 중 최저(0 · 빈칸 뺌) 자동 · 사용자 적용 단가가 있으면 그 값 + 자재단가대비표 = 출처 단가 중 최저(0 · 빈칸 뺌 · 견적 포함 = 엑셀 MIN) 자동 · + 적용 차례 = 고른 출처(pick · 견적이 높아도) › 사용자 적용 단가 › 최저 + · 견적 = 사용자 손입력(quote · quote_page · 견적서 파일은 `B09_Estimation_Router_Quotes`) · 관급 · 별도계상 = 사용자 표시 → 재료비목록표 단가 = 적용 단가 재료비목록표 = 수량(B08 총괄자재수량집계표 합계 열) × 적용 단가 = 총액 ROUND(·, 0) (엑셀 재료비수량금액집계표 G 열 자리 · 수량 · 단가 하나라도 빈칸이면 빈칸) @@ -28,8 +30,8 @@ from typing import Any EPS = 1e-7 #: 자재단가대비표 출처 칸 차례 — 엑셀 조달 · 물가정보 · 물가자료(같은 값이면 앞 칸) -# ↔ M01 재료_자재품목 값 열 · 유가 = 오피넷 -SOURCES = ("관급", "물가정보", "물가자료", "유통물가", "거래가격", "유가") +# ↔ M01 재료_자재품목 값 열 · 유가 = 오피넷 · 견적 = 사용자 손입력(엑셀 M/N 열) +SOURCES = ("관급", "물가정보", "물가자료", "유통물가", "거래가격", "유가", "견적") #: 자재 줄 사용자 표시 SUPPLY = ("관급", "별도") @@ -144,9 +146,12 @@ def compare_sheet( rows = [] for no, m in enumerate(materials, 1): mine = user.get(m["key"]) or {} - prices = m.get("prices") or {} + prices = {**(m.get("prices") or {}), "견적": mine.get("quote")} + pages = {**(m.get("pages") or {}), "견적": mine.get("quote_page")} low, low_from = lowest(prices) - if "applied" in mine: + if mine.get("pick") in SOURCES: + applied, applied_from = num(prices.get(mine["pick"])), mine["pick"] + elif "applied" in mine: applied, applied_from = num(mine["applied"]), "사용자" else: applied, applied_from = low, low_from @@ -159,11 +164,12 @@ def compare_sheet( "unit": m.get("unit") or "", "origin": m.get("origin") or "", "prices": {s: num(prices.get(s)) for s in SOURCES}, - "pages": {s: (m.get("pages") or {}).get(s) for s in SOURCES}, + "pages": {s: pages.get(s) for s in SOURCES}, "lowest": low, "lowest_from": low_from, "applied": applied, "applied_from": applied_from if applied is not None else None, + "pick": mine.get("pick") if mine.get("pick") in SOURCES else None, "supply": supply, "qty": num(m.get("qty")), } diff --git a/B09_Estimation/B09_Estimation_Calc_Base.ts b/B09_Estimation/B09_Estimation_Calc_Base.ts index a93f53147..5d4789743 100644 --- a/B09_Estimation/B09_Estimation_Calc_Base.ts +++ b/B09_Estimation/B09_Estimation_Calc_Base.ts @@ -7,7 +7,8 @@ * 조작 중은 이 파일이 바로 셈 · [저장] 때 서버가 파이썬 짝으로 정본을 다시 셈. * * 노무비 · 경비목록표 = 굳힌 단가 · 환율및기초자료 = 운전사 시간 노임 ROUNDDOWN(일당 × 분자 ÷ 분모, 0) - * 자재단가대비표 = 출처 중 최저 자동 · 사용자 적용 단가 앞섬 · 관급 · 별도 표시 → 재료비목록표 + * 자재단가대비표 = 출처 중 최저 자동(견적 포함) · 적용 = 고른 출처 › 사용자 단가 › 최저 · + * 관급 · 별도 표시 → 재료비목록표 * 재료비목록표 = 수량(B08 총괄 합계 열) × 적용 단가 = 총액 ROUND(·, 0) · 하나라도 빈칸이면 빈칸 * 중기사용료 = 손료 + 운전사 + 연료 + 잡품 · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 → 중기목록표 * 빈칸 = 원천이 없으면 null · 그 칸을 쓰는 합도 null · 「연결 없음」 목록(gaps)에 남김 @@ -27,7 +28,15 @@ export interface Gap { const EPS = 1e-7; /** 자재단가대비표 출처 칸 차례 — 엑셀 조달 · 물가정보 · 물가자료(같은 값이면 앞 칸) */ -export const SOURCES = ["관급", "물가정보", "물가자료", "유통물가", "거래가격", "유가"] as const; +export const SOURCES = [ + "관급", + "물가정보", + "물가자료", + "유통물가", + "거래가격", + "유가", + "견적", +] as const; /** 자재 줄 사용자 표시 */ export const SUPPLY = ["관급", "별도"] as const; @@ -119,15 +128,17 @@ export function lowest(prices: Row): [Num, string | null] { function compareSheet(materials: Row[], user: Row, gaps: Gap[]): Row[] { return materials.map((m, i) => { const mine: Row = user[m.key] || {}; - const prices: Row = m.prices || {}; + const prices: Row = { ...(m.prices || {}), 견적: mine.quote }; + const pages: Row = { ...(m.pages || {}), 견적: mine.quote_page }; const [low, lowFrom] = lowest(prices); + const pick = (SOURCES as readonly string[]).includes(mine.pick) ? mine.pick : null; let applied: Num; let appliedFrom: string | null; - if ("applied" in mine) [applied, appliedFrom] = [num(mine.applied), "사용자"]; + if (pick) [applied, appliedFrom] = [num(prices[pick]), pick]; + else if ("applied" in mine) [applied, appliedFrom] = [num(mine.applied), "사용자"]; else [applied, appliedFrom] = [low, lowFrom]; if (applied === null) gaps.push(gap("compare", m.key, m.name, "적용 단가 없음(출처 없음)")); const supply = (SUPPLY as readonly string[]).includes(mine.supply) ? mine.supply : null; - const pages: Row = m.pages || {}; return { ...head(m, i + 1), unit: m.unit || "", @@ -138,6 +149,7 @@ function compareSheet(materials: Row[], user: Row, gaps: Gap[]): Row[] { lowest_from: lowFrom, applied, applied_from: applied !== null ? appliedFrom : null, + pick, supply, qty: num(m.qty), }; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts index 4567f9a5f..a86daea13 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base.ts @@ -5,11 +5,14 @@ * 재료비목록표 = 엑셀 꼴 + 수량(B08 합계) · 총액 열(166-3) * * 고칠 칸 = 자재단가대비표 적용 단가(빈칸으로 지우면 최저 자동) · 관급 / 별도 칸 누름 = 표시 바꿈 + * · 견적 단가 · 면 손입력 · 적용 「출처」 칸 누름 = 고를 출처 돌림(값 있는 출처만 · 견적이 높아도) + * · 「견적서」 칸 누름 = 파일 모달(`B09_Estimation_UI_Tabs_Base_Quote`)(166-4) * 이동 = 재료비목록표 줄 → 대비표 · 중기목록표 줄 → 중기사용료 블록 · 블록 줄 → 경비 · 기초 · 대비표 * ========================================================================== */ import type { DataRow } from "@ui/ui_template_data_table"; import { SOURCES, type Row } from "./B09_Estimation_Calc_Base"; +import { openQuotes } from "./B09_Estimation_UI_Tabs_Base_Quote"; import { money, parseNumber, @@ -104,9 +107,16 @@ export const buildBasis: TabBuild = ({ sheets }): TabTable => { const SUPPLY_NEXT: Record = { "": "관급", 관급: "별도", 별도: null }; +/** 고를 출처 돌림 — 자동 → 값 있는 출처 차례 → 자동 */ +function nextPick(r: Row): string | null { + const have = SOURCES.filter((s) => (r.prices[s] ?? 0) > 0); + return have[have.indexOf(r.pick) + 1] ?? null; +} + export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { const rows: Row[] = sheets.compare || []; const out: DataRow[] = rows.map((r) => { + const files: string[] = (user.materials || {})[r.key]?.files || []; const row: DataRow = { no: r.no, key: r.key, @@ -115,8 +125,9 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { unit: r.unit, lowest: r.lowest, applied: r.applied, - applied_from: r.applied_from === "사용자" ? "사용자" : (r.applied_from ?? ""), + applied_from: r.pick ? `${r.pick} 고름` : (r.applied_from ?? ""), supply: r.supply ?? "", + files: files.length ? `${files.length} 파일` : "첨부", }; for (const s of SOURCES) { row[`p:${s}`] = r.prices[s]; @@ -142,25 +153,52 @@ export const buildCompare: TabBuild = ({ sheets, user, setUser }): TabTable => { money("applied", "적용 단가", ["적용"]), text("applied_from", "출처", 80, ["적용"]), text("supply", "관급 · 별도", 80), + text("files", "견적서", 72), ], rows: out, frozen: 2, - hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌) 자동 · 칸에 넣으면 사용자 값 · 지우면 다시 최저 · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도", - editable: (_i, key) => key === "applied", + hint: "적용 단가 = 출처 중 최저(0 · 빈칸 뺌 · 견적 포함) 자동 · 칸에 넣으면 사용자 값 · 지우면 다시 최저 · 「출처」 칸 누름 = 고를 출처 돌림(값 있는 출처 · 견적이 높아도 고름) · 견적 단가 · 면 = 손입력 · 「관급 · 별도」 칸 누름 = 없음 → 관급 → 별도 · 「견적서」 칸 누름 = 파일 올리기 · 보기", + editable: (_i, key) => key === "applied" || key === "p:견적" || key === "g:견적", onEdit: (i, key, value) => { const row = out[i]; - if (!row || key !== "applied") return; + if (!row) return; + const cell = mine(String(row.key)); + if (key === "g:견적") { + if (value.trim()) cell.quote_page = value.trim(); + else delete cell.quote_page; + return put(String(row.key), cell); + } const v = parseNumber(value); if (v === undefined) return; - const cell = mine(String(row.key)); - if (v === null) delete cell.applied; - else cell.applied = v; + const field = key === "applied" ? "applied" : key === "p:견적" ? "quote" : null; + if (!field) return; + if (v === null) delete cell[field]; + else cell[field] = v; + if (field === "applied") delete cell.pick; // 손 수 = 고른 출처를 풂 put(String(row.key), cell); }, onCell: (i, key) => { const row = out[i]; - if (!row || key !== "supply") return; + const r = rows[i]; + if (!row || !r) return; const cell = mine(String(row.key)); + if (key === "files") { + const label = `${r.name} ${r.spec}`.trim(); + return openQuotes(r.key, label, (names) => { + const now = mine(r.key); + if (JSON.stringify(now.files || []) === JSON.stringify(names)) return; + if (names.length) now.files = names; + else delete now.files; + put(r.key, now); + }); + } + if (key === "applied_from") { + const pick = nextPick(r); + if (pick) cell.pick = pick; + else delete cell.pick; + return put(String(row.key), cell); + } + if (key !== "supply") return; const next = SUPPLY_NEXT[String(row.supply ?? "")] ?? null; if (next) cell.supply = next; else delete cell.supply; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Base_Quote.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Base_Quote.ts new file mode 100644 index 000000000..6525253d6 --- /dev/null +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Base_Quote.ts @@ -0,0 +1,100 @@ +/* ============================================================================= + * B09_Estimation_UI_Tabs_Base_Quote.ts + * 자재단가대비표 「견적서」 칸 작은 모달(PLAN 166-4) — 자재 줄 하나의 견적서 여러 파일 · + * 목록 · 올리기(여러 개) · 내려받기 · 지우기. 파일은 올리는 즉시 서버에 둠([저장]과 따로). + * + * 길 = `B09_Estimation_Router_Quotes.py` · 바뀐 목록은 onChange(이름들)로 표 칸 「n 파일」에 반영 + * ========================================================================== */ + +import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; +import { openModal } from "@ui/ui_template_modal"; +import { createButton, el, showToast } from "@ui/ui_template_elements"; + +interface QuoteFile { + name: string; + size: number; + at: string; +} + +const url = (key: string, name?: string): string => { + const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; + const q = new URLSearchParams({ key, ...(name ? { name } : {}) }); + return `${API_BASE_URL}/projects/${projectId}/estimation/quotes${name ? "/file" : ""}?${q}`; +}; + +async function call(input: string, init: RequestInit = {}): Promise { + const response = await fetch(input, { credentials: "include", ...init }); + if (!response.ok) { + const body = (await response.json().catch(() => null)) as { detail?: unknown } | null; + throw new Error(typeof body?.detail === "string" ? body.detail : `HTTP ${response.status}`); + } + return response; +} + +async function download(key: string, name: string): Promise { + const blob = await (await call(url(key, name))).blob(); + const a = el("a", { attrs: { href: URL.createObjectURL(blob), download: name } }); + a.click(); + URL.revokeObjectURL(a.href); +} + +/** 견적서 모달 — label = 「품명 규격」 */ +export function openQuotes(key: string, label: string, onChange: (names: string[]) => void): void { + openModal({ + title: `견적서 — ${label}`, + closeLabel: "닫기", + mount: (body) => { + const list = el("ul", { className: "b09-quotes" }); + const show = (files: QuoteFile[]): void => { + onChange(files.map((f) => f.name)); + list.replaceChildren( + ...(files.length + ? files.map((f) => + el("li", { + children: [ + createButton({ + label: `${f.name} (${Math.ceil(f.size / 1024).toLocaleString()} KB)`, + variant: "ghost", + size: "sm", + onClick: () => void download(key, f.name).catch(fail), + }), + createButton({ + label: "지우기", + variant: "danger", + size: "sm", + onClick: () => + void call(url(key, f.name).replace("/file?", "?"), { method: "DELETE" }) + .then((r) => r.json()) + .then(show, fail), + }), + ], + }), + ) + : [el("li", { text: "올린 견적서 없음" })]), + ); + }; + const fail = (error: unknown): void => + showToast(`하지 못했습니다 — ${String(error)}`, "error"); + const input = el("input", { attrs: { type: "file", multiple: "" } }); + input.addEventListener("change", () => { + const form = new FormData(); + for (const file of Array.from(input.files ?? [])) form.append("files", file); + input.value = ""; + void call(url(key), { method: "POST", body: form }) + .then((r) => r.json()) + .then(show, fail); + }); + const pick = createButton({ + label: "견적서 올리기(여러 개)", + variant: "filled", + size: "sm", + onClick: () => input.click(), + }); + input.hidden = true; + body.append(pick, input, list); + void call(url(key)) + .then((r) => r.json()) + .then(show, fail); + }, + }); +} From 40b80ff1b698468786bc37b1b3cb2a0118f24f0a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 10 Oct 2026 00:09:37 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(b09):=20=EC=9E=90=EC=9E=AC=20=EA=B2=AC?= =?UTF-8?q?=EC=A0=81=EC=84=9C=20=EC=97=AC=EB=9F=AC=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=B2=A8=EB=B6=80=20=EA=B8=B8=20=C2=B7=20=EA=B2=AC=EC=A0=81=20?= =?UTF-8?q?=C2=B7=20=EA=B3=A0=EB=A5=B8=20=EC=B6=9C=EC=B2=98=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5(166-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Router_Quotes 새 길(목록 · 올리기 · 내려받기 · 지우기 · v1/quotes/<자재 키>/) · Store quote · quote_page · pick 칸 · 읽을 때 files 목록 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3 --- B09_Estimation/B09_Estimation_Router.py | 5 + .../B09_Estimation_Router_Quotes.py | 93 ++++++++++++++++ B09_Estimation/B09_Estimation_Store.py | 47 +++++++- resources/tester/test_166_4_b09_quote.py | 105 ++++++++++++++++++ 4 files changed, 247 insertions(+), 3 deletions(-) create mode 100644 B09_Estimation/B09_Estimation_Router_Quotes.py create mode 100644 resources/tester/test_166_4_b09_quote.py diff --git a/B09_Estimation/B09_Estimation_Router.py b/B09_Estimation/B09_Estimation_Router.py index fe9c66f5b..b973d2329 100644 --- a/B09_Estimation/B09_Estimation_Router.py +++ b/B09_Estimation/B09_Estimation_Router.py @@ -36,6 +36,7 @@ from B09_Estimation.B09_Estimation_Calc_Total import compute_total from B09_Estimation.B09_Estimation_Labor_Source import daega_inputs from B09_Estimation.B09_Estimation_Linked import linked_resource_keys from B09_Estimation.B09_Estimation_Rates import rate_candidates, rate_table +from B09_Estimation.B09_Estimation_Router_Quotes import router as quotes_router from B09_Estimation.B09_Estimation_Summary import compute_summary, hopyo_lines router = APIRouter(prefix="/api/projects", tags=["B09 Estimation"]) @@ -129,3 +130,7 @@ async def refresh_prices(project_id: UUID) -> dict[str, Any]: book = await asyncio.to_thread(prices.refresh_book, keys) await asyncio.to_thread(store.write_book, root, book) return await payload(project_id, root) + + +# 166-4 견적서 파일 길 — 같은 머리 · 같은 권한 +router.include_router(quotes_router) diff --git a/B09_Estimation/B09_Estimation_Router_Quotes.py b/B09_Estimation/B09_Estimation_Router_Quotes.py new file mode 100644 index 000000000..adb60a382 --- /dev/null +++ b/B09_Estimation/B09_Estimation_Router_Quotes.py @@ -0,0 +1,93 @@ +"""B09 자재 견적서 파일 길(PLAN 166-4) — 자재 줄마다 여러 파일 · DB 표 없음(폴더가 목록). + + GET /api/projects/{id}/estimation/quotes?key= 목록 [{name, size, at}] + POST /api/projects/{id}/estimation/quotes?key= 올리기(`files` 여러 개 · 같은 이름 = 교체) + GET /api/projects/{id}/estimation/quotes/file?key=&name= 내려받기 + DELETE /api/projects/{id}/estimation/quotes?key=&name= 지우기 → 목록 + +자재 키 · 파일 이름은 쿼리로(키에 `/` · `|` 가 섞임) · 저장 자리 = `B09_Estimation_Store.quote_dir`. +`B09_Estimation_Router` 가 끼워 넣음(같은 머리 · 같은 권한). +""" + +from __future__ import annotations + +import asyncio +from datetime import datetime +from pathlib import Path +from typing import Any +from uuid import UUID + +from fastapi import APIRouter, File, HTTPException, Query, UploadFile +from fastapi.responses import FileResponse + +from B03_FileInput.B03_FileInput_Engine import save_upload_stream +from B08_Quantity.B08_Quantity_Router_Hydro import _project_root +from B09_Estimation import B09_Estimation_Store as store + +router = APIRouter() +PATH = "/{project_id}/estimation/quotes" + + +def _listing(folder: Path) -> list[dict[str, Any]]: + return [ + { + "name": f.name, + "size": f.stat().st_size, + "at": datetime.fromtimestamp(f.stat().st_mtime).isoformat(timespec="seconds"), + } + for f in store.quote_files(folder) + ] + + +async def _folder(project_id: UUID, key: str) -> Path: + root = await _project_root(project_id) + try: + return store.quote_dir(root, key) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + + +async def _file(project_id: UUID, key: str, name: str) -> Path: + folder = await _folder(project_id, key) + path = folder / name + if store.safe_name(name) != name or not path.is_file(): + raise HTTPException(status_code=404, detail="견적서 파일이 없습니다.") + return path + + +@router.get(PATH) +async def list_quotes(project_id: UUID, key: str = Query(...)) -> list[dict[str, Any]]: + folder = await _folder(project_id, key) + return await asyncio.to_thread(_listing, folder) + + +@router.post(PATH) +async def upload_quotes( + project_id: UUID, key: str = Query(...), files: list[UploadFile] = File(...) +) -> list[dict[str, Any]]: + folder = await _folder(project_id, key) + folder.mkdir(parents=True, exist_ok=True) + for upload in files: + name = store.safe_name(upload.filename or "") + if not name: + raise HTTPException(status_code=400, detail="파일 이름이 없습니다.") + try: + await save_upload_stream(upload, folder / name) + except ValueError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + return await asyncio.to_thread(_listing, folder) + + +@router.get(PATH + "/file") +async def download_quote(project_id: UUID, key: str = Query(...), name: str = Query(...)): + path = await _file(project_id, key, name) + return FileResponse(path, filename=name) + + +@router.delete(PATH) +async def delete_quote( + project_id: UUID, key: str = Query(...), name: str = Query(...) +) -> list[dict[str, Any]]: + path = await _file(project_id, key, name) + await asyncio.to_thread(path.unlink) + return await asyncio.to_thread(_listing, path.parent) diff --git a/B09_Estimation/B09_Estimation_Store.py b/B09_Estimation/B09_Estimation_Store.py index 27ae5bf29..e32557ef1 100644 --- a/B09_Estimation/B09_Estimation_Store.py +++ b/B09_Estimation/B09_Estimation_Store.py @@ -4,9 +4,13 @@ "driver_factor": …, "fx": […]} — [단가 갱신] 때만 M01 로 새로 · [저장] 때 책에 없는 새 줄만 더함(있는 줄 안 바꿈) - user_values.json 사용자 값 {"materials": {자재 키: {applied?, supply?}}, "estimate": {…}} + user_values.json 사용자 값 {"materials": {자재 키: {applied?, supply?, quote?, quote_page?, + pick?}}, "estimate": {…}} — 고친 칸만(키가 있으면 그 값 · null = 빈칸 · 없으면 기본) + quotes/<자재 키>/ 견적서 파일(166-4 · 여러 개) — 폴더가 목록(키의 못 쓰는 글자는 %XX) · + 읽을 때 materials[키].files = 이름들 · 사용자 값으로는 저장 안 함 + 결과(표)는 저장하지 않음 — GET 때 서버가 셈(B08 꼴). """ @@ -15,14 +19,19 @@ from __future__ import annotations import json import math import os +import re from pathlib import Path from typing import Any +from urllib.parse import unquote -from B09_Estimation.B09_Estimation_Calc_Base import SUPPLY +from B09_Estimation.B09_Estimation_Calc_Base import SOURCES, SUPPLY FOLDER = ("B09_Estimation", "v1") BOOK = "price_book.json" USER = "user_values.json" +QUOTES = "quotes" +#: 폴더 · 파일 이름에 못 쓰는 글자(Windows) +BAD_NAME = re.compile(r'[<>:"/\\|?*\x00-\x1f]') #: 원가계산 사용자 입력 칸 — 요율 고르는 값 · 손입력(엑셀 총괄 · 원가계산서 · 내역 손입력 칸) ESTIMATE_FIELDS = ( @@ -89,6 +98,12 @@ def clean_user(raw: Any) -> dict[str, Any]: out["applied"] = _num(cell["applied"]) if cell.get("supply") in SUPPLY: out["supply"] = cell["supply"] + if "quote" in cell: + out["quote"] = _num(cell["quote"]) + if isinstance(cell.get("quote_page"), str) and cell["quote_page"].strip(): + out["quote_page"] = cell["quote_page"].strip() + if cell.get("pick") in SOURCES: + out["pick"] = cell["pick"] if out: materials[str(key)] = out estimate: dict[str, Any] = {} @@ -105,10 +120,36 @@ def clean_user(raw: Any) -> dict[str, Any]: def read_user(project_root: str | Path) -> dict[str, Any]: - return clean_user(_read(folder(project_root) / USER)) + user = clean_user(_read(folder(project_root) / USER)) + base = folder(project_root) / QUOTES + for sub in sorted(base.iterdir()) if base.is_dir() else []: + names = [f.name for f in quote_files(sub)] + if names: + user["materials"].setdefault(unquote(sub.name), {})["files"] = names + return user def write_user(project_root: str | Path, user: dict[str, Any]) -> dict[str, Any]: clean = clean_user(user) _write(folder(project_root) / USER, clean) return clean + + +def quote_dir(project_root: str | Path, key: str) -> Path: + """자재 키 → 견적서 폴더 — 못 쓰는 글자 · % 는 %XX(되풀면 키).""" + part = re.sub(r"[%]|" + BAD_NAME.pattern, lambda m: f"%{ord(m.group()):02X}", key) + if not part.strip(" ."): + raise ValueError("자재 키가 비었습니다.") + return folder(project_root) / QUOTES / part + + +def safe_name(name: str) -> str: + """올린 파일 이름 → 저장 이름(경로 떼고 · 못 쓰는 글자 _ · 앞뒤 점 · 빈칸 뺌).""" + return BAD_NAME.sub("_", re.split(r"[\\/]", name)[-1]).strip(" .") + + +def quote_files(path: Path) -> list[Path]: + """견적서 폴더 파일(올리는 중 임시 `.…upload` 뺌) · 이름 차례.""" + if not path.is_dir(): + return [] + return sorted(f for f in path.iterdir() if f.is_file() and not f.name.startswith(".")) diff --git a/resources/tester/test_166_4_b09_quote.py b/resources/tester/test_166_4_b09_quote.py new file mode 100644 index 000000000..9198884ba --- /dev/null +++ b/resources/tester/test_166_4_b09_quote.py @@ -0,0 +1,105 @@ +"""166-4 자재단가대비표 견적 열 · 적용 출처 고르기 · 견적서 여러 파일(임시 storage) · 거울.""" + +import sys +from pathlib import Path +from uuid import uuid4 + +import pytest + +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_Store as store # noqa: E402 +from B09_Estimation.B09_Estimation_Calc_Base import compute_base # noqa: E402 +from resources.tester.test_144_b09_base_calc import _run_ts # noqa: E402 + +MATERIALS = [ + {"key": "MT1", "name": "자갈", "prices": {"물가정보": 23000, "물가자료": 22000}, "qty": 2}, + {"key": "MT2", "name": "폭약", "prices": {"물가자료": 12000}}, + {"key": "b08:번호판", "name": "국가지점번호판", "prices": {}}, + {"key": "MT4", "name": "모래", "prices": {"물가자료": 30000}}, +] +INPUTS = {"labor": [], "machines": [], "materials": MATERIALS} +USER = { + "materials": { + "MT1": {"quote": 21000, "quote_page": "견적 A"}, # 견적이 최저 → 자동 + "MT2": {"quote": 12712, "pick": "견적"}, # 견적이 높아도 고름 + "b08:번호판": {"quote": 520000}, # 견적만 있는 줄 = 견적 자동 + "MT4": {"quote": 29000, "applied": 31000, "pick": "물가자료"}, # 고름 › 손 수 + } +} + + +def test_견적_최저_고름_손수(): + s = compute_base(INPUTS, USER) + rows = {r["key"]: r for r in s["compare"]} + got = { + k: (r["lowest"], r["lowest_from"], r["applied"], r["applied_from"]) for k, r in rows.items() + } + assert got == { + "MT1": (21000, "견적", 21000, "견적"), + "MT2": (12000, "물가자료", 12712, "견적"), + "b08:번호판": (520000, "견적", 520000, "견적"), + "MT4": (29000, "견적", 30000, "물가자료"), + } + assert rows["MT1"]["pages"]["견적"] == "견적 A" and rows["MT2"]["pick"] == "견적" + assert {r["key"]: r["price"] for r in s["material"]}["MT2"] == 12712 # 목록표 단가 따라감 + assert s["material"][0]["amount"] == 42000 + # 손 수만 있으면 「사용자」 · 고름 없으면 최저(견적 포함) + s2 = compute_base(INPUTS, {"materials": {"MT4": {"quote": 29000, "applied": 31000}}}) + assert s2["compare"][3]["applied_from"] == "사용자" + + +def test_거울_TS(): + assert _run_ts(INPUTS, USER) == compute_base(INPUTS, USER) + + +def test_저장_다듬기_파일_목록(tmp_path): + raw = {"materials": {"MT2": {"quote": 12712, "quote_page": " 견적 B ", "pick": "없는 출처", + "files": ["x"]}, "MT1": {"pick": "견적"}}} # fmt: skip + clean = store.write_user(tmp_path, raw) + assert clean["materials"] == { + "MT2": {"quote": 12712, "quote_page": "견적 B"}, + "MT1": {"pick": "견적"}, + } # files 는 저장 안 함(폴더가 목록) + folder = store.quote_dir(tmp_path, "b08:이름|㎥/hr") + folder.mkdir(parents=True) + (folder / "a.pdf").write_bytes(b"1") + (folder / ".a.pdf.123.upload").write_bytes(b"temp") + assert folder.parent == store.folder(tmp_path) / "quotes" + user = store.read_user(tmp_path) + assert user["materials"]["b08:이름|㎥/hr"] == {"files": ["a.pdf"]} + assert store.safe_name("../../x\\y:z.pdf") == "y_z.pdf" and store.safe_name("..") == "" + with pytest.raises(ValueError): + store.quote_dir(tmp_path, "..") + + +def test_올리기_목록_내려받기_지우기(tmp_path, monkeypatch): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + from B09_Estimation import B09_Estimation_Router_Quotes as quotes + + async def root(_project_id): + return tmp_path + + monkeypatch.setattr(quotes, "_project_root", root) + app = FastAPI() + app.include_router(quotes.router, prefix="/api/projects") + client = TestClient(app) + base = f"/api/projects/{uuid4()}/estimation/quotes" + key = {"key": "b08:pipe_600"} + files = [("files", ("견적1.pdf", b"one", "application/pdf")), + ("files", ("sub/견적2.xlsx", b"two!", "application/octet-stream"))] # fmt: skip + listed = client.post(base, params=key, files=files).json() + assert [(f["name"], f["size"]) for f in listed] == [("견적1.pdf", 3), ("견적2.xlsx", 4)] + assert (store.quote_dir(tmp_path, "b08:pipe_600") / "견적1.pdf").read_bytes() == b"one" + assert len(client.get(base, params=key).json()) == 2 + got = client.get(base + "/file", params={**key, "name": "견적2.xlsx"}) + assert got.status_code == 200 and got.content == b"two!" + assert client.get(base + "/file", params={**key, "name": "../x"}).status_code == 404 + assert client.get(base, params={"key": ".."}).status_code == 400 + left = client.delete(base, params={**key, "name": "견적1.pdf"}).json() + assert [f["name"] for f in left] == ["견적2.xlsx"] + assert store.read_user(tmp_path)["materials"] == {"b08:pipe_600": {"files": ["견적2.xlsx"]}}