From 40c16d56a1cbfbd0a86807552a20da626c2742e9 Mon Sep 17 00:00:00 2001 From: HHY Date: Fri, 25 Sep 2026 03:30:27 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20P0-6=20Excel=E5=AF=BC=E5=85=A5=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E5=85=A8=E5=A5=97(=E5=AE=A2=E6=88=B7/=E5=90=88?= =?UTF-8?q?=E5=90=8C/=E5=BA=93=E5=AD=98/=E7=BB=9F=E8=AE=A1,=20=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E8=A1=A8=E5=A4=B4=E6=8E=A2=E6=B5=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG | 2 + doc/API.md | 14 ++++ frontend/src/api/index.ts | 8 +++ frontend/src/views/ContractsView.vue | 5 +- frontend/src/views/CustomersView.vue | 22 +++++- frontend/src/views/ReportView.vue | 5 +- frontend/src/views/WarehouseView.vue | 20 +++++- pyproject.toml | 1 + src/huamenchuang/api/contracts.py | 28 ++++++++ src/huamenchuang/api/customers.py | 66 +++++++++++++++++- src/huamenchuang/api/reports.py | 49 ++++++++++++++ src/huamenchuang/api/warehouse.py | 78 +++++++++++++++++++++- src/huamenchuang/services/excel_service.py | 67 ++++++++++++++++++- uv.lock | 11 +++ 14 files changed, 367 insertions(+), 9 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 0436c9b..a52dde4 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,5 +1,7 @@ # CHANGELOG +- [2026-09-25 14:50] FEAT: v2.25 P0-6 Excel 导入导出全套。①excel_service 扩展: read_rows 自动探测表头行(兼容首行标题合并格, 修复导入全跳过)/pick 中文列名取值/simple_table_workbook/statistics_workbook 双Sheet; ②客户: excel-template 模板/excel-in 批量导入(缺姓名行级报错)/excel-out 导出; ③合同 excel-out(含已收); ④库存 excel-out/excel-in(同名更新数量累加); ⑤reports/excel-statistics(订单汇总+收款统计); ⑥依赖补 python-multipart(UploadFile 必需, 缺失曾致后端启动失败); ⑦前端 apiUpload+四个页面导入导出按钮; 实测: 模板回传导入成功1条/库存导出→再导入更新1条; vue-tsc 0 错+构建通过 (Files: pyproject.toml, src/huamenchuang/services/excel_service.py, src/huamenchuang/api/customers.py, src/huamenchuang/api/contracts.py, src/huamenchuang/api/warehouse.py, src/huamenchuang/api/reports.py, frontend/src/api/index.ts, frontend/src/views/CustomersView.vue, frontend/src/views/WarehouseView.vue, frontend/src/views/ContractsView.vue, frontend/src/views/ReportView.vue, doc/API.md) + - [2026-09-25 13:50] FEAT: v2.24 P0-5 收款财务闭环。①ContractReceipt 模型(SK+日期+流水/方式五类/作废状态)+finance.py(账户 CRUD/登记收款/作废/流水分页/年度统计); ②GET /receipts/statistics(收款总额/合同应收/未收/12 月分布, 对齐 statistics_tb); ③合同列表注入 received/unreceived(当页一次 GROUP BY 聚合); ④新页面 /financial 财务管理(统计卡+月度柱图+流水+登记+账户管理)+路由菜单; 合同页加已收/未收列+收款按钮对话框; 修复 v-for 索引 m+1 类型; vue-tsc 0 错+构建通过; API 实测登记 3000→统计/列表联动正确 (Files: src/huamenchuang/models/business.py, src/huamenchuang/api/finance.py, src/huamenchuang/api/contracts.py, src/huamenchuang/api/router.py, frontend/src/views/FinancialView.vue, frontend/src/views/ContractsView.vue, frontend/src/views/LayoutView.vue, frontend/src/router/index.ts, doc/API.md) - [2026-09-25 13:00] FEAT: v2.23 P0-4 按库存优化+切割准备。①cut_solution 加 use_stock/stock_length 参数: use_stock=true 从仓库 profile 库存(单根长度>0)取原料 FFD 优化, 需求超库存输出 shortage 缺料清单(不拦截); 非库存模式 stock_length 可配; ②新增 preparation 切割准备/领料清单(按原料聚合根数/切割件数/余料)随方案保存; ③下料方案页加库存开关+标准长度输入+领料单表格+缺料清单+打印; vue-tsc 0 错+构建通过; API 实测合同3(15件/利用率87.4%/领料6米5根) (Files: src/huamenchuang/api/platform.py, frontend/src/views/CutSolutionView.vue, doc/API.md) diff --git a/doc/API.md b/doc/API.md index f6f5ce0..2583d6f 100644 --- a/doc/API.md +++ b/doc/API.md @@ -431,3 +431,17 @@ uv run uvicorn huamenchuang.main:app --reload # 启动,http://localhost:8000 | **收款统计** | GET | `/api/receipts/statistics` | `year?`(默认当年) | `{total_received, contract_total, unreceived, monthly[12]}`(对齐 statistics/_tb) | 合同列表(`GET /api/contracts`)响应行新增:`received`(有效收款合计)、`unreceived`(合同额-已收)。 + +### 12.20 Excel 导入导出(v2.20 新增,P0-数据迁移) + +依赖 `openpyxl` + `python-multipart`。导入文件自动探测表头行(兼容带标题行的导出格式);导出为带样式 xlsx。 + +| 接口名称 | 方式 | URL 路径 | 请求参数 | 说明 | +|---|---|---|---|---| +| 客户导入模板 | GET | `/api/customers/excel-template` | — | 列:姓名*/电话/地址/业务员/备注 | +| **客户批量导入** | POST | `/api/customers/excel-in` | multipart `file` | 对齐 customers_excel_in;缺姓名行跳过并逐行报错 | +| 客户导出 | GET | `/api/customers/excel-out` | — | 全量 | +| 合同导出 | GET | `/api/contracts/excel-out` | — | 对齐 contracts_excel,含已收金额 | +| **库存批量导入** | POST | `/api/warehouse/excel-in` | multipart `file` | 对齐 materials_config_excel:同名材料更新(数量累加),否则新增 | +| 库存导出 | GET | `/api/warehouse/excel-out` | — | 对齐 warehouse_excel_out | +| 统计报表导出 | GET | `/api/reports/excel-statistics` | — | 对齐 excel-statistics:订单汇总+收款统计双 Sheet | diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 3bf4fc7..c47245e 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -51,3 +51,11 @@ export async function apiDownload(url: string, filename: string): Promise link.click() URL.revokeObjectURL(link.href) } + +/** 上传文件(multipart,自动带 token),返回 envelope data */ +export async function apiUpload(url: string, file: File): Promise { + const fd = new FormData() + fd.append('file', file) + const res = await http.post(url, fd, { headers: { 'Content-Type': 'multipart/form-data' } }) + return res.data.data as T +} diff --git a/frontend/src/views/ContractsView.vue b/frontend/src/views/ContractsView.vue index a58d525..d8be0e3 100644 --- a/frontend/src/views/ContractsView.vue +++ b/frontend/src/views/ContractsView.vue @@ -8,6 +8,7 @@ 查询
+ 导出 Excel 新建合同
@@ -89,7 +90,7 @@ import { onMounted, reactive, ref } from 'vue' import { useRouter } from 'vue-router' import { ElMessage, ElMessageBox } from 'element-plus' -import { api } from '../api' +import { api, apiDownload } from '../api' const router = useRouter() const rows = ref([]) @@ -169,6 +170,8 @@ async function saveReceipt() { load() } +function exportExcel() { apiDownload('/api/contracts/excel-out', 'contracts.xlsx') } + onMounted(load) diff --git a/frontend/src/views/CustomersView.vue b/frontend/src/views/CustomersView.vue index 7e7cce8..e6a073f 100644 --- a/frontend/src/views/CustomersView.vue +++ b/frontend/src/views/CustomersView.vue @@ -4,6 +4,11 @@ 查询
+ + 导入 Excel + + 下载模板 + 导出 Excel 新增客户
@@ -43,7 +48,7 @@ diff --git a/frontend/src/views/ReportView.vue b/frontend/src/views/ReportView.vue index a0b9e05..7a85d57 100644 --- a/frontend/src/views/ReportView.vue +++ b/frontend/src/views/ReportView.vue @@ -8,6 +8,7 @@ 近14天 近30天 + 导出统计 Excel @@ -43,7 +44,7 @@ diff --git a/frontend/src/views/WarehouseView.vue b/frontend/src/views/WarehouseView.vue index e5fae5b..b1578e7 100644 --- a/frontend/src/views/WarehouseView.vue +++ b/frontend/src/views/WarehouseView.vue @@ -8,6 +8,10 @@ 查询
+ + 导入 Excel + + 导出 Excel 新增材料
@@ -63,7 +67,7 @@ diff --git a/pyproject.toml b/pyproject.toml index 4fb0ce3..f6d36b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ dependencies = [ "pydantic-settings>=2.3", "pyjwt>=2.8", "openpyxl>=3.1", + "python-multipart>=0.0.32", ] [project.scripts] diff --git a/src/huamenchuang/api/contracts.py b/src/huamenchuang/api/contracts.py index 556f2e7..bcab1d0 100644 --- a/src/huamenchuang/api/contracts.py +++ b/src/huamenchuang/api/contracts.py @@ -70,6 +70,34 @@ async def list_contracts(user: CurrentUser, db: DbSession, page: int = 1, return ok(data={"contracts": paginated(outs, page, page_size, total)}) +@router.get("/excel-out") +async def excel_out(user: CurrentUser, db: DbSession): + """合同全量导出 Excel(对齐目标站 contracts_excel)。""" + from fastapi.responses import Response + from huamenchuang.services.excel_service import simple_table_workbook + from huamenchuang.models.business import ContractReceipt + from sqlalchemy import func + rows_db = list((await db.execute( + select(Contract).where(Contract.company_id == user.company_id) + .order_by(Contract.id))).scalars()) + received_map: dict[int, float] = {} + if rows_db: + rrows = (await db.execute( + select(ContractReceipt.contract_id, func.sum(ContractReceipt.amount)) + .where(ContractReceipt.contract_id.in_([c.id for c in rows_db]), + ContractReceipt.status == 1) + .group_by(ContractReceipt.contract_id))).all() + received_map = {cid: float(v or 0) for cid, v in rrows} + data = [[c.contract_no, c.customer_name, c.phone, c.address, float(c.money), + received_map.get(c.id, 0), status_label(c.status), str(c.created_at)[:10]] + for c in rows_db] + content = simple_table_workbook("合同列表", + ["合同号", "客户", "电话", "地址", "金额", "已收", "状态", "创建日期"], + data, widths=[18, 14, 16, 28, 12, 12, 10, 12]) + return Response(content, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": "attachment; filename=contracts.xlsx"}) + + @router.put("/{contract_id}") async def update_contract(contract_id: int, body: ContractBody, user: CurrentUser, db: DbSession): """编辑合同。""" diff --git a/src/huamenchuang/api/customers.py b/src/huamenchuang/api/customers.py index a8b834c..789f150 100644 --- a/src/huamenchuang/api/customers.py +++ b/src/huamenchuang/api/customers.py @@ -1,9 +1,11 @@ """客户路由。""" -from fastapi import APIRouter +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, paginated +from huamenchuang.models.contract import Customer from huamenchuang.services.customer_service import CustomerService router = APIRouter(prefix="/customers", tags=["客户"]) @@ -66,3 +68,65 @@ async def delete_customer(customer_id: int, user: CurrentUser, db: DbSession): except KeyError as e: return fail(str(e)) return ok(message="删除成功") + + +# ---------------- Excel 导入导出 ---------------- + +@router.get("/excel-template") +async def excel_template(user: CurrentUser): + """客户导入模板下载。""" + from fastapi.responses import Response + from huamenchuang.services.excel_service import simple_table_workbook + content = simple_table_workbook("客户导入模板", ["姓名*", "电话", "地址", "业务员", "备注"], + [["张三", "13800001111", "XX路10号", "李四", ""], []], + widths=[16, 18, 30, 14, 20]) + return Response(content, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": "attachment; filename=customers_template.xlsx"}) + + +@router.post("/excel-in") +async def excel_in(file: UploadFile, user: CurrentUser, db: DbSession): + """客户批量导入(对齐目标站 customers_excel_in;列:姓名*/电话/地址/业务员/备注)。""" + if not file.filename or not file.filename.lower().endswith((".xlsx", ".xls")): + return fail("请上传 xlsx 文件") + from huamenchuang.services.excel_service import pick, read_rows + try: + rows = read_rows(await file.read()) + except Exception: + return fail("文件解析失败,请使用导入模板") + if not rows: + return fail("文件中没有数据行") + ok_n, skip, errors = 0, 0, [] + for i, row in enumerate(rows, 2): + name = pick(row, "姓名", "姓名*", "名称", "name") + if not name: + skip += 1 + errors.append(f"第{i}行缺姓名") + continue + db.add(Customer(company_id=user.company_id, name=str(name).strip(), + phone=str(pick(row, "电话", "手机", "phone", default="") or ""), + address=str(pick(row, "地址", "address", default="") or ""), + salesmen=str(pick(row, "业务员", "salesmen", default="") or ""), + note=str(pick(row, "备注", "note", default="") or ""))) + ok_n += 1 + await db.flush() + msg = f"导入完成:成功 {ok_n} 条,跳过 {skip} 条" + if errors: + msg += ";" + ";".join(errors[:5]) + return ok(data={"success": ok_n, "skipped": skip}, message=msg) + + +@router.get("/excel-out") +async def excel_out(user: CurrentUser, db: DbSession): + """客户全量导出(对齐目标站 warehouse_excel_out 类语义)。""" + from fastapi.responses import Response + from huamenchuang.services.excel_service import simple_table_workbook + rows_db = list((await db.execute( + select(Customer).where(Customer.company_id == user.company_id) + .order_by(Customer.id))).scalars()) + data = [[c.name, c.phone, c.address, c.salesmen or "", float(c.balance), str(c.created_at)[:10]] + for c in rows_db] + content = simple_table_workbook("客户列表", ["姓名", "电话", "地址", "业务员", "余额", "建档日期"], + data, widths=[16, 18, 30, 14, 12, 12]) + return Response(content, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": "attachment; filename=customers.xlsx"}) diff --git a/src/huamenchuang/api/reports.py b/src/huamenchuang/api/reports.py index 66ca1f4..a382112 100644 --- a/src/huamenchuang/api/reports.py +++ b/src/huamenchuang/api/reports.py @@ -51,3 +51,52 @@ async def summary(user: CurrentUser, db: DbSession, days: int = 14): status_dist = [{"status": s, "count": c} for s, c in status_rows] return ok(data={"by_day": by_day, "totals": totals, "status_dist": status_dist, "days": days}) + + +@router.get("/excel-statistics") +async def excel_statistics(user: CurrentUser, db: DbSession): + """统计报表导出 Excel(对齐目标站 excel-statistics:订单汇总+收款统计)。""" + from fastapi.responses import Response + from huamenchuang.models.business import ContractReceipt + from huamenchuang.services.excel_service import statistics_workbook + + cid = user.company_id + import datetime as _dt + year = _dt.date.today().year + + async def count(model, extra=None): + conds = [model.company_id == cid] + if extra is not None: + conds.append(extra) + return (await db.execute(select(func.count()).select_from(model).where(*conds))).scalar_one() + + order_money = (await db.execute(select(func.coalesce(func.sum(Order.money), 0)) + .where(Order.company_id == cid))).scalar() or 0 + summary = { + "订单总数": await count(Order), + "意向单": await count(Order, Order.status == 0), + "已完工": await count(Order, Order.status == 3), + "报价单": await count(Quotation), + "客户数": await count(Customer), + "合同数": await count(Contract), + "量尺单": await count(MeasureOrder), + "售后单(未完结)": await count(AfterSale, AfterSale.status != 2), + "订单总金额": round(float(order_money), 2), + } + ycond = (ContractReceipt.company_id == cid, ContractReceipt.status == 1, + func.strftime("%Y", ContractReceipt.receipt_date) == str(year)) + received = (await db.execute(select(func.coalesce(func.sum(ContractReceipt.amount), 0)) + .where(*ycond))).scalar() or 0 + months_rows = (await db.execute( + select(func.strftime("%m", ContractReceipt.receipt_date), func.sum(ContractReceipt.amount)) + .where(*ycond).group_by(func.strftime("%m", ContractReceipt.receipt_date)))).all() + months = {int(m): float(v or 0) for m, v in months_rows} + contract_total = (await db.execute(select(func.coalesce(func.sum(Contract.money), 0)) + .where(Contract.company_id == cid, Contract.status < 3))).scalar() or 0 + receipts = {"year": year, "total_received": round(float(received), 2), + "contract_total": round(float(contract_total), 2), + "unreceived": round(float(contract_total) - float(received), 2), + "monthly": [months.get(m, 0) for m in range(1, 13)]} + content = statistics_workbook(summary, receipts) + return Response(content, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": "attachment; filename=statistics.xlsx"}) diff --git a/src/huamenchuang/api/warehouse.py b/src/huamenchuang/api/warehouse.py index 7d9ef4d..a466235 100644 --- a/src/huamenchuang/api/warehouse.py +++ b/src/huamenchuang/api/warehouse.py @@ -1,5 +1,5 @@ -"""仓库库存路由:材料 CRUD + 出入库。""" -from fastapi import APIRouter +"""仓库库存路由:材料 CRUD + 出入库 + Excel 导入导出。""" +from fastapi import APIRouter, UploadFile from pydantic import BaseModel, Field from sqlalchemy import desc, select @@ -69,6 +69,80 @@ async def create_item(body: ItemBody, user: CurrentUser, db: DbSession): return ok(data={"id": item.id}, message="创建成功") +# ---------------- Excel 导入导出 ---------------- + +@router.get("/excel-out") +async def excel_out(user: CurrentUser, db: DbSession): + """库存全量导出(对齐目标站 warehouse_excel_out)。""" + from fastapi.responses import Response + from huamenchuang.services.excel_service import simple_table_workbook + items = list((await db.execute( + select(WarehouseItem).where(WarehouseItem.company_id == user.company_id) + .order_by(WarehouseItem.id))).scalars()) + mat_label = {"profile": "型材", "glass": "玻璃", "hardware": "五金", "addon": "配件"} + data = [[i.bom_code, i.name, mat_label.get(i.material, i.material), i.spec, i.color, + float(i.price), float(i.cost), float(i.count), i.unit, i.length, float(i.warning)] + for i in items] + content = simple_table_workbook("库存列表", + ["编码", "名称", "类别", "规格", "颜色", "售价", "成本", + "库存数量", "单位", "单根长度mm", "预警线"], + data, widths=[12, 20, 8, 14, 10, 10, 10, 10, 8, 12, 10]) + return Response(content, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": "attachment; filename=warehouse.xlsx"}) + + +@router.post("/excel-in") +async def excel_in(file: UploadFile, user: CurrentUser, db: DbSession): + """材料批量导入(对齐目标站 materials_config_excel:同名材料按名称匹配更新数量,否则新增)。""" + if not file.filename or not file.filename.lower().endswith((".xlsx", ".xls")): + return fail("请上传 xlsx 文件") + from huamenchuang.services.excel_service import pick, read_rows + try: + rows = read_rows(await file.read()) + except Exception: + return fail("文件解析失败") + if not rows: + return fail("文件中没有数据行") + mat_map = {"型材": "profile", "玻璃": "glass", "五金": "hardware", "配件": "addon", + "profile": "profile", "glass": "glass", "hardware": "hardware", "addon": "addon"} + existing = {i.name: i for i in (await db.execute( + select(WarehouseItem).where(WarehouseItem.company_id == user.company_id))).scalars()} + created, updated, skipped = 0, 0, 0 + for i, row in enumerate(rows, 2): + name = pick(row, "名称", "材料名称", "name") + if not name: + skipped += 1 + continue + name = str(name).strip() + mat = mat_map.get(str(pick(row, "类别", "材料", "material", default="型材") or "型材"), "profile") + values = dict( + bom_code=str(pick(row, "编码", "bom_code", default="") or ""), + material=mat, spec=str(pick(row, "规格", "spec", default="") or ""), + color=str(pick(row, "颜色", "color", default="") or ""), + price=float(pick(row, "售价", "price", default=0) or 0), + cost=float(pick(row, "成本", "cost", default=0) or 0), + count=float(pick(row, "库存数量", "数量", "count", default=0) or 0), + unit=str(pick(row, "单位", "unit", default="根") or "根"), + length=int(float(pick(row, "单根长度mm", "长度", "length", default=0) or 0)), + warning=float(pick(row, "预警线", "warning", default=0) or 0)) + if name in existing: # 同名更新(数量累加) + item = existing[name] + for k, v in values.items(): + if k == "count": + continue + setattr(item, k, v) + item.count = float(item.count) + values["count"] + updated += 1 + else: + item = WarehouseItem(company_id=user.company_id, name=name, **values) + db.add(item) + existing[name] = item + created += 1 + await db.flush() + return ok(data={"created": created, "updated": updated, "skipped": skipped}, + message=f"导入完成:新增 {created} 条,更新 {updated} 条,跳过 {skipped} 条") + + @router.put("/{item_id}") async def update_item(item_id: int, body: ItemBody, user: CurrentUser, db: DbSession): """编辑材料。""" diff --git a/src/huamenchuang/services/excel_service.py b/src/huamenchuang/services/excel_service.py index 25df88d..4a71da8 100644 --- a/src/huamenchuang/services/excel_service.py +++ b/src/huamenchuang/services/excel_service.py @@ -1,8 +1,8 @@ -"""Excel 生成服务(openpyxl):BOM 单、订单导出、业务数据导入导出共用工具。""" +"""Excel 生成/解析服务(openpyxl):BOM 单、订单导出、业务数据导入导出共用工具。""" import io from typing import Any -from openpyxl import Workbook +from openpyxl import Workbook, load_workbook from openpyxl.styles import Alignment, Border, Font, PatternFill, Side from openpyxl.utils import get_column_letter @@ -96,3 +96,66 @@ def order_workbook(order: dict, bom: dict) -> bytes: row += 1 ws.cell(row=row, column=1, value="制单:____________ 审核:____________ 日期:____________") return save_bytes(wb) + + +# ---------------- 导入解析 ---------------- + +def read_rows(data: bytes, sheet_index: int = 0) -> list[dict]: + """解析上传的 xlsx → 行字典列表(自动探测表头行,兼容首行大标题的导出格式)。 + + 探测规则:前 3 行中非空单元格最多的一行视为表头;空行跳过。 + """ + wb = load_workbook(io.BytesIO(data), read_only=True, data_only=True) + ws = wb.worksheets[sheet_index] + all_rows = [list(r) for r in ws.iter_rows(values_only=True)] + if not all_rows: + return [] + # 探测表头行:取前 3 行中非空单元格数最多的行 + scan = all_rows[:3] + header_idx = max(range(len(scan)), key=lambda i: sum(1 for v in scan[i] if v is not None and str(v).strip())) + headers = [str(v).strip() if v is not None else "" for v in all_rows[header_idx]] + out: list[dict] = [] + for values in all_rows[header_idx + 1:]: + row = {headers[i]: values[i] for i in range(min(len(headers), len(values)))} + if any(v is not None and str(v).strip() for v in row.values()): + out.append(row) + return out + + +def pick(row: dict, *names: str, default=None): + """按候选列名(含中文表头)取值,全部缺失返回 default。""" + for n in names: + if n in row and row[n] not in (None, ""): + return row[n] + return default + + +# ---------------- 业务导出工作簿 ---------------- + +def simple_table_workbook(title: str, headers: list[str], rows: list[list], + widths: list[int] | None = None) -> bytes: + """单表导出(客户/合同/库存等通用)。""" + wb = new_workbook() + ws = wb.active + ws.title = title[:28] or "导出" + row = write_title(ws, 1, title, len(headers)) + write_table(ws, row, headers, rows, widths) + return save_bytes(wb) + + +def statistics_workbook(orders_summary: dict, receipts: dict) -> bytes: + """统计报表导出:订单汇总 + 收款统计双 Sheet。""" + wb = new_workbook() + ws = wb.active + ws.title = "订单汇总" + row = write_title(ws, 1, "订单/业务统计", 4) + rows = [[k, v, "", ""] for k, v in orders_summary.items()] + row = write_table(ws, row, ["指标", "数值", "", ""], rows, widths=[24, 18, 8, 8]) + ws2 = wb.create_sheet("收款统计") + row = write_title(ws2, 1, f"收款统计 {receipts['year']} 年", 14) + row = write_table(ws2, row, + ["年度收款", "合同应收", "未收"] + [f"{m}月" for m in range(1, 13)], + [[receipts["total_received"], receipts["contract_total"], receipts["unreceived"], + *receipts["monthly"]]], + widths=[12] * 15) + return save_bytes(wb) diff --git a/uv.lock b/uv.lock index 05e86ea..2733483 100644 --- a/uv.lock +++ b/uv.lock @@ -157,6 +157,7 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyjwt" }, + { name = "python-multipart" }, { name = "sqlalchemy" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -169,6 +170,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.7" }, { name = "pydantic-settings", specifier = ">=2.3" }, { name = "pyjwt", specifier = ">=2.8" }, + { name = "python-multipart", specifier = ">=0.0.32" }, { name = "sqlalchemy", specifier = ">=2.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, ] @@ -282,6 +284,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3"