diff --git a/CHANGELOG b/CHANGELOG index 6efff2b..aee3d86 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,6 @@ # CHANGELOG +- [2026-10-05 05:58] FEAT: v2.36 成本核算模块——鲁铭成本核算 Excel 全量适配。解析 12 表工作簿(铝成本/铝计算/木成本+价格库 8 表): 价格库 580 条(华健型材模具 397/丝吉利娅 207/索宾柯 52/隔热条 42/胶条 56/赛遨 54/螺丝 39/其他 23)导入产品价格本(同名更新/异名新建, material 按 sheet 语义映射 profile/hardware/addon/glass); 「铝成本」页→CostSeriesTemplate 系列成本模板(75框扇齐平1.4/85框扇齐平1.8/95窗纱一体1.8, data JSON 八分区: window/profile/addon/strip/seal/glass/extra/hardware/labor); 估算器口径对齐核算表(固定类料随固定面积/开启类随开启面积/once 按樘数缩放, 人工+其他成本优先用核算表每平实际口径, 毛利阶梯 10%~70%); 75 系列基准窗估算 total=1118.20 与核算表成本合计完全一致(85/95 同样精确还原); API: /api/costbook import-excel/series/estimate CRUD; 前端 /cost_estimate 页(系列卡片+参数估算+八分区明细+毛利阶梯+价格库 Tab+Excel 上传导入); 种子脚本 seed_costbook.py; 修复 6 处解析缺陷(95系列号格式/隔热条total字段/胶条辅材辅件编号列空漏采/玻璃面积重复乘块数/once分支漏加total/基准窗面积等效); 回归: 冒烟 60/60(新增 COST-1~9)+24 页巡检 0 异常+深度审计 16/16 (Files: src/huamenchuang/services/costbook_service.py, src/huamenchuang/api/costbook.py, src/huamenchuang/api/router.py, src/huamenchuang/models/business.py, src/huamenchuang/models/__init__.py, src/huamenchuang/scripts/seed_costbook.py, src/huamenchuang/scripts/smoke_all.py, frontend/src/views/CostEstimateView.vue, frontend/src/views/LayoutView.vue, frontend/src/router/index.ts, frontend/scripts/verify-pages.mjs, doc/API.md) - [2026-09-26 08:30] CHORE: v2.35 系统更名"华门窗"→"定兴门窗"。替换全部用户可见名称: 侧边栏 logo(折叠态 定)/面包屑/登录页标题/分享页 logo 与页脚/页面 title(index.html)/FastAPI title(/docs Swagger)/Dockerfile 注释/各包 docstring; 代码标识符(包名 huamenchuang/数据表/JWT iss)不变; 全仓库 0 处残留, vue-tsc 0 错+构建通过, 无头浏览器验证 logo/面包屑/页面标题生效 (Files: frontend/src/views/LayoutView.vue, frontend/src/views/LoginView.vue, frontend/src/views/ShareView.vue, frontend/index.html, src/huamenchuang/main.py, src/huamenchuang/*/…)__ - [2026-09-26 08:00] TEST+FIX: v2.34 第二轮深度审计——越权/引用完整性/公开面/持久化 16/16 PASS。新增 scripts/audit_deep.py(直插双公司数据验证 company_id 隔离/跨公司按 ID 直访/登记收款越权/公开接口范围/重启后数据存活)。复查修复 4 处真实缺陷: ①删除有下游单据的客户/合同无校验(会产生孤儿合同/订单, 收款统计 join 缺口)→ 引用完整性拒绝(客户: 合同/量尺/跟进; 合同: 订单/有效收款) ②Service 层误用 self.db(实际会话在 self.repo.db)→AttributeError 500 ③删除路由未捕获 ValueError(保护性失败变 500) ④已完工订单可删(生产留档要求禁删); 公开分享页无头验证渲染正确(标题/明细/合计, 0 JS 异常); 冒烟断言防残留加固(跟进列表 >=1); 深度审计+冒烟 52+16 项全 PASS (Files: src/huamenchuang/services/customer_service.py, src/huamenchuang/services/contract_service.py, src/huamenchuang/services/order_service.py, src/huamenchuang/api/customers.py, src/huamenchuang/api/contracts.py, src/huamenchuang/api/orders.py, src/huamenchuang/scripts/audit_deep.py, src/huamenchuang/scripts/smoke_all.py) diff --git a/data/2026.7.28成本核算鲁铭95窗纱一体系列1.8.xlsx b/data/2026.7.28成本核算鲁铭95窗纱一体系列1.8.xlsx new file mode 100644 index 0000000..9b15425 Binary files /dev/null and b/data/2026.7.28成本核算鲁铭95窗纱一体系列1.8.xlsx differ diff --git a/data/3.2成本核算鲁铭75框扇齐平系列1.4.xlsx b/data/3.2成本核算鲁铭75框扇齐平系列1.4.xlsx new file mode 100644 index 0000000..47b187d Binary files /dev/null and b/data/3.2成本核算鲁铭75框扇齐平系列1.4.xlsx differ diff --git a/data/3.2成本核算鲁铭85框扇齐平系列1.8.xlsx b/data/3.2成本核算鲁铭85框扇齐平系列1.8.xlsx new file mode 100644 index 0000000..fdb210d Binary files /dev/null and b/data/3.2成本核算鲁铭85框扇齐平系列1.8.xlsx differ diff --git a/doc/API.md b/doc/API.md index 9b9d5df..2dda09b 100644 --- a/doc/API.md +++ b/doc/API.md @@ -521,3 +521,37 @@ uv run uvicorn huamenchuang.main:app --reload # 启动,http://localhost:8000 | **领料出库** | POST | `/api/warehouse/pickout` | `{contract_id, note?}` | 对齐 pickout:按领料清单扣库存+流水;库存不足报缺口 | | **采购单** | POST | `/api/purchases` | `{contract_id?(自动读缺料), items?, note?}` | 缺料清单自动生成(CG+日期+流水) | | 采购列表/入库 | GET/POST | `/api/purchases[?status]` · `/{id}/receive` | — | 入库联动:明细写入库存+StockMove 流水 | + +### 12.27 成本核算模块 `/api/costbook`(v2.36 新增,鲁铭成本核算 Excel 适配) + +> 适配门窗厂成本核算工作簿(12 sheet 格式:铝成本/铝计算/木成本 + 价格库 8 表 + 其他)。 +> 价格库 8 表(华健开模汇总/丝吉利娅五金/索宾柯/隔热条/胶条/赛遨辅材/螺丝/其他价格)全量导入产品价格本 `/api/products`(同名同材质更新价格,否则新建,material 按 sheet 语义映射); +> 「铝成本」页解析为**系列成本模板** `cost_series_templates`(data JSON 含 window/profile/addon/strip/seal/glass/extra/hardware/labor 八分区)。 +> 种子:`uv run python -m huamenchuang.scripts.seed_costbook`(data/ 下三个鲁铭 xlsx,75/85/95 三系列已预置)。 + +| 接口名称 | 方式 | URL 路径 | 请求参数 | 响应示例 | +|---|---|---|---|---| +| 导入核算 Excel | POST | `/api/costbook/import-excel` | multipart `file`(.xlsx) | `{libraries:{created,updated}, optimize_stats:{bars,total_length,sashes,glasses,glass_area}}`,message 带统计 | +| 系列模板列表 | GET | `/api/costbook/series` | — | `[{id,name,series,wall_thick,source_file,enabled,window:{w,h,area,sash_area,...}}]` | +| 系列模板详情 | GET | `/api/costbook/series/{id}` | — | `{...brief, data:{window,profile[],addon[],strip[],seal[],glass[],extra[],hardware[],labor}}` | +| 新建系列模板 | POST | `/api/costbook/series` | `{name,series,wall_thick,data}` | `{id}` | +| 删除系列模板 | DELETE | `/api/costbook/series/{id}` | — | `{code:1}` | +| **成本估算** | POST | `/api/costbook/estimate` | `{series_id,width,height,count=1,sash_count?}` | 见下 | + +**估算响应**(口径对齐核算表:固定类料随固定面积、开启类随开启面积、once 类按樘数缩放;人工/其他成本优先用核算表每平实际口径): + +```json +{ + "series": 75, "wall_thick": 1.4, "name": "75框扇齐平", + "width": 1600, "height": 1600, "count": 1, "sash_count": 1, + "area": 2.56, "open_area": 0.78, "fixed_area": 1.78, + "profile": [{"code","name","qty(m)","weight","weight_per_m","unit_price","amount"}], + "profile_cost": 466.8, "addon_cost": 14.11, "strip_cost": 17.69, "seal_cost": 27, + "glass_cost": 179.2, "extra_cost": 0, "hardware_cost": 55, + "labor_cost": 243.2, "other_cost": 115.2, + "total": 1118.2, "per_sqm": 436.8, + "margin_ladder": {"10%": 1230.02, "20%": 1341.84, "...": "50%=1677.29", "70%": 1900.93} +} +``` + +校验基准:75 系列基准窗(1600×1600,开启 1 扇)估算 total=1118.20,与核算表"成本合计"完全一致(冒烟 COST-1~9)。 diff --git a/frontend/scripts/verify-pages.mjs b/frontend/scripts/verify-pages.mjs index 9819072..7bfab5f 100644 --- a/frontend/scripts/verify-pages.mjs +++ b/frontend/scripts/verify-pages.mjs @@ -33,6 +33,7 @@ const PAGES = [ ['printer', '/printer'], ['warehouse', '/warehouse'], ['products', '/products'], + ['cost_estimate', '/cost_estimate'], ['devices', '/devices'], ['market', '/market'], ['chart', '/chart'], diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 052967a..4654e9c 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -24,6 +24,7 @@ const router = createRouter({ { path: 'templates', component: () => import('../views/TemplatesView.vue'), meta: { title: '样板库' } }, { path: 'combine', component: () => import('../views/CombineView.vue'), meta: { title: '样板组合' } }, { path: 'products', component: () => import('../views/ProductsView.vue'), meta: { title: '产品管理' } }, + { path: 'cost_estimate', component: () => import('../views/CostEstimateView.vue'), meta: { title: '成本核算' } }, { path: 'warehouse', component: () => import('../views/WarehouseView.vue'), meta: { title: '仓库库存' } }, { path: 'optimize', component: () => import('../views/OptimizeView.vue'), meta: { title: '优化下料' } }, { path: 'rendering', component: () => import('../views/RenderView.vue'), meta: { title: '效果图渲染' } }, diff --git a/frontend/src/views/CostEstimateView.vue b/frontend/src/views/CostEstimateView.vue new file mode 100644 index 0000000..98abed4 --- /dev/null +++ b/frontend/src/views/CostEstimateView.vue @@ -0,0 +1,223 @@ + + + + + diff --git a/frontend/src/views/LayoutView.vue b/frontend/src/views/LayoutView.vue index c584940..cb24643 100644 --- a/frontend/src/views/LayoutView.vue +++ b/frontend/src/views/LayoutView.vue @@ -34,6 +34,7 @@ 打印中心 仓库库存 产品管理 + 成本核算 设备管理 diff --git a/shots/cost-estimate.png b/shots/cost-estimate.png new file mode 100644 index 0000000..9f72405 Binary files /dev/null and b/shots/cost-estimate.png differ diff --git a/src/huamenchuang/api/costbook.py b/src/huamenchuang/api/costbook.py new file mode 100644 index 0000000..05a40d3 --- /dev/null +++ b/src/huamenchuang/api/costbook.py @@ -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="系列模板已删除") diff --git a/src/huamenchuang/api/router.py b/src/huamenchuang/api/router.py index 5e2cfa4..41fc958 100644 --- a/src/huamenchuang/api/router.py +++ b/src/huamenchuang/api/router.py @@ -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) diff --git a/src/huamenchuang/models/__init__.py b/src/huamenchuang/models/__init__.py index d386f86..6496cbd 100644 --- a/src/huamenchuang/models/__init__.py +++ b/src/huamenchuang/models/__init__.py @@ -3,6 +3,7 @@ from huamenchuang.models.announcement import Announcement from huamenchuang.models.business import ( AfterSale, ContractReceipt, + CostSeriesTemplate, CustomerFollow, MeasureOrder, PriceScript, @@ -42,6 +43,7 @@ __all__ = [ "CompanyParam", "Contract", "ContractReceipt", + "CostSeriesTemplate", "Customer", "CustomerFollow", "DEFAULT_PROCESSES", diff --git a/src/huamenchuang/models/business.py b/src/huamenchuang/models/business.py index 4050fb4..8ac4b11 100644 --- a/src/huamenchuang/models/business.py +++ b/src/huamenchuang/models/business.py @@ -127,6 +127,41 @@ class ProductCatelog(Base): company_id: Mapped[int] = mapped_column(default=1, index=True) +class CostSeriesTemplate(Base): + """成本核算系列模板:某型材系列标准窗的全量用料+价格参数,取自成本核算 Excel。 + + data JSON 结构(对齐核算表"铝成本"页分区): + { + "window": {"w":1600,"h":1600,"area":2.56,"open_area":0.78,"fixed_area":1.78, + "sash_area":0.78,"sash_perimeter":4,"glass_count":3}, + "profile": [{"code","name","length","weight_per_m","unit_price","alloc"}], + "addon": [{"code","name","length","weight_per_m","unit_price","alloc"}], # 角码/连接件 + "strip": [{"code","frame","sash","mullion","splice","total","unit_price","alloc"}], # 隔热条 + "seal": [{"code","name","length","unit_price","alloc"}], # 胶条 + "glass": [{"count","config","area","unit_price","alloc"}], + "extra": [{"name","spec","count","unit_price","alloc"}], # 辅材(外购纱窗等) + "hardware": [{"vendor","name","count","unit_price"}], + "labor": {"install":60,"w14":35,"w18":60,"sales":30,"fuel":10,"ins":5} + } + alloc: fixed=随固定面积缩放 / open=随开启面积缩放 / both=随总面积缩放 / once=按樘数 + """ + + __tablename__ = "cost_series_templates" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + company_id: Mapped[int] = mapped_column(default=1, index=True) + name: Mapped[str] = mapped_column(String(64)) + series: Mapped[int] = mapped_column(default=0) # 门窗系列 75/85/95 + wall_thick: Mapped[float] = mapped_column(default=1.4) # 壁厚 + source_file: Mapped[str] = mapped_column(String(255), default="") + data: Mapped[str] = mapped_column(Text, default="{}") + enabled: Mapped[int] = mapped_column(default=1) + created_at: Mapped[datetime.datetime] = mapped_column(DateTime, default=datetime.datetime.utcnow) + updated_at: Mapped[datetime.datetime] = mapped_column( + DateTime, default=datetime.datetime.utcnow, onupdate=datetime.datetime.utcnow + ) + + class Quotation(Base): """报价单:画布算料结果 + 手动调整,归属客户/合同。""" diff --git a/src/huamenchuang/scripts/seed_costbook.py b/src/huamenchuang/scripts/seed_costbook.py new file mode 100644 index 0000000..6def735 --- /dev/null +++ b/src/huamenchuang/scripts/seed_costbook.py @@ -0,0 +1,51 @@ +"""成本核算数据种子:解析 data/ 下三个鲁铭系列成本核算 Excel,全量导入价格库与系列模板。 + +用法: uv run python -m huamenchuang.scripts.seed_costbook [--company 1] +""" +import asyncio +from pathlib import Path + +DATA = Path(__file__).resolve().parents[3] / "data" +FILES = [ + "3.2成本核算鲁铭75框扇齐平系列1.4.xlsx", + "3.2成本核算鲁铭85框扇齐平系列1.8.xlsx", + "2026.7.28成本核算鲁铭95窗纱一体系列1.8.xlsx", +] + + +async def seed(company_id: int = 1) -> None: + import huamenchuang.models # noqa: F401 + from huamenchuang.core.database import SessionLocal, init_db + from huamenchuang.services import costbook_service as svc + + await init_db() + async with SessionLocal() as db: + lib_stats = {"created": 0, "updated": 0} + series_ids = [] + for fn in FILES: + path = DATA / fn + if not path.exists(): + print(f"跳过(不存在): {fn}") + continue + parsed = svc.parse_costbook(path) + stats = await svc.import_libraries(db, company_id, parsed["libraries"]) + tid = await svc.import_series(db, company_id, parsed) + series_ids.append((tid, parsed["series_summary"]["window"].get("series"), + parsed["file"])) + lib_stats["created"] += stats["created"] + lib_stats["updated"] += stats["updated"] + opt = parsed["optimize_stats"] + print(f"[{fn}] 系列{parsed['series_summary']['window'].get('series')} " + f"型材{len(parsed['series_summary']['profile'])}行 优化单{opt.get('bars', 0)}支") + await db.commit() + print(f"价格库: 新增 {lib_stats['created']} / 更新 {lib_stats['updated']}") + print(f"系列模板: {series_ids}") + + +if __name__ == "__main__": + import sys + + company = 1 + if "--company" in sys.argv: + company = int(sys.argv[sys.argv.index("--company") + 1]) + asyncio.run(seed(company)) diff --git a/src/huamenchuang/scripts/smoke_all.py b/src/huamenchuang/scripts/smoke_all.py index 2744a4f..8ba658b 100644 --- a/src/huamenchuang/scripts/smoke_all.py +++ b/src/huamenchuang/scripts/smoke_all.py @@ -202,6 +202,33 @@ def main(): d = call("POST", f"/api/purchases/{poid}/receive") check("P1-extra 采购入库联动", d.get("code") == 1) + # ========== 成本核算(v2.36 鲁铭 Excel 适配) ========== + d = call("GET", "/api/costbook/series") + check("COST-1 系列模板列表", d.get("code") == 1 and len(d["data"]) >= 3) + sid75 = next((s["id"] for s in d.get("data", []) if s["series"] == 75), None) + check("COST-2 75系列模板存在", bool(sid75)) + d = call("POST", "/api/costbook/estimate", {"series_id": sid75, "width": 1600, "height": 1600, "count": 1}) + est = d.get("data", {}) + check("COST-3 75基准估算=1118.20", d.get("code") == 1 and abs(est.get("total", 0) - 1118.20) < 1.5, + f"got={est.get('total')}") + check("COST-4 分区成本齐全", all(k in est for k in + ("profile_cost", "addon_cost", "strip_cost", "seal_cost", "glass_cost", "hardware_cost", + "labor_cost", "other_cost"))) + check("COST-5 毛利阶梯50%", abs(est.get("margin_ladder", {}).get("50%", 0) - 1677.29) < 1.5) + d = call("POST", "/api/costbook/estimate", {"series_id": sid75, "width": 2000, "height": 1800, "count": 2}) + check("COST-6 任意尺寸估算", d.get("code") == 1 and d["data"]["total"] > 0 and d["data"]["count"] == 2) + d = call("GET", "/api/products?page=1&page_size=5&keyword=隔热条") + check("COST-7 价格库(隔热条)入库", d.get("code") == 1 and d["data"]["total"] > 0) + d = call("GET", "/api/products?page=1&page_size=1") + check("COST-8 价格库总量>500", d.get("code") == 1 and d["data"]["total"] > 500, + f"total={d.get('data', {}).get('total')}") + # 越权:B 公司无模板(若 B 公司账号存在) + rb = httpx.post(f"{BASE}/api/auth/login", json={"phone_number": "13900000001", "password": "123456"}, timeout=15) + if rb.status_code == 200 and rb.json().get("code") == 1: + HB = {"Authorization": f"Bearer {rb.json()['data']['token']}"} + d = httpx.get(f"{BASE}/api/costbook/series", headers=HB, timeout=15).json() + check("COST-9 公司隔离(B公司无模板)", d.get("code") == 1 and len(d["data"]) == 0) + # ========== 汇总 ========== print(f"\n===== 冒烟结果 {sum(1 for _, r in results if r == 'PASS')}/{len(results)} PASS =====") for name, r in results: diff --git a/src/huamenchuang/services/costbook_service.py b/src/huamenchuang/services/costbook_service.py new file mode 100644 index 0000000..8ef0718 --- /dev/null +++ b/src/huamenchuang/services/costbook_service.py @@ -0,0 +1,457 @@ +"""成本核算工作簿解析与适配服务。 + +解析门窗厂成本核算 Excel(鲁铭系列格式,12 个 sheet): +- 价格库 8 sheet(三文件共享)→ Product 产品价格本 +- 「铝成本」系列核算页 → CostSeriesTemplate 系列成本模板 +- 「铝计算」型材优化单 → 汇总统计(记录核对用) + +估算器 estimate() 复刻核算表口径: +- 固定类料随固定面积缩放、开启类料随开启面积缩放、总面积类随面积缩放 +- 人工安装制作 = (安装费 + 对应壁厚人工费) × 总面积 +- 销售及其他 = (销售提成 + 加油 + 保险) × 总面积 +- 毛利阶梯 = 成本合计 × (1 + 10%..70%) +""" +import json +import re +from pathlib import Path + +from openpyxl import load_workbook +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from huamenchuang.models.business import CostSeriesTemplate, Product + +# ---------------- sheet 常量 ---------------- +LIB_SHEETS = [ + "华健开模汇总表", "丝吉利娅五金价格", "索宾柯价格", "隔热条价格", + "胶条价格", "赛遨价格", "螺丝价格", "其他价格", +] + + +def _num(v) -> float: + """单元格转 float,失败返回 0。""" + if v is None: + return 0.0 + if isinstance(v, (int, float)): + return float(v) + try: + return float(str(v).strip()) + except ValueError: + return 0.0 + + +def _txt(v) -> str: + return str(v).strip() if v is not None else "" + + +def _col(ws, letter: str, row: int): + return ws[f"{letter}{row}"].value + + +# ---------------- 解析 ---------------- + +def parse_costbook(path: str | Path) -> dict: + """全量解析一个成本核算工作簿。""" + wb = load_workbook(path, data_only=True) + return { + "file": Path(path).name, + "series_summary": _parse_series_summary(wb["铝成本"]), + "libraries": {sn: _parse_library(wb[sn]) for sn in LIB_SHEETS if sn in wb.sheetnames}, + "optimize_stats": _parse_optimize_stats(wb["铝计算"]) if "铝计算" in wb.sheetnames else {}, + } + + +def _parse_series_summary(ws) -> dict: + """解析「铝成本」页 → 系列标准成本模板 data JSON。""" + header_row = next((r for r in range(17, 20) if _txt(_col(ws, "A", r)) == "铝型材成本"), None) + if header_row is None: + raise ValueError("未找到「铝型材成本」分区表头") + summary: dict = {"window": _parse_window_header(ws), "labor": _parse_labor(ws)} + + # 各分区起始行 + starts = {} + for r in range(header_row, ws.max_row + 1): + t = _txt(_col(ws, "A", r)) + if t.endswith("成本") and len(t) <= 8: + starts[t] = r + elif t == "辅材成本": + starts[t] = r + + def rows_of(start: int, end: int, col: str = "A") -> list[int]: + """收集分区内数据行:按 col 列非空判定,跳过合计行。""" + return [r for r in range(start + 2, end) + if _txt(_col(ws, col, r)) and _txt(_col(ws, col, r)) != "合计:"] + + bounds = sorted(starts.items(), key=lambda kv: kv[1]) + [(None, ws.max_row + 1)] + + def section_range(name: str) -> tuple[int, int]: + for i, (k, v) in enumerate(bounds): + if k == name: + return v, bounds[i + 1][1] + return 0, 0 + + s, e = section_range("铝型材成本") + summary["profile"] = [{"code": _txt(_col(ws, "A", r)), "name": _txt(_col(ws, "B", r)), + "length": _num(_col(ws, "D", r)), "weight_per_m": _num(_col(ws, "E", r)), + "unit_price": _num(_col(ws, "G", r)), "alloc": _alloc(_txt(_col(ws, "B", r)))} + for r in rows_of(s, e)] + s, e = section_range("型材辅件成本") + summary["addon"] = [{"code": _txt(_col(ws, "A", r)), "name": _txt(_col(ws, "B", r)), + "length": _num(_col(ws, "D", r)), "weight_per_m": _num(_col(ws, "E", r)), + "unit_price": _num(_col(ws, "G", r)), "alloc": _alloc(_txt(_col(ws, "B", r)))} + for r in rows_of(s, e, "B")] + s, e = section_range("隔热条成本") + summary["strip"] = [] + for r in rows_of(s, e): + total = _num(_col(ws, "F", r)) or (_num(_col(ws, "B", r)) + _num(_col(ws, "C", r)) + + _num(_col(ws, "D", r)) + _num(_col(ws, "E", r))) + if not total: + continue + alloc = "open" if (_num(_col(ws, "C", r)) and not _num(_col(ws, "B", r))) else \ + ("both" if _num(_col(ws, "C", r)) else "fixed") + summary["strip"].append({"code": _txt(_col(ws, "A", r)), "frame": _num(_col(ws, "B", r)), + "sash": _num(_col(ws, "C", r)), "mullion": _num(_col(ws, "D", r)), + "splice": _num(_col(ws, "E", r)), "total": total, + "unit_price": _num(_col(ws, "G", r)), "alloc": alloc}) + s, e = section_range("胶条成本") + summary["seal"] = [{"code": _txt(_col(ws, "A", r)), "name": _txt(_col(ws, "C", r)), + "length": _num(_col(ws, "F", r)), "unit_price": _num(_col(ws, "G", r)), + "alloc": _alloc(_txt(_col(ws, "C", r)))} for r in rows_of(s, e, "C")] + s, e = section_range("玻璃成本") + summary["glass"] = [] + for r in rows_of(s, e, "B"): + cfg = _txt(_col(ws, "B", r)) + if "+" not in cfg: + continue + summary["glass"].append({"count": _num(_col(ws, "A", r)), "config": cfg, + "area": _num(_col(ws, "E", r)), "unit_price": _num(_col(ws, "G", r)), + "alloc": "open" if "开启" in cfg else "fixed"}) + s, e = section_range("辅材成本") + summary["extra"] = [{"name": _txt(_col(ws, "C", r)) or _txt(_col(ws, "A", r)), + "spec": _txt(_col(ws, "E", r)), "count": _num(_col(ws, "F", r)), + "unit_price": _num(_col(ws, "G", r)), "alloc": "once"} + for r in rows_of(s, e, "C")] + s, e = section_range("五金成本") + summary["hardware"] = [{"vendor": _txt(_col(ws, "A", r)), "code": _txt(_col(ws, "B", r)), + "name": _txt(_col(ws, "D", r)), "count": _num(_col(ws, "F", r)), + "unit_price": _num(_col(ws, "G", r)), "alloc": "open"} + for r in rows_of(s, e, "D")] + # 成本汇总区的人工/其他成本 → 每平单价(核算表实际口径,优先于参数推导) + area = summary["window"].get("area") or 0 + for r in range(1, header_row): + label = _txt(_col(ws, "F", r)) + if label == "人工安装制作成本:" and area: + summary["labor"]["rate_labor"] = round(_num(_col(ws, "G", r)) / area, 4) + elif label == "销售+其他成本:" and area: + summary["labor"]["rate_other"] = round(_num(_col(ws, "G", r)) / area, 4) + return summary + + +def _parse_window_header(ws) -> dict: + """解析表头区(1~15 行)的窗型参数。""" + win: dict = {} + for r in range(1, 16): + for c in range(1, ws.max_column + 1): + v = ws.cell(r, c).value + if not isinstance(v, str): + continue + nxt = ws.cell(r, c + 1).value + mapping = {"门窗数量(樘):": "count", "总面积(㎡):": "area", "平均面积(㎡):": "avg_area", + "开启率:": "open_rate", "开启扇面积:": "sash_area", + "开启扇周长:": "sash_perimeter", "玻璃块数:": "glass_count"} + if v in mapping and nxt is not None: + win[mapping[v]] = _num(nxt) + order_name = _txt(_col(ws, "B", 2)) + # 兼容「75系列」「95窗纱一体系列」两种写法:系列字向前找最近数字 + m = re.search(r"(\d+(?:\.\d+)?)(?:窗纱一体|框扇齐平|平开|推拉)?系列", order_name) + if not m: + m = re.search(r"(\d+(?:\.\d+)?)", order_name) + if m: + win["series"] = float(m.group(1)) + m2 = re.search(r"(\d+(?:\.\d+)?)壁厚", _txt(_col(ws, "B", 2))) + if m2: + win["wall_thick"] = float(m2.group(1)) + # 基准窗宽高:Excel 未给尺寸时按基准面积取等效正窗(各料均按面积比例缩放,宽高比不影响口径) + if "area" in win and "w" not in win: + side = round((win["area"] ** 0.5) * 1000) + win["w"], win["h"] = side, side + win.setdefault("open_area", win.get("sash_area", 0)) + win.setdefault("fixed_area", round(win.get("area", 0) - win.get("sash_area", 0), 4)) + return win + + +def _parse_labor(ws) -> dict: + """解析人工费参数区(K/L 列)。""" + labor = {"install": 0, "w14": 0, "w18": 0, "sales": 0, "fuel": 0, "ins": 0} + keymap = {"安装费:": "install", "1.4壁厚人工费:": "w14", "1.8壁厚人工费:": "w18", + "销售:": "sales", "加油:": "fuel", "保险:": "ins"} + for r in range(1, 20): + for c in range(1, ws.max_column): + v = ws.cell(r, c).value + if isinstance(v, str) and v in keymap: + labor[keymap[v]] = _num(ws.cell(r, c + 1).value) + return labor + + +def _alloc(name: str) -> str: + """按名称推断用料归因(fixed/open/both)。""" + if "扇" in name: + return "open" + if any(k in name for k in ("框", "梃", "拼")): + return "fixed" + return "both" + + +def _parse_library(ws) -> list[dict]: + """解析价格库 sheet → 统一行结构 [{code,name,spec,unit,price,note}]。""" + sn = ws.title + out: list[dict] = [] + if sn == "华健开模汇总表": + for r in range(3, ws.max_row + 1): + code = _txt(_col(ws, "B", r)) + if not code: + continue + out.append({"code": code, "name": _txt(_col(ws, "C", r)), + "spec": f"{_num(_col(ws, 'D', r)):g}", "unit": "米", "price": 0, + "note": f"模具费{_num(_col(ws, 'E', r)):g}元"}) + elif sn == "丝吉利娅五金价格": + for r in range(2, ws.max_row + 1): + code, name = _txt(_col(ws, "B", r)), _txt(_col(ws, "C", r)) + if not name: + continue + out.append({"code": code, "name": name, "spec": "", "unit": "套", + "price": round(_num(_col(ws, "D", r)), 2), "note": "丝吉利娅"}) + elif sn == "索宾柯价格": + for r in range(2, ws.max_row + 1): + code = _txt(_col(ws, "A", r)) + if not code: + continue + out.append({"code": code, "name": f"索宾柯 {code}", "spec": "", "unit": "套", + "price": round(_num(_col(ws, "B", r)), 2), "note": "索宾柯"}) + elif sn == "隔热条价格": + for r in range(2, ws.max_row + 1): + code = _txt(_col(ws, "A", r)) + if not code: + continue + out.append({"code": code, "name": f"隔热条 {_txt(_col(ws, 'B', r))}", + "spec": f"{_num(_col(ws, 'C', r)):g}g/m", "unit": "米", + "price": round(_num(_col(ws, "E", r)), 4), "note": f"{_num(_col(ws, 'D', r)):g}元/吨"}) + elif sn == "胶条价格": + for r in range(3, ws.max_row + 1): + code = _txt(_col(ws, "A", r)) + if not code: + continue + out.append({"code": code, "name": f"胶条 {code}", + "spec": f"{_num(_col(ws, 'E', r)):.4f}kg/m", "unit": "米", + "price": round(_num(_col(ws, "G", r)), 4), "note": f"河北三联 {_num(_col(ws, 'F', r)):g}元/箱"}) + elif sn == "赛遨价格": + for r in range(3, ws.max_row + 1): + code = _txt(_col(ws, "B", r)) + if not code: + continue + note = " | ".join(x for x in (_txt(_col(ws, "E", r)), _txt(_col(ws, "I", r))) if x) + out.append({"code": code, "name": _txt(_col(ws, "C", r)), "spec": "", "unit": "个", + "price": round(_num(_col(ws, "F", r)), 4), "note": note}) + elif sn == "螺丝价格": + for r in range(3, ws.max_row + 1): + name = _txt(_col(ws, "B", r)) + if not name: + continue + out.append({"code": "", "name": f"{name} {_txt(_col(ws, 'C', r))}", + "spec": _txt(_col(ws, "D", r)), "unit": "个", + "price": round(_num(_col(ws, "G", r)), 4), "note": "山东米兰"}) + elif sn == "其他价格": + for r in range(2, ws.max_row + 1): + name = _txt(_col(ws, "A", r)) + if not name: + continue + unit_txt, price = _txt(_col(ws, "B", r)), _num(_col(ws, "C", r)) + material = _guess_material(name, unit_txt) + unit = {"元/kg": "公斤", "元/m²": "平方米", "元/套": "套", "元/个": "个", + "元/桶": "桶", "元/米": "米"}.get(unit_txt, "个") + out.append({"code": "", "name": name, "spec": "", "unit": unit, + "price": round(price, 4), "note": "", "_material": material}) + return out + + +def _guess_material(name: str, unit_txt: str) -> str: + """「其他价格」行 → Product.material 分类。""" + if "玻璃" in name or unit_txt == "元/m²": + return "glass" + if any(k in name for k in ("执手", "合页", "隐藏式", "五金")): + return "hardware" + if any(k in name for k in ("型材", "基材")): + return "profile" + return "addon" + + +def _parse_optimize_stats(ws) -> dict: + """「铝计算」型材优化单 → 汇总统计(核对参考,不逐行入库)。""" + stats = {"bars": 0, "total_length": 0.0, "sashes": 0, "glasses": 0, "glass_area": 0.0} + for r in range(3, ws.max_row + 1): + code = _txt(_col(ws, "A", r)) + if code: + stats["bars"] += 1 + stats["total_length"] += _num(_col(ws, "F", r)) + if _txt(_col(ws, "H", r)) and _num(_col(ws, "K", r)): + stats["sashes"] += _num(_col(ws, "K", r)) + if _txt(_col(ws, "O", r)) and _num(_col(ws, "T", r)): + stats["glasses"] += _num(_col(ws, "T", r)) + stats["glass_area"] += _num(_col(ws, "U", r)) * _num(_col(ws, "T", r)) + return stats + + +# ---------------- 入库适配 ---------------- + +async def import_libraries(db: AsyncSession, company_id: int, libraries: dict) -> dict: + """价格库 → Product 表。同名同材质已存在则更新价格,否则新建。""" + stats = {"created": 0, "updated": 0} + for sheet, rows in libraries.items(): + for row in rows: + material = row.get("_material") or _sheet_material(sheet) + name = row["name"] + existing = (await db.execute(select(Product).where( + Product.company_id == company_id, Product.material == material, + Product.name == name))).scalars().first() + if existing is None: + db.add(Product(company_id=company_id, material=material, name=name, + spec=row.get("spec", ""), unit=row.get("unit", "个"), + price=row.get("price", 0), note=row.get("note", ""))) + stats["created"] += 1 + else: + if row.get("price"): + existing.price = row["price"] + if row.get("spec"): + existing.spec = row["spec"] + stats["updated"] += 1 + await db.flush() + return stats + + +def _sheet_material(sheet: str) -> str: + if sheet == "华健开模汇总表": + return "profile" + if sheet in ("丝吉利娅五金价格", "索宾柯价格"): + return "hardware" + return "addon" + + +async def import_series(db: AsyncSession, company_id: int, parsed: dict) -> int: + """「铝成本」页 → CostSeriesTemplate(同系列同名覆盖)。""" + summary = parsed["series_summary"] + win = summary["window"] + name = f"{win.get('series', 0):g}框扇齐平" if "窗纱" not in parsed["file"] else f"{win.get('series', 0):g}窗纱一体" + existing = (await db.execute(select(CostSeriesTemplate).where( + CostSeriesTemplate.company_id == company_id, CostSeriesTemplate.series == int(win.get("series", 0)), + CostSeriesTemplate.name == name))).scalars().first() + payload = dict(name=name, series=int(win.get("series", 0)), + wall_thick=float(win.get("wall_thick", 1.4)), + source_file=parsed["file"], data=json.dumps(summary, ensure_ascii=False)) + if existing is None: + obj = CostSeriesTemplate(company_id=company_id, **payload) + db.add(obj) + await db.flush() + return obj.id + for k, v in payload.items(): + setattr(existing, k, v) + await db.flush() + return existing.id + + +# ---------------- 估算器 ---------------- + +def estimate(tpl: CostSeriesTemplate, width_mm: float, height_mm: float, + count: int = 1, sash_count: int | None = None) -> dict: + """按系列模板估算任意尺寸窗型的成本明细(口径对齐核算表)。""" + data = json.loads(tpl.data) + win = data["window"] + base_area = win.get("area") or (win.get("w", 1600) * win.get("h", 1600) / 1e6) + area = width_mm * height_mm / 1e6 * count + sash_count = sash_count if sash_count is not None else max(1, round(count)) + base_sash_area = win.get("sash_area") or 0.0001 + base_fixed_area = win.get("fixed_area") or (base_area - base_sash_area) or 0.0001 + # 新窗型开启面积按基准开启率折算 + open_rate = base_sash_area / base_area + new_sash_area = area * open_rate + new_fixed_area = area - new_sash_area + ratio = {"fixed": new_fixed_area / base_fixed_area, "open": new_sash_area / base_sash_area, + "both": area / base_area, "once": float(count)} + + def scale_len(row: dict) -> float: + length = row.get("length") or row.get("total") or 0 + return length * ratio.get(row.get("alloc", "both"), ratio["both"]) + + def cost_of(rows, len_key="length") -> tuple[list[dict], float]: + detail = [] + total = 0.0 + for row in rows: + if row.get("alloc") == "once": + qty = count * (row.get("count") or 1) + amount = qty * row.get("unit_price", 0) + total += amount + detail.append({**row, "qty": qty, "amount": round(amount, 2)}) + else: + meters = scale_len(row) + unit_price = row.get("unit_price", 0) + weight = meters * row.get("weight_per_m", 0) + amount = weight * unit_price if row.get("weight_per_m") else meters * unit_price + total += amount + detail.append({**row, "qty": round(meters, 3), "weight": round(weight, 3), + "amount": round(amount, 2)}) + return detail, total + + out: dict = {"series": tpl.series, "wall_thick": tpl.wall_thick, "name": tpl.name, + "width": width_mm, "height": height_mm, "count": count, + "sash_count": sash_count, "area": round(area, 4), + "open_area": round(new_sash_area, 4), "fixed_area": round(new_fixed_area, 4)} + + sections_total = 0.0 + detail, cost = cost_of(data.get("profile", [])) + out["profile"], sections_total = detail, sections_total + cost + out["profile_cost"] = round(cost, 2) + detail, cost = cost_of(data.get("addon", [])) + out["addon"], out["addon_cost"] = detail, round(cost, 2) + sections_total += cost + detail, cost = cost_of(data.get("strip", [])) + out["strip"], out["strip_cost"] = detail, round(cost, 2) + sections_total += cost + detail, cost = cost_of(data.get("seal", [])) + out["seal"], out["seal_cost"] = detail, round(cost, 2) + sections_total += cost + # 玻璃:模板 area 已是该配置块数总面积,按面积比例缩放 + glass_detail, glass_cost = [], 0.0 + for g in data.get("glass", []): + g_ratio = ratio["open"] if g.get("alloc") == "open" else ratio["fixed"] + amount = g["area"] * g_ratio * g.get("unit_price", 0) + glass_cost += amount + glass_detail.append({**g, "blocks": round(g["count"] * g_ratio, 2), + "area": round(g["area"] * g_ratio, 4), "amount": round(amount, 2)}) + out["glass"], out["glass_cost"] = glass_detail, round(glass_cost, 2) + sections_total += glass_cost + detail, cost = cost_of(data.get("extra", [])) + out["extra"], out["extra_cost"] = detail, round(cost, 2) + sections_total += cost + hw_detail, hw_cost = [], 0.0 + for hw in data.get("hardware", []): + qty = sash_count * (hw.get("count") or 1) + amount = qty * hw.get("unit_price", 0) + hw_cost += amount + hw_detail.append({**hw, "qty": qty, "amount": round(amount, 2)}) + out["hardware"], out["hardware_cost"] = hw_detail, round(hw_cost, 2) + sections_total += hw_cost + + labor = data.get("labor", {}) + # 优先用核算表实际每平口径,缺省按参数推导(安装费+对应壁厚人工费) + labor_per_sqm = labor.get("rate_labor") or ( + labor.get("install", 0) + (labor.get("w18", 0) if tpl.wall_thick > 1.4 else labor.get("w14", 0))) + other_per_sqm = labor.get("rate_other") or ( + labor.get("sales", 0) + labor.get("fuel", 0) + labor.get("ins", 0)) + out["labor_cost"] = round(labor_per_sqm * area, 2) + out["other_cost"] = round(other_per_sqm * area, 2) + sections_total += out["labor_cost"] + out["other_cost"] + + out["total"] = round(sections_total, 2) + out["per_sqm"] = round(sections_total / area, 2) if area else 0 + out["margin_ladder"] = {f"{p}%": round(out["total"] * (1 + p / 100), 2) + for p in (10, 20, 30, 40, 50, 60, 70)} + return out