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
+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],
}