feat(warehouse): 新增批量导入功能并优化系统配置

- 添加批量导入页面支持Excel和图片上传解析
- 实现移动端批量导入功能界面和交互
- 集成图片压缩上传功能提升用户体验
- 增加批量任务状态管理和进度跟踪
- 完善批量数据确认和执行流程
- 配置环境变量支持Docker部署
- 提高文件上传大小限制增强实用性
- 优化系统配置以适应生产环境需求
This commit is contained in:
lixiaolong 2026-07-17 15:00:06 +08:00
parent d872e3e235
commit e76c07fb5e
18 changed files with 2917 additions and 5 deletions

View File

@ -0,0 +1,80 @@
package com.ruoyi.web.controller.warehouse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.warehouse.domain.WmsBatchActionUpdateRequest;
import com.ruoyi.warehouse.domain.WmsBatchItem;
import com.ruoyi.warehouse.domain.WmsBatchJob;
import com.ruoyi.warehouse.service.IWmsBatchImportService;
@RestController
@RequestMapping("/warehouse/batch")
public class WmsBatchImportController extends BaseController
{
@Autowired
private IWmsBatchImportService batchImportService;
@GetMapping("/list")
public TableDataInfo list(WmsBatchJob job)
{
startPage();
return getDataTable(batchImportService.selectJobList(job));
}
@PostMapping("/upload")
@Log(title = "WMS批量导入", businessType = BusinessType.IMPORT)
public AjaxResult upload(MultipartFile file) throws Exception
{
return success(batchImportService.uploadExcel(file, getUsername()));
}
@GetMapping("/{id}")
public AjaxResult getInfo(@PathVariable Long id)
{
return success(batchImportService.selectJobById(id));
}
@GetMapping("/{jobId}/items")
public AjaxResult items(@PathVariable Long jobId)
{
return success(batchImportService.selectItemList(jobId));
}
@PutMapping("/item")
public AjaxResult updateItem(@RequestBody WmsBatchItem item)
{
return success(batchImportService.updateItem(item));
}
@PutMapping("/{jobId}/items/action")
public AjaxResult updateItemAction(@PathVariable Long jobId, @RequestBody WmsBatchActionUpdateRequest request)
{
return toAjax(batchImportService.updateItemAction(jobId, request.getActionType(), request.getItemIds()));
}
@DeleteMapping("/item/{id}")
public AjaxResult deleteItem(@PathVariable Long id)
{
return toAjax(batchImportService.deleteItem(id));
}
@PostMapping("/{jobId}/confirm")
@Log(title = "WMS批量确认执行", businessType = BusinessType.INSERT)
public AjaxResult confirm(@PathVariable Long jobId)
{
return success(batchImportService.confirmJob(jobId, getUsername()));
}
}

View File

@ -6,8 +6,8 @@ ruoyi:
version: 3.9.2 version: 3.9.2
# 版权年份 # 版权年份
copyrightYear: 2026 copyrightYear: 2026
# 文件路径 示例( Windows配置D:/ruoyi/uploadPathLinux配置 /home/ruoyi/uploadPath # 文件路径 示例( Windows配置D:/ruoyi/uploadPathLinux/Docker配置 /app/uploadPath
profile: D:/ruoyi/uploadPath profile: ${RUOYI_PROFILE:D:/ruoyi/uploadPath}
# 获取ip地址开关 # 获取ip地址开关
addressEnabled: false addressEnabled: false
# 验证码类型 math 数字计算 char 字符验证 # 验证码类型 math 数字计算 char 字符验证
@ -56,9 +56,9 @@ spring:
servlet: servlet:
multipart: multipart:
# 单个文件大小 # 单个文件大小
max-file-size: 10MB max-file-size: ${MAX_FILE_SIZE:50MB}
# 设置总上传的文件大小 # 设置总上传的文件大小
max-request-size: 20MB max-request-size: ${MAX_REQUEST_SIZE:60MB}
# 服务模块 # 服务模块
devtools: devtools:
restart: restart:

View File

@ -0,0 +1,29 @@
package com.ruoyi.warehouse.domain;
import java.util.List;
public class WmsBatchActionUpdateRequest
{
private String actionType;
private List<Long> itemIds;
public String getActionType()
{
return actionType;
}
public void setActionType(String actionType)
{
this.actionType = actionType;
}
public List<Long> getItemIds()
{
return itemIds;
}
public void setItemIds(List<Long> itemIds)
{
this.itemIds = itemIds;
}
}

View File

@ -0,0 +1,74 @@
package com.ruoyi.warehouse.domain;
import java.math.BigDecimal;
import com.ruoyi.common.core.domain.BaseEntity;
public class WmsBatchItem extends BaseEntity
{
private static final long serialVersionUID = 1L;
private Long id;
private Long jobId;
private Integer rowNo;
private String actionType;
private String rawText;
private String parsedJson;
private Integer status;
private String errorMsg;
private String warningMsg;
private Long refId;
private String areaName;
private String productName;
private String brand;
private String category;
private String spec;
private String unit;
private BigDecimal quantity;
private String operatorName;
private Integer typeCode;
private String sourceNo;
private String remark;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getJobId() { return jobId; }
public void setJobId(Long jobId) { this.jobId = jobId; }
public Integer getRowNo() { return rowNo; }
public void setRowNo(Integer rowNo) { this.rowNo = rowNo; }
public String getActionType() { return actionType; }
public void setActionType(String actionType) { this.actionType = actionType; }
public String getRawText() { return rawText; }
public void setRawText(String rawText) { this.rawText = rawText; }
public String getParsedJson() { return parsedJson; }
public void setParsedJson(String parsedJson) { this.parsedJson = parsedJson; }
public Integer getStatus() { return status; }
public void setStatus(Integer status) { this.status = status; }
public String getErrorMsg() { return errorMsg; }
public void setErrorMsg(String errorMsg) { this.errorMsg = errorMsg; }
public String getWarningMsg() { return warningMsg; }
public void setWarningMsg(String warningMsg) { this.warningMsg = warningMsg; }
public Long getRefId() { return refId; }
public void setRefId(Long refId) { this.refId = refId; }
public String getAreaName() { return areaName; }
public void setAreaName(String areaName) { this.areaName = areaName; }
public String getProductName() { return productName; }
public void setProductName(String productName) { this.productName = productName; }
public String getBrand() { return brand; }
public void setBrand(String brand) { this.brand = brand; }
public String getCategory() { return category; }
public void setCategory(String category) { this.category = category; }
public String getSpec() { return spec; }
public void setSpec(String spec) { this.spec = spec; }
public String getUnit() { return unit; }
public void setUnit(String unit) { this.unit = unit; }
public BigDecimal getQuantity() { return quantity; }
public void setQuantity(BigDecimal quantity) { this.quantity = quantity; }
public String getOperatorName() { return operatorName; }
public void setOperatorName(String operatorName) { this.operatorName = operatorName; }
public Integer getTypeCode() { return typeCode; }
public void setTypeCode(Integer typeCode) { this.typeCode = typeCode; }
public String getSourceNo() { return sourceNo; }
public void setSourceNo(String sourceNo) { this.sourceNo = sourceNo; }
public String getRemark() { return remark; }
public void setRemark(String remark) { this.remark = remark; }
}

