Compare commits

...

2 Commits

Author SHA1 Message Date
e76c07fb5e feat(warehouse): 新增批量导入功能并优化系统配置
- 添加批量导入页面支持Excel和图片上传解析
- 实现移动端批量导入功能界面和交互
- 集成图片压缩上传功能提升用户体验
- 增加批量任务状态管理和进度跟踪
- 完善批量数据确认和执行流程
- 配置环境变量支持Docker部署
- 提高文件上传大小限制增强实用性
- 优化系统配置以适应生产环境需求
2026-07-17 15:00:06 +08:00
d872e3e235 feat(warehouse): 优化智能代理功能和库存管理
- 修改默认出库类型从销售出库改为生产出库
- 添加产品类别自动推断功能,支持基于历史数据和关键词的智能分类
- 实现库存查询结果显示和选择功能,优化用户体验
- 增强代理计划的消息提示和错误处理机制
- 添加现有产品类别的获取和展示功能
- 优化库存不足和产品未找到的提示信息
- 修复前端表单默认值设置问题
2026-07-17 09:17:42 +08:00
22 changed files with 3388 additions and 19 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,13 +6,12 @@ ruoyi:
version: 3.9.2
# 版权年份
copyrightYear: 2026
# 文件路径 示例( Windows配置D:/ruoyi/uploadPathLinux配置 /home/ruoyi/uploadPath
profile: D:/ruoyi/uploadPath
# 文件路径 示例( Windows配置D:/ruoyi/uploadPathLinux/Docker配置 /app/uploadPath
profile: ${RUOYI_PROFILE:D:/ruoyi/uploadPath}
# 获取ip地址开关
addressEnabled: false
# 验证码类型 math 数字计算 char 字符验证
captchaType: math
# 开发环境配置
server:
# 服务器的HTTP端口默认为8080
@ -57,9 +56,9 @@ spring:
servlet:
multipart:
# 单个文件大小
max-file-size: 10MB
max-file-size: ${MAX_FILE_SIZE:50MB}
# 设置总上传的文件大小
max-request-size: 20MB
max-request-size: ${MAX_REQUEST_SIZE:60MB}
# 服务模块
devtools:
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

@ -9,6 +9,7 @@ public interface IProductInfoService
public List<ProductInfo> selectProductInfoAll();
public ProductInfo selectProductInfoById(Long id);
public boolean checkProductCodeUnique(ProductInfo productInfo);
public String inferProductCategory(ProductInfo productInfo);
public int insertProductInfo(ProductInfo productInfo);
public int updateProductInfo(ProductInfo productInfo);
public int deleteProductInfoByIds(Long[] ids);

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

