feat: 成本核算模块(鲁铭成本核算Excel全量适配, 价格库580条+75/85/95三系列模板+估算器基准精确还原)

- 解析12表成本核算工作簿: 价格库8表→产品价格本(华健型材模具/丝吉利娅/索宾柯五金/隔热条/胶条/赛遨辅材/螺丝/其他)
- 「铝成本」页→系列成本模板(75框扇齐平1.4/85框扇齐平1.8/95窗纱一体1.8, 八分区JSON)
- 估算器口径对齐核算表: 固定/开启/总面积归因缩放+人工每平实际口径+毛利阶梯10%~70%
- 75系列基准估算total=1118.20与核算表成本合计完全一致(85/95同)
- API /api/costbook: import-excel/series/estimate; 前端 /cost_estimate 页
- 种子 seed_costbook.py; 修复6处解析缺陷; 冒烟60/60+24页巡检0异常+深度审计16/16
This commit is contained in:
HHY
2026-10-05 05:59:08 -07:00
parent 85c51e280c
commit 6ccd36dc3d
17 changed files with 942 additions and 0 deletions
+107
View File
@@ -0,0 +1,107 @@
"""成本核算路由:系列成本模板(取自成本核算 Excel)+ 价格库导入 + 成本估算。"""
import json
from fastapi import APIRouter, UploadFile
from pydantic import BaseModel, Field
from sqlalchemy import select
from huamenchuang.core.deps import CurrentUser, DbSession
from huamenchuang.core.response import fail, ok
from huamenchuang.models.business import CostSeriesTemplate
from huamenchuang.services import costbook_service as svc
router = APIRouter(tags=["成本核算"])
def _brief(t: CostSeriesTemplate) -> dict:
data = json.loads(t.data)
return {"id": t.id, "name": t.name, "series": t.series, "wall_thick": t.wall_thick,
"source_file": t.source_file, "enabled": t.enabled, "window": data.get("window")}
def _full(t: CostSeriesTemplate) -> dict:
return {**_brief(t), "data": json.loads(t.data)}
@router.post("/costbook/import-excel")
async def import_excel(file: UploadFile, user: CurrentUser, db: DbSession):
"""上传成本核算 Excel(12 sheet 鲁铭格式):价格库 8 表入产品价格本,铝成本页入系列模板。"""
blob = await file.read()
if not blob:
return fail("文件为空")
import io
from openpyxl import load_workbook
try:
wb = load_workbook(io.BytesIO(blob), data_only=True)
parsed = {"file": file.filename or "upload.xlsx",
"series_summary": svc._parse_series_summary(wb["铝成本"]),
"libraries": {sn: svc._parse_library(wb[sn])
for sn in svc.LIB_SHEETS if sn in wb.sheetnames},
"optimize_stats": svc._parse_optimize_stats(wb["铝计算"]) if "铝计算" in wb.sheetnames else {}}
except KeyError as e:
return fail(f"缺少必需工作表 {e},请确认上传成本核算格式工作簿")
except Exception as e: # noqa: BLE001
return fail(f"解析失败:{e}")
lib_stats = await svc.import_libraries(db, user.company_id, parsed["libraries"])
await svc.import_series(db, user.company_id, parsed)
return ok(data={"libraries": lib_stats, "optimize_stats": parsed["optimize_stats"]},
message=f"导入完成:价格库新增 {lib_stats['created']} / 更新 {lib_stats['updated']} 条")
@router.get("/costbook/series")
async def list_series(user: CurrentUser, db: DbSession):
rows = list((await db.execute(select(CostSeriesTemplate).where(
CostSeriesTemplate.company_id == user.company_id)
.order_by(CostSeriesTemplate.series, CostSeriesTemplate.id))).scalars())
return ok(data=[_brief(t) for t in rows])
class EstimateBody(BaseModel):
series_id: int
width: float = Field(gt=0, description="窗宽 mm")
height: float = Field(gt=0, description="窗高 mm")
count: int = Field(default=1, ge=1, description="樘数")
sash_count: int | None = Field(default=None, ge=0, description="开启扇数,缺省按基准开启率")
@router.post("/costbook/estimate")
async def estimate(body: EstimateBody, user: CurrentUser, db: DbSession):
t = await db.get(CostSeriesTemplate, body.series_id)
if t is None or t.company_id != user.company_id:
return fail("系列模板不存在")
return ok(data=svc.estimate(t, body.width, body.height, body.count, body.sash_count))
@router.get("/costbook/series/{sid}")
async def get_series(sid: int, user: CurrentUser, db: DbSession):
t = await db.get(CostSeriesTemplate, sid)
if t is None or t.company_id != user.company_id:
return fail("系列模板不存在")
return ok(data=_full(t))
class SeriesBody(BaseModel):
name: str = Field(min_length=1, max_length=64)
series: int = Field(ge=0)
wall_thick: float = Field(gt=0)
data: dict
@router.post("/costbook/series")
async def create_series(body: SeriesBody, user: CurrentUser, db: DbSession):
t = CostSeriesTemplate(company_id=user.company_id, name=body.name, series=body.series,
wall_thick=body.wall_thick,
data=json.dumps(body.data, ensure_ascii=False))
db.add(t)
await db.flush()
return ok(data={"id": t.id}, message="系列模板已创建")
@router.delete("/costbook/series/{sid}")
async def delete_series(sid: int, user: CurrentUser, db: DbSession):
t = await db.get(CostSeriesTemplate, sid)
if t is None or t.company_id != user.company_id:
return fail("系列模板不存在")
await db.delete(t)
return ok(message="系列模板已删除")
+2
View File
@@ -6,6 +6,7 @@ from huamenchuang.api import (
auth,
business,
contracts,
costbook,
customers,
finance,
optimize,
@@ -45,6 +46,7 @@ api_router.include_router(salesmen.router)
api_router.include_router(purchases.router)
api_router.include_router(warehouse.router)
api_router.include_router(business.router)
api_router.include_router(costbook.router)
api_router.include_router(platform.router)
api_router.include_router(reports.router)
api_router.include_router(platform.router)