feat(warehouse): 升级智能代理服务功能

- 集成大模型配置支持,包括API密钥、超时设置和输出令牌限制
- 优化意图解析算法,提升数量提取和引用识别准确性
- 增加入库单关联采购请求和到货单功能
- 完善入库确认前的库区和操作员验证机制
- 重构HTTP客户端配置,统一连接超时和协议版本设置
- 优化前端对话框组件,改进错误处理和结果显示
- 添加移动端上下文引用功能,增强对话连续性
- 扩展入库记录表格显示来源单号和待分配状态
- 实现Excel导入组件错误处理和更新选项控制
This commit is contained in:
lixiaolong 2026-09-15 15:50:24 +08:00
parent 46ec60d6f5
commit 2ea0df3e1b
39 changed files with 4672 additions and 94 deletions

View File

@ -0,0 +1,239 @@
package com.ruoyi.web.controller.warehouse;
import java.util.List;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
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.common.utils.poi.ExcelUtil;
import com.ruoyi.warehouse.domain.PurchaseBatch;
import com.ruoyi.warehouse.domain.PurchaseBatchAssignRequest;
import com.ruoyi.warehouse.domain.PurchaseArrival;
import com.ruoyi.warehouse.domain.PurchaseArrivalAcceptanceRequest;
import com.ruoyi.warehouse.domain.PurchaseArrivalRevokeRequest;
import com.ruoyi.warehouse.domain.PurchaseRequest;
import com.ruoyi.warehouse.domain.PurchaseStatusUpdateRequest;
import com.ruoyi.warehouse.service.IPurchaseArrivalService;
import com.ruoyi.warehouse.service.IPurchaseBatchService;
import com.ruoyi.warehouse.service.IProductInfoService;
import com.ruoyi.warehouse.service.IPurchaseRequestService;
import com.ruoyi.warehouse.service.IPurchaseSmartImportService;
@RestController
@RequestMapping("/warehouse/purchase")
public class PurchaseRequestController extends BaseController
{
@Autowired
private IPurchaseRequestService purchaseRequestService;
@Autowired
private IPurchaseBatchService purchaseBatchService;
@Autowired
private IPurchaseArrivalService purchaseArrivalService;
@Autowired
private IProductInfoService productInfoService;
@Autowired
private IPurchaseSmartImportService purchaseSmartImportService;
@PreAuthorize("@ss.hasAnyPermi('warehouse:purchase:list,warehouse:purchase:manage')")
@GetMapping("/list")
public TableDataInfo list(PurchaseRequest purchaseRequest)
{
startPage();
return getDataTable(purchaseRequestService.selectPurchaseRequestList(purchaseRequest));
}
@PreAuthorize("@ss.hasAnyPermi('warehouse:purchase:export,warehouse:purchase:manage')")
@Log(title = "采购申请", businessType = BusinessType.EXPORT)
@PostMapping("/export")
public void export(HttpServletResponse response, PurchaseRequest purchaseRequest) throws Exception
{
List<PurchaseRequest> list = purchaseRequestService.selectPurchaseRequestList(purchaseRequest);
PurchaseTemplateExcelUtil.writeExport(response, list, productInfoService.selectProductInfoAll());
}
@PreAuthorize("@ss.hasAnyPermi('warehouse:purchase:import,warehouse:purchase:manage')")
@Log(title = "采购申请", businessType = BusinessType.IMPORT)
@PostMapping("/importData")
public AjaxResult importData(MultipartFile file) throws Exception
{
ExcelUtil<PurchaseRequest> util = new ExcelUtil<PurchaseRequest>(PurchaseRequest.class);
List<PurchaseRequest> requestList = util.importExcel(file.getInputStream());
return success(purchaseRequestService.importPurchaseRequest(requestList, getUsername()));
}
@PreAuthorize("@ss.hasAnyPermi('warehouse:purchase:add,warehouse:purchase:manage')")
@Log(title = "采购申请智能导入", businessType = BusinessType.IMPORT)
@PostMapping("/smart-import")
public AjaxResult smartImport(MultipartFile file) throws Exception
{
return success(purchaseSmartImportService.analyze(file));
}
@PreAuthorize("@ss.hasAnyPermi('warehouse:purchase:import,warehouse:purchase:manage')")
@PostMapping("/importTemplate")
public void importTemplate(HttpServletResponse response) throws Exception
{
PurchaseTemplateExcelUtil.write(response, productInfoService.selectProductInfoAll());
}
@PreAuthorize("@ss.hasAnyPermi('warehouse:purchase:query,warehouse:purchase:manage')")
@GetMapping("/{id}")
public AjaxResult getInfo(@PathVariable Long id)
{
return success(purchaseRequestService.selectPurchaseRequestById(id));
}
@PreAuthorize("@ss.hasAnyPermi('warehouse:purchase:add,warehouse:purchase:manage')")
@Log(title = "采购申请", businessType = BusinessType.INSERT)
@PostMapping
public AjaxResult add(@Validated @RequestBody PurchaseRequest purchaseRequest)
{
return toAjax(purchaseRequestService.insertPurchaseRequest(purchaseRequest));
}
@PreAuthorize("@ss.hasAnyPermi('warehouse:purchase:add,warehouse:purchase:manage')")
@Log(title = "采购申请表格录入", businessType = BusinessType.INSERT)
@PostMapping("/table")
public AjaxResult tableAdd(@RequestBody List<PurchaseRequest> requestList)
{
return success(purchaseRequestService.batchInsertPurchaseRequest(requestList));
}
@PreAuthorize("@ss.hasAnyPermi('warehouse:purchase:edit,warehouse:purchase:manage')")
@Log(title = "采购申请", businessType = BusinessType.UPDATE)
@PutMapping
public AjaxResult edit(@Validated @RequestBody PurchaseRequest purchaseRequest)
{
return toAjax(purchaseRequestService.updatePurchaseRequest(purchaseRequest));
}
@PreAuthorize("@ss.hasAnyPermi('warehouse:purchase:edit,warehouse:purchase:manage')")
@Log(title = "采购状态", businessType = BusinessType.UPDATE)
@PutMapping("/{id}/status")
public AjaxResult updateStatus(@PathVariable Long id,
@Validated @RequestBody PurchaseStatusUpdateRequest request)
{
return toAjax(purchaseRequestService.updatePurchaseStatus(id, request));
}
@PreAuthorize("@ss.hasAnyPermi('warehouse:purchase:list,warehouse:purchase:manage')")
@GetMapping("/arrival/{requestId}")
public AjaxResult arrivalList(@PathVariable Long requestId)
{
return success(purchaseArrivalService.selectPurchaseArrivalList(requestId));
}
@PreAuthorize("@ss.hasAnyPermi('warehouse:purchase:edit,warehouse:purchase:manage')")
@Log(title = "采购到货", businessType = BusinessType.INSERT)
@PostMapping("/arrival")
public AjaxResult addArrival(@Validated @RequestBody PurchaseArrival arrival)
{
return toAjax(purchaseArrivalService.insertPurchaseArrival(arrival));
}
@PreAuthorize("@ss.hasAnyPermi('warehouse:purchase:edit,warehouse:purchase:manage')")
@Log(title = "采购到货验收", businessType = BusinessType.UPDATE)
@PutMapping("/arrival/{id}/accept")
public AjaxResult acceptArrival(@PathVariable Long id,
@Validated @RequestBody PurchaseArrivalAcceptanceRequest request)
{
return toAjax(purchaseArrivalService.acceptPurchaseArrival(id, request));
}
@PreAuthorize("@ss.hasAnyPermi('warehouse:purchase:edit,warehouse:purchase:manage')")
@Log(title = "撤销采购到货验收", businessType = BusinessType.UPDATE)
@PutMapping("/arrival/{id}/revoke")
public AjaxResult revokeArrival(@PathVariable Long id,
@Validated @RequestBody PurchaseArrivalRevokeRequest request)
{
return toAjax(purchaseArrivalService.revokePurchaseArrivalAcceptance(id, request));
}
@PreAuthorize("@ss.hasAnyPermi('warehouse:purchase:remove,warehouse:purchase:manage')")
@Log(title = "采购到货", businessType = BusinessType.DELETE)
@DeleteMapping("/arrival/{id}")
public AjaxResult removeArrival(@PathVariable Long id)
{
return toAjax(purchaseArrivalService.deletePurchaseArrival(id));
}
@PreAuthorize("@ss.hasPermi('warehouse:purchase:manage')")
@GetMapping("/batch/list")
public TableDataInfo batchList(PurchaseBatch batch)
{
startPage();
return getDataTable(purchaseBatchService.selectPurchaseBatchList(batch));
}
@PreAuthorize("@ss.hasPermi('warehouse:purchase:manage')")
@GetMapping("/batch/options")
public AjaxResult batchOptions(String keyword)
{
return success(purchaseBatchService.selectPurchaseBatchOptions(keyword));
}
@PreAuthorize("@ss.hasAnyPermi('warehouse:purchase:list,warehouse:purchase:manage')")
@GetMapping("/batch/filter-options")
public AjaxResult batchFilterOptions(String keyword)
{
return success(purchaseBatchService.selectPurchaseBatchFilterOptions(keyword));
}
@PreAuthorize("@ss.hasPermi('warehouse:purchase:manage')")
@Log(title = "采购批次", businessType = BusinessType.INSERT)
@PostMapping("/batch")
public AjaxResult addBatch(@RequestBody PurchaseBatch batch)
{
return toAjax(purchaseBatchService.insertPurchaseBatch(batch));
}
@PreAuthorize("@ss.hasPermi('warehouse:purchase:manage')")
@Log(title = "采购批次", businessType = BusinessType.UPDATE)
@PutMapping("/batch")
public AjaxResult editBatch(@RequestBody PurchaseBatch batch)
{
return toAjax(purchaseBatchService.updatePurchaseBatch(batch));
}
@PreAuthorize("@ss.hasPermi('warehouse:purchase:manage')")
@Log(title = "采购批次分配", businessType = BusinessType.UPDATE)
@PutMapping("/batch/assign")
public AjaxResult assignBatch(@RequestBody PurchaseBatchAssignRequest request)
{
return toAjax(purchaseBatchService.assignPurchaseBatch(request));
}
@PreAuthorize("@ss.hasPermi('warehouse:purchase:manage')")
@Log(title = "采购批次", businessType = BusinessType.DELETE)
@DeleteMapping("/batch/{id}")
public AjaxResult removeBatch(@PathVariable Long id)
{
return toAjax(purchaseBatchService.deletePurchaseBatchById(id));
}
@PreAuthorize("@ss.hasAnyPermi('warehouse:purchase:remove,warehouse:purchase:manage')")
@Log(title = "采购申请", businessType = BusinessType.DELETE)
@DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable Long[] ids)
{
return toAjax(purchaseRequestService.deletePurchaseRequestByIds(ids));
}
}

View File

@ -0,0 +1,264 @@
package com.ruoyi.web.controller.warehouse;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.DataValidation;
import org.apache.poi.ss.usermodel.DataValidationConstraint;
import org.apache.poi.ss.usermodel.DataValidationHelper;
import org.apache.poi.ss.usermodel.DataValidation.ErrorStyle;
import org.apache.poi.ss.usermodel.FillPatternType;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.IndexedColors;
import org.apache.poi.ss.usermodel.Name;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.SheetVisibility;
import org.apache.poi.ss.util.CellRangeAddressList;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.file.FileUtils;
import com.ruoyi.warehouse.domain.ProductInfo;
import com.ruoyi.warehouse.domain.PurchaseRequest;
public final class PurchaseTemplateExcelUtil
{
private static final String[] HEADERS = {
"关联物料编码", "物料名称", "品牌", "类别", "参数规格", "采购链接",
"需求数量", "单位", "期望到货日期", "产品备注", "申请备注"
};
private static final String[] EXPORT_HEADERS = {
"关联物料编码", "物料名称", "品牌", "类别", "参数规格", "采购链接",
"需求数量", "单位", "期望到货日期", "申请人", "采购状态", "采购批次", "进度说明",
"产品备注", "申请备注", "已验收数量", "待验收数量"
};
private PurchaseTemplateExcelUtil()
{
}
public static void write(HttpServletResponse response, List<ProductInfo> products) throws IOException
{
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("utf-8");
FileUtils.setAttachmentResponseHeader(response, "采购申请导入模板.xlsx");
try (XSSFWorkbook workbook = createTemplateWorkbook(products))
{
workbook.write(response.getOutputStream());
}
}
static XSSFWorkbook createTemplateWorkbook(List<ProductInfo> products)
{
XSSFWorkbook workbook = new XSSFWorkbook();
Sheet sheet = workbook.createSheet("采购申请数据");
Sheet options = workbook.createSheet("系统下拉数据");
CellStyle headerStyle = createHeaderStyle(workbook);
Row header = sheet.createRow(0);
for (int index = 0; index < HEADERS.length; index++)
{
Cell cell = header.createCell(index);
cell.setCellValue(HEADERS[index]);
cell.setCellStyle(headerStyle);
sheet.setColumnWidth(index, columnWidth(index));
}
sheet.createFreezePane(0, 1);
addOptionColumn(workbook, sheet, options, 0, "物料编码", values(products, "code"));
addOptionColumn(workbook, sheet, options, 1, "物料名称", values(products, "name"));
addOptionColumn(workbook, sheet, options, 2, "品牌", values(products, "brand"));
addOptionColumn(workbook, sheet, options, 3, "类别", values(products, "category"));
addOptionColumn(workbook, sheet, options, 4, "参数规格", values(products, "spec"));
addOptionColumn(workbook, sheet, options, 7, "单位", values(products, "unit"));
workbook.setSheetVisibility(workbook.getSheetIndex(options), SheetVisibility.HIDDEN);
return workbook;
}
public static void writeExport(HttpServletResponse response, List<PurchaseRequest> requests,
List<ProductInfo> products) throws IOException
{
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("utf-8");
FileUtils.setAttachmentResponseHeader(response, "采购申请数据.xlsx");
try (XSSFWorkbook workbook = new XSSFWorkbook())
{
Sheet sheet = workbook.createSheet("采购申请数据");
Sheet options = workbook.createSheet("系统下拉数据");
CellStyle headerStyle = createHeaderStyle(workbook);
Row header = sheet.createRow(0);
for (int index = 0; index < EXPORT_HEADERS.length; index++)
{
Cell cell = header.createCell(index);
cell.setCellValue(EXPORT_HEADERS[index]);
cell.setCellStyle(headerStyle);
sheet.setColumnWidth(index, exportColumnWidth(index));
}
sheet.createFreezePane(0, 1);
SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd");
for (int rowIndex = 0; rowIndex < requests.size(); rowIndex++)
{
PurchaseRequest request = requests.get(rowIndex);
Row row = sheet.createRow(rowIndex + 1);
Object[] values = {
request.getProductCode(), request.getMaterialName(), request.getMaterialBrand(),
request.getCategory(), request.getMaterialParams(),
request.getPurchaseLink(), request.getQuantity(), request.getUnit(),
request.getExpectedDate() == null ? null : dateFormat.format(request.getExpectedDate()),
request.getApplicantName(), purchaseStatusLabel(request.getPurchaseStatus()),
request.getPurchaseBatchNo(), request.getProgressRemark(), request.getProductRemark(),
request.getRequestRemark(), request.getAcceptedQuantity(), request.getPendingArrivalQuantity()
};
for (int column = 0; column < values.length; column++)
{
setCellValue(row.createCell(column), values[column]);
}
}
addOptionColumn(workbook, sheet, options, 0, "物料编码", values(products, "code"));
addOptionColumn(workbook, sheet, options, 1, "物料名称", values(products, "name"));
addOptionColumn(workbook, sheet, options, 2, "品牌", values(products, "brand"));
addOptionColumn(workbook, sheet, options, 3, "类别", values(products, "category"));
addOptionColumn(workbook, sheet, options, 4, "参数规格", values(products, "spec"));
addOptionColumn(workbook, sheet, options, 7, "单位", values(products, "unit"));
workbook.setSheetVisibility(workbook.getSheetIndex(options), SheetVisibility.HIDDEN);
workbook.write(response.getOutputStream());
}
}
private static void addOptionColumn(XSSFWorkbook workbook, Sheet sheet, Sheet options,
int targetColumn, String title, List<String> values)
{
int optionColumn = options.getRow(0) == null ? 0 : options.getRow(0).getLastCellNum();
Row titleRow = options.getRow(0);
if (titleRow == null)
{
titleRow = options.createRow(0);
optionColumn = 0;
}
titleRow.createCell(optionColumn).setCellValue(title);
if (values.isEmpty())
{
return;
}
for (int index = 0; index < values.size(); index++)
{
Row row = options.getRow(index + 1);
if (row == null)
{
row = options.createRow(index + 1);
}
row.createCell(optionColumn).setCellValue(values.get(index));
}
String rangeName = "purchase_option_" + targetColumn;
Name name = workbook.createName();
name.setNameName(rangeName);
String optionColumnName = org.apache.poi.ss.util.CellReference.convertNumToColString(optionColumn);
name.setRefersToFormula("'系统下拉数据'!$" + optionColumnName + "$2:$" + optionColumnName + "$" + (values.size() + 1));
DataValidationHelper helper = sheet.getDataValidationHelper();
DataValidationConstraint constraint = helper.createFormulaListConstraint(rangeName);
CellRangeAddressList range = new CellRangeAddressList(1, 10000, targetColumn, targetColumn);
DataValidation validation = helper.createValidation(constraint, range);
validation.setEmptyCellAllowed(true);
// XSSF writes the inverse OOXML showDropDown flag; true keeps the arrow visible in Excel/WPS.
validation.setSuppressDropDownArrow(true);
validation.setErrorStyle(ErrorStyle.WARNING);
validation.setShowErrorBox(true);
validation.createErrorBox("不在系统数据中", "可以继续输入新值,确认后系统会新增或复用该属性");
validation.setShowPromptBox(false);
sheet.addValidationData(validation);
}
private static List<String> values(List<ProductInfo> products, String field)
{
Set<String> values = new LinkedHashSet<>();
for (ProductInfo product : products)
{
String value = switch (field)
{
case "code" -> product.getProductCode();
case "name" -> product.getProductName();
case "brand" -> product.getBrand();
case "category" -> product.getCategory();
case "spec" -> product.getSpec();
case "unit" -> product.getUnit();
default -> null;
};
if (StringUtils.isNotBlank(value))
{
values.add(value.trim());
}
}
return new ArrayList<>(values);
}
private static CellStyle createHeaderStyle(XSSFWorkbook workbook)
{
CellStyle style = workbook.createCellStyle();
style.setFillForegroundColor(IndexedColors.LIGHT_CORNFLOWER_BLUE.getIndex());
style.setFillPattern(FillPatternType.SOLID_FOREGROUND);
Font font = workbook.createFont();
font.setBold(true);
style.setFont(font);
return style;
}
private static int columnWidth(int column)
{
if (column == 5)
{
return 42 * 256;
}
if (column == 4 || column >= 9)
{
return 24 * 256;
}
return 18 * 256;
}
private static int exportColumnWidth(int column)
{
if (column == 5)
{
return 42 * 256;
}
if (column == 4 || column == 12 || column == 13 || column == 14)
{
return 24 * 256;
}
return 18 * 256;
}
private static void setCellValue(Cell cell, Object value)
{
if (value == null)
{
cell.setBlank();
}
else if (value instanceof Number number)
{
cell.setCellValue(number.doubleValue());
}
else
{
cell.setCellValue(String.valueOf(value));
}
}
private static String purchaseStatusLabel(Integer status)
{
String[] labels = { "待采购", "已下单", "采购中", "部分到货", "已到齐", "已取消" };
return status != null && status >= 0 && status < labels.length ? labels[status] : "未知";
}
}

View File

@ -144,6 +144,13 @@ xss:
# WMS agent model config. Keep api-key in environment variables, not in source code. # WMS agent model config. Keep api-key in environment variables, not in source code.
agent: agent:
model:
base-url: ${AGENT_MODEL_BASE_URL:http://192.168.28.10:14081}
api-key: ${AGENT_MODEL_API_KEY:}
model: ${AGENT_MODEL_NAME:Qwen/Qwen3.8-27B-FP8}
timeout-ms: ${AGENT_MODEL_TIMEOUT_MS:8000}
max-output-tokens: ${AGENT_MODEL_MAX_OUTPUT_TOKENS:512}
# 图片批量导入仍使用支持视觉输入的远程模型。
openai: openai:
base-url: ${OPENAI_BASE_URL:http://192.168.28.10:18080} base-url: ${OPENAI_BASE_URL:http://192.168.28.10:18080}
api-key: ${OPENAI_API_KEY:sk-dd185946191d42eaa54892dec488bf7b54cad2d7433aee8aac7a017365b0829d} api-key: ${OPENAI_API_KEY:sk-dd185946191d42eaa54892dec488bf7b54cad2d7433aee8aac7a017365b0829d}

View File

@ -9,6 +9,8 @@ import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.common.constant.CacheConstants; import com.ruoyi.common.constant.CacheConstants;
import com.ruoyi.common.constant.Constants; import com.ruoyi.common.constant.Constants;
import com.ruoyi.common.core.domain.model.LoginUser; import com.ruoyi.common.core.domain.model.LoginUser;
@ -72,8 +74,7 @@ public class TokenService
// 解析对应的权限以及用户信息 // 解析对应的权限以及用户信息
String uuid = (String) claims.get(Constants.LOGIN_USER_KEY); String uuid = (String) claims.get(Constants.LOGIN_USER_KEY);
String userKey = getTokenKey(uuid); String userKey = getTokenKey(uuid);
LoginUser user = redisCache.getCacheObject(userKey); return getCachedLoginUser(userKey);
return user;
} }
catch (Exception e) catch (Exception e)
{ {
@ -248,7 +249,7 @@ public class TokenService
} }
for (String key : keys) for (String key : keys)
{ {
LoginUser loginUser = redisCache.getCacheObject(key); LoginUser loginUser = getCachedLoginUser(key);
if (loginUser == null || loginUser.getUser() == null || loginUser.getUser().isAdmin()) if (loginUser == null || loginUser.getUser() == null || loginUser.getUser().isAdmin())
{ {
// 管理员拥有所有权限,跳过 // 管理员拥有所有权限,跳过
@ -267,4 +268,33 @@ public class TokenService
log.info("角色[{}]权限变更,已刷新在线用户[{}]的权限缓存", roleId, loginUser.getUsername()); log.info("角色[{}]权限变更,已刷新在线用户[{}]的权限缓存", roleId, loginUser.getUsername());
} }
} }
/**
* DevTools 重启后,旧 Redis 会话可能由不同类加载器反序列化为 JSONObject。
*/
private LoginUser getCachedLoginUser(String userKey)
{
Object cached = redisCache.getCacheObject(userKey);
if (cached == null)
{
return null;
}
if (cached instanceof LoginUser loginUser)
{
return loginUser;
}
try
{
if (cached instanceof JSONObject jsonObject)
{
return jsonObject.toJavaObject(LoginUser.class);
}
return JSON.parseObject(JSON.toJSONString(cached), LoginUser.class);
}
catch (Exception e)
{
log.warn("Redis 登录会话[{}]格式无效,已忽略:{}", userKey, e.getMessage());
return null;
}
}
} }

View File

@ -22,6 +22,13 @@ public class InRecord extends BaseEntity
private Long productId; private Long productId;
private Long purchaseRequestId;
private Long arrivalId;
@Excel(name = "来源单号")
private String sourceNo;
@Excel(name = "入库类型", readConverterExp = "1=生产入库,2=采购入库,3=归还入库,4=退货入库") @Excel(name = "入库类型", readConverterExp = "1=生产入库,2=采购入库,3=归还入库,4=退货入库")
private Integer inType; private Integer inType;
@ -75,6 +82,12 @@ public class InRecord extends BaseEntity
@NotNull(message = "产品不能为空") @NotNull(message = "产品不能为空")
public Long getProductId() { return productId; } public Long getProductId() { return productId; }
public void setProductId(Long productId) { this.productId = productId; } public void setProductId(Long productId) { this.productId = productId; }
public Long getPurchaseRequestId() { return purchaseRequestId; }
public void setPurchaseRequestId(Long purchaseRequestId) { this.purchaseRequestId = purchaseRequestId; }
public Long getArrivalId() { return arrivalId; }
public void setArrivalId(Long arrivalId) { this.arrivalId = arrivalId; }
public String getSourceNo() { return sourceNo; }
public void setSourceNo(String sourceNo) { this.sourceNo = sourceNo; }
@NotNull(message = "入库类型不能为空") @NotNull(message = "入库类型不能为空")
public Integer getInType() { return inType; } public Integer getInType() { return inType; }

View File

@ -0,0 +1,96 @@
package com.ruoyi.warehouse.domain;
import java.math.BigDecimal;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.core.domain.BaseEntity;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotNull;
public class PurchaseArrival extends BaseEntity
{
private static final long serialVersionUID = 1L;
private Long id;
private String arrivalNo;
private Long purchaseRequestId;
private BigDecimal arrivalQuantity;
@JsonFormat(pattern = "yyyy-MM-dd")
private Date arrivalDate;
private Integer acceptanceStatus;
private String acceptanceRemark;
private String acceptedBy;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date acceptedTime;
private Integer preAcceptPurchaseStatus;
private String revokedBy;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date revokedTime;
private String revokeRemark;
private String requestNo;
private Long applicantId;
private String applicantName;
private Long productId;
private String productCode;
private String materialName;
private String materialBrand;
private String materialParams;
private String unit;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getArrivalNo() { return arrivalNo; }
public void setArrivalNo(String arrivalNo) { this.arrivalNo = arrivalNo; }
public Long getPurchaseRequestId() { return purchaseRequestId; }
public void setPurchaseRequestId(Long purchaseRequestId) { this.purchaseRequestId = purchaseRequestId; }
@NotNull(message = "到货数量不能为空")
@DecimalMin(value = "0.001", message = "到货数量必须大于0")
public BigDecimal getArrivalQuantity() { return arrivalQuantity; }
public void setArrivalQuantity(BigDecimal arrivalQuantity) { this.arrivalQuantity = arrivalQuantity; }
@NotNull(message = "到货日期不能为空")
public Date getArrivalDate() { return arrivalDate; }
public void setArrivalDate(Date arrivalDate) { this.arrivalDate = arrivalDate; }
public Integer getAcceptanceStatus() { return acceptanceStatus; }
public void setAcceptanceStatus(Integer acceptanceStatus) { this.acceptanceStatus = acceptanceStatus; }
public String getAcceptanceRemark() { return acceptanceRemark; }
public void setAcceptanceRemark(String acceptanceRemark) { this.acceptanceRemark = acceptanceRemark; }
public String getAcceptedBy() { return acceptedBy; }
public void setAcceptedBy(String acceptedBy) { this.acceptedBy = acceptedBy; }
public Date getAcceptedTime() { return acceptedTime; }
public void setAcceptedTime(Date acceptedTime) { this.acceptedTime = acceptedTime; }
public Integer getPreAcceptPurchaseStatus() { return preAcceptPurchaseStatus; }
public void setPreAcceptPurchaseStatus(Integer preAcceptPurchaseStatus) { this.preAcceptPurchaseStatus = preAcceptPurchaseStatus; }
public String getRevokedBy() { return revokedBy; }
public void setRevokedBy(String revokedBy) { this.revokedBy = revokedBy; }
public Date getRevokedTime() { return revokedTime; }
public void setRevokedTime(Date revokedTime) { this.revokedTime = revokedTime; }
public String getRevokeRemark() { return revokeRemark; }
public void setRevokeRemark(String revokeRemark) { this.revokeRemark = revokeRemark; }
public String getRequestNo() { return requestNo; }
public void setRequestNo(String requestNo) { this.requestNo = requestNo; }
public Long getApplicantId() { return applicantId; }
public void setApplicantId(Long applicantId) { this.applicantId = applicantId; }
public String getApplicantName() { return applicantName; }
public void setApplicantName(String applicantName) { this.applicantName = applicantName; }
public Long getProductId() { return productId; }
public void setProductId(Long productId) { this.productId = productId; }
public String getProductCode() { return productCode; }
public void setProductCode(String productCode) { this.productCode = productCode; }
public String getMaterialName() { return materialName; }
public void setMaterialName(String materialName) { this.materialName = materialName; }
public String getMaterialBrand() { return materialBrand; }
public void setMaterialBrand(String materialBrand) { this.materialBrand = materialBrand; }
public String getMaterialParams() { return materialParams; }
public void setMaterialParams(String materialParams) { this.materialParams = materialParams; }
public String getUnit() { return unit; }
public void setUnit(String unit) { this.unit = unit; }
}

View File

