This commit is contained in:
2026-07-30 10:30:50 +08:00
parent cf6d708566
commit 853c478657
85 changed files with 4711 additions and 1052 deletions
@@ -1,48 +1,45 @@
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { onMounted, onUnmounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { addNotification } from '@/shared/notification'
import { useInventory } from '@/modules/inventory/composables/useInventory'
import { useInventoryStore } from '@/stores/inventory'
import InventorySidebar from './components/InventorySidebar.vue'
import DashboardTab from './components/DashboardTab.vue'
import ProductsTab from './components/ProductsTab.vue'
import MaterialsTab from './components/MaterialsTab.vue'
import InventoryTab from './components/InventoryTab.vue'
import PurchaseOrdersTab from './components/PurchaseOrdersTab.vue'
import SalesOrdersTab from './components/SalesOrdersTab.vue'
import SuppliersTab from './components/SuppliersTab.vue'
import CustomersTab from './components/CustomersTab.vue'
import FinanceTab from './components/FinanceTab.vue'
import MovementsTab from './components/MovementsTab.vue'
const store = useAppStore()
const store = useInventoryStore()
const appStore = useAppStore()
const router = useRouter()
const {
state,
activeMenu,
checkBackendHealth,
loadDashboard,
destroyPickers
} = useInventory()
const route = useRoute()
// Derive activeTab from route path (last segment)
const tabFromRoute = () => {
const segments = route.path.split('/')
return segments[segments.length - 1] || 'dashboard'
}
// Sync store.activeTab when route changes
watch(() => route.path, () => {
store.activeTab = tabFromRoute()
}, { immediate: true })
onMounted(() => {
if (!store.user) {
if (!appStore.user) {
router.push('/login')
return
}
checkBackendHealth().then(() => {
if (!state.backendDbReady) {
addNotification(state.backendDbMessage || '业务服务不可用', 'warning')
store.activeTab = tabFromRoute()
store.checkBackendHealth().then(() => {
if (!store.backendDbReady) {
addNotification(store.backendDbMessage || '业务服务不可用', 'warning')
return
}
loadDashboard()
store.loadDashboard()
})
})
onUnmounted(() => {
destroyPickers()
store.destroyPickers()
})
</script>
@@ -61,32 +58,23 @@ onUnmounted(() => {
<section class="inventory-content">
<div class="inventory-content-header">
<div class="inventory-breadcrumb">
<span>{{ activeMenu?.group?.title || '进销存' }}</span>
<span>{{ store.activeMenu?.group?.title || '进销存' }}</span>
<span class="sep">/</span>
<span class="current">{{ activeMenu?.item?.label || '' }}</span>
<span class="current">{{ store.activeMenu?.item?.label || '' }}</span>
</div>
</div>
<t-alert
v-if="!state.backendDbReady"
:title="state.backendDbMessage"
v-if="!store.backendDbReady"
:title="store.backendDbMessage"
theme="warning"
show-icon
:closable="false"
style="margin-bottom: 16px;"
/>
<t-loading :loading="state.loading">
<DashboardTab v-if="state.activeTab === 'dashboard'" />
<ProductsTab v-else-if="state.activeTab === 'products'" />
<MaterialsTab v-else-if="state.activeTab === 'materials'" />
<InventoryTab v-else-if="state.activeTab === 'inventory'" />
<PurchaseOrdersTab v-else-if="state.activeTab === 'purchases'" />
<SalesOrdersTab v-else-if="state.activeTab === 'sales_orders'" />
<SuppliersTab v-else-if="state.activeTab === 'suppliers'" />
<CustomersTab v-else-if="state.activeTab === 'customers'" />
<FinanceTab v-else-if="state.activeTab === 'finance'" />
<MovementsTab v-else-if="state.activeTab === 'movements'" />
<t-loading :loading="store.loading">
<router-view />
</t-loading>
</section>
</div>
@@ -1,7 +1,15 @@
<script setup lang="ts">
import { useInventory } from '@/modules/inventory/composables/useInventory'
import { useRouter } from 'vue-router'
import { useInventoryStore } from '@/stores/inventory'
const { state, menuGroups, openGroups, toggleGroup, handleMenuClick } = useInventory()
const router = useRouter()
const store = useInventoryStore()
const { menuGroups, openGroups, toggleGroup } = store
const navigateTo = (itemKey: string) => {
store.activeTab = itemKey
router.push(`/inventory/${itemKey}`)
}
</script>
<template>
@@ -15,8 +23,8 @@ const { state, menuGroups, openGroups, toggleGroup, handleMenuClick } = useInven
<div
v-for="item in group.items"
:key="item.key"
:class="['sidebar-item', { active: state.activeTab === item.key }]"
@click="handleMenuClick(item.key)"
:class="['sidebar-item', { active: store.activeTab === item.key }]"
@click="navigateTo(item.key)"
>
{{ item.label }}
</div>
@@ -3,7 +3,9 @@ import { ref, computed, onMounted } from 'vue'
import { DialogPlugin } from 'tdesign-vue-next'
import { useInventory } from '../composables/useInventory'
import { apiRequest } from '@/shared/api'
import { inventoryApi } from '@/shared/api-client'
import { addNotification, handleApiError } from '@/shared/notification'
import type { Schema } from '@/types/schemas'
function confirmDialog(header: string, body: string, theme: string, confirmText: string, cancelText: string): Promise<boolean> {
return new Promise((resolve) => {
@@ -47,6 +49,46 @@ const receiveWarehouseId = ref<number | null>(null)
const receiveRemark = ref('')
const receiving = ref(false)
// ── 采购需求推导 ──
const showDemandModal = ref(false)
const demandLoading = ref(false)
const salesOrdersForDemand = ref<Schema<'SalesOrderResponse'>[]>([])
const selectedSalesOrderIds = ref<number[]>([])
const demandResult = ref<Schema<'PurchaseDemandResponse'> | null>(null)
async function openDemandDialog() {
showDemandModal.value = true
demandResult.value = null
selectedSalesOrderIds.value = []
try {
const data = await inventoryApi.listSalesOrders({ limit: 100 })
salesOrdersForDemand.value = (data as any)?.items || data || []
} catch (e) {
handleApiError(e, '加载销售订单')
}
}
async function calculateDemands() {
if (!selectedSalesOrderIds.value.length) {
addNotification('请选择至少一个销售订单', 'warning')
return
}
demandLoading.value = true
try {
demandResult.value = await inventoryApi.calculatePurchaseDemands(selectedSalesOrderIds.value)
} catch (e) {
handleApiError(e, '计算采购需求')
} finally {
demandLoading.value = false
}
}
function closeDemandModal() {
showDemandModal.value = false
demandResult.value = null
selectedSalesOrderIds.value = []
}
function openCreateOrder() {
editingItem.value = null
form.value = {
@@ -299,6 +341,7 @@ onMounted(() => {
</t-select>
<t-button @click="loadPurchaseOrders">刷新</t-button>
<t-button type="primary" @click="openCreateOrder">新增采购订单</t-button>
<t-button theme="success" variant="outline" @click="openDemandDialog">采购建议</t-button>
</div>
<t-table :data="state.purchaseOrders" :loading="state.loading" stripe>
@@ -515,6 +558,87 @@ onMounted(() => {
<t-button type="primary" :loading="receiving" :disabled="receiving" @click="receivePurchaseOrder">确认入库</t-button>
</template>
</t-dialog>
<!-- 采购需求推导对话框 -->
<t-dialog
v-model:visible="showDemandModal"
title="采购需求推导"
width="1000px"
@closed="closeDemandModal"
>
<div style="margin-bottom: 16px;">
<p style="margin-bottom: 8px; color: var(--td-text-color-secondary);">
选择销售订单,系统将自动按 BOM 展开物料需求、对比库存、推荐供应商。
</p>
<div style="display: flex; gap: 12px; align-items: center;">
<t-select
v-model="selectedSalesOrderIds"
multiple
placeholder="请选择销售订单"
style="flex: 1;"
:loading="salesOrdersForDemand.length === 0"
>
<t-option
v-for="order in salesOrdersForDemand"
:key="order.id"
:label="`${order.order_no} - ${order.customer_name || ''}`"
:value="order.id"
/>
</t-select>
<t-button
type="primary"
:loading="demandLoading"
:disabled="!selectedSalesOrderIds.length"
@click="calculateDemands"
>
计算
</t-button>
</div>
</div>
<template v-if="demandResult">
<div style="display: flex; gap: 24px; margin-bottom: 12px; font-weight: 600;">
<span>来源订单:{{ demandResult.source_order_nos?.join(', ') || '-' }}</span>
<span>缺货物料:<t-tag :type="demandResult.shortage_count > 0 ? 'danger' : 'success'" size="small">{{ demandResult.shortage_count }}</t-tag></span>
<span>预计采购总额:{{ formatCurrency(Number(demandResult.total_estimated_cost || 0)) }}</span>
</div>
<t-table :data="demandResult.items || []" stripe border size="small" max-height="400">
<t-table-column prop="material_sku" label="物料SKU" width="120" />
<t-table-column prop="material_name" label="物料名称" min-width="140" />
<t-table-column label="需求量" width="90">
<template #default="{ row }">{{ row.required_quantity }}</template>
</t-table-column>
<t-table-column label="库存量" width="90">
<template #default="{ row }">{{ row.available_quantity }}</template>
</t-table-column>
<t-table-column label="缺口" width="90">
<template #default="{ row }">
<t-tag :type="row.shortage_quantity > 0 ? 'danger' : 'success'" size="small">
{{ row.shortage_quantity }}
</t-tag>
</template>
</t-table-column>
<t-table-column label="单价" width="90">
<template #default="{ row }">{{ formatCurrency(Number(row.unit_cost || 0)) }}</template>
</t-table-column>
<t-table-column label="预计金额" width="110">
<template #default="{ row }">{{ formatCurrency(Number(row.estimated_cost || 0)) }}</template>
</t-table-column>
<t-table-column prop="suggested_supplier_name" label="建议供应商" min-width="120">
<template #default="{ row }">{{ row.suggested_supplier_name || '-' }}</template>
</t-table-column>
<t-table-column label="交期(天)" width="80">
<template #default="{ row }">{{ row.supplier_lead_time ?? '-' }}</template>
</t-table-column>
</t-table>
<t-empty v-if="(demandResult.items || []).length === 0" description="无物料需求(BOM 为空或订单无明细)" />
</template>
<template #footer>
<t-button @click="showDemandModal = false">关闭</t-button>
</template>
</t-dialog>
</div>
</template>
@@ -1,518 +1,10 @@
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'
import type { Schema } from '@/types/schemas'
declare const AirDatepicker: any
const state = reactive({
activeTab: 'dashboard' as string,
backendDbReady: true,
backendDbMessage: '' as string,
productCategory: 'finished' as string,
dashboard: null as any, // 后端 /api/dashboard 无 response_model,暂未生成类型
financeSummary: null as Schema<'FinanceSummaryResponse'> | null,
financePeriod: {
year: new Date().getFullYear(),
quarter: '' as string
},
financeTransactions: [] as Schema<'FinanceTransactionResponse'>[],
receivables: [] as Schema<'ReceivableItemResponse'>[],
payables: [] as Schema<'PayableItemResponse'>[],
customerFinanceStatement: [] as Schema<'PartnerStatementItemResponse'>[],
supplierFinanceStatement: [] as Schema<'PartnerStatementItemResponse'>[],
customerProductStatement: [] as Schema<'PartnerProductStatementItemResponse'>[],
supplierProductStatement: [] as Schema<'PartnerProductStatementItemResponse'>[],
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)
/**
* Backward-compatible wrapper — delegates to Pinia store.
* All existing components calling `useInventory()` keep working unchanged.
* New code should import `useInventoryStore` directly from `@/stores/inventory`.
*/
import { useInventoryStore } from '@/stores/inventory'
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: '已下单',
partial_received: '部分收货',
received: '已收货',
paid: '已付款',
cancelled: '已作废'
}
return statusMap[status] || status
}
const isPurchaseOrderLocked = (status: string): boolean => {
return ['received', 'paid', 'cancelled'].includes(status)
}
const getSalesOrderStatusLabel = (status: string): string => {
const statusMap: Record<string, string> = {
manufacturing: '制造中',
delivered: '已交付',
paid: '已收款',
cancelled: '已作废'
}
return statusMap[status] || status
}
const getDeliveryStatusLabel = (ds: string): string => {
const map: Record<string, string> = { manufacturing: '制造中', delivered: '已交付', cancelled: '已作废' }
return map[ds] || ds
}
const getPaymentStatusLabel = (ps: string): string => {
const map: Record<string, string> = { unpaid: '未收款', paid: '已收款' }
return map[ps] || ps
}
const getReceiptStatusLabel = (rs: string): string => {
const map: Record<string, string> = { pending: '已下单', partial_received: '部分收货', received: '已收货', cancelled: '已作废' }
return map[rs] || rs
}
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?.items || []
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,
getDeliveryStatusLabel,
getPaymentStatusLabel,
getReceiptStatusLabel,
checkBackendHealth,
loadDashboard,
loadFinishedProducts,
loadProducts,
loadMaterials,
loadWarehouses,
ensureStockBaseData,
loadSuppliers,
loadProductionOrders,
loadPurchaseOrders,
loadCustomers,
loadInventory,
loadMovements,
loadFinance,
refreshFinanceByPeriod,
switchTab,
closeModal,
toggleGroup,
handleMenuClick,
switchProductCategory,
formatCurrency,
formatNumber,
formatDateTime,
formatDate
}
return useInventoryStore()
}
@@ -0,0 +1,372 @@
<template>
<div class="page-container">
<div class="page-header">
<t-button variant="text" @click="router.back()">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>
返回
</t-button>
<h1>批量分析</h1>
<p>同时上传多个 STP 文件,并行分析、统一查看进度</p>
</div>
<!-- 上传区 -->
<div class="upload-layout" v-if="!state.batchId">
<div class="upload-main-card">
<h2 class="section-title">1. 选择多个 STP 文件</h2>
<div
:class="['upload-zone', { 'drag-over': state.dragOver }]"
@dragover.prevent="state.dragOver = true"
@dragleave.prevent="state.dragOver = false"
@drop="handleDrop"
@click="fileInput?.click()"
>
<input
ref="fileInput"
type="file"
accept=".stp,.step"
multiple
@change="handleFileChange"
hidden
/>
<div class="upload-icon">📁</div>
<div class="upload-text">
<span class="upload-title">点击选择或拖拽多个 STP/STEP 文件</span>
<span class="upload-hint">支持批量上传,单次最多 20 个文件</span>
</div>
</div>
<div v-if="state.selectedFiles.length" class="batch-file-list">
<div class="batch-file-header">
<span>已选择 {{ state.selectedFiles.length }} 个文件</span>
<t-button variant="text" size="small" @click="state.selectedFiles = []">清空</t-button>
</div>
<div v-for="(file, idx) in state.selectedFiles" :key="idx" class="batch-file-item">
<span class="file-name">{{ file.name }}</span>
<span class="file-size">{{ formatFileSize(file.size) }}</span>
<t-button variant="text" size="small" @click="removeFile(idx)">×</t-button>
</div>
</div>
<div v-if="state.error" class="error-message">{{ state.error }}</div>
</div>
<div class="upload-side-card">
<h2 class="section-title">2. 注塑模参数</h2>
<div class="material-panel compact-panel">
<t-form-item label="产品材料">
<t-select v-model="state.selectedMaterial">
<t-option value="ABS" label="ABS (1.05 g/cm³)" />
<t-option value="PP" label="PP (0.90 g/cm³)" />
<t-option value="PE" label="PE (0.95 g/cm³)" />
<t-option value="PC" label="PC (1.20 g/cm³)" />
<t-option value="PA" label="PA (1.14 g/cm³)" />
<t-option value="POM" label="POM (1.41 g/cm³)" />
<t-option value="PMMA" label="PMMA (1.18 g/cm³)" />
<t-option value="PBT" label="PBT (1.31 g/cm³)" />
</t-select>
</t-form-item>
</div>
<t-button
v-if="state.selectedFiles.length"
theme="primary"
class="upload-submit-btn"
@click="batchUpload"
:disabled="state.uploading"
>
{{ state.uploading ? '上传中...' : '3. 开始批量分析' }}
</t-button>
<div v-else class="inline-note">先选择 STP 文件,再填写材料并开始批量分析。</div>
</div>
</div>
<!-- 进度看板 -->
<div v-else class="batch-dashboard">
<div class="batch-progress-header">
<div class="batch-progress-info">
<h2>批量任务:{{ state.batchId.slice(0, 8) }}...</h2>
<p>
共 {{ state.batchData?.total }} 个任务,
<t-tag type="success" variant="light">完成 {{ state.batchData?.completed }}</t-tag>
<t-tag type="danger" variant="light" v-if="state.batchData?.failed">失败 {{ state.batchData?.failed }}</t-tag>
<t-tag type="warning" variant="light" v-if="state.batchData?.processing">进行中 {{ state.batchData?.processing }}</t-tag>
</p>
</div>
<t-button
variant="outline"
size="small"
@click="state.batchId = ''; state.batchData = null"
>
新建批量
</t-button>
</div>
<!-- 整体进度条 -->
<div class="progress-bar" style="margin-bottom: var(--space-4);">
<div
class="progress-fill"
:style="{ width: (state.batchData?.progress_percent || 0) + '%' }"
></div>
</div>
<!-- 任务列表 -->
<div class="table-container">
<t-table :data="state.batchData?.tasks || []" row-key="task_id" stripe>
<t-table-column title="文件名" colKey="filename" />
<t-table-column title="状态">
<template #default="{ row }">
<t-tag
:type="row.status === 'completed' ? 'success' : row.status === 'failed' ? 'danger' : 'warning'"
variant="light"
>
{{ statusLabel(row.status) }}
</t-tag>
</template>
</t-table-column>
<t-table-column title="进度">
<template #default="{ row }">
<div v-if="row.status === 'processing'" class="progress-bar" style="width: 100px; display: inline-block;">
<div class="progress-fill" :style="{ width: (row.progress || 0) + '%' }"></div>
</div>
<span v-else>{{ row.progress || 0 }}%</span>
</template>
</t-table-column>
<t-table-column title="错误信息" colKey="error" />
<t-table-column title="操作">
<template #default="{ row }">
<t-button
v-if="row.status === 'completed'"
theme="primary"
size="small"
@click="router.push(`/moldinsight/result/${row.task_id}`)"
>
查看结果
</t-button>
<span v-else-if="row.status === 'failed'" style="color: var(--danger-color);">
{{ row.error || '分析失败' }}
</span>
<span v-else style="color: var(--text-secondary);">处理中...</span>
</template>
</t-table-column>
</t-table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAppStore } from '@/stores/app'
import { apiRequest } from '@/shared/api'
import { handleApiError, addNotification } from '@/shared/notification'
import { clearAuth } from '@/shared/auth'
import { formatFileSize } from '@/shared/utils'
interface BatchTask {
task_id: string
filename: string
status: string
progress?: number
error?: string
html_file?: string
}
interface BatchData {
batch_id: string
total: number
completed: number
failed: number
processing: number
progress_percent: number
tasks: BatchTask[]
}
const router = useRouter()
const appStore = useAppStore()
const fileInput = ref<HTMLInputElement | null>(null)
let pollTimer: ReturnType<typeof setTimeout> | null = null
const state = reactive({
selectedFiles: [] as File[],
selectedMaterial: 'ABS',
dragOver: false,
uploading: false,
error: '',
batchId: null as string | null,
batchData: null as BatchData | null,
})
const handleFileChange = (event: Event) => {
const target = event.target as HTMLInputElement
const files = Array.from(target.files || [])
addFiles(files)
}
const handleDrop = (event: DragEvent) => {
event.preventDefault()
state.dragOver = false
const files = Array.from(event.dataTransfer?.files || [])
addFiles(files)
}
const addFiles = (files: File[]) => {
const valid = files.filter(f => {
const lower = f.name.toLowerCase()
return lower.endsWith('.stp') || lower.endsWith('.step')
})
if (valid.length < files.length) {
state.error = '部分文件格式不支持,已自动过滤非 STP/STEP 文件'
}
const combined = [...state.selectedFiles, ...valid]
if (combined.length > 20) {
state.error = '单次批量上传最多 20 个文件'
state.selectedFiles = combined.slice(0, 20)
} else {
state.selectedFiles = combined
}
}
const removeFile = (idx: number) => {
state.selectedFiles.splice(idx, 1)
}
const statusLabel = (status: string) => {
const map: Record<string, string> = {
processing: '处理中',
completed: '已完成',
failed: '失败',
unknown: '未知',
rejected: '已拒绝',
}
return map[status] || status
}
const batchUpload = async () => {
if (!appStore.token) {
state.error = '请先登录后再上传文件'
router.push('/login')
return
}
if (!state.selectedFiles.length) return
state.uploading = true
state.error = ''
const formData = new FormData()
for (const file of state.selectedFiles) {
formData.append('files', file)
}
formData.append('material', state.selectedMaterial)
formData.append('draft_angle', '2.0')
formData.append('shrinkage_rate', '0.5')
formData.append('parting_precision', '0.1')
formData.append('cavity_match', '95')
try {
const res = await fetch('/api/batch-upload', {
method: 'POST',
headers: { Authorization: `Bearer ${appStore.token}` },
body: formData,
})
if (res.status === 401) {
clearAuth()
router.push('/login')
return
}
if (!res.ok) throw new Error(`上传失败: ${res.status}`)
const data = await res.json()
state.batchId = data.batch_id
addNotification(`已创建批量任务:${data.accepted} 个文件已接受`, 'success')
startBatchPolling(data.batch_id)
} catch (e) {
state.error = handleApiError(e, '批量上传')
} finally {
state.uploading = false
}
}
const startBatchPolling = (batchId: string) => {
const poll = async () => {
try {
const data = await apiRequest<BatchData>(`/api/batch/${batchId}`)
state.batchData = data
// 全部完成或全部失败则停止轮询
if (data.processing === 0) {
addNotification(
`批量任务完成:${data.completed} 个成功,${data.failed} 个失败`,
data.failed > 0 ? 'warning' : 'success'
)
return
}
pollTimer = setTimeout(poll, 3000)
} catch (e) {
handleApiError(e, '查询批量状态')
}
}
poll()
}
onMounted(() => {
if (!appStore.user) {
router.push('/login')
return
}
})
onUnmounted(() => {
if (pollTimer) clearTimeout(pollTimer)
})
</script>
<style scoped>
.batch-file-list {
margin-top: var(--space-3);
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
padding: var(--space-2);
}
.batch-file-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-2);
font-weight: 600;
}
.batch-file-item {
display: flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) 0;
border-bottom: 1px solid var(--border-light);
}
.batch-file-item .file-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.batch-file-item .file-size {
color: var(--text-secondary);
font-size: 0.85rem;
white-space: nowrap;
}
.batch-dashboard {
margin-top: var(--space-4);
}
.batch-progress-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--space-2);
}
.batch-progress-info h2 {
margin: 0 0 var(--space-1);
}
.batch-progress-info p {
margin: 0;
display: flex;
align-items: center;
gap: var(--space-2);
}
</style>
@@ -3,6 +3,9 @@
<div class="page-header">
<h1>注塑模 STP 分析</h1>
<p>上传 STEP/STP 产品件,完成自动分模、工程建议与导出</p>
<t-button variant="outline" size="small" @click="router.push('/moldinsight/batch')" style="margin-top: var(--space-2);">
📦 批量分析
</t-button>
</div>
<div class="moldinsight-intro-grid">
+142 -1
View File
@@ -123,6 +123,9 @@
<t-button type="default" size="small" @click="createProductFromAnalysis" :loading="state.creatingProduct" title="将本次模具分析创建为进销存成品,可在进销存模块继续配置 BOM / 销售">
📋 创建为成品
</t-button>
<t-button type="default" size="small" @click="estimateCost" :loading="state.costLoading" title="估算模具造价与单件成本">
💰 成本估算
</t-button>
</div>
<div id="preview-3d" v-if="selectedHtmlFile" class="viewer-section viewer-section-hero">
@@ -388,6 +391,97 @@
</template>
</div>
<div id="cost-estimate" class="viewer-section">
<div class="summary-header">
<h3>成本估算</h3>
<t-tag v-if="state.costResult" :type="state.costResult.source === 'ai' ? 'success' : 'warning'">
{{ state.costResult.source === 'ai' ? 'AI 估算' : '规则估算' }}
</t-tag>
<t-tag v-else theme="primary">待生成</t-tag>
</div>
<div class="result-card" style="margin-bottom: var(--space-3);">
<t-button
type="primary"
size="small"
:disabled="state.costLoading"
@click="estimateCost()"
title="基于当前方案估算模具造价与单件成本"
>
{{ state.costLoading ? '⏳ 估算中...' : '💰 生成成本估算' }}
</t-button>
</div>
<t-alert v-if="state.costError" theme="warning" title="成本估算失败" :message="state.costError" />
<template v-if="state.costResult">
<div class="result-grid">
<div class="result-card result-card-highlight">
<h4>模具造价</h4>
<div class="info-list">
<div class="info-item">
<span class="info-label">总造价</span>
<span class="info-value" style="font-size: 1.2rem; font-weight: 700; color: var(--primary-color);">
{{ state.costResult.total_mold_cost }}
</span>
</div>
<div class="info-item">
<span class="info-label">材料费</span>
<span class="info-value">{{ state.costResult.mold_cost.material }}</span>
</div>
<div class="info-item">
<span class="info-label">加工费</span>
<span class="info-value">{{ state.costResult.mold_cost.machining }}</span>
</div>
<div class="info-item">
<span class="info-label">复杂度系数</span>
<span class="info-value">{{ state.costResult.mold_cost.complexity_factor }}</span>
</div>
</div>
</div>
<div class="result-card">
<h4>单件成本</h4>
<div class="info-list">
<div class="info-item">
<span class="info-label">单件费用</span>
<span class="info-value" style="font-size: 1.1rem; font-weight: 600; color: var(--warning-color);">
{{ state.costResult.cost_per_part }}
</span>
</div>
<div class="info-item">
<span class="info-label">材料用量</span>
<span class="info-value">{{ state.costResult.part_cost.material }}</span>
</div>
<div class="info-item">
<span class="info-label">成型周期</span>
<span class="info-value">{{ state.costResult.part_cost.cycle_time }}</span>
</div>
<div class="info-item">
<span class="info-label">置信度</span>
<span class="info-value">{{ Math.round((state.costResult.confidence || 0) * 100) }}%</span>
</div>
</div>
</div>
</div>
<div v-if="state.costResult.assumptions?.length" class="result-card full-width" style="margin-top: var(--space-3);">
<h4>估算假设</h4>
<div class="recommendations-list">
<div
v-for="(assumption, idx) in state.costResult.assumptions"
:key="'cost-assumption-' + idx"
class="recommendation-item low"
>
<div class="recommendation-text">ℹ️ {{ assumption }}</div>
</div>
</div>
</div>
</template>
<t-alert v-else-if="!state.costError" theme="info" title="尚未生成" message="点击“生成成本估算”后,将基于当前方案估算模具造价与单件成本。" />
</div>
<div id="llm-report" class="viewer-section" v-if="hasVisibleDesignReport">
<div class="summary-header">
<h3>LLM 设计报告</h3>
@@ -564,6 +658,25 @@ interface CamOperation {
estimated_time_min?: number
}
interface CostEstimate {
mold_cost: {
material: string
machining: string
complexity_factor: string
subtotal: string
}
part_cost: {
material: string
cycle_time: string
cost_per_part: string
}
total_mold_cost: string
cost_per_part: string
confidence: number
assumptions: string[]
source?: string
}
interface SideActionAiAdvice {
source: string
status: string
@@ -607,7 +720,10 @@ const state = reactive({
controller: 'fanuc',
include_gcode: false
},
creatingProduct: false
creatingProduct: false,
costLoading: false,
costError: '',
costResult: null as CostEstimate | null,
})
const camSteelOptions = [
@@ -889,6 +1005,7 @@ const resultAnchorLinks = [
{ id: 'preview-3d', label: '预览' },
{ id: 'ai-side-action', label: 'AI倒扣分析' },
{ id: 'export-cam', label: 'CAM/CNC' },
{ id: 'cost-estimate', label: '成本估算' },
{ id: 'llm-report', label: '设计报告' }
]
@@ -1141,4 +1258,28 @@ const generateCamPlan = async () => {
state.camLoading = false
}
}
const estimateCost = async () => {
try {
state.costLoading = true
state.costError = ''
const taskId = route.params.taskId as string
const result = await apiRequest<any>('/api/cost-estimate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ task_id: taskId })
})
state.costResult = result?.data || null
addNotification(
result?.data?.source === 'ai' ? 'AI 成本估算完成' : '规则成本估算完成',
'success'
)
} catch (e: any) {
state.costResult = null
state.costError = e.message || '成本估算失败'
addNotification(`成本估算失败: ${state.costError}`, 'error')
} finally {
state.costLoading = false
}
}
</script>
+18 -1
View File
@@ -8,8 +8,25 @@ const router = createRouter({
{ path: '/login', component: () => import('@/modules/login/LoginView.vue') },
{ path: '/users', component: () => import('@/modules/users/UsersView.vue') },
{ path: '/moldinsight', component: () => import('@/modules/moldinsight/MoldInsightView.vue') },
{ path: '/moldinsight/batch', component: () => import('@/modules/moldinsight/BatchView.vue') },
{ path: '/moldinsight/result/:taskId', component: () => import('@/modules/moldinsight/ResultView.vue') },
{ path: '/inventory', component: () => import('@/modules/inventory/InventoryView.vue') },
{
path: '/inventory',
component: () => import('@/modules/inventory/InventoryView.vue'),
redirect: '/inventory/dashboard',
children: [
{ path: 'dashboard', component: () => import('@/modules/inventory/components/DashboardTab.vue') },
{ path: 'products', component: () => import('@/modules/inventory/components/ProductsTab.vue') },
{ path: 'materials', component: () => import('@/modules/inventory/components/MaterialsTab.vue') },
{ path: 'inventory', component: () => import('@/modules/inventory/components/InventoryTab.vue') },
{ path: 'purchases', component: () => import('@/modules/inventory/components/PurchaseOrdersTab.vue') },
{ path: 'sales_orders', component: () => import('@/modules/inventory/components/SalesOrdersTab.vue') },
{ path: 'suppliers', component: () => import('@/modules/inventory/components/SuppliersTab.vue') },
{ path: 'customers', component: () => import('@/modules/inventory/components/CustomersTab.vue') },
{ path: 'finance', component: () => import('@/modules/inventory/components/FinanceTab.vue') },
{ path: 'movements', component: () => import('@/modules/inventory/components/MovementsTab.vue') },
],
},
{ path: '/_design-system', component: () => import('@/design-system/DesignSystemView.vue') },
{ path: '/_release', component: () => import('@/design-system/ReleaseView.vue') },
],
+410
View File
@@ -0,0 +1,410 @@
/**
* Typed domain API client — wraps `apiRequest` with OpenAPI-generated types.
*
* Usage:
* import { inventoryApi } from '@/shared/api-client'
* const products = await inventoryApi.listProducts({ item_type: 'finished' })
* // products is typed as Schema<'ProductResponse'>[]
*/
import { apiRequest } from './api'
import type { Schema } from '@/types/schemas'
// ── Auth ───────────────────────────────────────────────────────
export const authApi = {
login(username: string, password: string) {
const body = new URLSearchParams({ username, password })
return apiRequest<Schema<'Token'>>('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: body.toString(),
})
},
loginJson(username: string, password: string) {
return apiRequest<Schema<'Token'>>('/api/auth/login/json', {
method: 'POST',
body: JSON.stringify({ username, password } satisfies Schema<'LoginRequest'>),
})
},
me() {
return apiRequest<Schema<'UserResponse'>>('/api/auth/me')
},
logout() {
return apiRequest('/api/auth/logout', { method: 'POST' })
},
// Users
listUsers() {
return apiRequest<Schema<'UserResponse'>[]>('/api/auth/users')
},
createUser(data: Schema<'UserCreate'>) {
return apiRequest<Schema<'UserResponse'>>('/api/auth/users', {
method: 'POST',
body: JSON.stringify(data),
})
},
updateUser(userId: number, data: Schema<'UserUpdate'>) {
return apiRequest<Schema<'UserResponse'>>(`/api/auth/users/${userId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
},
deleteUser(userId: number) {
return apiRequest(`/api/auth/users/${userId}`, { method: 'DELETE' })
},
resetPassword(userId: number, newPassword: string) {
return apiRequest(`/api/auth/users/${userId}/reset-password`, {
method: 'PUT',
body: JSON.stringify({ new_password: newPassword }),
})
},
// Roles
listRoles() {
return apiRequest<Schema<'RoleResponse'>[]>('/api/auth/roles')
},
createRole(data: Schema<'RoleCreate'>) {
return apiRequest<Schema<'RoleResponse'>>('/api/auth/roles', {
method: 'POST',
body: JSON.stringify(data),
})
},
updateRole(roleId: number, data: Schema<'RoleCreate'>) {
return apiRequest<Schema<'RoleResponse'>>(`/api/auth/roles/${roleId}`, {
method: 'PUT',
body: JSON.stringify(data),
})
},
deleteRole(roleId: number) {
return apiRequest(`/api/auth/roles/${roleId}`, { method: 'DELETE' })
},
setRolePermissions(roleId: number, permissionIds: number[]) {
return apiRequest(`/api/auth/roles/${roleId}/permissions`, {
method: 'PUT',
body: JSON.stringify({ permission_ids: permissionIds }),
})
},
// Permissions
listPermissions() {
return apiRequest<Schema<'PermissionResponse'>[]>('/api/auth/permissions')
},
}
// ── Inventory / ERP ────────────────────────────────────────────
export const inventoryApi = {
// Dashboard
dashboard() {
return apiRequest('/api/dashboard')
},
// Products
listProducts(params?: { item_type?: string; limit?: number }) {
const q = new URLSearchParams()
if (params?.item_type) q.set('item_type', params.item_type)
if (params?.limit) q.set('limit', String(params.limit))
const qs = q.toString()
return apiRequest<Schema<'ProductResponse'>[]>(`/api/products${qs ? `?${qs}` : ''}`)
},
createProduct(data: Schema<'ProductCreate'>) {
return apiRequest<Schema<'ProductResponse'>>('/api/products', {
method: 'POST',
body: JSON.stringify(data),
})
},
updateProduct(id: number, data: Partial<Schema<'ProductCreate'>>) {
return apiRequest<Schema<'ProductResponse'>>(`/api/products/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
},
deleteProduct(id: number) {
return apiRequest(`/api/products/${id}`, { method: 'DELETE' })
},
getProductMaterials(productId: number) {
return apiRequest<{ items: Schema<'ProductMaterialItemResponse'>[] }>(`/api/products/${productId}/materials`)
},
// from-task (mold analysis → product)
createProductFromTask(taskId: string) {
return apiRequest<Schema<'ProductResponse'>>(`/api/products/from-task/${taskId}`, {
method: 'POST',
})
},
// Warehouses
listWarehouses() {
return apiRequest<Schema<'WarehouseResponse'>[]>('/api/warehouses')
},
createWarehouse(data: Schema<'WarehouseCreate'>) {
return apiRequest<Schema<'WarehouseResponse'>>('/api/warehouses', {
method: 'POST',
body: JSON.stringify(data),
})
},
// Inventory
listInventory() {
return apiRequest<{ items: Schema<'InventoryResponse'>[] }>('/api/inventory')
},
// Stock Movements
listStockMovements(params?: { limit?: number }) {
const q = params?.limit ? `?limit=${params.limit}` : ''
return apiRequest<{ items: Schema<'StockMovementResponse'>[] }>(`/api/stock-movements${q}`)
},
// Suppliers
listSuppliers() {
return apiRequest<Schema<'SupplierResponse'>[]>('/api/suppliers')
},
createSupplier(data: Schema<'SupplierCreate'>) {
return apiRequest<Schema<'SupplierResponse'>>('/api/suppliers', {
method: 'POST',
body: JSON.stringify(data),
})
},
updateSupplier(id: number, data: Partial<Schema<'SupplierCreate'>>) {
return apiRequest<Schema<'SupplierResponse'>>(`/api/suppliers/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
},
deleteSupplier(id: number) {
return apiRequest(`/api/suppliers/${id}`, { method: 'DELETE' })
},
// Customers
listCustomers() {
return apiRequest<Schema<'CustomerResponse'>[]>('/api/customers')
},
createCustomer(data: Schema<'CustomerCreate'>) {
return apiRequest<Schema<'CustomerResponse'>>('/api/customers', {
method: 'POST',
body: JSON.stringify(data),
})
},
updateCustomer(id: number, data: Partial<Schema<'CustomerCreate'>>) {
return apiRequest<Schema<'CustomerResponse'>>(`/api/customers/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
},
deleteCustomer(id: number) {
return apiRequest(`/api/customers/${id}`, { method: 'DELETE' })
},
// Purchase Orders
listPurchaseOrders(params?: { limit?: number }) {
const q = params?.limit ? `?limit=${params.limit}` : ''
return apiRequest<{ items: Schema<'PurchaseOrderResponse'>[] }>(`/api/purchase-orders${q}`)
},
createPurchaseOrder(data: Schema<'PurchaseOrderCreate'>) {
return apiRequest<Schema<'PurchaseOrderResponse'>>('/api/purchase-orders', {
method: 'POST',
body: JSON.stringify(data),
})
},
updatePurchaseOrder(id: number, data: Partial<Schema<'PurchaseOrderCreate'>>) {
return apiRequest<Schema<'PurchaseOrderResponse'>>(`/api/purchase-orders/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
},
receivePurchaseOrder(id: number, data: Schema<'PurchaseOrderReceiveRequest'>) {
return apiRequest<Schema<'PurchaseOrderResponse'>>(`/api/purchase-orders/${id}/receive`, {
method: 'POST',
body: JSON.stringify(data),
})
},
updatePurchaseOrderStatus(id: number, status: string) {
return apiRequest<Schema<'PurchaseOrderResponse'>>(`/api/purchase-orders/${id}/status`, {
method: 'PUT',
body: JSON.stringify({ status }),
})
},
// Sales Orders
listSalesOrders(params?: { limit?: number }) {
const q = params?.limit ? `?limit=${params.limit}` : ''
return apiRequest<{ items: Schema<'SalesOrderResponse'>[] }>(`/api/sales-orders${q}`)
},
getSalesOrder(id: number) {
return apiRequest<Schema<'SalesOrderDetailResponse'>>(`/api/sales-orders/${id}`)
},
createSalesOrder(data: Schema<'SalesOrderCreate'>) {
return apiRequest<Schema<'SalesOrderResponse'>>('/api/sales-orders', {
method: 'POST',
body: JSON.stringify(data),
})
},
updateSalesOrder(id: number, data: Partial<Schema<'SalesOrderCreate'>>) {
return apiRequest<Schema<'SalesOrderResponse'>>(`/api/sales-orders/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
})
},
deleteSalesOrder(id: number) {
return apiRequest(`/api/sales-orders/${id}`, { method: 'DELETE' })
},
// Purchase Demands (采购需求推导)
calculatePurchaseDemands(salesOrderIds: number[]) {
return apiRequest<Schema<'PurchaseDemandResponse'>>('/api/purchase-demands/calculate', {
method: 'POST',
body: JSON.stringify({ sales_order_ids: salesOrderIds } satisfies Schema<'PurchaseDemandCalculateRequest'>),
})
},
// Finance
financeSummary(params?: { year?: number; quarter?: number }) {
const q = new URLSearchParams()
if (params?.year) q.set('year', String(params.year))
if (params?.quarter) q.set('quarter', String(params.quarter))
return apiRequest<Schema<'FinanceSummaryResponse'>>(`/api/finance/summary?${q}`)
},
financeTransactions(params?: { status?: string; limit?: number; year?: number; quarter?: number }) {
const q = new URLSearchParams()
if (params?.status) q.set('status', params.status)
if (params?.limit) q.set('limit', String(params.limit))
if (params?.year) q.set('year', String(params.year))
if (params?.quarter) q.set('quarter', String(params.quarter))
return apiRequest<{ items: Schema<'FinanceTransactionResponse'>[] }>(`/api/finance/transactions?${q}`)
},
financeReceivables(params?: { limit?: number; year?: number; quarter?: number }) {
const q = new URLSearchParams()
if (params?.limit) q.set('limit', String(params.limit))
if (params?.year) q.set('year', String(params.year))
if (params?.quarter) q.set('quarter', String(params.quarter))
return apiRequest<Schema<'ReceivableItemResponse'>[]>(`/api/finance/receivables?${q}`)
},
financePayables(params?: { limit?: number; year?: number; quarter?: number }) {
const q = new URLSearchParams()
if (params?.limit) q.set('limit', String(params.limit))
if (params?.year) q.set('year', String(params.year))
if (params?.quarter) q.set('quarter', String(params.quarter))
return apiRequest<Schema<'PayableItemResponse'>[]>(`/api/finance/payables?${q}`)
},
financePartnerStatement(partnerType: 'customer' | 'supplier', params?: { year?: number; quarter?: number }) {
const q = new URLSearchParams()
if (params?.year) q.set('year', String(params.year))
if (params?.quarter) q.set('quarter', String(params.quarter))
return apiRequest<Schema<'FinancePartnerStatementResponse'>>(`/api/finance/partner-statement/${partnerType}?${q}`)
},
financePartnerProductStatement(partnerType: 'customer' | 'supplier', params?: { year?: number; quarter?: number }) {
const q = new URLSearchParams()
if (params?.year) q.set('year', String(params.year))
if (params?.quarter) q.set('quarter', String(params.quarter))
return apiRequest<Schema<'FinancePartnerProductStatementResponse'>>(`/api/finance/partner-product-statement/${partnerType}?${q}`)
},
}
// ── MoldInsight ────────────────────────────────────────────────
export const moldinsightApi = {
uploadStp(formData: FormData) {
return apiRequest<{ task_id: string }>('/api/upload', {
method: 'POST',
body: formData,
})
},
getStatus(taskId: string) {
return apiRequest<{
task_id: string
status: string
progress: number
result?: Record<string, unknown>
error?: string
}>(`/api/status/${taskId}`)
},
batchUpload(formData: FormData) {
return apiRequest<{
batch_id: string
accepted: number
rejected: number
task_ids: string[]
}>('/api/batch-upload', {
method: 'POST',
body: formData,
})
},
getBatchStatus(batchId: string) {
return apiRequest<{
batch_id: string
total: number
completed: number
failed: number
processing: number
progress_percent: number
tasks: Array<{
task_id: string
filename: string
status: string
progress?: number
error?: string
html_file?: string
}>
}>(`/api/batch/${batchId}`)
},
estimateCost(data: {
task_id?: string
material?: string
mold_type?: string
cavity_count?: number
weight?: number
dimensions?: { length: number; width: number; height: number }
}) {
return apiRequest<{
mold_cost?: number
part_cost?: number
total_mold_cost?: number
confidence?: number
assumptions?: string[]
currency?: string
}>('/api/cost-estimate', {
method: 'POST',
body: JSON.stringify(data),
})
},
getAluminumPrice() {
return apiRequest<{ price: number; unit: string; updated_at: string }>('/api/aluminum-price/')
},
}
+463
View File
@@ -0,0 +1,463 @@
import { defineStore } from 'pinia'
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'
import type { Schema } from '@/types/schemas'
declare const AirDatepicker: any
// Dashboard has no Pydantic schema in backend, define it here
interface DashboardData {
finished_product_count: number
total_stock: number
total_value: number
supplier_count: number
customer_count: number
warehouse_count: number
}
export const useInventoryStore = defineStore('inventory', () => {
// ── state ──
const activeTab = ref('dashboard')
const backendDbReady = ref(true)
const backendDbMessage = ref('')
const productCategory = ref('finished')
const dashboard = ref<DashboardData | null>(null)
const financeSummary = ref<Schema<'FinanceSummaryResponse'> | null>(null)
const financePeriod = reactive({ year: new Date().getFullYear(), quarter: '' as string })
const financeTransactions = ref<Schema<'FinanceTransactionResponse'>[]>([])
const receivables = ref<Schema<'ReceivableItemResponse'>[]>([])
const payables = ref<Schema<'PayableItemResponse'>[]>([])
const customerFinanceStatement = ref<Schema<'PartnerStatementItemResponse'>[]>([])
const supplierFinanceStatement = ref<Schema<'PartnerStatementItemResponse'>[]>([])
const customerProductStatement = ref<Schema<'PartnerProductStatementItemResponse'>[]>([])
const supplierProductStatement = ref<Schema<'PartnerProductStatementItemResponse'>[]>([])
const products = ref<Schema<'ProductResponse'>[]>([])
const materials = ref<Schema<'ProductResponse'>[]>([])
const finishedProducts = ref<Schema<'ProductResponse'>[]>([])
const purchaseOrders = ref<Schema<'PurchaseOrderResponse'>[]>([])
const purchaseWarehouseId = ref<number | null>(null)
const purchaseReceiveItems = ref<any[]>([])
const productionOrders = ref<Schema<'SalesOrderResponse'>[]>([])
const productionPlan = ref<any>(null)
const productionWarehouseId = ref<number | null>(null)
const suppliers = ref<Schema<'SupplierResponse'>[]>([])
const customers = ref<Schema<'CustomerResponse'>[]>([])
const warehouses = ref<Schema<'WarehouseResponse'>[]>([])
const inventory = ref<Schema<'InventoryResponse'>[]>([])
const movements = ref<Schema<'StockMovementResponse'>[]>([])
const loading = ref(false)
const showModal = ref(false)
const modalType = ref('')
const editingItem = ref<any>(null)
const productBomItems = ref<any[]>([])
const materialConsumptionItems = ref<any[]>([])
const showMaterialConsumptionModal = ref(false)
const consumedMaterials = ref<any[]>([])
const restockItems = ref<any[]>([])
const showRestockModal = ref(false)
const form = ref<any>({})
// ── date-picker refs ──
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)
// ── legacy state object (backward-compat for components still using `state.xxx`) ──
const state = reactive({
get activeTab() { return activeTab.value }, set activeTab(v) { activeTab.value = v },
get backendDbReady() { return backendDbReady.value }, set backendDbReady(v) { backendDbReady.value = v },
get backendDbMessage() { return backendDbMessage.value }, set backendDbMessage(v) { backendDbMessage.value = v },
get productCategory() { return productCategory.value }, set productCategory(v) { productCategory.value = v },
get dashboard() { return dashboard.value }, set dashboard(v) { dashboard.value = v },
get financeSummary() { return financeSummary.value }, set financeSummary(v) { financeSummary.value = v },
financePeriod,
get financeTransactions() { return financeTransactions.value }, set financeTransactions(v) { financeTransactions.value = v },
get receivables() { return receivables.value }, set receivables(v) { receivables.value = v },
get payables() { return payables.value }, set payables(v) { payables.value = v },
get customerFinanceStatement() { return customerFinanceStatement.value }, set customerFinanceStatement(v) { customerFinanceStatement.value = v },
get supplierFinanceStatement() { return supplierFinanceStatement.value }, set supplierFinanceStatement(v) { supplierFinanceStatement.value = v },
get customerProductStatement() { return customerProductStatement.value }, set customerProductStatement(v) { customerProductStatement.value = v },
get supplierProductStatement() { return supplierProductStatement.value }, set supplierProductStatement(v) { supplierProductStatement.value = v },
get products() { return products.value }, set products(v) { products.value = v },
get materials() { return materials.value }, set materials(v) { materials.value = v },
get finishedProducts() { return finishedProducts.value }, set finishedProducts(v) { finishedProducts.value = v },
get purchaseOrders() { return purchaseOrders.value }, set purchaseOrders(v) { purchaseOrders.value = v },
get purchaseWarehouseId() { return purchaseWarehouseId.value }, set purchaseWarehouseId(v) { purchaseWarehouseId.value = v },
get purchaseReceiveItems() { return purchaseReceiveItems.value }, set purchaseReceiveItems(v) { purchaseReceiveItems.value = v },
get productionOrders() { return productionOrders.value }, set productionOrders(v) { productionOrders.value = v },
get productionPlan() { return productionPlan.value }, set productionPlan(v) { productionPlan.value = v },
get productionWarehouseId() { return productionWarehouseId.value }, set productionWarehouseId(v) { productionWarehouseId.value = v },
get suppliers() { return suppliers.value }, set suppliers(v) { suppliers.value = v },
get customers() { return customers.value }, set customers(v) { customers.value = v },
get warehouses() { return warehouses.value }, set warehouses(v) { warehouses.value = v },
get inventory() { return inventory.value }, set inventory(v) { inventory.value = v },
get movements() { return movements.value }, set movements(v) { movements.value = v },
get loading() { return loading.value }, set loading(v) { loading.value = v },
get showModal() { return showModal.value }, set showModal(v) { showModal.value = v },
get modalType() { return modalType.value }, set modalType(v) { modalType.value = v },
get editingItem() { return editingItem.value }, set editingItem(v) { editingItem.value = v },
get productBomItems() { return productBomItems.value }, set productBomItems(v) { productBomItems.value = v },
get materialConsumptionItems() { return materialConsumptionItems.value }, set materialConsumptionItems(v) { materialConsumptionItems.value = v },
get showMaterialConsumptionModal() { return showMaterialConsumptionModal.value }, set showMaterialConsumptionModal(v) { showMaterialConsumptionModal.value = v },
get consumedMaterials() { return consumedMaterials.value }, set consumedMaterials(v) { consumedMaterials.value = v },
get restockItems() { return restockItems.value }, set restockItems(v) { restockItems.value = v },
get showRestockModal() { return showRestockModal.value }, set showRestockModal(v) { showRestockModal.value = v },
get form() { return form.value }, set form(v) { form.value = v },
})
// ── helpers ──
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)
}
// ── pickers ──
const destroyPickers = () => {
if (deliveryPicker) { deliveryPicker.destroy(); deliveryPicker = null }
if (expectedPicker) { expectedPicker.destroy(); expectedPicker = null }
}
const initPickers = () => {
destroyPickers()
if (typeof AirDatepicker !== 'function') return
if (modalType.value === 'salesOrder' && deliveryDateInput.value) {
deliveryPicker = new AirDatepicker(deliveryDateInput.value, {
timepicker: false, autoClose: true, zIndex: 2005, dateFormat: 'yyyy-MM-dd',
onSelect: ({ formattedDate }: any) => {
form.value.delivery_date = formattedDate || ''
form.value.delivery_date_native = toNativeValue(formattedDate || '')
}
})
const initial = parseDateTimeLocal(form.value.delivery_date)
if (initial) deliveryPicker.selectDate(initial, { silent: true })
}
if (modalType.value === 'purchaseOrder' && expectedDateInput.value) {
expectedPicker = new AirDatepicker(expectedDateInput.value, {
timepicker: false, autoClose: true, zIndex: 2005, dateFormat: 'yyyy-MM-dd',
onSelect: ({ formattedDate }: any) => {
form.value.expected_date = formattedDate || ''
form.value.expected_date_native = toNativeValue(formattedDate || '')
}
})
const initial = parseDateTimeLocal(form.value.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()
}
// ── label helpers ──
const getMovementTypeLabel = (movementType: string): string => {
const map: 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 map[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 map: Record<string, string> = { draft: '已下单', pending: '已下单', partial_received: '部分收货', received: '已收货', paid: '已付款', cancelled: '已作废' }
return map[status] || status
}
const isPurchaseOrderLocked = (status: string): boolean => ['received', 'paid', 'cancelled'].includes(status)
const getSalesOrderStatusLabel = (status: string): string => {
const map: Record<string, string> = { manufacturing: '制造中', delivered: '已交付', paid: '已收款', cancelled: '已作废' }
return map[status] || status
}
const getDeliveryStatusLabel = (ds: string): string => {
const map: Record<string, string> = { manufacturing: '制造中', delivered: '已交付', cancelled: '已作废' }
return map[ds] || ds
}
const getPaymentStatusLabel = (ps: string): string => {
const map: Record<string, string> = { unpaid: '未收款', paid: '已收款' }
return map[ps] || ps
}
const getReceiptStatusLabel = (rs: string): string => {
const map: Record<string, string> = { pending: '已下单', partial_received: '部分收货', received: '已收货', cancelled: '已作废' }
return map[rs] || rs
}
// ── data loading ──
const checkBackendHealth = async () => {
try {
const resp = await fetch('/health', { method: 'GET' })
if (!resp.ok) { backendDbReady.value = false; backendDbMessage.value = '后端服务异常,暂无法加载业务数据'; return }
const health = await resp.json().catch(() => null)
if (health && health.database_connected === false) { backendDbReady.value = false; backendDbMessage.value = '数据库未连接,当前仅可浏览界面,业务数据暂不可用'; return }
backendDbReady.value = true; backendDbMessage.value = ''
} catch { backendDbReady.value = false; backendDbMessage.value = '无法连接后端服务' }
}
const loadDashboard = async () => {
loading.value = true
try { dashboard.value = await apiRequest('/api/dashboard') }
catch (e) { handleApiError(e, '加载仪表盘') }
finally { loading.value = false }
}
const loadFinishedProducts = async () => {
loading.value = true
try { finishedProducts.value = await apiRequest('/api/products?item_type=finished&limit=100') }
catch (e) { handleApiError(e, '加载成品') }
finally { loading.value = false }
}
const loadProducts = async () => { await loadFinishedProducts() }
const loadMaterials = async () => {
loading.value = true
try { materials.value = await apiRequest('/api/products?item_type=material&limit=100') }
catch (e) { handleApiError(e, '加载物料') }
finally { loading.value = false }
}
const loadWarehouses = async () => {
loading.value = true
try { warehouses.value = await apiRequest('/api/warehouses') }
catch (e) { handleApiError(e, '加载仓库') }
finally { loading.value = false }
}
const ensureStockBaseData = async () => {
if (!materials.value.length) await loadMaterials()
if (!warehouses.value.length) await loadWarehouses()
if (!warehouses.value.length) {
try {
await apiRequest('/api/warehouses', { method: 'POST', body: JSON.stringify({ name: '默认仓库' }) })
await loadWarehouses()
addNotification('已自动创建默认仓库', 'success')
} catch (e) { handleApiError(e, '自动创建默认仓库') }
}
}
const loadSuppliers = async () => {
loading.value = true
try { suppliers.value = await apiRequest('/api/suppliers') }
catch (e) { handleApiError(e, '加载供应商') }
finally { loading.value = false }
}
const loadProductionOrders = async () => {
loading.value = true
try {
const [orders, wh] = await Promise.all([apiRequest('/api/sales-orders?limit=100'), apiRequest('/api/warehouses')])
productionOrders.value = orders?.items || []
warehouses.value = wh || []
if (!productionWarehouseId.value) {
productionWarehouseId.value = warehouses.value.find((w: any) => w.is_default)?.id || warehouses.value[0]?.id || null
}
} catch (e) { handleApiError(e, '加载按单生产数据') }
finally { loading.value = false }
}
const loadPurchaseOrders = async () => {
loading.value = true
try {
const [orders, wh] = await Promise.all([apiRequest('/api/purchase-orders?limit=100'), apiRequest('/api/warehouses')])
purchaseOrders.value = orders?.items || []
warehouses.value = wh || []
if (!purchaseWarehouseId.value) {
purchaseWarehouseId.value = warehouses.value.find((w: any) => w.is_default)?.id || warehouses.value[0]?.id || null
}
} catch (e) { handleApiError(e, '加载采购订单') }
finally { loading.value = false }
}
const loadCustomers = async () => {
loading.value = true
try { customers.value = await apiRequest('/api/customers') }
catch (e) { handleApiError(e, '加载客户') }
finally { loading.value = false }
}
const loadInventory = async () => {
loading.value = true
try { inventory.value = (await apiRequest('/api/inventory'))?.items || [] }
catch (e) { handleApiError(e, '加载库存') }
finally { loading.value = false }
}
const loadMovements = async () => {
loading.value = true
try { movements.value = (await apiRequest('/api/stock-movements'))?.items || [] }
catch (e) { handleApiError(e, '加载变动记录') }
finally { loading.value = false }
}
const loadFinance = async () => {
loading.value = true
try {
const selectedYear = Number(financePeriod.year) || new Date().getFullYear()
const selectedQuarter = financePeriod.quarter ? Number(financePeriod.quarter) : null
const periodQuery = selectedQuarter ? `year=${selectedYear}&quarter=${selectedQuarter}` : `year=${selectedYear}`
const [summary, transactions, recv, pay, custStmt, suppStmt, custProdStmt, suppProdStmt] = 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}`)
])
financeSummary.value = summary
financeTransactions.value = transactions?.items || []
receivables.value = recv
payables.value = pay
customerFinanceStatement.value = custStmt.items || []
supplierFinanceStatement.value = suppStmt.items || []
customerProductStatement.value = custProdStmt.items || []
supplierProductStatement.value = suppProdStmt.items || []
} catch (e) { handleApiError(e, '加载财务数据') }
finally { loading.value = false }
}
const refreshFinanceByPeriod = () => { if (activeTab.value === 'finance') loadFinance() }
const switchTab = (tab: string) => { activeTab.value = tab }
const closeModal = () => {
showModal.value = false; modalType.value = ''; editingItem.value = null
productBomItems.value = []; purchaseReceiveItems.value = []; form.value = {}
destroyPickers()
}
const modalTitle = computed(() => {
const prefix = editingItem.value ? '编辑' : '新增'
const typeMap: Record<string, string> = {
product: form.value.item_type === 'finished' ? '成品' : '物料',
inventoryItem: '物料库存', salesOrder: '销售订单', purchaseOrder: '采购订单',
purchaseReceive: '采购到货入库', supplier: '供应商', customer: '客户'
}
return prefix + (typeMap[modalType.value] || '')
})
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 === activeTab.value)
if (item) return { group, item }
}
return null
})
const handleMenuClick = (itemKey: string) => { switchTab(itemKey) }
const switchProductCategory = (category: string) => { productCategory.value = category }
return {
// state refs (new Pinia style)
activeTab, backendDbReady, backendDbMessage, productCategory, dashboard,
financeSummary, financePeriod, financeTransactions, receivables, payables,
customerFinanceStatement, supplierFinanceStatement, customerProductStatement, supplierProductStatement,
products, materials, finishedProducts, purchaseOrders, purchaseWarehouseId, purchaseReceiveItems,
productionOrders, productionPlan, productionWarehouseId, suppliers, customers, warehouses,
inventory, movements, loading, showModal, modalType, editingItem, productBomItems,
materialConsumptionItems, showMaterialConsumptionModal, consumedMaterials,
restockItems, showRestockModal, form,
// legacy state object (backward compat)
state,
// refs
deliveryDateInput, expectedDateInput, deliveryDateNativeInput, expectedDateNativeInput,
// computed
menuGroups, openGroups, activeMenu, modalTitle,
// actions
parseDateTimeLocal, toPickerValue, toApiDateTime, toNativeValue, fromNativeValue,
destroyPickers, initPickers, openDateTimePicker,
getMovementTypeLabel, getMovementBadgeClass, getPurchaseOrderStatusLabel, isPurchaseOrderLocked,
getSalesOrderStatusLabel, getDeliveryStatusLabel, getPaymentStatusLabel, getReceiptStatusLabel,
checkBackendHealth, loadDashboard, loadFinishedProducts, loadProducts, loadMaterials,
loadWarehouses, ensureStockBaseData, loadSuppliers, loadProductionOrders, loadPurchaseOrders,
loadCustomers, loadInventory, loadMovements, loadFinance, refreshFinanceByPeriod,
switchTab, closeModal, toggleGroup, handleMenuClick, switchProductCategory,
formatCurrency, formatNumber, formatDateTime, formatDate
}
})
+1176 -6
View File
File diff suppressed because it is too large Load Diff