v0.2.0: CRM/ERP 系统升级 - 清理 .gitignore 并移除误提交的 venv/env/db 文件

- 更新 .gitignore:全面覆盖环境变量、数据库、日志、缓存、上传文件
- 移除误跟踪的 server/venv/、crm_data.db、.env 文件
- 新增 server/.env.example 模板
- 新增合同管理、利润核算、AI教练等功能模块
- 新增 Playwright e2e 测试套件
- 前后端多项功能升级和 bug 修复
This commit is contained in:
hankin
2026-05-11 07:24:19 +00:00
parent 0f4c6b7924
commit 815cbf9d8c
2526 changed files with 11875 additions and 804148 deletions
+338 -4
View File
@@ -6,7 +6,7 @@ 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
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
@@ -32,8 +32,9 @@ 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)
result = await svc.create_order(db, current_user, body, company_id)
return ok(data=result.model_dump(mode="json"), message=f"订单 {result.order_no} 创建成功")
@@ -47,16 +48,349 @@ async def list_orders(
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)
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)
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="发票创建并关联成功")