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,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