feat: P0-6 Excel导入导出全套(客户/合同/库存/统计, 自动表头探测)

This commit is contained in:
HHY
2026-09-25 03:30:27 -07:00
parent 436d006b5a
commit 40c16d56a1
14 changed files with 367 additions and 9 deletions
+28
View File
@@ -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):
"""编辑合同。"""
+65 -1
View File
@@ -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"})
+49
View File
@@ -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"})
+76 -2
View File
@@ -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):
"""编辑材料。"""
+65 -2
View File
@@ -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)