1139 lines
41 KiB
Vue
1139 lines
41 KiB
Vue
<template>
|
||
<div class="semi-module">
|
||
<div class="header-tools">
|
||
<div class="left-tools">
|
||
<el-input
|
||
v-model="queryParams.keyword"
|
||
placeholder="🔍 搜索物料 / 批号 / SN / 工单号 / BOM编号..."
|
||
class="search-input"
|
||
clearable
|
||
@clear="fetchData"
|
||
@keyup.enter="fetchData"
|
||
>
|
||
<template #append>
|
||
<el-button :icon="Search" @click="fetchData" />
|
||
</template>
|
||
</el-input>
|
||
</div>
|
||
|
||
<div class="right-tools">
|
||
<el-button type="primary" :icon="Plus" @click="handleCreate" class="action-btn">半成品入库登记</el-button>
|
||
<el-button :icon="Refresh" @click="fetchData" class="action-btn">刷新</el-button>
|
||
|
||
<el-popover placement="bottom-end" title="列配置" :width="500" trigger="click">
|
||
<template #reference>
|
||
<el-button :icon="Setting" class="action-btn">表头</el-button>
|
||
</template>
|
||
<el-checkbox-group v-model="visibleColumnProps" class="column-selector">
|
||
<div class="col-group-title">基础信息</div>
|
||
<el-row :gutter="10">
|
||
<el-col :span="12" v-for="c in baseColumns" :key="c.prop"><el-checkbox :label="c.prop">{{ c.label }}</el-checkbox></el-col>
|
||
</el-row>
|
||
<div class="col-group-title" style="margin-top:10px">生产与库存</div>
|
||
<el-row :gutter="10">
|
||
<el-col :span="12" v-for="c in stockColumns" :key="c.prop"><el-checkbox :label="c.prop">{{ c.label }}</el-checkbox></el-col>
|
||
</el-row>
|
||
</el-checkbox-group>
|
||
</el-popover>
|
||
</div>
|
||
</div>
|
||
|
||
<el-table
|
||
v-loading="loading"
|
||
:data="tableData"
|
||
border
|
||
stripe
|
||
style="width: 100%"
|
||
class="modern-table"
|
||
highlight-current-row
|
||
header-cell-class-name="table-header-gray"
|
||
>
|
||
<template v-for="col in allColumns" :key="col.prop">
|
||
<el-table-column
|
||
v-if="visibleColumnProps.includes(col.prop)"
|
||
:prop="col.prop"
|
||
:label="col.label"
|
||
:min-width="col.minWidth || '140'"
|
||
show-overflow-tooltip
|
||
>
|
||
<template #default="scope" v-if="['serial_number', 'batch_number'].includes(col.prop)">
|
||
<span v-if="scope.row[col.prop]" :class="col.prop === 'serial_number' ? 'tag-sn' : 'tag-bn'">
|
||
{{ scope.row[col.prop] }}
|
||
</span>
|
||
<span v-else class="text-placeholder">-</span>
|
||
</template>
|
||
|
||
<template #default="scope" v-else-if="col.prop === 'qty_stock'">
|
||
<span class="stock-num">{{ scope.row.sum_stock }}</span>
|
||
<el-tag size="small" type="info" effect="plain" class="sum-tag">总</el-tag>
|
||
</template>
|
||
|
||
<template #default="scope" v-else-if="col.prop === 'qty_available'">
|
||
<span class="avail-num">{{ scope.row.sum_available }}</span>
|
||
<el-tag size="small" type="info" effect="plain" class="sum-tag">总</el-tag>
|
||
</template>
|
||
|
||
<template #default="scope" v-else-if="col.prop === 'status'">
|
||
<el-tag :type="getStatusType(scope.row.status)" effect="light" round>
|
||
{{ scope.row.status }}
|
||
</el-tag>
|
||
</template>
|
||
|
||
<template #default="scope" v-else-if="col.prop === 'quality_status'">
|
||
<el-tag :type="getQualityType(scope.row.quality_status)" effect="dark" size="small">
|
||
{{ scope.row.quality_status }}
|
||
</el-tag>
|
||
</template>
|
||
|
||
<template #default="scope" v-else-if="['quality_report_link', 'detail_link'].includes(col.prop)">
|
||
<el-link v-if="scope.row[col.prop]" type="primary" :href="scope.row[col.prop]" target="_blank" :underline="false">
|
||
<el-icon><Link /></el-icon> 查看
|
||
</el-link>
|
||
</template>
|
||
|
||
<template #default="scope" v-else-if="['raw_material_cost', 'manual_cost'].includes(col.prop)">
|
||
<span class="money-text">{{ formatMoney(scope.row[col.prop]) }}</span>
|
||
</template>
|
||
</el-table-column>
|
||
</template>
|
||
|
||
<el-table-column label="操作" width="220" fixed="right" align="center">
|
||
<template #default="{ row }">
|
||
<el-button link type="warning" size="default" @click="handlePrint(row)">
|
||
<el-icon><Printer /></el-icon> 打印
|
||
</el-button>
|
||
<el-button link type="primary" size="default" @click="handleUpdate(row)">编辑</el-button>
|
||
<el-popconfirm title="确定删除该条记录吗?不可恢复。" @confirm="handleDelete(row)" width="220">
|
||
<template #reference>
|
||
<el-button link type="danger" size="default">删除</el-button>
|
||
</template>
|
||
</el-popconfirm>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
|
||
<el-pagination
|
||
class="pagination-bar"
|
||
v-model:current-page="queryParams.page"
|
||
v-model:page-size="queryParams.pageSize"
|
||
:total="total"
|
||
:page-sizes="[15, 30, 50, 100]"
|
||
layout="total, sizes, prev, pager, next, jumper"
|
||
background
|
||
@size-change="fetchData"
|
||
@current-change="fetchData"
|
||
/>
|
||
|
||
<el-dialog
|
||
v-model="visible"
|
||
:title="dialogStatus === 'create' ? '新增半成品入库' : '编辑半成品信息'"
|
||
width="1050px"
|
||
top="5vh"
|
||
destroy-on-close
|
||
:close-on-click-modal="false"
|
||
class="stylish-dialog"
|
||
>
|
||
<el-form :model="form" label-width="110px" ref="formRef" :rules="rules" size="large" class="stylish-form">
|
||
|
||
<div class="form-card basic-card">
|
||
<div class="card-title">
|
||
<el-icon class="icon"><Box /></el-icon>
|
||
<span>1. 基础信息</span>
|
||
<span class="sub-title" v-if="dialogStatus === 'create'"> (请先搜索选择半成品物料)</span>
|
||
</div>
|
||
|
||
<div class="card-content">
|
||
<el-row :gutter="24" v-if="dialogStatus === 'create'" style="margin-bottom: 20px;">
|
||
<el-col :span="10">
|
||
<el-form-item label="物料搜索" prop="base_id" class="highlight-label">
|
||
<el-select
|
||
v-model="form.base_id"
|
||
filterable
|
||
remote
|
||
reserve-keyword
|
||
placeholder="输入名称或规格..."
|
||
:remote-method="handleSearchMaterial"
|
||
@visible-change="handleMaterialDropdownVisible"
|
||
:loading="searchLoading"
|
||
style="width: 100%"
|
||
@change="onMaterialSelected"
|
||
size="large"
|
||
default-first-option
|
||
>
|
||
<el-option
|
||
v-for="item in materialOptions"
|
||
:key="item.id"
|
||
:label="item.name"
|
||
:value="item.id"
|
||
>
|
||
<div class="option-item">
|
||
<span class="opt-name">{{ item.name }}</span>
|
||
<span class="opt-spec">{{ item.spec }}</span>
|
||
<el-tag v-if="item.isHistory" size="small" type="info" effect="plain">历史</el-tag>
|
||
<el-tag v-else size="small" type="success" effect="plain">系统</el-tag>
|
||
</div>
|
||
</el-option>
|
||
</el-select>
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="14" style="display: flex; align-items: center;">
|
||
<span class="search-tip">
|
||
<el-icon><InfoFilled /></el-icon> 未输入时展示最新物料;输入关键词进行精确搜索。
|
||
</span>
|
||
</el-col>
|
||
</el-row>
|
||
|
||
<div class="read-only-grid">
|
||
<el-row :gutter="24">
|
||
<el-col :span="8"><el-form-item label="名称"><el-input v-model="form.material_name" disabled class="is-text-view" /></el-form-item></el-col>
|
||
<el-col :span="8"><el-form-item label="规格型号"><el-input v-model="form.spec_model" disabled class="is-text-view" /></el-form-item></el-col>
|
||
<el-col :span="8"><el-form-item label="单位"><el-input v-model="form.unit" disabled class="is-text-view" /></el-form-item></el-col>
|
||
<el-col :span="8"><el-form-item label="类别"><el-input v-model="form.category" disabled class="is-text-view" /></el-form-item></el-col>
|
||
<el-col :span="8"><el-form-item label="类型"><el-input v-model="form.material_type" disabled class="is-text-view" /></el-form-item></el-col>
|
||
</el-row>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-card inbound-card">
|
||
<div class="card-title">
|
||
<el-icon class="icon"><House /></el-icon>
|
||
<span>2. 入库详情</span>
|
||
</div>
|
||
|
||
<div class="card-content">
|
||
<el-row :gutter="24">
|
||
<el-col :span="6">
|
||
<el-form-item label="编码/SKU" prop="sku">
|
||
<el-input v-model="form.sku" placeholder="系统自动生成" disabled />
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="6">
|
||
<el-form-item label="入库日期" prop="in_date">
|
||
<el-date-picker v-model="form.in_date" type="date" value-format="YYYY-MM-DD" style="width:100%" disabled />
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="6"><el-form-item label="条码" prop="barcode"><el-input v-model="form.barcode" placeholder="扫描条码" /></el-form-item></el-col>
|
||
<el-col :span="6"><el-form-item label="库位" prop="warehouse_location"><el-input v-model="form.warehouse_location" placeholder="例如: B-01-01" /></el-form-item></el-col>
|
||
</el-row>
|
||
|
||
<div class="identity-panel">
|
||
<el-row>
|
||
<el-col :span="24" style="margin-bottom: 12px;">
|
||
<el-radio-group v-model="entryMode" @change="handleEntryModeChange" :disabled="modeLocked" class="custom-radio-group">
|
||
<el-radio-button label="batch">按批号入库 (Batch)</el-radio-button>
|
||
<el-radio-button label="serial">按序列号入库 (SN)</el-radio-button>
|
||
</el-radio-group>
|
||
<span v-if="modeLocked" class="locked-msg"><el-icon><Lock /></el-icon> 根据历史记录已锁定入库方式</span>
|
||
</el-col>
|
||
</el-row>
|
||
|
||
<el-row :gutter="24">
|
||
<el-col :span="12">
|
||
<el-form-item label="批号" prop="batch_number">
|
||
<el-input
|
||
v-model="form.batch_number"
|
||
:placeholder="entryMode === 'batch' ? '系统生成...' : '不可用'"
|
||
:disabled="entryMode === 'serial'"
|
||
clearable
|
||
>
|
||
<template #prefix><span class="prefix-tag bn">BN</span></template>
|
||
</el-input>
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="12">
|
||
<el-form-item label="序列号" prop="serial_number">
|
||
<el-input
|
||
v-model="form.serial_number"
|
||
:placeholder="entryMode === 'serial' ? '请扫描 SN...' : '不可用'"
|
||
:disabled="entryMode === 'batch'"
|
||
clearable
|
||
>
|
||
<template #prefix><span class="prefix-tag sn">SN</span></template>
|
||
</el-input>
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
</div>
|
||
|
||
<el-row :gutter="24" style="margin-top: 15px;">
|
||
<el-col :span="6">
|
||
<el-form-item label="入库数量" prop="in_quantity">
|
||
<el-input-number v-model="form.in_quantity" :min="1" controls-position="right" style="width:100%" class="strong-input" />
|
||
</el-form-item>
|
||
</el-col>
|
||
|
||
<template v-if="dialogStatus === 'update'">
|
||
<el-col :span="6">
|
||
<el-form-item label="当前库存" prop="stock_quantity">
|
||
<el-input-number v-model="form.stock_quantity" disabled style="width:100%" :controls="false" />
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="6">
|
||
<el-form-item label="当前可用" prop="available_quantity">
|
||
<el-input-number v-model="form.available_quantity" disabled style="width:100%" :controls="false" />
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="6">
|
||
<el-form-item label="库存状态" prop="status">
|
||
<el-select v-model="form.status" style="width:100%">
|
||
<el-option label="在库" value="在库" />
|
||
<el-option label="出库" value="出库" />
|
||
<el-option label="损耗" value="损耗" />
|
||
</el-select>
|
||
</el-form-item>
|
||
</el-col>
|
||
</template>
|
||
|
||
<el-col :span="6">
|
||
<el-form-item label="质量状态" prop="quality_status">
|
||
<el-select v-model="form.quality_status" style="width:100%">
|
||
<el-option label="待检" value="待检"><span style="color:#909399">⚪ 待检</span></el-option>
|
||
<el-option label="合格" value="合格"><span style="color:#67C23A">🟢 合格</span></el-option>
|
||
<el-option label="不合格" value="不合格"><span style="color:#F56C6C">🔴 不合格</span></el-option>
|
||
<el-option label="返修中" value="返修中"><span style="color:#E6A23C">🟠 返修中</span></el-option>
|
||
</el-select>
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="12"><el-form-item label="质量报告" prop="quality_report_link"><el-input v-model="form.quality_report_link" placeholder="报告链接 URL" /></el-form-item></el-col>
|
||
</el-row>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-card production-card">
|
||
<div class="card-title">
|
||
<el-icon class="icon"><Setting /></el-icon>
|
||
<span>3. 生产与成本信息</span>
|
||
</div>
|
||
<div class="card-content">
|
||
<div class="divider-text">生产任务信息</div>
|
||
<el-row :gutter="24">
|
||
<el-col :span="8"><el-form-item label="工单号"><el-input v-model="form.work_order_code" placeholder="WO-xxx" /></el-form-item></el-col>
|
||
<el-col :span="8"><el-form-item label="BOM编号"><el-input v-model="form.bom_code" placeholder="BOM-xxx" /></el-form-item></el-col>
|
||
<el-col :span="8"><el-form-item label="BOM版本"><el-input v-model="form.bom_version" placeholder="v1.0" /></el-form-item></el-col>
|
||
</el-row>
|
||
|
||
<el-row :gutter="24">
|
||
<el-col :span="8">
|
||
<el-form-item label="生产负责人">
|
||
<el-autocomplete
|
||
v-model="form.production_manager"
|
||
:fetch-suggestions="querySearchManager"
|
||
placeholder="输入或选择负责人"
|
||
style="width: 100%"
|
||
clearable
|
||
:trigger-on-focus="true"
|
||
@select="handleManagerSelect"
|
||
/>
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="16">
|
||
<el-form-item label="生产时间">
|
||
<el-date-picker
|
||
v-model="form.production_time_range"
|
||
type="datetimerange"
|
||
range-separator="至"
|
||
start-placeholder="开始时间"
|
||
end-placeholder="结束时间"
|
||
value-format="YYYY-MM-DD HH:mm:ss"
|
||
style="width: 100%"
|
||
/>
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
|
||
<div class="divider-text">成本核算 (单件)</div>
|
||
<el-row :gutter="24">
|
||
<el-col :span="8">
|
||
<el-form-item label="原材料成本">
|
||
<el-input-number v-model="form.raw_material_cost" :precision="2" controls-position="right" style="width:100%">
|
||
<template #prefix>¥</template>
|
||
</el-input-number>
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="8">
|
||
<el-form-item label="手动/工时">
|
||
<el-input-number v-model="form.manual_cost" :precision="2" controls-position="right" style="width:100%">
|
||
<template #prefix>¥</template>
|
||
</el-input-number>
|
||
</el-form-item>
|
||
</el-col>
|
||
<el-col :span="8">
|
||
<el-form-item label="单件总成本">
|
||
<el-input-number v-model="form.unit_total_cost" :precision="2" disabled :controls="false" style="width:100%" class="total-price-input">
|
||
<template #prefix>¥</template>
|
||
</el-input-number>
|
||
</el-form-item>
|
||
</el-col>
|
||
</el-row>
|
||
|
||
<el-row :gutter="24" style="margin-top:10px">
|
||
<el-col :span="24"><el-form-item label="详情链接"><el-input v-model="form.detail_link" placeholder="外部生产系统详情页 http://" /></el-form-item></el-col>
|
||
</el-row>
|
||
</div>
|
||
</div>
|
||
|
||
</el-form>
|
||
|
||
<template #footer>
|
||
<div class="dialog-footer">
|
||
<el-button @click="visible = false" size="large">取消</el-button>
|
||
<el-button type="primary" :loading="submitting" @click="submitForm" size="large" class="confirm-btn">
|
||
{{ dialogStatus === 'create' ? '确认入库并打印' : '保存修改' }}
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
<el-dialog
|
||
v-model="printVisible"
|
||
title="标签打印预览"
|
||
width="400px"
|
||
destroy-on-close
|
||
append-to-body
|
||
>
|
||
<div style="text-align: center;">
|
||
<div v-loading="printLoading" class="preview-box">
|
||
<img v-if="previewUrl" :src="previewUrl" alt="Label Preview" style="width: 100%; border: 1px solid #ccc;"/>
|
||
<div v-else class="empty-preview">正在生成预览...</div>
|
||
</div>
|
||
|
||
<div style="margin-top: 20px; font-size: 14px; color: #666;">
|
||
<p>打印机 IP: 192.168.9.205</p>
|
||
<p>尺寸: 40mm x 30mm</p>
|
||
</div>
|
||
</div>
|
||
<template #footer>
|
||
<div class="dialog-footer">
|
||
<el-button @click="printVisible = false">取消</el-button>
|
||
<el-button type="primary" :loading="printing" @click="confirmPrint">
|
||
<el-icon>
|
||
<Printer/>
|
||
</el-icon>
|
||
确认打印
|
||
</el-button>
|
||
</div>
|
||
</template>
|
||
</el-dialog>
|
||
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, reactive, onMounted, watch, computed } from 'vue'
|
||
import { Plus, Setting, Refresh, Search, Lock, Box, House, InfoFilled, Link, Printer } from '@element-plus/icons-vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import dayjs from 'dayjs'
|
||
import {
|
||
getSemiList,
|
||
createSemiInbound,
|
||
updateSemiInbound,
|
||
deleteSemiInbound,
|
||
searchMaterialBase
|
||
} from '@/api/inbound/semi'
|
||
import { getLabelPreview, executePrint } from '@/api/common/print'
|
||
|
||
// ------------------------------------
|
||
// 状态与变量
|
||
// ------------------------------------
|
||
const loading = ref(false)
|
||
const submitting = ref(false)
|
||
const visible = ref(false)
|
||
const searchLoading = ref(false)
|
||
const dialogStatus = ref<'create' | 'update'>('create')
|
||
const tableData = ref([])
|
||
const total = ref(0)
|
||
const formRef = ref()
|
||
const queryParams = reactive({ page: 1, pageSize: 15, keyword: '' })
|
||
const materialOptions = ref<any[]>([])
|
||
|
||
// 打印相关变量
|
||
const printVisible = ref(false)
|
||
const printLoading = ref(false)
|
||
const printing = ref(false)
|
||
const previewUrl = ref('')
|
||
const currentPrintData = ref<any>({})
|
||
|
||
const entryMode = ref('batch')
|
||
const modeLocked = ref(false)
|
||
|
||
// 列定义
|
||
const baseColumns = [
|
||
{ prop: 'material_name', label: '名称' },
|
||
{ prop: 'category', label: '类别' },
|
||
{ prop: 'material_type', label: '类型' },
|
||
{ prop: 'spec_model', label: '规格型号' },
|
||
{ prop: 'unit', label: '单位' },
|
||
]
|
||
|
||
// 半成品特有的列定义
|
||
const stockColumns = [
|
||
{ prop: 'id', label: 'ID', minWidth: '60' },
|
||
{ prop: 'base_id', label: 'BaseID', minWidth: '80' },
|
||
{ prop: 'sku', label: 'SKU', minWidth: '120' },
|
||
{ prop: 'inbound_date', label: '入库日期', minWidth: '120' },
|
||
{ prop: 'barcode', label: '条码', minWidth: '120' },
|
||
{ prop: 'serial_number', label: '序列号', minWidth: '150' },
|
||
{ prop: 'batch_number', label: '批号', minWidth: '150' },
|
||
{ prop: 'status', label: '状态', minWidth: '100' },
|
||
{ prop: 'quality_status', label: '质量状态', minWidth: '100' }, // 对应 inspection_status
|
||
{ prop: 'qty_inbound', label: '入库量', minWidth: '100' },
|
||
{ prop: 'qty_stock', label: '库存数', minWidth: '100' },
|
||
{ prop: 'qty_available', label: '可用数', minWidth: '100' },
|
||
{ prop: 'warehouse_loc', label: '库位', minWidth: '120' },
|
||
// 新增字段
|
||
{ prop: 'bom_code', label: 'BOM编号', minWidth: '120' },
|
||
{ prop: 'bom_version', label: 'BOM版本', minWidth: '90' },
|
||
{ prop: 'work_order_code', label: '工单号', minWidth: '120' },
|
||
{ prop: 'raw_material_cost', label: '原料成本', minWidth: '100' },
|
||
{ prop: 'manual_cost', label: '人工成本', minWidth: '100' },
|
||
{ prop: 'production_manager', label: '生产负责人', minWidth: '100' },
|
||
{ prop: 'production_start_time', label: '生产开始', minWidth: '160' },
|
||
{ prop: 'production_end_time', label: '生产结束', minWidth: '160' },
|
||
{ prop: 'quality_report_link', label: '质量报告', minWidth: '100' },
|
||
{ prop: 'detail_link', label: '详情链接', minWidth: '100' },
|
||
]
|
||
|
||
const allColumns = [...baseColumns, ...stockColumns]
|
||
|
||
// 表头持久化
|
||
const STORAGE_KEY = 'stock_semi_visible_columns'
|
||
const defaultColumns = [
|
||
'material_name', 'spec_model', 'unit',
|
||
'inbound_date', 'serial_number', 'batch_number', 'status', 'quality_status',
|
||
'bom_code', 'work_order_code', 'qty_stock', 'qty_available'
|
||
]
|
||
|
||
const getSavedColumns = () => {
|
||
try {
|
||
const saved = localStorage.getItem(STORAGE_KEY)
|
||
return saved ? JSON.parse(saved) : defaultColumns
|
||
} catch (e) {
|
||
return defaultColumns
|
||
}
|
||
}
|
||
|
||
const visibleColumnProps = ref(getSavedColumns())
|
||
|
||
watch(visibleColumnProps, (newVal) => {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(newVal))
|
||
}, { deep: true })
|
||
|
||
|
||
const form = reactive({
|
||
id: undefined,
|
||
base_id: undefined as number | undefined,
|
||
material_name: '', spec_model: '', category: '', unit: '', material_type: '',
|
||
sku: '', barcode: '', in_date: '',
|
||
serial_number: '', batch_number: '',
|
||
status: '在库',
|
||
quality_status: '合格', // 原 inspection_status
|
||
in_quantity: 1, stock_quantity: 1, available_quantity: 1,
|
||
warehouse_location: '',
|
||
// 半成品特有字段
|
||
bom_code: '',
|
||
bom_version: '',
|
||
work_order_code: '',
|
||
raw_material_cost: 0,
|
||
manual_cost: 0,
|
||
unit_total_cost: 0, // 计算字段
|
||
production_manager: '',
|
||
production_time_range: [] as string[], // [start, end]
|
||
quality_report_link: '',
|
||
detail_link: ''
|
||
})
|
||
|
||
// ------------------------------------
|
||
// 历史记录管理器 (Local Storage)
|
||
// ------------------------------------
|
||
const HISTORY_KEYS = {
|
||
PRODUCTION_MANAGER: 'history_production_managers',
|
||
MATERIAL: 'history_semi_materials'
|
||
}
|
||
|
||
// 保存历史 (String 类型)
|
||
const saveToHistory = (key: string, value: string) => {
|
||
if (!value) return
|
||
try {
|
||
const existing = localStorage.getItem(key)
|
||
let list = existing ? JSON.parse(existing) : []
|
||
// 移除旧的,添加到前面
|
||
list = list.filter((i: string) => i !== value)
|
||
list.unshift(value)
|
||
if (list.length > 20) list = list.slice(0, 20) // 最多存20条
|
||
localStorage.setItem(key, JSON.stringify(list))
|
||
} catch (e) { console.error('save history failed', e) }
|
||
}
|
||
|
||
// 获取历史 (String 类型)
|
||
const getHistoryList = (key: string): any[] => {
|
||
try {
|
||
const existing = localStorage.getItem(key)
|
||
const list = existing ? JSON.parse(existing) : []
|
||
return list.map((v: string) => ({ value: v }))
|
||
} catch (e) { return [] }
|
||
}
|
||
|
||
// 保存物料历史 (Object 类型)
|
||
const saveMaterialHistory = (item: any) => {
|
||
if (!item || !item.id) return
|
||
const key = HISTORY_KEYS.MATERIAL
|
||
try {
|
||
const existing = localStorage.getItem(key)
|
||
let list = existing ? JSON.parse(existing) : []
|
||
list = list.filter((i: any) => i.id !== item.id)
|
||
list.unshift({ ...item, isHistory: true })
|
||
if (list.length > 10) list = list.slice(0, 10)
|
||
localStorage.setItem(key, JSON.stringify(list))
|
||
} catch (e) {}
|
||
}
|
||
|
||
const getMaterialHistory = () => {
|
||
try {
|
||
const existing = localStorage.getItem(HISTORY_KEYS.MATERIAL)
|
||
return existing ? JSON.parse(existing) : []
|
||
} catch (e) { return [] }
|
||
}
|
||
|
||
// ------------------------------------
|
||
// Autocomplete 建议逻辑 (混合模式:历史+当前表格)
|
||
// ------------------------------------
|
||
const createFilter = (queryString: string) => {
|
||
return (item: any) => {
|
||
return (item.value.toLowerCase().indexOf(queryString.toLowerCase()) === 0)
|
||
}
|
||
}
|
||
|
||
// 辅助函数:从当前表格提取
|
||
const getTableDataUnique = (field: string) => {
|
||
const uniqueItems = Array.from(new Set(tableData.value.map((i: any) => i[field]).filter(Boolean)))
|
||
return uniqueItems.map(i => ({ value: i }))
|
||
}
|
||
|
||
// 通用查询: 历史记录 + 当前页面数据
|
||
const mixedSearch = (queryString: string, tableField: string, storageKey: string, cb: any) => {
|
||
const tableList = getTableDataUnique(tableField)
|
||
const historyList = getHistoryList(storageKey)
|
||
|
||
// 合并去重
|
||
const map = new Map()
|
||
historyList.forEach(i => map.set(i.value, i))
|
||
tableList.forEach(i => map.set(i.value, i))
|
||
|
||
const allList = Array.from(map.values())
|
||
const results = queryString ? allList.filter(createFilter(queryString)) : allList
|
||
cb(results)
|
||
}
|
||
|
||
// 1. 生产负责人
|
||
const querySearchManager = (qs: string, cb: any) => mixedSearch(qs, 'production_manager', HISTORY_KEYS.PRODUCTION_MANAGER, cb)
|
||
const handleManagerSelect = (item: any) => saveToHistory(HISTORY_KEYS.PRODUCTION_MANAGER, item.value)
|
||
|
||
|
||
// ------------------------------------
|
||
// 逻辑校验规则
|
||
// ------------------------------------
|
||
const validateUnique = (rule: any, value: string, callback: any) => {
|
||
if (!value) return callback()
|
||
const isDuplicate = tableData.value.some((row: any) => {
|
||
if (dialogStatus.value === 'update' && row.id === form.id) return false
|
||
if (rule.field === 'serial_number' && row.serial_number === value) return true
|
||
if (rule.field === 'batch_number' && row.batch_number === value) return true
|
||
return false
|
||
})
|
||
if (isDuplicate) {
|
||
callback(new Error('编号重复'))
|
||
} else {
|
||
callback()
|
||
}
|
||
}
|
||
|
||
const validateIdentity = (rule: any, value: any, callback: any) => {
|
||
if (entryMode.value === 'serial' && !form.serial_number && rule.field === 'serial_number') {
|
||
callback(new Error('SN必填'))
|
||
} else if (entryMode.value === 'batch' && !form.batch_number && rule.field === 'batch_number') {
|
||
callback(new Error('批号必填'))
|
||
} else {
|
||
callback()
|
||
}
|
||
}
|
||
|
||
const rules = {
|
||
base_id: [{ required: true, message: '请选择物料', trigger: 'change' }],
|
||
in_quantity: [{ required: true, message: '请输入数量', trigger: 'blur' }],
|
||
serial_number: [{ validator: validateIdentity, trigger: 'blur' }, { validator: validateUnique, trigger: 'blur' }],
|
||
batch_number: [{ validator: validateIdentity, trigger: 'blur' }, { validator: validateUnique, trigger: 'blur' }]
|
||
}
|
||
|
||
// ------------------------------------
|
||
// 核心逻辑函数
|
||
// ------------------------------------
|
||
const checkHistoryAndSetMode = async (baseId: number) => {
|
||
try {
|
||
const res: any = await getSemiList({ page: 1, pageSize: 1000 })
|
||
const allItems = res.data.items || []
|
||
const historyItems = allItems.filter((item: any) => item.base_id === baseId)
|
||
|
||
if (historyItems.length > 0) {
|
||
modeLocked.value = true
|
||
const latest = historyItems.sort((a: any, b: any) => b.id - a.id)[0]
|
||
if (latest.serial_number) {
|
||
entryMode.value = 'serial'
|
||
form.serial_number = ''
|
||
form.batch_number = ''
|
||
} else {
|
||
entryMode.value = 'batch'
|
||
form.serial_number = ''
|
||
const lastBatch = latest.batch_number || '000000'
|
||
form.batch_number = incrementBatchNumber(lastBatch)
|
||
}
|
||
} else {
|
||
modeLocked.value = false
|
||
entryMode.value = 'batch'
|
||
form.batch_number = '000001'
|
||
}
|
||
|
||
if(formRef.value) {
|
||
formRef.value.clearValidate('serial_number')
|
||
formRef.value.clearValidate('batch_number')
|
||
}
|
||
} catch (e) {
|
||
console.error(e)
|
||
modeLocked.value = false
|
||
entryMode.value = 'batch'
|
||
form.batch_number = '000001'
|
||
}
|
||
}
|
||
|
||
const incrementBatchNumber = (batchStr: string) => {
|
||
if (!batchStr || !/^\d+$/.test(batchStr)) return '000001'
|
||
const num = parseInt(batchStr, 10)
|
||
return (num + 1).toString().padStart(6, '0')
|
||
}
|
||
|
||
const handleEntryModeChange = (val: string) => {
|
||
if (val === 'batch') {
|
||
form.serial_number = ''
|
||
form.batch_number = '000001'
|
||
if(formRef.value) formRef.value.clearValidate('serial_number')
|
||
} else {
|
||
form.batch_number = ''
|
||
if(formRef.value) formRef.value.clearValidate('batch_number')
|
||
}
|
||
}
|
||
|
||
// ------------------------------------
|
||
// 物料搜索逻辑 (优化:支持空查询加载默认值)
|
||
// ------------------------------------
|
||
const handleMaterialDropdownVisible = (visible: boolean) => {
|
||
if (visible) {
|
||
if (materialOptions.value.length === 0) {
|
||
handleSearchMaterial('')
|
||
}
|
||
}
|
||
}
|
||
|
||
const handleSearchMaterial = async (query: string) => {
|
||
searchLoading.value = true
|
||
try {
|
||
const res: any = await searchMaterialBase(query)
|
||
const apiResults = (res.data || []).map((i: any) => ({ ...i, isHistory: false }))
|
||
|
||
if (!query) {
|
||
const history = getMaterialHistory()
|
||
const historyIds = new Set(history.map((h: any) => h.id))
|
||
const filteredApi = apiResults.filter((apiItem: any) => !historyIds.has(apiItem.id))
|
||
materialOptions.value = [...history, ...filteredApi]
|
||
} else {
|
||
materialOptions.value = apiResults
|
||
}
|
||
} finally {
|
||
searchLoading.value = false
|
||
}
|
||
}
|
||
|
||
const onMaterialSelected = (val: number) => {
|
||
const item = materialOptions.value.find(i => i.id === val)
|
||
if (item) {
|
||
saveMaterialHistory(item)
|
||
|
||
form.material_name = item.name
|
||
form.spec_model = item.spec
|
||
form.category = item.category
|
||
form.unit = item.unit
|
||
form.material_type = item.type
|
||
checkHistoryAndSetMode(item.id)
|
||
}
|
||
}
|
||
|
||
// 自动计算单件总成本
|
||
watch(() => [form.raw_material_cost, form.manual_cost], () => {
|
||
form.unit_total_cost = Number((form.raw_material_cost + form.manual_cost).toFixed(2))
|
||
})
|
||
|
||
const fetchData = async () => {
|
||
loading.value = true
|
||
try {
|
||
const res: any = await getSemiList(queryParams)
|
||
tableData.value = res.data.items || []
|
||
total.value = res.data.total || 0
|
||
} finally { loading.value = false }
|
||
}
|
||
|
||
const handleCreate = () => {
|
||
dialogStatus.value = 'create'
|
||
resetForm()
|
||
form.in_date = dayjs().format('YYYY-MM-DD')
|
||
modeLocked.value = false
|
||
entryMode.value = 'batch'
|
||
form.batch_number = ''
|
||
visible.value = true
|
||
// 每次打开弹窗时,先清空选项,让下拉时触发“历史加载”
|
||
materialOptions.value = []
|
||
}
|
||
|
||
const handleUpdate = (row: any) => {
|
||
dialogStatus.value = 'update'
|
||
resetForm()
|
||
modeLocked.value = true
|
||
|
||
form.id = row.id
|
||
form.base_id = row.base_id
|
||
form.material_name = row.material_name
|
||
form.spec_model = row.spec_model
|
||
form.category = row.category
|
||
form.unit = row.unit
|
||
form.material_type = row.material_type
|
||
form.sku = row.sku
|
||
form.barcode = row.barcode
|
||
form.in_date = row.inbound_date
|
||
form.warehouse_location = row.warehouse_loc
|
||
|
||
if (row.serial_number) {
|
||
entryMode.value = 'serial'
|
||
form.serial_number = row.serial_number
|
||
form.batch_number = ''
|
||
} else {
|
||
entryMode.value = 'batch'
|
||
form.batch_number = row.batch_number
|
||
form.serial_number = ''
|
||
}
|
||
|
||
form.status = row.status
|
||
form.quality_status = row.quality_status // 质量状态
|
||
form.in_quantity = Number(row.qty_inbound) || 0
|
||
form.stock_quantity = Number(row.qty_stock) || 0
|
||
form.available_quantity = Number(row.qty_available) || 0
|
||
|
||
// 映射新字段
|
||
form.bom_code = row.bom_code
|
||
form.bom_version = row.bom_version
|
||
form.work_order_code = row.work_order_code
|
||
form.raw_material_cost = Number(row.raw_material_cost) || 0
|
||
form.manual_cost = Number(row.manual_cost) || 0
|
||
form.production_manager = row.production_manager
|
||
|
||
if (row.production_start_time && row.production_end_time) {
|
||
form.production_time_range = [row.production_start_time, row.production_end_time]
|
||
} else {
|
||
form.production_time_range = []
|
||
}
|
||
|
||
form.quality_report_link = row.quality_report_link
|
||
form.detail_link = row.detail_link
|
||
|
||
// 编辑模式下,把当前物料塞入选项,防止显示为 ID
|
||
materialOptions.value = [{
|
||
id: row.base_id,
|
||
name: row.material_name,
|
||
spec: row.spec_model,
|
||
category: row.category
|
||
}]
|
||
|
||
visible.value = true
|
||
}
|
||
|
||
// ------------------------------------
|
||
// 提交逻辑 (新增自动打印逻辑)
|
||
// ------------------------------------
|
||
const submitForm = async () => {
|
||
if (!formRef.value) return
|
||
await formRef.value.validate(async (valid: boolean) => {
|
||
if (valid) {
|
||
submitting.value = true
|
||
try {
|
||
// 解构时间范围
|
||
const payload: any = {
|
||
...form,
|
||
in_quantity: Number(form.in_quantity),
|
||
raw_material_cost: Number(form.raw_material_cost),
|
||
manual_cost: Number(form.manual_cost),
|
||
production_start_time: form.production_time_range?.[0] || null,
|
||
production_end_time: form.production_time_range?.[1] || null
|
||
}
|
||
delete payload.production_time_range // 后端不需要数组
|
||
|
||
if (dialogStatus.value === 'create') {
|
||
// 1. 创建入库
|
||
const res: any = await createSemiInbound(payload)
|
||
ElMessage.success('入库成功')
|
||
|
||
// 2. 自动打印 (使用返回的完整数据)
|
||
// res.data 包含了 newly created stock item, with generated ID and SKU
|
||
const newItem = res.data
|
||
if (newItem) {
|
||
ElMessage.info('正在发送打印指令...')
|
||
try {
|
||
await executePrint(newItem)
|
||
ElMessage.success('打印指令已发送')
|
||
} catch (printErr: any) {
|
||
console.error(printErr)
|
||
ElMessage.warning('入库成功,但自动打印失败:' + (printErr.msg || '未知错误'))
|
||
}
|
||
}
|
||
|
||
} else {
|
||
await updateSemiInbound(form.id!, payload)
|
||
ElMessage.success('更新成功')
|
||
}
|
||
|
||
// 保存生产负责人信息到历史记录
|
||
saveToHistory(HISTORY_KEYS.PRODUCTION_MANAGER, form.production_manager)
|
||
|
||
await fetchData()
|
||
visible.value = false
|
||
} catch (e: any) {
|
||
ElMessage.error(e.msg || '操作失败')
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
})
|
||
}
|
||
|
||
const handleDelete = async (row: any) => {
|
||
try {
|
||
await deleteSemiInbound(row.id)
|
||
ElMessage.success('删除成功')
|
||
fetchData()
|
||
} catch (e) {
|
||
ElMessage.error('删除失败')
|
||
}
|
||
}
|
||
|
||
// ------------------------------------
|
||
// 打印逻辑 (手动 & 预览)
|
||
// ------------------------------------
|
||
const handlePrint = async (row: any) => {
|
||
printVisible.value = true
|
||
printLoading.value = true
|
||
previewUrl.value = ''
|
||
|
||
const printData = {
|
||
global_print_id: row.global_print_id,
|
||
material_name: row.material_name,
|
||
spec_model: row.spec_model,
|
||
category: row.category,
|
||
material_type: row.material_type,
|
||
warehouse_loc: row.warehouse_loc,
|
||
serial_number: row.serial_number,
|
||
batch_number: row.batch_number,
|
||
sku: row.sku // 【重要】显式增加 SKU 字段传递给后端
|
||
}
|
||
currentPrintData.value = printData
|
||
|
||
try {
|
||
const res: any = await getLabelPreview(printData)
|
||
previewUrl.value = res.data
|
||
} catch (e) {
|
||
ElMessage.error('预览生成失败')
|
||
} finally {
|
||
printLoading.value = false
|
||
}
|
||
}
|
||
|
||
const confirmPrint = async () => {
|
||
printing.value = true
|
||
try {
|
||
await executePrint(currentPrintData.value)
|
||
ElMessage.success('指令已发送')
|
||
printVisible.value = false
|
||
} catch (e: any) {
|
||
ElMessage.error(e.msg || '打印失败')
|
||
} finally {
|
||
printing.value = false
|
||
}
|
||
}
|
||
|
||
const resetForm = () => {
|
||
materialOptions.value = []
|
||
Object.assign(form, {
|
||
id: undefined, base_id: undefined,
|
||
material_name: '', spec_model: '', category: '', unit: '', material_type: '',
|
||
sku: '', barcode: '', in_date: '',
|
||
serial_number: '', batch_number: '',
|
||
status: '在库', quality_status: '合格',
|
||
in_quantity: 1, stock_quantity: 1, available_quantity: 1,
|
||
warehouse_location: '',
|
||
bom_code: '', bom_version: '', work_order_code: '',
|
||
raw_material_cost: 0, manual_cost: 0, unit_total_cost: 0,
|
||
production_manager: '', production_time_range: [],
|
||
quality_report_link: '', detail_link: ''
|
||
})
|
||
}
|
||
|
||
const getStatusType = (status: string) => {
|
||
const map: any = { '在库': 'success', '出库': 'info', '损耗': 'danger' }
|
||
return map[status] || 'warning'
|
||
}
|
||
|
||
const getQualityType = (status: string) => {
|
||
const map: any = { '合格': 'success', '不合格': 'danger', '待检': 'info', '返修中': 'warning' }
|
||
return map[status] || 'info'
|
||
}
|
||
|
||
const formatMoney = (val: any) => {
|
||
const num = Number(val)
|
||
return isNaN(num) ? '-' : `¥ ${num.toFixed(2)}`
|
||
}
|
||
|
||
onMounted(() => fetchData())
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* 全局布局 */
|
||
.semi-module {
|
||
background: #f5f7fa;
|
||
padding: 20px;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
/* 顶部工具栏 */
|
||
.header-tools {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 20px;
|
||
background: #fff;
|
||
padding: 15px 20px;
|
||
border-radius: 8px;
|
||
box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05);
|
||
}
|
||
.left-tools { flex: 0 0 350px; }
|
||
.right-tools { display: flex; gap: 10px; align-items: center; }
|
||
.action-btn { font-weight: 500; }
|
||
|
||
/* 表格美化 */
|
||
.modern-table {
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
box-shadow: 0 2px 12px 0 rgba(0,0,0,0.05);
|
||
}
|
||
:deep(.table-header-gray th) {
|
||
background-color: #f8f9fb !important;
|
||
color: #606266;
|
||
font-weight: 600;
|
||
height: 50px;
|
||
}
|
||
.tag-sn { color: #409EFF; font-weight: bold; font-family: monospace; }
|
||
.tag-bn { color: #67C23A; font-weight: bold; font-family: monospace; }
|
||
.money-text { font-family: 'Consolas', monospace; color: #303133; }
|
||
.stock-num { font-weight: bold; color: #333; font-size: 15px; }
|
||
.avail-num { font-weight: bold; color: #67C23A; font-size: 15px; }
|
||
.sum-tag { margin-left: 4px; transform: scale(0.9); }
|
||
|
||
/* 弹窗与表单美化 */
|
||
.stylish-form .form-card {
|
||
background: #fff;
|
||
border-radius: 8px;
|
||
border: 1px solid #e4e7ed;
|
||
margin-bottom: 20px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.card-title {
|
||
background: #fcfcfc;
|
||
padding: 12px 20px;
|
||
border-bottom: 1px solid #ebeef5;
|
||
font-weight: 600;
|
||
font-size: 15px;
|
||
color: #303133;
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
.card-title .icon { margin-right: 8px; font-size: 18px; color: #409EFF; }
|
||
.card-title .sub-title { font-size: 12px; color: #909399; font-weight: normal; margin-left: 10px; }
|
||
|
||
.card-content { padding: 20px; }
|
||
|
||
/* 基础信息卡片 (蓝色调,示读) */
|
||
.basic-card { border-left: 4px solid #409EFF; }
|
||
.search-tip { color: #909399; font-size: 12px; margin-left: 10px; display: flex; align-items: center; gap: 4px; }
|
||
.is-text-view :deep(.el-input__wrapper) {
|
||
box-shadow: none !important;
|
||
background-color: #f5f7fa;
|
||
border-bottom: 1px solid #dcdfe6;
|
||
border-radius: 0;
|
||
padding-left: 0;
|
||
}
|
||
.is-text-view :deep(.el-input__inner) { color: #606266; font-weight: 500; }
|
||
|
||
/* 入库信息卡片 */
|
||
.inbound-card { border-left: 4px solid #67C23A; }
|
||
|
||
/* 生产与成本卡片 (橙色调) */
|
||
.production-card { border-left: 4px solid #E6A23C; }
|
||
.production-card .card-title .icon { color: #E6A23C; }
|
||
|
||
|
||
/* 身份区域 (SN/BN) */
|
||
.identity-panel {
|
||
background: #fffbf0;
|
||
border: 1px dashed #e6a23c;
|
||
border-radius: 6px;
|
||
padding: 15px;
|
||
margin-bottom: 20px;
|
||
}
|
||
.custom-radio-group { margin-bottom: 10px; }
|
||
.locked-msg { font-size: 12px; color: #e6a23c; margin-left: 15px; }
|
||
.prefix-tag { font-weight: bold; font-size: 12px; padding: 0 5px; border-radius: 4px; }
|
||
.prefix-tag.bn { color: #67C23A; background: #f0f9eb; }
|
||
.prefix-tag.sn { color: #409EFF; background: #ecf5ff; }
|
||
|
||
/* 分割线 */
|
||
.divider-text {
|
||
display: flex;
|
||
align-items: center;
|
||
text-align: center;
|
||
margin: 30px 0 20px;
|
||
color: #909399;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
}
|
||
.divider-text::before, .divider-text::after {
|
||
content: '';
|
||
flex: 1;
|
||
border-bottom: 1px solid #ebeef5;
|
||
}
|
||
.divider-text::before { margin-right: 15px; }
|
||
.divider-text::after { margin-left: 15px; }
|
||
|
||
/* 底部按钮 */
|
||
.dialog-footer { display: flex; justify-content: flex-end; gap: 15px; margin-top: 20px; }
|
||
.info-alert { font-size: 12px; color: #909399; margin-top: 10px; display: flex; align-items: center; gap: 5px; }
|
||
.option-item { display: flex; justify-content: space-between; width: 100%; align-items: center;}
|
||
.opt-name { font-weight: bold; }
|
||
.opt-spec { color: #8492a6; font-size: 13px; margin-right: 10px; }
|
||
.total-price-input :deep(.el-input__inner) { color: #F56C6C; font-weight: bold; }
|
||
.preview-box {
|
||
min-height: 150px;
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
background: #f5f7fa;
|
||
border-radius: 4px;
|
||
}
|
||
.empty-preview {
|
||
color: #909399;
|
||
}
|
||
</style> |