@ -9,8 +9,10 @@ import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.springframework.beans.factory.annotation.Autowired;
@ -258,8 +260,8 @@ public class AgentServiceImpl implements IAgentService
}
if (ACTION_CREATE_OUT.equals(plan.getAction()) && plan.getOutType() == null)
{
plan.setOutType(3);
plan.getWarnings().add("未识别出库类型,默认销售出库");
plan.setOutType(1);
plan.getWarnings().add("未识别出库类型,默认生产出库");
}
if (ACTION_CONFIRM_IN.equals(plan.getAction()))
@ -274,7 +276,7 @@ public class AgentServiceImpl implements IAgentService
fillStockResults(plan);
plan.setNeedConfirm(false);
plan.setExecutable(true);
plan.setMessage(plan.getResults().isEmpty() ? "未查询到库存" : "查询到 " + plan.getResults().size() + " 条库存");
plan.setMessage(plan.getResults().isEmpty() ? stockNotFoundMessage(plan) : "查询到 " + plan.getResults().size() + " 条库存");
if (StringUtils.isBlank(plan.getSummary()))
{
plan.setSummary("查询库存");
@ -312,7 +314,7 @@ public class AgentServiceImpl implements IAgentService
fillSummary(plan);
if (!plan.isExecutable() && StringUtils.isBlank(plan.getMessage()))
{
plan.setMessage("信息不完整或存在多个候选,请补充后再执行");
fillUnableToExecuteMessage(plan);
}
return plan;
}
@ -418,7 +420,8 @@ public class AgentServiceImpl implements IAgentService
+ "如果用户说第几个、第二个、第2条等referenceIndex 返回从 1 开始的序号。"
+ "入库类型 inType1生产入库2采购入库3归还入库4退货入库。"
+ "出库类型 outType1生产出库2退货出库3销售出库。"
+ "新增产品时尽量从用户输入提取 productName, brand, category, spec, unit新增库区提取 areaName。不要编造信息无法识别就填 null。";
+ "新增产品时尽量从用户输入提取 productName, brand, category, spec, unit如果用户未提供 category优先从 existingCategories 选择合适类别,不合适时推断一个简短合理的类别。"
+ "除 category 可按规则推断外,不要编造其他字段;无法识别就填 null。新增库区提取 areaName。";
}
private String buildUserInput(String text, List<AgentResultItem> contextResults)
@ -426,6 +429,7 @@ public class AgentServiceImpl implements IAgentService
JSONObject payload = new JSONObject();
payload.put("text", text);
payload.put("contextResults", contextResults == null ? new ArrayList<>() : contextResults);
payload.put("existingCategories", existingCategories());
return payload.toJSONString();
}
@ -850,7 +854,13 @@ public class AgentServiceImpl implements IAgentService
ProductInfo product = match.getProduct();
plan.getCandidates().add(new AgentCandidate(product.getId(), "product", product.getProductName(), productDetail(product)));
}
plan.setMessage("产品不唯一,请补充名称、规格、品牌或类别");
if (!ACTION_QUERY_STOCK.equals(plan.getAction()))
{
fillProductChoices(plan, topMatches);
}
plan.setMessage(plan.getResults().isEmpty()
? "产品不唯一,请选择候选产品,或补充名称、规格、品牌、类别"
: "找到多个匹配产品,请在下方选择要操作的库存项");
}
private void resolveReferencedResult(AgentPlan plan, String originalText, List<AgentResultItem> contextResults)
@ -965,6 +975,37 @@ public class AgentServiceImpl implements IAgentService
{
plan.setUnit(plan.getUnit().trim());
}
if (StringUtils.isBlank(plan.getCategory()) && StringUtils.isNotBlank(plan.getProductName()))
{
ProductInfo draft = new ProductInfo();
draft.setProductName(plan.getProductName());
draft.setBrand(plan.getBrand());
draft.setSpec(plan.getSpec());
draft.setUnit(plan.getUnit());
String category = productService.inferProductCategory(draft);
if (StringUtils.isNotBlank(category))
{
plan.setCategory(category);
plan.getWarnings().add("未提供类别,已自动分类为:" + category);
}
}
}
private List<String> existingCategories()
{
Set<String> categories = new LinkedHashSet<>();
for (ProductInfo product : productService.selectProductInfoAll())
{
if (StringUtils.isNotBlank(product.getCategory()))
{
categories.add(product.getCategory().trim());
}
if (categories.size() >= 80)
{
break;
}
}
return new ArrayList<>(categories);
}
private boolean requiresOperator(String action)
@ -987,6 +1028,80 @@ public class AgentServiceImpl implements IAgentService
return true;
}
private void fillUnableToExecuteMessage(AgentPlan plan)
{
if (plan.getProductId() == null && hasProductClue(plan) && noProductCandidates(plan))
{
plan.setMessage("没找到产品:" + productClue(plan) + "。请先确认产品名称、品牌、类别或规格是否已维护。");
return;
}
if (plan.getAreaId() == null && StringUtils.isNotBlank(plan.getAreaName()) && noAreaCandidates(plan))
{
plan.setMessage("没找到库区:" + plan.getAreaName() + "。请先维护库区后再操作。");
return;
}
if (ACTION_CREATE_OUT.equals(plan.getAction()) && plan.getProductId() != null && plan.getResults().isEmpty())
{
String product = StringUtils.defaultIfBlank(plan.getProductName(), "该产品");
plan.setMessage("没找到可出库库存:" + product + "。请确认产品是否已有库存,或选择正确库区。");
return;
}
if (plan.getCandidates() != null && !plan.getCandidates().isEmpty())
{
plan.setMessage("存在多个候选,请先选择要操作的数据");
return;
}
if (!validQuantity(plan))
{
plan.setMessage(ACTION_CREATE_CHECK.equals(plan.getAction()) ? "未识别到实际盘点数量" : "未识别到有效数量");
return;
}
plan.setMessage("信息不完整,请补充后再执行");
}
private boolean noProductCandidates(AgentPlan plan)
{
for (AgentCandidate candidate : plan.getCandidates())
{
if ("product".equals(candidate.getType()))
{
return false;
}
}
return true;
}
private boolean hasProductClue(AgentPlan plan)
{
return StringUtils.isNotBlank(plan.getProductName())
|| StringUtils.isNotBlank(plan.getBrand())
|| StringUtils.isNotBlank(plan.getCategory())
|| StringUtils.isNotBlank(plan.getSpec());
}
private String productClue(AgentPlan plan)
{
return firstNotBlank(plan.getProductName(), plan.getBrand(), plan.getCategory(), plan.getSpec(), "未识别产品");
}
private String stockNotFoundMessage(AgentPlan plan)
{
List<String> parts = new ArrayList<>();
if (StringUtils.isNotBlank(plan.getAreaName()))
{
parts.add("库区:" + plan.getAreaName());
}
if (hasProductClue(plan))
{
parts.add("产品:" + productClue(plan));
}
if (parts.isEmpty())
{
return "未查询到库存数据";
}
return "未查询到符合条件的库存数据(" + String.join("", parts) + "";
}
private void fillActionCandidates(AgentPlan plan)
{
plan.getCandidates().add(new AgentCandidate(1L, "action", "查库存", "例如:查一下路由器库存"));
@ -1199,6 +1314,51 @@ public class AgentServiceImpl implements IAgentService
return item;
}
private AgentResultItem toProductResult(ProductInfo product)
{
AgentResultItem item = new AgentResultItem();
item.setProductId(product.getId());
item.setProductName(product.getProductName());
item.setBrand(product.getBrand());
item.setCategory(product.getCategory());
item.setSpec(product.getSpec());
item.setUnit(product.getUnit());
return item;
}
private void fillProductChoices(AgentPlan plan, List<ScoredProduct> matches)
{
int count = 0;
for (ScoredProduct match : first(matches, 10))
{
ProductInfo product = match.getProduct();
StockInfo query = new StockInfo();
query.setAreaId(plan.getAreaId());
query.setProductId(product.getId());
List<StockInfo> stockList = stockInfoService.selectStockInfoList(query);
if (stockList.isEmpty())
{
if (!ACTION_CREATE_OUT.equals(plan.getAction()))
{
plan.getResults().add(toProductResult(product));
count++;
}
}
else
{
for (StockInfo stock : stockList)
{
if (count++ >= 20)
{
plan.getWarnings().add("匹配结果较多,仅展示前 20 条");
return;
}
plan.getResults().add(toResult(stock));
}
}
}
}
private void fillCurrentStock(AgentPlan plan)
{
if (plan.getAreaId() == null || plan.getProductId() == null)
@ -1235,7 +1395,8 @@ public class AgentServiceImpl implements IAgentService
private void suggestAlternativeStockAreas(AgentPlan plan)
{
if (!ACTION_CREATE_OUT.equals(plan.getAction()) || plan.getProductId() == null || plan.isExecutable())
if (!ACTION_CREATE_OUT.equals(plan.getAction()) || plan.getProductId() == null || plan.isExecutable()
|| (plan.getAreaId() == null && (StringUtils.isNotBlank(plan.getAreaName()) || !noAreaCandidates(plan))))
{
return;
}

View File

@ -1,6 +1,9 @@
package com.ruoyi.warehouse.service.impl;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.ruoyi.common.constant.UserConstants;
@ -46,6 +49,21 @@ public class ProductInfoServiceImpl implements IProductInfoService
return StringUtils.isNull(info) || info.getId().longValue() == id.longValue() ? UserConstants.UNIQUE : UserConstants.NOT_UNIQUE;
}
@Override
public String inferProductCategory(ProductInfo productInfo)
{
if (productInfo == null || StringUtils.isBlank(productInfo.getProductName()))
{
return null;
}
String category = inferCategoryFromHistory(productInfo);
if (StringUtils.isNotBlank(category))
{
return category;
}
return generateCategory(productInfo);
}
@Override
public int insertProductInfo(ProductInfo productInfo)
{
@ -79,6 +97,10 @@ public class ProductInfoServiceImpl implements IProductInfoService
productInfo.setCategory(trimToNull(productInfo.getCategory()));
productInfo.setSpec(trimToNull(productInfo.getSpec()));
productInfo.setUnit(trimToNull(productInfo.getUnit()));
if (StringUtils.isBlank(productInfo.getCategory()) && StringUtils.isNotBlank(productInfo.getProductName()))
{
productInfo.setCategory(inferProductCategory(productInfo));
}
}
private void validateProductInfoUnique(ProductInfo productInfo)
@ -95,4 +117,151 @@ public class ProductInfoServiceImpl implements IProductInfoService
{
return StringUtils.isBlank(value) ? null : value.trim();
}
private String inferCategoryFromHistory(ProductInfo productInfo)
{
Map<String, Integer> scores = new HashMap<>();
for (ProductInfo product : productInfoMapper.selectProductInfoAll())
{
if (StringUtils.isBlank(product.getCategory()))
{
continue;
}
int score = categoryProductScore(productInfo, product);
if (score > 0)
{
String category = product.getCategory().trim();
scores.put(category, scores.getOrDefault(category, 0) + score);
}
}
String bestCategory = null;
int bestScore = 0;
int secondScore = 0;
for (Map.Entry<String, Integer> entry : scores.entrySet())
{
int score = entry.getValue();
if (score > bestScore)
{
secondScore = bestScore;
bestScore = score;
bestCategory = entry.getKey();
}
else if (score > secondScore)
{
secondScore = score;
}
}
if (bestScore >= 4 && bestScore >= secondScore + 2)
{
return bestCategory;
}
return null;
}
private int categoryProductScore(ProductInfo source, ProductInfo target)
{
int score = 0;
score += fieldScore(source.getProductName(), target.getProductName(), 8);
score += fieldScore(source.getBrand(), target.getBrand(), 6);
score += fieldScore(source.getSpec(), target.getSpec(), 4);
score += fieldScore(source.getUnit(), target.getUnit(), 1);
String draftText = productDraftText(source);
score += containsField(draftText, target.getProductName()) ? 4 : 0;
score += containsField(draftText, target.getBrand()) ? 2 : 0;
score += containsField(draftText, target.getSpec()) ? 2 : 0;
return score;
}
private int fieldScore(String input, String target, int exactScore)
{
if (StringUtils.isBlank(input) || StringUtils.isBlank(target))
{
return 0;
}
if (input.trim().equalsIgnoreCase(target.trim()))
{
return exactScore;
}
if (containsIgnoreCase(input, target) || containsIgnoreCase(target, input))
{
return Math.max(1, exactScore / 2);
}
return 0;
}
private String productDraftText(ProductInfo productInfo)
{
return StringUtils.defaultString(productInfo.getProductName()) + " "
+ StringUtils.defaultString(productInfo.getBrand()) + " "
+ StringUtils.defaultString(productInfo.getSpec()) + " "
+ StringUtils.defaultString(productInfo.getUnit());
}
private String generateCategory(ProductInfo productInfo)
{
String text = productDraftText(productInfo).toLowerCase(Locale.ROOT);
if (containsAny(text, "路由器", "交换机", "网关", "ap", "wifi", "网线", "光模块", "网络"))
{
return "网络设备";
}
if (containsAny(text, "电脑", "主机", "显示器", "键盘", "鼠标", "硬盘", "内存", "打印机", "扫描仪"))
{
return "办公设备";
}
if (containsAny(text, "", "抽纸", "卷纸", "硒鼓", "墨盒", "墨粉", "", "文件夹", "订书", "胶带"))
{
return "办公耗材";
}
if (containsAny(text, "手套", "口罩", "安全帽", "护目镜", "反光", "劳保"))
{
return "劳保用品";
}
if (containsAny(text, "扳手", "螺丝刀", "", "", "", "工具"))
{
return "工具";
}
if (containsAny(text, "螺丝", "螺母", "垫片", "扎带", "卡扣", "五金"))
{
return "五金耗材";
}
if (containsAny(text, "传感器", "继电器", "电源", "线束", "电缆", "接头", "模块", "芯片", "电阻", "电容"))
{
return "电子元器件";
}
if (containsAny(text, "机油", "润滑", "清洗剂", "胶水", "胶粘", "油漆", "化学"))
{
return "化工耗材";
}
if (containsAny(text, "轮胎", "刹车", "电瓶", "电池", "车灯", "滤芯", "汽车"))
{
return "汽车配件";
}
return "通用物料";
}
private boolean containsAny(String text, String... keywords)
{
for (String keyword : keywords)
{
if (containsIgnoreCase(text, keyword))
{
return true;
}
}
return false;
}
private boolean containsField(String text, String field)
{
return StringUtils.isNotBlank(text) && StringUtils.isNotBlank(field) && containsIgnoreCase(text, field);
}
private boolean containsIgnoreCase(String text, String keyword)
{
if (StringUtils.isBlank(text) || StringUtils.isBlank(keyword))
{
return false;
}
return text.toLowerCase(Locale.ROOT).contains(keyword.toLowerCase(Locale.ROOT));
}
}

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,
meta: { title: '移动库存' }
},
{
path: '/warehouse/batch-import',
component: Layout,
hidden: true,
children: [
{
path: '',
component: () => import('@/views/warehouse/batchImport/index'),
name: 'WmsBatchImport',
meta: { title: '批量导入' }
}
]
},
{
path: '',
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>
<p>面向库区产品入库出库和盘库的日常库存作业台</p>
</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 class="agent-panel">
@ -58,6 +61,11 @@
</div>
<el-table v-if="agent.plan.results && agent.plan.results.length" :data="agent.plan.results" size="mini" class="agent-table">
<el-table-column type="index" label="#" width="54" />
<el-table-column v-if="canSelectAgentResult(agent.plan)" label="选择" width="86">
<template slot-scope="scope">
<el-button type="primary" size="mini" plain @click="selectAgentResult(scope.row)">选择</el-button>
</template>
</el-table-column>
<el-table-column label="库区" prop="areaName" width="120" />
<el-table-column label="产品" min-width="220">
<template slot-scope="scope">
@ -229,6 +237,60 @@ export default {
this.agent.text = ''
this.agent.plan = null
},
canSelectAgentResult(plan) {
return plan && ['create_out_record', 'create_in_record', 'create_stock_check'].includes(plan.action) && plan.results && plan.results.length
},
selectAgentResult(item) {
if (!this.agent.plan || !item) {
return
}
const plan = this.completePlanWithResult(this.agent.plan, item)
this.agent.plan = plan
this.agent.contextResults = [item]
this.$modal.msgSuccess('已选择:' + (item.productName || '库存项'))
},
completePlanWithResult(source, item) {
const plan = Object.assign({}, source, {
stockId: item.id,
areaId: item.areaId,
productId: item.productId,
areaName: item.areaName,
productName: item.productName,
brand: item.brand,
category: item.category,
spec: item.spec,
unit: item.unit,
currentStock: item.stockNum,
candidates: [],
results: [],
needConfirm: true,
message: '已选择产品,请确认后执行'
})
const quantity = this.toNumber(plan.quantity)
const stock = this.toNumber(item.stockNum)
if (plan.action === 'create_out_record') {
plan.executable = !!(plan.areaId && plan.productId && quantity > 0)
plan.afterStock = quantity > 0 && item.stockNum !== undefined && item.stockNum !== null ? stock - quantity : undefined
if (plan.executable && stock < quantity) {
plan.executable = false
plan.message = '库存不足,当前库存:' + item.stockNum
}
} else if (plan.action === 'create_in_record') {
plan.executable = !!(plan.areaId && plan.productId && quantity > 0)
plan.afterStock = quantity > 0 && item.stockNum !== undefined && item.stockNum !== null ? stock + quantity : undefined
} else if (plan.action === 'create_stock_check') {
plan.executable = !!(plan.areaId && plan.productId && quantity >= 0)
plan.afterStock = quantity >= 0 ? quantity : undefined
}
if (!plan.executable && plan.message === '已选择产品,请确认后执行') {
plan.message = '已选择产品,还缺少库区或数量'
}
return plan
},
toNumber(value) {
const numberValue = Number(value)
return Number.isFinite(numberValue) ? numberValue : 0
},
actionLabel(action) {
const map = {
query_stock: '查询库存',
@ -278,6 +340,12 @@ export default {
border: 1px solid #e6ebf2;
border-radius: 8px;
}
.hero-actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.hero h1 {
margin: 0;
font-size: 24px;

View File

@ -76,12 +76,15 @@
<div v-if="agent.plan.candidates && agent.plan.candidates.length" class="agent-candidates">
<span v-for="item in agent.plan.candidates" :key="item.type + item.id">{{ item.name }} {{ item.detail || '' }}</span>
</div>
<article v-for="(item, index) in (agent.plan.results || [])" :key="'agent-result-' + item.id" class="agent-stock">
<article v-for="(item, index) in (agent.plan.results || [])" :key="'agent-result-' + index + '-' + (item.id || item.productId || '')" class="agent-stock">
<div>
<div class="card-title"> {{ index + 1 }} {{ item.productName || '-' }}</div>
<div class="meta-line">{{ item.areaName || '-' }} {{ productMeta(item) }}</div>
</div>
<strong>{{ item.stockNum }}</strong>
<div class="agent-stock-action">
<strong>{{ item.stockNum === undefined || item.stockNum === null ? '-' : item.stockNum }}</strong>
<el-button v-if="canSelectAgentResult(agent.plan)" type="primary" size="mini" plain @click="selectAgentResult(item)">选择</el-button>
</div>
</article>
</div>
</div>
@ -93,6 +96,7 @@
<button @click="switchTab('stock')">查库存</button>
<button @click="switchTab('product')" v-hasPermi="['warehouse:product:list']">产品维护</button>
<button @click="switchTab('area')" v-hasPermi="['warehouse:area:list']">库区维护</button>
<button @click="switchTab('batch')">批量导入</button>
</div>
<div class="panel-title">库存概览</div>
@ -300,6 +304,108 @@
</div>
</article>
</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>
<nav class="bottom-nav">
@ -435,6 +541,8 @@ import { listInRecord, getInRecord, addInRecord, updateInRecord, delInRecord, co
import { listOutRecord, addOutRecord } from '@/api/warehouse/outRecord'
import { listStockCheck, addStockCheck, confirmStockCheck } from '@/api/warehouse/stockCheck'
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 {
name: 'MobileWarehouse',
@ -470,6 +578,19 @@ export default {
contextResults: [],
pendingNextPlan: null,
plan: null
},
batch: {
uploading: false,
confirming: false,
jobLoading: false,
itemLoading: false,
batchSaving: false,
actionType: undefined,
jobList: [],
currentJob: null,
items: [],
selectedItemIds: [],
pollTimer: null
}
}
},
@ -484,6 +605,9 @@ export default {
unitOptions() {
return this.distinctProductOptions('unit')
},
canEditBatchItems() {
return this.batch.currentJob && ![3, 4].includes(this.batch.currentJob.status)
},
agentPlanItems() {
const plan = this.agent.plan
if (!plan) {
@ -508,6 +632,9 @@ export default {
this.loadOptions()
this.refreshAll()
},
beforeDestroy() {
this.stopBatchPolling()
},
methods: {
analyzeAgentText() {
const text = (this.agent.text || '').trim()
@ -546,6 +673,64 @@ export default {
this.agent.plan = null
this.agent.pendingNextPlan = null
},
canSelectAgentResult(plan) {
return plan && ['create_out_record', 'create_in_record', 'create_stock_check'].includes(plan.action) && plan.results && plan.results.length
},
selectAgentResult(item) {
if (!this.agent.plan || !item) {
return
}
const plan = this.completePlanWithResult(this.agent.plan, item)
this.agent.plan = plan
this.agent.contextResults = [item]
if (plan.executable) {
this.autoFillAgentForm(plan)
} else {
this.$modal.msgWarning(plan.message || '已选择产品,请补充缺少的信息')
}
},
completePlanWithResult(source, item) {
const plan = Object.assign({}, source, {
stockId: item.id,
areaId: item.areaId,
productId: item.productId,
areaName: item.areaName,
productName: item.productName,
brand: item.brand,
category: item.category,
spec: item.spec,
unit: item.unit,
currentStock: item.stockNum,
candidates: [],
results: [],
needConfirm: true,
message: '已选择产品,请确认后执行'
})
const quantity = this.toNumber(plan.quantity)
const stock = this.toNumber(item.stockNum)
if (plan.action === 'create_out_record') {
plan.executable = !!(plan.areaId && plan.productId && quantity > 0)
plan.afterStock = quantity > 0 && item.stockNum !== undefined && item.stockNum !== null ? stock - quantity : undefined
if (plan.executable && stock < quantity) {
plan.executable = false
plan.message = '库存不足,当前库存:' + item.stockNum
}
} else if (plan.action === 'create_in_record') {
plan.executable = !!(plan.areaId && plan.productId && quantity > 0)
plan.afterStock = quantity > 0 && item.stockNum !== undefined && item.stockNum !== null ? stock + quantity : undefined
} else if (plan.action === 'create_stock_check') {
plan.executable = !!(plan.areaId && plan.productId && quantity >= 0)
plan.afterStock = quantity >= 0 ? quantity : undefined
}
if (!plan.executable && plan.message === '已选择产品,请确认后执行') {
plan.message = '已选择产品,还缺少库区或数量'
}
return plan
},
toNumber(value) {
const numberValue = Number(value)
return Number.isFinite(numberValue) ? numberValue : 0
},
autoFillAgentForm(plan) {
if (!plan || !plan.executable) {
return
@ -561,7 +746,7 @@ export default {
spec: plan.spec,
unit: plan.unit,
outOperator: plan.operatorName,
outType: plan.outType || 3,
outType: plan.outType || 1,
outNum: plan.quantity,
sourceNo: undefined,
remark: plan.remark
@ -574,7 +759,7 @@ export default {
areaId: plan.areaId,
productId: plan.productId,
outOperator: plan.operatorName,
outType: plan.outType || 3,
outType: plan.outType || 1,
outNum: plan.quantity,
sourceNo: undefined,
remark: plan.remark
@ -697,6 +882,156 @@ export default {
loadAreas() {
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) {
this.activeTab = tab
if (tab === 'stock') this.loadStock()
@ -705,6 +1040,7 @@ export default {
if (tab === 'check') this.loadCheckRecords()
if (tab === 'product') this.loadProducts()
if (tab === 'area') this.loadAreas()
if (tab === 'batch') this.loadBatchJobs()
if (tab === 'home') this.refreshAll()
},
areaLabel(area) {
@ -739,7 +1075,7 @@ export default {
areaId: undefined,
productId: undefined,
inOperator: undefined,
inType: 1,
inType: 2,
inNum: undefined,
remark: undefined
}, data || {})
@ -1133,6 +1469,13 @@ export default {
font-size: 18px;
white-space: nowrap;
}
.agent-stock-action {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
flex-shrink: 0;
}
.quick-panel {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@ -1181,6 +1524,70 @@ export default {
width: 100%;
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 {
background: #ffffff;
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>

View File

@ -198,7 +198,7 @@ export default {
},
cancel() { this.open = false; this.reset() },
reset() {
this.form = { id: undefined, inNo: undefined, areaId: undefined, productId: undefined, inOperator: undefined, inType: 1, inNum: undefined, remark: undefined }
this.form = { id: undefined, inNo: undefined, areaId: undefined, productId: undefined, inOperator: undefined, inType: 2, inNum: undefined, remark: undefined }
this.resetForm('form')
},
handleQuery() { this.queryParams.pageNum = 1; this.getList() },

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批量导入明细';