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):
"""编辑材料。"""