790 lines
33 KiB
Python
790 lines
33 KiB
Python
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)
|