Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1

This commit is contained in:
2026-10-10 00:15:12 +09:00
10 changed files with 531 additions and 33 deletions
+23 -9
View File
@@ -8,8 +8,12 @@
노무비목록표 · 경비목록표 = 굳힌 단가 그대로(사용자 고침 없음 · [단가 갱신] 때만 새로)
환율및기초자료 = 환율 · 운전사 시간 노임 = 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 열 자리 · 수량 · 단가 하나라도 빈칸이면 빈칸)
중기사용료 = 손료(경비 · 기준가 천원 × 손료계수 ÷ 10,000) + 운전사(노무 · 인 × 시간 노임)
+ 연료(재료 · ℓ/hr × 적용 단가) + 잡품(재료 · 연료 금액 × % ÷ 100)
줄 = ROUNDDOWN(단가 × 수량, 1) · 비목 합 = ROUNDDOWN(Σ, 0) → 중기목록표
@@ -26,8 +30,8 @@ from typing import Any
EPS = 1e-7
#: 자재단가대비표 출처 칸 차례 — 엑셀 조달 · 물가정보 · 물가자료(같은 값이면 앞 칸)
# ↔ M01 재료_자재품목 값 열 · 유가 = 오피넷
SOURCES = ("관급", "물가정보", "물가자료", "유통물가", "거래가격", "유가")
# ↔ M01 재료_자재품목 값 열 · 유가 = 오피넷 · 견적 = 사용자 손입력(엑셀 M/N 열)
SOURCES = ("관급", "물가정보", "물가자료", "유통물가", "거래가격", "유가", "견적")
#: 자재 줄 사용자 표시
SUPPLY = ("관급", "별도")
@@ -142,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
@@ -157,21 +164,28 @@ 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")),
}
)
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]:
+29 -10
View File
@@ -7,7 +7,9 @@
* 조작 중은 이 파일이 바로 셈 · [저장] 때 서버가 파이썬 짝으로 정본을 다시 셈.
*
* 노무비 · 경비목록표 = 굳힌 단가 · 환율및기초자료 = 운전사 시간 노임 ROUNDDOWN(일당 × 분자 ÷ 분모, 0)
* 자재단가대비표 = 출처 중 최저 자동 · 사용자 적용 단가 앞섬 · 관급 · 별도 표시 → 재료비목록표
* 자재단가대비표 = 출처 중 최저 자동(견적 포함) · 적용 = 고른 출처 › 사용자 단가 › 최저 ·
* 관급 · 별도 표시 → 재료비목록표
* 재료비목록표 = 수량(B08 총괄 합계 열) × 적용 단가 = 총액 ROUND(·, 0) · 하나라도 빈칸이면 빈칸
* 중기사용료 = 손료 + 운전사 + 연료 + 잡품 · 줄 ROUNDDOWN 1 · 비목 합 ROUNDDOWN 0 → 중기목록표
* 빈칸 = 원천이 없으면 null · 그 칸을 쓰는 합도 null · 「연결 없음」 목록(gaps)에 남김
* ========================================================================== */
@@ -26,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;
@@ -118,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 || "",
@@ -137,17 +149,24 @@ 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),
};
});
}
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(
+12 -2
View File
@@ -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),
+5
View File
@@ -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)
@@ -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)
+44 -3
View File
@@ -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("."))
+53 -9
View File
@@ -2,13 +2,17 @@
* B09_Estimation_UI_Tabs_Base.ts
* B09 바탕 단가 탭 일곱(PLAN 144-2~4) — 노무비 · 경비목록표 · 환율및기초자료 · 자재단가대비표 ·
* 재료비목록표 · 중기사용료 · 중기목록표(실무 엑셀 꼴 · 값 = `B09_Estimation_Calc_Base`).
* 재료비목록표 = 엑셀 꼴 + 수량(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,
@@ -103,9 +107,16 @@ export const buildBasis: TabBuild = ({ sheets }): TabTable => {
const SUPPLY_NEXT: Record<string, string | null> = { "": "관급", 관급: "별도", 별도: 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,
@@ -114,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];
@@ -141,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;
@@ -179,13 +218,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),
};
};
@@ -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<Response> {
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<void> {
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);
},
});
}
@@ -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
+105
View File
@@ -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"]}}