From 6b3188e693a39e4e622adc3a40837966f7cd6955 Mon Sep 17 00:00:00 2001 From: chenjw28 <792430652@qq.com> Date: Tue, 23 Jun 2026 09:38:31 +0800 Subject: [PATCH] x --- .../inventory/components/CustomersTab.vue | 13 +++- .../inventory/components/InventoryTab.vue | 25 ++++++- .../inventory/components/MaterialsTab.vue | 31 +++++--- .../inventory/components/ProductsTab.vue | 9 ++- .../components/PurchaseOrdersTab.vue | 18 ++++- .../inventory/components/SalesOrdersTab.vue | 75 +++++++++++++++++-- .../inventory/components/SuppliersTab.vue | 13 +++- src/inventory/api/inventory_routes.py | 8 +- src/inventory/api/purchase_order_routes.py | 6 ++ src/inventory/api/sales_order_routes.py | 41 ++++++---- src/shared/models/database.py | 5 +- 11 files changed, 197 insertions(+), 47 deletions(-) diff --git a/frontend/src/modules/inventory/components/CustomersTab.vue b/frontend/src/modules/inventory/components/CustomersTab.vue index 2842c2b..b06f573 100644 --- a/frontend/src/modules/inventory/components/CustomersTab.vue +++ b/frontend/src/modules/inventory/components/CustomersTab.vue @@ -8,6 +8,7 @@ const { state, loadCustomers } = useInventory() const showModal = ref(false) const editingItem = ref(null) +const saving = ref(false) const form = reactive({ name: '', contact_person: '', @@ -37,6 +38,12 @@ function editCustomer(item: any) { } async function save() { + if (saving.value) return + if (!form.name || !form.name.trim()) { + addNotification('请输入客户名称', 'warning') + return + } + saving.value = true try { const payload = { name: form.name, @@ -62,6 +69,8 @@ async function save() { loadCustomers() } catch (e) { handleApiError(e, '保存客户') + } finally { + saving.value = false } } @@ -128,8 +137,8 @@ onMounted(() => { loadCustomers() }) diff --git a/frontend/src/modules/inventory/components/InventoryTab.vue b/frontend/src/modules/inventory/components/InventoryTab.vue index 5f60e19..5141573 100644 --- a/frontend/src/modules/inventory/components/InventoryTab.vue +++ b/frontend/src/modules/inventory/components/InventoryTab.vue @@ -8,6 +8,7 @@ const { state, loadInventory, ensureStockBaseData } = useInventory() const showModal = ref(false) const editingItem = ref(null) +const saving = ref(false) const form = reactive({ product_id: null as number | null, warehouse_id: null as number | null, @@ -42,6 +43,24 @@ function openEdit(item: any) { } async function save() { + if (saving.value) return + if (!form.product_id) { + addNotification('请选择物料', 'warning') + return + } + if (!form.warehouse_id) { + addNotification('请选择仓库', 'warning') + return + } + if (form.quantity < 0) { + addNotification('数量不能为负数', 'warning') + return + } + if (form.locked_quantity > form.quantity) { + addNotification('锁定数量不能大于库存数量', 'warning') + return + } + saving.value = true try { const payload = { product_id: form.product_id, @@ -68,6 +87,8 @@ async function save() { loadInventory() } catch (e) { handleApiError(e, '保存物料库存') + } finally { + saving.value = false } } @@ -139,8 +160,8 @@ onMounted(() => { loadInventory() }) diff --git a/frontend/src/modules/inventory/components/MaterialsTab.vue b/frontend/src/modules/inventory/components/MaterialsTab.vue index 2ff4d50..9a8cf1c 100644 --- a/frontend/src/modules/inventory/components/MaterialsTab.vue +++ b/frontend/src/modules/inventory/components/MaterialsTab.vue @@ -14,10 +14,12 @@ const { const showModal = ref(false) const editingItem = ref(null) +const saving = ref(false) const form = ref({}) const showRestockModal = ref(false) const restockItems = ref([]) const restockSupplierId = ref(null) +const restocking = ref(false) function openCreateMaterial() { editingItem.value = null @@ -42,6 +44,7 @@ async function editMaterial(product: any) { } async function saveProduct() { + if (saving.value) return if (!form.value.sku) { addNotification('请输入SKU', 'warning') return @@ -50,6 +53,7 @@ async function saveProduct() { addNotification('请输入名称', 'warning') return } + saving.value = true try { if (editingItem.value) { await apiRequest(`/api/products/${editingItem.value.id}`, { @@ -70,6 +74,8 @@ async function saveProduct() { loadMaterials() } catch (e) { handleApiError(e, '保存物料') + } finally { + saving.value = false } } @@ -111,6 +117,11 @@ const restockTotalAmount = computed(() => { }) async function saveRestock() { + if (restocking.value) return + if (!restockSupplierId.value) { + addNotification('请选择供应商', 'warning') + return + } if (!restockItems.value || restockItems.value.length === 0) { addNotification('请至少添加一个补货物料', 'warning') return @@ -130,9 +141,10 @@ async function saveRestock() { return } } + restocking.value = true try { const payload = { - supplier_id: restockSupplierId.value || state.suppliers[0]?.id || null, + supplier_id: restockSupplierId.value, expected_date: new Date().toISOString().split('T')[0], remark: '物料补货', items: restockItems.value.map((item: any) => ({ @@ -142,10 +154,6 @@ async function saveRestock() { remark: item.remark })) } - if (!payload.supplier_id) { - addNotification('请先添加供应商', 'warning') - return - } await apiRequest('/api/purchase-orders', { method: 'POST', body: JSON.stringify(payload) @@ -153,9 +161,12 @@ async function saveRestock() { addNotification('采购订单创建成功', 'success') showRestockModal.value = false restockItems.value = [] + restockSupplierId.value = null loadPurchaseOrders() } catch (e) { handleApiError(e, '保存补货订单') + } finally { + restocking.value = false } } @@ -234,8 +245,8 @@ onMounted(() => { loadMaterials() }) @@ -246,7 +257,7 @@ onMounted(() => { loadMaterials() }) @closed="closeRestockModal" > - + { loadMaterials() }) 补货总金额:{{ formatCurrency(restockTotalAmount) }} diff --git a/frontend/src/modules/inventory/components/ProductsTab.vue b/frontend/src/modules/inventory/components/ProductsTab.vue index e0527ab..243a75c 100644 --- a/frontend/src/modules/inventory/components/ProductsTab.vue +++ b/frontend/src/modules/inventory/components/ProductsTab.vue @@ -13,6 +13,7 @@ const { const showModal = ref(false) const editingItem = ref(null) +const saving = ref(false) const form = ref({}) const bomItems = ref([]) @@ -61,6 +62,7 @@ function removeBomItem(index: number) { } async function saveProduct() { + if (saving.value) return if (!form.value.sku) { addNotification('请输入SKU', 'warning') return @@ -69,6 +71,7 @@ async function saveProduct() { addNotification('请输入名称', 'warning') return } + saving.value = true try { if (editingItem.value) { await apiRequest(`/api/products/${editingItem.value.id}`, { @@ -97,6 +100,8 @@ async function saveProduct() { loadMaterials() } catch (e) { handleApiError(e, '保存成品') + } finally { + saving.value = false } } @@ -216,8 +221,8 @@ onMounted(() => { loadFinishedProducts() }) diff --git a/frontend/src/modules/inventory/components/PurchaseOrdersTab.vue b/frontend/src/modules/inventory/components/PurchaseOrdersTab.vue index 784d3d0..9164dd8 100644 --- a/frontend/src/modules/inventory/components/PurchaseOrdersTab.vue +++ b/frontend/src/modules/inventory/components/PurchaseOrdersTab.vue @@ -38,12 +38,14 @@ const editingItem = ref(null) const form = ref({}) const orderItems = ref([]) const purchaseWarehouseId = ref(null) +const saving = ref(false) const showReceiveModal = ref(false) const receivingOrder = ref(null) const receiveItems = ref([]) const receiveWarehouseId = ref(null) const receiveRemark = ref('') +const receiving = ref(false) function openCreateOrder() { editingItem.value = null @@ -99,6 +101,7 @@ async function editOrder(order: any) { } async function savePurchaseOrder() { + if (saving.value) return if (!form.value.supplier_id) { addNotification('请选择供应商', 'warning') return @@ -117,6 +120,7 @@ async function savePurchaseOrder() { return } } + saving.value = true try { const payload = { supplier_id: form.value.supplier_id, @@ -148,6 +152,8 @@ async function savePurchaseOrder() { loadPurchaseOrders() } catch (e) { handleApiError(e, '保存采购订单') + } finally { + saving.value = false } } @@ -183,6 +189,7 @@ async function openReceiveDialog(order: any) { } async function receivePurchaseOrder() { + if (receiving.value) return if (!receiveWarehouseId.value) { addNotification('请选择入库仓库', 'warning') return @@ -197,6 +204,7 @@ async function receivePurchaseOrder() { addNotification('请输入本次入库数量', 'warning') return } + receiving.value = true try { await apiRequest(`/api/purchase-orders/${receivingOrder.value.id}/receive`, { method: 'POST', @@ -215,6 +223,8 @@ async function receivePurchaseOrder() { loadMovements() } catch (e) { handleApiError(e, '采购入库') + } finally { + receiving.value = false } } @@ -456,8 +466,8 @@ onMounted(() => { 订单总金额:{{ formatCurrency(orderTotalAmount) }} @@ -501,8 +511,8 @@ onMounted(() => { diff --git a/frontend/src/modules/inventory/components/SalesOrdersTab.vue b/frontend/src/modules/inventory/components/SalesOrdersTab.vue index 70af9ed..2c1a7a8 100644 --- a/frontend/src/modules/inventory/components/SalesOrdersTab.vue +++ b/frontend/src/modules/inventory/components/SalesOrdersTab.vue @@ -38,10 +38,20 @@ const editingItem = ref(null) const form = ref({}) const moldItems = ref([]) const productionWarehouseId = ref(null) +const saving = ref(false) const showConsumptionModal = ref(false) const consumptionItems = ref([]) const consumedMaterials = ref([]) +const consuming = ref(false) + +// 删除守卫:已收款/已交付/已作废/有收款记录 的订单禁止删除(与后端一致) +function isSalesOrderDeleteLocked(row: any): boolean { + return row.payment_status === 'paid' + || row.delivery_status === 'delivered' + || row.delivery_status === 'cancelled' + || (Number(row.received_amount || 0) > 0) +} function openCreateOrder() { editingItem.value = null @@ -117,6 +127,7 @@ async function editOrder(order: any) { } async function saveSalesOrder() { + if (saving.value) return if (!form.value.customer_id) { addNotification('请选择客户', 'warning') return @@ -125,6 +136,33 @@ async function saveSalesOrder() { addNotification('请至少添加一个模具', 'warning') return } + for (const [i, item] of moldItems.value.entries()) { + const idx = i + 1 + if (item.mold_mode === 'new') { + if (!item.mold_sku || !String(item.mold_sku).trim()) { + addNotification(`第 ${idx} 行:新模请填写模具SKU`, 'warning') + return + } + if (!item.mold_name || !String(item.mold_name).trim()) { + addNotification(`第 ${idx} 行:新模请填写模具名称`, 'warning') + return + } + } else { + if (!item.mold_id) { + addNotification(`第 ${idx} 行:改模请选择已有模具`, 'warning') + return + } + } + if (!item.quantity || item.quantity <= 0) { + addNotification(`第 ${idx} 行:数量必须大于 0`, 'warning') + return + } + if (!item.unit_price || item.unit_price < 0) { + addNotification(`第 ${idx} 行:单价不能为负`, 'warning') + return + } + } + saving.value = true try { const payload = { customer_id: form.value.customer_id, @@ -164,13 +202,19 @@ async function saveSalesOrder() { loadMovements() } catch (e) { handleApiError(e, '保存销售订单') + } finally { + saving.value = false } } -async function deleteOrder(id: number) { - if (!await confirmDialog('删除确认', '确定要删除这个销售订单吗?', 'warning', '删除', '取消')) return +async function deleteOrder(row: any) { + if (isSalesOrderDeleteLocked(row)) { + addNotification('已收款/已交付/已作废或有收款记录的订单不可删除,请先作废相关收款单', 'warning') + return + } + if (!await confirmDialog('删除确认', '确定要删除这个销售订单吗?删除将自动回补已扣减的物料。', 'warning', '删除', '取消')) return try { - await apiRequest(`/api/sales-orders/${id}`, { method: 'DELETE' }) + await apiRequest(`/api/sales-orders/${row.id}`, { method: 'DELETE' }) addNotification('销售订单已删除', 'success') loadProductionOrders() loadInventory() @@ -239,11 +283,24 @@ const consumptionTotalAmount = computed(() => { }) async function saveConsumption() { + if (consuming.value) return if (!editingItem.value) return if (!consumptionItems.value || consumptionItems.value.length === 0) { addNotification('请至少添加一个消耗物料', 'warning') return } + for (const [i, item] of consumptionItems.value.entries()) { + const idx = i + 1 + if (!item.material_id) { + addNotification(`第 ${idx} 行:请选择物料`, 'warning') + return + } + if (!item.quantity || item.quantity <= 0) { + addNotification(`第 ${idx} 行:数量必须大于 0`, 'warning') + return + } + } + consuming.value = true try { const payload = { items: consumptionItems.value.map((item: any) => ({ @@ -275,6 +332,8 @@ async function saveConsumption() { loadMovements() } catch (e) { handleApiError(e, '保存物料消耗') + } finally { + consuming.value = false } } @@ -372,7 +431,7 @@ onMounted(() => { :disabled="row.payment_status === 'paid' || row.delivery_status === 'cancelled'" @click="editOrder(row)" >编辑 - 删除 + 删除 { @@ -563,8 +622,8 @@ onMounted(() => { 消耗总金额:{{ formatCurrency(consumptionTotalAmount) }} diff --git a/frontend/src/modules/inventory/components/SuppliersTab.vue b/frontend/src/modules/inventory/components/SuppliersTab.vue index a6dce09..8d3cf5d 100644 --- a/frontend/src/modules/inventory/components/SuppliersTab.vue +++ b/frontend/src/modules/inventory/components/SuppliersTab.vue @@ -8,6 +8,7 @@ const { state, loadSuppliers } = useInventory() const showModal = ref(false) const editingItem = ref(null) +const saving = ref(false) const form = reactive({ name: '', contact_person: '', @@ -37,6 +38,12 @@ function editSupplier(item: any) { } async function save() { + if (saving.value) return + if (!form.name || !form.name.trim()) { + addNotification('请输入供应商名称', 'warning') + return + } + saving.value = true try { const payload = { name: form.name, @@ -62,6 +69,8 @@ async function save() { loadSuppliers() } catch (e) { handleApiError(e, '保存供应商') + } finally { + saving.value = false } } @@ -128,8 +137,8 @@ onMounted(() => { loadSuppliers() }) diff --git a/src/inventory/api/inventory_routes.py b/src/inventory/api/inventory_routes.py index bed69af..91a1f3a 100644 --- a/src/inventory/api/inventory_routes.py +++ b/src/inventory/api/inventory_routes.py @@ -10,6 +10,7 @@ from fastapi import APIRouter, Depends, Query, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, update, func +from sqlalchemy.exc import IntegrityError from typing import Optional, List from shared.database.database import get_db_session @@ -114,7 +115,12 @@ async def create_inventory( location=payload.location ) db_session.add(inventory) - await db_session.commit() + try: + await db_session.commit() + except IntegrityError: + # 并发创建命中 (product_id, warehouse_id) 唯一约束 + await db_session.rollback() + raise HTTPException(status_code=400, detail="该仓库已存在该物料库存记录") await db_session.refresh(inventory) return InventoryResponse( diff --git a/src/inventory/api/purchase_order_routes.py b/src/inventory/api/purchase_order_routes.py index da6e4a0..452a959 100644 --- a/src/inventory/api/purchase_order_routes.py +++ b/src/inventory/api/purchase_order_routes.py @@ -344,6 +344,10 @@ async def update_purchase_order_status( valid_statuses = ["pending", "partial_received", "received", "paid", "cancelled"] if new_status not in valid_statuses: raise HTTPException(status_code=400, detail=f"无效的状态值,有效值为: {valid_statuses}") + + # 收货状态(partial_received/received)只能由收货端点驱动,禁止手动设置,避免与实际入库脱钩 + if new_status in ("partial_received", "received"): + raise HTTPException(status_code=400, detail="收货状态只能通过收货入库操作自动变更,不能手动设置") # 状态转换逻辑 if order.status == "paid": @@ -380,6 +384,8 @@ async def receive_purchase_order( current_user: User = Depends(get_current_active_user) ): order, supplier = await _get_order_with_supplier(db_session, order_id) + if order.status in ("cancelled", "paid"): + raise HTTPException(status_code=400, detail=f"当前采购单状态为 {order.status},不允许收货") warehouse = await _resolve_receive_warehouse(db_session, payload.warehouse_id) item_result = await db_session.execute( diff --git a/src/inventory/api/sales_order_routes.py b/src/inventory/api/sales_order_routes.py index 8594f44..58abbb0 100644 --- a/src/inventory/api/sales_order_routes.py +++ b/src/inventory/api/sales_order_routes.py @@ -446,7 +446,7 @@ async def create_sales_order( customer_id=order_data.customer_id, order_date=now, delivery_date=order_data.delivery_date, - manufacturing_date=now, + manufacturing_date=now.date(), created_at=now, remark=order_data.remark, operator_id=current_user.id, @@ -563,6 +563,10 @@ async def delete_sales_order( order, customer = await _get_sales_order_with_customer(db_session, order_id) if order.status == "delivered": raise HTTPException(status_code=400, detail="已交付的销售订单禁止删除") + if order.status == "paid": + raise HTTPException(status_code=400, detail="已收款的销售订单禁止删除,请先作废相关收款单") + if (order.received_amount or 0) > 0: + raise HTTPException(status_code=400, detail="该销售订单已存在收款记录(received_amount>0),禁止删除,请先作废相关收款单") await _rollback_issued_materials(db_session, order, current_user) await db_session.delete(order) await db_session.commit() @@ -586,9 +590,14 @@ async def consume_materials( 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) - + + if order.status in ("delivered", "paid", "cancelled"): + raise HTTPException(status_code=400, detail="当前订单状态不允许记录物料消耗") + if order.production_status == "completed": + raise HTTPException(status_code=400, detail="该销售单已完成生产,不允许记录物料消耗") + default_warehouse = await _get_default_warehouse(db_session) # 计算总物料成本 @@ -602,45 +611,47 @@ async def consume_materials( 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 = Decimal(str(material.cost_price or 0)) * item.quantity + + # 统一转 Decimal,避免 Decimal * float 在 Postgres(Numeric) 上抛 TypeError + consume_qty = Decimal(str(item.quantity)) + unit_cost = Decimal(str(material.cost_price or 0)) + cost = unit_cost * consume_qty total_cost += cost - + # 更新物料库存(原子操作防并发) 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) + .where(Inventory.quantity >= consume_qty) + .values(quantity=Inventory.quantity - consume_qty) .returning(Inventory.quantity) ) 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) + after_qty = Decimal(str(after_qty)) + before_qty = after_qty + consume_qty # 记录物料消耗 movement = StockMovement( product_id=material.id, warehouse_id=default_warehouse.id, - quantity=item.quantity, + quantity=consume_qty, before_quantity=before_qty, after_quantity=after_qty, movement_type="consumption", reference_type="sales_order", reference_id=order.id, - unit_price=material.cost_price, + unit_price=unit_cost, total_amount=cost, operator_id=current_user.id, remark=item.remark ) db_session.add(movement) - # 更新订单的实际物料成本 - order.actual_material_cost = total_cost + # 累加实际物料成本(创建时已记录计划发料成本,此处为额外消耗,不应覆盖) + order.actual_material_cost = Decimal(str(order.actual_material_cost or 0)) + total_cost await db_session.commit() await db_session.refresh(order) diff --git a/src/shared/models/database.py b/src/shared/models/database.py index dc3721c..b5bc880 100644 --- a/src/shared/models/database.py +++ b/src/shared/models/database.py @@ -660,7 +660,10 @@ class Warehouse(Base): class Inventory(Base): """库存表""" __tablename__ = "inventory" - + __table_args__ = ( + UniqueConstraint("product_id", "warehouse_id", name="uq_inventory_product_warehouse"), + ) + 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)