feat: P0-6 Excel导入导出全套(客户/合同/库存/统计, 自动表头探测)
This commit is contained in:
@@ -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):
|
||||
"""编辑合同。"""
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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):
|
||||
"""编辑材料。"""
|
||||
|
||||
Reference in New Issue
Block a user