@ -0,0 +1,15 @@
package com.ruoyi.warehouse.domain;
import jakarta.validation.constraints.NotNull;
public class PurchaseArrivalAcceptanceRequest
{
@NotNull(message = "验收结果不能为空")
private Integer acceptanceStatus;
private String acceptanceRemark;
public Integer getAcceptanceStatus() { return acceptanceStatus; }
public void setAcceptanceStatus(Integer acceptanceStatus) { this.acceptanceStatus = acceptanceStatus; }
public String getAcceptanceRemark() { return acceptanceRemark; }
public void setAcceptanceRemark(String acceptanceRemark) { this.acceptanceRemark = acceptanceRemark; }
}

View File

@ -0,0 +1,19 @@
package com.ruoyi.warehouse.domain;
import jakarta.validation.constraints.NotBlank;
public class PurchaseArrivalRevokeRequest
{
private String revokeRemark;
@NotBlank(message = "撤销原因不能为空")
public String getRevokeRemark()
{
return revokeRemark;
}
public void setRevokeRemark(String revokeRemark)
{
this.revokeRemark = revokeRemark;
}
}

View File

@ -0,0 +1,28 @@
package com.ruoyi.warehouse.domain;
import com.ruoyi.common.core.domain.BaseEntity;
public class PurchaseBatch extends BaseEntity
{
private static final long serialVersionUID = 1L;
private Long id;
private String batchNo;
private String batchName;
private Integer purchaseStatus;
private String progressRemark;
private Integer requestCount;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getBatchNo() { return batchNo; }
public void setBatchNo(String batchNo) { this.batchNo = batchNo; }
public String getBatchName() { return batchName; }
public void setBatchName(String batchName) { this.batchName = batchName; }
public Integer getPurchaseStatus() { return purchaseStatus; }
public void setPurchaseStatus(Integer purchaseStatus) { this.purchaseStatus = purchaseStatus; }
public String getProgressRemark() { return progressRemark; }
public void setProgressRemark(String progressRemark) { this.progressRemark = progressRemark; }
public Integer getRequestCount() { return requestCount; }
public void setRequestCount(Integer requestCount) { this.requestCount = requestCount; }
}

View File

@ -0,0 +1,12 @@
package com.ruoyi.warehouse.domain;
public class PurchaseBatchAssignRequest
{
private Long[] requestIds;
private Long batchId;
public Long[] getRequestIds() { return requestIds; }
public void setRequestIds(Long[] requestIds) { this.requestIds = requestIds; }
public Long getBatchId() { return batchId; }
public void setBatchId(Long batchId) { this.batchId = batchId; }
}

View File

@ -0,0 +1,144 @@
package com.ruoyi.warehouse.domain;
import java.math.BigDecimal;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.ruoyi.common.annotation.Excel;
import com.ruoyi.common.core.domain.BaseEntity;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
public class PurchaseRequest extends BaseEntity
{
private static final long serialVersionUID = 1L;
private Long id;
private String requestNo;
private Long productId;
private Long purchaseBatchId;
@Excel(name = "关联物料编码", prompt = "已有物料建议填写准确编码;留空时按名称、品牌、类别、参数规格匹配")
private String productCode;
@Excel(name = "物料名称", prompt = "必填;无匹配物料时将自动新增产品信息")
private String materialName;
@Excel(name = "品牌")
private String materialBrand;
@Excel(name = "类别")
private String category;
@Excel(name = "参数规格")
private String materialParams;
@Excel(name = "采购链接")
private String purchaseLink;
@Excel(name = "需求数量")
private BigDecimal quantity;
@Excel(name = "单位")
private String unit;
@JsonFormat(pattern = "yyyy-MM-dd")
@Excel(name = "期望到货日期", width = 20, dateFormat = "yyyy-MM-dd")
private Date expectedDate;
private Long applicantId;
@Excel(name = "申请人", type = Excel.Type.EXPORT)
private String applicantName;
@Excel(name = "采购状态", readConverterExp = "0=待采购,1=已下单,2=采购中,3=部分到货,4=已到齐,5=已取消", type = Excel.Type.EXPORT)
private Integer purchaseStatus;
@Excel(name = "采购批次", type = Excel.Type.EXPORT)
private String purchaseBatchNo;
@Excel(name = "进度说明", type = Excel.Type.EXPORT)
private String progressRemark;
@Excel(name = "产品备注")
private String productRemark;
@Excel(name = "申请备注")
private String requestRemark;
private String productName;
private String productBrand;
private String productCategory;
private String productSpec;
@Excel(name = "已验收数量", type = Excel.Type.EXPORT)
private BigDecimal acceptedQuantity;
@Excel(name = "待验收数量", type = Excel.Type.EXPORT)
private BigDecimal pendingArrivalQuantity;
private BigDecimal remainingQuantity;
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getRequestNo() { return requestNo; }
public void setRequestNo(String requestNo) { this.requestNo = requestNo; }
public Long getProductId() { return productId; }
public void setProductId(Long productId) { this.productId = productId; }
public Long getPurchaseBatchId() { return purchaseBatchId; }
public void setPurchaseBatchId(Long purchaseBatchId) { this.purchaseBatchId = purchaseBatchId; }
public String getProductCode() { return productCode; }
public void setProductCode(String productCode) { this.productCode = productCode; }
@NotBlank(message = "物料名称不能为空")
public String getMaterialName() { return materialName; }
public void setMaterialName(String materialName) { this.materialName = materialName; }
public String getMaterialBrand() { return materialBrand; }
public void setMaterialBrand(String materialBrand) { this.materialBrand = materialBrand; }
public String getCategory() { return category; }
public void setCategory(String category) { this.category = category; }
public String getMaterialParams() { return materialParams; }
public void setMaterialParams(String materialParams) { this.materialParams = materialParams; }
public String getPurchaseLink() { return purchaseLink; }
public void setPurchaseLink(String purchaseLink) { this.purchaseLink = purchaseLink; }
@NotNull(message = "需求数量不能为空")
@DecimalMin(value = "0.001", message = "需求数量必须大于0")
public BigDecimal getQuantity() { return quantity; }
public void setQuantity(BigDecimal quantity) { this.quantity = quantity; }
public String getUnit() { return unit; }
public void setUnit(String unit) { this.unit = unit; }
public Date getExpectedDate() { return expectedDate; }
public void setExpectedDate(Date expectedDate) { this.expectedDate = expectedDate; }
public Long getApplicantId() { return applicantId; }
public void setApplicantId(Long applicantId) { this.applicantId = applicantId; }
public String getApplicantName() { return applicantName; }
public void setApplicantName(String applicantName) { this.applicantName = applicantName; }
public Integer getPurchaseStatus() { return purchaseStatus; }
public void setPurchaseStatus(Integer purchaseStatus) { this.purchaseStatus = purchaseStatus; }
public String getPurchaseBatchNo() { return purchaseBatchNo; }
public void setPurchaseBatchNo(String purchaseBatchNo) { this.purchaseBatchNo = purchaseBatchNo; }
public String getProgressRemark() { return progressRemark; }
public void setProgressRemark(String progressRemark) { this.progressRemark = progressRemark; }
public String getProductRemark() { return productRemark; }
public void setProductRemark(String productRemark) { this.productRemark = productRemark; }
public String getRequestRemark() { return requestRemark; }
public void setRequestRemark(String requestRemark) { this.requestRemark = requestRemark; }
public String getProductName() { return productName; }
public void setProductName(String productName) { this.productName = productName; }
public String getProductBrand() { return productBrand; }
public void setProductBrand(String productBrand) { this.productBrand = productBrand; }
public String getProductCategory() { return productCategory; }
public void setProductCategory(String productCategory) { this.productCategory = productCategory; }
public String getProductSpec() { return productSpec; }
public void setProductSpec(String productSpec) { this.productSpec = productSpec; }
public BigDecimal getAcceptedQuantity() { return acceptedQuantity; }
public void setAcceptedQuantity(BigDecimal acceptedQuantity) { this.acceptedQuantity = acceptedQuantity; }
public BigDecimal getPendingArrivalQuantity() { return pendingArrivalQuantity; }
public void setPendingArrivalQuantity(BigDecimal pendingArrivalQuantity) { this.pendingArrivalQuantity = pendingArrivalQuantity; }
public BigDecimal getRemainingQuantity() { return remainingQuantity; }
public void setRemainingQuantity(BigDecimal remainingQuantity) { this.remainingQuantity = remainingQuantity; }
}

View File

@ -0,0 +1,15 @@
package com.ruoyi.warehouse.domain;
import jakarta.validation.constraints.NotNull;
public class PurchaseStatusUpdateRequest
{
@NotNull(message = "采购状态不能为空")
private Integer purchaseStatus;
private String progressRemark;
public Integer getPurchaseStatus() { return purchaseStatus; }
public void setPurchaseStatus(Integer purchaseStatus) { this.purchaseStatus = purchaseStatus; }
public String getProgressRemark() { return progressRemark; }
public void setProgressRemark(String progressRemark) { this.progressRemark = progressRemark; }
}

View File

@ -11,6 +11,8 @@ public interface InRecordMapper
public InRecord selectInRecordByIdForUpdate(Long id); public InRecord selectInRecordByIdForUpdate(Long id);
public InRecord selectInRecordByArrivalIdForUpdate(Long arrivalId);
public InRecord checkInNoUnique(String inNo); public InRecord checkInNoUnique(String inNo);
public int insertInRecord(InRecord inRecord); public int insertInRecord(InRecord inRecord);
@ -20,4 +22,6 @@ public interface InRecordMapper
public int confirmInRecord(InRecord inRecord); public int confirmInRecord(InRecord inRecord);
public int deleteInRecordByIds(Long[] ids); public int deleteInRecordByIds(Long[] ids);
public int deletePendingInRecordByArrivalId(Long arrivalId);
} }

View File

@ -0,0 +1,19 @@
package com.ruoyi.warehouse.mapper;
import java.math.BigDecimal;
import java.util.List;
import com.ruoyi.warehouse.domain.PurchaseArrival;
public interface PurchaseArrivalMapper
{
List<PurchaseArrival> selectPurchaseArrivalList(Long purchaseRequestId);
PurchaseArrival selectPurchaseArrivalById(Long id);
PurchaseArrival selectPurchaseArrivalByIdForUpdate(Long id);
BigDecimal selectActiveQuantityByRequestId(Long purchaseRequestId);
BigDecimal selectAcceptedQuantityByRequestId(Long purchaseRequestId);
int countByRequestId(Long purchaseRequestId);
int insertPurchaseArrival(PurchaseArrival arrival);
int updatePurchaseArrivalAcceptance(PurchaseArrival arrival);
int revokePurchaseArrivalAcceptance(PurchaseArrival arrival);
int deletePendingPurchaseArrival(Long id);
}

View File

@ -0,0 +1,22 @@
package com.ruoyi.warehouse.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.warehouse.domain.PurchaseBatch;
public interface PurchaseBatchMapper
{
List<PurchaseBatch> selectPurchaseBatchList(PurchaseBatch batch);
List<PurchaseBatch> selectPurchaseBatchOptions(@Param("keyword") String keyword);
List<PurchaseBatch> selectPurchaseBatchFilterOptions(@Param("keyword") String keyword);
PurchaseBatch selectPurchaseBatchById(Long id);
PurchaseBatch selectPurchaseBatchByNo(String batchNo);
int insertPurchaseBatch(PurchaseBatch batch);
int updatePurchaseBatch(PurchaseBatch batch);
int countRequestsByBatchId(Long batchId);
int countRequestsByIds(Long[] requestIds);
int assignRequests(@Param("requestIds") Long[] requestIds, @Param("batch") PurchaseBatch batch, @Param("updateBy") String updateBy);
int syncRequestBatchNo(@Param("batchId") Long batchId, @Param("batchNo") String batchNo,
@Param("updateBy") String updateBy);
int deletePurchaseBatchById(Long id);
}

View File

@ -0,0 +1,18 @@
package com.ruoyi.warehouse.mapper;
import java.util.List;
import org.apache.ibatis.annotations.Param;
import com.ruoyi.warehouse.domain.PurchaseRequest;
public interface PurchaseRequestMapper
{
List<PurchaseRequest> selectPurchaseRequestList(PurchaseRequest purchaseRequest);
PurchaseRequest selectPurchaseRequestById(@Param("id") Long id, @Param("applicantId") Long applicantId);
PurchaseRequest selectPurchaseRequestByIdForUpdate(Long id);
PurchaseRequest checkRequestNoUnique(String requestNo);
int insertPurchaseRequest(PurchaseRequest purchaseRequest);
int updatePurchaseRequest(@Param("purchaseRequest") PurchaseRequest purchaseRequest, @Param("applicantId") Long applicantId);
int deletePurchaseRequestByIds(@Param("ids") Long[] ids, @Param("applicantId") Long applicantId);
int updatePurchaseStatus(@Param("id") Long id, @Param("purchaseStatus") Integer purchaseStatus,
@Param("progressRemark") String progressRemark, @Param("updateBy") String updateBy);
}

View File

@ -0,0 +1,15 @@
package com.ruoyi.warehouse.service;
import java.util.List;
import com.ruoyi.warehouse.domain.PurchaseArrival;
import com.ruoyi.warehouse.domain.PurchaseArrivalAcceptanceRequest;
import com.ruoyi.warehouse.domain.PurchaseArrivalRevokeRequest;
public interface IPurchaseArrivalService
{
List<PurchaseArrival> selectPurchaseArrivalList(Long purchaseRequestId);
int insertPurchaseArrival(PurchaseArrival arrival);
int acceptPurchaseArrival(Long id, PurchaseArrivalAcceptanceRequest request);
int revokePurchaseArrivalAcceptance(Long id, PurchaseArrivalRevokeRequest request);
int deletePurchaseArrival(Long id);
}

View File

@ -0,0 +1,16 @@
package com.ruoyi.warehouse.service;
import java.util.List;
import com.ruoyi.warehouse.domain.PurchaseBatch;
import com.ruoyi.warehouse.domain.PurchaseBatchAssignRequest;
public interface IPurchaseBatchService
{
List<PurchaseBatch> selectPurchaseBatchList(PurchaseBatch batch);
List<PurchaseBatch> selectPurchaseBatchOptions(String keyword);
List<PurchaseBatch> selectPurchaseBatchFilterOptions(String keyword);
int insertPurchaseBatch(PurchaseBatch batch);
int updatePurchaseBatch(PurchaseBatch batch);
int assignPurchaseBatch(PurchaseBatchAssignRequest request);
int deletePurchaseBatchById(Long id);
}

View File

@ -0,0 +1,17 @@
package com.ruoyi.warehouse.service;
import java.util.List;
import com.ruoyi.warehouse.domain.PurchaseRequest;
import com.ruoyi.warehouse.domain.PurchaseStatusUpdateRequest;
public interface IPurchaseRequestService
{
List<PurchaseRequest> selectPurchaseRequestList(PurchaseRequest purchaseRequest);
PurchaseRequest selectPurchaseRequestById(Long id);
int insertPurchaseRequest(PurchaseRequest purchaseRequest);
int updatePurchaseRequest(PurchaseRequest purchaseRequest);
int deletePurchaseRequestByIds(Long[] ids);
int updatePurchaseStatus(Long id, PurchaseStatusUpdateRequest request);
int batchInsertPurchaseRequest(List<PurchaseRequest> requestList);
String importPurchaseRequest(List<PurchaseRequest> requestList, String username);
}

View File

@ -0,0 +1,9 @@
package com.ruoyi.warehouse.service;
import org.springframework.web.multipart.MultipartFile;
import com.alibaba.fastjson2.JSONObject;
public interface IPurchaseSmartImportService
{
JSONObject analyze(MultipartFile file) throws Exception;
}

View File

