v0.1.0: CRM/ERP 系统内测版本 - 安全加固完成

- Docker bridge 网络隔离(8000 端口封死)
- Gunicorn 4 Worker 多进程
- Alembic 数据库迁移基线
- 日志轮转 20m×3
- JWT 密钥 + DB 密码 + CORS 收紧
- 3-2-1 备份链路(NAS + R740-B 冷备)
- 连接池 pool_pre_ping + pool_recycle=3600
This commit is contained in:
hankin
2026-03-16 07:31:37 +00:00
commit 423baff73b
2578 changed files with 824643 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# Service Layer — 业务逻辑集中层
# REST API 路由和 MCP 工具共用此层函数
+58
View File
@@ -0,0 +1,58 @@
"""
AI 对话历史服务 — 持久化 + 查询
"""
from __future__ import annotations
import uuid
from sqlalchemy import select, desc
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.ai import AiChatSession
async def save_message(
db: AsyncSession,
user_id: uuid.UUID,
role: str,
content: str,
msg_type: str = "text",
) -> AiChatSession:
"""保存一条对话消息"""
msg = AiChatSession(
user_id=user_id,
role=role,
content=content,
msg_type=msg_type,
)
db.add(msg)
await db.commit()
await db.refresh(msg)
return msg
async def load_history(
db: AsyncSession,
user_id: uuid.UUID,
limit: int = 50,
) -> list[dict]:
"""加载用户最近 N 条对话(时间正序返回,方便前端渲染)"""
stmt = (
select(AiChatSession)
.where(AiChatSession.user_id == user_id)
.order_by(desc(AiChatSession.created_at))
.limit(limit)
)
result = await db.execute(stmt)
rows = result.scalars().all()
# 反转为时间正序
rows = list(reversed(rows))
return [
{
"id": str(r.id),
"role": r.role,
"content": r.content,
"type": r.msg_type,
"created_at": r.created_at.isoformat() if r.created_at else None,
}
for r in rows
]
+87
View File
@@ -0,0 +1,87 @@
"""
联系人服务 — CRUD (V5.0)
"""
from __future__ import annotations
import uuid
from sqlalchemy import select, func, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.crm import CrmContact
async def list_contacts(
db: AsyncSession,
customer_id: uuid.UUID,
) -> list[dict]:
"""列出某客户下所有未删除的联系人"""
stmt = (
select(CrmContact)
.where(
CrmContact.customer_id == customer_id,
CrmContact.is_deleted.is_(False),
)
.order_by(CrmContact.created_at)
)
rows = (await db.execute(stmt)).scalars().all()
return [_to_dict(c) for c in rows]
async def create_contact(
db: AsyncSession,
customer_id: uuid.UUID,
data: dict,
) -> dict:
"""新增联系人"""
contact = CrmContact(
customer_id=customer_id,
name=data["name"],
phone=data.get("phone"),
title=data.get("title"),
)
db.add(contact)
await db.commit()
await db.refresh(contact)
return _to_dict(contact)
async def update_contact(
db: AsyncSession,
contact_id: uuid.UUID,
data: dict,
) -> dict:
"""编辑联系人"""
contact = await db.get(CrmContact, contact_id)
if not contact or contact.is_deleted:
raise ValueError("联系人不存在")
for field in ("name", "phone", "title"):
if field in data:
setattr(contact, field, data[field])
await db.commit()
await db.refresh(contact)
return _to_dict(contact)
async def delete_contact(
db: AsyncSession,
contact_id: uuid.UUID,
) -> None:
"""软删除联系人"""
contact = await db.get(CrmContact, contact_id)
if not contact or contact.is_deleted:
raise ValueError("联系人不存在")
contact.is_deleted = True
await db.commit()
def _to_dict(c: CrmContact) -> dict:
return {
"id": str(c.id),
"customer_id": str(c.customer_id),
"name": c.name,
"phone": c.phone,
"title": c.title,
"ai_buyer_persona": c.ai_buyer_persona or {},
"created_at": c.created_at.isoformat() if c.created_at else None,
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
}
+336
View File
@@ -0,0 +1,336 @@
"""
客户管理 Service 层
REST API 路由 和 MCP 工具 共用此层函数
"""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import BizException, ForbiddenException, NotFoundException
from app.models.crm import CrmCustomer
from app.schemas.auth import CurrentUserPayload
from app.schemas.crm import (
CustomerCreate,
CustomerListResponse,
CustomerResponse,
CustomerUpdate,
)
# ── ORM → Response ───────────────────────────────────────
def _to_response(c: CrmCustomer) -> CustomerResponse:
return CustomerResponse(
id=c.id,
name=c.name,
level=c.level,
industry=c.industry,
contact=c.contact,
phone=c.phone,
email=c.email,
address=c.address,
ai_score=float(c.ai_score or 0),
ai_persona=c.ai_persona,
owner_id=c.owner_id,
owner_name=c.owner.real_name if c.owner else None,
status=c.status,
is_deleted=c.is_deleted,
created_at=c.created_at,
updated_at=c.updated_at,
)
# ── 权限校验 ─────────────────────────────────────────────
def _check_access(customer: CrmCustomer, user: CurrentUserPayload) -> None:
if user.data_scope == "all":
return
if user.data_scope == "dept_and_sub":
return # 简化版:放通本部门
# data_scope == 'self'
if customer.owner_id != user.user_id:
raise ForbiddenException("无权访问该客户(数据权限:仅本人)")
# ── Service Functions ────────────────────────────────────
async def create_customer(
db: AsyncSession,
user: CurrentUserPayload,
body: CustomerCreate,
) -> CustomerResponse:
customer = CrmCustomer(
name=body.name,
level=body.level,
industry=body.industry,
contact=body.contact,
phone=body.phone,
email=body.email,
address=body.address,
status=body.status,
owner_id=user.user_id,
)
db.add(customer)
await db.commit()
await db.refresh(customer)
return _to_response(customer)
async def list_customers(
db: AsyncSession,
user: CurrentUserPayload,
page: int = 1,
size: int = 20,
keyword: str | None = None,
level: str | None = None,
include_archived: bool = False,
) -> CustomerListResponse:
if include_archived:
base_where = [] # 不过滤 is_deleted
else:
base_where = [CrmCustomer.is_deleted.is_(False)]
# ── 数据域隔离 ──
if user.data_scope == "self":
base_where.append(CrmCustomer.owner_id == user.user_id)
elif user.data_scope == "dept_and_sub":
if user.dept_id is not None:
from app.models.sys import SysUser
sub = select(SysUser.id).where(
SysUser.dept_id == user.dept_id,
SysUser.is_deleted.is_(False),
)
base_where.append(CrmCustomer.owner_id.in_(sub))
if keyword:
base_where.append(CrmCustomer.name.ilike(f"%{keyword}%"))
if level:
base_where.append(CrmCustomer.level == level)
total = (
await db.execute(select(func.count()).select_from(CrmCustomer).where(*base_where))
).scalar() or 0
stmt = (
select(CrmCustomer)
.where(*base_where)
.order_by(CrmCustomer.created_at.desc())
.offset((page - 1) * size)
.limit(size)
)
customers = (await db.execute(stmt)).scalars().all()
return CustomerListResponse(
total=total,
items=[_to_response(c) for c in customers],
page=page,
size=size,
)
async def get_customer(
db: AsyncSession,
user: CurrentUserPayload,
customer_id: uuid.UUID,
) -> CustomerResponse:
stmt = select(CrmCustomer).where(
CrmCustomer.id == customer_id,
CrmCustomer.is_deleted.is_(False),
)
customer = (await db.execute(stmt)).scalar_one_or_none()
if customer is None:
raise NotFoundException("客户不存在或已被删除")
_check_access(customer, user)
return _to_response(customer)
async def update_customer(
db: AsyncSession,
user: CurrentUserPayload,
customer_id: uuid.UUID,
body: CustomerUpdate,
) -> CustomerResponse:
stmt = select(CrmCustomer).where(
CrmCustomer.id == customer_id,
CrmCustomer.is_deleted.is_(False),
)
customer = (await db.execute(stmt)).scalar_one_or_none()
if customer is None:
raise NotFoundException("客户不存在或已被删除")
_check_access(customer, user)
update_data = body.model_dump(exclude_unset=True)
if not update_data:
raise BizException(message="未提供任何需要更新的字段")
update_data["updated_at"] = datetime.utcnow()
await db.execute(
update(CrmCustomer).where(CrmCustomer.id == customer_id).values(**update_data)
)
await db.commit()
updated = (
await db.execute(select(CrmCustomer).where(CrmCustomer.id == customer_id))
).scalar_one()
return _to_response(updated)
async def delete_customer(
db: AsyncSession,
user: CurrentUserPayload,
customer_id: uuid.UUID,
) -> None:
stmt = select(CrmCustomer).where(
CrmCustomer.id == customer_id,
CrmCustomer.is_deleted.is_(False),
)
customer = (await db.execute(stmt)).scalar_one_or_none()
if customer is None:
raise NotFoundException("客户不存在或已被删除")
_check_access(customer, user)
await db.execute(
update(CrmCustomer)
.where(CrmCustomer.id == customer_id)
.values(is_deleted=True, updated_at=datetime.utcnow())
)
await db.commit()
async def restore_customer(
db: AsyncSession,
user: CurrentUserPayload,
customer_id: uuid.UUID,
) -> None:
stmt = select(CrmCustomer).where(
CrmCustomer.id == customer_id,
CrmCustomer.is_deleted.is_(True),
)
customer = (await db.execute(stmt)).scalar_one_or_none()
if customer is None:
raise NotFoundException("客户不存在或未被归档")
_check_access(customer, user)
await db.execute(
update(CrmCustomer)
.where(CrmCustomer.id == customer_id)
.values(is_deleted=False, updated_at=datetime.utcnow())
)
await db.commit()
async def get_customer_products(
db: AsyncSession,
user: CurrentUserPayload,
customer_id: uuid.UUID,
) -> list[dict]:
"""通过订单反查客户关联的产品 SKU(去重聚合)"""
from app.models.order import ErpOrder, ErpOrderItem
from app.models.erp import ProductSku
from sqlalchemy import desc as sa_desc
# 确认客户存在
stmt = select(CrmCustomer).where(CrmCustomer.id == customer_id)
customer = (await db.execute(stmt)).scalar_one_or_none()
if customer is None:
raise NotFoundException("客户不存在")
_check_access(customer, user)
# 聚合: 该客户所有订单中的 SKU,含总数量、最近下单时间
agg_stmt = (
select(
ErpOrderItem.sku_id,
ProductSku.sku_code,
ProductSku.name.label("sku_name"),
ProductSku.spec,
func.sum(ErpOrderItem.qty).label("total_qty"),
func.max(ErpOrder.order_date).label("last_order_date"),
func.count(func.distinct(ErpOrder.id)).label("order_count"),
)
.join(ErpOrder, ErpOrderItem.order_id == ErpOrder.id)
.join(ProductSku, ErpOrderItem.sku_id == ProductSku.id)
.where(
ErpOrder.customer_id == customer_id,
ErpOrder.is_deleted.is_(False),
ErpOrderItem.is_deleted.is_(False),
)
.group_by(
ErpOrderItem.sku_id,
ProductSku.sku_code,
ProductSku.name,
ProductSku.spec,
)
.order_by(sa_desc("last_order_date"))
)
rows = (await db.execute(agg_stmt)).all()
return [
{
"sku_id": str(r.sku_id),
"sku_code": r.sku_code,
"sku_name": r.sku_name,
"spec": r.spec,
"total_qty": float(r.total_qty),
"order_count": r.order_count,
"last_order_date": r.last_order_date.isoformat() if r.last_order_date else None,
}
for r in rows
]
async def search_customers(
db: AsyncSession,
user: CurrentUserPayload,
q: str,
limit: int = 20,
) -> list[dict]:
"""模糊搜索客户,返回精简列表(供远程选择器用)"""
base_where = [CrmCustomer.is_deleted.is_(False)]
# 数据域隔离
if user.data_scope == "self":
base_where.append(CrmCustomer.owner_id == user.user_id)
elif user.data_scope == "dept_and_sub":
if user.dept_id is not None:
from app.models.sys import SysUser
sub = select(SysUser.id).where(
SysUser.dept_id == user.dept_id,
SysUser.is_deleted.is_(False),
)
base_where.append(CrmCustomer.owner_id.in_(sub))
# 模糊搜索(名称 / 联系人 / 电话)
from sqlalchemy import or_
base_where.append(
or_(
CrmCustomer.name.ilike(f"%{q}%"),
CrmCustomer.contact.ilike(f"%{q}%"),
CrmCustomer.phone.ilike(f"%{q}%"),
)
)
stmt = (
select(CrmCustomer)
.where(*base_where)
.order_by(CrmCustomer.name)
.limit(limit)
)
customers = (await db.execute(stmt)).scalars().all()
return [
{
"id": str(c.id),
"name": c.name,
"level": c.level,
"contact": c.contact,
"phone": c.phone,
}
for c in customers
]
+278
View File
@@ -0,0 +1,278 @@
"""
财务票据 Service 层
REST API 路由 和 MCP 工具 共用此层函数
"""
from __future__ import annotations
import uuid
from datetime import date, datetime
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import BizException, ForbiddenException, NotFoundException
from app.models.finance import FinExpenseDetail, FinExpenseRecord, FinInvoicePool
from app.schemas.auth import CurrentUserPayload
from app.schemas.finance import (
ExpenseBriefResponse, ExpenseCreate, ExpenseDetailResponse,
ExpenseListResponse, ExpenseResponse, ExpenseStatusUpdate,
InvoiceCreate, InvoiceListResponse, InvoiceResponse,
)
def _inv_to_resp(inv: FinInvoicePool) -> InvoiceResponse:
return InvoiceResponse(
id=inv.id, uploader_id=inv.uploader_id,
uploader_name=inv.uploader.real_name if inv.uploader else None,
file_url=inv.file_url, merchant_name=inv.merchant_name,
amount=float(inv.amount or 0), invoice_date=inv.invoice_date,
type=inv.type, ai_extracted_data=inv.ai_extracted_data or {},
is_used=inv.is_used, created_at=inv.created_at,
)
def _detail_to_resp(d: FinExpenseDetail) -> ExpenseDetailResponse:
return ExpenseDetailResponse(
id=d.id, invoice_id=d.invoice_id,
invoice_merchant=d.invoice.merchant_name if d.invoice else None,
invoice_amount=float(d.invoice.amount) if d.invoice else None,
expense_desc=d.expense_desc, original_type=d.original_type,
offset_type=d.offset_type, amount=float(d.amount or 0),
)
def _exp_to_resp(exp: FinExpenseRecord, with_details: bool = True) -> ExpenseResponse:
return ExpenseResponse(
id=exp.id, system_no=exp.system_no, applicant_id=exp.applicant_id,
applicant_name=exp.applicant.real_name if exp.applicant else None,
total_amount=float(exp.total_amount or 0), status=exp.status,
remark=exp.remark, approved_by=exp.approved_by,
approver_name=exp.approver.real_name if exp.approver else None,
approved_at=exp.approved_at,
details=[_detail_to_resp(d) for d in exp.details] if with_details else [],
created_at=exp.created_at, updated_at=exp.updated_at,
)
def _exp_to_brief(exp: FinExpenseRecord) -> ExpenseBriefResponse:
return ExpenseBriefResponse(
id=exp.id, system_no=exp.system_no, applicant_id=exp.applicant_id,
applicant_name=exp.applicant.real_name if exp.applicant else None,
total_amount=float(exp.total_amount or 0), status=exp.status,
created_at=exp.created_at,
)
async def _generate_expense_no(db: AsyncSession) -> str:
today = date.today().strftime("%Y%m%d")
prefix = f"EXP-{today}-"
count = (await db.execute(
select(func.count()).select_from(FinExpenseRecord)
.where(FinExpenseRecord.system_no.like(f"{prefix}%"))
)).scalar() or 0
return f"{prefix}{count + 1:03d}"
async def _release_invoices(db: AsyncSession, expense_id: uuid.UUID, now: datetime) -> None:
detail_stmt = select(FinExpenseDetail.invoice_id).where(
FinExpenseDetail.expense_id == expense_id, FinExpenseDetail.invoice_id.is_not(None),
)
inv_ids = (await db.execute(detail_stmt)).scalars().all()
if inv_ids:
await db.execute(
update(FinInvoicePool).where(FinInvoicePool.id.in_(inv_ids))
.values(is_used=False, updated_at=now)
)
# ── Service Functions ────────────────────────────────────
async def create_invoice(db: AsyncSession, user: CurrentUserPayload, body: InvoiceCreate) -> InvoiceResponse:
invoice = FinInvoicePool(
uploader_id=user.user_id, file_url=body.file_url,
merchant_name=body.merchant_name, amount=body.amount,
invoice_date=body.invoice_date, type=body.type,
ai_extracted_data=body.ai_extracted_data, is_used=False,
)
db.add(invoice)
await db.commit()
await db.refresh(invoice)
return _inv_to_resp(invoice)
async def list_invoices(
db: AsyncSession, user: CurrentUserPayload,
page: int = 1, size: int = 20,
inv_type: str | None = None, is_used: bool | None = None,
) -> InvoiceListResponse:
where = [FinInvoicePool.is_deleted.is_(False)]
if user.data_scope == "self":
where.append(FinInvoicePool.uploader_id == user.user_id)
elif user.data_scope == "dept_and_sub":
if user.dept_id is not None:
from app.models.sys import SysUser
dept_users = select(SysUser.id).where(SysUser.dept_id == user.dept_id, SysUser.is_deleted.is_(False))
where.append(FinInvoicePool.uploader_id.in_(dept_users))
if inv_type:
where.append(FinInvoicePool.type == inv_type)
if is_used is not None:
where.append(FinInvoicePool.is_used == is_used)
total = (await db.execute(select(func.count()).select_from(FinInvoicePool).where(*where))).scalar() or 0
stmt = select(FinInvoicePool).where(*where).order_by(FinInvoicePool.created_at.desc()).offset((page - 1) * size).limit(size)
invoices = (await db.execute(stmt)).scalars().all()
return InvoiceListResponse(total=total, items=[_inv_to_resp(i) for i in invoices], page=page, size=size)
async def void_invoice(db: AsyncSession, user: CurrentUserPayload, invoice_id: uuid.UUID) -> None:
inv = (await db.execute(
select(FinInvoicePool).where(FinInvoicePool.id == invoice_id, FinInvoicePool.is_deleted.is_(False))
)).scalar_one_or_none()
if inv is None:
raise NotFoundException("票据不存在或已作废")
if user.data_scope != "all" and inv.uploader_id != user.user_id:
raise ForbiddenException("无权作废他人上传的票据")
if inv.is_used:
raise BizException(message="该票据已关联报销单,无法作废。请先撤回对应报销单。")
await db.execute(update(FinInvoicePool).where(FinInvoicePool.id == invoice_id).values(is_deleted=True, updated_at=datetime.utcnow()))
await db.commit()
async def create_expense(db: AsyncSession, user: CurrentUserPayload, body: ExpenseCreate) -> ExpenseResponse:
invoice_ids = [item.invoice_id for item in body.items]
try:
async with db.begin_nested():
lock_stmt = select(FinInvoicePool).where(
FinInvoicePool.id.in_(invoice_ids), FinInvoicePool.is_deleted.is_(False),
).with_for_update()
locked_invs = (await db.execute(lock_stmt)).scalars().all()
locked_map = {inv.id: inv for inv in locked_invs}
for item in body.items:
inv = locked_map.get(item.invoice_id)
if inv is None:
raise BizException(message=f"发票 {item.invoice_id} 不存在或已被作废")
if inv.is_used:
raise BizException(message=f"发票 {inv.merchant_name or inv.id}{inv.amount}) 已被其他报销单使用,禁止重复报销")
system_no = await _generate_expense_no(db)
expense = FinExpenseRecord(
system_no=system_no, applicant_id=user.user_id,
total_amount=body.total_amount, status="submitted", remark=body.remark,
)
db.add(expense)
await db.flush()
for item in body.items:
db.add(FinExpenseDetail(
expense_id=expense.id, invoice_id=item.invoice_id,
expense_desc=item.expense_desc, original_type=item.original_type,
offset_type=item.offset_type, amount=item.amount,
))
await db.execute(
update(FinInvoicePool).where(FinInvoicePool.id.in_(invoice_ids))
.values(is_used=True, updated_at=datetime.utcnow())
)
await db.commit()
except BizException:
await db.rollback()
raise
except Exception as e:
await db.rollback()
raise BizException(code=500, message=f"报销单创建事务失败: {e!s}") from e
refreshed = (await db.execute(select(FinExpenseRecord).where(FinExpenseRecord.id == expense.id))).scalar_one()
return _exp_to_resp(refreshed)
async def list_expenses(
db: AsyncSession, user: CurrentUserPayload,
page: int = 1, size: int = 20,
status: str | None = None, applicant_id: uuid.UUID | None = None,
) -> ExpenseListResponse:
where = [FinExpenseRecord.is_deleted.is_(False)]
if user.data_scope == "self":
where.append(FinExpenseRecord.applicant_id == user.user_id)
elif user.data_scope == "dept_and_sub":
if user.dept_id is not None:
from app.models.sys import SysUser
dept_users = select(SysUser.id).where(SysUser.dept_id == user.dept_id, SysUser.is_deleted.is_(False))
where.append(FinExpenseRecord.applicant_id.in_(dept_users))
if status:
where.append(FinExpenseRecord.status == status)
if applicant_id and user.data_scope == "all":
where.append(FinExpenseRecord.applicant_id == applicant_id)
total = (await db.execute(select(func.count()).select_from(FinExpenseRecord).where(*where))).scalar() or 0
stmt = select(FinExpenseRecord).where(*where).order_by(FinExpenseRecord.created_at.desc()).offset((page - 1) * size).limit(size)
expenses = (await db.execute(stmt)).scalars().all()
return ExpenseListResponse(total=total, items=[_exp_to_brief(e) for e in expenses], page=page, size=size)
async def get_expense(db: AsyncSession, user: CurrentUserPayload, expense_id: uuid.UUID) -> ExpenseResponse:
exp = (await db.execute(
select(FinExpenseRecord).where(FinExpenseRecord.id == expense_id, FinExpenseRecord.is_deleted.is_(False))
)).scalar_one_or_none()
if exp is None:
raise NotFoundException("报销单不存在")
if user.data_scope == "self" and exp.applicant_id != user.user_id:
raise ForbiddenException("无权查看他人的报销单")
return _exp_to_resp(exp)
async def update_expense_status(
db: AsyncSession, user: CurrentUserPayload,
expense_id: uuid.UUID, body: ExpenseStatusUpdate,
) -> str:
"""返回操作结果消息"""
exp = (await db.execute(
select(FinExpenseRecord).where(FinExpenseRecord.id == expense_id, FinExpenseRecord.is_deleted.is_(False))
)).scalar_one_or_none()
if exp is None:
raise NotFoundException("报销单不存在")
now = datetime.utcnow()
if body.action == "withdraw":
if exp.applicant_id != user.user_id:
raise ForbiddenException("只能撤回自己的报销单")
if exp.status != "submitted":
raise BizException(message=f"当前状态 [{exp.status}] 不允许撤回,仅 submitted 状态可撤回")
try:
async with db.begin_nested():
await db.execute(update(FinExpenseRecord).where(FinExpenseRecord.id == expense_id).values(status="voided", updated_at=now))
await _release_invoices(db, expense_id, now)
await db.commit()
except BizException:
await db.rollback()
raise
except Exception as e:
await db.rollback()
raise BizException(code=500, message=f"撤回事务失败: {e!s}") from e
return "报销单已撤回,关联发票已释放"
elif body.action == "approve":
if user.data_scope != "all":
raise ForbiddenException("仅管理员/财务可审批")
if exp.status != "submitted":
raise BizException(message=f"当前状态 [{exp.status}] 不允许审批")
await db.execute(update(FinExpenseRecord).where(FinExpenseRecord.id == expense_id).values(
status="approved", approved_by=user.user_id, approved_at=now, updated_at=now,
))
await db.commit()
return "报销单已审批通过"
elif body.action == "reject":
if user.data_scope != "all":
raise ForbiddenException("仅管理员/财务可驳回")
if exp.status != "submitted":
raise BizException(message=f"当前状态 [{exp.status}] 不允许驳回")
try:
async with db.begin_nested():
await db.execute(update(FinExpenseRecord).where(FinExpenseRecord.id == expense_id).values(
status="rejected", approved_by=user.user_id, approved_at=now, updated_at=now,
))
await _release_invoices(db, expense_id, now)
await db.commit()
except BizException:
await db.rollback()
raise
except Exception as e:
await db.rollback()
raise BizException(code=500, message=f"驳回事务失败: {e!s}") from e
return "报销单已驳回,关联发票已释放"
raise BizException(message=f"未知操作: {body.action}")
+108
View File
@@ -0,0 +1,108 @@
"""
意图分类服务 — 基于 4060 节点 Qwen3.5-4B
将用户输入快速分类为不同的意图类型,用于路由到对应的处理逻辑。
"""
from __future__ import annotations
import json
import re
import httpx
from app.core.config import settings
# 意图 → Dify App 路由映射(可扩展)
INTENT_ROUTES = {
"crm": "dify_agent", # 客户、订单、产品、发货等 CRM 操作
"finance": "dify_agent", # 财务、报销、票据操作
"knowledge": "dify_agent", # 知识库问答(未来可以指向独立的 RAG App)
"general": "dify_agent", # 通用闲聊
"report": "dify_workflow_report", # 周报/月报生成
}
SYSTEM_PROMPT = """你是一个意图分类器。根据用户输入,判断它属于以下哪个意图类别。只返回 JSON 格式 {"intent": "xxx", "confidence": 0.xx}。
意图类别:
- crm: 客户管理(查询/新建客户)、订单管理(查询/下单)、产品/库存、发货物流
- finance: 报销、票据、发票、财务审批
- report: 生成周报、月报、工作汇报
- knowledge: 产品知识、技术问答、公司规章制度
- general: 日常闲聊、问候、与业务无关的问题
只输出 JSON,不要解释。"""
async def classify_intent(message: str) -> dict:
"""
调用 4060 上的 Qwen3.5-4B 做快速意图分类。
返回 {"intent": "crm", "confidence": 0.95, "route": "dify_agent"}
如果分类失败,默认路由到 dify_agent。
"""
fallback = {"intent": "general", "confidence": 0.0, "route": "dify_agent"}
if not settings.OLLAMA_4060_BASE_URL:
return fallback
url = f"{settings.OLLAMA_4060_BASE_URL}/api/chat"
payload = {
"model": settings.OLLAMA_4060_MODEL,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": message},
],
"stream": False,
"options": {
"temperature": 0.1,
"num_predict": 500, # Qwen3.5 的 CoT thinking 会消耗较多 token
},
}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(url, json=payload)
if resp.status_code != 200:
print(f"[IntentGateway] 4060 返回 {resp.status_code}: {resp.text[:200]}")
return fallback
data = resp.json()
# Qwen3.5 的 CoT 推理放在 message.thinking 字段,最终结果在 message.content
content = data.get("message", {}).get("content", "")
thinking = data.get("message", {}).get("thinking", "")
# 优先从 content 提取 JSON,回退到 thinking
for text_source in [content, thinking]:
if not text_source:
continue
# 去掉 <think>...</think> 块
cleaned = re.sub(r'<think>.*?</think>', '', text_source, flags=re.DOTALL).strip()
json_match = re.search(r'\{[^}]+\}', cleaned)
if json_match:
try:
result = json.loads(json_match.group())
intent = result.get("intent", "general")
confidence = float(result.get("confidence", 0.0))
route = INTENT_ROUTES.get(intent, "dify_agent")
print(f"[IntentGateway] intent={intent}, confidence={confidence:.2f}, route={route}")
return {"intent": intent, "confidence": confidence, "route": route}
except json.JSONDecodeError:
continue
# 从 thinking 内容中启发式推断意图(当 JSON 未生成完成时)
combined = (thinking + " " + content).lower()
if any(kw in combined for kw in ["crm", "customer", "客户", "订单", "order"]):
print(f"[IntentGateway] 启发式推断: crm")
return {"intent": "crm", "confidence": 0.7, "route": "dify_agent"}
if any(kw in combined for kw in ["finance", "报销", "发票", "票据", "财务"]):
print(f"[IntentGateway] 启发式推断: finance")
return {"intent": "finance", "confidence": 0.7, "route": "dify_agent"}
if any(kw in combined for kw in ["report", "周报", "月报", "汇报"]):
print(f"[IntentGateway] 启发式推断: report")
return {"intent": "report", "confidence": 0.7, "route": "dify_workflow_report"}
print(f"[IntentGateway] JSON 解析失败, 内容长度: content={len(content)}, thinking={len(thinking)}")
return fallback
except httpx.TimeoutException:
print("[IntentGateway] 4060 超时,降级到默认路由")
return fallback
except Exception as e:
print(f"[IntentGateway] 错误: {e}")
return fallback
+220
View File
@@ -0,0 +1,220 @@
"""
OCR 服务 — 基于 3090 节点 Qwen3.5-27B (Vision)
对发票/名片图片做 AI 视觉理解,提取结构化数据。
"""
from __future__ import annotations
import base64
import json
import re
import httpx
from app.core.config import settings
INVOICE_PROMPT = """你是一个专业的发票OCR解析器。请分析图片中的发票/票据,提取以下结构化信息,以 JSON 格式返回:
{
"merchant": "开票方/销售方名称",
"amount": 金额数字(不带货币符号),
"date": "YYYY-MM-DD 格式的开票日期",
"invoice_code": "发票代码(如有)",
"invoice_number": "发票号码(如有)",
"tax_rate": "税率(如有)",
"tax_amount": 税额数字(如有),
"items": "发票上的商品/服务名称",
"buyer": "购买方/抬头(如有)",
"remark": "备注信息(如有)"
}
只输出 JSON,不需要解释。如果某个字段无法识别,设为 null。"""
BUSINESS_CARD_PROMPT = """你是一个名片OCR解析器。请分析图片中的名片,提取以下信息并以 JSON 返回:
{
"name": "姓名",
"company": "公司名称",
"title": "职位",
"phone": "电话号码",
"email": "邮箱",
"address": "地址",
"other": "其他信息"
}
只输出 JSON。无法识别的字段设为 null。"""
async def ocr_image(
image_base64: str,
scene: str = "invoice",
) -> dict:
"""
调用 3090 Qwen-VL 对图片做视觉理解/OCR。
Args:
image_base64: base64 编码的图片数据
scene: "invoice" | "business_card" | "general"
Returns:
{"success": True, "data": {...提取的结构化数据...}}
"""
fallback = {"success": False, "data": {}, "error": "OCR 服务不可用"}
if not settings.OLLAMA_3090_BASE_URL:
return fallback
prompt = INVOICE_PROMPT if scene == "invoice" else (
BUSINESS_CARD_PROMPT if scene == "business_card" else
"请详细描述图片中的所有文字内容,以 JSON 格式输出。"
)
url = f"{settings.OLLAMA_3090_BASE_URL}/api/chat"
payload = {
"model": settings.OLLAMA_3090_MODEL,
"messages": [
{
"role": "user",
"content": "/no_think\n" + prompt,
"images": [image_base64], # Ollama vision 格式
},
],
"stream": False,
"options": {
"temperature": 0.1,
"num_predict": 2000,
},
}
try:
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(url, json=payload)
if resp.status_code != 200:
print(f"[OCR] 3090 返回 {resp.status_code}: {resp.text[:200]}")
return {"success": False, "data": {}, "error": f"VL 模型返回 {resp.status_code}"}
data = resp.json()
# Qwen3.5 的 CoT 推理放在 message.thinking,最终结果在 message.content
content = data.get("message", {}).get("content", "")
thinking = data.get("message", {}).get("thinking", "")
# 优先从 content 提取 JSON,回退到 thinking
for text_source in [content, thinking]:
if not text_source:
continue
cleaned = re.sub(r'<think>.*?</think>', '', text_source, flags=re.DOTALL).strip()
json_match = re.search(r'\{[\s\S]*\}', cleaned)
if json_match:
try:
result = json.loads(json_match.group())
print(f"[OCR] 解析成功: {list(result.keys())}")
return {"success": True, "data": result}
except json.JSONDecodeError:
continue
# 没有提取到 JSON,返回原始文本
raw = content or thinking
print(f"[OCR] 未能提取 JSON, 内容长度: content={len(content)}, thinking={len(thinking)}")
return {"success": True, "data": {"raw_text": raw[:2000]}}
except httpx.TimeoutException:
print("[OCR] 3090 超时(60s")
return {"success": False, "data": {}, "error": "VL 模型响应超时"}
except json.JSONDecodeError as e:
print(f"[OCR] JSON 解析失败: {e}")
return {"success": False, "data": {}, "error": f"JSON 解析失败: {e}"}
except Exception as e:
print(f"[OCR] 错误: {e}")
return {"success": False, "data": {}, "error": str(e)}
TEXT_INVOICE_PROMPT = """你是一个专业的发票数据提取器。以下是一份发票/票据的文本内容(来自 PDF 转换后的 Markdown 或纯文本)。
请从中提取以下结构化信息,以 JSON 格式返回:
{
"merchant": "开票方/销售方名称",
"amount": 金额数字(不带货币符号),
"date": "YYYY-MM-DD 格式的开票日期",
"invoice_code": "发票代码(如有)",
"invoice_number": "发票号码(如有)",
"tax_rate": "税率(如有)",
"tax_amount": 税额数字(如有),
"items": "发票上的商品/服务名称",
"buyer": "购买方/抬头(如有)",
"remark": "备注信息(如有)"
}
只输出 JSON,不需要解释。如果某个字段无法识别,设为 null。
注意:文本可能是从 PDF 转换而来,格式可能不规整,请智能识别。"""
async def extract_invoice_from_text(
text: str,
scene: str = "invoice",
) -> dict:
"""
用 LLM 从纯文本(MD/TXT)中提取发票结构化数据。
不走视觉模型,纯文本理解,更快更准。
"""
fallback = {"success": False, "data": {}, "error": "AI 文本提取服务不可用"}
if not settings.OLLAMA_3090_BASE_URL:
return fallback
prompt = TEXT_INVOICE_PROMPT if scene == "invoice" else (
BUSINESS_CARD_PROMPT if scene == "business_card" else
"请从以下文本中提取所有关键信息,以 JSON 格式输出。"
)
# 限制文本长度,避免 token 爆炸
truncated = text[:8000] if len(text) > 8000 else text
url = f"{settings.OLLAMA_3090_BASE_URL}/api/chat"
payload = {
"model": settings.OLLAMA_3090_MODEL,
"messages": [
{
"role": "user",
"content": f"/no_think\n{prompt}\n\n--- 以下是发票文本内容 ---\n\n{truncated}",
# 不传 images —— 纯文本模式
},
],
"stream": False,
"options": {
"temperature": 0.1,
"num_predict": 2000,
},
}
try:
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(url, json=payload)
if resp.status_code != 200:
print(f"[TextExtract] 3090 返回 {resp.status_code}: {resp.text[:200]}")
return {"success": False, "data": {}, "error": f"LLM 返回 {resp.status_code}"}
data = resp.json()
content = data.get("message", {}).get("content", "")
thinking = data.get("message", {}).get("thinking", "")
for text_source in [content, thinking]:
if not text_source:
continue
cleaned = re.sub(r'<think>.*?</think>', '', text_source, flags=re.DOTALL).strip()
json_match = re.search(r'\{[\s\S]*\}', cleaned)
if json_match:
try:
result = json.loads(json_match.group())
print(f"[TextExtract] AI 提取成功: {list(result.keys())}")
return {"success": True, "data": result}
except json.JSONDecodeError:
continue
raw = content or thinking
print(f"[TextExtract] 未能提取 JSON, 内容: {raw[:200]}")
return {"success": True, "data": {"raw_text": raw[:2000]}}
except httpx.TimeoutException:
print("[TextExtract] 3090 超时")
return {"success": False, "data": {}, "error": "LLM 响应超时"}
except Exception as e:
print(f"[TextExtract] 错误: {e}")
return {"success": False, "data": {}, "error": str(e)}
+300
View File
@@ -0,0 +1,300 @@
"""
订单管理 Service 层
REST API 路由 和 MCP 工具 共用此层函数
"""
from __future__ import annotations
import uuid
from datetime import date, datetime
from typing import Any
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import BizException, ForbiddenException, NotFoundException
from app.models.crm import CrmCustomer
from app.models.erp import ProductSku
from app.models.order import ErpOrder, ErpOrderItem
from app.schemas.auth import CurrentUserPayload
from app.schemas.order import (
OrderBriefResponse,
OrderCreate,
OrderItemResponse,
OrderListResponse,
OrderResponse,
PriceCalculateResponse,
)
# ── 工具函数 ─────────────────────────────────────────────
async def _generate_order_no(db: AsyncSession) -> str:
today = date.today().strftime("%Y%m%d")
prefix = f"ORD-{today}-"
stmt = (
select(func.count())
.select_from(ErpOrder)
.where(ErpOrder.order_no.like(f"{prefix}%"))
)
count = (await db.execute(stmt)).scalar() or 0
return f"{prefix}{count + 1:03d}"
def _item_to_response(item: ErpOrderItem) -> OrderItemResponse:
return OrderItemResponse(
id=item.id,
sku_id=item.sku_id,
sku_code=item.sku.sku_code if item.sku else None,
sku_name=item.sku.name if item.sku else None,
spec=item.sku.spec if item.sku else None,
qty=float(item.qty),
unit_price=float(item.unit_price),
sub_total=float(item.sub_total),
shipped_qty=float(item.shipped_qty or 0),
)
def _order_to_response(o: ErpOrder, with_items: bool = True) -> OrderResponse:
return OrderResponse(
id=o.id,
order_no=o.order_no,
customer_id=o.customer_id,
customer_name=o.customer.name if o.customer else None,
salesperson_id=o.salesperson_id,
salesperson_name=o.salesperson.real_name if o.salesperson else None,
total_amount=float(o.total_amount or 0),
shipping_state=o.shipping_state,
payment_state=o.payment_state,
paid_amount=float(o.paid_amount or 0),
remark=o.remark,
order_date=o.order_date,
items=[_item_to_response(i) for i in o.items] if with_items else [],
created_at=o.created_at,
updated_at=o.updated_at,
)
def _order_to_brief(o: ErpOrder) -> OrderBriefResponse:
return OrderBriefResponse(
id=o.id,
order_no=o.order_no,
customer_id=o.customer_id,
customer_name=o.customer.name if o.customer else None,
salesperson_name=o.salesperson.real_name if o.salesperson else None,
total_amount=float(o.total_amount or 0),
shipping_state=o.shipping_state,
payment_state=o.payment_state,
paid_amount=float(o.paid_amount or 0),
order_date=o.order_date,
created_at=o.created_at,
)
def _check_order_access(order: ErpOrder, user: CurrentUserPayload) -> None:
if user.data_scope == "all":
return
if user.data_scope == "self":
if order.salesperson_id != user.user_id:
raise ForbiddenException("无权访问该订单(数据权限:仅本人)")
# ── Service Functions ────────────────────────────────────
async def calculate_price(
db: AsyncSession,
customer_id: uuid.UUID,
sku_id: uuid.UUID,
) -> PriceCalculateResponse:
sku = (
await db.execute(
select(ProductSku).where(
ProductSku.id == sku_id,
ProductSku.is_deleted.is_(False),
)
)
).scalar_one_or_none()
if sku is None:
raise NotFoundException("产品 SKU 不存在")
# 历史成交价追溯
history_stmt = (
select(ErpOrderItem.unit_price, ErpOrder.order_no, ErpOrder.order_date)
.join(ErpOrder, ErpOrderItem.order_id == ErpOrder.id)
.where(
ErpOrder.customer_id == customer_id,
ErpOrderItem.sku_id == sku_id,
ErpOrder.is_deleted.is_(False),
ErpOrderItem.is_deleted.is_(False),
)
.order_by(ErpOrder.created_at.desc())
.limit(1)
)
history = (await db.execute(history_stmt)).first()
if history:
return PriceCalculateResponse(
sku_id=sku.id,
sku_code=sku.sku_code,
sku_name=sku.name,
unit_price=float(history.unit_price),
price_source="history",
last_order_no=history.order_no,
last_order_date=history.order_date,
)
return PriceCalculateResponse(
sku_id=sku.id,
sku_code=sku.sku_code,
sku_name=sku.name,
unit_price=float(sku.standard_price or 0),
price_source="standard",
)
async def create_order(
db: AsyncSession,
user: CurrentUserPayload,
body: OrderCreate,
) -> OrderResponse:
# 校验客户存在
cust = (
await db.execute(
select(CrmCustomer).where(
CrmCustomer.id == body.customer_id,
CrmCustomer.is_deleted.is_(False),
)
)
).scalar_one_or_none()
if cust is None:
raise NotFoundException("客户不存在")
# 校验所有 SKU 存在
sku_ids = [item.sku_id for item in body.items]
skus = (
await db.execute(
select(ProductSku).where(
ProductSku.id.in_(sku_ids),
ProductSku.is_deleted.is_(False),
)
)
).scalars().all()
found_ids = {s.id for s in skus}
missing = [str(sid) for sid in sku_ids if sid not in found_ids]
if missing:
raise BizException(message=f"以下 SKU 不存在: {', '.join(missing)}")
try:
async with db.begin_nested():
order_no = await _generate_order_no(db)
total = sum(item.qty * item.unit_price for item in body.items)
order = ErpOrder(
order_no=order_no,
customer_id=body.customer_id,
salesperson_id=user.user_id,
total_amount=total,
shipping_state="pending",
payment_state="unpaid",
paid_amount=0,
remark=body.remark,
order_date=body.order_date or date.today(),
)
db.add(order)
await db.flush()
for item in body.items:
order_item = ErpOrderItem(
order_id=order.id,
sku_id=item.sku_id,
qty=item.qty,
unit_price=item.unit_price,
sub_total=round(item.qty * item.unit_price, 2),
shipped_qty=0,
)
db.add(order_item)
await db.commit()
except BizException:
raise
except Exception as e:
await db.rollback()
raise BizException(code=500, message=f"订单创建事务失败: {e!s}") from e
refreshed = (
await db.execute(select(ErpOrder).where(ErpOrder.id == order.id))
).scalar_one()
return _order_to_response(refreshed)
async def list_orders(
db: AsyncSession,
user: CurrentUserPayload,
page: int = 1,
size: int = 20,
customer_id: uuid.UUID | None = None,
shipping_state: str | None = None,
payment_state: str | None = None,
keyword: str | None = None,
) -> OrderListResponse:
where: list[Any] = [ErpOrder.is_deleted.is_(False)]
if user.data_scope == "self":
where.append(ErpOrder.salesperson_id == user.user_id)
elif user.data_scope == "dept_and_sub":
if user.dept_id is not None:
from app.models.sys import SysUser
sub = select(SysUser.id).where(
SysUser.dept_id == user.dept_id,
SysUser.is_deleted.is_(False),
)
where.append(ErpOrder.salesperson_id.in_(sub))
if customer_id:
where.append(ErpOrder.customer_id == customer_id)
if shipping_state:
where.append(ErpOrder.shipping_state == shipping_state)
if payment_state:
where.append(ErpOrder.payment_state == payment_state)
if keyword:
where.append(ErpOrder.order_no.ilike(f"%{keyword}%"))
total = (
await db.execute(select(func.count()).select_from(ErpOrder).where(*where))
).scalar() or 0
stmt = (
select(ErpOrder)
.where(*where)
.order_by(ErpOrder.created_at.desc())
.offset((page - 1) * size)
.limit(size)
)
orders = (await db.execute(stmt)).scalars().all()
return OrderListResponse(
total=total,
items=[_order_to_brief(o) for o in orders],
page=page,
size=size,
)
async def get_order(
db: AsyncSession,
user: CurrentUserPayload,
order_id: uuid.UUID,
) -> OrderResponse:
order = (
await db.execute(
select(ErpOrder).where(
ErpOrder.id == order_id,
ErpOrder.is_deleted.is_(False),
)
)
).scalar_one_or_none()
if order is None:
raise NotFoundException("订单不存在或已被删除")
_check_order_access(order, user)
return _order_to_response(order)
+396
View File
@@ -0,0 +1,396 @@
"""
产品与库存 Service 层
REST API 路由 和 MCP 工具 共用此层函数
"""
from __future__ import annotations
import uuid
from datetime import datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import BizException, NotFoundException
from app.models.erp import InventoryFlow, ProductCategory, ProductSku
from app.schemas.auth import CurrentUserPayload
from app.schemas.erp import (
CategoryCreate,
CategoryNode,
CategoryUpdate,
InventoryFlowCreate,
InventoryFlowResponse,
SkuCreate,
SkuListResponse,
SkuResponse,
SkuUpdate,
)
# ── ORM → Response ───────────────────────────────────────
def _sku_to_response(s: ProductSku) -> SkuResponse:
return SkuResponse(
id=s.id,
sku_code=s.sku_code,
name=s.name,
category_id=s.category_id,
category_name=s.category.name if s.category else None,
spec=s.spec,
standard_price=float(s.standard_price or 0),
stock_qty=float(s.stock_qty or 0),
warning_threshold=float(s.warning_threshold or 0),
unit=s.unit,
status=s.status,
created_at=s.created_at,
updated_at=s.updated_at,
)
def _flow_to_response(f: InventoryFlow) -> InventoryFlowResponse:
return InventoryFlowResponse(
id=f.id,
sku_id=f.sku_id,
sku_code=f.sku.sku_code if f.sku else None,
sku_name=f.sku.name if f.sku else None,
change_qty=float(f.change_qty),
reason=f.reason,
remark=f.remark,
operator_id=f.operator_id,
operator_name=f.operator.real_name if f.operator else None,
created_at=f.created_at,
)
def _build_tree(
items: list[ProductCategory],
parent_id: uuid.UUID | None = None,
) -> list[CategoryNode]:
nodes: list[CategoryNode] = []
for item in items:
if item.parent_id == parent_id:
children = _build_tree(items, item.id)
nodes.append(
CategoryNode(
id=item.id,
parent_id=item.parent_id,
name=item.name,
sort_order=item.sort_order,
children=children,
)
)
nodes.sort(key=lambda n: n.sort_order)
return nodes
# ── Service Functions ────────────────────────────────────
async def get_category_tree(db: AsyncSession) -> list[dict[str, Any]]:
stmt = (
select(ProductCategory)
.where(ProductCategory.is_deleted.is_(False))
.order_by(ProductCategory.sort_order)
)
categories = list((await db.execute(stmt)).scalars().all())
tree = _build_tree(categories, parent_id=None)
return [n.model_dump(mode="json") for n in tree]
async def create_category(
db: AsyncSession,
body: CategoryCreate,
) -> dict[str, Any]:
if body.parent_id:
parent = (
await db.execute(
select(ProductCategory).where(
ProductCategory.id == body.parent_id,
ProductCategory.is_deleted.is_(False),
)
)
).scalar_one_or_none()
if parent is None:
raise NotFoundException("父级分类不存在")
cat = ProductCategory(
name=body.name,
parent_id=body.parent_id,
sort_order=body.sort_order,
)
db.add(cat)
await db.commit()
await db.refresh(cat)
return {
"id": str(cat.id),
"name": cat.name,
"parent_id": str(cat.parent_id) if cat.parent_id else None,
}
async def update_category(
db: AsyncSession,
cat_id: uuid.UUID,
body: CategoryUpdate,
) -> None:
cat = (
await db.execute(
select(ProductCategory).where(
ProductCategory.id == cat_id,
ProductCategory.is_deleted.is_(False),
)
)
).scalar_one_or_none()
if cat is None:
raise NotFoundException("分类不存在或已被删除")
update_data = body.model_dump(exclude_unset=True)
if not update_data:
raise BizException(message="未提供任何需要更新的字段")
update_data["updated_at"] = datetime.utcnow()
await db.execute(
update(ProductCategory).where(ProductCategory.id == cat_id).values(**update_data)
)
await db.commit()
async def delete_category(db: AsyncSession, cat_id: uuid.UUID) -> None:
cat = (
await db.execute(
select(ProductCategory).where(
ProductCategory.id == cat_id,
ProductCategory.is_deleted.is_(False),
)
)
).scalar_one_or_none()
if cat is None:
raise NotFoundException("分类不存在或已被删除")
child_count = (
await db.execute(
select(func.count()).select_from(ProductCategory).where(
ProductCategory.parent_id == cat_id,
ProductCategory.is_deleted.is_(False),
)
)
).scalar() or 0
if child_count > 0:
raise BizException(message=f"该分类下有 {child_count} 个子分类,无法删除")
sku_count = (
await db.execute(
select(func.count()).select_from(ProductSku).where(
ProductSku.category_id == cat_id,
ProductSku.is_deleted.is_(False),
)
)
).scalar() or 0
if sku_count > 0:
raise BizException(message=f"该分类下有 {sku_count} 个产品 SKU,无法删除")
await db.execute(
update(ProductCategory)
.where(ProductCategory.id == cat_id)
.values(is_deleted=True, updated_at=datetime.utcnow())
)
await db.commit()
async def list_skus(
db: AsyncSession,
page: int = 1,
size: int = 20,
category_id: uuid.UUID | None = None,
keyword: str | None = None,
) -> SkuListResponse:
where: list[Any] = [ProductSku.is_deleted.is_(False)]
if category_id:
where.append(ProductSku.category_id == category_id)
if keyword:
where.append(
ProductSku.name.ilike(f"%{keyword}%")
| ProductSku.sku_code.ilike(f"%{keyword}%")
)
total = (
await db.execute(select(func.count()).select_from(ProductSku).where(*where))
).scalar() or 0
stmt = (
select(ProductSku)
.where(*where)
.order_by(ProductSku.created_at.desc())
.offset((page - 1) * size)
.limit(size)
)
rows = (await db.execute(stmt)).scalars().all()
return SkuListResponse(
total=total,
items=[_sku_to_response(s) for s in rows],
page=page,
size=size,
)
async def create_sku(db: AsyncSession, body: SkuCreate) -> SkuResponse:
exists = (
await db.execute(
select(ProductSku.id).where(
ProductSku.sku_code == body.sku_code,
ProductSku.is_deleted.is_(False),
)
)
).scalar_one_or_none()
if exists:
raise BizException(message=f"SKU 编码 '{body.sku_code}' 已存在")
sku = ProductSku(
sku_code=body.sku_code,
name=body.name,
category_id=body.category_id,
spec=body.spec,
standard_price=body.standard_price,
stock_qty=body.stock_qty,
warning_threshold=body.warning_threshold,
unit=body.unit,
status=body.status,
)
db.add(sku)
await db.commit()
await db.refresh(sku)
return _sku_to_response(sku)
async def update_sku(
db: AsyncSession,
sku_id: uuid.UUID,
body: SkuUpdate,
) -> SkuResponse:
sku = (
await db.execute(
select(ProductSku).where(
ProductSku.id == sku_id, ProductSku.is_deleted.is_(False)
)
)
).scalar_one_or_none()
if sku is None:
raise NotFoundException("产品不存在或已被删除")
update_data = body.model_dump(exclude_unset=True)
if not update_data:
raise BizException(message="未提供任何需要更新的字段")
update_data["updated_at"] = datetime.utcnow()
await db.execute(
update(ProductSku).where(ProductSku.id == sku_id).values(**update_data)
)
await db.commit()
refreshed = (
await db.execute(select(ProductSku).where(ProductSku.id == sku_id))
).scalar_one()
return _sku_to_response(refreshed)
async def create_inventory_flow(
db: AsyncSession,
user: CurrentUserPayload,
body: InventoryFlowCreate,
) -> InventoryFlowResponse:
sku = (
await db.execute(
select(ProductSku).where(
ProductSku.id == body.sku_id, ProductSku.is_deleted.is_(False)
)
)
).scalar_one_or_none()
if sku is None:
raise NotFoundException("产品 SKU 不存在")
if body.change_qty < 0:
current_stock = float(sku.stock_qty or 0)
if current_stock + body.change_qty < 0:
raise BizException(
message=f"库存不足:当前库存 {current_stock},请求出库 {abs(body.change_qty)}"
)
try:
async with db.begin_nested():
flow = InventoryFlow(
sku_id=body.sku_id,
change_qty=body.change_qty,
reason=body.reason,
remark=body.remark,
operator_id=user.user_id,
)
db.add(flow)
await db.flush()
await db.execute(
update(ProductSku)
.where(ProductSku.id == body.sku_id)
.values(
stock_qty=ProductSku.stock_qty + Decimal(str(body.change_qty)),
updated_at=datetime.utcnow(),
)
)
await db.commit()
except Exception as e:
await db.rollback()
raise BizException(code=500, message=f"库存变更事务失败: {e!s}") from e
refreshed = (
await db.execute(select(InventoryFlow).where(InventoryFlow.id == flow.id))
).scalar_one()
return _flow_to_response(refreshed)
async def get_inventory_flows(
db: AsyncSession,
sku_id: uuid.UUID,
page: int = 1,
size: int = 50,
) -> dict[str, Any]:
sku = (
await db.execute(
select(ProductSku).where(
ProductSku.id == sku_id, ProductSku.is_deleted.is_(False)
)
)
).scalar_one_or_none()
if sku is None:
raise NotFoundException("产品 SKU 不存在")
where: list[Any] = [
InventoryFlow.sku_id == sku_id,
InventoryFlow.is_deleted.is_(False),
]
total = (
await db.execute(
select(func.count()).select_from(InventoryFlow).where(*where)
)
).scalar() or 0
stmt = (
select(InventoryFlow)
.where(*where)
.order_by(InventoryFlow.created_at.desc())
.offset((page - 1) * size)
.limit(size)
)
flows = (await db.execute(stmt)).scalars().all()
return {
"total": total,
"sku_code": sku.sku_code,
"sku_name": sku.name,
"current_stock": float(sku.stock_qty or 0),
"items": [_flow_to_response(f).model_dump(mode="json") for f in flows],
"page": page,
"size": size,
}
@@ -0,0 +1,220 @@
"""
销项发票 Service 层 — CRUD + 多条件查询 + 导出
"""
from __future__ import annotations
import io
import uuid
from datetime import date, datetime
from sqlalchemy import and_, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import BizException, NotFoundException
from app.models.finance import FinSalesInvoice
from app.schemas.auth import CurrentUserPayload
from app.schemas.sales_invoice import (
SalesInvoiceCreate,
SalesInvoiceListResponse,
SalesInvoiceResponse,
SalesInvoiceUpdate,
)
def _to_response(inv: FinSalesInvoice) -> SalesInvoiceResponse:
return SalesInvoiceResponse(
id=inv.id,
issuer=inv.issuer,
receiver_customer_id=inv.receiver_customer_id,
customer_name=inv.receiver_customer.name if inv.receiver_customer else None,
invoice_number=inv.invoice_number,
amount=float(inv.amount or 0),
billing_date=inv.billing_date,
payment_status=inv.payment_status,
payment_date=inv.payment_date,
payment_amount=float(inv.payment_amount or 0),
remark=inv.remark,
created_by=inv.created_by,
creator_name=inv.creator.real_name if inv.creator else None,
created_at=inv.created_at,
updated_at=inv.updated_at,
)
async def create_invoice(
db: AsyncSession,
user: CurrentUserPayload,
body: SalesInvoiceCreate,
) -> SalesInvoiceResponse:
# 检查发票号唯一性
existing = (await db.execute(
select(func.count()).select_from(FinSalesInvoice).where(
FinSalesInvoice.invoice_number == body.invoice_number,
FinSalesInvoice.is_deleted.is_(False),
)
)).scalar()
if existing:
raise BizException(message=f"发票号 {body.invoice_number} 已存在")
inv = FinSalesInvoice(
issuer=body.issuer,
receiver_customer_id=body.receiver_customer_id,
invoice_number=body.invoice_number,
amount=body.amount,
billing_date=body.billing_date,
remark=body.remark,
created_by=user.user_id,
)
db.add(inv)
await db.commit()
await db.refresh(inv)
return _to_response(inv)
async def list_invoices(
db: AsyncSession,
page: int = 1,
size: int = 20,
customer_name: str | None = None,
invoice_number: str | None = None,
payment_status: str | None = None,
start_date: date | None = None,
end_date: date | None = None,
) -> SalesInvoiceListResponse:
conditions = [FinSalesInvoice.is_deleted.is_(False)]
if invoice_number:
conditions.append(FinSalesInvoice.invoice_number.ilike(f"%{invoice_number}%"))
if payment_status:
conditions.append(FinSalesInvoice.payment_status == payment_status)
if start_date:
conditions.append(FinSalesInvoice.billing_date >= start_date)
if end_date:
conditions.append(FinSalesInvoice.billing_date <= end_date)
where = and_(*conditions) if conditions else True
total = (await db.execute(
select(func.count()).select_from(FinSalesInvoice).where(where)
)).scalar() or 0
stmt = (
select(FinSalesInvoice)
.where(where)
.order_by(FinSalesInvoice.billing_date.desc())
.offset((page - 1) * size)
.limit(size)
)
invoices = (await db.execute(stmt)).scalars().all()
items = [_to_response(inv) for inv in invoices]
# 如果有客户名称筛选,在 Python 层过滤(因为是 join 字段)
if customer_name:
items = [i for i in items if customer_name.lower() in (i.customer_name or "").lower()]
total = len(items)
return SalesInvoiceListResponse(
total=total,
items=items,
page=page,
size=size,
)
async def get_invoice(
db: AsyncSession,
invoice_id: uuid.UUID,
) -> SalesInvoiceResponse:
stmt = select(FinSalesInvoice).where(
FinSalesInvoice.id == invoice_id,
FinSalesInvoice.is_deleted.is_(False),
)
inv = (await db.execute(stmt)).scalar_one_or_none()
if inv is None:
raise NotFoundException("发票不存在或已被删除")
return _to_response(inv)
async def update_invoice(
db: AsyncSession,
invoice_id: uuid.UUID,
body: SalesInvoiceUpdate,
) -> SalesInvoiceResponse:
stmt = select(FinSalesInvoice).where(
FinSalesInvoice.id == invoice_id,
FinSalesInvoice.is_deleted.is_(False),
)
inv = (await db.execute(stmt)).scalar_one_or_none()
if inv is None:
raise NotFoundException("发票不存在或已被删除")
update_data = body.model_dump(exclude_unset=True)
if not update_data:
raise BizException(message="未提供任何需要更新的字段")
update_data["updated_at"] = datetime.utcnow()
await db.execute(
update(FinSalesInvoice)
.where(FinSalesInvoice.id == invoice_id)
.values(**update_data)
)
await db.commit()
updated = (await db.execute(
select(FinSalesInvoice).where(FinSalesInvoice.id == invoice_id)
)).scalar_one()
return _to_response(updated)
async def export_invoices(
db: AsyncSession,
start_date: date | None = None,
end_date: date | None = None,
) -> io.BytesIO:
"""导出指定时间段的发票汇总及回款追踪表"""
from openpyxl import Workbook
conditions = [FinSalesInvoice.is_deleted.is_(False)]
if start_date:
conditions.append(FinSalesInvoice.billing_date >= start_date)
if end_date:
conditions.append(FinSalesInvoice.billing_date <= end_date)
stmt = (
select(FinSalesInvoice)
.where(and_(*conditions))
.order_by(FinSalesInvoice.billing_date.desc())
)
invoices = (await db.execute(stmt)).scalars().all()
wb = Workbook()
ws = wb.active
ws.title = "发票汇总及回款追踪"
ws.append([
"发票号", "开票方", "受票客户", "票面金额",
"开票日期", "回款状态", "已回款金额", "回款日期", "备注"
])
for inv in invoices:
ws.append([
inv.invoice_number,
inv.issuer,
inv.receiver_customer.name if inv.receiver_customer else "",
float(inv.amount or 0),
inv.billing_date.isoformat() if inv.billing_date else "",
inv.payment_status,
float(inv.payment_amount or 0),
inv.payment_date.isoformat() if inv.payment_date else "",
inv.remark or "",
])
# 列宽
col_widths = [20, 25, 25, 15, 15, 12, 15, 15, 30]
for i, w in enumerate(col_widths, 1):
ws.column_dimensions[chr(64 + i)].width = w
buffer = io.BytesIO()
wb.save(buffer)
buffer.seek(0)
return buffer
+164
View File
@@ -0,0 +1,164 @@
"""
销售日志服务 — CRUD + Dify 工作流异步触发
"""
from __future__ import annotations
import uuid
from datetime import date
from typing import Any
import httpx
from sqlalchemy import select, func, desc, and_
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.ai import SalesLog
from app.schemas.auth import CurrentUserPayload
async def create_log(
db: AsyncSession,
user: CurrentUserPayload,
content: str,
customer_id: str | None = None,
contact_ids: list[str] | None = None,
log_date: date | None = None,
) -> dict:
"""创建销售日志"""
log = SalesLog(
salesperson_id=user.user_id,
customer_id=uuid.UUID(customer_id) if customer_id else None,
contact_ids=contact_ids or [],
content=content,
log_date=log_date or date.today(),
)
db.add(log)
await db.commit()
await db.refresh(log)
return _to_dict(log)
async def list_logs(
db: AsyncSession,
user: CurrentUserPayload,
page: int = 1,
size: int = 20,
customer_id: str | None = None,
user_id: str | None = None,
start_date: str | None = None,
end_date: str | None = None,
) -> dict:
"""查询销售日志列表"""
conditions = [SalesLog.is_deleted.is_(False)]
# 数据权限
if user.data_scope == "self":
conditions.append(SalesLog.salesperson_id == user.user_id)
elif user_id:
conditions.append(SalesLog.salesperson_id == uuid.UUID(user_id))
if start_date:
conditions.append(SalesLog.log_date >= start_date)
if end_date:
conditions.append(SalesLog.log_date <= end_date)
if customer_id:
conditions.append(SalesLog.customer_id == uuid.UUID(customer_id))
where = and_(*conditions)
# count
count_stmt = select(func.count()).select_from(SalesLog).where(where)
total = (await db.execute(count_stmt)).scalar() or 0
# data
stmt = (
select(SalesLog)
.where(where)
.order_by(desc(SalesLog.created_at))
.offset((page - 1) * size)
.limit(size)
)
rows = (await db.execute(stmt)).scalars().all()
return {
"total": total,
"page": page,
"size": size,
"items": [_to_dict(r) for r in rows],
}
async def trigger_persona_workflow(
log_id: uuid.UUID,
customer_id: uuid.UUID,
content: str,
salesperson_name: str = "",
contact_ids: list[str] | None = None,
) -> None:
"""异步触发 Dify 画像提取 Workflowfire-and-forget"""
from app.core.config import settings
if not settings.DIFY_WORKFLOW_PERSONA_KEY or not settings.DIFY_API_BASE_URL:
print("[Workflow] 画像提取 Workflow 未配置,跳过")
return
url = f"{settings.DIFY_API_BASE_URL}/v1/workflows/run"
headers = {
"Authorization": f"Bearer {settings.DIFY_WORKFLOW_PERSONA_KEY}",
"Content-Type": "application/json",
}
payload = {
"inputs": {
"customer_id": str(customer_id),
"content": content,
"salesperson_name": salesperson_name,
"contact_ids": ",".join(contact_ids) if contact_ids else "",
},
"response_mode": "blocking",
"user": str(customer_id),
}
max_retries = 3
for attempt in range(max_retries):
try:
async with httpx.AsyncClient(timeout=300) as client:
resp = await client.post(url, json=payload, headers=headers)
print(f"[Workflow] 画像提取触发 status={resp.status_code}, body={resp.text[:200]}")
# 成功后回写 ai_processed
if resp.status_code == 200:
try:
from app.db.database import async_session_factory
from sqlalchemy import update as sa_update
async with async_session_factory() as session:
await session.execute(
sa_update(SalesLog)
.where(SalesLog.id == log_id)
.values(ai_processed=True)
)
await session.commit()
print(f"[Workflow] ai_processed 已更新 log_id={log_id}")
except Exception as db_err:
print(f"[Workflow] ai_processed 回写失败: {db_err}")
return # 成功,退出重试循环
else:
print(f"[Workflow] HTTP {resp.status_code},第 {attempt+1}/{max_retries}")
except Exception as e:
print(f"[Workflow] 画像提取触发失败 (第 {attempt+1}/{max_retries} 次): {e}")
# 重试前等待
if attempt < max_retries - 1:
import asyncio
await asyncio.sleep(10 * (attempt + 1))
def _to_dict(log: SalesLog) -> dict:
return {
"id": str(log.id),
"salesperson_id": str(log.salesperson_id),
"customer_id": str(log.customer_id) if log.customer_id else None,
"contact_ids": log.contact_ids or [],
"content": log.content,
"log_date": log.log_date.isoformat() if log.log_date else None,
"ai_processed": log.ai_processed,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
+221
View File
@@ -0,0 +1,221 @@
"""
物流发货 Service 层
REST API 路由 和 MCP 工具 共用此层函数
"""
from __future__ import annotations
import uuid
from datetime import date, datetime
from decimal import Decimal
from typing import Any
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.exceptions import BizException, ForbiddenException, NotFoundException
from app.models.erp import InventoryFlow, ProductSku
from app.models.order import ErpOrder, ErpOrderItem
from app.models.shipping import ErpShippingItem, ErpShippingRecord
from app.schemas.auth import CurrentUserPayload
from app.schemas.shipping import (
ShippingBriefResponse, ShippingCreate, ShippingItemResponse,
ShippingListResponse, ShippingResponse,
)
async def _generate_shipping_no(db: AsyncSession) -> str:
today = date.today().strftime("%Y%m%d")
prefix = f"SHP-{today}-"
count = (await db.execute(
select(func.count()).select_from(ErpShippingRecord)
.where(ErpShippingRecord.shipping_no.like(f"{prefix}%"))
)).scalar() or 0
return f"{prefix}{count + 1:03d}"
def _ship_item_to_resp(si: ErpShippingItem) -> ShippingItemResponse:
return ShippingItemResponse(
id=si.id, order_item_id=si.order_item_id, sku_id=si.sku_id,
sku_code=si.sku.sku_code if si.sku else None,
sku_name=si.sku.name if si.sku else None,
spec=si.sku.spec if si.sku else None,
unit=si.sku.unit if si.sku else None,
shipped_qty=float(si.shipped_qty),
)
def _ship_to_resp(sr: ErpShippingRecord, with_items: bool = True) -> ShippingResponse:
return ShippingResponse(
id=sr.id, shipping_no=sr.shipping_no, order_id=sr.order_id,
order_no=sr.order.order_no if sr.order else None,
customer_name=sr.order.customer.name if sr.order and sr.order.customer else None,
carrier=sr.carrier, tracking_no=sr.tracking_no, status=sr.status,
ship_date=sr.ship_date, remark=sr.remark,
operator_name=sr.operator.real_name if sr.operator else None,
items=[_ship_item_to_resp(i) for i in sr.items] if with_items else [],
created_at=sr.created_at,
)
def _ship_to_brief(sr: ErpShippingRecord) -> ShippingBriefResponse:
return ShippingBriefResponse(
id=sr.id, shipping_no=sr.shipping_no, order_id=sr.order_id,
order_no=sr.order.order_no if sr.order else None,
customer_name=sr.order.customer.name if sr.order and sr.order.customer else None,
carrier=sr.carrier, tracking_no=sr.tracking_no, status=sr.status,
ship_date=sr.ship_date,
operator_name=sr.operator.real_name if sr.operator else None,
created_at=sr.created_at,
)
def _check_shipping_access(order: ErpOrder, user: CurrentUserPayload) -> None:
if user.data_scope == "all":
return
if user.data_scope == "self" and order.salesperson_id != user.user_id:
raise ForbiddenException("无权访问该订单的发货记录(数据权限:仅本人)")
async def create_shipping(
db: AsyncSession, user: CurrentUserPayload, body: ShippingCreate,
) -> tuple[ShippingResponse, str]:
"""返回 (response, new_shipping_state)"""
order = (await db.execute(
select(ErpOrder).where(ErpOrder.id == body.order_id, ErpOrder.is_deleted.is_(False))
)).scalar_one_or_none()
if order is None:
raise NotFoundException("订单不存在")
if order.shipping_state == "shipped":
raise BizException(message="该订单已全部发完,无法再次发货")
_check_shipping_access(order, user)
order_item_ids = [item.order_item_id for item in body.items]
oi_rows = (await db.execute(
select(ErpOrderItem).where(
ErpOrderItem.id.in_(order_item_ids),
ErpOrderItem.order_id == body.order_id,
ErpOrderItem.is_deleted.is_(False),
)
)).scalars().all()
oi_map: dict[uuid.UUID, ErpOrderItem] = {oi.id: oi for oi in oi_rows}
for item in body.items:
oi = oi_map.get(item.order_item_id)
if oi is None:
raise BizException(message=f"订单明细行 {item.order_item_id} 不存在或不属于该订单")
remaining = float(oi.qty) - float(oi.shipped_qty or 0)
if item.shipped_qty > remaining:
raise BizException(message=f"SKU {item.sku_id} 发货数量超出未发余量:本次 {item.shipped_qty},剩余可发 {remaining}")
new_state = "partial"
try:
async with db.begin_nested():
now = datetime.utcnow()
shipping_no = await _generate_shipping_no(db)
record = ErpShippingRecord(
shipping_no=shipping_no, order_id=body.order_id,
carrier=body.carrier, tracking_no=body.tracking_no,
status="transit", ship_date=body.ship_date or date.today(),
remark=body.remark, operator_id=user.user_id,
)
db.add(record)
await db.flush()
for item in body.items:
si = ErpShippingItem(
shipping_id=record.id, order_item_id=item.order_item_id,
sku_id=item.sku_id, shipped_qty=item.shipped_qty,
)
db.add(si)
result = await db.execute(
update(ProductSku).where(
ProductSku.id == item.sku_id,
ProductSku.stock_qty >= item.shipped_qty,
).values(
stock_qty=ProductSku.stock_qty - Decimal(str(item.shipped_qty)),
updated_at=now,
)
)
if result.rowcount == 0:
sku = (await db.execute(select(ProductSku).where(ProductSku.id == item.sku_id))).scalar_one_or_none()
current_stock = float(sku.stock_qty) if sku else 0
raise BizException(message=f"库存不足无法发货: SKU {item.sku_id},当前库存 {current_stock},请求出库 {item.shipped_qty}")
db.add(InventoryFlow(
sku_id=item.sku_id, change_qty=-item.shipped_qty,
reason="shipment", remark=f"订单发货出库 - 发货单 {shipping_no}",
operator_id=user.user_id,
))
await db.execute(
update(ErpOrderItem).where(ErpOrderItem.id == item.order_item_id)
.values(shipped_qty=ErpOrderItem.shipped_qty + Decimal(str(item.shipped_qty)), updated_at=now)
)
await db.flush()
all_items = (await db.execute(
select(ErpOrderItem).where(ErpOrderItem.order_id == body.order_id, ErpOrderItem.is_deleted.is_(False))
)).scalars().all()
all_shipped = all(float(i.shipped_qty or 0) >= float(i.qty) for i in all_items)
new_state = "shipped" if all_shipped else "partial"
await db.execute(
update(ErpOrder).where(ErpOrder.id == body.order_id)
.values(shipping_state=new_state, updated_at=now)
)
await db.commit()
except BizException:
await db.rollback()
raise
except Exception as e:
await db.rollback()
raise BizException(code=500, message=f"发货事务失败: {e!s}") from e
refreshed = (await db.execute(
select(ErpShippingRecord).where(ErpShippingRecord.id == record.id)
)).scalar_one()
return _ship_to_resp(refreshed), new_state
async def list_shipping(
db: AsyncSession, user: CurrentUserPayload,
page: int = 1, size: int = 20,
order_no: str | None = None, tracking_no: str | None = None,
) -> ShippingListResponse:
where: list[Any] = [ErpShippingRecord.is_deleted.is_(False)]
if user.data_scope == "self":
my_orders = select(ErpOrder.id).where(ErpOrder.salesperson_id == user.user_id, ErpOrder.is_deleted.is_(False))
where.append(ErpShippingRecord.order_id.in_(my_orders))
elif user.data_scope == "dept_and_sub":
if user.dept_id is not None:
from app.models.sys import SysUser
dept_users = select(SysUser.id).where(SysUser.dept_id == user.dept_id, SysUser.is_deleted.is_(False))
dept_orders = select(ErpOrder.id).where(ErpOrder.salesperson_id.in_(dept_users), ErpOrder.is_deleted.is_(False))
where.append(ErpShippingRecord.order_id.in_(dept_orders))
if order_no:
matched = select(ErpOrder.id).where(ErpOrder.order_no.ilike(f"%{order_no}%"))
where.append(ErpShippingRecord.order_id.in_(matched))
if tracking_no:
where.append(ErpShippingRecord.tracking_no.ilike(f"%{tracking_no}%"))
total = (await db.execute(select(func.count()).select_from(ErpShippingRecord).where(*where))).scalar() or 0
stmt = select(ErpShippingRecord).where(*where).order_by(ErpShippingRecord.created_at.desc()).offset((page - 1) * size).limit(size)
records = (await db.execute(stmt)).scalars().all()
return ShippingListResponse(total=total, items=[_ship_to_brief(r) for r in records], page=page, size=size)
async def get_shipping_by_order(
db: AsyncSession, user: CurrentUserPayload, order_id: uuid.UUID,
) -> dict[str, Any]:
order = (await db.execute(
select(ErpOrder).where(ErpOrder.id == order_id, ErpOrder.is_deleted.is_(False))
)).scalar_one_or_none()
if order is None:
raise NotFoundException("订单不存在")
_check_shipping_access(order, user)
stmt = select(ErpShippingRecord).where(
ErpShippingRecord.order_id == order_id, ErpShippingRecord.is_deleted.is_(False),
).order_by(ErpShippingRecord.created_at.desc())
records = (await db.execute(stmt)).scalars().all()
return {
"order_id": str(order_id), "order_no": order.order_no,
"shipping_state": order.shipping_state, "total_shipments": len(records),
"shipments": [_ship_to_resp(r).model_dump(mode="json") for r in records],
}