815cbf9d8c
- 更新 .gitignore:全面覆盖环境变量、数据库、日志、缓存、上传文件 - 移除误跟踪的 server/venv/、crm_data.db、.env 文件 - 新增 server/.env.example 模板 - 新增合同管理、利润核算、AI教练等功能模块 - 新增 Playwright e2e 测试套件 - 前后端多项功能升级和 bug 修复
397 lines
15 KiB
Python
397 lines
15 KiB
Python
"""
|
||
ERP 订单管理路由 —— /api/orders
|
||
薄路由层:参数解析 + 调用 Service + 包装响应
|
||
"""
|
||
from __future__ import annotations
|
||
import uuid
|
||
from fastapi import APIRouter, Depends, Query
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from app.api.deps import get_current_user, get_current_company_id
|
||
from app.db.database import get_db
|
||
from app.schemas.auth import CurrentUserPayload
|
||
from app.schemas.order import OrderCreate
|
||
from app.schemas.response import ok
|
||
from app.services import order_service as svc
|
||
|
||
router = APIRouter(prefix="/orders", tags=["订单管理"])
|
||
|
||
|
||
@router.get("/price/calculate", summary="B2B 动态定价:历史成交价追溯 → 标准价兜底")
|
||
async def calculate_price(
|
||
customer_id: uuid.UUID = Query(..., description="客户 ID"),
|
||
sku_id: uuid.UUID = Query(..., description="产品 SKU ID"),
|
||
db: AsyncSession = Depends(get_db),
|
||
_: CurrentUserPayload = Depends(get_current_user),
|
||
) -> dict:
|
||
result = await svc.calculate_price(db, customer_id, sku_id)
|
||
return ok(data=result.model_dump(mode="json"))
|
||
|
||
|
||
@router.post("", summary="创建订单(主子表事务)")
|
||
async def create_order(
|
||
body: OrderCreate,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||
) -> dict:
|
||
result = await svc.create_order(db, current_user, body, company_id)
|
||
return ok(data=result.model_dump(mode="json"), message=f"订单 {result.order_no} 创建成功")
|
||
|
||
|
||
@router.get("", summary="订单大盘列表(含数据权限隔离)")
|
||
async def list_orders(
|
||
page: int = Query(1, ge=1),
|
||
size: int = Query(20, ge=1, le=100),
|
||
customer_id: uuid.UUID | None = Query(None),
|
||
shipping_state: str | None = Query(None, pattern=r"^(pending|partial|shipped)$"),
|
||
payment_state: str | None = Query(None, pattern=r"^(unpaid|partial|cleared)$"),
|
||
keyword: str | None = Query(None, description="模糊搜索订单号"),
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||
) -> dict:
|
||
result = await svc.list_orders(db, current_user, page, size, customer_id, shipping_state, payment_state, keyword, company_id)
|
||
return ok(data=result.model_dump(mode="json"))
|
||
|
||
|
||
@router.get("/unlinked-invoices", summary="查询未关联订单的发票列表")
|
||
async def list_unlinked_invoices(
|
||
keyword: str | None = Query(None, description="发票号模糊搜索"),
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||
) -> dict:
|
||
from sqlalchemy import select
|
||
from app.models.finance import FinSalesInvoice
|
||
conditions = [
|
||
FinSalesInvoice.company_id == company_id,
|
||
FinSalesInvoice.is_deleted.is_(False),
|
||
FinSalesInvoice.order_id.is_(None),
|
||
]
|
||
if keyword:
|
||
conditions.append(FinSalesInvoice.invoice_number.ilike(f"%{keyword}%"))
|
||
|
||
stmt = (
|
||
select(FinSalesInvoice)
|
||
.where(*conditions)
|
||
.order_by(FinSalesInvoice.created_at.desc())
|
||
.limit(50)
|
||
)
|
||
invoices = (await db.execute(stmt)).scalars().all()
|
||
return ok(data=[
|
||
{
|
||
"id": str(inv.id),
|
||
"invoice_number": inv.invoice_number,
|
||
"issuer": inv.issuer,
|
||
"receiver_name": inv.receiver_customer.name if inv.receiver_customer else None,
|
||
"amount": float(inv.amount),
|
||
"billing_date": str(inv.billing_date),
|
||
}
|
||
for inv in invoices
|
||
])
|
||
|
||
|
||
@router.get("/{order_id}", summary="订单全景详情(关系预加载 items + customer)")
|
||
async def get_order(
|
||
order_id: uuid.UUID,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||
) -> dict:
|
||
result = await svc.get_order(db, current_user, order_id, company_id)
|
||
return ok(data=result.model_dump(mode="json"))
|
||
|
||
|
||
@router.get("/{order_id}/invoices", summary="获取订单关联的销项发票")
|
||
async def get_order_invoices(
|
||
order_id: uuid.UUID,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||
) -> dict:
|
||
from sqlalchemy import select
|
||
from app.models.finance import FinSalesInvoice
|
||
stmt = (
|
||
select(FinSalesInvoice)
|
||
.where(
|
||
FinSalesInvoice.order_id == order_id,
|
||
FinSalesInvoice.company_id == company_id,
|
||
FinSalesInvoice.is_deleted.is_(False),
|
||
)
|
||
.order_by(FinSalesInvoice.created_at.desc())
|
||
)
|
||
invoices = (await db.execute(stmt)).scalars().all()
|
||
return ok(data=[
|
||
{
|
||
"id": str(inv.id),
|
||
"invoice_number": inv.invoice_number,
|
||
"issuer": inv.issuer,
|
||
"receiver_name": inv.receiver_customer.name if inv.receiver_customer else None,
|
||
"amount": float(inv.amount),
|
||
"billing_date": str(inv.billing_date),
|
||
"payment_status": inv.payment_status,
|
||
"payment_date": str(inv.payment_date) if inv.payment_date else None,
|
||
"payment_amount": float(inv.payment_amount or 0),
|
||
"payment_due_date": str(inv.payment_due_date) if inv.payment_due_date else None,
|
||
}
|
||
for inv in invoices
|
||
])
|
||
|
||
|
||
@router.put("/{order_id}/payment", summary="更新订单收款状态")
|
||
async def update_order_payment(
|
||
order_id: uuid.UUID,
|
||
body: dict,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||
) -> dict:
|
||
from sqlalchemy import select, update as sa_update
|
||
from app.models.order import ErpOrder
|
||
from datetime import datetime
|
||
|
||
order = (await db.execute(
|
||
select(ErpOrder).where(
|
||
ErpOrder.id == order_id,
|
||
ErpOrder.company_id == company_id,
|
||
ErpOrder.is_deleted.is_(False),
|
||
)
|
||
)).scalar_one_or_none()
|
||
if order is None:
|
||
from app.core.exceptions import NotFoundException
|
||
raise NotFoundException("订单不存在")
|
||
|
||
values = {}
|
||
if "paid_amount" in body:
|
||
paid = float(body["paid_amount"])
|
||
values["paid_amount"] = paid
|
||
total = float(order.total_amount)
|
||
if paid >= total:
|
||
values["payment_state"] = "cleared"
|
||
elif paid > 0:
|
||
values["payment_state"] = "partial"
|
||
else:
|
||
values["payment_state"] = "unpaid"
|
||
if "payment_state" in body:
|
||
values["payment_state"] = body["payment_state"]
|
||
if values:
|
||
values["updated_at"] = datetime.utcnow()
|
||
await db.execute(
|
||
sa_update(ErpOrder).where(ErpOrder.id == order_id).values(**values)
|
||
)
|
||
await db.commit()
|
||
|
||
return ok(message="收款状态已更新")
|
||
|
||
|
||
@router.get("/{order_id}/invoice-detail-preview", summary="生成开票明细预览")
|
||
async def invoice_detail_preview(
|
||
order_id: uuid.UUID,
|
||
mode: str = Query("full", pattern=r"^(full|batch)$", description="full=整体开票, batch=按发货批次"),
|
||
shipping_id: uuid.UUID | None = Query(None, description="batch模式下必传发货单ID"),
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||
) -> dict:
|
||
"""根据模式生成开票明细: 整体=订单全部商品, 批次=指定发货单商品"""
|
||
from sqlalchemy import select
|
||
from sqlalchemy.orm import selectinload
|
||
from app.models.order import ErpOrder, ErpOrderItem
|
||
from app.models.shipping import ErpShippingRecord, ErpShippingItem
|
||
from app.models.crm import CrmCustomer
|
||
from app.models.sys import SysCompany
|
||
from app.core.exceptions import NotFoundException, BizException
|
||
|
||
# 查订单
|
||
order = (await db.execute(
|
||
select(ErpOrder)
|
||
.where(ErpOrder.id == order_id, ErpOrder.company_id == company_id, ErpOrder.is_deleted.is_(False))
|
||
.options(
|
||
selectinload(ErpOrder.items),
|
||
selectinload(ErpOrder.customer),
|
||
selectinload(ErpOrder.salesperson),
|
||
)
|
||
)).scalar_one_or_none()
|
||
if not order:
|
||
raise NotFoundException("订单不存在")
|
||
|
||
# 买方名称
|
||
buyer_name = order.customer.name if order.customer else ""
|
||
# 卖方名称
|
||
company = (await db.execute(
|
||
select(SysCompany).where(SysCompany.id == company_id)
|
||
)).scalar_one_or_none()
|
||
seller_name = company.name if company else ""
|
||
|
||
items_data = []
|
||
total_amount = 0.0
|
||
|
||
if mode == "full":
|
||
# 整体开票: 聚合全部订单明细
|
||
for oi in (order.items or []):
|
||
sub = float(oi.sub_total or 0)
|
||
items_data.append({
|
||
"sku_code": oi.sku.sku_code if oi.sku else "",
|
||
"sku_name": oi.sku.name if oi.sku else "",
|
||
"spec": oi.sku.spec if oi.sku else "",
|
||
"unit": oi.sku.unit if oi.sku else "",
|
||
"qty": float(oi.qty),
|
||
"unit_price": float(oi.unit_price),
|
||
"sub_total": sub,
|
||
})
|
||
total_amount += sub
|
||
else:
|
||
# 按发货批次
|
||
if not shipping_id:
|
||
raise BizException(message="batch模式需指定shipping_id")
|
||
ship = (await db.execute(
|
||
select(ErpShippingRecord)
|
||
.where(
|
||
ErpShippingRecord.id == shipping_id,
|
||
ErpShippingRecord.order_id == order_id,
|
||
ErpShippingRecord.is_deleted.is_(False),
|
||
)
|
||
.options(selectinload(ErpShippingRecord.items).selectinload(ErpShippingItem.sku))
|
||
)).scalar_one_or_none()
|
||
if not ship:
|
||
raise NotFoundException("发货单不存在")
|
||
|
||
# 查对应的订单明细来获取单价
|
||
order_item_map = {str(oi.id): oi for oi in (order.items or [])}
|
||
for si in (ship.items or []):
|
||
oi = order_item_map.get(str(si.order_item_id))
|
||
unit_price = float(oi.unit_price) if oi else 0
|
||
qty = float(si.shipped_qty)
|
||
sub = round(qty * unit_price, 2)
|
||
items_data.append({
|
||
"sku_code": si.sku.sku_code if si.sku else "",
|
||
"sku_name": si.sku.name if si.sku else "",
|
||
"spec": si.sku.spec if si.sku else "",
|
||
"unit": si.sku.unit if si.sku else "",
|
||
"qty": qty,
|
||
"unit_price": unit_price,
|
||
"sub_total": sub,
|
||
})
|
||
total_amount += sub
|
||
|
||
return ok(data={
|
||
"order_no": order.order_no,
|
||
"buyer_name": buyer_name,
|
||
"seller_name": seller_name,
|
||
"customer_id": str(order.customer_id),
|
||
"items": items_data,
|
||
"total_amount": round(total_amount, 2),
|
||
"shipping_id": str(shipping_id) if shipping_id else None,
|
||
})
|
||
|
||
|
||
@router.post("/{order_id}/invoices/link", summary="关联已有发票到订单")
|
||
async def link_existing_invoice(
|
||
order_id: uuid.UUID,
|
||
body: dict,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||
) -> dict:
|
||
"""将已存在的销项发票关联到该订单"""
|
||
from sqlalchemy import select, update as sa_update
|
||
from app.models.finance import FinSalesInvoice
|
||
from app.core.exceptions import NotFoundException, BizException
|
||
from datetime import datetime
|
||
|
||
invoice_id = body.get("invoice_id")
|
||
shipping_record_id = body.get("shipping_record_id")
|
||
if not invoice_id:
|
||
raise BizException(message="请提供 invoice_id")
|
||
|
||
inv = (await db.execute(
|
||
select(FinSalesInvoice).where(
|
||
FinSalesInvoice.id == uuid.UUID(invoice_id),
|
||
FinSalesInvoice.company_id == company_id,
|
||
FinSalesInvoice.is_deleted.is_(False),
|
||
)
|
||
)).scalar_one_or_none()
|
||
if not inv:
|
||
raise NotFoundException("发票不存在")
|
||
|
||
values = {"order_id": order_id, "updated_at": datetime.utcnow()}
|
||
if shipping_record_id:
|
||
values["shipping_record_id"] = uuid.UUID(shipping_record_id)
|
||
|
||
await db.execute(
|
||
sa_update(FinSalesInvoice)
|
||
.where(FinSalesInvoice.id == uuid.UUID(invoice_id))
|
||
.values(**values)
|
||
)
|
||
await db.commit()
|
||
return ok(message="发票已关联到订单")
|
||
|
||
|
||
@router.post("/{order_id}/invoices/create", summary="直接创建发票并关联到订单")
|
||
async def create_and_link_invoice(
|
||
order_id: uuid.UUID,
|
||
body: dict,
|
||
db: AsyncSession = Depends(get_db),
|
||
current_user: CurrentUserPayload = Depends(get_current_user),
|
||
company_id: uuid.UUID = Depends(get_current_company_id),
|
||
) -> dict:
|
||
"""创建新的销项发票,同时关联到当前订单"""
|
||
from sqlalchemy import select
|
||
from app.models.finance import FinSalesInvoice
|
||
from app.models.order import ErpOrder
|
||
from app.core.exceptions import NotFoundException, BizException
|
||
from datetime import date as dt_date
|
||
|
||
order = (await db.execute(
|
||
select(ErpOrder).where(
|
||
ErpOrder.id == order_id,
|
||
ErpOrder.company_id == company_id,
|
||
ErpOrder.is_deleted.is_(False),
|
||
)
|
||
)).scalar_one_or_none()
|
||
if not order:
|
||
raise NotFoundException("订单不存在")
|
||
|
||
invoice_number = body.get("invoice_number", "").strip()
|
||
amount = float(body.get("amount", 0))
|
||
issuer = body.get("issuer", "").strip()
|
||
receiver_customer_id = body.get("receiver_customer_id") or str(order.customer_id)
|
||
billing_date_str = body.get("billing_date")
|
||
shipping_record_id = body.get("shipping_record_id")
|
||
remark = body.get("remark")
|
||
|
||
if not invoice_number:
|
||
raise BizException(message="请填写发票号")
|
||
if amount <= 0:
|
||
raise BizException(message="开票金额需大于0")
|
||
if not issuer:
|
||
raise BizException(message="请填写开票方名称")
|
||
|
||
# 检查唯一性
|
||
from sqlalchemy import func as sa_func
|
||
existing = (await db.execute(
|
||
select(sa_func.count()).select_from(FinSalesInvoice).where(
|
||
FinSalesInvoice.invoice_number == invoice_number,
|
||
FinSalesInvoice.is_deleted.is_(False),
|
||
)
|
||
)).scalar()
|
||
if existing:
|
||
raise BizException(message=f"发票号 {invoice_number} 已存在")
|
||
|
||
inv = FinSalesInvoice(
|
||
issuer=issuer,
|
||
receiver_customer_id=uuid.UUID(receiver_customer_id),
|
||
invoice_number=invoice_number,
|
||
amount=amount,
|
||
billing_date=dt_date.fromisoformat(billing_date_str) if billing_date_str else dt_date.today(),
|
||
remark=remark,
|
||
order_id=order_id,
|
||
shipping_record_id=uuid.UUID(shipping_record_id) if shipping_record_id else None,
|
||
created_by=current_user.user_id,
|
||
company_id=company_id,
|
||
)
|
||
db.add(inv)
|
||
await db.commit()
|
||
return ok(data={"id": str(inv.id), "invoice_number": invoice_number}, message="发票创建并关联成功")
|
||
|