@ -6,6 +6,7 @@ import java.net.http.HttpClient;
import java.net.http.HttpRequest; import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.text.Normalizer;
import java.time.Duration; import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
@ -46,6 +47,11 @@ import com.ruoyi.warehouse.service.IWarehouseAreaService;
@Service @Service
public class AgentServiceImpl implements IAgentService public class AgentServiceImpl implements IAgentService
{ {
private static final HttpClient MODEL_CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.version(HttpClient.Version.HTTP_1_1)
.build();
private static final String ACTION_QUERY_STOCK = "query_stock"; private static final String ACTION_QUERY_STOCK = "query_stock";
private static final String ACTION_CREATE_IN = "create_in_record"; private static final String ACTION_CREATE_IN = "create_in_record";
private static final String ACTION_CONFIRM_IN = "confirm_in_record"; private static final String ACTION_CONFIRM_IN = "confirm_in_record";
@ -55,19 +61,33 @@ public class AgentServiceImpl implements IAgentService
private static final String ACTION_CREATE_AREA = "create_area"; private static final String ACTION_CREATE_AREA = "create_area";
private static final String ACTION_UNKNOWN = "unknown"; private static final String ACTION_UNKNOWN = "unknown";
private static final Pattern NUMBER_PATTERN = Pattern.compile("([0-9]+(?:\\.[0-9]+)?)");
private static final Pattern IN_NO_PATTERN = Pattern.compile("(IN\\d+)", Pattern.CASE_INSENSITIVE); private static final Pattern IN_NO_PATTERN = Pattern.compile("(IN\\d+)", Pattern.CASE_INSENSITIVE);
private static final Pattern INDEX_PATTERN = Pattern.compile("第\\s*([0-9一二两三四五六七八九十]+)\\s*(个|条|项)?"); private static final Pattern INDEX_PATTERN = Pattern.compile("第\\s*([0-9一二两三四五六七八九十]+)\\s*(个|条|项)?");
private static final Pattern REFERENCE_PATTERN = Pattern.compile("上一个|上一条|刚才(?:的)?(?:那个|那条|那项)|刚刚(?:的)?(?:那个|那条|那项)|这个|那个|它|上述|上面(?:的)?");
private static final Pattern QUANTITY_WITH_UNIT_PATTERN = Pattern.compile("([0-9]+(?:\\.[0-9]+)?)\\s*(个|件|箱|支|台|套|包|卷|瓶|袋|张|本)");
private static final Pattern CHINESE_QUANTITY_PATTERN = Pattern.compile("([一二两三四五六七八九十]+)\\s*(个|件|箱|支|台|套|包|卷|瓶|袋|张|本)");
private static final Pattern ACTION_QUANTITY_PATTERN = Pattern.compile("(?:入库|出库|盘库|盘点)\\s*(?:数量\\s*[::]?\\s*)?([0-9]+(?:\\.[0-9]+)?)");
private static final Pattern NAMED_QUANTITY_PATTERN = Pattern.compile("数量\\s*[::]?\\s*([0-9]+(?:\\.[0-9]+)?)");
private static final Pattern PORT_SPEC_PATTERN = Pattern.compile("([0-90-9]+)\\s*(?:进|入)\\s*([0-90-9]+)\\s*出", Pattern.CASE_INSENSITIVE);
@Value("${agent.openai.base-url}") @Value("${agent.model.base-url}")
private String baseUrl; private String baseUrl;
@Value("${agent.openai.api-key}") @Value("${agent.model.api-key:}")
private String apiKey; private String apiKey;
@Value("${agent.openai.model}") @Value("${agent.model.model}")
private String model; private String model;
@Value("${agent.model.timeout-ms:8000}")
private long modelTimeoutMs;
@Value("${agent.model.max-output-tokens:512}")
private int maxOutputTokens;
private volatile List<String> categoryCache = List.of();
private volatile long categoryCacheExpiresAt;
@Autowired @Autowired
private IWarehouseAreaService areaService; private IWarehouseAreaService areaService;
@ -99,15 +119,15 @@ public class AgentServiceImpl implements IAgentService
AgentPlan plan = null; AgentPlan plan = null;
List<String> warnings = new ArrayList<>(); List<String> warnings = new ArrayList<>();
if (StringUtils.isBlank(apiKey)) if (StringUtils.isBlank(baseUrl))
{ {
warnings.add("未配置 OPENAI_API_KEY,已使用基础规则解析"); warnings.add("未配置大模型地址,已使用基础规则解析");
} }
else else
{ {
try try
{ {
plan = callModel(text, contextResults); plan = callModel(text, hasExplicitReference(text) ? contextResults : null);
} }
catch (Exception e) catch (Exception e)
{ {
@ -247,10 +267,13 @@ public class AgentServiceImpl implements IAgentService
{ {
plan.setAction(ACTION_UNKNOWN); plan.setAction(ACTION_UNKNOWN);
} }
if (requiresOperator(plan.getAction()) && StringUtils.isBlank(plan.getOperatorName())) resolveExplicitAction(plan, originalText);
resolveOperator(plan, originalText, username);
if (requiresOperator(plan.getAction()) && originalText != null)
{ {
plan.setOperatorName(resolveNickName(username)); // 数量必须来自本次原文的明确数量表达,不能使用模型从规格中猜出的数字。
plan.getWarnings().add("未识别到人员,默认使用当前登录用户"); plan.setQuantity(extractQuantity(originalText));
} }
if (ACTION_CREATE_IN.equals(plan.getAction()) && plan.getInType() == null) if (ACTION_CREATE_IN.equals(plan.getAction()) && plan.getInType() == null)
@ -373,25 +396,27 @@ public class AgentServiceImpl implements IAgentService
{ {
JSONObject body = new JSONObject(); JSONObject body = new JSONObject();
body.put("model", model); body.put("model", model);
JSONArray input = new JSONArray(); JSONArray messages = new JSONArray();
input.add(message("system", systemPrompt())); messages.add(message("system", systemPrompt()));
input.add(message("user", buildUserInput(text, contextResults))); messages.add(message("user", buildUserInput(text, contextResults)));
body.put("input", input); body.put("messages", messages);
JSONObject format = new JSONObject(); body.put("response_format", JSONObject.of("type", "json_object"));
format.put("type", "json_object"); body.put("chat_template_kwargs", JSONObject.of("enable_thinking", false));
JSONObject textFormat = new JSONObject(); body.put("temperature", 0);
textFormat.put("format", format); body.put("max_tokens", maxOutputTokens);
body.put("text", textFormat); body.put("stream", false);
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build(); HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(normalizeEndpoint(baseUrl))) .uri(URI.create(normalizeEndpoint(baseUrl)))
.timeout(Duration.ofSeconds(30)) .timeout(Duration.ofMillis(modelTimeoutMs))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body.toJSONString(), StandardCharsets.UTF_8)) .POST(HttpRequest.BodyPublishers.ofString(body.toJSONString(), StandardCharsets.UTF_8));
.build(); if (StringUtils.isNotBlank(apiKey))
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); {
requestBuilder.header("Authorization", "Bearer " + apiKey);
}
HttpResponse<String> response = MODEL_CLIENT.send(requestBuilder.build(),
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() < 200 || response.statusCode() >= 300) if (response.statusCode() < 200 || response.statusCode() >= 300)
{ {
throw new ServiceException("HTTP " + response.statusCode()); throw new ServiceException("HTTP " + response.statusCode());
@ -417,7 +442,9 @@ public class AgentServiceImpl implements IAgentService
return "你是库存管理系统的意图解析器,只返回一个 JSON 对象,不要解释。" return "你是库存管理系统的意图解析器,只返回一个 JSON 对象,不要解释。"
+ "action 只能是 query_stock, create_in_record, confirm_in_record, create_out_record, create_stock_check, create_product, create_area, unknown。" + "action 只能是 query_stock, create_in_record, confirm_in_record, create_out_record, create_stock_check, create_product, create_area, unknown。"
+ "字段包括 action, areaName, productName, brand, category, spec, unit, quantity, inType, outType, operatorName, recordNo, remark, summary, referenceIndex。" + "字段包括 action, areaName, productName, brand, category, spec, unit, quantity, inType, outType, operatorName, recordNo, remark, summary, referenceIndex。"
+ "如果用户说第几个、第二个、第2条等,referenceIndex 返回从 1 开始的序号。" + "只有原文明说第几个、第2条、上一个、刚才那个、这个、那个或它时,referenceIndex 才能返回从 1 开始的序号;‘一个’、‘1个’表示数量,不是引用,没有明确指代必须返回 null。"
+ "quantity 只表示本次入库、出库或盘点的数量,规格型号里的数字不算数量,‘一个’表示数量 1。"
+ "类似‘2进6出’、‘8口’的描述优先放入 spec,不要把人员姓名、‘东西’、‘产品’放入 productName。"
+ "入库类型 inType:1生产入库,2采购入库,3归还入库,4退货入库。" + "入库类型 inType:1生产入库,2采购入库,3归还入库,4退货入库。"
+ "出库类型 outType:1生产出库,2退货出库,3销售出库。" + "出库类型 outType:1生产出库,2退货出库,3销售出库。"
+ "新增产品时尽量从用户输入提取 productName, brand, category, spec, unit;如果用户未提供 category,优先从 existingCategories 选择合适类别,不合适时推断一个简短合理的类别。" + "新增产品时尽量从用户输入提取 productName, brand, category, spec, unit;如果用户未提供 category,优先从 existingCategories 选择合适类别,不合适时推断一个简短合理的类别。"
@ -435,25 +462,40 @@ public class AgentServiceImpl implements IAgentService
private String normalizeEndpoint(String configuredBaseUrl) private String normalizeEndpoint(String configuredBaseUrl)
{ {
String url = StringUtils.defaultIfBlank(configuredBaseUrl, "http://192.168.28.10:18080").trim(); String url = StringUtils.defaultIfBlank(configuredBaseUrl, "http://192.168.28.10:14081").trim();
while (url.endsWith("/")) while (url.endsWith("/"))
{ {
url = url.substring(0, url.length() - 1); url = url.substring(0, url.length() - 1);
} }
if (url.endsWith("/v1")) if (url.endsWith("/v1"))
{ {
return url + "/responses"; return url + "/chat/completions";
} }
if (url.endsWith("/v1/responses")) if (url.endsWith("/v1/chat/completions") || url.endsWith("/chat/completions"))
{ {
return url; return url;
} }
return url + "/v1/responses"; if (url.endsWith("/v1/responses"))
{
return url.substring(0, url.length() - "/responses".length()) + "/chat/completions";
}
return url + "/v1/chat/completions";
} }
private String extractOutputText(String responseBody) private String extractOutputText(String responseBody)
{ {
JSONObject root = JSON.parseObject(responseBody); JSONObject root = JSON.parseObject(responseBody);
JSONArray choices = root.getJSONArray("choices");
if (choices != null && !choices.isEmpty())
{
JSONObject choice = choices.getJSONObject(0);
JSONObject responseMessage = choice == null ? null : choice.getJSONObject("message");
String content = responseMessage == null ? null : responseMessage.getString("content");
if (StringUtils.isNotBlank(content))
{
return content;
}
}
String outputText = root.getString("output_text"); String outputText = root.getString("output_text");
if (StringUtils.isNotBlank(outputText)) if (StringUtils.isNotBlank(outputText))
{ {
@ -704,17 +746,35 @@ public class AgentServiceImpl implements IAgentService
private BigDecimal extractQuantity(String text) private BigDecimal extractQuantity(String text)
{ {
Matcher matcher = NUMBER_PATTERN.matcher(StringUtils.defaultString(text)); String source = StringUtils.defaultString(text);
BigDecimal quantity = null; Matcher matcher = NAMED_QUANTITY_PATTERN.matcher(source);
if (matcher.find())
{
return new BigDecimal(matcher.group(1));
}
matcher = QUANTITY_WITH_UNIT_PATTERN.matcher(source);
while (matcher.find()) while (matcher.find())
{ {
if (matcher.start() > 0 && text.charAt(matcher.start() - 1) == '第') if (matcher.start() == 0 || source.charAt(matcher.start() - 1) != '第')
{ {
continue; return new BigDecimal(matcher.group(1));
} }
quantity = new BigDecimal(matcher.group(1));
} }
return quantity; matcher = CHINESE_QUANTITY_PATTERN.matcher(source);
while (matcher.find())
{
if (matcher.start() == 0 || source.charAt(matcher.start() - 1) != '第')
{
Integer value = parseIndex(matcher.group(1));
return value == null ? null : BigDecimal.valueOf(value);
}
}
matcher = ACTION_QUANTITY_PATTERN.matcher(source);
if (matcher.find())
{
return new BigDecimal(matcher.group(1));
}
return null;
} }
private String extractInNo(String text) private String extractInNo(String text)
@ -865,12 +925,29 @@ public class AgentServiceImpl implements IAgentService
private void resolveReferencedResult(AgentPlan plan, String originalText, List<AgentResultItem> contextResults) private void resolveReferencedResult(AgentPlan plan, String originalText, List<AgentResultItem> contextResults)
{ {
if (plan.getReferenceIndex() == null) if (originalText == null)
{ {
plan.setReferenceIndex(extractReferenceIndex(originalText)); return;
} }
if (plan.getReferenceIndex() == null) Integer explicitIndex = extractReferenceIndex(originalText);
if (!hasExplicitReference(originalText))
{ {
// 不信任模型自行推断的序号,防止把“出库一个”理解成“出库第一个”。
plan.setReferenceIndex(null);
return;
}
if (explicitIndex != null)
{
plan.setReferenceIndex(explicitIndex);
}
else if (contextResults != null && contextResults.size() == 1)
{
plan.setReferenceIndex(1);
}
else
{
plan.setReferenceIndex(null);
plan.setMessage("上次结果有多项,请明确说第几个");
return; return;
} }
if (contextResults == null || contextResults.isEmpty()) if (contextResults == null || contextResults.isEmpty())
@ -992,6 +1069,25 @@ public class AgentServiceImpl implements IAgentService
} }
private List<String> existingCategories() private List<String> existingCategories()
{
long now = System.currentTimeMillis();
if (now < categoryCacheExpiresAt)
{
return categoryCache;
}
synchronized (this)
{
if (now < categoryCacheExpiresAt)
{
return categoryCache;
}
categoryCache = loadExistingCategories();
categoryCacheExpiresAt = now + 60_000L;
return categoryCache;
}
}
private List<String> loadExistingCategories()
{ {
Set<String> categories = new LinkedHashSet<>(); Set<String> categories = new LinkedHashSet<>();
for (ProductInfo product : productService.selectProductInfoAll()) for (ProductInfo product : productService.selectProductInfoAll())
@ -1016,6 +1112,94 @@ public class AgentServiceImpl implements IAgentService
|| ACTION_CREATE_CHECK.equals(action); || ACTION_CREATE_CHECK.equals(action);
} }
private boolean hasExplicitReference(String text)
{
return extractReferenceIndex(text) != null || REFERENCE_PATTERN.matcher(StringUtils.defaultString(text)).find();
}
private void resolveExplicitAction(AgentPlan plan, String originalText)
{
if (StringUtils.isBlank(originalText))
{
return;
}
if ((originalText.contains("添加") || originalText.contains("新增") || originalText.contains("录入"))
&& originalText.contains("库区"))
{
plan.setAction(ACTION_CREATE_AREA);
}
else if ((originalText.contains("添加") || originalText.contains("新增") || originalText.contains("录入"))
&& (originalText.contains("产品") || originalText.contains("物料")))
{
plan.setAction(ACTION_CREATE_PRODUCT);
}
else if (originalText.contains("确认") && originalText.contains("入库"))
{
plan.setAction(ACTION_CONFIRM_IN);
}
else if (originalText.contains("出库"))
{
plan.setAction(ACTION_CREATE_OUT);
}
else if (originalText.contains("盘库") || originalText.contains("盘点"))
{
plan.setAction(ACTION_CREATE_CHECK);
}
else if (originalText.contains("入库"))
{
plan.setAction(ACTION_CREATE_IN);
}
}
private void resolveOperator(AgentPlan plan, String originalText, String username)
{
if (!requiresOperator(plan.getAction()))
{
return;
}
if (StringUtils.isNotBlank(originalText))
{
SysUser matchedUser = null;
int matchedLength = 0;
SysUser query = new SysUser();
query.setStatus("0");
for (SysUser user : userService.selectUserList(query))
{
String matchedName = matchedUserName(originalText, user);
if (StringUtils.isNotBlank(matchedName) && matchedName.length() > matchedLength)
{
matchedUser = user;
matchedLength = matchedName.length();
}
}
if (matchedUser != null)
{
plan.setOperatorName(StringUtils.defaultIfBlank(matchedUser.getNickName(), matchedUser.getUserName()));
return;
}
}
if (StringUtils.isBlank(plan.getOperatorName()))
{
plan.setOperatorName(resolveNickName(username));
plan.getWarnings().add("未识别到人员,默认使用当前登录用户");
}
}
private String matchedUserName(String text, SysUser user)
{
String nickName = user == null ? null : user.getNickName();
String userName = user == null ? null : user.getUserName();
if (StringUtils.isNotBlank(nickName) && text.contains(nickName))
{
return nickName;
}
if (StringUtils.isNotBlank(userName) && text.toLowerCase(Locale.ROOT).contains(userName.toLowerCase(Locale.ROOT)))
{
return userName;
}
return null;
}
private boolean noAreaCandidates(AgentPlan plan) private boolean noAreaCandidates(AgentPlan plan)
{ {
for (AgentCandidate candidate : plan.getCandidates()) for (AgentCandidate candidate : plan.getCandidates())
@ -1150,12 +1334,23 @@ public class AgentServiceImpl implements IAgentService
private int productScore(AgentPlan plan, String originalText, ProductInfo product) private int productScore(AgentPlan plan, String originalText, ProductInfo product)
{ {
String requestedPortSpec = extractPortSpec(originalText);
String productPortSpec = extractPortSpec(product.getSpec());
if (StringUtils.isNotBlank(requestedPortSpec) && StringUtils.isNotBlank(productPortSpec)
&& !requestedPortSpec.equals(productPortSpec))
{
return 0;
}
int score = 0; int score = 0;
score += fieldScore(plan.getProductName(), product.getProductName(), 8); score += fieldScore(plan.getProductName(), product.getProductName(), 8);
score += fieldScore(plan.getBrand(), product.getBrand(), 4); score += fieldScore(plan.getBrand(), product.getBrand(), 4);
score += fieldScore(plan.getCategory(), product.getCategory(), 4); score += fieldScore(plan.getCategory(), product.getCategory(), 4);
score += fieldScore(plan.getSpec(), product.getSpec(), 4); score += fieldScore(plan.getSpec(), product.getSpec(), 14);
score += fieldScore(plan.getUnit(), product.getUnit(), 1); score += fieldScore(plan.getUnit(), product.getUnit(), 1);
if (StringUtils.isNotBlank(requestedPortSpec) && requestedPortSpec.equals(productPortSpec))
{
score += 24;
}
String fullInfo = productFullText(product); String fullInfo = productFullText(product);
if (containsIgnoreCase(originalText, fullInfo) || containsIgnoreCase(fullInfo, originalText)) if (containsIgnoreCase(originalText, fullInfo) || containsIgnoreCase(fullInfo, originalText))
{ {
@ -1166,7 +1361,7 @@ public class AgentServiceImpl implements IAgentService
score += containsField(originalText, product.getProductName()) ? 4 : 0; score += containsField(originalText, product.getProductName()) ? 4 : 0;
score += containsField(originalText, product.getBrand()) ? 2 : 0; score += containsField(originalText, product.getBrand()) ? 2 : 0;
score += containsField(originalText, product.getCategory()) ? 2 : 0; score += containsField(originalText, product.getCategory()) ? 2 : 0;
score += containsField(originalText, product.getSpec()) ? 2 : 0; score += containsNormalized(originalText, product.getSpec()) ? 10 : 0;
} }
return score; return score;
} }
@ -1177,11 +1372,11 @@ public class AgentServiceImpl implements IAgentService
{ {
return 0; return 0;
} }
if (input.trim().equalsIgnoreCase(target.trim())) if (normalizeSearchText(input).equals(normalizeSearchText(target)))
{ {
return exactScore; return exactScore;
} }
if (containsIgnoreCase(input, target) || containsIgnoreCase(target, input)) if (containsNormalized(input, target) || containsNormalized(target, input))
{ {
return Math.max(1, exactScore / 2); return Math.max(1, exactScore / 2);
} }
@ -1218,6 +1413,38 @@ public class AgentServiceImpl implements IAgentService
return text.toLowerCase(Locale.ROOT).contains(keyword.toLowerCase(Locale.ROOT)); return text.toLowerCase(Locale.ROOT).contains(keyword.toLowerCase(Locale.ROOT));
} }
private boolean containsNormalized(String text, String keyword)
{
String normalizedText = normalizeSearchText(text);
String normalizedKeyword = normalizeSearchText(keyword);
return StringUtils.isNotBlank(normalizedText) && StringUtils.isNotBlank(normalizedKeyword)
&& normalizedText.contains(normalizedKeyword);
}
private String normalizeSearchText(String value)
{
if (StringUtils.isBlank(value))
{
return "";
}
return Normalizer.normalize(value, Normalizer.Form.NFKC)
.toLowerCase(Locale.ROOT)
.replace('×', 'x')
.replace('*', '*')
.replaceAll("[\\s\\p{Punct},。;:、]+", "");
}
private String extractPortSpec(String text)
{
String source = Normalizer.normalize(StringUtils.defaultString(text), Normalizer.Form.NFKC);
Matcher matcher = PORT_SPEC_PATTERN.matcher(source);
if (!matcher.find())
{
return null;
}
return matcher.group(1) + "进" + matcher.group(2) + "出";
}
private void fillProduct(AgentPlan plan, ProductInfo product) private void fillProduct(AgentPlan plan, ProductInfo product)
{ {
plan.setProductId(product.getId()); plan.setProductId(product.getId());

View File

@ -78,6 +78,16 @@ public class InRecordServiceImpl implements IInRecordService
{ {
throw new ServiceException("已入库记录不能修改"); throw new ServiceException("已入库记录不能修改");
} }
if (dbRecord != null && dbRecord.getArrivalId() != null)
{
inRecord.setInNo(dbRecord.getInNo());
inRecord.setProductId(dbRecord.getProductId());
inRecord.setPurchaseRequestId(dbRecord.getPurchaseRequestId());
inRecord.setArrivalId(dbRecord.getArrivalId());
inRecord.setSourceNo(dbRecord.getSourceNo());
inRecord.setInType(dbRecord.getInType());
inRecord.setInNum(dbRecord.getInNum());
}
return inRecordMapper.updateInRecord(inRecord); return inRecordMapper.updateInRecord(inRecord);
} }
@ -91,6 +101,10 @@ public class InRecordServiceImpl implements IInRecordService
{ {
throw new ServiceException("已入库记录不能删除"); throw new ServiceException("已入库记录不能删除");
} }
if (dbRecord != null && dbRecord.getArrivalId() != null)
{
throw new ServiceException("采购验收生成的待入库记录不能删除");
}
} }
return inRecordMapper.deleteInRecordByIds(ids); return inRecordMapper.deleteInRecordByIds(ids);
} }
@ -108,6 +122,15 @@ public class InRecordServiceImpl implements IInRecordService
{ {
throw new ServiceException("该入库记录已确认"); throw new ServiceException("该入库记录已确认");
} }
validateInRecord(inRecord);
if (inRecord.getAreaId() == null)
{
throw new ServiceException("请先选择库区再确认入库");
}
if (inRecord.getProductId() == null)
{
throw new ServiceException("入库产品不能为空");
}
stockInfoMapper.upsertIncreaseStock(inRecord.getAreaId(), inRecord.getProductId(), inRecord.getInNum()); stockInfoMapper.upsertIncreaseStock(inRecord.getAreaId(), inRecord.getProductId(), inRecord.getInNum());
InRecord confirmRecord = new InRecord(); InRecord confirmRecord = new InRecord();
confirmRecord.setId(id); confirmRecord.setId(id);

View File

@ -0,0 +1,274 @@
package com.ruoyi.warehouse.service.impl;
import java.math.BigDecimal;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.warehouse.domain.InRecord;
import com.ruoyi.warehouse.domain.PurchaseArrival;
import com.ruoyi.warehouse.domain.PurchaseArrivalAcceptanceRequest;
import com.ruoyi.warehouse.domain.PurchaseArrivalRevokeRequest;
import com.ruoyi.warehouse.domain.PurchaseRequest;
import com.ruoyi.warehouse.mapper.InRecordMapper;
import com.ruoyi.warehouse.mapper.PurchaseArrivalMapper;
import com.ruoyi.warehouse.mapper.PurchaseRequestMapper;
import com.ruoyi.warehouse.service.IPurchaseArrivalService;
@Service
public class PurchaseArrivalServiceImpl implements IPurchaseArrivalService
{
private static final AtomicInteger ARRIVAL_SEQUENCE = new AtomicInteger();
@Autowired
private PurchaseArrivalMapper purchaseArrivalMapper;
@Autowired
private PurchaseRequestMapper purchaseRequestMapper;
@Autowired
private InRecordMapper inRecordMapper;
@Override
public List<PurchaseArrival> selectPurchaseArrivalList(Long purchaseRequestId)
{
if (purchaseRequestMapper.selectPurchaseRequestById(purchaseRequestId, null) == null)
{
throw new ServiceException("采购申请不存在");
}
return purchaseArrivalMapper.selectPurchaseArrivalList(purchaseRequestId);
}
@Override
@Transactional
public int insertPurchaseArrival(PurchaseArrival arrival)
{
if (arrival == null || arrival.getPurchaseRequestId() == null)
{
throw new ServiceException("采购申请不能为空");
}
PurchaseRequest request = purchaseRequestMapper.selectPurchaseRequestByIdForUpdate(arrival.getPurchaseRequestId());
requireOwnerOrManager(request);
if (Integer.valueOf(5).equals(request.getPurchaseStatus()))
{
throw new ServiceException("已取消的采购申请不能登记到货");
}
if (arrival.getArrivalQuantity() == null || arrival.getArrivalQuantity().signum() <= 0)
{
throw new ServiceException("到货数量必须大于0");
}
if (arrival.getArrivalDate() == null)
{
throw new ServiceException("到货日期不能为空");
}
BigDecimal activeQuantity = purchaseArrivalMapper.selectActiveQuantityByRequestId(request.getId());
if (activeQuantity.add(arrival.getArrivalQuantity()).compareTo(request.getQuantity()) > 0)
{
throw new ServiceException("本次到货数量超过剩余可登记数量");
}
arrival.setArrivalNo(generateArrivalNo());
arrival.setAcceptanceStatus(0);
arrival.setCreateBy(SecurityUtils.getUsername());
arrival.setRemark(trimToNull(arrival.getRemark()));
return purchaseArrivalMapper.insertPurchaseArrival(arrival);
}
@Override
@Transactional
public int acceptPurchaseArrival(Long id, PurchaseArrivalAcceptanceRequest acceptance)
{
PurchaseArrival arrival = purchaseArrivalMapper.selectPurchaseArrivalByIdForUpdate(id);
if (arrival == null)
{
throw new ServiceException("到货记录不存在");
}
requireOwnerOrManager(arrival.getApplicantId());
if (!Integer.valueOf(0).equals(arrival.getAcceptanceStatus()))
{
throw new ServiceException("该到货记录已验收,不能重复操作");
}
if (acceptance == null || acceptance.getAcceptanceStatus() == null
|| (acceptance.getAcceptanceStatus() != 1 && acceptance.getAcceptanceStatus() != 2))
{
throw new ServiceException("验收结果不正确");
}
String username = SecurityUtils.getUsername();
PurchaseRequest request = null;
if (Integer.valueOf(1).equals(acceptance.getAcceptanceStatus()))
{
request = purchaseRequestMapper.selectPurchaseRequestByIdForUpdate(arrival.getPurchaseRequestId());
arrival.setPreAcceptPurchaseStatus(request.getPurchaseStatus());
}
arrival.setAcceptanceStatus(acceptance.getAcceptanceStatus());
arrival.setAcceptanceRemark(trimToNull(acceptance.getAcceptanceRemark()));
arrival.setAcceptedBy(username);
arrival.setAcceptedTime(new Date());
arrival.setUpdateBy(username);
int rows = purchaseArrivalMapper.updatePurchaseArrivalAcceptance(arrival);
if (Integer.valueOf(1).equals(arrival.getAcceptanceStatus()))
{
createPendingInRecord(arrival, username);
BigDecimal accepted = purchaseArrivalMapper.selectAcceptedQuantityByRequestId(arrival.getPurchaseRequestId());
int status = accepted.compareTo(request.getQuantity()) >= 0 ? 4 : 3;
purchaseRequestMapper.updatePurchaseStatus(request.getId(), status,
arrival.getAcceptanceRemark(), username);
}
return rows;
}
@Override
@Transactional
public int revokePurchaseArrivalAcceptance(Long id, PurchaseArrivalRevokeRequest revoke)
{
PurchaseArrival arrival = purchaseArrivalMapper.selectPurchaseArrivalByIdForUpdate(id);
if (arrival == null)
{
throw new ServiceException("到货记录不存在");
}
requireOwnerOrManager(arrival.getApplicantId());
if (Integer.valueOf(0).equals(arrival.getAcceptanceStatus()))
{
throw new ServiceException("该到货记录尚未验收,无需撤销");
}
if (revoke == null || StringUtils.isBlank(revoke.getRevokeRemark()))
{
throw new ServiceException("撤销原因不能为空");
}
PurchaseRequest request = purchaseRequestMapper.selectPurchaseRequestByIdForUpdate(arrival.getPurchaseRequestId());
if (Integer.valueOf(2).equals(arrival.getAcceptanceStatus()))
{
if (Integer.valueOf(5).equals(request.getPurchaseStatus()))
{
throw new ServiceException("采购申请已取消,不能撤销拒收");
}
BigDecimal activeQuantity = purchaseArrivalMapper.selectActiveQuantityByRequestId(request.getId());
if (activeQuantity.add(arrival.getArrivalQuantity()).compareTo(request.getQuantity()) > 0)
{
throw new ServiceException("拒收后已登记替代到货,撤销会超过采购数量,请先删除对应的待验收到货");
}
}
else
{
InRecord inRecord = inRecordMapper.selectInRecordByArrivalIdForUpdate(id);
if (inRecord == null)
{
throw new ServiceException("未找到验收生成的待入库记录,不能自动撤销");
}
if (Integer.valueOf(1).equals(inRecord.getInStatus()))
{
throw new ServiceException("该批到货已经确认入库,不能直接撤销,请先办理库存冲销");
}
if (inRecordMapper.deletePendingInRecordByArrivalId(id) != 1)
{
throw new ServiceException("待入库记录状态已变化,请刷新后重试");
}
}
String username = SecurityUtils.getUsername();
arrival.setRevokedBy(username);
arrival.setRevokedTime(new Date());
arrival.setRevokeRemark(revoke.getRevokeRemark().trim());
arrival.setUpdateBy(username);
int rows = purchaseArrivalMapper.revokePurchaseArrivalAcceptance(arrival);
if (rows != 1)
{
throw new ServiceException("到货验收状态已变化,请刷新后重试");
}
if (Integer.valueOf(1).equals(arrival.getAcceptanceStatus()))
{
BigDecimal accepted = purchaseArrivalMapper.selectAcceptedQuantityByRequestId(request.getId());
int status;
if (accepted.signum() == 0)
{
Integer previousStatus = arrival.getPreAcceptPurchaseStatus();
status = previousStatus != null && previousStatus >= 0 && previousStatus <= 2 ? previousStatus : 2;
}
else
{
status = accepted.compareTo(request.getQuantity()) >= 0 ? 4 : 3;
}
purchaseRequestMapper.updatePurchaseStatus(request.getId(), status,
"撤销到货验收:" + arrival.getRevokeRemark(), username);
}
return rows;
}
@Override
@Transactional
public int deletePurchaseArrival(Long id)
{
PurchaseArrival arrival = purchaseArrivalMapper.selectPurchaseArrivalById(id);
if (arrival == null)
{
throw new ServiceException("到货记录不存在");
}
requireOwnerOrManager(arrival.getApplicantId());
if (!Integer.valueOf(0).equals(arrival.getAcceptanceStatus()))
{
throw new ServiceException("已验收的到货记录不能删除");
}
return purchaseArrivalMapper.deletePendingPurchaseArrival(id);
}
private void createPendingInRecord(PurchaseArrival arrival, String username)
{
InRecord inRecord = new InRecord();
inRecord.setInNo("AIN" + arrival.getId());
inRecord.setAreaId(null);
inRecord.setProductId(arrival.getProductId());
inRecord.setPurchaseRequestId(arrival.getPurchaseRequestId());
inRecord.setArrivalId(arrival.getId());
inRecord.setSourceNo(arrival.getArrivalNo());
inRecord.setInType(2);
inRecord.setInNum(arrival.getArrivalQuantity());
inRecord.setInStatus(0);
inRecord.setOperator(username);
inRecord.setInOperator(null);
inRecord.setRemark("采购到货验收:" + arrival.getRequestNo());
inRecordMapper.insertInRecord(inRecord);
}
private void requireOwnerOrManager(PurchaseRequest request)
{
if (request == null)
{
throw new ServiceException("采购申请不存在");
}
requireOwnerOrManager(request.getApplicantId());
}
private void requireOwnerOrManager(Long applicantId)
{
if (!isManager() && !SecurityUtils.getUserId().equals(applicantId))
{
throw new ServiceException("只能处理自己的采购到货");
}
}
private boolean isManager()
{
return SecurityUtils.isAdmin() || SecurityUtils.hasPermi("warehouse:purchase:manage");
}
private String trimToNull(String value)
{
return StringUtils.isBlank(value) ? null : value.trim();
}
private String generateArrivalNo()
{
int sequence = ARRIVAL_SEQUENCE.updateAndGet(value -> value >= 999 ? 0 : value + 1);
return "PA" + new SimpleDateFormat("yyyyMMddHHmmssSSS").format(new Date())
+ String.format("%03d", sequence);
}
}

View File

@ -0,0 +1,141 @@
package com.ruoyi.warehouse.service.impl;
import java.util.List;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.warehouse.domain.PurchaseBatch;
import com.ruoyi.warehouse.domain.PurchaseBatchAssignRequest;
import com.ruoyi.warehouse.mapper.PurchaseBatchMapper;
import com.ruoyi.warehouse.service.IPurchaseBatchService;
@Service
public class PurchaseBatchServiceImpl implements IPurchaseBatchService
{
@Autowired
private PurchaseBatchMapper purchaseBatchMapper;
@Override
public List<PurchaseBatch> selectPurchaseBatchList(PurchaseBatch batch)
{
return purchaseBatchMapper.selectPurchaseBatchList(batch);
}
@Override
public List<PurchaseBatch> selectPurchaseBatchOptions(String keyword)
{
return purchaseBatchMapper.selectPurchaseBatchOptions(StringUtils.trim(keyword));
}
@Override
public List<PurchaseBatch> selectPurchaseBatchFilterOptions(String keyword)
{
return purchaseBatchMapper.selectPurchaseBatchFilterOptions(StringUtils.trim(keyword));
}
@Override
public int insertPurchaseBatch(PurchaseBatch batch)
{
checkManager();
if (batch.getPurchaseStatus() == null)
{
batch.setPurchaseStatus(1);
}
normalizeAndValidate(batch);
if (purchaseBatchMapper.selectPurchaseBatchByNo(batch.getBatchNo()) != null)
{
throw new ServiceException("采购批次已存在");
}
batch.setCreateBy(SecurityUtils.getUsername());
return purchaseBatchMapper.insertPurchaseBatch(batch);
}
@Override
@Transactional
public int updatePurchaseBatch(PurchaseBatch batch)
{
checkManager();
if (batch.getId() == null || purchaseBatchMapper.selectPurchaseBatchById(batch.getId()) == null)
{
throw new ServiceException("采购批次不存在");
}
normalizeAndValidate(batch);
PurchaseBatch sameNo = purchaseBatchMapper.selectPurchaseBatchByNo(batch.getBatchNo());
if (sameNo != null && !sameNo.getId().equals(batch.getId()))
{
throw new ServiceException("采购批次已存在");
}
String username = SecurityUtils.getUsername();
batch.setUpdateBy(username);
int rows = purchaseBatchMapper.updatePurchaseBatch(batch);
purchaseBatchMapper.syncRequestBatchNo(batch.getId(), batch.getBatchNo(), username);
return rows;
}
@Override
@Transactional
public int assignPurchaseBatch(PurchaseBatchAssignRequest request)
{
checkManager();
if (request == null || request.getRequestIds() == null || request.getRequestIds().length == 0)
{
throw new ServiceException("请选择要分配或延期的采购申请");
}
if (request.getBatchId() == null)
{
throw new ServiceException("请选择目标采购批次");
}
PurchaseBatch batch = purchaseBatchMapper.selectPurchaseBatchById(request.getBatchId());
if (batch == null)
{
throw new ServiceException("目标采购批次不存在");
}
if (purchaseBatchMapper.countRequestsByIds(request.getRequestIds()) != request.getRequestIds().length)
{
throw new ServiceException("部分采购申请不存在,批次分配失败");
}
purchaseBatchMapper.assignRequests(request.getRequestIds(), batch, SecurityUtils.getUsername());
return request.getRequestIds().length;
}
@Override
public int deletePurchaseBatchById(Long id)
{
checkManager();
if (purchaseBatchMapper.selectPurchaseBatchById(id) == null)
{
throw new ServiceException("采购批次不存在");
}
if (purchaseBatchMapper.countRequestsByBatchId(id) > 0)
{
throw new ServiceException("该批次仍有关联的采购申请,不能删除");
}
return purchaseBatchMapper.deletePurchaseBatchById(id);
}
private void normalizeAndValidate(PurchaseBatch batch)
{
batch.setBatchNo(StringUtils.trim(batch.getBatchNo()));
batch.setBatchName(StringUtils.trim(batch.getBatchName()));
batch.setProgressRemark(StringUtils.trim(batch.getProgressRemark()));
if (StringUtils.isBlank(batch.getBatchNo()))
{
throw new ServiceException("采购批次编号不能为空");
}
if (batch.getPurchaseStatus() == null || batch.getPurchaseStatus() < 1 || batch.getPurchaseStatus() > 5)
{
throw new ServiceException("采购批次状态不正确");
}
}
private void checkManager()
{
if (!SecurityUtils.isAdmin() && !SecurityUtils.hasPermi("warehouse:purchase:manage"))
{
throw new ServiceException("只有管理员可以维护采购批次");
}
}
}

View File

@ -0,0 +1,411 @@
package com.ruoyi.warehouse.service.impl;
import java.math.BigDecimal;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.warehouse.domain.ProductInfo;
import com.ruoyi.warehouse.domain.PurchaseRequest;
import com.ruoyi.warehouse.domain.PurchaseStatusUpdateRequest;
import com.ruoyi.warehouse.mapper.ProductInfoMapper;
import com.ruoyi.warehouse.mapper.PurchaseArrivalMapper;
import com.ruoyi.warehouse.mapper.PurchaseRequestMapper;
import com.ruoyi.warehouse.service.IProductInfoService;
import com.ruoyi.warehouse.service.IPurchaseRequestService;
@Service
public class PurchaseRequestServiceImpl implements IPurchaseRequestService
{
private static final AtomicInteger REQUEST_SEQUENCE = new AtomicInteger();
@Autowired
private PurchaseRequestMapper purchaseRequestMapper;
@Autowired
private ProductInfoMapper productInfoMapper;
@Autowired
private IProductInfoService productInfoService;
@Autowired
private PurchaseArrivalMapper purchaseArrivalMapper;
@Override
public List<PurchaseRequest> selectPurchaseRequestList(PurchaseRequest purchaseRequest)
{
return purchaseRequestMapper.selectPurchaseRequestList(purchaseRequest);
}
@Override
public PurchaseRequest selectPurchaseRequestById(Long id)
{
return requireRequest(id);
}
@Override
@Transactional
public int insertPurchaseRequest(PurchaseRequest purchaseRequest)
{
normalize(purchaseRequest);
validateRequest(purchaseRequest);
purchaseRequest.setApplicantId(SecurityUtils.getUserId());
purchaseRequest.setApplicantName(SecurityUtils.getUsername());
purchaseRequest.setCreateBy(SecurityUtils.getUsername());
purchaseRequest.setPurchaseStatus(0);
purchaseRequest.setPurchaseBatchNo(null);
purchaseRequest.setProgressRemark(null);
if (StringUtils.isBlank(purchaseRequest.getRequestNo()))
{
purchaseRequest.setRequestNo(generateRequestNo());
}
else if (purchaseRequestMapper.checkRequestNoUnique(purchaseRequest.getRequestNo()) != null)
{
throw new ServiceException("申请单号已存在");
}
resolveProduct(purchaseRequest);
return purchaseRequestMapper.insertPurchaseRequest(purchaseRequest);
}
@Override
@Transactional
public int updatePurchaseRequest(PurchaseRequest purchaseRequest)
{
if (purchaseRequest.getId() == null)
{
throw new ServiceException("采购申请ID不能为空");
}
PurchaseRequest existing = requireRequest(purchaseRequest.getId());
checkOwnerOrManager(existing);
normalize(purchaseRequest);
validateRequest(purchaseRequest);
BigDecimal activeArrivalQuantity = purchaseArrivalMapper.selectActiveQuantityByRequestId(existing.getId());
if (activeArrivalQuantity.compareTo(purchaseRequest.getQuantity()) > 0)
{
throw new ServiceException("需求数量不能小于已登记或已验收的到货数量");
}
if (activeArrivalQuantity.signum() > 0 && !sameMaterial(existing, purchaseRequest))
{
throw new ServiceException("已有到货记录,不能更换采购物料");
}
if (StringUtils.isBlank(purchaseRequest.getRequestNo()))
{
purchaseRequest.setRequestNo(existing.getRequestNo());
}
PurchaseRequest sameNo = purchaseRequestMapper.checkRequestNoUnique(purchaseRequest.getRequestNo());
if (sameNo != null && !sameNo.getId().equals(purchaseRequest.getId()))
{
throw new ServiceException("申请单号已存在");
}
// 申请归属和采购进度只能由系统及进度管理接口维护。
purchaseRequest.setApplicantId(existing.getApplicantId());
purchaseRequest.setApplicantName(existing.getApplicantName());
purchaseRequest.setPurchaseStatus(existing.getPurchaseStatus());
purchaseRequest.setPurchaseBatchNo(existing.getPurchaseBatchNo());
purchaseRequest.setProgressRemark(existing.getProgressRemark());
purchaseRequest.setUpdateBy(SecurityUtils.getUsername());
resolveProduct(purchaseRequest);
int rows = purchaseRequestMapper.updatePurchaseRequest(purchaseRequest, currentApplicantScope());
if (rows == 0)
{
throw new ServiceException("无权修改其他员工的采购申请");
}
return rows;
}
@Override
@Transactional
public int deletePurchaseRequestByIds(Long[] ids)
{
if (ids == null || ids.length == 0)
{
throw new ServiceException("请选择采购申请");
}
for (Long id : ids)
{
PurchaseRequest request = requireRequest(id);
checkOwnerOrManager(request);
if (purchaseArrivalMapper.countByRequestId(id) > 0)
{
throw new ServiceException("申请 " + request.getRequestNo() + " 已有到货记录,不能删除");
}
}
int rows = purchaseRequestMapper.deletePurchaseRequestByIds(ids, currentApplicantScope());
if (rows != ids.length)
{
throw new ServiceException("只能删除自己的采购申请");
}
return rows;
}
@Override
@Transactional
public int updatePurchaseStatus(Long id, PurchaseStatusUpdateRequest request)
{
PurchaseRequest existing = requireRequest(id);
checkOwnerOrManager(existing);
Integer status = request.getPurchaseStatus();
if (status == null || (status != 0 && status != 1 && status != 2 && status != 5))
{
throw new ServiceException("到货相关状态由验收数量自动计算");
}
if (purchaseArrivalMapper.selectAcceptedQuantityByRequestId(id).signum() > 0)
{
throw new ServiceException("已有验收到货,采购状态由验收进度自动计算");
}
if (status == 5 && purchaseArrivalMapper.selectActiveQuantityByRequestId(id).signum() > 0)
{
throw new ServiceException("已有待验收或已验收到货,不能取消采购申请");
}
return purchaseRequestMapper.updatePurchaseStatus(id, status,
trimToNull(request.getProgressRemark()), SecurityUtils.getUsername());
}
@Override
@Transactional
public int batchInsertPurchaseRequest(List<PurchaseRequest> requestList)
{
if (requestList == null || requestList.isEmpty())
{
throw new ServiceException("表格数据不能为空");
}
int count = 0;
for (int index = 0; index < requestList.size(); index++)
{
try
{
insertPurchaseRequest(requestList.get(index));
count++;
}
catch (Exception e)
{
throw new ServiceException("第" + (index + 1) + "行:" + e.getMessage());
}
}
return count;
}
@Override
@Transactional
public String importPurchaseRequest(List<PurchaseRequest> requestList, String username)
{
if (requestList == null || requestList.isEmpty())
{
throw new ServiceException("导入数据不能为空");
}
int successCount = 0;
StringBuilder failure = new StringBuilder();
for (int index = 0; index < requestList.size(); index++)
{
try
{
PurchaseRequest request = requestList.get(index);
request.setApplicantId(SecurityUtils.getUserId());
request.setApplicantName(username);
request.setCreateBy(username);
insertPurchaseRequest(request);
successCount++;
}
catch (Exception e)
{
failure.append("第").append(index + 2).append("行:").append(e.getMessage()).append(";");
}
}
if (failure.length() > 0)
{
throw new ServiceException("导入失败," + failure);
}
return "成功导入 " + successCount + " 条采购申请";
}
private PurchaseRequest requireRequest(Long id)
{
PurchaseRequest request = purchaseRequestMapper.selectPurchaseRequestById(id, null);
if (request == null)
{
throw new ServiceException("采购申请不存在");
}
return request;
}
private void checkOwnerOrManager(PurchaseRequest request)
{
if (!isPurchaseManager() && !SecurityUtils.getUserId().equals(request.getApplicantId()))
{
throw new ServiceException("无权操作其他员工的采购申请");
}
}
private boolean isPurchaseManager()
{
return SecurityUtils.isAdmin() || SecurityUtils.hasPermi("warehouse:purchase:manage");
}
private Long currentApplicantScope()
{
return isPurchaseManager() ? null : SecurityUtils.getUserId();
}
private void normalize(PurchaseRequest request)
{
request.setRequestNo(StringUtils.trim(request.getRequestNo()));
request.setProductCode(trimToNull(request.getProductCode()));
request.setMaterialName(StringUtils.trim(request.getMaterialName()));
request.setMaterialBrand(trimToNull(request.getMaterialBrand()));
request.setCategory(StringUtils.trim(request.getCategory()));
request.setMaterialParams(StringUtils.trim(request.getMaterialParams()));
request.setPurchaseLink(StringUtils.trim(request.getPurchaseLink()));
request.setUnit(StringUtils.trim(request.getUnit()));
request.setRequestRemark(trimToNull(request.getRequestRemark()));
if (StringUtils.isBlank(request.getRemark()) && StringUtils.isNotBlank(request.getRequestRemark()))
{
request.setRemark(request.getRequestRemark());
}
}
private void validateRequest(PurchaseRequest request)
{
if (StringUtils.isBlank(request.getMaterialName()))
{
throw new ServiceException("物料名称不能为空");
}
if (request.getQuantity() == null || request.getQuantity().signum() <= 0)
{
throw new ServiceException("需求数量必须大于0");
}
if (StringUtils.isNotBlank(request.getPurchaseLink())
&& !request.getPurchaseLink().matches("(?i)^https?://.+"))
{
throw new ServiceException("采购链接必须以 http:// 或 https:// 开头");
}
}
private void resolveProduct(PurchaseRequest request)
{
ProductInfo draft = new ProductInfo();
draft.setProductCode(request.getProductCode());
draft.setProductName(request.getMaterialName());
draft.setBrand(request.getMaterialBrand());
draft.setCategory(trimToNull(request.getCategory()));
draft.setSpec(trimToNull(request.getMaterialParams()));
draft.setUnit(trimToNull(request.getUnit()));
draft.setRemark(trimToNull(request.getProductRemark()));
ProductInfo product = null;
if (request.getProductId() != null)
{
ProductInfo selected = productInfoMapper.selectProductInfoById(request.getProductId());
if (selected == null)
{
throw new ServiceException("选择的产品信息不存在");
}
if (sameProduct(draft, selected))
{
product = selected;
}
}
if (product == null)
{
product = resolveProductByCode(draft);
}
if (product == null && StringUtils.isBlank(draft.getCategory()))
{
draft.setCategory(productInfoService.inferProductCategory(draft));
request.setCategory(draft.getCategory());
}
if (product == null)
{
product = productInfoMapper.checkProductInfoUnique(draft);
}
if (product == null)
{
productInfoService.insertProductInfo(draft);
product = draft;
}
request.setProductId(product.getId());
request.setProductCode(product.getProductCode());
request.setMaterialName(product.getProductName());
request.setMaterialBrand(product.getBrand());
request.setCategory(product.getCategory());
request.setMaterialParams(product.getSpec());
request.setUnit(product.getUnit());
request.setProductRemark(product.getRemark());
}
private ProductInfo resolveProductByCode(ProductInfo draft)
{
if (StringUtils.isBlank(draft.getProductCode()))
{
return null;
}
ProductInfo product = productInfoMapper.checkProductCodeUnique(draft.getProductCode());
if (product == null)
{
return null;
}
if (!matchesProvidedField(draft.getProductName(), product.getProductName())
|| !matchesProvidedField(draft.getBrand(), product.getBrand())
|| !matchesProvidedField(draft.getCategory(), product.getCategory())
|| !matchesProvidedField(draft.getSpec(), product.getSpec())
|| !matchesProvidedField(draft.getUnit(), product.getUnit()))
{
throw new ServiceException("物料编码 " + draft.getProductCode() + " 与导入的物料信息不一致");
}
return product;
}
private boolean matchesProvidedField(String imported, String existing)
{
return StringUtils.isBlank(imported) || sameText(imported, existing);
}
private boolean sameProduct(ProductInfo left, ProductInfo right)
{
return sameText(left.getProductName(), right.getProductName())
&& sameText(left.getBrand(), right.getBrand())
&& sameText(left.getCategory(), right.getCategory())
&& sameText(left.getSpec(), right.getSpec());
}
private boolean sameMaterial(PurchaseRequest left, PurchaseRequest right)
{
return sameText(left.getMaterialName(), right.getMaterialName())
&& sameText(left.getMaterialBrand(), right.getMaterialBrand())
&& sameText(left.getCategory(), right.getCategory())
&& sameText(left.getMaterialParams(), right.getMaterialParams());
}
private boolean sameText(String left, String right)
{
String leftValue = trimToNull(left);
String rightValue = trimToNull(right);
if (leftValue == null || rightValue == null)
{
return leftValue == rightValue;
}
return leftValue.equalsIgnoreCase(rightValue);
}
private String trimToNull(String value)
{
return StringUtils.isBlank(value) ? null : value.trim();
}
private String generateRequestNo()
{
int sequence = REQUEST_SEQUENCE.updateAndGet(value -> value >= 999 ? 0 : value + 1);
return "PR" + new SimpleDateFormat("yyyyMMddHHmmssSSS").format(new Date())
+ String.format("%03d", sequence);
}
}

View File

@ -0,0 +1,756 @@
package com.ruoyi.warehouse.service.impl;
import java.io.InputStream;
import java.math.BigDecimal;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import org.apache.commons.io.FilenameUtils;
import org.apache.poi.ss.usermodel.DataFormatter;
import org.apache.poi.ss.usermodel.FormulaEvaluator;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.apache.poi.ss.util.CellRangeAddress;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.warehouse.domain.ProductInfo;
import com.ruoyi.warehouse.service.IProductInfoService;
import com.ruoyi.warehouse.service.IPurchaseSmartImportService;
@Service
public class PurchaseSmartImportServiceImpl implements IPurchaseSmartImportService
{
private static final int MAX_ROWS = 100;
private static final int REVIEW_CHUNK_SIZE = 8;
private static final long MAX_FILE_SIZE = 10L * 1024 * 1024;
private static final HttpClient HTTP_CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.version(HttpClient.Version.HTTP_1_1)
.build();
@Value("${agent.model.base-url}")
private String textModelBaseUrl;
@Value("${agent.model.api-key:}")
private String textModelApiKey;
@Value("${agent.model.model}")
private String textModel;
@Autowired
private IProductInfoService productService;
@Override
public JSONObject analyze(MultipartFile file) throws Exception
{
validateFile(file);
String extension = FilenameUtils.getExtension(file.getOriginalFilename()).toLowerCase(Locale.ROOT);
List<ProductInfo> products = productService.selectProductInfoAll();
String output = isExcel(extension) ? analyzeExcel(file, products) : analyzeImage(file, extension, products);
JSONObject result = normalizeResult(output, products);
if (result.getJSONArray("items").isEmpty())
{
throw new ServiceException("没有识别到可导入的采购明细");
}
return result;
}
private String analyzeExcel(MultipartFile file, List<ProductInfo> products) throws Exception
{
JSONArray rows = readExcel(file);
if (rows.isEmpty())
{
throw new ServiceException("Excel中没有可分析的数据");
}
JSONObject mapping = resolveHeaderMapping(rows);
JSONArray items = new JSONArray();
for (int i = 0; i < rows.size(); i++)
{
JSONObject source = rows.getJSONObject(i);
JSONObject item = new JSONObject();
for (String header : mapping.keySet())
{
String field = mapping.getString(header);
Object value = source.get(header);
if (StringUtils.isNotBlank(field) && value != null && StringUtils.isNotBlank(value.toString()))
{
item.put(field, value);
}
}
if (!item.isEmpty()) items.add(item);
}
reviewExcelItems(items, products);
propagateBrands(items, products);
return JSONObject.of("items", items).toJSONString();
}
private void propagateBrands(JSONArray items, List<ProductInfo> products)
{
List<String> brands = new ArrayList<>();
for (ProductInfo product : products) addDistinctBrand(brands, product.getBrand());
for (int i = 0; i < items.size(); i++) addDistinctBrand(brands, items.getJSONObject(i).getString("materialBrand"));
brands.sort(Comparator.comparingInt(String::length).reversed());
for (int i = 0; i < items.size(); i++)
{
JSONObject item = items.getJSONObject(i);
if (StringUtils.isNotBlank(item.getString("materialBrand"))) continue;
String spec = normalizeSearchText(item.getString("materialParams"));
if (StringUtils.isBlank(spec)) continue;
for (String brand : brands)
{
if (!spec.startsWith(normalizeSearchText(brand))) continue;
item.put("materialBrand", brand);
JSONArray inferred = item.getJSONArray("inferredFields");
if (inferred == null)
{
inferred = new JSONArray();
item.put("inferredFields", inferred);
}
if (!inferred.contains("materialBrand")) inferred.add("materialBrand");
break;
}
}
}
private void addDistinctBrand(List<String> brands, String brand)
{
String value = StringUtils.trim(brand);
if (StringUtils.isBlank(value)) return;
for (String existing : brands)
{
if (same(existing, value)) return;
}
brands.add(value);
}
private void reviewExcelItems(JSONArray items, List<ProductInfo> products) throws Exception
{
JSONArray existingCategories = existingCategories(products);
List<CompletableFuture<String>> tasks = new ArrayList<>();
for (int start = 0; start < items.size(); start += REVIEW_CHUNK_SIZE)
{
JSONArray rows = new JSONArray();
for (int i = start; i < Math.min(start + REVIEW_CHUNK_SIZE, items.size()); i++)
{
JSONObject row = new JSONObject(items.getJSONObject(i));
row.put("_rowIndex", i);
rows.add(row);
}
String prompt = "逐行检查采购数据内容。识别单元格内容错位、名称和型号混在一起、数量带单位、日期格式不规范等问题,"
+ "并补全品牌、类别、单位。每一行的category都不能为空,优先从existingCategories选择最合适分类;"
+ "无法精确判断时选择最接近的通用分类。不要改变正确内容,不要臆造品牌或型号。"
+ "quantity只能来自原数量列;规格中的包装数量(如【600个】、每包500个、1000PCS)属于materialParams,"
+ "绝不能作为采购quantity。原quantity是有效数字时禁止修改,原数量缺失时也不要从规格推断。"
+ "使用紧凑JSON且不要格式化换行,只返回需要修改或补全的字段,格式:"
+ "{\"patches\":[{\"rowIndex\":0,\"fields\":{\"字段\":\"新值\"},\"warning\":\"仅有歧义时说明\"}]}。"
+ "常规补全不要返回warning或inferredFields。允许字段仅为productCode,materialName,materialBrand,"
+ "category,materialParams,unit,quantity,expectedDate,purchaseLink,productRemark,remark。无修改返回空patches。rows="
+ rows.toJSONString() + ",existingCategories=" + existingCategories.toJSONString();
tasks.add(CompletableFuture.supplyAsync(() -> {
try
{
return callInventoryModel(JSONArray.of(
message("system", "你是采购Excel数据校验器,只返回JSON对象。"), message("user", prompt)), 2048);
}
catch (Exception e)
{
throw new CompletionException(e);
}
}));
}
try
{
for (CompletableFuture<String> task : tasks)
{
JSONObject result = JSON.parseObject(extractJson(task.join()));
JSONArray patches = result.getJSONArray("patches");
if (patches == null) continue;
for (int i = 0; i < patches.size(); i++) applyReviewPatch(items, patches.getJSONObject(i));
}
}
catch (CompletionException e)
{
Throwable cause = e.getCause();
if (cause instanceof ServiceException) throw (ServiceException) cause;
throw new ServiceException("库存AI校验超时或失败,请稍后重试");
}
}
private void applyReviewPatch(JSONArray items, JSONObject patch)
{
if (patch == null) return;
Integer rowIndex = patch.getInteger("rowIndex");
if (rowIndex == null || rowIndex < 0 || rowIndex >= items.size()) return;
JSONObject item = items.getJSONObject(rowIndex);
JSONObject fields = patch.getJSONObject("fields");
if (fields != null)
{
JSONArray inferred = new JSONArray();
for (String field : fields.keySet())
{
if (isImportField(field))
{
if ("quantity".equals(field) && !canApplyQuantityPatch(item.get("quantity"))) continue;
item.put(field, fields.get(field));
inferred.add(field);
}
}
if (!inferred.isEmpty()) item.put("inferredFields", inferred);
}
if (StringUtils.isNotBlank(patch.getString("warning"))) item.put("warning", patch.getString("warning"));
}
private boolean canApplyQuantityPatch(Object originalQuantity)
{
if (originalQuantity == null || StringUtils.isBlank(originalQuantity.toString())) return false;
String value = originalQuantity.toString().replace(",", "").trim();
return !value.matches("^\\d+(?:\\.\\d+)?$");
}
private JSONArray existingCategories(List<ProductInfo> products)
{
JSONArray categories = new JSONArray();
for (ProductInfo product : products)
{
String category = StringUtils.trim(product.getCategory());
if (StringUtils.isNotBlank(category) && !categories.contains(category)) categories.add(category);
if (categories.size() >= 100) break;
}
return categories;
}
private JSONObject resolveHeaderMapping(JSONArray rows) throws Exception
{
JSONObject mapping = new JSONObject();
JSONObject first = rows.getJSONObject(0);
JSONArray unknownHeaders = new JSONArray();
for (String header : first.keySet())
{
if ("_sourceRow".equals(header)) continue;
String field = knownHeaderField(header);
if (field == null)
{
if (!isIgnoredHeader(header)) unknownHeaders.add(header);
}
else mapping.put(header, field);
}
if (!mapping.containsValue("materialName") || !unknownHeaders.isEmpty())
{
JSONArray samples = new JSONArray();
for (int i = 0; i < Math.min(3, rows.size()); i++) samples.add(rows.get(i));
String prompt = "只分析Excel表头映射,不处理每行数据。可用目标字段为productCode,materialName,"
+ "materialBrand,category,materialParams,unit,quantity,expectedDate,purchaseLink,productRemark,remark。"
+ "单价、总价、申请人等无关列不要映射。只返回JSON:{\"mapping\":{\"原表头\":\"目标字段\"}}。"
+ "headers=" + first.keySet() + ", samples=" + samples.toJSONString();
String output = callInventoryModel(JSONArray.of(message("system", "你是Excel表头映射器,只返回JSON对象。"),
message("user", prompt)), 512);
JSONObject aiMapping = JSON.parseObject(extractJson(output)).getJSONObject("mapping");
if (aiMapping != null)
{
for (String header : aiMapping.keySet())
{
String field = aiMapping.getString(header);
if (first.containsKey(header) && isImportField(field)) mapping.put(header, field);
}
}
}
if (!mapping.containsValue("materialName"))
{
throw new ServiceException("AI未能识别物料名称列,请检查Excel表头");
}
return mapping;
}
private String analyzeImage(MultipartFile file, String extension, List<ProductInfo> products) throws Exception
{
String mime = "jpg".equals(extension) || "jpeg".equals(extension) ? "image/jpeg" : "image/" + extension;
JSONArray content = new JSONArray();
content.add(JSONObject.of("type", "text", "text",
"识别图片中的采购表格,并参考 existingProducts 关联物料或补全缺失信息。existingProducts="
+ productCatalog(products).toJSONString()));
content.add(JSONObject.of("type", "image_url", "image_url", JSONObject.of("url",
"data:" + mime + ";base64," + Base64.getEncoder().encodeToString(file.getBytes()))));
return callInventoryModel(JSONArray.of(message("system", systemPrompt()), message("user", content)));
}
private JSONArray readExcel(MultipartFile file) throws Exception
{
JSONArray result = new JSONArray();
try (InputStream input = file.getInputStream(); Workbook workbook = WorkbookFactory.create(input))
{
DataFormatter formatter = new DataFormatter(Locale.CHINA);
FormulaEvaluator evaluator = workbook.getCreationHelper().createFormulaEvaluator();
HeaderLocation location = findHeader(workbook, formatter, evaluator);
if (location == null)
{
throw new ServiceException("未识别到Excel表头,请确认表头包含物料名称、品名或产品名称");
}
Sheet sheet = location.sheet;
int headerIndex = location.rowIndex;
Row header = sheet.getRow(headerIndex);
int lastColumn = lastHeaderColumn(sheet, headerIndex, header);
for (int rowIndex = headerIndex + 1; rowIndex <= sheet.getLastRowNum() && result.size() < MAX_ROWS; rowIndex++)
{
Row row = sheet.getRow(rowIndex);
JSONObject values = new JSONObject();
if (row != null)
{
for (int column = 0; column < lastColumn; column++)
{
String name = headerCellText(sheet, headerIndex, column, formatter, evaluator);
String value = dataCellText(sheet, rowIndex, column, formatter, evaluator);
if (StringUtils.isNotBlank(name) && StringUtils.isNotBlank(value))
{
values.put(name, value);
}
}
}
if (!values.isEmpty())
{
values.put("_sourceRow", rowIndex + 1);
result.add(values);
}
}
}
return result;
}
private HeaderLocation findHeader(Workbook workbook, DataFormatter formatter, FormulaEvaluator evaluator)
{
HeaderLocation best = null;
int bestScore = 0;
HeaderLocation fallback = null;
int fallbackScore = 0;
for (int sheetIndex = 0; sheetIndex < workbook.getNumberOfSheets(); sheetIndex++)
{
Sheet sheet = workbook.getSheetAt(sheetIndex);
int end = Math.min(sheet.getLastRowNum(), sheet.getFirstRowNum() + 50);
for (int i = sheet.getFirstRowNum(); i <= end; i++)
{
Row row = sheet.getRow(i);
int nonEmpty = 0;
int recognized = 0;
boolean hasNameHeader = false;
int rowLastColumn = lastHeaderColumn(sheet, i, row);
if (rowLastColumn > 0)
{
for (int column = 0; column < rowLastColumn; column++)
{
String value = headerCellText(sheet, i, column, formatter, evaluator).replaceAll("\\s+", "")
.toLowerCase(Locale.ROOT);
if (StringUtils.isBlank(value)) continue;
nonEmpty++;
hasNameHeader |= value.contains("物料名称") || value.contains("产品名称")
|| value.contains("物品名称") || value.equals("物品") || value.equals("物料")
|| value.equals("产品") || value.equals("品名") || value.equals("名称")
|| value.equals("materialname") || value.equals("productname") || value.equals("itemname");
if (isKnownHeader(value)) recognized++;
}
}
int score = recognized * 10 + nonEmpty;
if ((hasNameHeader || recognized >= 3) && nonEmpty >= 2
&& (score > bestScore || score == bestScore && best != null && i > best.rowIndex))
{
best = new HeaderLocation(sheet, i);
bestScore = score;
}
// AI负责最终字段映射。这里保留一个宽松候选,避免陌生表头在调用AI前被拦截。
if (recognized >= 2 && nonEmpty >= 3 && score > fallbackScore)
{
fallback = new HeaderLocation(sheet, i);
fallbackScore = score;
}
}
}
return best != null ? best : fallback;
}
private boolean isKnownHeader(String value)
{
return value.contains("物料") || value.contains("物品") || value.contains("产品")
|| value.contains("品名") || value.equals("名称")
|| value.contains("规格") || value.contains("型号") || value.contains("参数")
|| value.contains("数量") || value.contains("品牌") || value.contains("类别")
|| value.contains("分类") || value.contains("单位") || value.contains("链接")
|| value.contains("日期") || value.contains("备注") || value.contains("material")
|| value.contains("product") || value.contains("quantity") || value.contains("spec");
}
private static class HeaderLocation
{
private final Sheet sheet;
private final int rowIndex;
private HeaderLocation(Sheet sheet, int rowIndex)
{
this.sheet = sheet;
this.rowIndex = rowIndex;
}
}
private String cellText(Row row, int column, DataFormatter formatter, FormulaEvaluator evaluator)
{
if (row == null || row.getCell(column) == null)
{
return "";
}
return StringUtils.trim(formatter.formatCellValue(row.getCell(column), evaluator));
}
private String headerCellText(Sheet sheet, int rowIndex, int column, DataFormatter formatter,
FormulaEvaluator evaluator)
{
return mergedCellText(sheet, rowIndex, column, formatter, evaluator, true);
}
private String dataCellText(Sheet sheet, int rowIndex, int column, DataFormatter formatter,
FormulaEvaluator evaluator)
{
return mergedCellText(sheet, rowIndex, column, formatter, evaluator, false);
}
private String mergedCellText(Sheet sheet, int rowIndex, int column, DataFormatter formatter,
FormulaEvaluator evaluator, boolean resolveHorizontal)
{
Row row = sheet.getRow(rowIndex);
String direct = cellText(row, column, formatter, evaluator);
if (StringUtils.isNotBlank(direct)) return direct;
for (int i = 0; i < sheet.getNumMergedRegions(); i++)
{
CellRangeAddress region = sheet.getMergedRegion(i);
if (!region.isInRange(rowIndex, column)) continue;
if (!resolveHorizontal && region.getFirstColumn() != region.getLastColumn()) return "";
return cellText(sheet.getRow(region.getFirstRow()), region.getFirstColumn(), formatter, evaluator);
}
return "";
}
private int lastHeaderColumn(Sheet sheet, int headerIndex, Row header)
{
int lastColumn = header == null ? 0 : Math.max(0, header.getLastCellNum());
for (int i = 0; i < sheet.getNumMergedRegions(); i++)
{
CellRangeAddress region = sheet.getMergedRegion(i);
if (headerIndex >= region.getFirstRow() && headerIndex <= region.getLastRow())
{
lastColumn = Math.max(lastColumn, region.getLastColumn() + 1);
}
}
return lastColumn;
}
private JSONArray productCatalog(List<ProductInfo> products)
{
JSONArray catalog = new JSONArray();
for (ProductInfo product : products)
{
if (catalog.size() >= 300)
{
break;
}
catalog.add(JSONObject.of("productId", product.getId(), "productCode", product.getProductCode(),
"materialName", product.getProductName(), "materialBrand", product.getBrand(),
"category", product.getCategory(), "materialParams", product.getSpec(), "unit", product.getUnit()));
}
return catalog;
}
private String knownHeaderField(String header)
{
String value = normalizeSearchText(header);
if (List.of("关联物料编码", "物料编码", "产品编码", "productcode", "materialcode").contains(value)) return "productCode";
if (List.of("物料名称", "产品名称", "物品名称", "商品名称", "物品", "物料", "产品", "品名", "名称",
"materialname", "productname", "itemname").contains(value)) return "materialName";
if (List.of("品牌", "厂牌", "brand").contains(value)) return "materialBrand";
if (List.of("类别", "分类", "物料类别", "产品分类", "category").contains(value)) return "category";
if (List.of("参数规格", "规格型号", "型号规格", "规格", "型号", "参数", "spec", "model").contains(value)) return "materialParams";
if (List.of("单位", "计量单位", "unit").contains(value)) return "unit";
if (List.of("需求数量", "采购数量", "数量", "qty", "quantity").contains(value)) return "quantity";
if (List.of("期望到货日期", "期望到货", "预计到货日期", "到货日期", "expecteddate").contains(value)) return "expectedDate";
if (List.of("采购链接", "参考链接", "商品链接", "链接", "url", "purchaselink").contains(value)) return "purchaseLink";
if (List.of("产品备注", "物料备注", "productremark").contains(value)) return "productRemark";
if (List.of("申请备注", "备注", "remark").contains(value)) return "remark";
return null;
}
private boolean isImportField(String field)
{
return List.of("productCode", "materialName", "materialBrand", "category", "materialParams", "unit",
"quantity", "expectedDate", "purchaseLink", "productRemark", "remark").contains(field);
}
private boolean isIgnoredHeader(String header)
{
String value = normalizeSearchText(header);
return List.of("单价", "价格", "含税单价", "总价", "金额", "合计", "申请人", "申请者", "申请员工",
"序号", "行号", "编号", "部门", "税率", "供应商").contains(value);
}
private String normalizeSearchText(String value)
{
return StringUtils.defaultString(value).replaceAll("[\\s_()()/\\-]+", "").toLowerCase(Locale.ROOT);
}
private String callInventoryModel(JSONArray input, int maxTokens) throws Exception
{
return callInventoryModel(input, maxTokens, Duration.ofSeconds(120));
}
private String callInventoryModel(JSONArray input) throws Exception
{
return callInventoryModel(input, 8192, Duration.ofSeconds(120));
}
private String callInventoryModel(JSONArray input, int maxTokens, Duration timeout) throws Exception
{
JSONObject body = new JSONObject();
body.put("model", textModel);
body.put("messages", input);
body.put("chat_template_kwargs", JSONObject.of("enable_thinking", false));
body.put("temperature", 0);
body.put("max_tokens", maxTokens);
body.put("stream", false);
HttpRequest.Builder builder = HttpRequest.newBuilder().uri(URI.create(normalizeChatEndpoint(textModelBaseUrl)))
.timeout(timeout).header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body.toJSONString(), StandardCharsets.UTF_8));
if (StringUtils.isNotBlank(textModelApiKey))
{
builder.header("Authorization", "Bearer " + textModelApiKey);
}
HttpResponse<String> response = HTTP_CLIENT.send(builder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() < 200 || response.statusCode() >= 300)
{
throw new ServiceException("库存AI分析失败,HTTP " + response.statusCode());
}
JSONObject root = JSON.parseObject(response.body());
JSONArray choices = root.getJSONArray("choices");
String text = choices == null || choices.isEmpty() ? null : choices.getJSONObject(0).getJSONObject("message").getString("content");
if (StringUtils.isBlank(text))
{
throw new ServiceException("库存AI未返回分析内容");
}
return text;
}
private JSONObject normalizeResult(String output, List<ProductInfo> products)
{
JSONObject root = JSON.parseObject(extractJson(output));
JSONArray sourceItems = root.getJSONArray("items");
JSONArray items = new JSONArray();
List<String> globalWarnings = new ArrayList<>();
if (sourceItems == null)
{
return JSONObject.of("items", items, "warnings", JSONArray.of("模型返回格式不正确"));
}
for (int i = 0; i < sourceItems.size() && items.size() < MAX_ROWS; i++)
{
JSONObject source = sourceItems.getJSONObject(i);
if (source == null || StringUtils.isBlank(source.getString("materialName")))
{
globalWarnings.add("第 " + (i + 1) + " 行未识别到物料名称,已跳过");
continue;
}
JSONObject item = new JSONObject();
copyFields(source, item);
ProductInfo matched = matchProduct(item, products);
if (matched != null)
{
item.put("productId", matched.getId());
item.put("productCode", matched.getProductCode());
item.put("materialName", matched.getProductName());
item.put("materialBrand", matched.getBrand());
item.put("category", matched.getCategory());
item.put("materialParams", matched.getSpec());
item.put("unit", matched.getUnit());
String materialLabel = StringUtils.isNotBlank(matched.getProductCode())
? matched.getProductCode() : matched.getProductName() + " / " + matched.getSpec();
appendTip(item, "已关联现有物料 " + materialLabel);
}
if (source.getJSONArray("inferredFields") != null && !source.getJSONArray("inferredFields").isEmpty())
{
List<String> labels = new ArrayList<>();
for (String field : source.getJSONArray("inferredFields").toJavaList(String.class))
{
String label = importFieldLabel(field);
if (!labels.contains(label)) labels.add(label);
}
appendTip(item, "AI推断字段:" + String.join("、", labels));
}
if (StringUtils.isNotBlank(source.getString("warning")))
{
appendTip(item, source.getString("warning"));
}
if (StringUtils.isBlank(item.getString("category")))
{
String category = categoryByMaterialName(item.getString("materialName"), products);
if (StringUtils.isBlank(category)) category = defaultCategory(products);
item.put("category", category);
appendTip(item, "未能精确判断分类,已归入" + category);
}
items.add(item);
}
return JSONObject.of("items", items, "warnings", globalWarnings);
}
private String categoryByMaterialName(String materialName, List<ProductInfo> products)
{
String category = null;
for (ProductInfo product : products)
{
if (!same(materialName, product.getProductName()) || StringUtils.isBlank(product.getCategory())) continue;
if (category != null && !same(category, product.getCategory())) return null;
category = product.getCategory();
}
return category;
}
private String defaultCategory(List<ProductInfo> products)
{
String[] preferred = { "其他", "通用物料", "其他物料", "未分类" };
for (String candidate : preferred)
{
for (ProductInfo product : products)
{
if (same(candidate, product.getCategory())) return product.getCategory();
}
}
return "其他";
}
private void copyFields(JSONObject source, JSONObject target)
{
String[] fields = { "productCode", "materialName", "materialBrand", "category",
"materialParams", "unit", "quantity", "expectedDate", "purchaseLink", "productRemark", "remark" };
for (String field : fields)
{
target.put(field, source.get(field));
}
if (target.getBigDecimal("quantity") == null || target.getBigDecimal("quantity").signum() <= 0)
{
target.put("quantity", BigDecimal.ONE);
appendTip(target, "未识别到有效数量,已暂填1");
}
}
private String importFieldLabel(String field)
{
if ("productCode".equals(field)) return "关联物料编码";
if ("materialName".equals(field)) return "物料名称";
if ("materialBrand".equals(field)) return "品牌";
if ("category".equals(field)) return "分类";
if ("materialParams".equals(field)) return "参数规格";
if ("unit".equals(field)) return "单位";
if ("quantity".equals(field)) return "数量";
if ("expectedDate".equals(field)) return "期望到货日期";
if ("purchaseLink".equals(field)) return "采购链接";
if ("productRemark".equals(field)) return "产品备注";
if ("remark".equals(field)) return "申请备注";
return field;
}
private ProductInfo matchProduct(JSONObject item, List<ProductInfo> products)
{
String code = item.getString("productCode");
if (StringUtils.isNotBlank(code))
{
for (ProductInfo product : products)
{
if (same(code, product.getProductCode()))
{
return product;
}
}
}
List<ProductInfo> matches = new ArrayList<>();
for (ProductInfo product : products)
{
if (same(item.getString("materialName"), product.getProductName())
&& (StringUtils.isBlank(item.getString("materialParams")) || same(item.getString("materialParams"), product.getSpec())))
{
matches.add(product);
}
}
return matches.size() == 1 ? matches.get(0) : null;
}
private void appendTip(JSONObject item, String tip)
{
String current = item.getString("smartTip");
item.put("smartTip", StringUtils.isBlank(current) ? tip : current + ";" + tip);
}
private boolean same(String left, String right)
{
return StringUtils.isNotBlank(left) && StringUtils.isNotBlank(right) && left.trim().equalsIgnoreCase(right.trim());
}
private JSONObject message(String role, Object content)
{
return JSONObject.of("role", role, "content", content);
}
private String systemPrompt()
{
return "你是采购清单智能整理器,只返回JSON对象。输出格式为{items:[...]},每项字段必须包含"
+ "productId,productCode,materialName,materialBrand,category,materialParams,unit,quantity,expectedDate,purchaseLink,productRemark,remark,inferredFields,warning。"
+ "识别任意中文或英文表头并正确映射,不能因列顺序不同而错位。单价、总价和申请人不是导入字段,不要混入其他字段。"
+ "参数规格中若含品牌或明确型号,可补全materialBrand;能从知名型号可靠判断品牌时允许推断,否则品牌留空。"
+ "category可合理归类,quantity缺失填null,expectedDate统一yyyy-MM-dd。"
+ "优先从existingProducts中选择完全一致的物料并返回其productId和productCode,不要把相似但型号不同的物料关联。"
+ "inferredFields列出所有不是源表直接提供、而是系统匹配或AI推断得到的字段名;不确定信息写入warning。最多返回100项。";
}
private String normalizeChatEndpoint(String configuredUrl)
{
String url = StringUtils.defaultString(configuredUrl).trim();
while (url.endsWith("/")) url = url.substring(0, url.length() - 1);
if (url.endsWith("/v1")) return url + "/chat/completions";
if (url.endsWith("/v1/chat/completions") || url.endsWith("/chat/completions")) return url;
return url + "/v1/chat/completions";
}
private String extractJson(String output)
{
int start = output.indexOf('{');
int end = output.lastIndexOf('}');
return start >= 0 && end > start ? output.substring(start, end + 1) : output;
}
private boolean isExcel(String extension)
{
return "xls".equals(extension) || "xlsx".equals(extension);
}
private void validateFile(MultipartFile file)
{
if (file == null || file.isEmpty()) throw new ServiceException("请选择图片或Excel文件");
if (file.getSize() > MAX_FILE_SIZE) throw new ServiceException("文件不能超过10MB");
String extension = FilenameUtils.getExtension(file.getOriginalFilename()).toLowerCase(Locale.ROOT);
if (!List.of("xls", "xlsx", "png", "jpg", "jpeg", "webp").contains(extension))
{
throw new ServiceException("仅支持 xls、xlsx、png、jpg、jpeg、webp 文件");
}
}
}

View File

@ -79,13 +79,13 @@ public class WmsBatchImportServiceImpl implements IWmsBatchImportService
private static final int ITEM_DONE = 3; private static final int ITEM_DONE = 3;
private static final int ITEM_FAILED = 4; private static final int ITEM_FAILED = 4;
@Value("${agent.openai.base-url}") @Value("${agent.model.base-url}")
private String baseUrl; private String baseUrl;
@Value("${agent.openai.api-key}") @Value("${agent.model.api-key:}")
private String apiKey; private String apiKey;
@Value("${agent.openai.model}") @Value("${agent.model.model}")
private String model; private String model;
@Autowired @Autowired
@ -381,10 +381,6 @@ public class WmsBatchImportServiceImpl implements IWmsBatchImportService
private List<WmsBatchItem> parseImage(WmsBatchJob job, String username) throws Exception private List<WmsBatchItem> parseImage(WmsBatchJob job, String username) throws Exception
{ {
if (StringUtils.isBlank(apiKey))
{
throw new ServiceException("图片导入需要配置 OPENAI_API_KEY 环境变量");
}
updateJob(job.getId(), JOB_PARSING, 30, null); updateJob(job.getId(), JOB_PARSING, 30, null);
String output = callVisionModel(job); String output = callVisionModel(job);
updateJob(job.getId(), JOB_PARSING, 70, null); updateJob(job.getId(), JOB_PARSING, 70, null);
@ -560,36 +556,34 @@ public class WmsBatchImportServiceImpl implements IWmsBatchImportService
JSONObject body = new JSONObject(); JSONObject body = new JSONObject();
body.put("model", model); body.put("model", model);
JSONArray input = new JSONArray(); JSONArray messages = new JSONArray();
input.add(message("system", visionSystemPrompt())); messages.add(message("system", visionSystemPrompt()));
JSONArray content = new JSONArray(); JSONArray content = new JSONArray();
JSONObject text = new JSONObject(); JSONObject text = new JSONObject();
text.put("type", "input_text"); text.put("type", "text");
text.put("text", visionUserPrompt()); text.put("text", visionUserPrompt());
content.add(text); content.add(text);
JSONObject imageContent = new JSONObject(); JSONObject imageContent = new JSONObject();
imageContent.put("type", "input_image"); imageContent.put("type", "image_url");
imageContent.put("image_url", "data:" + mimeType + ";base64," + encoded); imageContent.put("image_url", JSONObject.of("url", "data:" + mimeType + ";base64," + encoded));
content.add(imageContent); content.add(imageContent);
input.add(message("user", content)); messages.add(message("user", content));
body.put("input", input); body.put("messages", messages);
body.put("chat_template_kwargs", JSONObject.of("enable_thinking", false));
body.put("temperature", 0);
body.put("max_tokens", 8192);
body.put("stream", false);
JSONObject format = new JSONObject(); HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10))
format.put("type", "json_object"); .version(HttpClient.Version.HTTP_1_1).build();
JSONObject textFormat = new JSONObject(); HttpRequest.Builder requestBuilder = HttpRequest.newBuilder()
textFormat.put("format", format);
body.put("text", textFormat);
HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(normalizeEndpoint(baseUrl))) .uri(URI.create(normalizeEndpoint(baseUrl)))
.timeout(Duration.ofSeconds(60)) .timeout(Duration.ofSeconds(120))
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body.toJSONString(), StandardCharsets.UTF_8)) .POST(HttpRequest.BodyPublishers.ofString(body.toJSONString(), StandardCharsets.UTF_8));
.build(); if (StringUtils.isNotBlank(apiKey)) requestBuilder.header("Authorization", "Bearer " + apiKey);
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); HttpResponse<String> response = client.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() < 200 || response.statusCode() >= 300) if (response.statusCode() < 200 || response.statusCode() >= 300)
{ {
throw new ServiceException("图片模型解析失败,HTTP " + response.statusCode()); throw new ServiceException("图片模型解析失败,HTTP " + response.statusCode());
@ -1207,25 +1201,34 @@ public class WmsBatchImportServiceImpl implements IWmsBatchImportService
private String normalizeEndpoint(String configuredBaseUrl) private String normalizeEndpoint(String configuredBaseUrl)
{ {
String url = StringUtils.defaultIfBlank(configuredBaseUrl, "http://192.168.28.10:18080").trim(); String url = StringUtils.defaultIfBlank(configuredBaseUrl, "http://192.168.28.10:14081").trim();
while (url.endsWith("/")) while (url.endsWith("/"))
{ {
url = url.substring(0, url.length() - 1); url = url.substring(0, url.length() - 1);
} }
if (url.endsWith("/v1")) if (url.endsWith("/v1"))
{ {
return url + "/responses"; return url + "/chat/completions";
} }
if (url.endsWith("/v1/responses")) if (url.endsWith("/v1/chat/completions") || url.endsWith("/chat/completions"))
{ {
return url; return url;
} }
return url + "/v1/responses"; return url + "/v1/chat/completions";
} }
private String extractOutputText(String responseBody) private String extractOutputText(String responseBody)
{ {
JSONObject root = JSON.parseObject(responseBody); JSONObject root = JSON.parseObject(responseBody);
JSONArray choices = root.getJSONArray("choices");
if (choices != null && !choices.isEmpty())
{
JSONObject message = choices.getJSONObject(0).getJSONObject("message");
if (message != null && StringUtils.isNotBlank(message.getString("content")))
{
return message.getString("content");
}
}
String outputText = root.getString("output_text"); String outputText = root.getString("output_text");
if (StringUtils.isNotBlank(outputText)) if (StringUtils.isNotBlank(outputText))
{ {

View File

@ -6,6 +6,9 @@
<result property="inNo" column="in_no"/> <result property="inNo" column="in_no"/>
<result property="areaId" column="area_id"/> <result property="areaId" column="area_id"/>
<result property="productId" column="product_id"/> <result property="productId" column="product_id"/>
<result property="purchaseRequestId" column="purchase_request_id"/>
<result property="arrivalId" column="arrival_id"/>
<result property="sourceNo" column="source_no"/>
<result property="inType" column="in_type"/> <result property="inType" column="in_type"/>
<result property="inNum" column="in_num"/> <result property="inNum" column="in_num"/>
<result property="inStatus" column="in_status"/> <result property="inStatus" column="in_status"/>
@ -26,7 +29,8 @@
</resultMap> </resultMap>
<sql id="selectInRecordVo"> <sql id="selectInRecordVo">
select ir.id, ir.in_no, ir.area_id, ir.product_id, ir.in_type, ir.in_num, ir.in_status, select ir.id, ir.in_no, ir.area_id, ir.product_id, ir.purchase_request_id, ir.arrival_id, ir.source_no,
ir.in_type, ir.in_num, ir.in_status,
ir.operator, ir.in_operator, ir.confirm_time, ir.remark, ir.create_time, ir.update_time, ir.operator, ir.in_operator, ir.confirm_time, ir.remark, ir.create_time, ir.update_time,
wa.area_code, wa.area_name, pi.product_code, pi.product_name, pi.brand, pi.category, pi.spec, pi.unit wa.area_code, wa.area_name, pi.product_code, pi.product_name, pi.brand, pi.category, pi.spec, pi.unit
from in_record ir from in_record ir
@ -57,17 +61,26 @@
</select> </select>
<select id="selectInRecordByIdForUpdate" parameterType="Long" resultMap="InRecordResult"> <select id="selectInRecordByIdForUpdate" parameterType="Long" resultMap="InRecordResult">
select id, in_no, area_id, product_id, in_type, in_num, in_status, operator, in_operator, confirm_time, remark, create_time, update_time select id, in_no, area_id, product_id, purchase_request_id, arrival_id, source_no, in_type, in_num,
in_status, operator, in_operator, confirm_time, remark, create_time, update_time
from in_record where id = #{id} for update from in_record where id = #{id} for update
</select> </select>
<select id="selectInRecordByArrivalIdForUpdate" parameterType="Long" resultMap="InRecordResult">
select id, in_no, area_id, product_id, purchase_request_id, arrival_id, source_no, in_type, in_num,
in_status, operator, in_operator, confirm_time, remark, create_time, update_time
from in_record where arrival_id = #{arrivalId} for update
</select>
<select id="checkInNoUnique" parameterType="String" resultMap="InRecordResult"> <select id="checkInNoUnique" parameterType="String" resultMap="InRecordResult">
<include refid="selectInRecordVo"/> where ir.in_no = #{inNo} limit 1 <include refid="selectInRecordVo"/> where ir.in_no = #{inNo} limit 1
</select> </select>
<insert id="insertInRecord" parameterType="InRecord" useGeneratedKeys="true" keyProperty="id"> <insert id="insertInRecord" parameterType="InRecord" useGeneratedKeys="true" keyProperty="id">
insert into in_record(in_no, area_id, product_id, in_type, in_num, in_status, operator, in_operator, remark, create_time) insert into in_record(in_no, area_id, product_id, purchase_request_id, arrival_id, source_no, in_type, in_num,
values(#{inNo}, #{areaId}, #{productId}, #{inType}, #{inNum}, #{inStatus}, #{operator}, #{inOperator}, #{remark}, sysdate()) in_status, operator, in_operator, remark, create_time)
values(#{inNo}, #{areaId}, #{productId}, #{purchaseRequestId}, #{arrivalId}, #{sourceNo}, #{inType}, #{inNum},
#{inStatus}, #{operator}, #{inOperator}, #{remark}, sysdate())
</insert> </insert>
<update id="updateInRecord" parameterType="InRecord"> <update id="updateInRecord" parameterType="InRecord">
@ -96,4 +109,8 @@
delete from in_record where in_status = 0 and id in delete from in_record where in_status = 0 and id in
<foreach collection="array" item="id" open="(" separator="," close=")">#{id}</foreach> <foreach collection="array" item="id" open="(" separator="," close=")">#{id}</foreach>
</delete> </delete>
<delete id="deletePendingInRecordByArrivalId" parameterType="Long">
delete from in_record where arrival_id = #{arrivalId} and in_status = 0
</delete>
</mapper> </mapper>

View File

@ -0,0 +1,104 @@
<?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.PurchaseArrivalMapper">
<resultMap type="PurchaseArrival" id="PurchaseArrivalResult">
<id property="id" column="id"/>
<result property="arrivalNo" column="arrival_no"/>
<result property="purchaseRequestId" column="purchase_request_id"/>
<result property="arrivalQuantity" column="arrival_quantity"/>
<result property="arrivalDate" column="arrival_date"/>
<result property="acceptanceStatus" column="acceptance_status"/>
<result property="acceptanceRemark" column="acceptance_remark"/>
<result property="acceptedBy" column="accepted_by"/>
<result property="acceptedTime" column="accepted_time"/>
<result property="preAcceptPurchaseStatus" column="pre_accept_purchase_status"/>
<result property="revokedBy" column="revoked_by"/>
<result property="revokedTime" column="revoked_time"/>
<result property="revokeRemark" column="revoke_remark"/>
<result property="remark" column="remark"/>
<result property="createBy" column="create_by"/>
<result property="createTime" column="create_time"/>
<result property="updateBy" column="update_by"/>
<result property="updateTime" column="update_time"/>
<result property="requestNo" column="request_no"/>
<result property="applicantId" column="applicant_id"/>
<result property="applicantName" column="applicant_name"/>
<result property="productId" column="product_id"/>
<result property="productCode" column="product_code"/>
<result property="materialName" column="material_name"/>
<result property="materialBrand" column="material_brand"/>
<result property="materialParams" column="material_params"/>
<result property="unit" column="unit"/>
</resultMap>
<sql id="selectPurchaseArrivalVo">
select pa.id, pa.arrival_no, pa.purchase_request_id, pa.arrival_quantity, pa.arrival_date,
pa.acceptance_status, pa.acceptance_remark, pa.accepted_by, pa.accepted_time,
pa.pre_accept_purchase_status, pa.revoked_by, pa.revoked_time, pa.revoke_remark,
pa.remark, pa.create_by, pa.create_time, pa.update_by, pa.update_time,
pr.request_no, pr.applicant_id, pr.applicant_name, pr.product_id,
pi.product_code, pr.material_name, pr.material_brand, pr.material_params, pr.unit
from purchase_arrival pa
join purchase_request pr on pr.id = pa.purchase_request_id
left join product_info pi on pi.id = pr.product_id
</sql>
<select id="selectPurchaseArrivalList" parameterType="Long" resultMap="PurchaseArrivalResult">
<include refid="selectPurchaseArrivalVo"/>
where pa.purchase_request_id = #{purchaseRequestId}
order by pa.id desc
</select>
<select id="selectPurchaseArrivalById" parameterType="Long" resultMap="PurchaseArrivalResult">
<include refid="selectPurchaseArrivalVo"/> where pa.id = #{id}
</select>
<select id="selectPurchaseArrivalByIdForUpdate" parameterType="Long" resultMap="PurchaseArrivalResult">
<include refid="selectPurchaseArrivalVo"/> where pa.id = #{id} for update
</select>
<select id="selectActiveQuantityByRequestId" parameterType="Long" resultType="java.math.BigDecimal">
select coalesce(sum(arrival_quantity), 0)
from purchase_arrival
where purchase_request_id = #{purchaseRequestId} and acceptance_status in (0, 1)
</select>
<select id="selectAcceptedQuantityByRequestId" parameterType="Long" resultType="java.math.BigDecimal">
select coalesce(sum(arrival_quantity), 0)
from purchase_arrival
where purchase_request_id = #{purchaseRequestId} and acceptance_status = 1
</select>
<select id="countByRequestId" parameterType="Long" resultType="int">
select count(*) from purchase_arrival where purchase_request_id = #{purchaseRequestId}
</select>
<insert id="insertPurchaseArrival" parameterType="PurchaseArrival" useGeneratedKeys="true" keyProperty="id">
insert into purchase_arrival(arrival_no, purchase_request_id, arrival_quantity, arrival_date,
acceptance_status, remark, create_by, create_time)
values(#{arrivalNo}, #{purchaseRequestId}, #{arrivalQuantity}, #{arrivalDate}, 0,
#{remark}, #{createBy}, sysdate())
</insert>
<update id="updatePurchaseArrivalAcceptance" parameterType="PurchaseArrival">
update purchase_arrival
set acceptance_status = #{acceptanceStatus}, acceptance_remark = #{acceptanceRemark},
accepted_by = #{acceptedBy}, accepted_time = #{acceptedTime},
pre_accept_purchase_status = #{preAcceptPurchaseStatus},
update_by = #{updateBy}, update_time = sysdate()
where id = #{id} and acceptance_status = 0
</update>
<update id="revokePurchaseArrivalAcceptance" parameterType="PurchaseArrival">
update purchase_arrival
set acceptance_status = 0,
acceptance_remark = null, accepted_by = null, accepted_time = null,
revoked_by = #{revokedBy}, revoked_time = #{revokedTime}, revoke_remark = #{revokeRemark},
update_by = #{updateBy}, update_time = sysdate()
where id = #{id} and acceptance_status in (1, 2)
</update>
<delete id="deletePendingPurchaseArrival" parameterType="Long">
delete from purchase_arrival where id = #{id} and acceptance_status = 0
</delete>
</mapper>

View File

@ -0,0 +1,107 @@
<?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.PurchaseBatchMapper">
<resultMap type="PurchaseBatch" id="PurchaseBatchResult">
<id property="id" column="id"/>
<result property="batchNo" column="batch_no"/>
<result property="batchName" column="batch_name"/>
<result property="purchaseStatus" column="purchase_status"/>
<result property="progressRemark" column="progress_remark"/>
<result property="requestCount" column="request_count"/>
<result property="createBy" column="create_by"/>
<result property="createTime" column="create_time"/>
<result property="updateBy" column="update_by"/>
<result property="updateTime" column="update_time"/>
</resultMap>
<select id="selectPurchaseBatchList" parameterType="PurchaseBatch" resultMap="PurchaseBatchResult">
select pb.id, pb.batch_no, pb.batch_name, pb.purchase_status, pb.progress_remark,
pb.create_by, pb.create_time, pb.update_by, pb.update_time, count(pr.id) as request_count
from purchase_batch pb
left join purchase_request pr on pr.purchase_batch_id = pb.id
<where>
<if test="batchNo != null and batchNo != ''">and pb.batch_no like concat('%', #{batchNo}, '%')</if>
<if test="batchName != null and batchName != ''">and pb.batch_name like concat('%', #{batchName}, '%')</if>
<if test="purchaseStatus != null">and pb.purchase_status = #{purchaseStatus}</if>
</where>
group by pb.id, pb.batch_no, pb.batch_name, pb.purchase_status, pb.progress_remark,
pb.create_by, pb.create_time, pb.update_by, pb.update_time
order by pb.id desc
</select>
<select id="selectPurchaseBatchOptions" parameterType="String" resultMap="PurchaseBatchResult">
select pb.id, pb.batch_no, pb.batch_name, pb.purchase_status, pb.progress_remark,
pb.create_by, pb.create_time, pb.update_by, pb.update_time,
(select count(*) from purchase_request pr where pr.purchase_batch_id = pb.id) as request_count
from purchase_batch pb
where pb.purchase_status in (1, 2)
<if test="keyword != null and keyword != ''">
and (pb.batch_no like concat('%', #{keyword}, '%') or pb.batch_name like concat('%', #{keyword}, '%'))
</if>
order by pb.id desc
limit 100
</select>
<select id="selectPurchaseBatchFilterOptions" parameterType="String" resultMap="PurchaseBatchResult">
select pb.id, pb.batch_no, pb.batch_name, pb.purchase_status, pb.progress_remark,
pb.create_by, pb.create_time, pb.update_by, pb.update_time,
(select count(*) from purchase_request pr where pr.purchase_batch_id = pb.id) as request_count
from purchase_batch pb
<where>
<if test="keyword != null and keyword != ''">
and (pb.batch_no like concat('%', #{keyword}, '%') or pb.batch_name like concat('%', #{keyword}, '%'))
</if>
</where>
order by pb.id desc
limit 100
</select>
<select id="selectPurchaseBatchById" parameterType="Long" resultMap="PurchaseBatchResult">
select id, batch_no, batch_name, purchase_status, progress_remark, create_by, create_time, update_by, update_time
from purchase_batch where id = #{id}
</select>
<select id="selectPurchaseBatchByNo" parameterType="String" resultMap="PurchaseBatchResult">
select id, batch_no, batch_name, purchase_status, progress_remark, create_by, create_time, update_by, update_time
from purchase_batch where batch_no = #{batchNo} limit 1
</select>
<insert id="insertPurchaseBatch" parameterType="PurchaseBatch" useGeneratedKeys="true" keyProperty="id">
insert into purchase_batch(batch_no, batch_name, purchase_status, progress_remark, create_by, create_time)
values(#{batchNo}, #{batchName}, #{purchaseStatus}, #{progressRemark}, #{createBy}, sysdate())
</insert>
<update id="updatePurchaseBatch" parameterType="PurchaseBatch">
update purchase_batch
set batch_no = #{batchNo}, batch_name = #{batchName}, purchase_status = #{purchaseStatus},
progress_remark = #{progressRemark}, update_by = #{updateBy}, update_time = sysdate()
where id = #{id}
</update>
<select id="countRequestsByBatchId" parameterType="Long" resultType="int">
select count(*) from purchase_request where purchase_batch_id = #{batchId}
</select>
<select id="countRequestsByIds" parameterType="Long" resultType="int">
select count(*) from purchase_request where id in
<foreach collection="array" item="id" open="(" separator="," close=")">#{id}</foreach>
</select>
<update id="assignRequests">
update purchase_request
set purchase_batch_id = #{batch.id}, purchase_batch_no = #{batch.batchNo},
update_by = #{updateBy}, update_time = sysdate()
where id in
<foreach collection="requestIds" item="id" open="(" separator="," close=")">#{id}</foreach>
</update>
<update id="syncRequestBatchNo">
update purchase_request
set purchase_batch_no = #{batchNo}, update_by = #{updateBy}, update_time = sysdate()
where purchase_batch_id = #{batchId}
</update>
<delete id="deletePurchaseBatchById" parameterType="Long">
delete from purchase_batch where id = #{id}
</delete>
</mapper>

View File

@ -0,0 +1,148 @@
<?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.PurchaseRequestMapper">
<resultMap type="PurchaseRequest" id="PurchaseRequestResult">
<id property="id" column="id"/>
<result property="requestNo" column="request_no"/>
<result property="productId" column="product_id"/>
<result property="purchaseBatchId" column="purchase_batch_id"/>
<result property="materialName" column="material_name"/>
<result property="materialBrand" column="material_brand"/>
<result property="category" column="category"/>
<result property="materialParams" column="material_params"/>
<result property="purchaseLink" column="purchase_link"/>
<result property="quantity" column="quantity"/>
<result property="unit" column="unit"/>
<result property="expectedDate" column="expected_date"/>
<result property="applicantId" column="applicant_id"/>
<result property="applicantName" column="applicant_name"/>
<result property="purchaseStatus" column="purchase_status"/>
<result property="purchaseBatchNo" column="purchase_batch_no"/>
<result property="progressRemark" column="progress_remark"/>
<result property="productRemark" column="product_remark"/>
<result property="requestRemark" column="remark"/>
<result property="remark" column="remark"/>
<result property="createBy" column="create_by"/>
<result property="createTime" column="create_time"/>
<result property="updateBy" column="update_by"/>
<result property="updateTime" column="update_time"/>
<result property="productCode" column="product_code"/>
<result property="productName" column="product_name"/>
<result property="productBrand" column="product_brand"/>
<result property="productCategory" column="product_category"/>
<result property="productSpec" column="product_spec"/>
<result property="acceptedQuantity" column="accepted_quantity"/>
<result property="pendingArrivalQuantity" column="pending_arrival_quantity"/>
<result property="remainingQuantity" column="remaining_quantity"/>
</resultMap>
<sql id="selectPurchaseRequestVo">
select pr.id, pr.request_no, pr.product_id, pr.purchase_batch_id, pr.material_name, pr.material_brand, pr.category, pr.material_params,
pr.purchase_link, pr.quantity, pr.unit, pr.expected_date, pr.applicant_id, pr.applicant_name,
pr.purchase_status, pr.purchase_batch_no, pr.progress_remark, pr.remark,
pr.create_by, pr.create_time, pr.update_by, pr.update_time,
pi.product_code, pi.product_name, pi.brand as product_brand,
pi.category as product_category, pi.spec as product_spec, pi.remark as product_remark,
coalesce(pa.accepted_quantity, 0) as accepted_quantity,
coalesce(pa.pending_quantity, 0) as pending_arrival_quantity,
greatest(pr.quantity - coalesce(pa.accepted_quantity, 0) - coalesce(pa.pending_quantity, 0), 0) as remaining_quantity
from purchase_request pr
left join product_info pi on pi.id = pr.product_id
left join (
select purchase_request_id,
sum(case when acceptance_status = 1 then arrival_quantity else 0 end) as accepted_quantity,
sum(case when acceptance_status = 0 then arrival_quantity else 0 end) as pending_quantity
from purchase_arrival
group by purchase_request_id
) pa on pa.purchase_request_id = pr.id
</sql>
<select id="selectPurchaseRequestList" parameterType="PurchaseRequest" resultMap="PurchaseRequestResult">
<include refid="selectPurchaseRequestVo"/>
<where>
<if test="requestNo != null and requestNo != ''">and pr.request_no like concat('%', #{requestNo}, '%')</if>
<if test="productId != null">and pr.product_id = #{productId}</if>
<if test="materialName != null and materialName != ''">
and lower(concat_ws(' ', pr.material_name, pr.material_brand, pr.category, pr.material_params, pi.product_name, pi.product_code))
like concat('%', lower(#{materialName}), '%')
</if>
<if test="category != null and category != ''">and pr.category like concat('%', #{category}, '%')</if>
<if test="applicantId != null">and pr.applicant_id = #{applicantId}</if>
<if test="applicantName != null and applicantName != ''">and pr.applicant_name like concat('%', #{applicantName}, '%')</if>
<if test="purchaseStatus != null">and pr.purchase_status = #{purchaseStatus}</if>
<if test="purchaseBatchNo != null and purchaseBatchNo != ''">and pr.purchase_batch_no like concat('%', #{purchaseBatchNo}, '%')</if>
</where>
order by pr.id desc
</select>
<select id="selectPurchaseRequestById" resultMap="PurchaseRequestResult">
<include refid="selectPurchaseRequestVo"/>
where pr.id = #{id}
<if test="applicantId != null">and pr.applicant_id = #{applicantId}</if>
</select>
<select id="selectPurchaseRequestByIdForUpdate" parameterType="Long" resultMap="PurchaseRequestResult">
select pr.id, pr.request_no, pr.product_id, pr.purchase_batch_id, pr.material_name,
pr.material_brand, pr.category, pr.material_params, pr.purchase_link, pr.quantity,
pr.unit, pr.expected_date, pr.applicant_id, pr.applicant_name, pr.purchase_status,
pr.purchase_batch_no, pr.progress_remark, pr.remark, pr.create_by, pr.create_time,
pr.update_by, pr.update_time
from purchase_request pr where pr.id = #{id} for update
</select>
<select id="checkRequestNoUnique" parameterType="String" resultMap="PurchaseRequestResult">
<include refid="selectPurchaseRequestVo"/> where pr.request_no = #{requestNo} limit 1
</select>
<insert id="insertPurchaseRequest" parameterType="PurchaseRequest" useGeneratedKeys="true" keyProperty="id">
insert into purchase_request(
request_no, product_id, material_name, material_brand, category, material_params, purchase_link,
quantity, unit, expected_date, applicant_id, applicant_name, purchase_status, purchase_batch_no,
progress_remark, remark, create_by, create_time
) values (
#{requestNo}, #{productId}, #{materialName}, #{materialBrand}, #{category}, #{materialParams}, #{purchaseLink},
#{quantity}, #{unit}, #{expectedDate}, #{applicantId}, #{applicantName}, #{purchaseStatus}, #{purchaseBatchNo},
#{progressRemark}, #{remark}, #{createBy}, sysdate()
)
</insert>
<update id="updatePurchaseRequest">
update purchase_request
<set>
<if test="purchaseRequest.requestNo != null and purchaseRequest.requestNo != ''">request_no = #{purchaseRequest.requestNo},</if>
product_id = #{purchaseRequest.productId},
<if test="purchaseRequest.materialName != null and purchaseRequest.materialName != ''">material_name = #{purchaseRequest.materialName},</if>
<if test="purchaseRequest.materialBrand != null">material_brand = #{purchaseRequest.materialBrand},</if>
<if test="purchaseRequest.category != null">category = #{purchaseRequest.category},</if>
<if test="purchaseRequest.materialParams != null">material_params = #{purchaseRequest.materialParams},</if>
<if test="purchaseRequest.purchaseLink != null">purchase_link = #{purchaseRequest.purchaseLink},</if>
<if test="purchaseRequest.quantity != null">quantity = #{purchaseRequest.quantity},</if>
<if test="purchaseRequest.unit != null">unit = #{purchaseRequest.unit},</if>
<if test="purchaseRequest.expectedDate != null">expected_date = #{purchaseRequest.expectedDate},</if>
<if test="purchaseRequest.applicantId != null">applicant_id = #{purchaseRequest.applicantId},</if>
<if test="purchaseRequest.applicantName != null and purchaseRequest.applicantName != ''">applicant_name = #{purchaseRequest.applicantName},</if>
<if test="purchaseRequest.purchaseStatus != null">purchase_status = #{purchaseRequest.purchaseStatus},</if>
<if test="purchaseRequest.purchaseBatchNo != null">purchase_batch_no = #{purchaseRequest.purchaseBatchNo},</if>
<if test="purchaseRequest.progressRemark != null">progress_remark = #{purchaseRequest.progressRemark},</if>
<if test="purchaseRequest.remark != null">remark = #{purchaseRequest.remark},</if>
<if test="purchaseRequest.updateBy != null">update_by = #{purchaseRequest.updateBy},</if>
update_time = sysdate()
</set>
where id = #{purchaseRequest.id}
<if test="applicantId != null">and applicant_id = #{applicantId}</if>
</update>
<delete id="deletePurchaseRequestByIds">
delete from purchase_request where id in
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
<if test="applicantId != null">and applicant_id = #{applicantId}</if>
</delete>
<update id="updatePurchaseStatus">
update purchase_request
set purchase_status = #{purchaseStatus},
progress_remark = #{progressRemark},
update_by = #{updateBy}, update_time = sysdate()
where id = #{id}
</update>
</mapper>

View File

@ -0,0 +1,87 @@
import request from '@/utils/request'
export function listPurchase(query) {
return request({ url: '/warehouse/purchase/list', method: 'get', params: query })
}
export function getPurchase(id) {
return request({ url: '/warehouse/purchase/' + id, method: 'get' })
}
export function addPurchase(data) {
return request({ url: '/warehouse/purchase', method: 'post', data })
}
export function tableAddPurchase(data) {
return request({ url: '/warehouse/purchase/table', method: 'post', data })
}
export function smartImportPurchase(data) {
return request({
url: '/warehouse/purchase/smart-import',
method: 'post',
data,
headers: { 'Content-Type': 'multipart/form-data', repeatSubmit: false },
timeout: 300000
})
}
export function updatePurchase(data) {
return request({ url: '/warehouse/purchase', method: 'put', data })
}
export function updatePurchaseStatus(id, data) {
return request({ url: '/warehouse/purchase/' + id + '/status', method: 'put', data })
}
export function listPurchaseArrival(requestId) {
return request({ url: '/warehouse/purchase/arrival/' + requestId, method: 'get' })
}
export function addPurchaseArrival(data) {
return request({ url: '/warehouse/purchase/arrival', method: 'post', data })
}
export function acceptPurchaseArrival(id, data) {
return request({ url: '/warehouse/purchase/arrival/' + id + '/accept', method: 'put', data })
}
export function revokePurchaseArrival(id, data) {
return request({ url: '/warehouse/purchase/arrival/' + id + '/revoke', method: 'put', data })
}
export function delPurchaseArrival(id) {
return request({ url: '/warehouse/purchase/arrival/' + id, method: 'delete' })
}
export function listPurchaseBatch(query) {
return request({ url: '/warehouse/purchase/batch/list', method: 'get', params: query })
}
export function optionPurchaseBatch(keyword) {
return request({ url: '/warehouse/purchase/batch/options', method: 'get', params: { keyword } })
}
export function filterOptionPurchaseBatch(keyword) {
return request({ url: '/warehouse/purchase/batch/filter-options', method: 'get', params: { keyword } })
}
export function addPurchaseBatch(data) {
return request({ url: '/warehouse/purchase/batch', method: 'post', data })
}
export function updatePurchaseBatch(data) {
return request({ url: '/warehouse/purchase/batch', method: 'put', data })
}
export function assignPurchaseBatch(data) {
return request({ url: '/warehouse/purchase/batch/assign', method: 'put', data })
}
export function delPurchaseBatch(id) {
return request({ url: '/warehouse/purchase/batch/' + id, method: 'delete' })
}
export function delPurchase(ids) {
return request({ url: '/warehouse/purchase/' + ids, method: 'delete' })
}

View File

@ -1,10 +1,10 @@
<template> <template>
<el-dialog :title="title" :visible.sync="visible" :width="width" append-to-body @close="handleClose"> <el-dialog :title="title" :visible.sync="visible" :width="width" append-to-body @close="handleClose">
<el-upload ref="uploadRef" :limit="1" accept=".xlsx, .xls" :headers="headers" :action="uploadUrl" :disabled="isUploading" :on-progress="handleProgress" :on-success="handleSuccess" :auto-upload="false" drag> <el-upload ref="uploadRef" :limit="1" accept=".xlsx, .xls" :headers="headers" :action="uploadUrl" :disabled="isUploading" :on-progress="handleProgress" :on-success="handleSuccess" :on-error="handleError" :auto-upload="false" drag>
<i class="el-icon-upload"></i> <i class="el-icon-upload"></i>
<div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div> <div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
<div class="el-upload__tip text-center" slot="tip"> <div class="el-upload__tip text-center" slot="tip">
<div class="el-upload__tip" slot="tip"> <div v-if="showUpdateSupport" class="el-upload__tip" slot="tip">
<el-checkbox v-model="updateSupport"> {{ updateSupportLabel }} </el-checkbox> <el-checkbox v-model="updateSupport"> {{ updateSupportLabel }} </el-checkbox>
</div> </div>
<span>仅允许导入xls、xlsx格式文件。</span> <span>仅允许导入xls、xlsx格式文件。</span>
@ -52,6 +52,10 @@ export default {
updateSupportLabel: { updateSupportLabel: {
type: String, type: String,
default: '是否更新已经存在的数据' default: '是否更新已经存在的数据'
},
showUpdateSupport: {
type: Boolean,
default: true
} }
}, },
data() { data() {
@ -99,14 +103,29 @@ export default {
}, },
// 上传成功 // 上传成功
handleSuccess(response) { handleSuccess(response) {
this.visible = false
this.isUploading = false this.isUploading = false
if (!response || response.code !== 200) {
this.$modal.msgError((response && response.msg) || '导入失败,请检查文件内容。')
return
}
this.visible = false
if (this.$refs.uploadRef) { if (this.$refs.uploadRef) {
this.$refs.uploadRef.clearFiles() this.$refs.uploadRef.clearFiles()
} }
this.$alert("<div style='overflow:auto;overflow-x:hidden;max-height:70vh;padding:10px 20px 0;'>" + response.msg + '</div>', '导入结果', { dangerouslyUseHTMLString: true }) this.$alert("<div style='overflow:auto;overflow-x:hidden;max-height:70vh;padding:10px 20px 0;'>" + response.msg + '</div>', '导入结果', { dangerouslyUseHTMLString: true })
this.$emit('success') this.$emit('success')
}, },
handleError(error) {
this.isUploading = false
let message = '导入失败,请检查网络、权限或文件格式。'
if (error && error.message) {
try {
const response = JSON.parse(error.message)
message = response.msg || message
} catch (ignored) {}
}
this.$modal.msgError(message)
},
// 提交上传 // 提交上传
handleSubmit() { handleSubmit() {
const files = this.$refs.uploadRef.uploadFiles const files = this.$refs.uploadRef.uploadFiles

View File

@ -6,6 +6,8 @@
<p>面向库区、产品、入库、出库和盘库的日常库存作业台。</p> <p>面向库区、产品、入库、出库和盘库的日常库存作业台。</p>
</div> </div>
<div class="hero-actions"> <div class="hero-actions">
<el-button type="primary" icon="el-icon-top-right" @click="$router.push('/warehouse/outRecord')" v-hasPermi="['warehouse:out:list']">出库登记</el-button>
<el-button type="success" icon="el-icon-shopping-cart-2" @click="$router.push('/purchase')" v-hasPermi="['warehouse:purchase:list']">采购申请</el-button>
<el-button type="primary" icon="el-icon-upload2" @click="$router.push('/warehouse/batch-import')">批量导入</el-button> <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> <el-button type="primary" plain icon="el-icon-mobile-phone" @click="$router.push('/mobile')">移动端入口</el-button>
</div> </div>
@ -209,11 +211,14 @@ export default {
return return
} }
this.agent.loading = true this.agent.loading = true
analyzeAgent({ text, contextResults: this.agent.contextResults }).then(res => { const usesContext = this.hasExplicitAgentReference(text)
analyzeAgent({ text, contextResults: usesContext ? this.agent.contextResults : [] }).then(res => {
this.agent.plan = res.data this.agent.plan = res.data
this.agent.text = '' this.agent.text = ''
if (res.data && res.data.results && res.data.results.length) { if (res.data && res.data.results && res.data.results.length) {
this.agent.contextResults = res.data.results this.agent.contextResults = res.data.results
} else if (!usesContext) {
this.agent.contextResults = []
} }
}).finally(() => { }).finally(() => {
this.agent.loading = false this.agent.loading = false
@ -229,6 +234,7 @@ export default {
this.$modal.msgSuccess(res.data.message || '操作完成') this.$modal.msgSuccess(res.data.message || '操作完成')
this.loadDashboard() this.loadDashboard()
this.agent.text = '' this.agent.text = ''
this.agent.contextResults = []
}).finally(() => { }).finally(() => {
this.agent.executing = false this.agent.executing = false
}) })
@ -236,6 +242,10 @@ export default {
clearAgent() { clearAgent() {
this.agent.text = '' this.agent.text = ''
this.agent.plan = null this.agent.plan = null
this.agent.contextResults = []
},
hasExplicitAgentReference(text) {
return /第\s*[0-9一二两三四五六七八九十]+\s*(个|条|项)?|上一个|上一条|刚才(?:的)?(?:那个|那条|那项)|刚刚(?:的)?(?:那个|那条|那项)|这个|那个|它|上述|上面(?:的)?/.test(text || '')
}, },
canSelectAgentResult(plan) { canSelectAgentResult(plan) {
return plan && ['create_out_record', 'create_in_record', 'create_stock_check'].includes(plan.action) && plan.results && plan.results.length return plan && ['create_out_record', 'create_in_record', 'create_stock_check'].includes(plan.action) && plan.results && plan.results.length
@ -342,6 +352,7 @@ export default {
} }
.hero-actions { .hero-actions {
display: flex; display: flex;
flex-wrap: wrap;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
flex-shrink: 0; flex-shrink: 0;

View File

@ -643,11 +643,14 @@ export default {
return return
} }
this.agent.loading = true this.agent.loading = true
analyzeAgent({ text, contextResults: this.agent.contextResults }).then(res => { const usesContext = this.hasExplicitAgentReference(text)
analyzeAgent({ text, contextResults: usesContext ? this.agent.contextResults : [] }).then(res => {
this.agent.plan = res.data this.agent.plan = res.data
this.agent.text = '' this.agent.text = ''
if (res.data && res.data.results && res.data.results.length) { if (res.data && res.data.results && res.data.results.length) {
this.agent.contextResults = res.data.results this.agent.contextResults = res.data.results
} else if (!usesContext) {
this.agent.contextResults = []
} }
this.autoFillAgentForm(res.data) this.autoFillAgentForm(res.data)
}).finally(() => { }).finally(() => {
@ -664,6 +667,7 @@ export default {
this.$modal.msgSuccess(res.data.message || '操作完成') this.$modal.msgSuccess(res.data.message || '操作完成')
this.refreshAll() this.refreshAll()
this.agent.text = '' this.agent.text = ''
this.agent.contextResults = []
}).finally(() => { }).finally(() => {
this.agent.executing = false this.agent.executing = false
}) })
@ -672,6 +676,10 @@ export default {
this.agent.text = '' this.agent.text = ''
this.agent.plan = null this.agent.plan = null
this.agent.pendingNextPlan = null this.agent.pendingNextPlan = null
this.agent.contextResults = []
},
hasExplicitAgentReference(text) {
return /第\s*[0-9一二两三四五六七八九十]+\s*(个|条|项)?|上一个|上一条|刚才(?:的)?(?:那个|那条|那项)|刚刚(?:的)?(?:那个|那条|那项)|这个|那个|它|上述|上面(?:的)?/.test(text || '')
}, },
canSelectAgentResult(plan) { canSelectAgentResult(plan) {
return plan && ['create_out_record', 'create_in_record', 'create_stock_check'].includes(plan.action) && plan.results && plan.results.length return plan && ['create_out_record', 'create_in_record', 'create_stock_check'].includes(plan.action) && plan.results && plan.results.length

View File

@ -49,7 +49,13 @@
<el-table v-loading="loading" :data="recordList" @selection-change="handleSelectionChange"> <el-table v-loading="loading" :data="recordList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" /> <el-table-column type="selection" width="55" align="center" />
<el-table-column label="入库单号" align="center" prop="inNo" min-width="150" /> <el-table-column label="入库单号" align="center" prop="inNo" min-width="150" />
<el-table-column label="库区" align="center" prop="areaName" min-width="110" /> <el-table-column label="库区" align="center" prop="areaName" min-width="110">
<template slot-scope="scope">
<span v-if="scope.row.areaId">{{ scope.row.areaName || scope.row.areaCode }}</span>
<el-tag v-else type="warning" size="mini">待分配</el-tag>
</template>
</el-table-column>
<el-table-column label="来源单号" align="center" prop="sourceNo" min-width="170" show-overflow-tooltip />
<el-table-column label="产品信息" min-width="280"> <el-table-column label="产品信息" min-width="280">
<template slot-scope="scope"> <template slot-scope="scope">
<div class="product-name">{{ scope.row.productName || '-' }}</div> <div class="product-name">{{ scope.row.productName || '-' }}</div>
@ -77,7 +83,7 @@
<template slot-scope="scope"> <template slot-scope="scope">
<el-button v-if="scope.row.inStatus === 0" size="mini" type="text" icon="el-icon-check" @click="handleConfirm(scope.row)" v-hasPermi="['warehouse:in:confirm']">入库</el-button> <el-button v-if="scope.row.inStatus === 0" size="mini" type="text" icon="el-icon-check" @click="handleConfirm(scope.row)" v-hasPermi="['warehouse:in:confirm']">入库</el-button>
<el-button v-if="scope.row.inStatus === 0" size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)" v-hasPermi="['warehouse:in:edit']">修改</el-button> <el-button v-if="scope.row.inStatus === 0" size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)" v-hasPermi="['warehouse:in:edit']">修改</el-button>
<el-button v-if="scope.row.inStatus === 0" size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)" v-hasPermi="['warehouse:in:remove']">删除</el-button> <el-button v-if="scope.row.inStatus === 0 && !scope.row.arrivalId" size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)" v-hasPermi="['warehouse:in:remove']">删除</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@ -86,14 +92,14 @@
<el-dialog :title="title" :visible.sync="open" width="620px" append-to-body> <el-dialog :title="title" :visible.sync="open" width="620px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="100px"> <el-form ref="form" :model="form" :rules="rules" label-width="100px">
<el-form-item label="入库单号" prop="inNo"><el-input v-model="form.inNo" placeholder="不填则自动生成" /></el-form-item> <el-form-item label="入库单号" prop="inNo"><el-input v-model="form.inNo" :disabled="!!form.arrivalId" placeholder="不填则自动生成" /></el-form-item>
<el-form-item label="库区" prop="areaId"> <el-form-item label="库区" prop="areaId">
<el-select v-model="form.areaId" placeholder="请选择库区" filterable> <el-select v-model="form.areaId" placeholder="请选择库区" filterable>
<el-option v-for="item in areaOptions" :key="item.id" :label="areaLabel(item)" :value="item.id" /> <el-option v-for="item in areaOptions" :key="item.id" :label="areaLabel(item)" :value="item.id" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="产品" prop="productId"> <el-form-item label="产品" prop="productId">
<el-select v-model="form.productId" placeholder="请选择产品" filterable> <el-select v-model="form.productId" :disabled="!!form.arrivalId" placeholder="请选择产品" filterable>
<el-option v-for="item in productOptions" :key="item.id" :label="productLabel(item)" :value="item.id" /> <el-option v-for="item in productOptions" :key="item.id" :label="productLabel(item)" :value="item.id" />
</el-select> </el-select>
</el-form-item> </el-form-item>
@ -103,11 +109,11 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="入库类型" prop="inType"> <el-form-item label="入库类型" prop="inType">
<el-select v-model="form.inType" placeholder="请选择入库类型"> <el-select v-model="form.inType" :disabled="!!form.arrivalId" placeholder="请选择入库类型">
<el-option v-for="dict in dict.type.wms_in_type" :key="dict.value" :label="dict.label" :value="Number(dict.value)" /> <el-option v-for="dict in dict.type.wms_in_type" :key="dict.value" :label="dict.label" :value="Number(dict.value)" />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="入库数量" prop="inNum"><el-input-number v-model="form.inNum" :min="0.001" :precision="3" :step="1" controls-position="right" /></el-form-item> <el-form-item label="入库数量" prop="inNum"><el-input-number v-model="form.inNum" :disabled="!!form.arrivalId" :min="0.001" :precision="3" :step="1" controls-position="right" /></el-form-item>
<el-form-item label="备注" prop="remark"><el-input v-model="form.remark" type="textarea" placeholder="请输入备注" /></el-form-item> <el-form-item label="备注" prop="remark"><el-input v-model="form.remark" type="textarea" placeholder="请输入备注" /></el-form-item>
</el-form> </el-form>
<div slot="footer" class="dialog-footer"> <div slot="footer" class="dialog-footer">
@ -230,6 +236,11 @@ export default {
}) })
}, },
handleConfirm(row) { handleConfirm(row) {
if (!row.areaId || !row.inOperator) {
this.$modal.msgWarning('请先完善库区和入库人')
this.handleUpdate(row)
return
}
this.$modal.confirm('是否确认入库单号为"' + row.inNo + '"的记录?').then(() => confirmInRecord(row.id)).then(() => { this.$modal.confirm('是否确认入库单号为"' + row.inNo + '"的记录?').then(() => confirmInRecord(row.id)).then(() => {
this.getList() this.getList()
this.$modal.msgSuccess('入库成功') this.$modal.msgSuccess('入库成功')

View File

@ -0,0 +1,945 @@
<template>
<div class="app-container">
<el-form ref="queryForm" :model="queryParams" size="small" :inline="true" v-show="showSearch" label-width="80px">
<el-form-item label="物料" prop="materialName">
<el-input v-model="queryParams.materialName" placeholder="名称、参数或物料编码" clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="采购状态" prop="purchaseStatus">
<el-select v-model="queryParams.purchaseStatus" placeholder="请选择状态" clearable>
<el-option v-for="item in statusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="采购批次" prop="purchaseBatchNo">
<el-select v-model="queryParams.purchaseBatchNo" filterable remote clearable reserve-keyword :remote-method="searchBatchFilterOptions" :loading="batchFilterLoading" placeholder="输入编号或名称搜索批次">
<el-option v-for="item in batchFilterOptions" :key="item.id" :label="batchLabel(item)" :value="item.batchNo" />
</el-select>
</el-form-item>
<el-form-item label="申请人" prop="applicantName">
<el-select v-model="queryParams.applicantName" filterable clearable placeholder="请选择申请人">
<el-option v-for="item in userOptions" :key="item.userId" :label="userLabel(item)" :value="item.userName" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd" v-hasPermi="['warehouse:purchase:add', 'warehouse:purchase:manage']">新增申请</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="primary" plain icon="el-icon-s-grid" size="mini" @click="handleGridAdd" v-hasPermi="['warehouse:purchase:add', 'warehouse:purchase:manage']">表格录入</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="primary" plain icon="el-icon-magic-stick" size="mini" @click="handleSmartImport" v-hasPermi="['warehouse:purchase:add', 'warehouse:purchase:manage']">智能导入</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="success" plain icon="el-icon-edit" size="mini" :disabled="single" @click="handleUpdate" v-hasPermi="['warehouse:purchase:edit', 'warehouse:purchase:manage']">修改</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="danger" plain icon="el-icon-delete" size="mini" :disabled="multiple" @click="handleDelete" v-hasPermi="['warehouse:purchase:remove', 'warehouse:purchase:manage']">删除</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="warning" plain icon="el-icon-upload2" size="mini" @click="handleImport" v-hasPermi="['warehouse:purchase:import', 'warehouse:purchase:manage']">导入</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="warning" plain icon="el-icon-download" size="mini" @click="handleExport" v-hasPermi="['warehouse:purchase:export', 'warehouse:purchase:manage']">导出</el-button>
</el-col>
<el-col :span="1.5">
<el-button v-if="canManageAll" type="primary" plain icon="el-icon-sort" size="mini" :disabled="multiple" @click="handleAssignBatch" v-hasPermi="['warehouse:purchase:manage']">分配/转批次</el-button>
</el-col>
<el-col :span="1.5">
<el-button v-if="canManageAll" type="primary" plain icon="el-icon-s-operation" size="mini" @click="handleBatchManage" v-hasPermi="['warehouse:purchase:manage']">批次管理</el-button>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
</el-row>
<el-table v-loading="loading" :data="purchaseList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" :selectable="canOperate" />
<el-table-column label="申请人" prop="applicantName" width="110" />
<el-table-column label="物料名称" prop="materialName" min-width="150" show-overflow-tooltip />
<el-table-column label="品牌" prop="materialBrand" width="110" show-overflow-tooltip />
<el-table-column label="关联库房物料" min-width="170" show-overflow-tooltip>
<template slot-scope="scope">
<span v-if="scope.row.productId">{{ scope.row.productCode }} / {{ scope.row.productName }}</span>
<el-tag v-else size="mini" type="info">未关联</el-tag>
</template>
</el-table-column>
<el-table-column label="类别" prop="category" width="110" show-overflow-tooltip />
<el-table-column label="参数规格" prop="materialParams" min-width="140" show-overflow-tooltip />
<el-table-column label="采购链接" width="90" align="center">
<template slot-scope="scope">
<el-link v-if="scope.row.purchaseLink" :href="scope.row.purchaseLink" target="_blank" rel="noopener noreferrer" type="primary">打开</el-link>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="数量" width="110" align="right">
<template slot-scope="scope">{{ scope.row.quantity }} {{ scope.row.unit || '' }}</template>
</el-table-column>
<el-table-column label="到货验收" min-width="190">
<template slot-scope="scope">
<div>已验收 {{ scope.row.acceptedQuantity || 0 }} / {{ scope.row.quantity }}</div>
<div class="arrival-summary" v-if="Number(scope.row.pendingArrivalQuantity) > 0">待验收 {{ scope.row.pendingArrivalQuantity }} {{ scope.row.unit || '' }}</div>
</template>
</el-table-column>
<el-table-column label="采购状态" width="100" align="center">
<template slot-scope="scope"><el-tag :type="statusType(scope.row.purchaseStatus)" size="mini">{{ statusLabel(scope.row.purchaseStatus) }}</el-tag></template>
</el-table-column>
<el-table-column label="采购批次" prop="purchaseBatchNo" width="130" show-overflow-tooltip />
<el-table-column label="期望日期" prop="expectedDate" width="110" />
<el-table-column label="创建时间" prop="createTime" width="160">
<template slot-scope="scope">{{ parseTime(scope.row.createTime) }}</template>
</el-table-column>
<el-table-column label="操作" align="center" width="285" fixed="right" class-name="small-padding fixed-width">
<template slot-scope="scope">
<el-button v-if="canOperate(scope.row)" size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)" v-hasPermi="['warehouse:purchase:edit', 'warehouse:purchase:manage']">修改</el-button>
<el-button size="mini" type="text" icon="el-icon-box" @click="handleArrival(scope.row)" v-hasPermi="['warehouse:purchase:list', 'warehouse:purchase:manage']">到货</el-button>
<el-button v-if="canOperate(scope.row) && Number(scope.row.acceptedQuantity || 0) === 0" size="mini" type="text" icon="el-icon-refresh" @click="handleStatus(scope.row)" v-hasPermi="['warehouse:purchase:edit', 'warehouse:purchase:manage']">状态</el-button>
<el-button v-if="canManageAll" size="mini" type="text" icon="el-icon-sort" @click="handleAssignBatch(scope.row)" v-hasPermi="['warehouse:purchase:manage']">转批次</el-button>
<el-button v-if="canOperate(scope.row)" size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)" v-hasPermi="['warehouse:purchase:remove', 'warehouse:purchase:manage']">删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
<el-dialog :title="title" :visible.sync="open" width="720px" custom-class="purchase-dialog" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="105px">
<el-row :gutter="16">
<el-col :span="24">
<el-form-item label="已有产品" prop="productId">
<el-select v-model="form.productId" filterable clearable placeholder="选择后自动填充;不选择则按下方信息复用或新增" @change="handleExistingProductChange">
<el-option v-for="item in productOptions" :key="item.id" :label="productLabel(item)" :value="item.id" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="产品名称" prop="materialName">
<el-select v-model="form.materialName" filterable clearable allow-create default-first-option placeholder="请选择或输入产品名称" @change="handleMaterialNameChange">
<el-option v-for="item in productNameOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="品牌" prop="materialBrand">
<el-select v-model="form.materialBrand" filterable clearable allow-create default-first-option placeholder="请选择或输入品牌" @change="syncProductId">
<el-option v-for="item in brandOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="分类" prop="category">
<el-select v-model="form.category" filterable clearable allow-create default-first-option placeholder="请选择或输入分类" @change="syncProductId">
<el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="规格型号" prop="materialParams">
<el-select v-model="form.materialParams" filterable clearable allow-create default-first-option placeholder="请选择或输入规格型号" @change="syncProductId">
<el-option v-for="item in specOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="单位" prop="unit">
<el-select v-model="form.unit" filterable clearable allow-create default-first-option placeholder="单位" @change="syncProductId">
<el-option v-for="item in unitOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12"><el-form-item label="需求数量" prop="quantity"><el-input-number v-model="form.quantity" :min="0.001" :precision="3" :controls="false" /></el-form-item></el-col>
<el-col :span="12"><el-form-item label="期望到货日期" prop="expectedDate"><el-date-picker v-model="form.expectedDate" type="date" value-format="yyyy-MM-dd" placeholder="请选择日期" /></el-form-item></el-col>
<el-col :span="24"><el-form-item label="产品备注" prop="productRemark"><el-input v-model="form.productRemark" placeholder="新建产品时写入产品信息,复用已有产品时不覆盖" /></el-form-item></el-col>
<el-col :span="24"><el-form-item label="采购链接" prop="purchaseLink"><el-input v-model="form.purchaseLink" placeholder="请输入商品或供应商链接" /></el-form-item></el-col>
<el-col :span="24"><el-form-item label="申请备注" prop="remark"><el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入用途、预算等补充信息" /></el-form-item></el-col>
</el-row>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitForm">确定</el-button>
<el-button @click="cancel">取消</el-button>
</div>
</el-dialog>
<el-dialog title="分配或转移采购批次" :visible.sync="assignOpen" width="520px" append-to-body>
<el-form ref="assignForm" :model="assignForm" :rules="assignRules" label-width="95px">
<el-form-item label="目标批次" prop="batchId">
<el-select v-model="assignForm.batchId" filterable remote :remote-method="searchBatchOptions" :loading="batchOptionLoading" placeholder="输入编号或名称搜索可用批次">
<el-option v-for="item in batchOptions" :key="item.id" :label="batchLabel(item)" :value="item.id" />
</el-select>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitAssignBatch">确定</el-button>
<el-button @click="assignOpen = false">取消</el-button>
</div>
</el-dialog>
<el-dialog title="采购批次管理" :visible.sync="batchOpen" width="820px" append-to-body>
<el-form ref="batchQueryForm" :model="batchQuery" size="small" :inline="true" label-width="70px">
<el-form-item label="批次" prop="batchNo"><el-input v-model="batchQuery.batchNo" clearable placeholder="批次编号" @keyup.enter.native="handleBatchQuery" /></el-form-item>
<el-form-item label="名称" prop="batchName"><el-input v-model="batchQuery.batchName" clearable placeholder="批次名称" @keyup.enter.native="handleBatchQuery" /></el-form-item>
<el-form-item label="状态" prop="purchaseStatus">
<el-select v-model="batchQuery.purchaseStatus" clearable placeholder="全部状态">
<el-option v-for="item in batchStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleBatchQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetBatchQuery">重置</el-button>
</el-form-item>
</el-form>
<el-button type="primary" plain icon="el-icon-plus" size="mini" class="mb8" @click="handleAddBatch">新增批次</el-button>
<el-table v-loading="batchLoading" :data="batchList" max-height="420">
<el-table-column label="批次编号" prop="batchNo" min-width="140" />
<el-table-column label="批次名称" prop="batchName" min-width="150" show-overflow-tooltip />
<el-table-column label="批次状态" width="100" align="center">
<template slot-scope="scope"><el-tag :type="batchStatusType(scope.row.purchaseStatus)" size="mini">{{ batchStatusLabel(scope.row.purchaseStatus) }}</el-tag></template>
</el-table-column>
<el-table-column label="产品数" prop="requestCount" width="80" align="center" />
<el-table-column label="批次说明" prop="progressRemark" min-width="160" show-overflow-tooltip />
<el-table-column label="操作" width="130" align="center">
<template slot-scope="scope">
<el-button type="text" icon="el-icon-edit" @click="handleEditBatch(scope.row)">修改</el-button>
<el-button type="text" icon="el-icon-delete" @click="handleDeleteBatch(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="batchTotal > 0" :total="batchTotal" :page.sync="batchQuery.pageNum" :limit.sync="batchQuery.pageSize" @pagination="getBatchList" />
</el-dialog>
<el-dialog :title="batchTitle" :visible.sync="batchFormOpen" width="540px" append-to-body>
<el-form ref="batchForm" :model="batchForm" :rules="batchRules" label-width="95px">
<el-form-item label="批次编号" prop="batchNo"><el-input v-model="batchForm.batchNo" placeholder="如:PC202609-01" /></el-form-item>
<el-form-item label="批次名称" prop="batchName"><el-input v-model="batchForm.batchName" placeholder="请输入批次名称" /></el-form-item>
<el-form-item label="批次状态" prop="purchaseStatus">
<el-select v-model="batchForm.purchaseStatus" placeholder="请选择批次状态">
<el-option v-for="item in batchStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="批次说明" prop="progressRemark"><el-input v-model="batchForm.progressRemark" type="textarea" :rows="3" placeholder="请输入批次层面的说明" /></el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitBatch">确定</el-button>
<el-button @click="batchFormOpen = false">取消</el-button>
</div>
</el-dialog>
<el-dialog title="采购申请智能导入" :visible.sync="smartImportOpen" width="560px" custom-class="purchase-smart-dialog" append-to-body @closed="handleSmartImportClosed">
<el-upload ref="smartUpload" drag action="#" :auto-upload="false" :limit="1" :file-list="smartFileList" accept=".xls,.xlsx,.png,.jpg,.jpeg,.webp" :on-change="handleSmartFileChange" :on-remove="handleSmartFileRemove">
<i class="el-icon-upload" />
<div class="el-upload__text">拖入或选择采购清单</div>
<div slot="tip" class="el-upload__tip">支持 Excel、表格截图和清单图片,分析结果会先进入可编辑表格</div>
</el-upload>
<div slot="footer" class="dialog-footer">
<el-button type="primary" icon="el-icon-magic-stick" :loading="smartImportLoading" :disabled="!smartFile" @click="submitSmartImport">开始分析</el-button>
<el-button @click="smartImportOpen = false">取消</el-button>
</div>
</el-dialog>
<el-dialog title="采购申请表格录入" :visible.sync="gridOpen" width="95%" custom-class="purchase-grid-dialog" append-to-body>
<div class="grid-toolbar mb8">
<el-button type="primary" plain icon="el-icon-plus" size="mini" @click="addGridRow">新增行</el-button>
<el-button icon="el-icon-delete" size="mini" :disabled="gridRows.length <= 1" @click="removeEmptyGridRows">清理空行</el-button>
</div>
<el-table :data="gridRows" border height="470" class="purchase-grid">
<el-table-column type="index" label="#" width="48" fixed="left" />
<el-table-column v-if="gridRows.some(row => row.smartTip)" label="识别提示" min-width="210">
<template slot-scope="scope"><span class="smart-tip">{{ scope.row.smartTip || '-' }}</span></template>
</el-table-column>
<el-table-column label="关联物料编码" min-width="150">
<template slot-scope="scope">
<el-select v-model="scope.row.productCode" filterable clearable allow-create default-first-option @change="handleGridCodeChange(scope.row)" @paste.native="handleGridPaste($event, scope.$index, 0)">
<el-option v-for="item in productCodeOptions" :key="item.id" :label="item.productCode + ' / ' + item.productName" :value="item.productCode" />
</el-select>
</template>
</el-table-column>
<el-table-column label="物料名称" min-width="170">
<template slot-scope="scope"><el-select v-model="scope.row.materialName" filterable allow-create default-first-option @change="handleGridNameChange(scope.row)" @paste.native="handleGridPaste($event, scope.$index, 1)"><el-option v-for="item in productNameOptions" :key="item" :label="item" :value="item" /></el-select></template>
</el-table-column>
<el-table-column label="品牌" min-width="130">
<template slot-scope="scope"><el-select v-model="scope.row.materialBrand" filterable clearable allow-create default-first-option @paste.native="handleGridPaste($event, scope.$index, 2)"><el-option v-for="item in brandOptions" :key="item" :label="item" :value="item" /></el-select></template>
</el-table-column>
<el-table-column label="分类" min-width="130">
<template slot-scope="scope"><el-select v-model="scope.row.category" filterable clearable allow-create default-first-option @paste.native="handleGridPaste($event, scope.$index, 3)"><el-option v-for="item in categoryOptions" :key="item" :label="item" :value="item" /></el-select></template>
</el-table-column>
<el-table-column label="参数规格" min-width="160">
<template slot-scope="scope"><el-select v-model="scope.row.materialParams" filterable clearable allow-create default-first-option @paste.native="handleGridPaste($event, scope.$index, 4)"><el-option v-for="item in specOptions" :key="item" :label="item" :value="item" /></el-select></template>
</el-table-column>
<el-table-column label="单位" min-width="100">
<template slot-scope="scope"><el-select v-model="scope.row.unit" filterable clearable allow-create default-first-option @paste.native="handleGridPaste($event, scope.$index, 5)"><el-option v-for="item in unitOptions" :key="item" :label="item" :value="item" /></el-select></template>
</el-table-column>
<el-table-column label="需求数量" min-width="110">
<template slot-scope="scope"><el-input v-model="scope.row.quantity" @paste.native="handleGridPaste($event, scope.$index, 6)" /></template>
</el-table-column>
<el-table-column label="期望到货日期" min-width="140">
<template slot-scope="scope"><el-date-picker v-model="scope.row.expectedDate" type="date" value-format="yyyy-MM-dd" @paste.native="handleGridPaste($event, scope.$index, 7)" /></template>
</el-table-column>
<el-table-column label="采购链接" min-width="210">
<template slot-scope="scope"><el-input v-model="scope.row.purchaseLink" @paste.native="handleGridPaste($event, scope.$index, 8)" /></template>
</el-table-column>
<el-table-column label="产品备注" min-width="180">
<template slot-scope="scope"><el-input v-model="scope.row.productRemark" @paste.native="handleGridPaste($event, scope.$index, 9)" /></template>
</el-table-column>
<el-table-column label="申请备注" min-width="180">
<template slot-scope="scope"><el-input v-model="scope.row.remark" @paste.native="handleGridPaste($event, scope.$index, 10)" /></template>
</el-table-column>
<el-table-column label="操作" width="55" fixed="right" align="center">
<template slot-scope="scope"><el-button type="text" icon="el-icon-delete" title="删除行" @click="removeGridRow(scope.$index)" /></template>
</el-table-column>
</el-table>
<div slot="footer" class="dialog-footer">
<el-button type="primary" :loading="gridSaving" @click="submitGrid">提交</el-button>
<el-button @click="gridOpen = false">取消</el-button>
</div>
</el-dialog>
<el-dialog title="采购状态" :visible.sync="statusOpen" width="480px" append-to-body>
<el-form ref="statusForm" :model="statusForm" :rules="statusRules" label-width="90px">
<el-form-item label="采购状态" prop="purchaseStatus">
<el-select v-model="statusForm.purchaseStatus">
<el-option v-for="item in manualStatusOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="进度说明" prop="progressRemark"><el-input v-model="statusForm.progressRemark" type="textarea" :rows="3" /></el-form-item>
</el-form>
<div slot="footer" class="dialog-footer"><el-button type="primary" @click="submitStatus">确定</el-button><el-button @click="statusOpen = false">取消</el-button></div>
</el-dialog>
<el-dialog title="分批到货与验收" :visible.sync="arrivalOpen" width="1100px" custom-class="arrival-dialog" append-to-body>
<div v-if="arrivalRequest" class="arrival-header">
<strong>{{ arrivalRequest.materialName }}</strong>
<span>采购 {{ arrivalRequest.quantity }} {{ arrivalRequest.unit || '' }}</span>
<span>已验收 {{ arrivalRequest.acceptedQuantity || 0 }}</span>
<span>待验收 {{ arrivalRequest.pendingArrivalQuantity || 0 }}</span>
<span>可登记 {{ arrivalRequest.remainingQuantity || 0 }}</span>
</div>
<el-form v-if="arrivalRequest && canOperate(arrivalRequest)" ref="arrivalForm" :model="arrivalForm" :rules="arrivalRules" :inline="true" size="small" class="arrival-form">
<el-form-item label="本次到货" prop="arrivalQuantity"><el-input-number v-model="arrivalForm.arrivalQuantity" :min="0.001" :max="Number(arrivalRequest.remainingQuantity || 0)" :precision="3" :controls="false" /></el-form-item>
<el-form-item label="到货日期" prop="arrivalDate"><el-date-picker v-model="arrivalForm.arrivalDate" type="date" value-format="yyyy-MM-dd" /></el-form-item>
<el-form-item label="备注" prop="remark"><el-input v-model="arrivalForm.remark" /></el-form-item>
<el-form-item><el-button type="primary" :disabled="Number(arrivalRequest.remainingQuantity) <= 0" @click="submitArrival">登记到货</el-button></el-form-item>
</el-form>
<el-table v-loading="arrivalLoading" :data="arrivalList" border class="arrival-table" max-height="440">
<el-table-column label="到货单号" prop="arrivalNo" min-width="175" />
<el-table-column label="到货日期" prop="arrivalDate" width="110" />
<el-table-column label="数量" prop="arrivalQuantity" width="100" align="right" />
<el-table-column label="验收状态" width="100" align="center"><template slot-scope="scope"><el-tag :type="acceptanceType(scope.row.acceptanceStatus)" size="mini">{{ acceptanceLabel(scope.row.acceptanceStatus) }}</el-tag></template></el-table-column>
<el-table-column label="到货备注" prop="remark" min-width="130" show-overflow-tooltip />
<el-table-column label="验收说明" prop="acceptanceRemark" min-width="130" show-overflow-tooltip />
<el-table-column label="撤销说明" prop="revokeRemark" min-width="130" show-overflow-tooltip />
<el-table-column label="验收人" prop="acceptedBy" width="100" />
<el-table-column label="操作" width="190" align="center" fixed="right">
<template slot-scope="scope">
<template v-if="scope.row.acceptanceStatus === 0 && arrivalRequest && canOperate(arrivalRequest)">
<el-button type="text" icon="el-icon-check" @click="handleAcceptance(scope.row, 1)">通过</el-button>
<el-button type="text" icon="el-icon-close" @click="handleAcceptance(scope.row, 2)">拒收</el-button>
<el-button type="text" icon="el-icon-delete" @click="handleDeleteArrival(scope.row)">删除</el-button>
</template>
<el-button v-else-if="arrivalRequest && canOperate(arrivalRequest)" type="text" icon="el-icon-refresh-left" @click="handleRevokeAcceptance(scope.row)">撤销验收</el-button>
</template>
</el-table-column>
</el-table>
</el-dialog>
<excel-import-dialog ref="purchaseImport" title="采购申请导入" action="/warehouse/purchase/importData" template-action="/warehouse/purchase/importTemplate" template-file-name="purchase_request_template" :show-update-support="false" @success="handleImportSuccess" />
</div>
</template>
<script>
import { listPurchase, getPurchase, addPurchase, tableAddPurchase, smartImportPurchase, updatePurchase, updatePurchaseStatus, delPurchase, listPurchaseArrival, addPurchaseArrival, acceptPurchaseArrival, revokePurchaseArrival, delPurchaseArrival, listPurchaseBatch, optionPurchaseBatch, filterOptionPurchaseBatch, addPurchaseBatch, updatePurchaseBatch, assignPurchaseBatch, delPurchaseBatch } from '@/api/warehouse/purchase'
import { optionselectProduct } from '@/api/warehouse/product'
import { optionselectUser } from '@/api/warehouse/user'
import ExcelImportDialog from '@/components/ExcelImportDialog'
export default {
name: 'PurchaseRequest',
components: { ExcelImportDialog },
data() {
return {
loading: true,
ids: [],
single: true,
multiple: true,
showSearch: true,
total: 0,
purchaseList: [],
productOptions: [],
open: false,
assignOpen: false,
batchOpen: false,
batchFormOpen: false,
gridOpen: false,
gridSaving: false,
gridRows: [],
smartImportOpen: false,
smartImportLoading: false,
smartFile: null,
smartFileList: [],
smartImportResultRows: null,
gridFields: ['productCode', 'materialName', 'materialBrand', 'category', 'materialParams', 'unit', 'quantity', 'expectedDate', 'purchaseLink', 'productRemark', 'remark'],
gridHeaderAliases: {
'关联物料编码': 'productCode', '物料编码': 'productCode',
'物料名称': 'materialName', '产品名称': 'materialName',
'品牌': 'materialBrand', '类别': 'category', '分类': 'category',
'参数规格': 'materialParams', '规格型号': 'materialParams',
'单位': 'unit', '需求数量': 'quantity', '数量': 'quantity',
'期望到货日期': 'expectedDate', '期望到货': 'expectedDate',
'采购链接': 'purchaseLink', '产品备注': 'productRemark', '申请备注': 'remark'
},
statusOpen: false,
statusForm: {},
arrivalOpen: false,
arrivalLoading: false,
arrivalRequest: null,
arrivalList: [],
arrivalForm: {},
title: '',
batchTitle: '',
batchList: [],
batchOptions: [],
batchFilterOptions: [],
userOptions: [],
batchLoading: false,
batchOptionLoading: false,
batchFilterLoading: false,
batchTotal: 0,
batchQuery: { pageNum: 1, pageSize: 10, batchNo: undefined, batchName: undefined, purchaseStatus: undefined },
statusOptions: [
{ value: 0, label: '待采购', type: 'info' },
{ value: 1, label: '已下单', type: '' },
{ value: 2, label: '采购中', type: 'warning' },
{ value: 3, label: '部分到货', type: 'warning' },
{ value: 4, label: '已到齐', type: 'success' },
{ value: 5, label: '已取消', type: 'danger' }
],
batchStatusOptions: [
{ value: 1, label: '规划中', type: 'info' },
{ value: 2, label: '执行中', type: 'warning' },
{ value: 3, label: '已结束', type: 'success' },
{ value: 4, label: '已归档', type: '' },
{ value: 5, label: '已取消', type: 'danger' }
],
queryParams: { pageNum: 1, pageSize: 10, materialName: undefined, purchaseStatus: undefined, purchaseBatchNo: undefined, applicantName: undefined },
form: {},
assignForm: {},
batchForm: {},
rules: {
materialName: [{ required: true, message: '物料名称不能为空', trigger: 'blur' }],
quantity: [{ required: true, message: '需求数量不能为空', trigger: 'change' }],
purchaseLink: [{ pattern: /^https?:\/\//i, message: '链接必须以 http:// 或 https:// 开头', trigger: 'blur' }]
},
assignRules: {
batchId: [{ required: true, message: '请选择目标采购批次', trigger: 'change' }]
},
batchRules: {
batchNo: [{ required: true, message: '批次编号不能为空', trigger: 'blur' }],
purchaseStatus: [{ required: true, message: '请选择批次状态', trigger: 'change' }]
},
statusRules: { purchaseStatus: [{ required: true, message: '请选择采购状态', trigger: 'change' }] },
arrivalRules: {
arrivalQuantity: [{ required: true, message: '到货数量不能为空', trigger: 'change' }],
arrivalDate: [{ required: true, message: '到货日期不能为空', trigger: 'change' }]
}
}
},
computed: {
canManageAll() {
const permissions = this.$store.getters.permissions || []
return permissions.includes('*:*:*') || permissions.includes('warehouse:purchase:manage')
},
manualStatusOptions() { return this.statusOptions.filter(item => [0, 1, 2, 5].includes(item.value)) },
productNameOptions() { return this.getDistinctOptions('productName') },
productCodeOptions() { return this.productOptions.filter(item => item.productCode) },
brandOptions() { return this.getDistinctOptions('brand') },
categoryOptions() { return this.getDistinctOptions('category') },
specOptions() { return this.getDistinctOptions('spec') },
unitOptions() { return this.getDistinctOptions('unit') }
},
created() {
this.getList()
this.getProductOptions()
this.getUserOptions()
this.searchBatchFilterOptions('')
if (this.canManageAll) this.getBatchOptions()
},
methods: {
userLabel(user) {
const account = user.userName || ''
return user.nickName && user.nickName !== account ? `${user.nickName}(${account})` : account
},
getUserOptions() {
optionselectUser().then(response => { this.userOptions = response.data || [] })
},
searchBatchFilterOptions(keyword) {
this.batchFilterLoading = true
filterOptionPurchaseBatch(keyword || '').then(response => {
this.batchFilterOptions = response.data || []
}).finally(() => { this.batchFilterLoading = false })
},
getList() {
this.loading = true
listPurchase(this.queryParams).then(response => {
this.purchaseList = response.rows
this.total = response.total
}).finally(() => { this.loading = false })
},
reset() {
this.form = { id: undefined, productId: undefined, materialName: undefined, materialBrand: undefined, category: undefined, materialParams: undefined, productRemark: undefined, purchaseLink: undefined, quantity: 1, unit: undefined, expectedDate: undefined, remark: undefined }
this.resetForm('form')
},
cancel() { this.open = false; this.reset() },
handleQuery() { this.queryParams.pageNum = 1; this.getList() },
resetQuery() { this.resetForm('queryForm'); this.handleQuery() },
handleSelectionChange(selection) {
this.ids = selection.map(item => item.id)
this.single = selection.length !== 1
this.multiple = !selection.length
},
handleAdd() { this.reset(); this.title = '新增采购申请'; this.open = true },
handleUpdate(row) {
this.reset()
const id = row.id || this.ids[0]
getPurchase(id).then(response => {
this.form = response.data
this.title = '修改采购申请'
this.open = true
})
},
submitForm() {
this.$refs.form.validate(valid => {
if (!valid) return
const updating = !!this.form.id
const action = updating ? updatePurchase(this.form) : addPurchase(this.form)
action.then(() => {
this.$modal.msgSuccess(updating ? '修改成功' : '申请已提交')
this.open = false
this.getList()
this.getProductOptions()
})
})
},
handleDelete(row) {
const ids = row.id || this.ids
this.$modal.confirm('是否确认删除选中的采购申请?').then(() => delPurchase(ids)).then(() => {
this.$modal.msgSuccess('删除成功')
this.getList()
}).catch(() => {})
},
handleStatus(row) {
this.statusForm = { id: row.id, purchaseStatus: row.purchaseStatus, progressRemark: row.progressRemark }
this.statusOpen = true
this.$nextTick(() => this.$refs.statusForm && this.$refs.statusForm.clearValidate())
},
submitStatus() {
this.$refs.statusForm.validate(valid => {
if (!valid) return
updatePurchaseStatus(this.statusForm.id, this.statusForm).then(() => {
this.$modal.msgSuccess('采购状态已更新')
this.statusOpen = false
this.getList()
})
})
},
handleArrival(row) {
this.arrivalOpen = true
this.arrivalRequest = { ...row }
this.resetArrivalForm()
this.refreshArrival(row.id)
},
refreshArrival(requestId) {
this.arrivalLoading = true
Promise.all([getPurchase(requestId), listPurchaseArrival(requestId)]).then(([requestResponse, arrivalResponse]) => {
this.arrivalRequest = requestResponse.data
this.arrivalList = arrivalResponse.data || []
}).finally(() => { this.arrivalLoading = false })
},
resetArrivalForm() {
this.arrivalForm = { purchaseRequestId: this.arrivalRequest && this.arrivalRequest.id, arrivalQuantity: undefined, arrivalDate: this.parseTime(new Date(), '{y}-{m}-{d}'), remark: undefined }
this.$nextTick(() => this.$refs.arrivalForm && this.$refs.arrivalForm.clearValidate())
},
submitArrival() {
this.arrivalForm.purchaseRequestId = this.arrivalRequest.id
this.$refs.arrivalForm.validate(valid => {
if (!valid) return
addPurchaseArrival(this.arrivalForm).then(() => {
this.$modal.msgSuccess('到货已登记,等待验收')
this.resetArrivalForm()
this.refreshArrival(this.arrivalRequest.id)
this.getList()
})
})
},
handleAcceptance(row, status) {
if (status === 1) {
this.$modal.confirm('确认本次到货数量和物料无误并通过验收?').then(() => acceptPurchaseArrival(row.id, { acceptanceStatus: 1 })).then(() => {
this.$modal.msgSuccess('验收通过,已生成待入库记录')
this.refreshArrival(this.arrivalRequest.id)
this.getList()
}).catch(() => {})
return
}
this.$prompt('请输入拒收原因', '拒收到货', { inputValidator: value => !!(value && value.trim()) || '拒收原因不能为空' }).then(({ value }) => {
return acceptPurchaseArrival(row.id, { acceptanceStatus: 2, acceptanceRemark: value })
}).then(() => {
this.$modal.msgSuccess('已拒收,本次数量可重新登记')
this.refreshArrival(this.arrivalRequest.id)
this.getList()
}).catch(() => {})
},
handleDeleteArrival(row) {
this.$modal.confirm('确认删除待验收到货记录“' + row.arrivalNo + '”?').then(() => delPurchaseArrival(row.id)).then(() => {
this.$modal.msgSuccess('到货记录已删除')
this.refreshArrival(this.arrivalRequest.id)
this.getList()
}).catch(() => {})
},
handleRevokeAcceptance(row) {
const action = row.acceptanceStatus === 1 ? '验收通过' : '拒收'
this.$prompt('请输入撤销原因', '撤销' + action, {
confirmButtonText: '确认撤销',
inputValidator: value => !!(value && value.trim()) || '撤销原因不能为空'
}).then(({ value }) => revokePurchaseArrival(row.id, { revokeRemark: value })).then(() => {
this.$modal.msgSuccess('验收已撤销,到货记录已恢复为待验收')
this.refreshArrival(this.arrivalRequest.id)
this.getList()
}).catch(() => {})
},
handleGridAdd() {
this.gridRows = Array.from({ length: 5 }, () => this.emptyGridRow())
this.gridOpen = true
},
handleSmartImport() {
this.resetSmartImport()
this.smartImportResultRows = null
this.smartImportOpen = true
},
handleSmartFileChange(file, fileList) {
this.smartFile = file.raw
this.smartFileList = fileList.slice(-1)
},
handleSmartFileRemove() {
this.smartFile = null
this.smartFileList = []
},
resetSmartImport() {
this.smartFile = null
this.smartFileList = []
if (this.$refs.smartUpload) this.$refs.smartUpload.clearFiles()
},
handleSmartImportClosed() {
this.resetSmartImport()
if (!this.smartImportResultRows) return
const count = this.smartImportResultRows.length
this.gridRows = this.smartImportResultRows
this.smartImportResultRows = null
this.$nextTick(() => {
this.gridOpen = true
this.$modal.msgSuccess(`分析完成,已载入 ${count} 条采购明细`)
})
},
submitSmartImport() {
if (!this.smartFile) return this.$modal.msgWarning('请选择图片或Excel文件')
const formData = new FormData()
formData.append('file', this.smartFile)
this.smartImportLoading = true
smartImportPurchase(formData).then(response => {
let result = response || {}
if (result.data && Array.isArray(result.data.items)) result = result.data
else if (result.data && result.data.data && Array.isArray(result.data.data.items)) result = result.data.data
const items = result.items || []
if (!Array.isArray(items) || !items.length) {
this.$modal.msgWarning((result.warnings || []).join(';') || '没有识别到可导入的采购明细')
return
}
this.smartImportResultRows = items.map(item => Object.assign(this.emptyGridRow(), item || {}))
this.smartImportOpen = false
const warnings = result.warnings || []
if (warnings.length) this.$modal.msgWarning(warnings.join(';'))
}).catch(error => {
if (error && error.message && !/timeout|Network Error|status code/i.test(error.message)) {
this.$modal.msgError('分析结果处理失败:' + error.message)
}
}).finally(() => { this.smartImportLoading = false })
},
emptyGridRow() {
return { productId: undefined, productCode: undefined, materialName: undefined, materialBrand: undefined, category: undefined, materialParams: undefined, unit: undefined, quantity: 1, expectedDate: undefined, purchaseLink: undefined, productRemark: undefined, remark: undefined }
},
addGridRow() { this.gridRows.push(this.emptyGridRow()) },
removeGridRow(index) {
this.gridRows.splice(index, 1)
if (!this.gridRows.length) this.addGridRow()
},
removeEmptyGridRows() {
const rows = this.gridRows.filter(row => this.gridRowHasData(row))
this.gridRows = rows.length ? rows : [this.emptyGridRow()]
},
gridRowHasData(row) {
return this.gridFields.some(field => field !== 'quantity' && row[field]) || (row.quantity !== undefined && Number(row.quantity) !== 1)
},
handleGridPaste(event, startRow, startColumn) {
const text = event.clipboardData && event.clipboardData.getData('text/plain')
if (!text || (!text.includes('\t') && !/[\r\n]/.test(text))) return
event.preventDefault()
const rows = text.replace(/\r/g, '').split('\n')
if (rows.length && rows[rows.length - 1] === '') rows.pop()
const matrix = rows.map(row => row.split('\t'))
const headerFields = matrix.length
? matrix[0].map(value => this.gridHeaderAliases[this.normalizeGridHeader(value)])
: []
const hasHeader = headerFields.some(Boolean)
if (hasHeader) matrix.shift()
if (!matrix.length) return
while (this.gridRows.length < startRow + matrix.length) this.addGridRow()
matrix.forEach((cells, rowOffset) => {
const target = this.gridRows[startRow + rowOffset]
cells.forEach((value, columnOffset) => {
const field = hasHeader ? headerFields[columnOffset] : this.gridFields[startColumn + columnOffset]
if (!field) return
this.$set(target, field, field === 'quantity' ? this.parseGridNumber(value) : (field === 'expectedDate' ? this.normalizeGridDate(value) : value.trim()))
})
if (target.productCode) this.handleGridCodeChange(target)
else if (target.materialName) this.handleGridNameChange(target)
})
},
normalizeGridHeader(value) {
return String(value || '').replace(/\s+/g, '').trim()
},
parseGridNumber(value) {
const number = Number(String(value).replace(/,/g, '').trim())
return Number.isFinite(number) ? number : value.trim()
},
normalizeGridDate(value) {
const text = String(value).trim()
if (/^\d+(\.\d+)?$/.test(text)) {
const date = new Date(Date.UTC(1899, 11, 30) + Number(text) * 86400000)
return this.parseTime(date, '{y}-{m}-{d}')
}
const matched = text.match(/^(\d{4})[\/.\-](\d{1,2})[\/.\-](\d{1,2})$/)
return matched ? matched[1] + '-' + matched[2].padStart(2, '0') + '-' + matched[3].padStart(2, '0') : text
},
handleGridCodeChange(row) {
const product = this.productOptions.find(item => this.sameText(item.productCode, row.productCode))
if (product) this.applyGridProduct(row, product, true)
else row.productId = undefined
},
handleGridNameChange(row) {
const products = this.productOptions.filter(item => this.sameText(item.productName, row.materialName))
if (products.length === 1) this.applyGridProduct(row, products[0], false)
else row.productId = undefined
},
applyGridProduct(row, product, overwrite) {
row.productId = product.id
row.productCode = product.productCode
const values = { materialName: product.productName, materialBrand: product.brand, category: product.category, materialParams: product.spec, unit: product.unit }
Object.keys(values).forEach(field => {
if (overwrite || !row[field]) this.$set(row, field, values[field])
})
},
submitGrid() {
const rows = this.gridRows.filter(row => this.gridRowHasData(row))
if (!rows.length) return this.$modal.msgError('请至少录入一行采购数据')
for (let index = 0; index < rows.length; index++) {
const row = rows[index]
if (!row.materialName) return this.$modal.msgError('第' + (index + 1) + '行物料名称不能为空')
if (!Number.isFinite(Number(row.quantity)) || Number(row.quantity) <= 0) return this.$modal.msgError('第' + (index + 1) + '行数量必须大于0')
if (row.purchaseLink && !/^https?:\/\//i.test(row.purchaseLink)) return this.$modal.msgError('第' + (index + 1) + '行采购链接格式不正确')
row.quantity = Number(row.quantity)
}
this.gridSaving = true
const payload = rows.map(row => {
const item = { productId: row.productId }
this.gridFields.forEach(field => { item[field] = row[field] })
return item
})
tableAddPurchase(payload).then(response => {
this.$modal.msgSuccess('已提交 ' + response.data + ' 条采购申请')
this.gridOpen = false
this.getList()
this.getProductOptions()
}).finally(() => { this.gridSaving = false })
},
handleAssignBatch(row) {
const hasRow = row && row.id
this.assignForm = { requestIds: hasRow ? [row.id] : this.ids, batchId: undefined }
this.getBatchOptions()
this.assignOpen = true
this.$nextTick(() => this.$refs.assignForm && this.$refs.assignForm.clearValidate())
},
submitAssignBatch() {
this.$refs.assignForm.validate(valid => {
if (!valid) return
assignPurchaseBatch(this.assignForm).then(() => {
this.$modal.msgSuccess('采购批次已更新')
this.assignOpen = false
this.getList()
this.getBatchList()
})
})
},
getBatchList() {
this.batchLoading = true
listPurchaseBatch(this.batchQuery).then(response => {
this.batchList = response.rows || []
this.batchTotal = response.total || 0
}).finally(() => { this.batchLoading = false })
},
getBatchOptions(keyword) {
this.batchOptionLoading = true
optionPurchaseBatch(keyword || '').then(response => { this.batchOptions = response.data || [] }).finally(() => { this.batchOptionLoading = false })
},
searchBatchOptions(keyword) {
this.getBatchOptions(keyword)
},
handleBatchManage() { this.batchQuery.pageNum = 1; this.getBatchList(); this.batchOpen = true },
handleBatchQuery() { this.batchQuery.pageNum = 1; this.getBatchList() },
resetBatchQuery() { this.resetForm('batchQueryForm'); this.handleBatchQuery() },
handleAddBatch() {
this.batchForm = { id: undefined, batchNo: undefined, batchName: undefined, purchaseStatus: 1, progressRemark: undefined }
this.batchTitle = '新增采购批次'
this.batchFormOpen = true
this.$nextTick(() => this.$refs.batchForm && this.$refs.batchForm.clearValidate())
},
handleEditBatch(row) {
this.batchForm = { ...row }
this.batchTitle = '修改采购批次'
this.batchFormOpen = true
this.$nextTick(() => this.$refs.batchForm && this.$refs.batchForm.clearValidate())
},
submitBatch() {
this.$refs.batchForm.validate(valid => {
if (!valid) return
const updating = !!this.batchForm.id
const action = updating ? updatePurchaseBatch(this.batchForm) : addPurchaseBatch(this.batchForm)
action.then(() => {
this.$modal.msgSuccess(updating ? '批次已更新' : '批次已创建')
this.batchFormOpen = false
this.getBatchList()
this.getBatchOptions()
this.getList()
})
})
},
handleDeleteBatch(row) {
this.$modal.confirm('是否删除采购批次“' + row.batchNo + '”?有关联申请的批次不能删除。').then(() => delPurchaseBatch(row.id)).then(() => {
this.$modal.msgSuccess('批次已删除')
this.getBatchList()
}).catch(() => {})
},
handleImport() { this.$refs.purchaseImport.open() },
handleImportSuccess() { this.getList(); this.getProductOptions() },
handleExport() { this.download('warehouse/purchase/export', { ...this.queryParams }, `purchase_request_${new Date().getTime()}.xlsx`) },
getProductOptions() {
optionselectProduct().then(response => { this.productOptions = response.data || [] })
},
getDistinctOptions(field) {
const values = this.productOptions.map(item => item[field]).filter(Boolean)
return Array.from(new Set(values))
},
canOperate(row) {
return this.canManageAll || Number(row.applicantId) === Number(this.$store.getters.id)
},
handleExistingProductChange(id) {
if (!id) return
const product = this.productOptions.find(item => item.id === id)
if (!product) return
this.applyProduct(product)
},
handleMaterialNameChange(value) {
this.form.productId = undefined
const products = this.productOptions.filter(item => this.sameText(item.productName, value))
if (products.length === 1) {
this.applyProduct(products[0])
} else {
this.syncProductId()
}
},
applyProduct(product) {
this.form.productId = product.id
this.form.productCode = product.productCode
this.form.materialName = product.productName
this.form.materialBrand = product.brand
this.form.category = product.category
this.form.materialParams = product.spec
this.form.unit = product.unit
this.form.productRemark = product.remark
},
syncProductId() {
const product = this.productOptions.find(item => this.sameText(item.productName, this.form.materialName) && this.sameText(item.brand, this.form.materialBrand) && this.sameText(item.category, this.form.category) && this.sameText(item.spec, this.form.materialParams))
this.form.productId = product ? product.id : undefined
this.form.productCode = product ? product.productCode : undefined
},
sameText(left, right) { return (left || '').trim().toLowerCase() === (right || '').trim().toLowerCase() },
productLabel(item) { return [item.productCode, item.productName, item.brand, item.category, item.spec, item.unit].filter(Boolean).join(' / ') },
batchLabel(item) { return [item.batchNo, item.batchName, this.batchStatusLabel(item.purchaseStatus)].filter(Boolean).join(' / ') },
batchStatusLabel(value) { const item = this.batchStatusOptions.find(option => option.value === value); return item ? item.label : '未知' },
batchStatusType(value) { const item = this.batchStatusOptions.find(option => option.value === value); return item ? item.type : 'info' },
acceptanceLabel(value) { return ['待验收', '已通过', '已拒收'][value] || '未知' },
acceptanceType(value) { return ['warning', 'success', 'danger'][value] || 'info' },
statusLabel(value) { const item = this.statusOptions.find(option => option.value === value); return item ? item.label : '未知' },
statusType(value) { const item = this.statusOptions.find(option => option.value === value); return item ? item.type : 'info' }
}
}
</script>
<style scoped>
.el-select,
.el-date-editor,
.el-input-number {
width: 100%;
}
::v-deep .purchase-dialog {
max-width: calc(100vw - 30px);
}
.arrival-summary {
color: #e6a23c;
line-height: 20px;
}
::v-deep .purchase-smart-dialog {
max-width: calc(100vw - 24px);
}
::v-deep .purchase-smart-dialog .el-upload,
::v-deep .purchase-smart-dialog .el-upload-dragger {
width: 100%;
}
.smart-tip {
color: #e6a23c;
line-height: 20px;
}
.arrival-header {
display: flex;
flex-wrap: wrap;
gap: 8px 20px;
margin-bottom: 14px;
line-height: 24px;
}
.arrival-form {
padding: 12px 0 2px;
border-top: 1px solid #ebeef5;
}
::v-deep .purchase-grid-dialog {
max-width: calc(100vw - 24px);
}
::v-deep .arrival-dialog {
max-width: calc(100vw - 24px);
}
::v-deep .purchase-grid .cell {
padding: 3px 5px;
}
::v-deep .purchase-grid .el-input__inner {
border-radius: 2px;
}
</style>

View File

@ -32,8 +32,11 @@ drop table if exists in_record;
create table in_record ( create table in_record (
id bigint(20) not null auto_increment comment '主键', id bigint(20) not null auto_increment comment '主键',
in_no varchar(64) not null comment '入库单号', in_no varchar(64) not null comment '入库单号',
area_id bigint(20) not null comment '库区id', area_id bigint(20) default null comment '库区id,待入库时可为空',
product_id bigint(20) not null comment '产品id', product_id bigint(20) not null comment '产品id',
purchase_request_id bigint(20) default null comment '采购申请ID',
arrival_id bigint(20) default null comment '采购到货ID',
source_no varchar(64) default null comment '来源单号',
in_type tinyint(1) not null comment '入库类型', in_type tinyint(1) not null comment '入库类型',
in_num decimal(16,3) not null comment '入库数量', in_num decimal(16,3) not null comment '入库数量',
in_status tinyint(1) not null default 0 comment '入库状态', in_status tinyint(1) not null default 0 comment '入库状态',
@ -45,6 +48,8 @@ create table in_record (
update_time datetime default null comment '更新时间', update_time datetime default null comment '更新时间',
primary key (id), primary key (id),
unique key uk_in_record_no (in_no), unique key uk_in_record_no (in_no),
key idx_in_record_purchase_request (purchase_request_id),
unique key uk_in_record_arrival (arrival_id),
key idx_in_record_area_product (area_id, product_id) key idx_in_record_area_product (area_id, product_id)
) engine=innodb auto_increment=1 comment='入库记录表'; ) engine=innodb auto_increment=1 comment='入库记录表';

View File

@ -0,0 +1,279 @@
-- 采购申请模块。执行一次后,在角色管理中给员工分配采购申请菜单;
-- “采购管理”按钮权限仅分配给采购管理员。
create table if not exists purchase_batch (
id bigint(20) not null auto_increment comment '主键',
batch_no varchar(64) not null comment '采购批次编号',
batch_name varchar(200) default null comment '采购批次名称',
purchase_status tinyint(1) not null default 1 comment '批次状态:1规划中 2执行中 3已结束 4已归档 5已取消',
progress_remark varchar(500) default null comment '进度说明',
create_by varchar(64) default null comment '创建人',
create_time datetime default null comment '创建时间',
update_by varchar(64) default null comment '更新人',
update_time datetime default null comment '更新时间',
primary key (id),
unique key uk_purchase_batch_no (batch_no)
) engine=innodb default charset=utf8mb4 comment='采购批次表';
alter table purchase_batch modify column purchase_status tinyint(1) not null default 1
comment '批次状态:1规划中 2执行中 3已结束 4已归档 5已取消';
create table if not exists purchase_request (
id bigint(20) not null auto_increment comment '主键',
request_no varchar(64) not null comment '申请单号',
product_id bigint(20) default null comment '关联库房物料ID',
purchase_batch_id bigint(20) default null comment '采购批次ID',
material_name varchar(200) not null comment '物料名称',
material_brand varchar(100) default null comment '品牌',
category varchar(100) default null comment '类别',
material_params varchar(500) default null comment '参数规格',
purchase_link varchar(1000) default null comment '采购链接',
quantity decimal(16,3) not null default 1.000 comment '需求数量',
unit varchar(32) default null comment '单位',
expected_date date default null comment '期望到货日期',
applicant_id bigint(20) not null comment '申请人用户ID',
applicant_name varchar(64) not null comment '申请人账号',
purchase_status tinyint(1) not null default 0 comment '状态:0待采购 1已下单 2采购中 3部分到货 4已到齐 5已取消',
purchase_batch_no varchar(64) default null comment '采购批次',
progress_remark varchar(500) default null comment '进度说明',
remark varchar(500) default null comment '申请备注',
create_by varchar(64) default null comment '创建人',
create_time datetime default null comment '创建时间',
update_by varchar(64) default null comment '更新人',
update_time datetime default null comment '更新时间',
primary key (id),
unique key uk_purchase_request_no (request_no),
key idx_purchase_request_product (product_id),
key idx_purchase_request_batch_id (purchase_batch_id),
key idx_purchase_request_applicant (applicant_id),
key idx_purchase_request_status (purchase_status),
key idx_purchase_request_batch (purchase_batch_no)
) engine=innodb default charset=utf8mb4 comment='采购申请表';
create table if not exists purchase_arrival (
id bigint(20) not null auto_increment comment '主键',
arrival_no varchar(64) not null comment '到货单号',
purchase_request_id bigint(20) not null comment '采购申请ID',
arrival_quantity decimal(16,3) not null comment '本次到货数量',
arrival_date date not null comment '到货日期',
acceptance_status tinyint(1) not null default 0 comment '验收状态:0待验收 1验收通过 2验收拒绝',
acceptance_remark varchar(500) default null comment '验收说明',
accepted_by varchar(64) default null comment '验收人',
accepted_time datetime default null comment '验收时间',
pre_accept_purchase_status tinyint(1) default null comment '验收前采购状态',
revoked_by varchar(64) default null comment '最近撤销人',
revoked_time datetime default null comment '最近撤销时间',
revoke_remark varchar(500) default null comment '最近撤销原因',
remark varchar(500) default null comment '到货备注',
create_by varchar(64) default null comment '创建人',
create_time datetime default null comment '创建时间',
update_by varchar(64) default null comment '更新人',
update_time datetime default null comment '更新时间',
primary key (id),
unique key uk_purchase_arrival_no (arrival_no),
key idx_purchase_arrival_request (purchase_request_id),
key idx_purchase_arrival_status (acceptance_status)
) engine=innodb default charset=utf8mb4 comment='采购分批到货及验收记录';
-- 兼容已执行过早期脚本的数据库。
set @purchase_brand_column = (
select count(*) from information_schema.columns
where table_schema = database() and table_name = 'purchase_request' and column_name = 'material_brand'
);
set @purchase_brand_sql = if(
@purchase_brand_column = 0,
'alter table purchase_request add column material_brand varchar(100) default null comment ''品牌'' after material_name',
'select 1'
);
prepare purchase_brand_stmt from @purchase_brand_sql;
execute purchase_brand_stmt;
deallocate prepare purchase_brand_stmt;
set @purchase_batch_id_column = (
select count(*) from information_schema.columns
where table_schema = database() and table_name = 'purchase_request' and column_name = 'purchase_batch_id'
);
set @purchase_batch_id_sql = if(
@purchase_batch_id_column = 0,
'alter table purchase_request add column purchase_batch_id bigint(20) default null comment ''采购批次ID'' after product_id, add key idx_purchase_request_batch_id (purchase_batch_id)',
'select 1'
);
prepare purchase_batch_id_stmt from @purchase_batch_id_sql;
execute purchase_batch_id_stmt;
deallocate prepare purchase_batch_id_stmt;
set @arrival_revoke_columns = (
select count(*) from information_schema.columns
where table_schema = database() and table_name = 'purchase_arrival' and column_name = 'pre_accept_purchase_status'
);
set @arrival_revoke_sql = if(
@arrival_revoke_columns = 0,
'alter table purchase_arrival add column pre_accept_purchase_status tinyint(1) default null comment ''验收前采购状态'' after accepted_time, add column revoked_by varchar(64) default null comment ''最近撤销人'' after pre_accept_purchase_status, add column revoked_time datetime default null comment ''最近撤销时间'' after revoked_by, add column revoke_remark varchar(500) default null comment ''最近撤销原因'' after revoked_time',
'select 1'
);
prepare arrival_revoke_stmt from @arrival_revoke_sql;
execute arrival_revoke_stmt;
deallocate prepare arrival_revoke_stmt;
alter table purchase_request modify column purchase_status tinyint(1) not null default 0
comment '状态:0待采购 1已下单 2采购中 3部分到货 4已到齐 5已取消';
-- 把旧版本中写在申请行上的批次编号迁移为批次主数据。
insert ignore into purchase_batch(batch_no, batch_name, purchase_status, progress_remark, create_by, create_time)
select purchase_batch_no, purchase_batch_no, greatest(max(purchase_status), 1), max(progress_remark), 'migration', sysdate()
from purchase_request
where purchase_batch_no is not null and purchase_batch_no != ''
group by purchase_batch_no;
update purchase_request pr
join purchase_batch pb on pb.batch_no = pr.purchase_batch_no
set pr.purchase_batch_id = pb.id,
pr.purchase_status = pb.purchase_status,
pr.progress_remark = pb.progress_remark
where pr.purchase_batch_id is null;
-- 采购完成后先生成待入库记录,库区和入库人由仓库人员补充后再确认入库。
alter table in_record modify column area_id bigint(20) default null comment '库区id,待入库时可为空';
set @in_purchase_request_column = (
select count(*) from information_schema.columns
where table_schema = database() and table_name = 'in_record' and column_name = 'purchase_request_id'
);
set @in_purchase_request_sql = if(
@in_purchase_request_column = 0,
'alter table in_record add column purchase_request_id bigint(20) default null comment ''采购申请ID'' after product_id',
'select 1'
);
prepare in_purchase_request_stmt from @in_purchase_request_sql;
execute in_purchase_request_stmt;
deallocate prepare in_purchase_request_stmt;
set @in_source_no_column = (
select count(*) from information_schema.columns
where table_schema = database() and table_name = 'in_record' and column_name = 'source_no'
);
set @in_source_no_sql = if(
@in_source_no_column = 0,
'alter table in_record add column source_no varchar(64) default null comment ''来源单号'' after purchase_request_id',
'select 1'
);
prepare in_source_no_stmt from @in_source_no_sql;
execute in_source_no_stmt;
deallocate prepare in_source_no_stmt;
set @in_arrival_column = (
select count(*) from information_schema.columns
where table_schema = database() and table_name = 'in_record' and column_name = 'arrival_id'
);
set @in_arrival_sql = if(
@in_arrival_column = 0,
'alter table in_record add column arrival_id bigint(20) default null comment ''采购到货ID'' after purchase_request_id',
'select 1'
);
prepare in_arrival_stmt from @in_arrival_sql;
execute in_arrival_stmt;
deallocate prepare in_arrival_stmt;
set @in_purchase_request_unique_index = (
select count(*) from information_schema.statistics
where table_schema = database() and table_name = 'in_record'
and index_name = 'uk_in_record_purchase_request' and non_unique = 0
);
set @drop_in_purchase_request_unique_sql = if(
@in_purchase_request_unique_index > 0,
'alter table in_record drop index uk_in_record_purchase_request',
'select 1'
);
prepare drop_in_purchase_request_unique_stmt from @drop_in_purchase_request_unique_sql;
execute drop_in_purchase_request_unique_stmt;
deallocate prepare drop_in_purchase_request_unique_stmt;
set @in_purchase_request_index = (
select count(*) from information_schema.statistics
where table_schema = database() and table_name = 'in_record' and index_name = 'idx_in_record_purchase_request'
);
set @in_purchase_request_index_sql = if(
@in_purchase_request_index = 0,
'alter table in_record add key idx_in_record_purchase_request (purchase_request_id)',
'select 1'
);
prepare in_purchase_request_index_stmt from @in_purchase_request_index_sql;
execute in_purchase_request_index_stmt;
deallocate prepare in_purchase_request_index_stmt;
set @in_arrival_index = (
select count(*) from information_schema.statistics
where table_schema = database() and table_name = 'in_record' and index_name = 'uk_in_record_arrival'
);
set @in_arrival_index_sql = if(
@in_arrival_index = 0,
'alter table in_record add unique key uk_in_record_arrival (arrival_id)',
'select 1'
);
prepare in_arrival_index_stmt from @in_arrival_index_sql;
execute in_arrival_index_stmt;
deallocate prepare in_arrival_index_stmt;
-- 旧版本按批次完成自动生成、但尚未真实入库的记录没有经过验收,升级时移除。
delete from in_record
where arrival_id is null and purchase_request_id is not null
and in_status = 0 and in_no like 'PIN%';
-- 菜单及按钮权限
delete from sys_menu where menu_id in (2035, 2036, 2037, 2038, 2039, 2040, 2041, 2042);
insert into sys_menu values('2035', '采购申请', '0', '6', 'purchase', 'warehouse/purchase/index', '', '', 1, 0, 'C', '0', '0', 'warehouse:purchase:list', 'shopping', 'admin', sysdate(), '', null, '与库存管理同级的采购申请菜单');
insert into sys_menu values('2036', '采购查询', '2035', '1', '#', '', '', '', 1, 0, 'F', '0', '0', 'warehouse:purchase:query', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu values('2037', '采购申请新增', '2035', '2', '#', '', '', '', 1, 0, 'F', '0', '0', 'warehouse:purchase:add', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu values('2038', '采购申请修改', '2035', '3', '#', '', '', '', 1, 0, 'F', '0', '0', 'warehouse:purchase:edit', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu values('2039', '采购申请删除', '2035', '4', '#', '', '', '', 1, 0, 'F', '0', '0', 'warehouse:purchase:remove', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu values('2040', '采购申请导入', '2035', '5', '#', '', '', '', 1, 0, 'F', '0', '0', 'warehouse:purchase:import', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu values('2041', '采购申请导出', '2035', '6', '#', '', '', '', 1, 0, 'F', '0', '0', 'warehouse:purchase:export', '#', 'admin', sysdate(), '', null, '');
insert into sys_menu values('2042', '采购管理', '2035', '7', '#', '', '', '', 1, 0, 'F', '0', '0', 'warehouse:purchase:manage', '#', 'admin', sysdate(), '', null, '仅管理员分配,可查看全部申请并修改采购进度');
-- 独立采购管理员角色。该角色可分配给普通用户,不使用受保护的 admin 超级管理员角色。
insert into sys_role(role_name, role_key, role_sort, data_scope, menu_check_strictly,
dept_check_strictly, status, del_flag, create_by, create_time, remark)
select '采购管理员', 'purchase_manager', 3, '1', 1, 1, '0', '0', 'admin', sysdate(),
'维护全部采购申请、采购批次和采购进度'
where not exists (
select 1 from sys_role where role_key = 'purchase_manager' and del_flag = '0'
);
set @purchase_manager_role_id = (
select role_id from sys_role
where role_key = 'purchase_manager' and del_flag = '0'
order by role_id limit 1
);
insert ignore into sys_role_menu(role_id, menu_id)
select @purchase_manager_role_id, menu_id
from sys_menu
where menu_id in (2035, 2036, 2037, 2038, 2039, 2040, 2041, 2042);
delete from sys_role_menu
where role_id = @purchase_manager_role_id and menu_id = 2000;
-- 普通采购申请人:可查看、导出全部申请,但服务端只允许修改和删除本人数据。
insert into sys_role(role_name, role_key, role_sort, data_scope, menu_check_strictly,
dept_check_strictly, status, del_flag, create_by, create_time, remark)
select '采购申请人', 'purchase_user', 4, '1', 1, 1, '0', '0', 'admin', sysdate(),
'提交采购申请;查看和导出全部申请;修改、删除本人申请'
where not exists (
select 1 from sys_role where role_key = 'purchase_user' and del_flag = '0'
);
set @purchase_user_role_id = (
select role_id from sys_role
where role_key = 'purchase_user' and del_flag = '0'
order by role_id limit 1
);
insert ignore into sys_role_menu(role_id, menu_id)
select @purchase_user_role_id, menu_id
from sys_menu
where menu_id in (2035, 2036, 2037, 2038, 2039, 2040, 2041);
delete from sys_role_menu
where role_id = @purchase_user_role_id and menu_id = 2000;