xxx
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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') },
|
||||
],
|
||||
|
||||
@@ -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/')
|
||||
},
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user