523 lines
17 KiB
Vue
523 lines
17 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { DialogPlugin } from 'tdesign-vue-next'
|
|
import { useInventory } from '../composables/useInventory'
|
|
import { apiRequest } from '@/shared/api'
|
|
import { addNotification, handleApiError } from '@/shared/notification'
|
|
|
|
function confirmDialog(header: string, body: string, theme: string, confirmText: string, cancelText: string): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
const dlg = DialogPlugin.confirm({
|
|
header, body, theme: theme as any,
|
|
confirmBtn: confirmText, cancelBtn: cancelText,
|
|
onConfirm: () => { dlg.hide(); resolve(true) },
|
|
onCancel: () => { dlg.hide(); resolve(false) },
|
|
onClose: () => { resolve(false) }
|
|
})
|
|
})
|
|
}
|
|
|
|
const {
|
|
state,
|
|
formatCurrency,
|
|
formatDateTime,
|
|
formatDate,
|
|
getPurchaseOrderStatusLabel,
|
|
getReceiptStatusLabel,
|
|
getPaymentStatusLabel,
|
|
isPurchaseOrderLocked,
|
|
loadPurchaseOrders,
|
|
loadSuppliers,
|
|
loadMaterials,
|
|
loadInventory,
|
|
loadMovements
|
|
} = useInventory()
|
|
|
|
const showModal = ref(false)
|
|
const editingItem = ref<any>(null)
|
|
const form = ref<any>({})
|
|
const orderItems = ref<any[]>([])
|
|
const purchaseWarehouseId = ref<number | null>(null)
|
|
const saving = ref(false)
|
|
|
|
const showReceiveModal = ref(false)
|
|
const receivingOrder = ref<any>(null)
|
|
const receiveItems = ref<any[]>([])
|
|
const receiveWarehouseId = ref<number | null>(null)
|
|
const receiveRemark = ref('')
|
|
const receiving = ref(false)
|
|
|
|
function openCreateOrder() {
|
|
editingItem.value = null
|
|
form.value = {
|
|
supplier_id: null,
|
|
expected_date: '',
|
|
remark: ''
|
|
}
|
|
orderItems.value = []
|
|
showModal.value = true
|
|
}
|
|
|
|
function addOrderItem() {
|
|
orderItems.value.push({
|
|
product_id: state.materials[0]?.id || null,
|
|
quantity: 1,
|
|
remark: ''
|
|
})
|
|
}
|
|
|
|
function removeOrderItem(index: number) {
|
|
orderItems.value.splice(index, 1)
|
|
}
|
|
|
|
const orderTotalAmount = computed(() => {
|
|
return orderItems.value.reduce((sum: number, item: any) => {
|
|
const material = state.materials.find((m: any) => m.id === item.product_id)
|
|
const unitPrice = material?.cost_price || 0
|
|
return sum + unitPrice * (item.quantity || 0)
|
|
}, 0)
|
|
})
|
|
|
|
async function editOrder(order: any) {
|
|
await loadMaterials()
|
|
await loadSuppliers()
|
|
editingItem.value = order
|
|
form.value = {
|
|
supplier_id: order.supplier_id || null,
|
|
expected_date: order.expected_date || '',
|
|
remark: order.remark || ''
|
|
}
|
|
try {
|
|
const detail = await apiRequest(`/api/purchase-orders/${order.id}`)
|
|
orderItems.value = (detail.items || []).map((item: any) => ({
|
|
product_id: item.product_id,
|
|
quantity: item.quantity,
|
|
remark: item.remark || ''
|
|
}))
|
|
} catch (e) {
|
|
handleApiError(e, '加载采购订单详情')
|
|
}
|
|
showModal.value = true
|
|
}
|
|
|
|
async function savePurchaseOrder() {
|
|
if (saving.value) return
|
|
if (!form.value.supplier_id) {
|
|
addNotification('请选择供应商', 'warning')
|
|
return
|
|
}
|
|
if (!orderItems.value || orderItems.value.length === 0) {
|
|
addNotification('请至少添加一个物料', 'warning')
|
|
return
|
|
}
|
|
for (const [i, item] of orderItems.value.entries()) {
|
|
if (!item.product_id) {
|
|
addNotification(`第 ${i + 1} 行:请选择物料`, 'warning')
|
|
return
|
|
}
|
|
if (!item.quantity || item.quantity <= 0) {
|
|
addNotification(`第 ${i + 1} 行:数量必须大于 0`, 'warning')
|
|
return
|
|
}
|
|
}
|
|
saving.value = true
|
|
try {
|
|
const payload = {
|
|
supplier_id: form.value.supplier_id,
|
|
expected_date: form.value.expected_date || null,
|
|
remark: form.value.remark || '',
|
|
items: orderItems.value.map((item: any) => ({
|
|
product_id: item.product_id,
|
|
quantity: item.quantity,
|
|
remark: item.remark
|
|
}))
|
|
}
|
|
if (editingItem.value) {
|
|
await apiRequest(`/api/purchase-orders/${editingItem.value.id}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify(payload)
|
|
})
|
|
addNotification('采购订单更新成功', 'success')
|
|
} else {
|
|
await apiRequest('/api/purchase-orders', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload)
|
|
})
|
|
addNotification('采购订单创建成功', 'success')
|
|
}
|
|
showModal.value = false
|
|
editingItem.value = null
|
|
form.value = {}
|
|
orderItems.value = []
|
|
loadPurchaseOrders()
|
|
} catch (e) {
|
|
handleApiError(e, '保存采购订单')
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
async function deleteOrder(id: number) {
|
|
if (!await confirmDialog('删除确认', '确定要删除这个采购订单吗?', 'warning', '删除', '取消')) return
|
|
try {
|
|
await apiRequest(`/api/purchase-orders/${id}`, { method: 'DELETE' })
|
|
addNotification('采购订单已删除', 'success')
|
|
loadPurchaseOrders()
|
|
} catch (e) {
|
|
handleApiError(e, '删除采购订单')
|
|
}
|
|
}
|
|
|
|
async function openReceiveDialog(order: any) {
|
|
try {
|
|
const detail = await apiRequest(`/api/purchase-orders/${order.id}`)
|
|
receivingOrder.value = order
|
|
receiveItems.value = (detail.items || []).map((item: any) => ({
|
|
id: item.id,
|
|
material_label: item.product_name || item.product_sku || '',
|
|
quantity: item.quantity || 0,
|
|
received_quantity: item.received_quantity || 0,
|
|
remaining: (item.quantity || 0) - (item.received_quantity || 0),
|
|
receive_quantity: 0
|
|
}))
|
|
receiveWarehouseId.value = purchaseWarehouseId.value || state.warehouses[0]?.id || null
|
|
receiveRemark.value = ''
|
|
showReceiveModal.value = true
|
|
} catch (e) {
|
|
handleApiError(e, '加载采购订单详情')
|
|
}
|
|
}
|
|
|
|
async function receivePurchaseOrder() {
|
|
if (receiving.value) return
|
|
if (!receiveWarehouseId.value) {
|
|
addNotification('请选择入库仓库', 'warning')
|
|
return
|
|
}
|
|
const items = receiveItems.value
|
|
.filter((item: any) => item.receive_quantity > 0)
|
|
.map((item: any) => ({
|
|
item_id: item.id,
|
|
receive_quantity: item.receive_quantity
|
|
}))
|
|
if (items.length === 0) {
|
|
addNotification('请输入本次入库数量', 'warning')
|
|
return
|
|
}
|
|
receiving.value = true
|
|
try {
|
|
await apiRequest(`/api/purchase-orders/${receivingOrder.value.id}/receive`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
warehouse_id: receiveWarehouseId.value,
|
|
remark: receiveRemark.value,
|
|
items
|
|
})
|
|
})
|
|
addNotification('入库完成', 'success')
|
|
showReceiveModal.value = false
|
|
receivingOrder.value = null
|
|
receiveItems.value = []
|
|
loadPurchaseOrders()
|
|
loadInventory()
|
|
loadMovements()
|
|
} catch (e) {
|
|
handleApiError(e, '采购入库')
|
|
} finally {
|
|
receiving.value = false
|
|
}
|
|
}
|
|
|
|
async function markAsPaid(orderId: number, receiptStatus: string) {
|
|
if (receiptStatus !== 'received') {
|
|
if (!await confirmDialog('非标准流程提醒', '该订单尚未全部收货,确定要提前付款吗?建议先完成收货后再付款。', 'warning', '仍要付款', '取消')) return
|
|
} else {
|
|
if (!await confirmDialog('付款确认', '确认已向供应商支付该订单款项?', 'success', '确认付款', '取消')) return
|
|
}
|
|
await doUpdateStatus(orderId, 'paid', '已付款')
|
|
}
|
|
|
|
async function cancelOrder(orderId: number) {
|
|
if (!await confirmDialog('作废确认', '确定要作废该订单吗?此操作不可撤销。', 'error', '确认作废', '取消')) return
|
|
await doUpdateStatus(orderId, 'cancelled', '已作废')
|
|
}
|
|
|
|
async function doUpdateStatus(orderId: number, status: string, label: string) {
|
|
try {
|
|
await apiRequest(`/api/purchase-orders/${orderId}/status`, {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ status })
|
|
})
|
|
addNotification(`订单已${label}`, 'success')
|
|
loadPurchaseOrders()
|
|
loadMovements()
|
|
loadInventory()
|
|
} catch (e) {
|
|
handleApiError(e, '更新状态')
|
|
}
|
|
}
|
|
|
|
function closeModal() {
|
|
showModal.value = false
|
|
editingItem.value = null
|
|
form.value = {}
|
|
orderItems.value = []
|
|
}
|
|
|
|
function closeReceiveModal() {
|
|
showReceiveModal.value = false
|
|
receivingOrder.value = null
|
|
receiveItems.value = []
|
|
}
|
|
|
|
async function initData() {
|
|
await loadMaterials()
|
|
await loadSuppliers()
|
|
await loadPurchaseOrders()
|
|
}
|
|
|
|
onMounted(() => {
|
|
initData()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div>
|
|
<div style="margin-bottom: 12px; display: flex; gap: 12px; align-items: center;">
|
|
<t-select
|
|
v-model="purchaseWarehouseId"
|
|
placeholder="选择仓库"
|
|
style="width: 200px;"
|
|
clearable
|
|
>
|
|
<t-option
|
|
v-for="warehouse in state.warehouses"
|
|
:key="warehouse.id"
|
|
:label="warehouse.name"
|
|
:value="warehouse.id"
|
|
/>
|
|
</t-select>
|
|
<t-button @click="loadPurchaseOrders">刷新</t-button>
|
|
<t-button type="primary" @click="openCreateOrder">新增采购订单</t-button>
|
|
</div>
|
|
|
|
<t-table :data="state.purchaseOrders" :loading="state.loading" stripe>
|
|
<t-table-column prop="order_no" label="采购单" />
|
|
<t-table-column prop="supplier_name" label="供应商" />
|
|
<t-table-column label="状态">
|
|
<template #default="{ row }">
|
|
<div style="display: flex; gap: 4px;">
|
|
<t-tag
|
|
:type="row.receipt_status === 'received' ? 'success' : row.receipt_status === 'partial_received' ? 'warning' : row.receipt_status === 'cancelled' ? 'danger' : 'info'"
|
|
size="small"
|
|
>{{ getReceiptStatusLabel(row.receipt_status) }}</t-tag>
|
|
<t-tag
|
|
:type="row.payment_status === 'paid' ? 'success' : 'info'"
|
|
size="small"
|
|
>{{ getPaymentStatusLabel(row.payment_status) }}</t-tag>
|
|
</div>
|
|
</template>
|
|
</t-table-column>
|
|
<t-table-column label="订单创建">
|
|
<template #default="{ row }">{{ formatDateTime(row.created_at) }}</template>
|
|
</t-table-column>
|
|
<t-table-column label="预计到货">
|
|
<template #default="{ row }">{{ formatDate(row.expected_date) }}</template>
|
|
</t-table-column>
|
|
<t-table-column label="实际到货">
|
|
<template #default="{ row }">{{ row.received_at ? formatDateTime(row.received_at) : '-' }}</template>
|
|
</t-table-column>
|
|
<t-table-column label="实际付款">
|
|
<template #default="{ row }">{{ row.paid_at ? formatDateTime(row.paid_at) : '-' }}</template>
|
|
</t-table-column>
|
|
<t-table-column label="总金额">
|
|
<template #default="{ row }">{{ formatCurrency(row.total_amount) }}</template>
|
|
</t-table-column>
|
|
<t-table-column label="已付款">
|
|
<template #default="{ row }">{{ formatCurrency(row.paid_amount || 0) }}</template>
|
|
</t-table-column>
|
|
<t-table-column label="操作" width="280">
|
|
<template #default="{ row }">
|
|
<t-button
|
|
type="primary"
|
|
size="small"
|
|
:disabled="isPurchaseOrderLocked(row.status)"
|
|
@click="editOrder(row)"
|
|
>
|
|
编辑
|
|
</t-button>
|
|
<t-button
|
|
type="danger"
|
|
size="small"
|
|
:disabled="isPurchaseOrderLocked(row.status)"
|
|
@click="deleteOrder(row.id)"
|
|
>
|
|
删除
|
|
</t-button>
|
|
<t-button
|
|
v-if="row.receipt_status === 'pending' || row.receipt_status === 'partial_received'"
|
|
type="success"
|
|
size="small"
|
|
@click="openReceiveDialog(row)"
|
|
>
|
|
到货入库
|
|
</t-button>
|
|
<t-button
|
|
v-if="row.payment_status !== 'paid' && row.receipt_status !== 'cancelled'"
|
|
type="warning"
|
|
size="small"
|
|
@click="markAsPaid(row.id, row.receipt_status)"
|
|
>
|
|
标记已付款
|
|
</t-button>
|
|
<t-button
|
|
v-if="row.payment_status !== 'paid' && row.receipt_status !== 'cancelled'"
|
|
type="info"
|
|
size="small"
|
|
@click="cancelOrder(row.id)"
|
|
>
|
|
作废
|
|
</t-button>
|
|
</template>
|
|
</t-table-column>
|
|
</t-table>
|
|
<t-empty v-if="!state.loading && state.purchaseOrders.length === 0" description="暂无采购订单" />
|
|
|
|
<t-dialog
|
|
v-model:visible="showModal"
|
|
:title="(editingItem ? '编辑' : '新增') + '采购订单'"
|
|
width="800px"
|
|
@closed="closeModal"
|
|
>
|
|
<t-form label-width="100px">
|
|
<t-form-item label="供应商" required>
|
|
<t-select v-model="form.supplier_id" placeholder="请选择供应商" style="width:100%">
|
|
<t-option
|
|
v-for="supplier in state.suppliers"
|
|
:key="supplier.id"
|
|
:label="supplier.name"
|
|
:value="supplier.id"
|
|
/>
|
|
</t-select>
|
|
</t-form-item>
|
|
<t-form-item label="预计到货">
|
|
<t-date-picker
|
|
v-model="form.expected_date"
|
|
type="date"
|
|
placeholder="选择日期"
|
|
style="width:100%"
|
|
value-format="YYYY-MM-DD"
|
|
/>
|
|
</t-form-item>
|
|
<t-form-item label="备注">
|
|
<t-input v-model="form.remark" placeholder="备注信息" />
|
|
</t-form-item>
|
|
</t-form>
|
|
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
|
|
<span style="font-weight: 600;">物料明细</span>
|
|
<t-button type="primary" size="small" @click="addOrderItem">+ 添加物料</t-button>
|
|
</div>
|
|
<t-table :data="orderItems" border size="small">
|
|
<t-table-column label="物料" min-width="200">
|
|
<template #default="{ row: line }">
|
|
<t-select v-model="line.product_id" placeholder="请选择物料" style="width:100%">
|
|
<t-option
|
|
v-for="material in state.materials"
|
|
:key="material.id"
|
|
:label="`${material.sku} - ${material.name}`"
|
|
:value="material.id"
|
|
/>
|
|
</t-select>
|
|
</template>
|
|
</t-table-column>
|
|
<t-table-column label="数量" width="120">
|
|
<template #default="{ row: line }">
|
|
<t-input-number v-model="line.quantity" :min="0.0001" :step="0.0001" size="small" style="width:100%" />
|
|
</template>
|
|
</t-table-column>
|
|
<t-table-column label="单价" width="120">
|
|
<template #default="{ row: line }">
|
|
<t-input-number
|
|
:model-value="(state.materials.find((m: any) => m.id === line.product_id)?.cost_price || 0)"
|
|
disabled
|
|
size="small"
|
|
style="width:100%"
|
|
/>
|
|
</template>
|
|
</t-table-column>
|
|
<t-table-column label="总价" width="120">
|
|
<template #default="{ row: line }">
|
|
{{ formatCurrency((state.materials.find((m: any) => m.id === line.product_id)?.cost_price || 0) * (line.quantity || 0)) }}
|
|
</template>
|
|
</t-table-column>
|
|
<t-table-column label="备注" width="150">
|
|
<template #default="{ row: line }">
|
|
<t-input v-model="line.remark" size="small" placeholder="备注" />
|
|
</template>
|
|
</t-table-column>
|
|
<t-table-column label="操作" width="80">
|
|
<template #default="{ $index }">
|
|
<t-button type="danger" size="small" @click="removeOrderItem($index)">删除</t-button>
|
|
</template>
|
|
</t-table-column>
|
|
</t-table>
|
|
<div style="text-align: right; margin-top: 8px; font-weight: 600;">
|
|
订单总金额:{{ formatCurrency(orderTotalAmount) }}
|
|
</div>
|
|
<template #footer>
|
|
<t-button @click="showModal = false" :disabled="saving">取消</t-button>
|
|
<t-button type="primary" :loading="saving" :disabled="saving" @click="savePurchaseOrder">保存</t-button>
|
|
</template>
|
|
</t-dialog>
|
|
|
|
<t-dialog
|
|
v-model:visible="showReceiveModal"
|
|
title="采购到货入库"
|
|
width="700px"
|
|
@closed="closeReceiveModal"
|
|
>
|
|
<t-form label-width="100px">
|
|
<t-form-item label="入库仓库" required>
|
|
<t-select v-model="receiveWarehouseId" placeholder="请选择仓库" style="width:100%">
|
|
<t-option
|
|
v-for="warehouse in state.warehouses"
|
|
:key="warehouse.id"
|
|
:label="warehouse.name"
|
|
:value="warehouse.id"
|
|
/>
|
|
</t-select>
|
|
</t-form-item>
|
|
<t-form-item label="备注">
|
|
<t-input v-model="receiveRemark" placeholder="入库备注" />
|
|
</t-form-item>
|
|
</t-form>
|
|
<t-table :data="receiveItems" border size="small">
|
|
<t-table-column prop="material_label" label="物料" />
|
|
<t-table-column prop="id" label="明细ID" width="80" />
|
|
<t-table-column label="剩余待入库" width="100">
|
|
<template #default="{ row: item }">{{ item.remaining }}</template>
|
|
</t-table-column>
|
|
<t-table-column label="本次入库" width="140">
|
|
<template #default="{ row: item }">
|
|
<t-input-number
|
|
v-model="item.receive_quantity"
|
|
:min="0"
|
|
:max="item.remaining"
|
|
size="small"
|
|
style="width:100%"
|
|
/>
|
|
</template>
|
|
</t-table-column>
|
|
</t-table>
|
|
<template #footer>
|
|
<t-button @click="showReceiveModal = false" :disabled="receiving">取消</t-button>
|
|
<t-button type="primary" :loading="receiving" :disabled="receiving" @click="receivePurchaseOrder">确认入库</t-button>
|
|
</template>
|
|
</t-dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
</style>
|