497 lines
17 KiB
TypeScript
497 lines
17 KiB
TypeScript
import { reactive, ref, computed } from 'vue'
|
|
import { apiRequest } from '@/shared/api'
|
|
import { addNotification, handleApiError } from '@/shared/notification'
|
|
import { formatCurrency, formatNumber, formatDateTime, formatDate } from '@/shared/utils'
|
|
|
|
declare const AirDatepicker: any
|
|
|
|
const state = reactive({
|
|
activeTab: 'dashboard' as string,
|
|
backendDbReady: true,
|
|
backendDbMessage: '' as string,
|
|
productCategory: 'finished' as string,
|
|
dashboard: null as any,
|
|
financeSummary: null as any,
|
|
financePeriod: {
|
|
year: new Date().getFullYear(),
|
|
quarter: '' as string
|
|
},
|
|
financeTransactions: [] as any[],
|
|
receivables: [] as any[],
|
|
payables: [] as any[],
|
|
customerFinanceStatement: [] as any[],
|
|
supplierFinanceStatement: [] as any[],
|
|
customerProductStatement: [] as any[],
|
|
supplierProductStatement: [] as any[],
|
|
products: [] as any[],
|
|
materials: [] as any[],
|
|
finishedProducts: [] as any[],
|
|
purchaseOrders: [] as any[],
|
|
purchaseWarehouseId: null as number | null,
|
|
purchaseReceiveItems: [] as any[],
|
|
productionOrders: [] as any[],
|
|
productionPlan: null as any,
|
|
productionWarehouseId: null as number | null,
|
|
suppliers: [] as any[],
|
|
customers: [] as any[],
|
|
warehouses: [] as any[],
|
|
inventory: [] as any[],
|
|
movements: [] as any[],
|
|
loading: false,
|
|
showModal: false,
|
|
modalType: '' as string,
|
|
editingItem: null as any,
|
|
productBomItems: [] as any[],
|
|
materialConsumptionItems: [] as any[],
|
|
showMaterialConsumptionModal: false,
|
|
consumedMaterials: [] as any[],
|
|
restockItems: [] as any[],
|
|
showRestockModal: false,
|
|
form: {} as any
|
|
})
|
|
|
|
let deliveryPicker: any = null
|
|
let expectedPicker: any = null
|
|
|
|
const deliveryDateInput = ref<HTMLElement | null>(null)
|
|
const expectedDateInput = ref<HTMLElement | null>(null)
|
|
const deliveryDateNativeInput = ref<HTMLElement | null>(null)
|
|
const expectedDateNativeInput = ref<HTMLElement | null>(null)
|
|
|
|
export function useInventory() {
|
|
const parseDateTimeLocal = (text: string | null | undefined): Date | null => {
|
|
if (!text) return null
|
|
const raw = String(text).trim()
|
|
const normalized = raw.replace('T', ' ').slice(0, 16)
|
|
const m = normalized.match(/^(\d{4})-(\d{2})-(\d{2})\s(\d{2}):(\d{2})$/)
|
|
if (!m) return null
|
|
const year = Number(m[1])
|
|
const month = Number(m[2])
|
|
const day = Number(m[3])
|
|
const hour = Number(m[4])
|
|
const minute = Number(m[5])
|
|
if (!Number.isFinite(year + month + day + hour + minute)) return null
|
|
return new Date(year, month - 1, day, hour, minute, 0)
|
|
}
|
|
|
|
const toPickerValue = (value: any): string => {
|
|
if (!value) return ''
|
|
const raw = String(value).trim()
|
|
if (/^\d{4}-\d{2}-\d{2}\s\d{2}:\d{2}/.test(raw)) return raw.slice(0, 16)
|
|
if (raw.includes('T')) return raw.replace('T', ' ').slice(0, 16)
|
|
const dt = new Date(raw)
|
|
if (!Number.isFinite(dt.getTime())) return ''
|
|
const pad = (n: number) => String(n).padStart(2, '0')
|
|
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())} ${pad(dt.getHours())}:${pad(dt.getMinutes())}`
|
|
}
|
|
|
|
const toApiDateTime = (value: any): string | null => {
|
|
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]
|
|
if (text.length === 10) return 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}`
|
|
}
|
|
return text
|
|
}
|
|
|
|
const toNativeValue = (value: any): string => {
|
|
if (!value) return ''
|
|
const text = String(value).trim()
|
|
if (text.length === 10 && !text.includes('T') && !text.includes(' ')) return text
|
|
const isoText = text.replace(' ', 'T')
|
|
return isoText.length >= 16 ? isoText.slice(0, 16) : isoText
|
|
}
|
|
|
|
const fromNativeValue = (value: any): string => {
|
|
if (!value) return ''
|
|
const text = String(value).trim()
|
|
if (text.length === 10 && !text.includes('T') && !text.includes(' ')) return text
|
|
return text.replace('T', ' ').slice(0, 16)
|
|
}
|
|
|
|
const destroyPickers = () => {
|
|
if (deliveryPicker) {
|
|
deliveryPicker.destroy()
|
|
deliveryPicker = null
|
|
}
|
|
if (expectedPicker) {
|
|
expectedPicker.destroy()
|
|
expectedPicker = null
|
|
}
|
|
}
|
|
|
|
const initPickers = () => {
|
|
destroyPickers()
|
|
if (typeof AirDatepicker !== 'function') return
|
|
|
|
if (state.modalType === 'salesOrder' && deliveryDateInput.value) {
|
|
deliveryPicker = new AirDatepicker(deliveryDateInput.value, {
|
|
timepicker: false,
|
|
autoClose: true,
|
|
zIndex: 2005,
|
|
dateFormat: 'yyyy-MM-dd',
|
|
onSelect: ({ formattedDate }: any) => {
|
|
state.form.delivery_date = formattedDate || ''
|
|
state.form.delivery_date_native = toNativeValue(formattedDate || '')
|
|
}
|
|
})
|
|
const initial = parseDateTimeLocal(state.form.delivery_date)
|
|
if (initial) deliveryPicker.selectDate(initial, { silent: true })
|
|
}
|
|
|
|
if (state.modalType === 'purchaseOrder' && expectedDateInput.value) {
|
|
expectedPicker = new AirDatepicker(expectedDateInput.value, {
|
|
timepicker: false,
|
|
autoClose: true,
|
|
zIndex: 2005,
|
|
dateFormat: 'yyyy-MM-dd',
|
|
onSelect: ({ formattedDate }: any) => {
|
|
state.form.expected_date = formattedDate || ''
|
|
state.form.expected_date_native = toNativeValue(formattedDate || '')
|
|
}
|
|
})
|
|
const initial = parseDateTimeLocal(state.form.expected_date)
|
|
if (initial) expectedPicker.selectDate(initial, { silent: true })
|
|
}
|
|
}
|
|
|
|
const openDateTimePicker = (pickerKind: string) => {
|
|
if (pickerKind === 'delivery' && deliveryPicker) {
|
|
deliveryPicker.show()
|
|
return
|
|
}
|
|
if (pickerKind === 'expected' && expectedPicker) {
|
|
expectedPicker.show()
|
|
return
|
|
}
|
|
|
|
const nativeInput = pickerKind === 'delivery' ? deliveryDateNativeInput.value : expectedDateNativeInput.value
|
|
if (!nativeInput) return
|
|
if (typeof (nativeInput as any).showPicker === 'function') {
|
|
(nativeInput as any).showPicker()
|
|
return
|
|
}
|
|
nativeInput.focus()
|
|
nativeInput.click()
|
|
}
|
|
|
|
const getMovementTypeLabel = (movementType: string): string => {
|
|
const movementLabelMap: Record<string, string> = {
|
|
in: '其他入库',
|
|
out: '其他出库',
|
|
adjust: '库存调整',
|
|
purchase_in: '采购入库',
|
|
return_from_production: '生产退料入库',
|
|
outsource_return: '外协回库',
|
|
finish_in: '完工入库',
|
|
issue_to_production: '生产领料出库',
|
|
outsource_send: '外协发料出库',
|
|
shipment_out: '销售出库',
|
|
scrap_out: '报废出库'
|
|
}
|
|
return movementLabelMap[movementType] || movementType
|
|
}
|
|
|
|
const getMovementBadgeClass = (movementType: string): string => {
|
|
if (['purchase_in', 'return_from_production', 'outsource_return', 'finish_in', 'in'].includes(movementType)) {
|
|
return 'badge-success'
|
|
}
|
|
if (['issue_to_production', 'outsource_send', 'shipment_out', 'scrap_out', 'out'].includes(movementType)) {
|
|
return 'badge-error'
|
|
}
|
|
return 'badge-warning'
|
|
}
|
|
|
|
const getPurchaseOrderStatusLabel = (status: string): string => {
|
|
const statusMap: Record<string, string> = {
|
|
draft: '已下单',
|
|
pending: '已下单',
|
|
received: '已收货',
|
|
paid: '已付款'
|
|
}
|
|
return statusMap[status] || status
|
|
}
|
|
|
|
const isPurchaseOrderLocked = (status: string): boolean => {
|
|
return ['received', 'paid'].includes(status)
|
|
}
|
|
|
|
const getSalesOrderStatusLabel = (status: string): string => {
|
|
const statusMap: Record<string, string> = {
|
|
manufacturing: '制造中',
|
|
delivered: '已交付',
|
|
paid: '已收款'
|
|
}
|
|
return statusMap[status] || status
|
|
}
|
|
|
|
const checkBackendHealth = async () => {
|
|
try {
|
|
const resp = await fetch('/health', { method: 'GET' })
|
|
if (!resp.ok) {
|
|
state.backendDbReady = false
|
|
state.backendDbMessage = '后端服务异常,暂无法加载业务数据'
|
|
return
|
|
}
|
|
const health = await resp.json().catch(() => null)
|
|
if (health && health.database_connected === false) {
|
|
state.backendDbReady = false
|
|
state.backendDbMessage = '数据库未连接,当前仅可浏览界面,业务数据暂不可用'
|
|
return
|
|
}
|
|
state.backendDbReady = true
|
|
state.backendDbMessage = ''
|
|
} catch {
|
|
state.backendDbReady = false
|
|
state.backendDbMessage = '无法连接后端服务'
|
|
}
|
|
}
|
|
|
|
const loadDashboard = async () => {
|
|
state.loading = true
|
|
try { state.dashboard = await apiRequest('/api/dashboard') }
|
|
catch (e) { handleApiError(e, '加载仪表盘') }
|
|
finally { state.loading = false }
|
|
}
|
|
|
|
const loadFinishedProducts = async () => {
|
|
state.loading = true
|
|
try { state.finishedProducts = await apiRequest('/api/products?item_type=finished&limit=100') }
|
|
catch (e) { handleApiError(e, '加载成品') }
|
|
finally { state.loading = false }
|
|
}
|
|
|
|
const loadProducts = async () => { await loadFinishedProducts() }
|
|
|
|
const loadMaterials = async () => {
|
|
state.loading = true
|
|
try { state.materials = await apiRequest('/api/products?item_type=material&limit=100') }
|
|
catch (e) { handleApiError(e, '加载物料') }
|
|
finally { state.loading = false }
|
|
}
|
|
|
|
const loadWarehouses = async () => {
|
|
state.loading = true
|
|
try { state.warehouses = await apiRequest('/api/warehouses') }
|
|
catch (e) { handleApiError(e, '加载仓库') }
|
|
finally { state.loading = false }
|
|
}
|
|
|
|
const ensureStockBaseData = async () => {
|
|
if (!state.materials.length) await loadMaterials()
|
|
if (!state.warehouses.length) await loadWarehouses()
|
|
if (!state.warehouses.length) {
|
|
try {
|
|
await apiRequest('/api/warehouses', { method: 'POST', body: JSON.stringify({ name: '默认仓库' }) })
|
|
await loadWarehouses()
|
|
addNotification('已自动创建默认仓库', 'success')
|
|
} catch (e) { handleApiError(e, '自动创建默认仓库') }
|
|
}
|
|
}
|
|
|
|
const loadSuppliers = async () => {
|
|
state.loading = true
|
|
try { state.suppliers = await apiRequest('/api/suppliers') }
|
|
catch (e) { handleApiError(e, '加载供应商') }
|
|
finally { state.loading = false }
|
|
}
|
|
|
|
const loadProductionOrders = async () => {
|
|
state.loading = true
|
|
try {
|
|
const [orders, warehouses] = await Promise.all([
|
|
apiRequest('/api/sales-orders?limit=100'),
|
|
apiRequest('/api/warehouses')
|
|
])
|
|
state.productionOrders = orders?.items || []
|
|
state.warehouses = warehouses || []
|
|
if (!state.productionWarehouseId) {
|
|
state.productionWarehouseId = state.warehouses.find((w: any) => w.is_default)?.id || state.warehouses[0]?.id || null
|
|
}
|
|
} catch (e) { handleApiError(e, '加载按单生产数据') }
|
|
finally { state.loading = false }
|
|
}
|
|
|
|
const loadPurchaseOrders = async () => {
|
|
state.loading = true
|
|
try {
|
|
const [orders, warehouses] = await Promise.all([
|
|
apiRequest('/api/purchase-orders?limit=100'),
|
|
apiRequest('/api/warehouses')
|
|
])
|
|
state.purchaseOrders = orders?.items || []
|
|
state.warehouses = warehouses || []
|
|
if (!state.purchaseWarehouseId) {
|
|
state.purchaseWarehouseId = state.warehouses.find((w: any) => w.is_default)?.id || state.warehouses[0]?.id || null
|
|
}
|
|
} catch (e) { handleApiError(e, '加载采购订单') }
|
|
finally { state.loading = false }
|
|
}
|
|
|
|
const loadCustomers = async () => {
|
|
state.loading = true
|
|
try { state.customers = await apiRequest('/api/customers') }
|
|
catch (e) { handleApiError(e, '加载客户') }
|
|
finally { state.loading = false }
|
|
}
|
|
|
|
const loadInventory = async () => {
|
|
state.loading = true
|
|
try { state.inventory = (await apiRequest('/api/inventory'))?.items || [] }
|
|
catch (e) { handleApiError(e, '加载库存') }
|
|
finally { state.loading = false }
|
|
}
|
|
|
|
const loadMovements = async () => {
|
|
state.loading = true
|
|
try { state.movements = (await apiRequest('/api/stock-movements'))?.items || [] }
|
|
catch (e) { handleApiError(e, '加载变动记录') }
|
|
finally { state.loading = false }
|
|
}
|
|
|
|
const loadFinance = async () => {
|
|
state.loading = true
|
|
try {
|
|
const selectedYear = Number(state.financePeriod.year) || new Date().getFullYear()
|
|
const selectedQuarter = state.financePeriod.quarter ? Number(state.financePeriod.quarter) : null
|
|
const periodQuery = selectedQuarter
|
|
? `year=${selectedYear}&quarter=${selectedQuarter}`
|
|
: `year=${selectedYear}`
|
|
const [summary, transactions, receivables, payables, customerStatement, supplierStatement, customerProductStatement, supplierProductStatement] = await Promise.all([
|
|
apiRequest(`/api/finance/summary?${periodQuery}`),
|
|
apiRequest(`/api/finance/transactions?status=confirmed&limit=20&${periodQuery}`),
|
|
apiRequest(`/api/finance/receivables?limit=20&${periodQuery}`),
|
|
apiRequest(`/api/finance/payables?limit=20&${periodQuery}`),
|
|
apiRequest(`/api/finance/partner-statement/customer?${periodQuery}`),
|
|
apiRequest(`/api/finance/partner-statement/supplier?${periodQuery}`),
|
|
apiRequest(`/api/finance/partner-product-statement/customer?${periodQuery}`),
|
|
apiRequest(`/api/finance/partner-product-statement/supplier?${periodQuery}`)
|
|
])
|
|
state.financeSummary = summary
|
|
state.financeTransactions = transactions
|
|
state.receivables = receivables
|
|
state.payables = payables
|
|
state.customerFinanceStatement = customerStatement.items || []
|
|
state.supplierFinanceStatement = supplierStatement.items || []
|
|
state.customerProductStatement = customerProductStatement.items || []
|
|
state.supplierProductStatement = supplierProductStatement.items || []
|
|
} catch (e) { handleApiError(e, '加载财务数据') }
|
|
finally { state.loading = false }
|
|
}
|
|
|
|
const refreshFinanceByPeriod = () => {
|
|
if (state.activeTab === 'finance') loadFinance()
|
|
}
|
|
|
|
const switchTab = (tab: string) => {
|
|
state.activeTab = tab
|
|
}
|
|
|
|
const closeModal = () => {
|
|
state.showModal = false
|
|
state.modalType = ''
|
|
state.editingItem = null
|
|
state.productBomItems = []
|
|
state.purchaseReceiveItems = []
|
|
state.form = {}
|
|
destroyPickers()
|
|
}
|
|
|
|
const modalTitle = computed(() => {
|
|
const prefix = state.editingItem ? '编辑' : '新增'
|
|
const typeMap: Record<string, string> = {
|
|
product: state.form.item_type === 'finished' ? '成品' : '物料',
|
|
inventoryItem: '物料库存',
|
|
salesOrder: '销售订单',
|
|
purchaseOrder: '采购订单',
|
|
purchaseReceive: '采购到货入库',
|
|
supplier: '供应商',
|
|
customer: '客户'
|
|
}
|
|
return prefix + (typeMap[state.modalType] || '')
|
|
})
|
|
|
|
const menuGroups = [
|
|
{ key: 'overview', title: '概览', items: [{ key: 'dashboard', label: '仪表盘' }] },
|
|
{ key: 'sales', title: '销售', items: [{ key: 'sales_orders', label: '销售订单管理' }] },
|
|
{ key: 'purchase', title: '采购', items: [{ key: 'purchases', label: '采购订单管理' }] },
|
|
{ key: 'product', title: '产品', items: [{ key: 'products', label: '成品管理' }, { key: 'materials', label: '物料管理' }] },
|
|
{ key: 'partner', title: '往来单位', items: [{ key: 'customers', label: '客户管理' }, { key: 'suppliers', label: '供应商管理' }] },
|
|
{ key: 'warehouse', title: '仓库', items: [{ key: 'inventory', label: '库存管理' }, { key: 'movements', label: '库存变动记录' }] },
|
|
{ key: 'finance', title: '财务', items: [{ key: 'finance', label: '财务概览' }] }
|
|
]
|
|
|
|
const openGroups = reactive<Record<string, boolean>>(
|
|
Object.fromEntries(menuGroups.map(g => [g.key, true]))
|
|
)
|
|
|
|
const toggleGroup = (groupKey: string) => { openGroups[groupKey] = !openGroups[groupKey] }
|
|
|
|
const activeMenu = computed(() => {
|
|
for (const group of menuGroups) {
|
|
const item = group.items.find(i => i.key === state.activeTab)
|
|
if (item) return { group, item }
|
|
}
|
|
return null
|
|
})
|
|
|
|
const handleMenuClick = (itemKey: string) => { switchTab(itemKey) }
|
|
|
|
const switchProductCategory = (category: string) => { state.productCategory = category }
|
|
|
|
return {
|
|
state,
|
|
deliveryDateInput,
|
|
expectedDateInput,
|
|
deliveryDateNativeInput,
|
|
expectedDateNativeInput,
|
|
menuGroups,
|
|
openGroups,
|
|
activeMenu,
|
|
modalTitle,
|
|
parseDateTimeLocal,
|
|
toPickerValue,
|
|
toApiDateTime,
|
|
toNativeValue,
|
|
fromNativeValue,
|
|
destroyPickers,
|
|
initPickers,
|
|
openDateTimePicker,
|
|
getMovementTypeLabel,
|
|
getMovementBadgeClass,
|
|
getPurchaseOrderStatusLabel,
|
|
isPurchaseOrderLocked,
|
|
getSalesOrderStatusLabel,
|
|
checkBackendHealth,
|
|
loadDashboard,
|
|
loadFinishedProducts,
|
|
loadProducts,
|
|
loadMaterials,
|
|
loadWarehouses,
|
|
ensureStockBaseData,
|
|
loadSuppliers,
|
|
loadProductionOrders,
|
|
loadPurchaseOrders,
|
|
loadCustomers,
|
|
loadInventory,
|
|
loadMovements,
|
|
loadFinance,
|
|
refreshFinanceByPeriod,
|
|
switchTab,
|
|
closeModal,
|
|
toggleGroup,
|
|
handleMenuClick,
|
|
switchProductCategory,
|
|
formatCurrency,
|
|
formatNumber,
|
|
formatDateTime,
|
|
formatDate
|
|
}
|
|
}
|