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:
@@ -0,0 +1,2 @@
|
||||
# Service Layer — 业务逻辑集中层
|
||||
# REST API 路由和 MCP 工具共用此层函数
|
||||
@@ -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
|
||||
]
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
]
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 画像提取 Workflow(fire-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,
|
||||
}
|
||||
@@ -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],
|
||||
}
|
||||
Reference in New Issue
Block a user