ForgeFlow-ERP/backend/app/api/routes/sales.py
2026-06-12 16:00:56 +08:00

790 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import re
from datetime import date, datetime
from io import BytesIO
from typing import Any
from urllib.parse import quote
from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
from fastapi.responses import Response
from openpyxl import Workbook, load_workbook
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from app.db.session import get_db
from app.models.master_data import Item, Warehouse
from app.models.operations import Delivery, DeliveryItem, PurchaseOrderItem, PurchaseOrderSalesOrderLink, ReturnOrder, StockLot, WorkOrder
from app.models.planning import MaterialDemand
from app.models.sales import Customer, SalesOrder, SalesOrderItem
from app.schemas.database import (
CustomerCreate,
CustomerRead,
SalesOrderCreate,
SalesOrderCreateResult,
SalesOrderItemRead,
SalesOrderRead,
)
from app.schemas.operations import DeliveryCreate, DeliveryItemRead, DeliveryRead
from app.services.auth import AuthContext, require_authenticated_user
from app.services.contacts import normalize_contacts, parse_contacts, serialize_contacts
from app.services.document_archives import DOCUMENT_TYPE_SALES_ORDER, DOCUMENT_TYPE_WAREHOUSE_OPERATION, generate_document_archive
from app.services.logistics import normalize_logistics_fields
from app.services.operations import (
create_inventory_txn,
get_delivery_items_query,
get_deliveries_query,
sync_sales_order_status,
to_decimal,
upsert_stock_balance,
)
from app.services.purchase_order_links import ensure_purchase_order_sales_link_table
from app.services.stocktake import ensure_warehouses_unlocked
from app.services.system_permissions import ensure_employee_has_permission
from app.services.sales_planning import (
create_customer_code,
decimalize,
get_sales_order_items_query,
get_sales_order_query,
)
router = APIRouter(dependencies=[Depends(require_authenticated_user)])
def _auth_context_user_id(context: AuthContext | object) -> int | None:
user = getattr(context, "user", None)
user_id = getattr(user, "id", None)
return int(user_id) if user_id is not None else None
def _generate_delivery_archive_fields(
db: Session,
*,
business_id: int | None,
created_by: int | None,
) -> dict[str, object]:
if not business_id:
return {
"archive_status": None,
"archive_business_id": None,
"archive_document_type": None,
"archive_error_message": None,
}
try:
archive_result = generate_document_archive(
db,
DOCUMENT_TYPE_WAREHOUSE_OPERATION,
int(business_id),
created_by=created_by,
)
return {
"archive_status": archive_result.archive_status,
"archive_business_id": int(business_id),
"archive_document_type": DOCUMENT_TYPE_WAREHOUSE_OPERATION,
"archive_error_message": archive_result.archive_error_message,
}
except Exception:
return {
"archive_status": "归档失败",
"archive_business_id": int(business_id),
"archive_document_type": DOCUMENT_TYPE_WAREHOUSE_OPERATION,
"archive_error_message": "归档失败PDF归档服务异常请稍后重新生成",
}
def _generate_delivery_archives_for_txns(
db: Session,
inventory_txn_ids: list[int],
*,
created_by: int | None,
) -> dict[str, object]:
first_archive_fields: dict[str, object] | None = None
for inventory_txn_id in inventory_txn_ids:
archive_fields = _generate_delivery_archive_fields(db, business_id=inventory_txn_id, created_by=created_by)
if first_archive_fields is None:
first_archive_fields = archive_fields
return first_archive_fields or _generate_delivery_archive_fields(db, business_id=None, created_by=created_by)
def _delivery_read_with_archive(row: dict[str, Any], archive_fields: dict[str, object]) -> DeliveryRead:
result = DeliveryRead.model_validate(dict(row))
result.archive_status = archive_fields["archive_status"]
result.archive_business_id = archive_fields["archive_business_id"]
result.archive_document_type = archive_fields["archive_document_type"]
result.archive_error_message = archive_fields["archive_error_message"]
return result
CUSTOMER_EXCEL_HEADERS = ["客户名称", "客户简称", "联系人1", "联系电话1", "联系人2", "联系电话2", "联系人3", "联系电话3", "送货地址", "账期(天)"]
CUSTOMER_EXCEL_REQUIRED_HEADERS = ["客户名称"]
def _serial_code_part(value: str | None, fallback: str) -> str:
code = "".join(ch for ch in str(value or "").upper() if ch.isalnum())
return (code[-8:] if code else fallback)[-8:]
def build_sales_order_no(db: Session, customer: Customer) -> str:
_ = customer
year_part = date.today().strftime("%Y")
prefix = f"销售{year_part}"
pattern = re.compile(rf"{re.escape(prefix)}-(\d{{5}})")
max_no = 0
rows = db.scalars(select(SalesOrder.order_no).where(SalesOrder.order_no.like(f"{prefix}-%"))).all()
for order_no in rows:
match = pattern.fullmatch(str(order_no or "").strip())
if match:
max_no = max(max_no, int(match.group(1)))
return f"{prefix}-{max_no + 1:05d}"
def build_delivery_no(db: Session, sales_order: SalesOrder | None = None, customer: Customer | None = None) -> str:
date_part = date.today().strftime("%Y%m%d")
biz_part = _serial_code_part(
sales_order.order_no if sales_order else (customer.short_name or customer.customer_code if customer else None),
f"CUS{customer.id:04d}" if customer else "DIRECT",
)
prefix = f"DLV-NB-{date_part}-{biz_part}"
count = db.scalar(select(func.count(Delivery.id)).where(Delivery.delivery_no.like(f"{prefix}-%"))) or 0
return f"{prefix}-{count + 1:03d}"
def _customer_contact_fields(payload: CustomerCreate) -> tuple[str | None, str | None]:
try:
contacts = normalize_contacts(
list(payload.contacts or []),
fallback_name=payload.contact_name,
fallback_phone=payload.contact_phone,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return serialize_contacts(contacts)
def _customer_read(customer: Customer) -> CustomerRead:
return CustomerRead.model_validate(
{
"id": customer.id,
"customer_code": customer.customer_code,
"customer_name": customer.customer_name,
"short_name": customer.short_name,
"contact_name": customer.contact_name,
"contact_phone": customer.contact_phone,
"contacts": parse_contacts(customer.contact_name, customer.contact_phone),
"address": customer.address,
"credit_days": customer.credit_days,
"status": customer.status,
}
)
def _clean_excel_text(value: Any) -> str:
return str(value or "").strip()
def _excel_int(value: Any, default: int | None = None) -> int | None:
text = _clean_excel_text(value)
if not text:
return default
try:
return int(float(text))
except (TypeError, ValueError) as exc:
raise ValueError(f"数字格式不正确:{text}") from exc
def _excel_workbook_response(workbook: Workbook, filename: str, ascii_filename: str) -> Response:
output = BytesIO()
workbook.save(output)
encoded_filename = quote(filename)
return Response(
content=output.getvalue(),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename=\"{ascii_filename}\"; filename*=UTF-8''{encoded_filename}"},
)
def _build_customer_workbook(rows: list[Customer]) -> Workbook:
workbook = Workbook()
worksheet = workbook.active
worksheet.title = "客户名录"
worksheet.freeze_panes = "A2"
worksheet.append(CUSTOMER_EXCEL_HEADERS)
for row in rows:
contacts = parse_contacts(row.contact_name, row.contact_phone)
worksheet.append(
[
row.customer_name,
row.short_name or "",
contacts[0]["contact_name"] if len(contacts) > 0 else "",
contacts[0]["contact_phone"] if len(contacts) > 0 else "",
contacts[1]["contact_name"] if len(contacts) > 1 else "",
contacts[1]["contact_phone"] if len(contacts) > 1 else "",
contacts[2]["contact_name"] if len(contacts) > 2 else "",
contacts[2]["contact_phone"] if len(contacts) > 2 else "",
row.address or "",
int(row.credit_days or 0),
]
)
for index, width in enumerate([30, 18, 18, 20, 18, 20, 18, 20, 46, 12], start=1):
worksheet.column_dimensions[worksheet.cell(row=1, column=index).column_letter].width = width
return workbook
def _read_customer_excel_rows(content: bytes) -> list[dict[str, Any]]:
try:
workbook = load_workbook(BytesIO(content), read_only=True, data_only=True)
except Exception as exc:
raise ValueError("Excel 文件读取失败,请确认上传的是客户名录 xlsx 文件") from exc
worksheet = workbook.active
rows = list(worksheet.iter_rows(values_only=True))
if not rows:
return []
headers = [_clean_excel_text(value) for value in rows[0]]
missing = [header for header in CUSTOMER_EXCEL_REQUIRED_HEADERS if header not in headers]
if missing:
raise ValueError(f"Excel 缺少客户名录必要列:{''.join(missing)}")
return [{headers[index]: value for index, value in enumerate(row) if index < len(headers)} for row in rows[1:]]
def _import_customer_excel(db: Session, content: bytes) -> dict[str, object]:
rows = _read_customer_excel_rows(content)
if not rows:
raise ValueError("Excel 中没有可导入的客户数据")
latest_by_name: dict[str, dict[str, Any]] = {}
skipped = 0
errors: list[str] = []
for index, row in enumerate(rows, start=2):
if not any(_clean_excel_text(value) for value in row.values()):
continue
customer_name = _clean_excel_text(row.get("客户名称"))
if not customer_name:
skipped += 1
errors.append(f"{index}行缺少客户名称")
continue
try:
credit_days = _excel_int(row.get("账期(天)"), None)
except ValueError as exc:
skipped += 1
errors.append(f"{index}{exc}")
continue
latest_by_name[customer_name] = {
"customer_name": customer_name,
"short_name": _clean_excel_text(row.get("客户简称")),
"contacts": normalize_contacts(
[
{"contact_name": row.get("联系人1"), "contact_phone": row.get("联系电话1")},
{"contact_name": row.get("联系人2"), "contact_phone": row.get("联系电话2")},
{"contact_name": row.get("联系人3"), "contact_phone": row.get("联系电话3")},
],
fallback_name=_clean_excel_text(row.get("联系人")),
fallback_phone=_clean_excel_text(row.get("联系电话")),
),
"address": _clean_excel_text(row.get("送货地址") if "送货地址" in row else row.get("地址")),
"credit_days": credit_days,
}
if not latest_by_name:
raise ValueError("Excel 中没有可导入的有效客户数据")
existing_rows = db.scalars(
select(Customer)
.where(Customer.customer_name.in_(latest_by_name.keys()))
.order_by(Customer.id.asc())
).all()
existing_by_name: dict[str, Customer] = {}
for customer in existing_rows:
existing_by_name.setdefault(_clean_excel_text(customer.customer_name), customer)
imported = 0
updated = 0
for customer_name, payload in latest_by_name.items():
contact_name, contact_phone = serialize_contacts(payload["contacts"])
customer = existing_by_name.get(customer_name)
if customer:
customer.short_name = payload["short_name"] or customer.short_name or customer_name
customer.contact_name = contact_name
customer.contact_phone = contact_phone
customer.address = payload["address"] or None
if payload["credit_days"] is not None:
customer.credit_days = payload["credit_days"]
customer.status = "ACTIVE"
db.add(customer)
updated += 1
continue
customer = Customer(
customer_code=create_customer_code(db),
customer_name=customer_name,
short_name=payload["short_name"] or customer_name,
contact_name=contact_name,
contact_phone=contact_phone,
address=payload["address"] or None,
credit_days=payload["credit_days"] if payload["credit_days"] is not None else 30,
status="ACTIVE",
)
db.add(customer)
db.flush()
imported += 1
db.commit()
return {
"imported": imported,
"updated": updated,
"skipped": skipped,
"errors": errors,
"message": f"导入完成:新增 {imported} 条,更新 {updated} 条,跳过 {skipped}",
}
@router.get("/customers", response_model=list[CustomerRead])
def list_customers(
limit: int = Query(default=50, ge=1, le=200),
db: Session = Depends(get_db),
) -> list[CustomerRead]:
rows = db.scalars(select(Customer).order_by(Customer.id.desc()).limit(limit)).all()
return [_customer_read(row) for row in rows]
@router.get("/customers/export")
def export_customers(db: Session = Depends(get_db)) -> Response:
rows = db.scalars(select(Customer).order_by(Customer.customer_name.asc(), Customer.id.asc())).all()
workbook = _build_customer_workbook(rows)
return _excel_workbook_response(workbook, f"客户名录_{datetime.now().strftime('%Y%m%d%H%M%S')}.xlsx", "customers.xlsx")
@router.post("/customers/import")
async def import_customers(
file: UploadFile = File(...),
db: Session = Depends(get_db),
) -> dict[str, object]:
content = await file.read()
try:
result = _import_customer_excel(db, content)
except ValueError as exc:
db.rollback()
raise HTTPException(status_code=400, detail=str(exc)) from exc
return result
@router.post("/customers", response_model=CustomerRead)
def create_customer(payload: CustomerCreate, db: Session = Depends(get_db)) -> CustomerRead:
contact_name, contact_phone = _customer_contact_fields(payload)
customer = Customer(
customer_code=create_customer_code(db),
customer_name=payload.customer_name,
short_name=payload.short_name,
contact_name=contact_name,
contact_phone=contact_phone,
status="ACTIVE",
)
customer.address = payload.address
customer.tax_no = payload.tax_no
customer.credit_days = payload.credit_days
db.add(customer)
db.commit()
db.refresh(customer)
return _customer_read(customer)
@router.put("/customers/{customer_id}", response_model=CustomerRead)
def update_customer(customer_id: int, payload: CustomerCreate, db: Session = Depends(get_db)) -> CustomerRead:
customer = db.get(Customer, customer_id)
if not customer:
raise HTTPException(status_code=404, detail="客户不存在")
contact_name, contact_phone = _customer_contact_fields(payload)
customer.customer_name = payload.customer_name
customer.short_name = payload.short_name
customer.contact_name = contact_name
customer.contact_phone = contact_phone
customer.address = payload.address
customer.tax_no = payload.tax_no
customer.credit_days = payload.credit_days
customer.status = "ACTIVE"
db.add(customer)
db.commit()
db.refresh(customer)
return _customer_read(customer)
@router.get("/orders", response_model=list[SalesOrderRead])
def list_sales_orders(
limit: int = Query(default=200, ge=1, le=500),
db: Session = Depends(get_db),
) -> list[SalesOrderRead]:
stmt = get_sales_order_query(limit=limit)
rows = db.execute(stmt).mappings().all()
return [SalesOrderRead.model_validate(dict(row)) for row in rows]
@router.post("/orders", response_model=SalesOrderCreateResult)
def create_sales_order(
payload: SalesOrderCreate,
db: Session = Depends(get_db),
context: AuthContext = Depends(require_authenticated_user),
) -> SalesOrderCreateResult:
customer = db.get(Customer, payload.customer_id)
if not customer:
raise HTTPException(status_code=404, detail="客户不存在")
if not payload.items:
raise HTTPException(status_code=400, detail="订单至少需要一条明细")
ensure_employee_has_permission(
db,
payload.sales_employee_id,
"MENU_SALES_PLANNING",
"销售人员",
required=True,
)
product_ids = [item.product_item_id for item in payload.items]
products = db.scalars(select(Item).where(Item.id.in_(product_ids))).all()
product_map = {product.id: product for product in products}
missing_products = [product_id for product_id in product_ids if product_id not in product_map]
if missing_products:
raise HTTPException(status_code=400, detail=f"产品不存在: {missing_products}")
order = SalesOrder(
order_no=build_sales_order_no(db, customer),
customer_id=payload.customer_id,
order_date=date.today(),
promised_date=payload.promised_date,
sales_employee_id=payload.sales_employee_id,
delivery_address=payload.delivery_address or customer.address,
tax_rate=decimalize(payload.tax_rate, "0.0001"),
total_amount=decimalize(0, "0.01"),
status="OPEN",
)
order.remark = payload.remark
db.add(order)
db.flush()
total_amount = decimalize(0, "0.01")
for idx, item_payload in enumerate(payload.items, start=1):
line_amount = decimalize(item_payload.order_qty * item_payload.unit_price, "0.01")
total_amount += line_amount
order_item = SalesOrderItem(
sales_order_id=order.id,
line_no=idx,
product_item_id=item_payload.product_item_id,
order_qty=decimalize(item_payload.order_qty),
delivered_qty=decimalize(0),
unit_price=decimalize(item_payload.unit_price, "0.0001"),
line_amount=line_amount,
promised_date=item_payload.promised_date or payload.promised_date,
status="OPEN",
)
order_item.customer_part_no = item_payload.customer_part_no
db.add(order_item)
order.total_amount = total_amount
db.commit()
try:
archive_kwargs = {}
created_by = _auth_context_user_id(context)
if created_by is not None:
archive_kwargs["created_by"] = created_by
archive_result = generate_document_archive(
db,
DOCUMENT_TYPE_SALES_ORDER,
int(order.id),
**archive_kwargs,
)
archive_status = archive_result.archive_status
archive_version = archive_result.archive_version
archive_error_message = archive_result.archive_error_message
except Exception as exc:
archive_status = "归档失败"
archive_version = None
archive_error_message = f"归档失败:{exc}"
return SalesOrderCreateResult(
order_id=order.id,
order_no=order.order_no,
total_amount=float(total_amount),
item_count=len(payload.items),
status=order.status,
archive_status=archive_status,
archive_version=archive_version,
archive_error_message=archive_error_message,
)
@router.delete("/orders/{order_id}")
def delete_sales_order(order_id: int, db: Session = Depends(get_db)) -> dict[str, str]:
ensure_purchase_order_sales_link_table(db)
order = db.get(SalesOrder, order_id)
if not order:
raise HTTPException(status_code=404, detail="销售订单不存在")
order_no = order.order_no
item_ids = list(db.scalars(select(SalesOrderItem.id).where(SalesOrderItem.sales_order_id == order_id)).all())
purchase_count = 0
work_order_count = 0
if item_ids:
purchase_count = db.scalar(
select(func.count(func.distinct(PurchaseOrderItem.purchase_order_id)))
.join(MaterialDemand, MaterialDemand.id == PurchaseOrderItem.source_demand_id)
.where(MaterialDemand.sales_order_item_id.in_(item_ids))
) or 0
work_order_count = db.scalar(select(func.count(WorkOrder.id)).where(WorkOrder.source_sales_order_item_id.in_(item_ids))) or 0
explicit_purchase_count = db.scalar(
select(func.count(PurchaseOrderSalesOrderLink.id))
.where(PurchaseOrderSalesOrderLink.sales_order_id == order_id)
) or 0
if purchase_count or explicit_purchase_count:
raise HTTPException(status_code=400, detail="销售订单已关联采购订单,不能删除")
delivery_count = db.scalar(select(func.count(Delivery.id)).where(Delivery.sales_order_id == order_id)) or 0
delivery_item_count = 0
if item_ids:
delivery_item_count = db.scalar(select(func.count(DeliveryItem.id)).where(DeliveryItem.sales_order_item_id.in_(item_ids))) or 0
if delivery_count or delivery_item_count:
raise HTTPException(status_code=400, detail="销售订单已有发货记录,不能删除")
return_count = db.scalar(select(func.count(ReturnOrder.id)).where(ReturnOrder.sales_order_id == order_id)) or 0
if return_count:
raise HTTPException(status_code=400, detail="销售订单已有退货记录,不能删除")
if work_order_count:
raise HTTPException(status_code=400, detail="销售订单已关联生产工单,不能删除")
if item_ids:
db.execute(delete(MaterialDemand).where(MaterialDemand.sales_order_item_id.in_(item_ids)))
db.execute(delete(SalesOrderItem).where(SalesOrderItem.sales_order_id == order_id))
db.execute(delete(SalesOrder).where(SalesOrder.id == order_id))
db.commit()
return {"message": f"销售订单 {order_no} 已删除"}
@router.get("/order-items", response_model=list[SalesOrderItemRead])
def list_sales_order_items(
sales_order_id: int | None = Query(default=None),
limit: int = Query(default=500, ge=1, le=1000),
db: Session = Depends(get_db),
) -> list[SalesOrderItemRead]:
stmt = get_sales_order_items_query(limit=limit, sales_order_id=sales_order_id)
rows = db.execute(stmt).mappings().all()
return [SalesOrderItemRead.model_validate(dict(row)) for row in rows]
@router.get("/deliveries", response_model=list[DeliveryRead])
def list_deliveries(
limit: int = Query(default=100, ge=1, le=500),
db: Session = Depends(get_db),
) -> list[DeliveryRead]:
rows = db.execute(get_deliveries_query(limit=limit)).mappings().all()
return [DeliveryRead.model_validate(dict(row)) for row in rows]
@router.get("/delivery-items", response_model=list[DeliveryItemRead])
def list_delivery_items(
delivery_id: int | None = Query(default=None),
limit: int = Query(default=200, ge=1, le=500),
db: Session = Depends(get_db),
) -> list[DeliveryItemRead]:
rows = db.execute(get_delivery_items_query(limit=limit, delivery_id=delivery_id)).mappings().all()
return [DeliveryItemRead.model_validate(dict(row)) for row in rows]
@router.post("/deliveries", response_model=DeliveryRead)
def create_delivery(
payload: DeliveryCreate,
context: AuthContext = Depends(require_authenticated_user),
db: Session = Depends(get_db),
) -> DeliveryRead:
warehouse = db.get(Warehouse, payload.warehouse_id)
if not warehouse:
raise HTTPException(status_code=404, detail="仓库不存在")
if str(warehouse.warehouse_type or "").upper() != "FINISHED":
raise HTTPException(status_code=400, detail="销售出库必须选择成品库")
ensure_warehouses_unlocked(db, [payload.warehouse_id], "销售出库")
logistics_waybill_no, logistics_freight_amount, logistics_photo_url = normalize_logistics_fields(
payload.waybill_no,
payload.freight_amount,
required=True,
order_photo_url=payload.order_photo_url,
)
if not payload.items:
raise HTTPException(status_code=400, detail="发货单至少需要一条明细")
sales_order_id = payload.sales_order_id
linked_order_ids: set[int] = set()
for item_payload in payload.items:
if not item_payload.sales_order_item_id:
continue
linked_order_id = db.scalar(
select(SalesOrderItem.sales_order_id).where(SalesOrderItem.id == item_payload.sales_order_item_id)
)
if not linked_order_id:
raise HTTPException(status_code=400, detail="订单明细不存在")
linked_order_ids.add(int(linked_order_id))
if linked_order_ids:
if len(linked_order_ids) > 1:
raise HTTPException(status_code=400, detail="一张发货单只能关联同一个销售订单下的明细")
linked_order_id = next(iter(linked_order_ids))
if sales_order_id and int(sales_order_id) != linked_order_id:
raise HTTPException(status_code=400, detail="发货单关联的订单头与订单明细不一致")
sales_order_id = linked_order_id
sales_order = db.get(SalesOrder, sales_order_id) if sales_order_id else None
if sales_order_id and not sales_order:
raise HTTPException(status_code=404, detail="订单不存在")
customer_id = payload.customer_id or (sales_order.customer_id if sales_order else None)
customer = db.get(Customer, customer_id) if customer_id else None
if not customer:
raise HTTPException(status_code=404, detail="发货必须选择收货客户")
ensure_employee_has_permission(
db,
payload.shipper_employee_id,
"MENU_INVENTORY_LEDGER",
"发货人",
required=False,
)
delivery = Delivery(
delivery_no=build_delivery_no(db, sales_order=sales_order, customer=customer),
sales_order_id=sales_order_id,
customer_id=customer.id,
warehouse_id=payload.warehouse_id,
delivery_date=payload.delivery_date or datetime.now(),
shipper_employee_id=payload.shipper_employee_id,
consignee_name=payload.consignee_name or (customer.contact_name if customer else None),
consignee_phone=payload.consignee_phone or (customer.contact_phone if customer else None),
delivery_address=payload.delivery_address or (customer.address if customer else None) or (sales_order.delivery_address if sales_order else None),
logistics_waybill_no=logistics_waybill_no,
logistics_freight_amount=logistics_freight_amount,
logistics_photo_url=logistics_photo_url,
status="POSTED",
remark=payload.remark,
)
db.add(delivery)
db.flush()
inventory_txn_ids: list[int] = []
for index, item_payload in enumerate(payload.items, start=1):
order_item = db.get(SalesOrderItem, item_payload.sales_order_item_id) if item_payload.sales_order_item_id else None
if sales_order:
if not order_item or order_item.sales_order_id != sales_order_id:
raise HTTPException(status_code=400, detail="订单明细不存在或不属于当前订单")
if order_item.product_item_id != item_payload.product_item_id:
raise HTTPException(status_code=400, detail="发货产品与订单明细不一致")
product = db.get(Item, item_payload.product_item_id)
if not product:
raise HTTPException(status_code=404, detail=f"产品不存在: {item_payload.product_item_id}")
delivery_qty = to_decimal(item_payload.delivery_qty)
if delivery_qty <= 0:
raise HTTPException(status_code=400, detail=f"产品 {product.item_name} 的发货数量必须大于0")
delivery_weight = to_decimal(item_payload.delivery_weight_kg)
if order_item:
order_remaining_qty = to_decimal(order_item.order_qty) - to_decimal(order_item.delivered_qty)
if delivery_qty > order_remaining_qty:
raise HTTPException(
status_code=400,
detail=f"产品 {product.item_name} 的发货数量不能超过订单未发数量 {order_remaining_qty}",
)
lot = None
if item_payload.lot_id:
lot = db.get(StockLot, item_payload.lot_id)
if not lot:
raise HTTPException(status_code=404, detail="发货批次不存在")
if lot.item_id != item_payload.product_item_id or lot.warehouse_id != payload.warehouse_id:
raise HTTPException(status_code=400, detail="所选批次不属于当前产品或当前发货仓")
else:
lot = db.scalar(
select(StockLot).where(
StockLot.item_id == item_payload.product_item_id,
StockLot.warehouse_id == payload.warehouse_id,
StockLot.quality_status == "PASS",
StockLot.status == "AVAILABLE",
StockLot.remaining_qty > 0,
)
)
if not lot:
raise HTTPException(status_code=400, detail=f"产品 {product.item_name} 无可用批次库存")
if lot.quality_status != "PASS" or lot.status != "AVAILABLE":
raise HTTPException(status_code=400, detail=f"库存批次号 {lot.lot_no} 当前不可发货")
available_qty = to_decimal(lot.remaining_qty) - to_decimal(lot.locked_qty)
available_weight = to_decimal(lot.remaining_weight_kg) - to_decimal(lot.locked_weight_kg)
if delivery_qty > available_qty:
raise HTTPException(status_code=400, detail=f"库存批次号 {lot.lot_no} 可用数量不足")
if delivery_weight <= 0:
if available_qty > 0 and available_weight > 0:
delivery_weight = available_weight * (delivery_qty / available_qty)
else:
delivery_weight = delivery_qty * to_decimal(product.unit_weight_kg)
lot.remaining_qty = to_decimal(lot.remaining_qty) - delivery_qty
lot.remaining_weight_kg = to_decimal(lot.remaining_weight_kg) - delivery_weight
if to_decimal(lot.remaining_qty) <= 0 or to_decimal(lot.remaining_weight_kg) <= 0:
lot.status = "DEPLETED"
db.add(lot)
upsert_stock_balance(
db,
item_id=item_payload.product_item_id,
warehouse_id=payload.warehouse_id,
location_id=lot.location_id,
qty_delta=-delivery_qty,
weight_delta=-delivery_weight,
available_qty_delta=-delivery_qty,
available_weight_delta=-delivery_weight,
unit_cost=to_decimal(lot.unit_cost, "0.0001"),
)
txn = create_inventory_txn(
db,
txn_type="SALES_OUT",
item_id=item_payload.product_item_id,
warehouse_id=payload.warehouse_id,
location_id=lot.location_id,
lot_id=lot.id,
qty_change=-delivery_qty,
weight_change=-delivery_weight,
unit_cost=to_decimal(lot.unit_cost, "0.0001"),
source_doc_type="DELIVERY",
source_doc_id=delivery.id,
source_line_id=order_item.id if order_item else None,
biz_time=delivery.delivery_date,
operator_user_id=context.user.id,
remark="发货出库",
amount_basis="QTY",
logistics_waybill_no=logistics_waybill_no,
logistics_freight_amount=logistics_freight_amount,
logistics_photo_url=logistics_photo_url,
)
inventory_txn_ids.append(int(txn.id))
line_amount = delivery_qty * to_decimal(item_payload.unit_price, "0.0001")
db.add(
DeliveryItem(
delivery_id=delivery.id,
line_no=index,
sales_order_item_id=item_payload.sales_order_item_id if order_item else None,
product_item_id=item_payload.product_item_id,
lot_id=lot.id,
delivery_qty=delivery_qty,
delivery_weight_kg=delivery_weight,
unit_price=to_decimal(item_payload.unit_price, "0.0001"),
line_amount=to_decimal(line_amount, "0.01"),
status="POSTED",
remark=item_payload.remark,
)
)
if order_item:
order_item.delivered_qty = to_decimal(order_item.delivered_qty) + delivery_qty
if to_decimal(order_item.delivered_qty) >= to_decimal(order_item.order_qty):
order_item.status = "CLOSED"
else:
order_item.status = "PARTIAL"
db.add(order_item)
if sales_order_id:
sync_sales_order_status(db, sales_order_id)
db.commit()
archive_fields = _generate_delivery_archives_for_txns(db, inventory_txn_ids, created_by=context.user.id)
row = db.execute(get_deliveries_query(limit=1).where(Delivery.id == delivery.id)).mappings().first()
if not row:
raise HTTPException(status_code=500, detail="发货单创建后读取失败")
return _delivery_read_with_archive(dict(row), archive_fields)