This commit is contained in:
2026-05-11 09:34:50 +08:00
parent 79ca3613ae
commit 5e1d475a22
14 changed files with 336 additions and 264 deletions
+1 -1
View File
@@ -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,
+22 -7
View File
@@ -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": "单据已作废"}
+15 -9
View File
@@ -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": "库存记录已删除"}
+1
View File
@@ -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,
)
)
+51 -43
View File
@@ -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"
+66 -51
View File
@@ -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(
+3
View File
@@ -55,7 +55,10 @@ from .material_schemas import (
MaterialPriceTrendResponse
)
from .common_schemas import PaginatedResponse
__all__ = [
"PaginatedResponse",
"ProductCreate", "ProductResponse", "ProductMaterialItemUpdate", "ProductBOMUpdate",
"ProductMaterialItemResponse", "ProductBOMResponse",
"SupplierCreate", "SupplierResponse",
@@ -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
@@ -40,6 +40,7 @@ class ProductResponse(BaseModel):
class ProductMaterialItemUpdate(BaseModel):
material_id: int
quantity: Decimal
loss_rate: Decimal = Decimal("0")
class ProductBOMUpdate(BaseModel):
+65 -33
View File
@@ -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)
+5 -5
View File
@@ -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}"