diff --git a/src/api/inventory/dashboard_routes.py b/src/api/inventory/dashboard_routes.py index b5cd509..2e564c8 100644 --- a/src/api/inventory/dashboard_routes.py +++ b/src/api/inventory/dashboard_routes.py @@ -69,7 +69,7 @@ async def get_dashboard( ] return { - "product_count": finished_product_count, + "finished_product_count": finished_product_count, "material_count": material_count, "supplier_count": supplier_count, "customer_count": customer_count, diff --git a/src/api/inventory/finance_routes.py b/src/api/inventory/finance_routes.py index 93cf5df..81e5140 100644 --- a/src/api/inventory/finance_routes.py +++ b/src/api/inventory/finance_routes.py @@ -31,8 +31,12 @@ from .schemas import ( PartnerStatementItemResponse, FinancePartnerProductStatementResponse, PartnerProductStatementItemResponse, + PaginatedResponse, ) from .utils import generate_order_no +from utils.logger import get_logger + +logger = get_logger(__name__) router = APIRouter(prefix="/finance", tags=["财务管理"]) @@ -227,7 +231,7 @@ async def create_payment( return _build_transaction_response(created) -@router.get("/transactions", response_model=List[FinanceTransactionResponse]) +@router.get("/transactions", response_model=PaginatedResponse[FinanceTransactionResponse]) async def list_transactions( txn_type: Optional[str] = None, status: Optional[str] = "confirmed", @@ -238,22 +242,29 @@ async def list_transactions( db_session: AsyncSession = Depends(get_db_session), current_user: User = Depends(get_current_active_user), ): - query = ( + base_query = ( select(FinanceTransaction) .options(selectinload(FinanceTransaction.allocations)) .order_by(FinanceTransaction.created_at.desc()) ) if txn_type: - query = query.where(FinanceTransaction.txn_type == txn_type) + base_query = base_query.where(FinanceTransaction.txn_type == txn_type) if status: - query = query.where(FinanceTransaction.status == status) + base_query = base_query.where(FinanceTransaction.status == status) if year is not None or quarter is not None: _, _, _, period_start, period_end = _resolve_period_scope(year, quarter) - query = query.where(FinanceTransaction.txn_date >= period_start).where(FinanceTransaction.txn_date < period_end) - query = query.offset(skip).limit(limit) + base_query = base_query.where(FinanceTransaction.txn_date >= period_start).where(FinanceTransaction.txn_date < period_end) + + count_query = select(func.count()).select_from(base_query.subquery()) + total = await db_session.scalar(count_query) or 0 + + query = base_query.offset(skip).limit(limit) result = await db_session.execute(query) rows = result.scalars().all() - return [_build_transaction_response(item) for item in rows] + return PaginatedResponse( + items=[_build_transaction_response(item) for item in rows], + total=total, skip=skip, limit=limit + ) @router.post("/transactions/{transaction_id}/void") @@ -287,6 +298,10 @@ async def void_transaction( txn.status = "voided" await db_session.commit() + logger.warning( + "财务单据已作废: txn_no=%s txn_type=%s amount=%s operator_id=%s", + txn.txn_no, txn.txn_type, txn.amount, current_user.id + ) return {"message": "单据已作废"} diff --git a/src/api/inventory/inventory_routes.py b/src/api/inventory/inventory_routes.py index 01d2259..50e3eaa 100644 --- a/src/api/inventory/inventory_routes.py +++ b/src/api/inventory/inventory_routes.py @@ -9,18 +9,18 @@ """ from fastapi import APIRouter, Depends, Query, HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select +from sqlalchemy import select, update, func from typing import Optional, List from database.database import get_db_session from services.auth_service import get_current_active_user from models.database import User, Product, Warehouse, Inventory -from .schemas import InventoryResponse, InventoryCreate, InventoryUpdate +from .schemas import InventoryResponse, InventoryCreate, InventoryUpdate, PaginatedResponse router = APIRouter(prefix="/inventory", tags=["库存管理"]) -@router.get("", response_model=List[InventoryResponse]) +@router.get("", response_model=PaginatedResponse[InventoryResponse]) async def list_inventory( warehouse_id: Optional[int] = None, product_id: Optional[int] = None, @@ -30,7 +30,7 @@ async def list_inventory( db_session: AsyncSession = Depends(get_db_session), current_user: User = Depends(get_current_active_user) ): - query = ( + base_query = ( select(Inventory, Product, Warehouse) .join(Product, Inventory.product_id == Product.id) .join(Warehouse, Inventory.warehouse_id == Warehouse.id) @@ -40,13 +40,16 @@ async def list_inventory( ) if warehouse_id: - query = query.where(Inventory.warehouse_id == warehouse_id) + base_query = base_query.where(Inventory.warehouse_id == warehouse_id) if product_id: - query = query.where(Inventory.product_id == product_id) + base_query = base_query.where(Inventory.product_id == product_id) if low_stock: - query = query.where(Inventory.quantity <= Product.min_stock) + base_query = base_query.where(Inventory.quantity <= Product.min_stock) - query = query.offset(skip).limit(limit) + count_query = select(func.count()).select_from(base_query.subquery()) + total = await db_session.scalar(count_query) or 0 + + query = base_query.offset(skip).limit(limit) result = await db_session.execute(query) inventory_list = [] @@ -63,7 +66,7 @@ async def list_inventory( available_quantity=inv.available_quantity )) - return inventory_list + return PaginatedResponse(items=inventory_list, total=total, skip=skip, limit=limit) @router.post("", response_model=InventoryResponse, status_code=201) @@ -140,6 +143,7 @@ async def update_inventory( .join(Warehouse, Inventory.warehouse_id == Warehouse.id) .where(Inventory.id == inventory_id) .where(Product.item_type == "material") + .with_for_update(of=Inventory) ) row = result.first() if not row: @@ -187,6 +191,8 @@ async def delete_inventory( inventory = result.scalar_one_or_none() if not inventory: raise HTTPException(status_code=404, detail="库存记录不存在") + if inventory.quantity > 0: + raise HTTPException(status_code=400, detail="库存数量不为零,无法删除库存记录") await db_session.delete(inventory) await db_session.commit() return {"message": "库存记录已删除"} diff --git a/src/api/inventory/product_routes.py b/src/api/inventory/product_routes.py index 32c2e45..2bf9ea0 100644 --- a/src/api/inventory/product_routes.py +++ b/src/api/inventory/product_routes.py @@ -252,6 +252,7 @@ async def replace_product_bom( finished_product_id=product_id, material_product_id=item.material_id, quantity=item.quantity, + loss_rate=item.loss_rate, ) ) diff --git a/src/api/inventory/purchase_order_routes.py b/src/api/inventory/purchase_order_routes.py index 0e5169c..d5c1a31 100644 --- a/src/api/inventory/purchase_order_routes.py +++ b/src/api/inventory/purchase_order_routes.py @@ -10,7 +10,7 @@ """ from fastapi import APIRouter, Depends, Query, HTTPException from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func +from sqlalchemy import select, func, update from typing import Optional, List from decimal import Decimal @@ -32,6 +32,7 @@ from .schemas import ( PurchaseOrderDetailResponse, PurchaseOrderItemResponse, PurchaseOrderReceiveRequest, + PaginatedResponse, ) from .utils import generate_order_no @@ -39,10 +40,6 @@ router = APIRouter(prefix="/purchase-orders", tags=["采购订单"]) def _build_purchase_order_response(order: PurchaseOrder, supplier_name: str) -> PurchaseOrderResponse: - # 检查字段是否存在,避免数据库中没有这些字段时的错误 - received_date = getattr(order, 'received_date', None) - paid_date = getattr(order, 'paid_date', None) - return PurchaseOrderResponse( id=order.id, order_no=order.order_no, @@ -54,8 +51,8 @@ def _build_purchase_order_response(order: PurchaseOrder, supplier_name: str) -> paid_amount=order.paid_amount, remark=order.remark, created_at=order.created_at, - received_date=received_date, - paid_date=paid_date + received_date=order.received_date, + paid_date=order.paid_date ) @@ -86,9 +83,6 @@ async def _build_purchase_order_detail( .order_by(PurchaseOrderItem.id.asc()) ) item_rows = item_result.all() - # 检查字段是否存在,避免数据库中没有这些字段时的错误 - received_date = getattr(order, 'received_date', None) - paid_date = getattr(order, 'paid_date', None) return PurchaseOrderDetailResponse( id=order.id, @@ -102,8 +96,8 @@ async def _build_purchase_order_detail( paid_amount=order.paid_amount, remark=order.remark, created_at=order.created_at, - received_date=received_date, - paid_date=paid_date, + received_date=order.received_date, + paid_date=order.paid_date, items=[ PurchaseOrderItemResponse( id=item.id, @@ -146,8 +140,8 @@ async def _apply_order_items( db_session: AsyncSession, order: PurchaseOrder, order_data: PurchaseOrderCreate -) -> float: - total_amount = 0.0 +) -> Decimal: + total_amount = Decimal("0") for item_data in order_data.items: product_result = await db_session.execute( select(Product).where(Product.id == item_data.product_id, Product.is_active == True) @@ -179,7 +173,7 @@ async def _apply_order_items( return total_amount -@router.get("", response_model=List[PurchaseOrderResponse]) +@router.get("", response_model=PaginatedResponse[PurchaseOrderResponse]) async def list_purchase_orders( status: Optional[str] = None, skip: int = Query(0, ge=0), @@ -187,23 +181,26 @@ async def list_purchase_orders( db_session: AsyncSession = Depends(get_db_session), current_user: User = Depends(get_current_active_user) ): - query = ( + base_query = ( select(PurchaseOrder, Supplier) .join(Supplier, PurchaseOrder.supplier_id == Supplier.id) .order_by(PurchaseOrder.created_at.desc()) ) if status: - query = query.where(PurchaseOrder.status == status) + base_query = base_query.where(PurchaseOrder.status == status) - query = query.offset(skip).limit(limit) + count_query = select(func.count()).select_from(base_query.subquery()) + total = await db_session.scalar(count_query) or 0 + + query = base_query.offset(skip).limit(limit) result = await db_session.execute(query) orders = [] for order, supplier in result.all(): orders.append(_build_purchase_order_response(order, supplier.name)) - return orders + return PaginatedResponse(items=orders, total=total, skip=skip, limit=limit) @router.post("", response_model=PurchaseOrderResponse, status_code=201) @@ -223,8 +220,12 @@ async def create_purchase_order( db_session.add(order) await db_session.flush() - order.total_amount = await _apply_order_items(db_session, order, order_data) - await db_session.commit() + try: + order.total_amount = await _apply_order_items(db_session, order, order_data) + await db_session.commit() + except (HTTPException, Exception): + await db_session.rollback() + raise await db_session.refresh(order) supplier_result = await db_session.execute(select(Supplier).where(Supplier.id == order.supplier_id)) @@ -266,13 +267,17 @@ async def update_purchase_order( for item in existing_items: await db_session.delete(item) - order.supplier_id = order_data.supplier_id - order.expected_date = order_data.expected_date - order.remark = order_data.remark - order.total_amount = await _apply_order_items(db_session, order, order_data) - order.status = "pending" + try: + order.supplier_id = order_data.supplier_id + order.expected_date = order_data.expected_date + order.remark = order_data.remark + order.total_amount = await _apply_order_items(db_session, order, order_data) + order.status = "pending" - await db_session.commit() + await db_session.commit() + except (HTTPException, Exception): + await db_session.rollback() + raise await db_session.refresh(order) supplier_result = await db_session.execute(select(Supplier).where(Supplier.id == order.supplier_id)) supplier = supplier_result.scalar_one_or_none() @@ -311,7 +316,7 @@ async def update_purchase_order_status( if not new_status: raise HTTPException(status_code=400, detail="状态不能为空") - valid_statuses = ["pending", "received", "paid"] + valid_statuses = ["pending", "partial_received", "received", "paid"] if new_status not in valid_statuses: raise HTTPException(status_code=400, detail=f"无效的状态值,有效值为: {valid_statuses}") @@ -322,12 +327,14 @@ async def update_purchase_order_status( if order.status == "received" and new_status != "paid": raise HTTPException(status_code=400, detail="已收货的采购订单只能标记为已付款") + if order.status == "partial_received" and new_status not in ("received", "paid"): + raise HTTPException(status_code=400, detail="部分收货的采购订单只能标记为已收货或已付款") + # 更新状态和对应时间 order.status = new_status - # 检查字段是否存在,避免数据库中没有这些字段时的错误 - if hasattr(order, 'received_date') and new_status == "received": + if new_status == "received": order.received_date = func.now() - elif hasattr(order, 'paid_date') and new_status == "paid": + elif new_status == "paid": order.paid_date = func.now() await db_session.commit() @@ -377,25 +384,28 @@ async def receive_purchase_order( if not product: raise HTTPException(status_code=400, detail=f"物料不存在: {item.product_id}") - inv_result = await db_session.execute( - select(Inventory) + upd_result = await db_session.execute( + update(Inventory) .where(Inventory.product_id == item.product_id) .where(Inventory.warehouse_id == warehouse.id) + .values(quantity=Inventory.quantity + receive_item.receive_quantity) + .returning(Inventory.quantity) ) - inventory = inv_result.scalar_one_or_none() - if not inventory: + after_qty = upd_result.scalar_one_or_none() + if after_qty is None: inventory = Inventory( product_id=item.product_id, warehouse_id=warehouse.id, - quantity=0, + quantity=receive_item.receive_quantity, locked_quantity=0 ) db_session.add(inventory) await db_session.flush() - - before_qty = inventory.quantity - inventory.quantity += receive_item.receive_quantity - after_qty = inventory.quantity + before_qty = 0 + after_qty = receive_item.receive_quantity + else: + after_qty = int(after_qty) + before_qty = after_qty - receive_item.receive_quantity item.received_quantity = (item.received_quantity or 0) + receive_item.receive_quantity movement = StockMovement( @@ -419,9 +429,7 @@ async def receive_purchase_order( any_received = any((item.received_quantity or 0) > 0 for item in item_map.values()) if all_received: order.status = "received" - # 检查字段是否存在,避免数据库中没有这些字段时的错误 - if hasattr(order, 'received_date'): - order.received_date = func.now() + order.received_date = func.now() elif any_received: order.status = "partial_received" diff --git a/src/api/inventory/sales_order_routes.py b/src/api/inventory/sales_order_routes.py index 8b2d5b5..a650a13 100644 --- a/src/api/inventory/sales_order_routes.py +++ b/src/api/inventory/sales_order_routes.py @@ -38,12 +38,14 @@ from .schemas import ( ProductionMaterialPlanItemResponse, SalesOrderIssueRequest, SalesOrderIssueResponse, - SalesOrderStatusUpdate + SalesOrderStatusUpdate, + PaginatedResponse, ) from .utils import generate_order_no router = APIRouter(prefix="/sales-orders", tags=["销售订单"]) -VALID_ORDER_STATUSES = {"manufacturing", "delivered", "paid"} +VALID_ORDER_STATUSES = {"draft", "manufacturing", "delivered", "paid"} +PRODUCTION_STATUSES = {"not_started", "bom_missing", "material_issued", "completed"} def _build_sales_order_response(order: SalesOrder, customer_name: str) -> SalesOrderResponse: @@ -225,7 +227,7 @@ async def _issue_materials_for_order_creation( raise HTTPException(status_code=400, detail=f"物料库存不足:{shortage_text}") production_no = order.production_no or generate_order_no("WO") - actual_material_cost = 0.0 + actual_material_cost = Decimal("0") movement_count = 0 for item in plan_items: @@ -289,25 +291,28 @@ async def _rollback_issued_materials( return for movement in movements: - inv_result = await db_session.execute( - select(Inventory) + upd_result = await db_session.execute( + update(Inventory) .where(Inventory.product_id == movement.product_id) .where(Inventory.warehouse_id == movement.warehouse_id) + .values(quantity=Inventory.quantity + movement.quantity) + .returning(Inventory.quantity) ) - inventory = inv_result.scalar_one_or_none() - if not inventory: + after_qty = upd_result.scalar_one_or_none() + if after_qty is None: inventory = Inventory( product_id=movement.product_id, warehouse_id=movement.warehouse_id, - quantity=0, + quantity=movement.quantity, locked_quantity=0 ) db_session.add(inventory) await db_session.flush() - - before_qty = inventory.quantity - inventory.quantity += movement.quantity - after_qty = inventory.quantity + before_qty = 0 + after_qty = movement.quantity + else: + after_qty = int(after_qty) + before_qty = after_qty - movement.quantity revert_movement = StockMovement( product_id=movement.product_id, @@ -331,8 +336,8 @@ async def _apply_order_items( db_session: AsyncSession, order: SalesOrder, order_data: SalesOrderCreate -) -> float: - total_amount = 0.0 +) -> Decimal: + total_amount = Decimal("0") for item_data in order_data.items: product = None if item_data.product_id is not None: @@ -378,7 +383,7 @@ async def _apply_order_items( return total_amount -@router.get("", response_model=List[SalesOrderResponse]) +@router.get("", response_model=PaginatedResponse[SalesOrderResponse]) async def list_sales_orders( status: Optional[str] = None, skip: int = Query(0, ge=0), @@ -386,23 +391,26 @@ async def list_sales_orders( db_session: AsyncSession = Depends(get_db_session), current_user: User = Depends(get_current_active_user) ): - query = ( + base_query = ( select(SalesOrder, Customer) .join(Customer, SalesOrder.customer_id == Customer.id) .order_by(SalesOrder.created_at.desc()) ) if status: - query = query.where(SalesOrder.status == status) + base_query = base_query.where(SalesOrder.status == status) - query = query.offset(skip).limit(limit) + count_query = select(func.count()).select_from(base_query.subquery()) + total = await db_session.scalar(count_query) or 0 + + query = base_query.offset(skip).limit(limit) result = await db_session.execute(query) orders = [] for order, customer in result.all(): orders.append(_build_sales_order_response(order, customer.name)) - return orders + return PaginatedResponse(items=orders, total=total, skip=skip, limit=limit) @router.post("", response_model=SalesOrderResponse, status_code=201) @@ -418,7 +426,7 @@ async def create_sales_order( customer_id=order_data.customer_id, order_date=now, delivery_date=order_data.delivery_date, - manufacturing_date=now.date(), + manufacturing_date=now, created_at=now, remark=order_data.remark, operator_id=current_user.id, @@ -427,9 +435,13 @@ async def create_sales_order( db_session.add(order) await db_session.flush() - order.total_amount = await _apply_order_items(db_session, order, order_data) - await _issue_materials_for_order_creation(db_session, order, current_user) - await db_session.commit() + try: + order.total_amount = await _apply_order_items(db_session, order, order_data) + await _issue_materials_for_order_creation(db_session, order, current_user) + await db_session.commit() + except (HTTPException, Exception): + await db_session.rollback() + raise await db_session.refresh(order) customer = await db_session.execute(select(Customer).where(Customer.id == order.customer_id)) @@ -458,21 +470,25 @@ async def update_sales_order( order, customer = await _get_sales_order_with_customer(db_session, order_id) if order.status == "paid": raise HTTPException(status_code=400, detail="已收款的销售订单禁止修改") - await _rollback_issued_materials(db_session, order, current_user) - await db_session.execute(delete(SalesOrderItem).where(SalesOrderItem.order_id == order.id)) + try: + await _rollback_issued_materials(db_session, order, current_user) + await db_session.execute(delete(SalesOrderItem).where(SalesOrderItem.order_id == order.id)) - order.customer_id = order_data.customer_id - order.delivery_date = order_data.delivery_date - order.remark = order_data.remark - order.production_status = "not_started" - order.production_no = None - order.planned_material_cost = 0 - order.actual_material_cost = 0 - order.status = "manufacturing" + order.customer_id = order_data.customer_id + order.delivery_date = order_data.delivery_date + order.remark = order_data.remark + order.production_status = "not_started" + order.production_no = None + order.planned_material_cost = 0 + order.actual_material_cost = 0 + order.status = "manufacturing" - order.total_amount = await _apply_order_items(db_session, order, order_data) - await _issue_materials_for_order_creation(db_session, order, current_user) - await db_session.commit() + order.total_amount = await _apply_order_items(db_session, order, order_data) + await _issue_materials_for_order_creation(db_session, order, current_user) + await db_session.commit() + except (HTTPException, Exception): + await db_session.rollback() + raise await db_session.refresh(order) customer_result = await db_session.execute(select(Customer).where(Customer.id == order.customer_id)) @@ -562,27 +578,26 @@ async def consume_materials( cost = Decimal(str(material.cost_price or 0)) * item.quantity total_cost += cost - # 更新物料库存 - inventory_result = await db_session.execute( - select(Inventory) + # 更新物料库存(原子操作防并发) + upd_result = await db_session.execute( + update(Inventory) .where(Inventory.product_id == material.id) .where(Inventory.warehouse_id == default_warehouse.id) + .where(Inventory.quantity >= item.quantity) + .values(quantity=Inventory.quantity - item.quantity) + .returning(Inventory.quantity) ) - inventory = inventory_result.scalar() - if inventory: - before_qty = inventory.quantity - inventory.quantity -= item.quantity - after_qty = inventory.quantity - if after_qty < 0: - raise HTTPException(status_code=400, detail=f"物料 {material.name} 库存不足") - else: - raise HTTPException(status_code=400, detail=f"物料 {material.name} 没有库存记录") - + after_qty = upd_result.scalar_one_or_none() + if after_qty is None: + raise HTTPException(status_code=400, detail=f"物料 {material.name} 库存不足") + after_qty = int(after_qty) + before_qty = after_qty + int(item.quantity) + # 记录物料消耗 movement = StockMovement( product_id=material.id, warehouse_id=default_warehouse.id, - quantity=-item.quantity, + quantity=item.quantity, before_quantity=before_qty, after_quantity=after_qty, movement_type="consumption", @@ -661,7 +676,7 @@ async def issue_sales_order_materials( production_no = payload.production_no or order.production_no or generate_order_no("WO") - actual_material_cost = 0.0 + actual_material_cost = Decimal("0") movement_count = 0 for item in plan_items: upd_result = await db_session.execute( diff --git a/src/api/inventory/schemas/__init__.py b/src/api/inventory/schemas/__init__.py index 64a896a..d54ae74 100644 --- a/src/api/inventory/schemas/__init__.py +++ b/src/api/inventory/schemas/__init__.py @@ -55,7 +55,10 @@ from .material_schemas import ( MaterialPriceTrendResponse ) +from .common_schemas import PaginatedResponse + __all__ = [ + "PaginatedResponse", "ProductCreate", "ProductResponse", "ProductMaterialItemUpdate", "ProductBOMUpdate", "ProductMaterialItemResponse", "ProductBOMResponse", "SupplierCreate", "SupplierResponse", diff --git a/src/api/inventory/schemas/common_schemas.py b/src/api/inventory/schemas/common_schemas.py new file mode 100644 index 0000000..39ef9ea --- /dev/null +++ b/src/api/inventory/schemas/common_schemas.py @@ -0,0 +1,11 @@ +from typing import TypeVar, Generic, List +from pydantic import BaseModel + +T = TypeVar("T") + + +class PaginatedResponse(BaseModel, Generic[T]): + items: List[T] + total: int + skip: int + limit: int diff --git a/src/api/inventory/schemas/product_schemas.py b/src/api/inventory/schemas/product_schemas.py index 56e1c72..be1bdf0 100644 --- a/src/api/inventory/schemas/product_schemas.py +++ b/src/api/inventory/schemas/product_schemas.py @@ -40,6 +40,7 @@ class ProductResponse(BaseModel): class ProductMaterialItemUpdate(BaseModel): material_id: int quantity: Decimal + loss_rate: Decimal = Decimal("0") class ProductBOMUpdate(BaseModel): diff --git a/src/api/inventory/stock_movement_routes.py b/src/api/inventory/stock_movement_routes.py index 28f36b9..d874173 100644 --- a/src/api/inventory/stock_movement_routes.py +++ b/src/api/inventory/stock_movement_routes.py @@ -11,13 +11,13 @@ """ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select +from sqlalchemy import select, update, func from typing import Optional, List from database.database import get_db_session from services.auth_service import get_current_active_user from models.database import User, Product, Warehouse, Inventory, StockMovement -from .schemas import StockMovementCreate, StockMovementResponse +from .schemas import StockMovementCreate, StockMovementResponse, PaginatedResponse from .utils import generate_order_no router = APIRouter(prefix="/stock-movements", tags=["库存变动"]) @@ -73,36 +73,65 @@ async def create_stock_movement( resolved_product_id = product.id - result = await db_session.execute( - select(Inventory) - .where(Inventory.product_id == resolved_product_id) - .where(Inventory.warehouse_id == movement_data.warehouse_id) - ) - inventory = result.scalar_one_or_none() - - if not inventory: - if movement_data.movement_type in OUTBOUND_TYPES: - raise HTTPException(status_code=400, detail="库存不足") - inventory = Inventory( - product_id=resolved_product_id, - warehouse_id=movement_data.warehouse_id, - quantity=0 - ) - db_session.add(inventory) - await db_session.flush() - - before_qty = inventory.quantity - if movement_data.movement_type in INBOUND_TYPES: - inventory.quantity += movement_data.quantity + upd_result = await db_session.execute( + update(Inventory) + .where(Inventory.product_id == resolved_product_id) + .where(Inventory.warehouse_id == movement_data.warehouse_id) + .values(quantity=Inventory.quantity + movement_data.quantity) + .returning(Inventory.quantity) + ) + after_qty = upd_result.scalar_one_or_none() + if after_qty is None: + inventory = Inventory( + product_id=resolved_product_id, + warehouse_id=movement_data.warehouse_id, + quantity=movement_data.quantity, + ) + db_session.add(inventory) + await db_session.flush() + before_qty = 0 + after_qty = movement_data.quantity + else: + after_qty = int(after_qty) + before_qty = after_qty - movement_data.quantity + elif movement_data.movement_type in OUTBOUND_TYPES: - if inventory.quantity < movement_data.quantity: + upd_result = await db_session.execute( + update(Inventory) + .where(Inventory.product_id == resolved_product_id) + .where(Inventory.warehouse_id == movement_data.warehouse_id) + .where(Inventory.quantity >= movement_data.quantity) + .values(quantity=Inventory.quantity - movement_data.quantity) + .returning(Inventory.quantity) + ) + after_qty = upd_result.scalar_one_or_none() + if after_qty is None: raise HTTPException(status_code=400, detail="库存不足") - inventory.quantity -= movement_data.quantity + after_qty = int(after_qty) + before_qty = after_qty + movement_data.quantity + else: + result = await db_session.execute( + select(Inventory) + .where(Inventory.product_id == resolved_product_id) + .where(Inventory.warehouse_id == movement_data.warehouse_id) + .with_for_update() + ) + inventory = result.scalar_one_or_none() + if not inventory: + inventory = Inventory( + product_id=resolved_product_id, + warehouse_id=movement_data.warehouse_id, + quantity=0, + ) + db_session.add(inventory) + await db_session.flush() + before_qty = 0 + else: + before_qty = int(inventory.quantity) inventory.quantity = movement_data.quantity - - after_qty = inventory.quantity + after_qty = movement_data.quantity movement = StockMovement( product_id=resolved_product_id, @@ -135,7 +164,7 @@ async def create_stock_movement( ) -@router.get("", response_model=List[StockMovementResponse]) +@router.get("", response_model=PaginatedResponse[StockMovementResponse]) async def list_stock_movements( product_id: Optional[int] = None, movement_type: Optional[str] = None, @@ -144,18 +173,21 @@ async def list_stock_movements( db_session: AsyncSession = Depends(get_db_session), current_user: User = Depends(get_current_active_user) ): - query = ( + base_query = ( select(StockMovement, Product) .join(Product, StockMovement.product_id == Product.id) .order_by(StockMovement.created_at.desc()) ) if product_id: - query = query.where(StockMovement.product_id == product_id) + base_query = base_query.where(StockMovement.product_id == product_id) if movement_type: - query = query.where(StockMovement.movement_type == movement_type) + base_query = base_query.where(StockMovement.movement_type == movement_type) - query = query.offset(skip).limit(limit) + count_query = select(func.count()).select_from(base_query.subquery()) + total = await db_session.scalar(count_query) or 0 + + query = base_query.offset(skip).limit(limit) result = await db_session.execute(query) movements = [] @@ -174,4 +206,4 @@ async def list_stock_movements( created_at=movement.created_at )) - return movements + return PaginatedResponse(items=movements, total=total, skip=skip, limit=limit) diff --git a/src/api/inventory/utils.py b/src/api/inventory/utils.py index f1e654b..a451f7a 100644 --- a/src/api/inventory/utils.py +++ b/src/api/inventory/utils.py @@ -4,8 +4,8 @@ 提供进销存系统通用的工具函数,包括: - 订单编号生成器(采购订单、销售订单、库存变动等) """ -from datetime import datetime -import uuid +from datetime import datetime, timezone +import secrets def generate_order_no(prefix: str) -> str: @@ -15,8 +15,8 @@ def generate_order_no(prefix: str) -> str: prefix: 订单类型前缀,如 PO(采购订单)、SO(销售订单)、SM(库存变动) Returns: - 格式为 {prefix}{YYYYMMDDHHMMSS}{4位随机字符} 的订单编号 + 格式为 {prefix}{YYYYMMDDHHMMSS}{8位随机字符} 的订单编号 """ - date_str = datetime.now().strftime("%Y%m%d%H%M%S") - random_str = uuid.uuid4().hex[:4].upper() + date_str = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") + random_str = secrets.token_hex(4).upper() return f"{prefix}{date_str}{random_str}" diff --git a/src/core/base_mold_generator.py b/src/core/base_mold_generator.py index 83928e3..8cdea28 100644 --- a/src/core/base_mold_generator.py +++ b/src/core/base_mold_generator.py @@ -2,7 +2,7 @@ from typing import Dict, List, Any, Tuple, Optional import math import numpy as np from OCC.Core.BRepOffsetAPI import BRepOffsetAPI_DraftAngle -from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Section, BRepAlgoAPI_Common, BRepAlgoAPI_Fuse +from OCC.Core.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Section, BRepAlgoAPI_Common from OCC.Core.BRepBuilderAPI import BRepBuilderAPI_MakeFace, BRepBuilderAPI_Transform from OCC.Core.BRepPrimAPI import BRepPrimAPI_MakeBox, BRepPrimAPI_MakeHalfSpace from OCC.Core.gp import gp_Pln, gp_Dir, gp_Pnt, gp_Trsf, gp_Ax2 @@ -226,14 +226,14 @@ class BaseMoldGenerator: def _split_cavity_core(self, shape: Any, parting_surface: Any, margin: int = 20) -> Tuple[Any, Any]: """ - 分离型腔和型芯 — 完全嵌入 + 突出贴合方式 + 用分型面将模具块切分为A板(上模/型腔)和B板(下模/型芯), + 然后从每个板中减去产品形状的对应部分。 流程: 1. 创建完整模具块(产品包围盒 + 全方向余量) - 2. 型腔(凹模)= 模具块 - 产品 → 产品完全嵌入型腔块中 - 3. 型芯(凸模)= 产品形状突出体 → 从芯块面突出贴合 - - 不再使用分型面中间切开产品的方式。 + 2. 用分型面将模具块切分为 A板 和 B板 + 3. A板 - 产品 = 型腔(凹模) + 4. B板 - 产品 = 型芯(凸模) """ try: bbox = Bnd_Box() @@ -247,110 +247,40 @@ class BaseMoldGenerator: mold_ymax = ymax + margin mold_zmax = zmax + margin - cavity_block = BRepPrimAPI_MakeBox( + mold_block = BRepPrimAPI_MakeBox( gp_Pnt(mold_xmin, mold_ymin, mold_zmin), gp_Pnt(mold_xmax, mold_ymax, mold_zmax) ).Shape() - cavity = self._subtract_product_from_plate(cavity_block, shape, "型腔") - if cavity is None: - logger.warning("型腔布尔减运算失败,使用原始模具块") - cavity = cavity_block + parting_plane = self._get_parting_plane(parting_surface, shape) + if parting_plane is None: + logger.warning("无法提取分型面平面,使用回退方案") + center_z = (zmin + zmax) / 2 + parting_plane = gp_Pln(gp_Pnt(0, 0, center_z), gp_Dir(0, 0, 1)) - parting_normal = self._extract_parting_normal(parting_surface) + a_plate, b_plate = self._split_mold_block_by_plane(mold_block, parting_plane) - core = self._build_protruding_core( - shape, cavity_block, - mold_xmin, mold_ymin, mold_zmin, - mold_xmax, mold_ymax, mold_zmax, - xmin, ymin, zmin, xmax, ymax, zmax, - parting_normal - ) + if a_plate is not None and b_plate is not None: + cavity = self._subtract_product_from_plate(a_plate, shape, "型腔(A板)") + core = self._subtract_product_from_plate(b_plate, shape, "型芯(B板)") - logger.info("型腔/型芯分离完成(完全嵌入+突出贴合)") - return cavity, core + if cavity is not None and core is not None: + logger.info("型腔/型芯分离完成(分型面切分+布尔减)") + return cavity, core + elif cavity is not None: + logger.warning("型芯生成失败,使用产品形状") + return cavity, shape + elif core is not None: + logger.warning("型腔生成失败,使用模具块") + return mold_block, core + + logger.warning("A/B板切分不完全,回退到原方案") + return self._split_cavity_core_fallback(shape, mold_block) except Exception as e: logger.error(f"型腔分离失败: {e}") return self._split_cavity_core_fallback(shape, None) - def _extract_parting_normal(self, parting_surface: Any) -> List[float]: - """从分型面提取法向量方向""" - try: - surface = BRepAdaptor_Surface(parting_surface) - if surface.GetType() == 0: - plane = surface.Plane() - normal = plane.Axis().Direction() - return [float(normal.X()), float(normal.Y()), float(normal.Z())] - except Exception: - pass - return [0.0, 0.0, 1.0] - - def _build_protruding_core( - self, - shape: Any, - cavity_block: Any, - mold_xmin: float, mold_ymin: float, mold_zmin: float, - mold_xmax: float, mold_ymax: float, mold_zmax: float, - xmin: float, ymin: float, zmin: float, - xmax: float, ymax: float, zmax: float, - parting_normal: List[float] = None, - ) -> Any: - """ - 构建突出贴合式型芯。 - - 型芯 = 底座平板 + 产品形状突出体。 - 底座平板沿分型方向位于产品下方,产品形状融合在底座之上, - 与型腔的凹入形状完美贴合。 - """ - nx, ny, nz = (parting_normal or [0.0, 0.0, 1.0]) - mold_min = [mold_xmin, mold_ymin, mold_zmin] - mold_max = [mold_xmax, mold_ymax, mold_zmax] - prod_min = [xmin, ymin, zmin] - prod_max = [xmax, ymax, zmax] - - base_p1 = list(mold_min) - base_p2 = list(mold_max) - - for i in range(3): - prod_span = prod_max[i] - prod_min[i] - overlap = max(prod_span * 0.03, 0.5) - if parting_normal[i] > 0: - base_p2[i] = prod_min[i] + overlap - elif parting_normal[i] < 0: - base_p1[i] = prod_max[i] - overlap - - base_plate = None - try: - base_plate = BRepPrimAPI_MakeBox( - gp_Pnt(base_p1[0], base_p1[1], base_p1[2]), - gp_Pnt(base_p2[0], base_p2[1], base_p2[2]) - ).Shape() - logger.info("型芯底座平板构建完成") - except Exception as e: - logger.warning(f"底座平板构建失败: {e}") - return shape - - try: - fuse_op = BRepAlgoAPI_Fuse(base_plate, shape) - if fuse_op.IsDone(): - core = fuse_op.Shape() - logger.info("型芯(底座+产品突出体)融合成功") - return core - except Exception as e: - logger.warning(f"底座与产品融合失败: {e}") - - try: - cut_op = BRepAlgoAPI_Cut(cavity_block, shape) - if cut_op.IsDone(): - logger.info("型芯通过布尔减回退构建") - return cut_op.Shape() - except Exception: - pass - - logger.info("型芯最终回退为产品形状") - return shape - def _get_parting_plane(self, parting_surface: Any, shape: Any) -> Optional[gp_Pln]: """从分型面提取平面方程""" try: @@ -443,12 +373,9 @@ class BaseMoldGenerator: def _split_cavity_core_fallback(self, shape: Any, mold_block: Optional[Any] = None) -> Tuple[Any, Any]: """ - 分模回退方案:完全嵌入+突出贴合,不切分模具块。 - - 1. 创建完整模具块 → 型腔 = 模具块 - 产品(产品完全嵌入) - 2. 型芯 = 产品形状突出体(突出贴合) + 分模回退方案:用边界框中心面作为分型面切分模具块。 """ - logger.warning("使用分模回退方案(完全嵌入+突出贴合)") + logger.warning("使用分模回退方案") try: bbox = Bnd_Box() @@ -462,19 +389,19 @@ class BaseMoldGenerator: gp_Pnt(xmax + margin, ymax + margin, zmax + margin) ).Shape() - cavity = self._subtract_product_from_plate(mold_block, shape, "型腔(回退)") - if cavity is None: - cavity = mold_block + center_z = (zmin + zmax) / 2 + parting_plane = gp_Pln(gp_Pnt(0, 0, center_z), gp_Dir(0, 0, 1)) + a_plate, b_plate = self._split_mold_block_by_plane(mold_block, parting_plane) - core = self._build_protruding_core( - shape, mold_block, - xmin - margin, ymin - margin, zmin - margin, - xmax + margin, ymax + margin, zmax + margin, - xmin, ymin, zmin, xmax, ymax, zmax - ) + if a_plate is not None and b_plate is not None: + cavity = self._subtract_product_from_plate(a_plate, shape, "型腔(回退)") + core = self._subtract_product_from_plate(b_plate, shape, "型芯(回退)") + if cavity is not None and core is not None: + logger.info("回退方案型腔/型芯分离完成") + return cavity, core - logger.info("回退方案型腔/型芯分离完成") - return cavity or mold_block, core + cavity = self._subtract_product_from_plate(mold_block, shape, "型腔(兜底)") + return cavity or mold_block, shape except Exception as e: logger.error(f"分模回退方案失败: {e}") diff --git a/src/database/init_db.py b/src/database/init_db.py index 3c89860..ceff8b4 100644 --- a/src/database/init_db.py +++ b/src/database/init_db.py @@ -197,6 +197,59 @@ async def ensure_schema_updates(): CREATE INDEX IF NOT EXISTS idx_mold_cavity_is_fallback ON mold_cavity_data (is_fallback) """)) + await conn.execute(text(""" + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'uq_inventory_product_warehouse' + ) THEN + ALTER TABLE inventory + ADD CONSTRAINT uq_inventory_product_warehouse UNIQUE (product_id, warehouse_id); + END IF; + END $$; + """)) + await conn.execute(text(""" + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'ck_inventory_qty_nonnegative' + ) THEN + ALTER TABLE inventory + ADD CONSTRAINT ck_inventory_qty_nonnegative + CHECK (quantity >= 0 AND locked_quantity >= 0 AND locked_quantity <= quantity); + END IF; + END $$; + """)) + await conn.execute(text(""" + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'ck_purchase_order_items_qty' + ) THEN + ALTER TABLE purchase_order_items + ADD CONSTRAINT ck_purchase_order_items_qty + CHECK (quantity > 0 AND received_quantity >= 0 AND received_quantity <= quantity); + END IF; + END $$; + """)) + await conn.execute(text(""" + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'ck_sales_order_items_qty' + ) THEN + ALTER TABLE sales_order_items + ADD CONSTRAINT ck_sales_order_items_qty + CHECK (quantity > 0 AND delivered_quantity >= 0 AND delivered_quantity <= quantity); + END IF; + END $$; + """)) + await conn.execute(text("ALTER TABLE sales_orders ADD COLUMN IF NOT EXISTS manufacturing_date TIMESTAMP WITHOUT TIME ZONE")) + await conn.execute(text("ALTER TABLE sales_orders ALTER COLUMN manufacturing_date TYPE TIMESTAMP WITHOUT TIME ZONE")) if __name__ == "__main__": diff --git a/src/models/database.py b/src/models/database.py index 35728b8..dc3721c 100644 --- a/src/models/database.py +++ b/src/models/database.py @@ -762,7 +762,7 @@ class SalesOrder(Base): customer_id = Column(Integer, ForeignKey("customers.id"), nullable=False, index=True) order_date = Column(DateTime, default=func.now()) delivery_date = Column(Date, nullable=True) - manufacturing_date = Column(Date, nullable=True) + manufacturing_date = Column(DateTime, nullable=True) actual_delivery_date = Column(DateTime, nullable=True) actual_payment_date = Column(DateTime, nullable=True) status = Column(String(20), default="draft")