From c72c47b10268257b568aa98f9ab6d17f46a922f1 Mon Sep 17 00:00:00 2001 From: SZCJW <792430652@qq.com> Date: Sun, 29 Mar 2026 16:24:43 +0800 Subject: [PATCH 1/4] x --- .env | 2 +- src/api/inventory/purchase_order_routes.py | 37 +- src/api/inventory/sales_order_routes.py | 85 +++- src/models/database.py | 10 +- static/style.css | 41 ++ static/vue-app.js | 456 ++++++++++++++++++++- 6 files changed, 609 insertions(+), 22 deletions(-) diff --git a/.env b/.env index 0de4387..d9b38af 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/src/api/inventory/purchase_order_routes.py b/src/api/inventory/purchase_order_routes.py index 41a36b3..accde36 100644 --- a/src/api/inventory/purchase_order_routes.py +++ b/src/api/inventory/purchase_order_routes.py @@ -205,7 +205,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() @@ -255,7 +255,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 +283,39 @@ 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 + 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, 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/models/database.py b/src/models/database.py index 5ef11f9..cf8539b 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) diff --git a/static/style.css b/static/style.css index 9ad45c1..ed25ac4 100644 --- a/static/style.css +++ b/static/style.css @@ -2764,6 +2764,47 @@ select.form-input { padding-top: var(--space-3); } +/* 物料消耗记录样式 */ +.material-consumption-list { + border: 1px solid var(--border); + border-radius: 4px; + overflow: hidden; +} + +.material-consumption-header { + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr; + background: var(--bg-secondary); + padding: 12px 16px; + font-weight: 600; + border-bottom: 1px solid var(--border); +} + +.material-consumption-row { + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr; + padding: 12px 16px; + border-bottom: 1px solid var(--border); +} + +.material-consumption-row:last-child { + border-bottom: none; +} + +.material-consumption-total { + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr; + padding: 12px 16px; + background: var(--bg-secondary); + font-weight: 600; + margin-top: 8px; + border-radius: 4px; +} + +.material-consumption-item { + text-align: left; +} + .viewer-frame { width: 100%; height: 500px; diff --git a/static/vue-app.js b/static/vue-app.js index 1a06371..46896b9 100644 --- a/static/vue-app.js +++ b/static/vue-app.js @@ -1588,6 +1588,11 @@ const InventoryView = { modalType: '', editingItem: null, productBomItems: [], + materialConsumptionItems: [], + showMaterialConsumptionModal: false, + consumedMaterials: [], + restockItems: [], + showRestockModal: false, form: {} }); @@ -1620,17 +1625,25 @@ const InventoryView = { const toApiDateTime = (value) => { if (!value) return null; const text = String(value).trim(); + // 提取日期部分,忽略时间部分 + if (text.includes('T')) { + return text.split('T')[0]; + } + if (text.includes(' ')) { + return text.split(' ')[0]; + } // 如果是只有日期部分的格式 (yyyy-MM-dd) - if (text.length === 10 && !text.includes('T') && !text.includes(' ')) { + if (text.length === 10) { return text; // 直接返回日期格式,后端 Pydantic 会自动处理 } - // 如果是带时间的格式,转换为 ISO 8601 格式 - if (text.includes('T')) { - return text.length === 16 ? `${text}:00` : text; + // 如果是日期对象 + if (value instanceof Date) { + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, '0'); + const day = String(value.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; } - // 将空格格式转换为 ISO 格式 - const isoText = text.replace(' ', 'T'); - return isoText.length === 16 ? `${isoText}:00` : isoText; + return text; }; const toNativeValue = (value) => { @@ -1752,6 +1765,20 @@ const InventoryView = { return 'badge-warning'; }; + const getPurchaseOrderStatusLabel = (status) => { + const statusMap = { + draft: '已下单', + pending: '已下单', + received: '已收货', + paid: '已付款' + }; + return statusMap[status] || status; + }; + + const isPurchaseOrderLocked = (status) => { + return ['received', 'paid'].includes(status); + }; + const loadDashboard = async () => { state.loading = true; try { @@ -2108,6 +2135,14 @@ const InventoryView = { await loadCustomers(); await loadFinishedProducts(); const detail = await apiRequest(`/api/sales-orders/${item.id}`); + // 加载已消耗的物料 + const movements = await apiRequest(`/api/stock-movements?reference_type=sales_order&reference_id=${item.id}&movement_type=consumption`); + state.consumedMaterials = (movements || []).map(movement => ({ + material_name: movement.product_name || movement.product_sku || '未知物料', + quantity: Math.abs(movement.quantity), + unit_price: movement.unit_price || 0, + amount: movement.total_amount || 0 + })); state.form = { customer_id: detail.customer_id, delivery_date: toPickerValue(detail.delivery_date), @@ -2236,6 +2271,171 @@ const InventoryView = { }); }; + const openMaterialConsumptionModal = async () => { + // 确保是在销售订单编辑或新增页面 + if (state.modalType !== 'salesOrder') { + addNotification('请先打开销售订单编辑页面', 'warning'); + return; + } + // 加载物料和仓库数据 + await loadMaterials(); + await loadWarehouses(); + // 初始化物料消耗列表 + state.materialConsumptionItems = []; + // 打开物料消耗模态框 + state.showMaterialConsumptionModal = true; + }; + + const addMaterialConsumptionItem = () => { + state.materialConsumptionItems.push({ + material_id: state.materials[0]?.id || null, + quantity: 1, + remark: '' + }); + }; + + const removeMaterialConsumptionItem = (index) => { + state.materialConsumptionItems.splice(index, 1); + }; + + const saveMaterialConsumption = async () => { + // 确保是在销售订单页面 + if (state.modalType !== 'salesOrder') { + addNotification('请先打开销售订单编辑页面', 'warning'); + return; + } + // 如果是新增订单,先保存订单再添加物料消耗 + if (!state.editingItem) { + // 关闭物料消耗模态框 + state.showMaterialConsumptionModal = false; + await saveSalesOrder(); + return; + } + + // 验证物料消耗项 + for (const [i, item] of state.materialConsumptionItems.entries()) { + const idx = i + 1; + if (!item.material_id) { + addNotification(`第 ${idx} 行:请选择物料`, 'warning'); + return; + } + if (!Number.isFinite(item.quantity) || item.quantity <= 0) { + addNotification(`第 ${idx} 行:数量必须大于 0`, 'warning'); + return; + } + } + + try { + // 调用 API 保存物料消耗 + const result = await apiRequest(`/api/sales-orders/${state.editingItem.id}/consume-materials`, { + method: 'POST', + body: JSON.stringify({ + items: state.materialConsumptionItems.map(item => ({ + material_id: item.material_id, + quantity: item.quantity, + remark: item.remark + })) + }) + }); + + addNotification('物料消耗记录保存成功', 'success'); + // 关闭模态框 + state.showMaterialConsumptionModal = false; + // 刷新订单详情 + const detail = await apiRequest(`/api/sales-orders/${state.editingItem.id}`); + state.form = { + ...state.form, + actual_material_cost: detail.actual_material_cost + }; + // 刷新已消耗的物料 + const movements = await apiRequest(`/api/stock-movements?reference_type=sales_order&reference_id=${state.editingItem.id}&movement_type=consumption`); + state.consumedMaterials = (movements || []).map(movement => ({ + material_name: movement.product_name || movement.product_sku || '未知物料', + quantity: Math.abs(movement.quantity), + unit_price: movement.unit_price || 0, + amount: movement.total_amount || 0 + })); + } catch (e) { + handleApiError(e, '保存物料消耗'); + } + }; + + const openRestockModal = async () => { + // 加载物料和供应商数据 + await loadMaterials(); + await loadSuppliers(); + // 初始化补货列表 + state.restockItems = []; + // 打开补货模态框 + state.showRestockModal = true; + }; + + const addRestockItem = () => { + state.restockItems.push({ + material_id: state.materials[0]?.id || null, + quantity: 1, + unit_price: 0, + remark: '' + }); + }; + + const removeRestockItem = (index) => { + state.restockItems.splice(index, 1); + }; + + const saveRestock = async () => { + // 验证补货项 + if (!state.restockItems || state.restockItems.length === 0) { + addNotification('请至少添加一个补货物料', 'warning'); + return; + } + + for (const [i, item] of state.restockItems.entries()) { + const idx = i + 1; + if (!item.material_id) { + addNotification(`第 ${idx} 行:请选择物料`, 'warning'); + return; + } + if (!Number.isFinite(item.quantity) || item.quantity <= 0) { + addNotification(`第 ${idx} 行:数量必须大于 0`, 'warning'); + return; + } + if (!Number.isFinite(item.unit_price) || item.unit_price < 0) { + addNotification(`第 ${idx} 行:单价必须大于等于 0`, 'warning'); + return; + } + } + + try { + // 创建采购订单 + const payload = { + supplier_id: state.suppliers[0]?.id || null, + expected_date: new Date().toISOString().split('T')[0], + remark: '物料补货', + items: state.restockItems.map(item => ({ + product_id: item.material_id, + quantity: item.quantity, + remark: item.remark + })) + }; + + const result = await apiRequest('/api/purchase-orders', { + method: 'POST', + body: JSON.stringify(payload) + }); + + addNotification('采购订单创建成功', 'success'); + // 关闭补货模态框 + state.showRestockModal = false; + // 清空补货列表 + state.restockItems = []; + // 刷新采购订单列表 + loadPurchaseOrders(); + } catch (e) { + handleApiError(e, '保存补货订单'); + } + }; + const closeModal = () => { state.showModal = false; state.modalType = ''; @@ -2513,19 +2713,45 @@ const InventoryView = { remark: item.remark || '' })) }; + let orderId; if (state.editingItem) { const result = await apiRequest(`/api/sales-orders/${state.editingItem.id}`, { method: 'PUT', body: JSON.stringify(payload) }); + orderId = state.editingItem.id; addNotification(result.production_status === 'bom_missing' ? '订单已保存,但成品未配置BOM,未扣减物料' : '销售订单更新成功并已自动扣减物料', result.production_status === 'bom_missing' ? 'warning' : 'success'); } else { const result = await apiRequest('/api/sales-orders', { method: 'POST', body: JSON.stringify(payload) }); + orderId = result.id; addNotification(result.production_status === 'bom_missing' ? '订单已创建,但成品未配置BOM,未扣减物料' : '销售订单创建成功并已自动扣减物料', result.production_status === 'bom_missing' ? 'warning' : 'success'); } + + // 如果有物料消耗记录,保存物料消耗 + if (state.materialConsumptionItems && state.materialConsumptionItems.length > 0) { + try { + // 调用 API 保存物料消耗 + const result = await apiRequest(`/api/sales-orders/${orderId}/consume-materials`, { + method: 'POST', + body: JSON.stringify({ + items: state.materialConsumptionItems.map(item => ({ + material_id: item.material_id, + quantity: item.quantity, + remark: item.remark + })) + }) + }); + addNotification('物料消耗记录保存成功', 'success'); + // 清空物料消耗记录 + state.materialConsumptionItems = []; + } catch (e) { + handleApiError(e, '保存物料消耗'); + } + } + closeModal(); loadProductionOrders(); loadFinishedProducts(); @@ -2549,6 +2775,19 @@ const InventoryView = { } }; + const updatePurchaseOrderStatus = async (order, targetStatus) => { + try { + await apiRequest(`/api/purchase-orders/${order.id}/status`, { + method: 'PATCH', + body: JSON.stringify({ status: targetStatus }) + }); + addNotification('采购订单状态已更新', 'success'); + loadPurchaseOrders(); + } catch (e) { + handleApiError(e, '更新采购订单状态'); + } + }; + const deleteSalesOrder = async (orderId) => { if (!confirm('确定删除这个销售订单吗?系统会自动回补已扣减物料。')) return; try { @@ -2721,8 +2960,9 @@ const InventoryView = { removeSalesOrderItem, setSalesOrderLineMode, saveSalesOrder, - deleteSalesOrder, updateSalesOrderStatus, + updatePurchaseOrderStatus, + deleteSalesOrder, addPurchaseOrderItem, removePurchaseOrderItem, savePurchaseOrder, @@ -2733,7 +2973,17 @@ const InventoryView = { refreshFinanceByPeriod, getMovementTypeLabel, - getMovementBadgeClass + getMovementBadgeClass, + openMaterialConsumptionModal, + addMaterialConsumptionItem, + removeMaterialConsumptionItem, + saveMaterialConsumption, + openRestockModal, + addRestockItem, + removeRestockItem, + saveRestock, + getPurchaseOrderStatusLabel, + isPurchaseOrderLocked }; }, template: ` @@ -2874,6 +3124,7 @@ const InventoryView = {
| {{ order.order_no }} | {{ order.supplier_name }} | -{{ order.status }} | +{{ getPurchaseOrderStatusLabel(order.status) }} | {{ order.expected_date ? formatDate(order.expected_date) : '-' }} | {{ formatCurrency(order.total_amount || 0) }} | {{ formatCurrency(order.paid_amount || 0) }} |
-
-
-
+
+
+
+
|
采购单 | 供应商 | 状态 | +订单创建 | 预计到货 | +实际到货 | +实际付款 | 总金额 | 已付款 | 操作 | @@ -3233,7 +3236,10 @@ const InventoryView = {{{ order.order_no }} | {{ order.supplier_name }} | {{ getPurchaseOrderStatusLabel(order.status) }} | +{{ order.created_at ? formatDateTime(order.created_at) : '-' }} | {{ order.expected_date ? formatDate(order.expected_date) : '-' }} | +{{ order.received_date ? formatDateTime(order.received_date) : '-' }} | +{{ order.paid_date ? formatDateTime(order.paid_date) : '-' }} | {{ formatCurrency(order.total_amount || 0) }} | {{ formatCurrency(order.paid_amount || 0) }} | From cd9b244bc89f40adfe1afab24f8d13ffee69b0d2 Mon Sep 17 00:00:00 2001 From: SZCJW <792430652@qq.com> Date: Thu, 9 Apr 2026 23:15:56 +0800 Subject: [PATCH 3/4] init --- migrate_purchase_status_dates.py | 75 ++++++++++++++++++++ src/api/inventory/product_routes.py | 10 +-- src/api/inventory/purchase_order_routes.py | 31 +++++--- src/api/inventory/schemas/product_schemas.py | 2 - src/database/init_db.py | 2 + static/index.html | 2 +- static/vue-app.js | 59 ++++++++++++--- 7 files changed, 153 insertions(+), 28 deletions(-) create mode 100644 migrate_purchase_status_dates.py 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 b3479c7..cfac12c 100644 --- a/src/api/inventory/purchase_order_routes.py +++ b/src/api/inventory/purchase_order_routes.py @@ -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, @@ -49,8 +53,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=order.received_date, - paid_date=order.paid_date + received_date=received_date, + paid_date=paid_date ) @@ -81,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, @@ -93,8 +101,8 @@ async def _build_purchase_order_detail( paid_amount=order.paid_amount, remark=order.remark, created_at=order.created_at, - received_date=order.received_date, - paid_date=order.paid_date, + received_date=received_date, + paid_date=paid_date, items=[ PurchaseOrderItemResponse( id=item.id, @@ -218,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) @@ -313,9 +323,10 @@ async def update_purchase_order_status( # 更新状态和对应时间 order.status = new_status - if new_status == "received": + # 检查字段是否存在,避免数据库中没有这些字段时的错误 + if hasattr(order, 'received_date') and new_status == "received": order.received_date = func.now() - elif new_status == "paid": + elif hasattr(order, 'paid_date') and new_status == "paid": order.paid_date = func.now() await db_session.commit() @@ -407,7 +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" - order.received_date = func.now() + # 检查字段是否存在,避免数据库中没有这些字段时的错误 + if hasattr(order, 'received_date'): + order.received_date = func.now() elif any_received: order.status = "partial_received" 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/database/init_db.py b/src/database/init_db.py index c722f39..a624be2 100644 --- a/src/database/init_db.py +++ b/src/database/init_db.py @@ -167,6 +167,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/static/index.html b/static/index.html index a859a8a..7b645ad 100644 --- a/static/index.html +++ b/static/index.html @@ -77,6 +77,6 @@ - + |
|---|