View File

@ -0,0 +1,141 @@
package com.ruoyi.warehouse.domain;
import com.ruoyi.common.core.domain.BaseEntity;
public class WmsBatchJob extends BaseEntity
{
private static final long serialVersionUID = 1L;
private Long id;
private String jobNo;
private String sourceType;
private String bizType;
private String fileName;
private String filePath;
private Integer status;
private Integer progress;
private Integer totalCount;
private Integer successCount;
private Integer failCount;
private String errorMsg;
public Long getId()
{
return id;
}
public void setId(Long id)
{
this.id = id;
}
public String getJobNo()
{
return jobNo;
}
public void setJobNo(String jobNo)
{
this.jobNo = jobNo;
}
public String getSourceType()
{
return sourceType;
}
public void setSourceType(String sourceType)
{
this.sourceType = sourceType;
}
public String getBizType()
{
return bizType;
}
public void setBizType(String bizType)
{
this.bizType = bizType;
}
public String getFileName()
{
return fileName;
}
public void setFileName(String fileName)
{
this.fileName = fileName;
}
public String getFilePath()
{
return filePath;
}
public void setFilePath(String filePath)
{
this.filePath = filePath;
}
public Integer getStatus()
{
return status;
}
public void setStatus(Integer status)
{
this.status = status;
}
public Integer getProgress()
{
return progress;
}
public void setProgress(Integer progress)
{
this.progress = progress;
}
public Integer getTotalCount()
{
return totalCount;
}
public void setTotalCount(Integer totalCount)
{
this.totalCount = totalCount;
}
public Integer getSuccessCount()
{
return successCount;
}
public void setSuccessCount(Integer successCount)
{
this.successCount = successCount;
}
public Integer getFailCount()
{
return failCount;
}
public void setFailCount(Integer failCount)
{
this.failCount = failCount;
}
public String getErrorMsg()
{
return errorMsg;
}
public void setErrorMsg(String errorMsg)
{
this.errorMsg = errorMsg;
}
}

View File

@ -0,0 +1,15 @@
package com.ruoyi.warehouse.mapper;
import java.util.List;
import com.ruoyi.warehouse.domain.WmsBatchItem;
public interface WmsBatchItemMapper
{
public List<WmsBatchItem> selectWmsBatchItemList(WmsBatchItem item);
public WmsBatchItem selectWmsBatchItemById(Long id);
public int insertWmsBatchItem(WmsBatchItem item);
public int updateWmsBatchItem(WmsBatchItem item);
public int deleteWmsBatchItemById(Long id);
public int deleteWmsBatchItemByJobId(Long jobId);
public int countByJobIdAndStatus(WmsBatchItem item);
}

View File

@ -0,0 +1,12 @@
package com.ruoyi.warehouse.mapper;
import java.util.List;
import com.ruoyi.warehouse.domain.WmsBatchJob;
public interface WmsBatchJobMapper
{
public List<WmsBatchJob> selectWmsBatchJobList(WmsBatchJob job);
public WmsBatchJob selectWmsBatchJobById(Long id);
public int insertWmsBatchJob(WmsBatchJob job);
public int updateWmsBatchJob(WmsBatchJob job);
}

View File

@ -0,0 +1,18 @@
package com.ruoyi.warehouse.service;
import java.util.List;
import org.springframework.web.multipart.MultipartFile;
import com.ruoyi.warehouse.domain.WmsBatchItem;
import com.ruoyi.warehouse.domain.WmsBatchJob;
public interface IWmsBatchImportService
{
public WmsBatchJob uploadExcel(MultipartFile file, String username) throws Exception;
public List<WmsBatchJob> selectJobList(WmsBatchJob job);
public WmsBatchJob selectJobById(Long id);
public List<WmsBatchItem> selectItemList(Long jobId);
public WmsBatchItem updateItem(WmsBatchItem item);
public int updateItemAction(Long jobId, String actionType, List<Long> itemIds);
public int deleteItem(Long id);
public WmsBatchJob confirmJob(Long jobId, String username);
}

View File

