diff --git a/.env b/.env
index f35cb5c..56e6a0d 100644
--- a/.env
+++ b/.env
@@ -32,7 +32,7 @@ DB_USER=moldinsight
DB_PASSWORD=Qqs1996*
# RustFS 对象存储配置 (S3v4 API)
-RUSTFS_ENDPOINT=http://szcjw:9000
+RUSTFS_ENDPOINT=http://szcjw:8010
RUSTFS_ACCESS_KEY=1RlKXw7v3DAsFr4fLckt
RUSTFS_SECRET_KEY=KjWCHXZOh7GAtkLq0eQgNpMSmE6zw8Ddyiou21bB
RUSTFS_TIMEOUT=30
diff --git a/migrate_purchase_status_dates.py b/migrate_purchase_status_dates.py
new file mode 100644
index 0000000..6ae6c6c
--- /dev/null
+++ b/migrate_purchase_status_dates.py
@@ -0,0 +1,75 @@
+#!/usr/bin/env python3
+"""
+数据库迁移脚本:为采购订单表添加状态变更时间字段
+- received_date: 实际到货时间
+- paid_date: 实际付款时间
+"""
+import asyncio
+import sys
+from pathlib import Path
+
+project_root = Path(__file__).parent
+sys.path.insert(0, str(project_root))
+sys.path.insert(0, str(project_root / "src"))
+
+from sqlalchemy.ext.asyncio import create_async_engine
+from sqlalchemy import text
+from config.settings import settings
+
+
+async def migrate():
+ """执行数据库迁移"""
+ if not settings.DATABASE_URL:
+ print("错误:未配置数据库连接")
+ return
+
+ engine = create_async_engine(
+ settings.DATABASE_URL,
+ echo=True
+ )
+
+ async with engine.begin() as conn:
+ # 检查字段是否已存在
+ check_sql = """
+ SELECT column_name
+ FROM information_schema.columns
+ WHERE table_name = 'purchase_orders'
+ AND column_name = 'received_date'
+ """
+ result = await conn.execute(text(check_sql))
+ if result.fetchone():
+ print("字段 received_date 已存在,跳过")
+ else:
+ # 添加实际到货时间字段
+ alter_sql = """
+ ALTER TABLE purchase_orders
+ ADD COLUMN received_date TIMESTAMP WITHOUT TIME ZONE
+ """
+ await conn.execute(text(alter_sql))
+ print("成功添加字段 received_date")
+
+ # 检查 paid_date 字段是否已存在
+ check_sql2 = """
+ SELECT column_name
+ FROM information_schema.columns
+ WHERE table_name = 'purchase_orders'
+ AND column_name = 'paid_date'
+ """
+ result2 = await conn.execute(text(check_sql2))
+ if result2.fetchone():
+ print("字段 paid_date 已存在,跳过")
+ else:
+ # 添加实际付款时间字段
+ alter_sql2 = """
+ ALTER TABLE purchase_orders
+ ADD COLUMN paid_date TIMESTAMP WITHOUT TIME ZONE
+ """
+ await conn.execute(text(alter_sql2))
+ print("成功添加字段 paid_date")
+
+ await engine.dispose()
+ print("迁移完成")
+
+
+if __name__ == "__main__":
+ asyncio.run(migrate())
diff --git a/src/api/inventory/product_routes.py b/src/api/inventory/product_routes.py
index 39cabae..1069201 100644
--- a/src/api/inventory/product_routes.py
+++ b/src/api/inventory/product_routes.py
@@ -36,7 +36,7 @@ async def _calculate_material_cost_map(db_session: AsyncSession, product_ids: Li
ProductMaterial.finished_product_id,
func.coalesce(
func.sum(
- Product.cost_price * ProductMaterial.quantity * (1 + ProductMaterial.loss_rate)
+ Product.cost_price * ProductMaterial.quantity
),
0
)
@@ -186,7 +186,7 @@ async def get_product_bom(
items: List[ProductMaterialItemResponse] = []
total_material_cost = 0.0
for bom, material in bom_result.all():
- line_cost = float(material.cost_price or 0) * float(bom.quantity) * (1 + float(bom.loss_rate or 0))
+ line_cost = float(material.cost_price or 0) * float(bom.quantity)
total_material_cost += line_cost
items.append(
ProductMaterialItemResponse(
@@ -194,7 +194,6 @@ async def get_product_bom(
material_sku=material.sku,
material_name=material.name,
quantity=round(float(bom.quantity), 4),
- loss_rate=round(float(bom.loss_rate or 0), 4),
unit_cost=round(float(material.cost_price or 0), 4),
line_cost=round(float(line_cost), 4),
)
@@ -246,15 +245,12 @@ async def replace_product_bom(
for item in payload.items:
if item.quantity <= 0:
- raise HTTPException(status_code=400, detail="物料数量必须大于0")
- if item.loss_rate < 0:
- raise HTTPException(status_code=400, detail="损耗率不能为负数")
+ raise HTTPException(status_code=400, detail="物料数量必须大于 0")
db_session.add(
ProductMaterial(
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 41a36b3..cfac12c 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
+from sqlalchemy import select, func
from typing import Optional, List
from database.database import get_db_session
@@ -38,6 +38,10 @@ 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,
@@ -48,7 +52,9 @@ def _build_purchase_order_response(order: PurchaseOrder, supplier_name: str) ->
total_amount=order.total_amount,
paid_amount=order.paid_amount,
remark=order.remark,
- created_at=order.created_at
+ created_at=order.created_at,
+ received_date=received_date,
+ paid_date=paid_date
)
@@ -79,6 +85,10 @@ 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,
order_no=order.order_no,
@@ -91,6 +101,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,
items=[
PurchaseOrderItemResponse(
id=item.id,
@@ -205,7 +217,7 @@ async def create_purchase_order(
expected_date=order_data.expected_date,
remark=order_data.remark,
operator_id=current_user.id,
- status="draft"
+ status="pending"
)
db_session.add(order)
await db_session.flush()
@@ -214,8 +226,10 @@ async def create_purchase_order(
await db_session.commit()
await db_session.refresh(order)
- supplier = await db_session.execute(select(Supplier).where(Supplier.id == order.supplier_id))
- supplier = supplier.scalar_one()
+ supplier_result = await db_session.execute(select(Supplier).where(Supplier.id == order.supplier_id))
+ supplier = supplier_result.scalar_one_or_none()
+ if not supplier:
+ raise HTTPException(status_code=404, detail="供应商不存在")
return _build_purchase_order_response(order, supplier.name)
@@ -255,7 +269,7 @@ async def update_purchase_order(
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 = "draft"
+ order.status = "pending"
await db_session.commit()
await db_session.refresh(order)
@@ -283,6 +297,46 @@ async def delete_purchase_order(
return {"message": "采购订单已删除"}
+@router.patch("/{order_id}/status")
+async def update_purchase_order_status(
+ order_id: int,
+ status: dict,
+ db_session: AsyncSession = Depends(get_db_session),
+ current_user: User = Depends(get_current_active_user)
+):
+ order, _ = await _get_order_with_supplier(db_session, order_id)
+
+ new_status = status.get("status")
+ if not new_status:
+ raise HTTPException(status_code=400, detail="状态不能为空")
+
+ valid_statuses = ["pending", "received", "paid"]
+ if new_status not in valid_statuses:
+ raise HTTPException(status_code=400, detail=f"无效的状态值,有效值为: {valid_statuses}")
+
+ # 状态转换逻辑
+ if order.status == "paid":
+ raise HTTPException(status_code=400, detail="已付款的采购订单禁止修改状态")
+
+ if order.status == "received" and new_status != "paid":
+ raise HTTPException(status_code=400, detail="已收货的采购订单只能标记为已付款")
+
+ # 更新状态和对应时间
+ order.status = new_status
+ # 检查字段是否存在,避免数据库中没有这些字段时的错误
+ if hasattr(order, 'received_date') and new_status == "received":
+ order.received_date = func.now()
+ elif hasattr(order, 'paid_date') and new_status == "paid":
+ order.paid_date = func.now()
+
+ await db_session.commit()
+ 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()
+ return _build_purchase_order_response(order, supplier.name if supplier else "未知供应商")
+
+
@router.post("/{order_id}/receive", response_model=PurchaseOrderDetailResponse)
async def receive_purchase_order(
order_id: int,
@@ -364,6 +418,9 @@ 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"
diff --git a/src/api/inventory/sales_order_routes.py b/src/api/inventory/sales_order_routes.py
index ea096c0..8ff5d45 100644
--- a/src/api/inventory/sales_order_routes.py
+++ b/src/api/inventory/sales_order_routes.py
@@ -13,6 +13,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, delete, update
from typing import Optional, List
from math import ceil
+from pydantic import BaseModel, Field
from database.database import get_db_session
from services.auth_service import get_current_active_user
@@ -513,7 +514,7 @@ async def delete_sales_order(
db_session: AsyncSession = Depends(get_db_session),
current_user: User = Depends(get_current_active_user)
):
- order, _ = await _get_sales_order_with_customer(db_session, order_id)
+ order, customer = await _get_sales_order_with_customer(db_session, order_id)
if order.status == "delivered":
raise HTTPException(status_code=400, detail="已交付的销售订单禁止删除")
await _rollback_issued_materials(db_session, order, current_user)
@@ -522,6 +523,88 @@ async def delete_sales_order(
return {"message": "销售订单已删除"}
+class MaterialConsumptionItem(BaseModel):
+ material_id: int
+ quantity: float = Field(gt=0)
+ remark: Optional[str] = None
+
+
+class MaterialConsumptionRequest(BaseModel):
+ items: List[MaterialConsumptionItem] = Field(min_length=1)
+
+
+@router.post("/{order_id}/consume-materials")
+async def consume_materials(
+ order_id: int,
+ request: MaterialConsumptionRequest,
+ db_session: AsyncSession = Depends(get_db_session),
+ current_user: User = Depends(get_current_active_user)
+):
+ """记录销售订单的物料消耗"""
+ order, customer = await _get_sales_order_with_customer(db_session, order_id)
+
+ # 计算总物料成本
+ total_cost = 0
+
+ # 处理每个物料消耗项
+ for item in request.items:
+ # 获取物料信息
+ material = await db_session.get(Product, item.material_id)
+ if not material:
+ raise HTTPException(status_code=404, detail=f"物料 ID {item.material_id} 不存在")
+ if material.item_type != "material":
+ raise HTTPException(status_code=400, detail=f"只能消耗物料类型的产品: {material.name}")
+
+ # 计算成本
+ cost = material.cost_price * item.quantity
+ total_cost += cost
+
+ # 更新物料库存
+ inventory_result = await db_session.execute(
+ select(Inventory)
+ .where(Inventory.product_id == material.id)
+ .where(Inventory.warehouse_id == 1)
+ )
+ 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} 没有库存记录")
+
+ # 记录物料消耗
+ movement = StockMovement(
+ product_id=material.id,
+ warehouse_id=1, # 默认仓库
+ quantity=-item.quantity,
+ before_quantity=before_qty,
+ after_quantity=after_qty,
+ movement_type="consumption",
+ reference_type="sales_order",
+ reference_id=order.id,
+ unit_price=material.cost_price,
+ total_amount=cost,
+ operator_id=current_user.id,
+ remark=item.remark
+ )
+ db_session.add(movement)
+
+ # 更新订单的实际物料成本
+ order.actual_material_cost = total_cost
+
+ await db_session.commit()
+ await db_session.refresh(order)
+
+ return {
+ "message": "物料消耗记录保存成功",
+ "total_cost": total_cost,
+ "order": _build_sales_order_response(order, customer.name)
+ }
+
+
@router.get("/{order_id}/production-plan", response_model=SalesOrderProductionPlanResponse)
async def get_sales_order_production_plan(
order_id: int,
diff --git a/src/api/inventory/schemas/product_schemas.py b/src/api/inventory/schemas/product_schemas.py
index 81cdd34..f0dd524 100644
--- a/src/api/inventory/schemas/product_schemas.py
+++ b/src/api/inventory/schemas/product_schemas.py
@@ -39,7 +39,6 @@ class ProductResponse(BaseModel):
class ProductMaterialItemUpdate(BaseModel):
material_id: int
quantity: float
- loss_rate: float = 0
class ProductBOMUpdate(BaseModel):
@@ -51,7 +50,6 @@ class ProductMaterialItemResponse(BaseModel):
material_sku: str
material_name: str
quantity: float
- loss_rate: float
unit_cost: float
line_cost: float
diff --git a/src/api/inventory/schemas/purchase_order_schemas.py b/src/api/inventory/schemas/purchase_order_schemas.py
index bfbbbf9..f3bcd43 100644
--- a/src/api/inventory/schemas/purchase_order_schemas.py
+++ b/src/api/inventory/schemas/purchase_order_schemas.py
@@ -28,6 +28,8 @@ class PurchaseOrderResponse(BaseModel):
paid_amount: float
remark: Optional[str]
created_at: datetime
+ received_date: Optional[datetime]
+ paid_date: Optional[datetime]
class Config:
from_attributes = True
diff --git a/src/database/init_db.py b/src/database/init_db.py
index 20f09f6..55e1fc1 100644
--- a/src/database/init_db.py
+++ b/src/database/init_db.py
@@ -168,6 +168,8 @@ async def ensure_schema_updates():
await conn.execute(text("ALTER TABLE sales_orders ADD COLUMN IF NOT EXISTS production_no VARCHAR(50)"))
await conn.execute(text("ALTER TABLE sales_orders ADD COLUMN IF NOT EXISTS planned_material_cost DOUBLE PRECISION DEFAULT 0"))
await conn.execute(text("ALTER TABLE sales_orders ADD COLUMN IF NOT EXISTS actual_material_cost DOUBLE PRECISION DEFAULT 0"))
+ await conn.execute(text("ALTER TABLE purchase_orders ADD COLUMN IF NOT EXISTS received_date TIMESTAMP WITHOUT TIME ZONE"))
+ await conn.execute(text("ALTER TABLE purchase_orders ADD COLUMN IF NOT EXISTS paid_date TIMESTAMP WITHOUT TIME ZONE"))
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS product_materials (
id SERIAL PRIMARY KEY,
diff --git a/src/models/database.py b/src/models/database.py
index 5ef11f9..cc89e7a 100644
--- a/src/models/database.py
+++ b/src/models/database.py
@@ -653,8 +653,8 @@ class Inventory(Base):
id = Column(Integer, primary_key=True, index=True)
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
warehouse_id = Column(Integer, ForeignKey("warehouses.id"), nullable=False, index=True)
- quantity = Column(Integer, default=0)
- locked_quantity = Column(Integer, default=0)
+ quantity = Column(Float, default=0)
+ locked_quantity = Column(Float, default=0)
batch_number = Column(String(50), nullable=True)
location = Column(String(100), nullable=True)
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
@@ -678,9 +678,9 @@ class StockMovement(Base):
product_id = Column(Integer, ForeignKey("products.id"), nullable=False, index=True)
warehouse_id = Column(Integer, ForeignKey("warehouses.id"), nullable=False)
movement_type = Column(String(20), nullable=False)
- quantity = Column(Integer, nullable=False)
- before_quantity = Column(Integer, default=0)
- after_quantity = Column(Integer, default=0)
+ quantity = Column(Float, nullable=False)
+ before_quantity = Column(Float, default=0)
+ after_quantity = Column(Float, default=0)
reference_type = Column(String(50), nullable=True)
reference_id = Column(Integer, nullable=True)
reference_no = Column(String(50), nullable=True)
@@ -712,6 +712,9 @@ class PurchaseOrder(Base):
operator_id = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
+ # 状态变更时间
+ received_date = Column(DateTime, nullable=True) # 已收货时间
+ paid_date = Column(DateTime, nullable=True) # 已付款时间
supplier = relationship("Supplier", back_populates="purchase_orders")
items = relationship("PurchaseOrderItem", back_populates="order", cascade="all, delete-orphan")
diff --git a/static/index.html b/static/index.html
index a859a8a..7b645ad 100644
--- a/static/index.html
+++ b/static/index.html
@@ -77,6 +77,6 @@
-
+