x
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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": "单据已作废"}
|
||||
|
||||
|
||||
|
||||
@@ -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": "库存记录已删除"}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
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,6 +267,7 @@ async def update_purchase_order(
|
||||
for item in existing_items:
|
||||
await db_session.delete(item)
|
||||
|
||||
try:
|
||||
order.supplier_id = order_data.supplier_id
|
||||
order.expected_date = order_data.expected_date
|
||||
order.remark = order_data.remark
|
||||
@@ -273,6 +275,9 @@ async def update_purchase_order(
|
||||
order.status = "pending"
|
||||
|
||||
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,8 +429,6 @@ 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()
|
||||
elif any_received:
|
||||
order.status = "partial_received"
|
||||
|
||||
@@ -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()
|
||||
|
||||
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,6 +470,7 @@ 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="已收款的销售订单禁止修改")
|
||||
try:
|
||||
await _rollback_issued_materials(db_session, order, current_user)
|
||||
await db_session.execute(delete(SalesOrderItem).where(SalesOrderItem.order_id == order.id))
|
||||
|
||||
@@ -473,6 +486,9 @@ async def update_sales_order(
|
||||
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:
|
||||
after_qty = upd_result.scalar_one_or_none()
|
||||
if after_qty is None:
|
||||
raise HTTPException(status_code=400, detail=f"物料 {material.name} 库存不足")
|
||||
else:
|
||||
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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
if movement_data.movement_type in INBOUND_TYPES:
|
||||
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:
|
||||
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="库存不足")
|
||||
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:
|
||||
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
|
||||
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
|
||||
elif movement_data.movement_type in OUTBOUND_TYPES:
|
||||
if inventory.quantity < movement_data.quantity:
|
||||
raise HTTPException(status_code=400, detail="库存不足")
|
||||
inventory.quantity -= movement_data.quantity
|
||||
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)
|
||||
|
||||
@@ -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}"
|
||||
|
||||
+40
-113
@@ -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("型腔/型芯分离完成(完全嵌入+突出贴合)")
|
||||
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
|
||||
|
||||
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
|
||||
)
|
||||
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)
|
||||
|
||||
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 or mold_block, core
|
||||
return cavity, core
|
||||
|
||||
cavity = self._subtract_product_from_plate(mold_block, shape, "型腔(兜底)")
|
||||
return cavity or mold_block, shape
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"分模回退方案失败: {e}")
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user