@ -0,0 +1,98 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.warehouse.mapper.WmsBatchItemMapper">
<resultMap type="WmsBatchItem" id="WmsBatchItemResult">
<id property="id" column="id"/>
<result property="jobId" column="job_id"/>
<result property="rowNo" column="row_no"/>
<result property="actionType" column="action_type"/>
<result property="rawText" column="raw_text"/>
<result property="parsedJson" column="parsed_json"/>
<result property="status" column="status"/>
<result property="errorMsg" column="error_msg"/>
<result property="warningMsg" column="warning_msg"/>
<result property="refId" column="ref_id"/>
<result property="areaName" column="area_name"/>
<result property="productName" column="product_name"/>
<result property="brand" column="brand"/>
<result property="category" column="category"/>
<result property="spec" column="spec"/>
<result property="unit" column="unit"/>
<result property="quantity" column="quantity"/>
<result property="operatorName" column="operator_name"/>
<result property="typeCode" column="type_code"/>
<result property="sourceNo" column="source_no"/>
<result property="remark" column="remark"/>
<result property="createTime" column="create_time"/>
<result property="updateTime" column="update_time"/>
</resultMap>
<sql id="selectWmsBatchItemVo">
select id, job_id, row_no, action_type, raw_text, parsed_json, status, error_msg, warning_msg, ref_id,
area_name, product_name, brand, category, spec, unit, quantity, operator_name, type_code,
source_no, remark, create_time, update_time
from wms_batch_item
</sql>
<select id="selectWmsBatchItemList" parameterType="WmsBatchItem" resultMap="WmsBatchItemResult">
<include refid="selectWmsBatchItemVo"/>
<where>
<if test="jobId != null">and job_id = #{jobId}</if>
<if test="status != null">and status = #{status}</if>
<if test="actionType != null and actionType != ''">and action_type = #{actionType}</if>
</where>
order by row_no asc, id asc
</select>
<select id="selectWmsBatchItemById" parameterType="Long" resultMap="WmsBatchItemResult">
<include refid="selectWmsBatchItemVo"/> where id = #{id}
</select>
<select id="countByJobIdAndStatus" parameterType="WmsBatchItem" resultType="int">
select count(1) from wms_batch_item where job_id = #{jobId} and status = #{status}
</select>
<insert id="insertWmsBatchItem" parameterType="WmsBatchItem" useGeneratedKeys="true" keyProperty="id">
insert into wms_batch_item(job_id, row_no, action_type, raw_text, parsed_json, status, error_msg, warning_msg,
ref_id, area_name, product_name, brand, category, spec, unit, quantity,
operator_name, type_code, source_no, remark, create_time)
values(#{jobId}, #{rowNo}, #{actionType}, #{rawText}, #{parsedJson}, #{status}, #{errorMsg}, #{warningMsg},
#{refId}, #{areaName}, #{productName}, #{brand}, #{category}, #{spec}, #{unit}, #{quantity},
#{operatorName}, #{typeCode}, #{sourceNo}, #{remark}, sysdate())
</insert>
<update id="updateWmsBatchItem" parameterType="WmsBatchItem">
update wms_batch_item
<set>
row_no = #{rowNo},
action_type = #{actionType},
raw_text = #{rawText},
parsed_json = #{parsedJson},
status = #{status},
error_msg = #{errorMsg},
warning_msg = #{warningMsg},
ref_id = #{refId},
area_name = #{areaName},
product_name = #{productName},
brand = #{brand},
category = #{category},
spec = #{spec},
unit = #{unit},
quantity = #{quantity},
operator_name = #{operatorName},
type_code = #{typeCode},
source_no = #{sourceNo},
remark = #{remark},
update_time = sysdate()
</set>
where id = #{id}
</update>
<delete id="deleteWmsBatchItemById" parameterType="Long">
delete from wms_batch_item where id = #{id}
</delete>
<delete id="deleteWmsBatchItemByJobId" parameterType="Long">
delete from wms_batch_item where job_id = #{value}
</delete>
</mapper>

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.warehouse.mapper.WmsBatchJobMapper">
<resultMap type="WmsBatchJob" id="WmsBatchJobResult">
<id property="id" column="id"/>
<result property="jobNo" column="job_no"/>
<result property="sourceType" column="source_type"/>
<result property="bizType" column="biz_type"/>
<result property="fileName" column="file_name"/>
<result property="filePath" column="file_path"/>
<result property="status" column="status"/>
<result property="progress" column="progress"/>
<result property="totalCount" column="total_count"/>
<result property="successCount" column="success_count"/>
<result property="failCount" column="fail_count"/>
<result property="errorMsg" column="error_msg"/>
<result property="createBy" column="create_by"/>
<result property="createTime" column="create_time"/>
<result property="updateTime" column="update_time"/>
</resultMap>
<sql id="selectWmsBatchJobVo">
select id, job_no, source_type, biz_type, file_name, file_path, status, progress,
total_count, success_count, fail_count, error_msg, create_by, create_time, update_time
from wms_batch_job
</sql>
<select id="selectWmsBatchJobList" parameterType="WmsBatchJob" resultMap="WmsBatchJobResult">
<include refid="selectWmsBatchJobVo"/>
<where>
<if test="jobNo != null and jobNo != ''">and job_no like concat('%', #{jobNo}, '%')</if>
<if test="sourceType != null and sourceType != ''">and source_type = #{sourceType}</if>
<if test="bizType != null and bizType != ''">and biz_type = #{bizType}</if>
<if test="status != null">and status = #{status}</if>
</where>
order by id desc
</select>
<select id="selectWmsBatchJobById" parameterType="Long" resultMap="WmsBatchJobResult">
<include refid="selectWmsBatchJobVo"/> where id = #{id}
</select>
<insert id="insertWmsBatchJob" parameterType="WmsBatchJob" useGeneratedKeys="true" keyProperty="id">
insert into wms_batch_job(job_no, source_type, biz_type, file_name, file_path, status, progress,
total_count, success_count, fail_count, error_msg, create_by, create_time)
values(#{jobNo}, #{sourceType}, #{bizType}, #{fileName}, #{filePath}, #{status}, #{progress},
#{totalCount}, #{successCount}, #{failCount}, #{errorMsg}, #{createBy}, sysdate())
</insert>
<update id="updateWmsBatchJob" parameterType="WmsBatchJob">
update wms_batch_job
<set>
<if test="sourceType != null">source_type = #{sourceType},</if>
<if test="bizType != null">biz_type = #{bizType},</if>
<if test="fileName != null">file_name = #{fileName},</if>
<if test="filePath != null">file_path = #{filePath},</if>
<if test="status != null">status = #{status},</if>
<if test="progress != null">progress = #{progress},</if>
<if test="totalCount != null">total_count = #{totalCount},</if>
<if test="successCount != null">success_count = #{successCount},</if>
<if test="failCount != null">fail_count = #{failCount},</if>
error_msg = #{errorMsg},
update_time = sysdate()
</set>
where id = #{id}
</update>
</mapper>

View File

@ -0,0 +1,46 @@
import request from '@/utils/request'
export function listBatchJob(query) {
return request({ url: '/warehouse/batch/list', method: 'get', params: query })
}
export function uploadBatchFile(file) {
const data = new FormData()
data.append('file', file)
return request({
url: '/warehouse/batch/upload',
method: 'post',
data,
headers: { 'Content-Type': 'multipart/form-data', repeatSubmit: false },
timeout: 60000
})
}
export function getBatchJob(id) {
return request({ url: '/warehouse/batch/' + id, method: 'get' })
}
export function listBatchItems(jobId) {
return request({ url: '/warehouse/batch/' + jobId + '/items', method: 'get' })
}
export function updateBatchItem(data) {
return request({ url: '/warehouse/batch/item', method: 'put', data })
}
export function updateBatchItemAction(jobId, data) {
return request({ url: '/warehouse/batch/' + jobId + '/items/action', method: 'put', data })
}
export function deleteBatchItem(id) {
return request({ url: '/warehouse/batch/item/' + id, method: 'delete' })
}
export function confirmBatchJob(jobId) {
return request({
url: '/warehouse/batch/' + jobId + '/confirm',
method: 'post',
timeout: 120000,
headers: { repeatSubmit: false }
})
}

View File

@ -67,6 +67,19 @@ export const constantRoutes = [
hidden: true, hidden: true,
meta: { title: '移动库存' } meta: { title: '移动库存' }
}, },
{
path: '/warehouse/batch-import',
component: Layout,
hidden: true,
children: [
{
path: '',
component: () => import('@/views/warehouse/batchImport/index'),
name: 'WmsBatchImport',
meta: { title: '批量导入' }
}
]
},
{ {
path: '', path: '',
component: Layout, component: Layout,

View File

@ -0,0 +1,77 @@
function isImageFile(file) {
return file && file.type && file.type.indexOf('image/') === 0
}
function canvasToBlob(canvas, type, quality) {
return new Promise(resolve => {
if (!canvas.toBlob) {
resolve(null)
return
}
canvas.toBlob(blob => resolve(blob), type, quality)
})
}
function loadImage(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = event => {
const image = new Image()
image.onload = () => resolve(image)
image.onerror = reject
image.src = event.target.result
}
reader.onerror = reject
reader.readAsDataURL(file)
})
}
export async function compressImageForUpload(file, options = {}) {
const maxSize = options.maxSize || 3 * 1024 * 1024
const maxWidth = options.maxWidth || 1600
const maxHeight = options.maxHeight || 1600
const outputType = options.outputType || 'image/jpeg'
const minQuality = options.minQuality || 0.55
if (!isImageFile(file) || typeof FileReader === 'undefined' || typeof document === 'undefined') {
return file
}
try {
const image = await loadImage(file)
let scale = Math.min(maxWidth / image.width, maxHeight / image.height, 1)
let quality = options.quality || 0.82
let bestBlob = null
for (let i = 0; i < 7; i++) {
const width = Math.max(1, Math.round(image.width * scale))
const height = Math.max(1, Math.round(image.height * scale))
const canvas = document.createElement('canvas')
canvas.width = width
canvas.height = height
const context = canvas.getContext('2d')
context.fillStyle = '#ffffff'
context.fillRect(0, 0, width, height)
context.drawImage(image, 0, 0, width, height)
const blob = await canvasToBlob(canvas, outputType, quality)
if (!blob) {
return file
}
bestBlob = blob
if (blob.size <= maxSize) {
break
}
scale = scale * 0.82
quality = Math.max(minQuality, quality - 0.08)
}
if (!bestBlob || bestBlob.size >= file.size) {
return file
}
const fileName = file.name ? file.name.replace(/\.[^.]+$/, '.jpg') : 'upload.jpg'
return new File([bestBlob], fileName, { type: outputType, lastModified: Date.now() })
} catch (error) {
return file
}
}

View File

@ -5,7 +5,10 @@
<h1>库存管理系统</h1> <h1>库存管理系统</h1>
<p>面向库区产品入库出库和盘库的日常库存作业台</p> <p>面向库区产品入库出库和盘库的日常库存作业台</p>
</div> </div>
<el-button type="primary" icon="el-icon-mobile-phone" @click="$router.push('/mobile')">移动端入口</el-button> <div class="hero-actions">
<el-button type="primary" icon="el-icon-upload2" @click="$router.push('/warehouse/batch-import')">批量导入</el-button>
<el-button type="primary" plain icon="el-icon-mobile-phone" @click="$router.push('/mobile')">移动端入口</el-button>
</div>
</section> </section>
<section class="agent-panel"> <section class="agent-panel">
@ -337,6 +340,12 @@ export default {
border: 1px solid #e6ebf2; border: 1px solid #e6ebf2;
border-radius: 8px; border-radius: 8px;
} }
.hero-actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.hero h1 { .hero h1 {
margin: 0; margin: 0;
font-size: 24px; font-size: 24px;

View File

@ -96,6 +96,7 @@
<button @click="switchTab('stock')">查库存</button> <button @click="switchTab('stock')">查库存</button>
<button @click="switchTab('product')" v-hasPermi="['warehouse:product:list']">产品维护</button> <button @click="switchTab('product')" v-hasPermi="['warehouse:product:list']">产品维护</button>
<button @click="switchTab('area')" v-hasPermi="['warehouse:area:list']">库区维护</button> <button @click="switchTab('area')" v-hasPermi="['warehouse:area:list']">库区维护</button>
<button @click="switchTab('batch')">批量导入</button>
</div> </div>
<div class="panel-title">库存概览</div> <div class="panel-title">库存概览</div>
@ -303,6 +304,108 @@
</div> </div>
</article> </article>
</section> </section>
<section v-show="activeTab === 'batch'" class="mobile-section">
<div class="section-head">
<div>
<div class="section-title">智能批量导入</div>
<div class="section-subtitle">上传 Excel 或图片解析后确认再写入库存数据</div>
</div>
<el-button size="mini" icon="el-icon-arrow-left" @click="switchTab('home')">返回</el-button>
</div>
<el-upload
class="mobile-upload"
action="#"
accept=".xls,.xlsx,.jpg,.jpeg,.png,.bmp"
:show-file-list="false"
:http-request="handleBatchUpload"
:disabled="batch.uploading"
>
<el-button class="full-button" type="primary" icon="el-icon-upload2" :loading="batch.uploading">上传 Excel / 图片</el-button>
<div slot="tip" class="upload-tip">图片识别耗时更久上传后请等待解析进度完成</div>
</el-upload>
<div v-if="batch.currentJob" class="batch-current">
<div class="card-head">
<div>
<div class="card-title">{{ batch.currentJob.jobNo }}</div>
<div class="card-subtitle">{{ batch.currentJob.fileName }} · {{ batchStatusLabel(batch.currentJob.status) }}</div>
</div>
<el-tag size="mini" :type="batchStatusType(batch.currentJob.status)">{{ batchSourceLabel(batch.currentJob.sourceType) }}</el-tag>
</div>
<el-progress :percentage="batch.currentJob.progress || 0" :status="batchProgressStatus(batch.currentJob)" />
<div class="batch-stats">
<el-tag size="mini">总数 {{ batch.currentJob.totalCount || 0 }}</el-tag>
<el-tag size="mini" type="success">可执行 {{ batch.currentJob.successCount || 0 }}</el-tag>
<el-tag size="mini" type="danger">异常 {{ batch.currentJob.failCount || 0 }}</el-tag>
</div>
<el-alert v-if="batch.currentJob.errorMsg" :title="batch.currentJob.errorMsg" type="warning" show-icon :closable="false" />
<el-button
class="full-button confirm-button"
type="danger"
icon="el-icon-check"
:loading="batch.confirming"
:disabled="batch.currentJob.status !== 2 || batch.currentJob.failCount > 0 || !batch.items.length"
@click="confirmBatch"
>确认执行当前任务</el-button>
</div>
<div v-if="batch.items.length" class="batch-action-panel">
<el-select v-model="batch.actionType" placeholder="批量设置操作类型" clearable>
<el-option label="新增库区" value="create_area" />
<el-option label="新增产品" value="create_product" />
<el-option label="入库登记" value="create_in_record" />
<el-option label="出库" value="create_out_record" />
<el-option label="盘库登记" value="create_stock_check" />
</el-select>
<div class="batch-action-buttons">
<el-button type="primary" plain :loading="batch.batchSaving" :disabled="!canEditBatchItems || !batch.actionType" @click="applyMobileBatchAction(false)">应用到全部</el-button>
<el-button type="primary" plain :loading="batch.batchSaving" :disabled="!canEditBatchItems || !batch.actionType || !batch.selectedItemIds.length" @click="applyMobileBatchAction(true)">应用已选 {{ batch.selectedItemIds.length }}</el-button>
</div>
</div>
<div v-if="batch.itemLoading" class="empty-tip">正在加载明细...</div>
<div v-else-if="batch.currentJob && batch.items.length === 0" class="empty-tip">暂无解析明细</div>
<article v-for="item in batch.items" :key="'batch-item-' + item.id" class="mobile-card batch-item-card">
<div class="card-head">
<div>
<div class="card-title"> {{ item.rowNo }} · {{ batchActionLabel(item.actionType) }}</div>
<div class="card-subtitle">{{ item.areaName || '未识别库区' }}</div>
</div>
<el-checkbox :value="isBatchItemSelected(item)" :disabled="!canEditBatchItems" @change="toggleBatchItem(item, $event)">选择</el-checkbox>
</div>
<div class="batch-product">{{ item.productName || '未识别产品' }}</div>
<div class="meta-line">{{ batchProductMeta(item) || '无产品明细' }}</div>
<div class="record-line">
<span v-if="item.quantity !== undefined && item.quantity !== null">数量{{ item.quantity }}</span>
<span v-if="item.operatorName">人员{{ item.operatorName }}</span>
</div>
<div class="batch-row-status">
<el-tag size="mini" :type="batchItemStatusType(item.status)">{{ batchItemStatusLabel(item.status) }}</el-tag>
<span v-if="item.errorMsg" class="error-text">{{ item.errorMsg }}</span>
<span v-else-if="item.warningMsg">{{ item.warningMsg }}</span>
</div>
</article>
<div class="panel-title">最近任务</div>
<div v-if="batch.jobLoading" class="empty-tip">正在加载任务...</div>
<div v-else-if="batch.jobList.length === 0" class="empty-tip">暂无批量任务</div>
<article v-for="job in batch.jobList" :key="'batch-job-' + job.id" class="mobile-card batch-job-card" @click="selectBatchJob(job)">
<div class="card-head">
<div>
<div class="card-title">{{ job.jobNo }}</div>
<div class="card-subtitle">{{ job.fileName || '-' }}</div>
</div>
<el-tag size="mini" :type="batchStatusType(job.status)">{{ batchStatusLabel(job.status) }}</el-tag>
</div>
<div class="record-line">
<span>{{ batchSourceLabel(job.sourceType) }}</span>
<span>进度 {{ job.progress || 0 }}%</span>
<span>异常 {{ job.failCount || 0 }}</span>
</div>
</article>
</section>
</main> </main>
<nav class="bottom-nav"> <nav class="bottom-nav">
@ -438,6 +541,8 @@ import { listInRecord, getInRecord, addInRecord, updateInRecord, delInRecord, co
import { listOutRecord, addOutRecord } from '@/api/warehouse/outRecord' import { listOutRecord, addOutRecord } from '@/api/warehouse/outRecord'
import { listStockCheck, addStockCheck, confirmStockCheck } from '@/api/warehouse/stockCheck' import { listStockCheck, addStockCheck, confirmStockCheck } from '@/api/warehouse/stockCheck'
import { analyzeAgent, executeAgent } from '@/api/warehouse/agent' import { analyzeAgent, executeAgent } from '@/api/warehouse/agent'
import { listBatchJob, uploadBatchFile, getBatchJob, listBatchItems, updateBatchItemAction, confirmBatchJob } from '@/api/warehouse/batchImport'
import { compressImageForUpload } from '@/utils/imageCompress'
export default { export default {
name: 'MobileWarehouse', name: 'MobileWarehouse',
@ -473,6 +578,19 @@ export default {
contextResults: [], contextResults: [],
pendingNextPlan: null, pendingNextPlan: null,
plan: null plan: null
},
batch: {
uploading: false,
confirming: false,
jobLoading: false,
itemLoading: false,
batchSaving: false,
actionType: undefined,
jobList: [],
currentJob: null,
items: [],
selectedItemIds: [],
pollTimer: null
} }
} }
}, },
@ -487,6 +605,9 @@ export default {
unitOptions() { unitOptions() {
return this.distinctProductOptions('unit') return this.distinctProductOptions('unit')
}, },
canEditBatchItems() {
return this.batch.currentJob && ![3, 4].includes(this.batch.currentJob.status)
},
agentPlanItems() { agentPlanItems() {
const plan = this.agent.plan const plan = this.agent.plan
if (!plan) { if (!plan) {
@ -511,6 +632,9 @@ export default {
this.loadOptions() this.loadOptions()
this.refreshAll() this.refreshAll()
}, },
beforeDestroy() {
this.stopBatchPolling()
},
methods: { methods: {
analyzeAgentText() { analyzeAgentText() {
const text = (this.agent.text || '').trim() const text = (this.agent.text || '').trim()
@ -758,6 +882,156 @@ export default {
loadAreas() { loadAreas() {
listArea(this.areaQuery).then(res => { this.areaList = res.rows || [] }) listArea(this.areaQuery).then(res => { this.areaList = res.rows || [] })
}, },
async handleBatchUpload(option) {
this.batch.uploading = true
const uploadFile = await compressImageForUpload(option.file)
if (uploadFile !== option.file) {
this.$modal.msgSuccess('照片已压缩,正在上传解析')
}
uploadBatchFile(uploadFile).then(res => {
this.batch.currentJob = res.data
this.batch.items = []
this.batch.selectedItemIds = []
this.$modal.msgSuccess('上传成功,正在后台解析')
this.startBatchPolling(res.data.id)
this.loadBatchJobs()
}).finally(() => {
this.batch.uploading = false
})
},
loadBatchJobs() {
this.batch.jobLoading = true
listBatchJob({ pageNum: 1, pageSize: 10 }).then(res => {
this.batch.jobList = res.rows || []
}).finally(() => {
this.batch.jobLoading = false
})
},
selectBatchJob(row) {
this.batch.currentJob = row
this.loadBatchItems(row.id)
if ([0, 1, 3].includes(row.status)) {
this.startBatchPolling(row.id)
}
},
startBatchPolling(jobId) {
this.stopBatchPolling()
this.batch.pollTimer = setInterval(() => {
this.refreshBatchJob(jobId)
}, 1500)
this.refreshBatchJob(jobId)
},
stopBatchPolling() {
if (this.batch.pollTimer) {
clearInterval(this.batch.pollTimer)
this.batch.pollTimer = null
}
},
refreshBatchJob(jobId) {
getBatchJob(jobId).then(res => {
this.batch.currentJob = res.data
if (this.batch.currentJob && [2, 4, 5, 6].includes(this.batch.currentJob.status)) {
this.stopBatchPolling()
this.loadBatchItems(jobId)
this.loadBatchJobs()
}
})
},
loadBatchItems(jobId) {
this.batch.itemLoading = true
listBatchItems(jobId).then(res => {
this.batch.items = res.data || []
this.batch.selectedItemIds = []
}).finally(() => {
this.batch.itemLoading = false
})
},
isBatchItemSelected(item) {
return this.batch.selectedItemIds.includes(item.id)
},
toggleBatchItem(item, checked) {
const ids = this.batch.selectedItemIds.slice()
const index = ids.indexOf(item.id)
if (checked && index === -1) {
ids.push(item.id)
}
if (!checked && index !== -1) {
ids.splice(index, 1)
}
this.batch.selectedItemIds = ids
},
applyMobileBatchAction(onlySelected) {
if (!this.batch.currentJob || !this.batch.actionType) {
return
}
const itemIds = onlySelected ? this.batch.selectedItemIds : undefined
if (onlySelected && !itemIds.length) {
return
}
const label = this.batchActionLabel(this.batch.actionType)
this.$modal.confirm('确认将' + (onlySelected ? '已选' : '全部') + '明细的操作类型设置为“' + label + '”?').then(() => {
this.batch.batchSaving = true
return updateBatchItemAction(this.batch.currentJob.id, { actionType: this.batch.actionType, itemIds })
}).then(() => {
this.$modal.msgSuccess('批量设置完成')
this.loadBatchItems(this.batch.currentJob.id)
this.refreshBatchJob(this.batch.currentJob.id)
}).finally(() => {
this.batch.batchSaving = false
})
},
confirmBatch() {
if (!this.batch.currentJob) {
return
}
this.$modal.confirm('确认执行当前批量任务?执行后会真正写入库存业务数据。').then(() => {
this.batch.confirming = true
return confirmBatchJob(this.batch.currentJob.id)
}).then(res => {
this.batch.currentJob = res.data
this.$modal.msgSuccess('执行完成')
this.loadBatchItems(this.batch.currentJob.id)
this.loadBatchJobs()
this.refreshAll()
this.loadOptions()
}).finally(() => {
this.batch.confirming = false
})
},
batchStatusLabel(status) {
return { 0: '已上传', 1: '解析中', 2: '待确认', 3: '执行中', 4: '完成', 5: '失败', 6: '已取消' }[status] || '-'
},
batchStatusType(status) {
return { 2: 'warning', 4: 'success', 5: 'danger', 6: 'info' }[status] || 'info'
},
batchSourceLabel(sourceType) {
return { excel: 'Excel', image: '图片', file: '文件' }[sourceType] || sourceType || '-'
},
batchItemStatusLabel(status) {
return { 1: '可执行', 2: '异常', 3: '已执行', 4: '失败' }[status] || '-'
},
batchItemStatusType(status) {
return { 1: 'success', 2: 'danger', 3: 'success', 4: 'danger' }[status] || 'info'
},
batchProgressStatus(job) {
if (!job) return undefined
if (job.status === 4) return 'success'
if (job.status === 5) return 'exception'
return undefined
},
batchActionLabel(action) {
return {
create_area: '新增库区',
create_product: '新增产品',
create_in_record: '入库登记',
create_out_record: '出库',
create_stock_check: '盘库登记',
unknown: '未识别'
}[action] || action || '-'
},
batchProductMeta(row) {
return [row.brand, row.category, row.spec, row.unit].filter(Boolean).join(' / ')
},
switchTab(tab) { switchTab(tab) {
this.activeTab = tab this.activeTab = tab
if (tab === 'stock') this.loadStock() if (tab === 'stock') this.loadStock()
@ -766,6 +1040,7 @@ export default {
if (tab === 'check') this.loadCheckRecords() if (tab === 'check') this.loadCheckRecords()
if (tab === 'product') this.loadProducts() if (tab === 'product') this.loadProducts()
if (tab === 'area') this.loadAreas() if (tab === 'area') this.loadAreas()
if (tab === 'batch') this.loadBatchJobs()
if (tab === 'home') this.refreshAll() if (tab === 'home') this.refreshAll()
}, },
areaLabel(area) { areaLabel(area) {
@ -1249,6 +1524,70 @@ export default {
width: 100%; width: 100%;
margin-bottom: 10px; margin-bottom: 10px;
} }
.mobile-upload {
margin-bottom: 12px;
}
.mobile-upload ::v-deep .el-upload {
width: 100%;
}
.upload-tip {
color: #909399;
font-size: 12px;
line-height: 18px;
}
.batch-current,
.batch-action-panel {
margin-bottom: 10px;
padding: 12px;
background: #ffffff;
border: 1px solid #e8edf3;
border-radius: 8px;
}
.batch-current .el-progress {
margin-top: 10px;
}
.batch-stats {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 10px 0;
}
.confirm-button {
margin-top: 10px;
margin-bottom: 0;
}
.batch-action-panel {
display: grid;
gap: 8px;
}
.batch-action-buttons {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.batch-action-buttons .el-button {
margin-left: 0;
}
.batch-product {
margin-top: 8px;
font-weight: 700;
line-height: 22px;
}
.batch-row-status {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
color: #7a8794;
font-size: 12px;
line-height: 20px;
}
.batch-job-card {
cursor: pointer;
}
.error-text {
color: #f56c6c;
}
.mobile-card { .mobile-card {
background: #ffffff; background: #ffffff;
border: 1px solid #e8edf3; border: 1px solid #e8edf3;

View File

@ -0,0 +1,415 @@
<template>
<div class="app-container batch-page">
<el-card shadow="never" class="batch-card">
<div slot="header" class="card-head">
<div>
<span class="title">智能批量导入</span>
<span class="subtitle">支持 Excel 和图片解析后先确认明细再写入库存业务表</span>
</div>
<el-button icon="el-icon-refresh" size="mini" @click="loadJobs">刷新任务</el-button>
</div>
<el-upload
class="upload-box"
drag
action="#"
accept=".xls,.xlsx,.jpg,.jpeg,.png,.bmp"
:show-file-list="false"
:http-request="handleUpload"
:disabled="uploading"
>
<i class="el-icon-upload"></i>
<div class="el-upload__text">拖入 Excel 或图片 <em>点击上传</em></div>
<div slot="tip" class="el-upload__tip">Excel 支持 xls/xlsx图片支持 jpg/png/bmp图片会调用大模型识别耗时可能更久</div>
</el-upload>
<div v-if="currentJob" class="job-panel">
<div class="job-main">
<div>
<div class="job-no">{{ currentJob.jobNo }}</div>
<div class="job-meta">{{ currentJob.fileName }} {{ statusLabel(currentJob.status) }}</div>
</div>
<el-button
type="danger"
icon="el-icon-check"
:loading="confirming"
:disabled="currentJob.status !== 2 || currentJob.failCount > 0 || !items.length"
@click="handleConfirm"
>确认执行</el-button>
</div>
<el-progress :percentage="currentJob.progress || 0" :status="progressStatus(currentJob)" />
<div class="job-stats">
<el-tag size="small">总数 {{ currentJob.totalCount || 0 }}</el-tag>
<el-tag size="small" type="success">可执行 {{ currentJob.successCount || 0 }}</el-tag>
<el-tag size="small" type="danger">异常 {{ currentJob.failCount || 0 }}</el-tag>
<el-alert v-if="currentJob.errorMsg" :title="currentJob.errorMsg" type="warning" show-icon :closable="false" />
</div>
</div>
</el-card>
<el-row :gutter="16">
<el-col :xs="24" :lg="7">
<el-card shadow="never" class="batch-card">
<div slot="header" class="card-head">
<span class="title">最近任务</span>
</div>
<el-table v-loading="jobLoading" :data="jobList" size="mini" height="420" @row-click="selectJob">
<el-table-column label="任务号" prop="jobNo" min-width="150" show-overflow-tooltip />
<el-table-column label="来源" width="72">
<template slot-scope="scope">{{ sourceLabel(scope.row.sourceType) }}</template>
</el-table-column>
<el-table-column label="状态" width="86">
<template slot-scope="scope">
<el-tag :type="statusType(scope.row.status)" size="mini">{{ statusLabel(scope.row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="进度" prop="progress" width="70" align="right" />
</el-table>
</el-card>
</el-col>
<el-col :xs="24" :lg="17">
<el-card shadow="never" class="batch-card">
<div slot="header" class="card-head">
<span class="title">待确认明细</span>
<span class="subtitle">异常明细需要修改或删除后才能确认执行</span>
</div>
<div class="batch-actions">
<el-select v-model="batchActionType" size="small" placeholder="批量设置操作类型" clearable>
<el-option label="新增库区" value="create_area" />
<el-option label="新增产品" value="create_product" />
<el-option label="入库登记" value="create_in_record" />
<el-option label="出库" value="create_out_record" />
<el-option label="盘库登记" value="create_stock_check" />
</el-select>
<el-button size="small" type="primary" plain :loading="batchSaving" :disabled="!canEditItems || !batchActionType || !items.length" @click="applyBatchAction(false)">应用到全部</el-button>
<el-button size="small" type="primary" plain :loading="batchSaving" :disabled="!canEditItems || !batchActionType || !selectedItems.length" @click="applyBatchAction(true)">应用到已选 {{ selectedItems.length }}</el-button>
</div>
<el-table v-loading="itemLoading" :data="items" size="mini" height="520" @selection-change="handleItemSelectionChange">
<el-table-column type="selection" width="45" />
<el-table-column label="行" prop="rowNo" width="58" />
<el-table-column label="操作" width="110">
<template slot-scope="scope">{{ actionLabel(scope.row.actionType) }}</template>
</el-table-column>
<el-table-column label="状态" width="92">
<template slot-scope="scope">
<el-tag :type="itemStatusType(scope.row.status)" size="mini">{{ itemStatusLabel(scope.row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="库区" prop="areaName" width="110" show-overflow-tooltip />
<el-table-column label="产品" min-width="190" show-overflow-tooltip>
<template slot-scope="scope">
<div>{{ scope.row.productName || '-' }}</div>
<div class="meta">{{ productMeta(scope.row) }}</div>
</template>
</el-table-column>
<el-table-column label="数量" prop="quantity" width="90" align="right" />
<el-table-column label="人员" prop="operatorName" width="100" show-overflow-tooltip />
<el-table-column label="提示/错误" min-width="220" show-overflow-tooltip>
<template slot-scope="scope">
<span class="error-text" v-if="scope.row.errorMsg">{{ scope.row.errorMsg }}</span>
<span v-else>{{ scope.row.warningMsg || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template slot-scope="scope">
<el-button type="text" size="mini" @click="openEdit(scope.row)">修改</el-button>
<el-button type="text" size="mini" class="danger-link" @click="removeItem(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
<el-dialog title="修改明细" :visible.sync="editOpen" width="620px" append-to-body>
<el-form ref="editForm" :model="editForm" label-width="90px">
<el-form-item label="操作类型">
<el-select v-model="editForm.actionType" placeholder="请选择操作类型" style="width: 100%">
<el-option label="新增库区" value="create_area" />
<el-option label="新增产品" value="create_product" />
<el-option label="入库登记" value="create_in_record" />
<el-option label="出库" value="create_out_record" />
<el-option label="盘库登记" value="create_stock_check" />
</el-select>
</el-form-item>
<el-form-item label="库区"><el-input v-model="editForm.areaName" /></el-form-item>
<el-form-item label="产品名称"><el-input v-model="editForm.productName" /></el-form-item>
<el-row :gutter="12">
<el-col :span="12"><el-form-item label="品牌"><el-input v-model="editForm.brand" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="类别"><el-input v-model="editForm.category" /></el-form-item></el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12"><el-form-item label="规格"><el-input v-model="editForm.spec" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="单位"><el-input v-model="editForm.unit" /></el-form-item></el-col>
</el-row>
<el-row :gutter="12">
<el-col :span="12"><el-form-item label="数量"><el-input-number v-model="editForm.quantity" :precision="3" :min="0" style="width: 100%" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="类型编码"><el-input-number v-model="editForm.typeCode" :min="1" :max="4" style="width: 100%" /></el-form-item></el-col>
</el-row>
<el-form-item label="操作人"><el-input v-model="editForm.operatorName" /></el-form-item>
<el-form-item label="备注"><el-input v-model="editForm.remark" type="textarea" /></el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" :loading="savingItem" @click="saveItem"> </el-button>
<el-button @click="editOpen = false"> </el-button>
</div>
</el-dialog>
</div>
</template>
<script>
import { listBatchJob, uploadBatchFile, getBatchJob, listBatchItems, updateBatchItem, updateBatchItemAction, deleteBatchItem, confirmBatchJob } from '@/api/warehouse/batchImport'
export default {
name: 'WmsBatchImport',
data() {
return {
uploading: false,
confirming: false,
jobLoading: false,
itemLoading: false,
savingItem: false,
batchSaving: false,
batchActionType: undefined,
jobList: [],
currentJob: null,
items: [],
selectedItems: [],
pollTimer: null,
editOpen: false,
editForm: {}
}
},
created() {
this.loadJobs()
},
beforeDestroy() {
this.stopPolling()
},
methods: {
handleUpload(option) {
this.uploading = true
uploadBatchFile(option.file).then(res => {
this.currentJob = res.data
this.items = []
this.$modal.msgSuccess('上传成功,正在后台解析,请留意进度')
this.startPolling(res.data.id)
this.loadJobs()
}).finally(() => {
this.uploading = false
})
},
loadJobs() {
this.jobLoading = true
listBatchJob({ pageNum: 1, pageSize: 20 }).then(res => {
this.jobList = res.rows || []
}).finally(() => {
this.jobLoading = false
})
},
selectJob(row) {
this.currentJob = row
this.loadItems(row.id)
if ([0, 1, 3].includes(row.status)) {
this.startPolling(row.id)
}
},
startPolling(jobId) {
this.stopPolling()
this.pollTimer = setInterval(() => {
this.refreshJob(jobId)
}, 1500)
this.refreshJob(jobId)
},
stopPolling() {
if (this.pollTimer) {
clearInterval(this.pollTimer)
this.pollTimer = null
}
},
refreshJob(jobId) {
getBatchJob(jobId).then(res => {
this.currentJob = res.data
if (this.currentJob && [2, 4, 5, 6].includes(this.currentJob.status)) {
this.stopPolling()
this.loadItems(jobId)
this.loadJobs()
}
})
},
loadItems(jobId) {
this.itemLoading = true
listBatchItems(jobId).then(res => {
this.items = res.data || []
this.selectedItems = []
}).finally(() => {
this.itemLoading = false
})
},
handleItemSelectionChange(selection) {
this.selectedItems = selection
},
openEdit(row) {
this.editForm = Object.assign({}, row)
this.editOpen = true
},
saveItem() {
this.savingItem = true
updateBatchItem(this.editForm).then(res => {
this.$modal.msgSuccess('明细已更新')
this.editOpen = false
this.loadItems(this.currentJob.id)
this.refreshJob(this.currentJob.id)
}).finally(() => {
this.savingItem = false
})
},
removeItem(row) {
this.$modal.confirm('确认删除第 ' + row.rowNo + ' 行明细?').then(() => deleteBatchItem(row.id)).then(() => {
this.$modal.msgSuccess('删除成功')
this.loadItems(this.currentJob.id)
this.refreshJob(this.currentJob.id)
})
},
applyBatchAction(onlySelected) {
const rows = onlySelected ? this.selectedItems : this.items
if (!rows.length || !this.batchActionType) {
return
}
const label = this.actionLabel(this.batchActionType)
this.$modal.confirm('确认将' + (onlySelected ? '已选' : '全部') + '明细的操作类型设置为“' + label + '”?').then(() => {
this.batchSaving = true
const itemIds = onlySelected ? rows.map(item => item.id) : undefined
return updateBatchItemAction(this.currentJob.id, { actionType: this.batchActionType, itemIds })
}).then(() => {
this.$modal.msgSuccess('批量设置完成')
this.loadItems(this.currentJob.id)
this.refreshJob(this.currentJob.id)
}).finally(() => {
this.batchSaving = false
})
},
handleConfirm() {
this.$modal.confirm('确认执行当前批量任务?执行后会真正写入库存业务数据。').then(() => {
this.confirming = true
return confirmBatchJob(this.currentJob.id)
}).then(res => {
this.currentJob = res.data
this.$modal.msgSuccess('执行完成')
this.loadItems(this.currentJob.id)
this.loadJobs()
}).finally(() => {
this.confirming = false
})
},
statusLabel(status) {
return { 0: '已上传', 1: '解析中', 2: '待确认', 3: '执行中', 4: '完成', 5: '失败', 6: '已取消' }[status] || '-'
},
statusType(status) {
return { 2: 'warning', 4: 'success', 5: 'danger', 6: 'info' }[status] || 'info'
},
sourceLabel(sourceType) {
return { excel: 'Excel', image: '图片', file: '文件' }[sourceType] || sourceType || '-'
},
itemStatusLabel(status) {
return { 1: '可执行', 2: '异常', 3: '已执行', 4: '失败' }[status] || '-'
},
itemStatusType(status) {
return { 1: 'success', 2: 'danger', 3: 'success', 4: 'danger' }[status] || 'info'
},
progressStatus(job) {
if (!job) return undefined
if (job.status === 4) return 'success'
if (job.status === 5) return 'exception'
return undefined
},
actionLabel(action) {
return {
create_area: '新增库区',
create_product: '新增产品',
create_in_record: '入库登记',
create_out_record: '出库',
create_stock_check: '盘库登记',
unknown: '未识别'
}[action] || action || '-'
},
productMeta(row) {
return [row.brand, row.category, row.spec, row.unit].filter(Boolean).join(' / ')
}
},
computed: {
canEditItems() {
return this.currentJob && ![3, 4].includes(this.currentJob.status)
}
}
}
</script>
<style scoped lang="scss">
.batch-page {
background: #f5f7fb;
}
.batch-card {
margin-bottom: 16px;
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.title {
font-weight: 700;
color: #1f2d3d;
}
.subtitle {
margin-left: 10px;
color: #7a8794;
font-size: 12px;
}
.upload-box {
max-width: 620px;
}
.job-panel {
margin-top: 16px;
padding: 14px;
border: 1px solid #e6ebf2;
border-radius: 6px;
background: #fbfcfe;
}
.job-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.job-no {
font-weight: 700;
color: #1f2d3d;
}
.job-meta,
.meta {
color: #909399;
font-size: 12px;
}
.job-stats {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.batch-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 10px;
}
.error-text,
.danger-link {
color: #f56c6c;
}
</style>

49
sql/wms_batch_import.sql Normal file
View File

@ -0,0 +1,49 @@
-- 批量导入任务表。执行一次即可。
create table if not exists wms_batch_job (
id bigint not null auto_increment comment '主键',
job_no varchar(64) not null comment '任务号',
source_type varchar(20) not null comment '来源类型 excel/image/file',
biz_type varchar(30) default 'mixed' comment '业务类型',
file_name varchar(255) default null comment '原始文件名',
file_path varchar(500) default null comment '服务器文件路径',
status tinyint not null default 0 comment '状态 0上传完成 1解析中 2待确认 3执行中 4完成 5失败 6取消',
progress int not null default 0 comment '进度',
total_count int not null default 0 comment '总条数',
success_count int not null default 0 comment '成功条数',
fail_count int not null default 0 comment '失败条数',
error_msg varchar(1000) default null comment '错误信息',
create_by varchar(64) default null comment '创建人',
create_time datetime default null comment '创建时间',
update_time datetime default null comment '更新时间',
primary key (id),
unique key uk_wms_batch_job_no (job_no)
) engine=innodb default charset=utf8mb4 comment='WMS批量导入任务';
create table if not exists wms_batch_item (
id bigint not null auto_increment comment '主键',
job_id bigint not null comment '任务ID',
row_no int default null comment 'Excel行号',
action_type varchar(40) not null comment '操作类型',
raw_text text comment '原始内容',
parsed_json text comment '解析JSON',
status tinyint not null default 1 comment '状态 1校验通过 2校验失败 3已执行 4执行失败',
error_msg varchar(1000) default null comment '错误信息',
warning_msg varchar(1000) default null comment '提示信息',
ref_id bigint default null comment '执行后业务ID',
area_name varchar(100) default null comment '库区名称',
product_name varchar(200) default null comment '产品名称',
brand varchar(100) default null comment '品牌',
category varchar(100) default null comment '类别',
spec varchar(200) default null comment '规格',
unit varchar(50) default null comment '单位',
quantity decimal(16,3) default null comment '数量',
operator_name varchar(100) default null comment '操作人',
type_code tinyint default null comment '入库/出库类型',
source_no varchar(100) default null comment '来源单据号',
remark varchar(500) default null comment '备注',
create_time datetime default null comment '创建时间',
update_time datetime default null comment '更新时间',
primary key (id),
key idx_wms_batch_item_job (job_id),
key idx_wms_batch_item_status (status)
) engine=innodb default charset=utf8mb4 comment='WMS批量导入明细';