新增图片模块

This commit is contained in:
2025-09-04 16:55:56 +08:00
parent acf191df81
commit 8c6cc08cac
24 changed files with 1119 additions and 6 deletions

View File

@@ -0,0 +1,173 @@
package com.zg.common.utils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.InputStream;
import java.nio.file.Files;
import java.text.SimpleDateFormat;
import java.util.*;
/**
* 本地图片保存工具
* - 根目录photo.root默认 D:\photo
* - 目录结构:{subDir}/yyyy-MM-dd/
* - 文件命名:{billNo}_{sapNo}_{xmMs}_{yyyyMMddHHmmssSSS}_{index}.{ext}
* - 返回相对web路径用于拼接 /photo/ 前缀生成可访问URL与文件名
*/
@Component
public class LocalPhotoUtil {
/** 本地根目录,可在 yml 配置photo.root: D:\photo */
@Value("${photo.root:D:\\\\photo}")
private String rootPath;
/** 非法文件名字符Windows */
private static final String ILLEGAL_CHARS = "\\/:*?\"<>|";
/** yyyy-MM-dd目录 */
private String today() {
return new SimpleDateFormat("yyyy-MM-dd").format(new Date());
}
/** yyyyMMddHHmmssSSS文件戳 */
private String nowStamp() {
return new SimpleDateFormat("yyyyMMddHHmmssSSS").format(new Date());
}
/**
* 组装基础文件名billNo_sapNo_xmMs不含后缀、不含时间戳/序号)
*/
public String buildBaseName(String billNo, String xmMs) {
String b = defaultString(billNo);
String x = defaultString(xmMs);
String joined = String.join("_", b, x);
return sanitize(joined);
}
/**
* 保存单个文件
* @param file 文件
* @param subDir 一级子目录(建议 "photo" 固定,也可按业务传入)
* @param baseName 基础名(不含扩展名;可用 buildBaseName 生成)
* @param index 序号从1开始用于同批次区分
* @return SaveResult(webPath, fileName)
*/
public SaveResult saveOne(MultipartFile file, String subDir, String baseName, int index) throws Exception {
String ext = resolveExt(file);
String safeSub = sanitize(defaultString(subDir, ""));
String dirPart = safeSub + File.separator + today() + File.separator;
String stamp = nowStamp();
String safeBase = sanitize(baseName);
// 最终文件名base_时间戳_序号.ext
String fileName = safeBase + "_" + stamp + "_" + index + ext;
File dir = new File(rootPath, dirPart);
if (!dir.exists() && !dir.mkdirs()) {
throw new RuntimeException("创建目录失败:" + dir.getAbsolutePath());
}
File dest = new File(dir, fileName);
try (InputStream in = file.getInputStream()) {
Files.copy(in, dest.toPath());
}
SaveResult r = new SaveResult();
r.setWebPath((dirPart + fileName).replace("\\", "/")); // e.g. photo/2025-09-01/xxx.jpg
r.setFileName(fileName);
return r;
}
/**
* 批量保存(顺序与入参 files 一致)
* @param files 文件列表
* @param subDir 一级子目录(建议传 "photo"
* @param baseName 基础名(不含扩展名)
*/
public List<SaveResult> saveBatch(List<MultipartFile> files, String subDir, String baseName) throws Exception {
if (files == null || files.isEmpty()) {
return Collections.emptyList();
}
List<SaveResult> list = new ArrayList<>(files.size());
for (int i = 0; i < files.size(); i++) {
list.add(saveOne(files.get(i), subDir, baseName, i + 1));
}
return list;
}
/**
* 便捷方法:直接传业务字段(内部会调用 buildBaseName
* @param files 多文件
* @param subDir 一级子目录(建议 "photo"
* @param billNo 单据号
* @param xmMs 项目描述
*/
public List<SaveResult> saveBatchByBiz(List<MultipartFile> files, String subDir,
String billNo, String xmMs) throws Exception {
String base = buildBaseName(billNo, xmMs);
return saveBatch(files, subDir, base);
}
/** 解析扩展名:优先取原文件名,其次按 contentType 兜底 */
private String resolveExt(MultipartFile file) {
String origin = file.getOriginalFilename();
if (origin != null) {
int dot = origin.lastIndexOf('.');
if (dot >= 0) {
String ext = origin.substring(dot);
if (ext.length() <= 10) { // 简单防御:避免异常超长扩展名
return ext;
}
}
}
String ct = file.getContentType();
if ("image/png".equalsIgnoreCase(ct)) return ".png";
if ("image/jpeg".equalsIgnoreCase(ct) || "image/jpg".equalsIgnoreCase(ct)) return ".jpg";
return ".bin";
}
/** 清洗文件名去空白、替换非法字符、规避Windows保留名、限长 */
public String sanitize(String name) {
if (name == null) return "unnamed";
String t = name.trim().replaceAll("\\s+", " ");
StringBuilder sb = new StringBuilder(t.length());
for (int i = 0; i < t.length(); i++) {
char c = t.charAt(i);
sb.append(ILLEGAL_CHARS.indexOf(c) >= 0 ? '_' : c);
}
String out = sb.toString();
String upper = out.toUpperCase(Locale.ROOT);
if (upper.matches("CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9]")) {
out = "_" + out;
}
// 限长,给时间戳与序号留空间
if (out.length() > 100) {
out = out.substring(0, 100);
}
return out;
}
/** null 转空串;带默认值重载 */
private String defaultString(String s) {
return s == null ? "" : s;
}
private String defaultString(String s, String def) {
return (s == null || s.isEmpty()) ? def : s;
}
/** 返回值对象 */
public static class SaveResult {
/** 相对web路径例如 photo/2025-09-01/xxx.jpg用于拼接 /photo/ 前缀生成URL */
private String webPath;
/** 最终文件名(含扩展名) */
private String fileName;
public String getWebPath() { return webPath; }
public void setWebPath(String webPath) { this.webPath = webPath; }
public String getFileName() { return fileName; }
public void setFileName(String fileName) { this.fileName = fileName; }
}
}

View File

@@ -32,6 +32,11 @@ public class ResourcesConfig implements WebMvcConfigurer
registry.addResourceHandler(Constants.RESOURCE_PREFIX + "/**")
.addResourceLocations("file:" + RuoYiConfig.getProfile() + "/");
/**把 /photo/** 映射到 D:/photo/ */
registry.addResourceHandler("/photo/**")
.addResourceLocations("file:D:/photo/"); // 关键file: 前缀 + 结尾 /
/** swagger配置 */
registry.addResourceHandler("/swagger-ui/**")
.addResourceLocations("classpath:/META-INF/resources/webjars/springfox-swagger-ui/")

View File

@@ -121,6 +121,8 @@ public class SecurityConfig
"/system/config/**",
"/AutoInventory/**",
"/ws/**",
"/photo/**",
"/wisdom/stock/**",
"/wisdom/**",
"/mock/**",
"/information/device/**",

View File

@@ -0,0 +1,86 @@
package com.zg.project.wisdom.controller;
import com.zg.framework.web.domain.AjaxResult;
import com.zg.project.wisdom.domain.dto.PhotoDeleteDTO;
import com.zg.project.wisdom.service.PhotoService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import java.util.Collections;
import java.util.List;
import java.util.Map;
/**
* 【照片上传接口】
* - 负责接收前端图片(单图/多图),并调用 Service 完成:落盘 + URL入库
* - 依赖静态资源映射:/photo/** -> D:\photo\
*/
@RestController
@RequestMapping("/photo")
public class PhotoController {
@Resource
private PhotoService photoService;
/**
* 【多图上传】(推荐)
* 提交方式multipart/form-data
* 参数:
* - files多文件同名字段可传多张
* - photoType0(入库相关) / 1(出库相关)
* - billNo单据号
* - xmMs项目描述
* - sapNo订单号
* 返回:每张图片的可访问 URL 列表
*/
@PostMapping(value = "/upload/batch", consumes = "multipart/form-data")
public AjaxResult uploadBatch(@RequestPart("files") List<MultipartFile> files,
@RequestParam("photoType") String photoType,
@RequestParam("billNo") String billNo,
@RequestParam("xmMs") String xmMs) {
List<String> urls = photoService.uploadAndSave(files, photoType, billNo, xmMs);
return AjaxResult.success("上传成功", urls);
}
/**
* 【单图上传】(可选)
* 与多图参数一致,只是文件字段为 file
* 返回:单张图片的可访问 URL
*/
@PostMapping(value = "/upload", consumes = "multipart/form-data")
public AjaxResult upload(@RequestPart("file") MultipartFile file,
@RequestParam("photoType") String photoType,
@RequestParam("billNo") String billNo,
@RequestParam("xmMs") String xmMs) {
List<String> urls = photoService.uploadAndSave(Collections.singletonList(file), photoType, billNo, xmMs);
return AjaxResult.success("上传成功", urls.isEmpty() ? null : urls.get(0));
}
/**
* 【查询:按 billNo可选photoType取照片URL列表】
* GET /photo/list?billNo=...&photoType=0|1
* - billNo 必填
* - photoType 可不传:不传则返回该单据下所有类型的照片
* 返回:纯 URL 列表
*/
@GetMapping("/list")
public AjaxResult list(@RequestParam("billNo") String billNo,
@RequestParam(value = "photoType", required = false) String photoType) {
return AjaxResult.success(photoService.listUrlsByBill(billNo, photoType));
}
/**
* 【删除:按 URL 列表】
* @param dto
* @return
*/
@PostMapping(value = "/delete", consumes = "application/json")
// @PreAuthorize("@ss.hasPermi('wisdom:photo:remove')")
public AjaxResult delete(@Validated @RequestBody PhotoDeleteDTO dto) {
Map<String, Object> result = photoService.deleteByUrls(dto.getUrls());
return AjaxResult.success("删除完成", result);
}
}

View File

@@ -1,5 +1,6 @@
package com.zg.project.wisdom.controller;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -7,6 +8,8 @@ import javax.servlet.http.HttpServletResponse;
import com.github.pagehelper.PageHelper;
import com.zg.project.wisdom.domain.dto.*;
import com.zg.project.wisdom.domain.vo.BillGroupVO;
import io.swagger.annotations.ApiOperation;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
@@ -44,6 +47,17 @@ public class RkInfoController extends BaseController
return getDataTable(list);
}
@ApiOperation("按单据分组bill_no列表若存在出库则同时返回 bill_no_ck")
@PreAuthorize("@ss.hasPermi('wisdom:stock:list')")
@PostMapping("/bill/groups")
public TableDataInfo billGroups(@RequestBody RkInfoQueryDTO query) {
// 分页
PageHelper.startPage(query.getPageNum(), query.getPageSize());
// 查询
List<RkInfo> rows = rkInfoService.selectGroupedByBill(query);
return getDataTable(rows);
}
/**
* 导出库存单据主列表

View File

@@ -43,6 +43,17 @@ public class RkStatisticsController {
return AjaxResult.success(resp);
}
/**
* 按仓库查询:场景列表 + 每个场景的可用库位明细
* 示例GET /warehouse/positions/available?warehouseCode=CK001
*/
@ApiOperation("按仓库查询各场景的可用库位(排除未出库占用)")
@GetMapping("/warehouse/available")
public AjaxResult listAvailableByWarehouse(@RequestParam String warehouseCode) {
List<SceneAvailableVO> data = rkStatisticsService.listAvailableByWarehouse(warehouseCode);
return AjaxResult.success(data);
}
/**
* 当前库存根据入库类型统计项目数,每个项目的条目数,实际入库总数,金额总和(仅未出库)
*/

View File

@@ -0,0 +1,95 @@
package com.zg.project.wisdom.domain;
import com.zg.framework.web.domain.BaseEntity;
import org.apache.commons.lang3.builder.ToStringBuilder;
import org.apache.commons.lang3.builder.ToStringStyle;
import java.io.Serializable;
/**
* 单据照片对象 stock_photo
* 按 bill_no 聚合查询;仅存一个可访问的 URLphoto_type0=入库、1=出库
*/
public class StockPhoto extends BaseEntity implements Serializable {
private static final long serialVersionUID = 1L;
/** 主键ID */
private Long id;
/** 单据号(对应 rk_info.bill_no用于聚合查询 */
private String billNo;
/** 照片业务类型0=入库相关1=出库相关 */
private String photoType;
/** 实际文件名含扩展名示例I202508110001_101360951_项目名_时间戳_序号.jpg */
private String fileName;
/** 可直接访问的图片URL形如 /photo/2025-09-01/xxx.jpg 或完整http地址 */
private String url;
/** 删除标记0=正常1=已删除 */
private String isDelete;
/* ========== Getter / Setter ========== */
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getBillNo() {
return billNo;
}
public void setBillNo(String billNo) {
this.billNo = billNo;
}
public String getPhotoType() {
return photoType;
}
public void setPhotoType(String photoType) {
this.photoType = photoType;
}
public String getFileName() {
return fileName;
}
public void setFileName(String fileName) {
this.fileName = fileName;
}
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public String getIsDelete() {
return isDelete;
}
public void setIsDelete(String isDelete) {
this.isDelete = isDelete;
}
/* ========== toStringRuoYi 风格) ========== */
@Override
public String toString() {
return new ToStringBuilder(this, ToStringStyle.MULTI_LINE_STYLE)
.append("id", getId())
.append("billNo", getBillNo())
.append("photoType", getPhotoType())
.append("fileName", getFileName())
.append("url", getUrl())
.append("isDelete", getIsDelete())
.append("createBy", getCreateBy())
.append("createTime", getCreateTime())
.append("updateBy", getUpdateBy())
.append("updateTime", getUpdateTime())
.append("remark", getRemark())
.toString();
}
}

View File

@@ -0,0 +1,15 @@
package com.zg.project.wisdom.domain.dto;
import javax.validation.constraints.NotEmpty;
import java.util.List;
/** 删除图片请求体:传 URL 列表 */
public class PhotoDeleteDTO {
/** 要删除的图片地址(与 stock_photo.url 一致) */
@NotEmpty
private List<String> urls;
public List<String> getUrls() { return urls; }
public void setUrls(List<String> urls) { this.urls = urls; }
}

View File

@@ -0,0 +1,39 @@
package com.zg.project.wisdom.domain.vo;// package com.zg.project.wisdom.domain.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
@Data
public class BillGroupVO {
/** 分组主键:统一用入库单号 bill_no若为空会用 bill_no_ck 兜底) */
private String billNo;
/** 出库单号(出库单据时有值,前端可展示/操作) */
private String billNoCk;
/** 单据类型IN / OUT */
private String billType;
/** 单据时间IN 取最早 rk_timeOUT 取最早 ly_time */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private Date billTime;
// ——主数据常用抬头字段(从首条提取即可)——
private String xj;
private String xmNo;
private String xmMs;
private String rkType; private String rkTypeName;
private String ckType; private String ckTypeName;
private String teamCode; private String teamName;
private String cangkuName;
private String lihuoYName;
private String remark;
// ——聚合指标——
private Integer itemCount; // 明细条数
private BigDecimal sumQty; // 数量合计sum real_qty
private BigDecimal sumAmount; // 金额合计sum ht_dj * real_qty
}

View File

@@ -0,0 +1,16 @@
package com.zg.project.wisdom.domain.vo;
import lombok.Data;
/**
* 可用库位简单视图
*/
@Data
public class PcdeDetailSimpleVO {
/** 库位编码 */
private String pcode;
/** 标签(如有) */
private String tag;
/** 编码ID如有 */
private String encodedId;
}

View File

@@ -0,0 +1,19 @@
package com.zg.project.wisdom.domain.vo;
import lombok.Data;
import java.util.List;
/**
* 场景下的可用库位
*/
@Data
public class SceneAvailableVO {
/** 场景编码pcde_detail.scene对应 scene_mapping.scene_code */
private String sceneCode;
/** 场景名称scene_mapping.scene_name */
private String sceneName;
/** 可用库位数量 */
private Integer availableCount;
/** 可用库位明细 */
private List<PcdeDetailSimpleVO> positions;
}

View File

@@ -7,6 +7,8 @@ import java.util.Map;
import com.zg.project.Inventory.domain.vo.PcdeCntVO;
import com.zg.project.Inventory.domain.vo.RkInfoMatchVO;
import com.zg.project.wisdom.domain.RkInfo;
import com.zg.project.wisdom.domain.StockPhoto;
import com.zg.project.wisdom.domain.dto.RkInfoQueryDTO;
import org.apache.ibatis.annotations.Param;
/**
@@ -33,6 +35,13 @@ public interface RkInfoMapper
*/
public List<RkInfo> selectRkInfoList(RkInfo rkInfo);
/**
* 使用 selectRkInfoVo 作为子查询,外层按 bill_no 分组聚合
* 不新增 resultMap / VO直接用 RkInfoResult 映射需要的字段
*/
List<RkInfo> selectGroupedByBill(@Param("q") RkInfoQueryDTO query,
@Param("needAudit") Integer needAudit);
/**
* 修改库存单据主

View File

@@ -99,4 +99,16 @@ public interface RkStatisticsMapper {
/** 出库:在 [startTs, endTs) 时间范围内、is_chuku=1 的原始行(仅最小过滤) */
List<RkOutVO> selectOutRange(@Param("startTs") String startTs,
@Param("endTs") String endTs);
/**
* 外层:按场景汇总可用数量,并级联查询可用库位明细(返回 SceneAvailableVO 列表)
*/
List<SceneAvailableVO> selectAvailableByWarehouse(@Param("warehouseCode") String warehouseCode);
/**
* 内层:指定仓库 + 场景,查询该场景下的可用库位明细
*/
List<PcdeDetailSimpleVO> selectAvailablePositionsByWarehouseAndScene(
@Param("warehouseCode") String warehouseCode,
@Param("sceneCode") String sceneCode);
}

View File

@@ -0,0 +1,36 @@
package com.zg.project.wisdom.mapper;
import com.zg.project.wisdom.domain.StockPhoto;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* 【单据照片Mapper】
* - 负责 URL 的持久化只存一条URL
*/
public interface StockPhotoMapper {
/**
* 新增(单条)
*/
int insert(StockPhoto photo);
/**
* 批量新增(推荐用于多图一次上传)
*/
int batchInsert(@Param("list") List<StockPhoto> list);
/**
* 按 bill_no可选 photoType查询图片列表
*/
List<StockPhoto> selectByBillNo(@Param("billNo") String billNo,
@Param("photoType") String photoType);
/**
* 批量删除
* @param urls
* @return
*/
int softDeleteByUrls(@Param("urls") List<String> urls);
}

View File

@@ -1,13 +1,12 @@
package com.zg.project.wisdom.service;
import java.util.List;
import java.util.Map;
import com.zg.project.Inventory.domain.dto.QueryDTO;
import com.zg.project.Inventory.domain.vo.ChartDataVO;
import com.zg.project.wisdom.domain.RkInfo;
import com.zg.project.wisdom.domain.dto.*;
import com.zg.project.wisdom.domain.vo.RkSubmitResultVO;
import com.zg.project.wisdom.domain.vo.BillGroupVO;
/**
* 库存单据主Service接口
@@ -27,12 +26,17 @@ public interface IRkInfoService
/**
* 查询库存单据主列表
*
*
* @param rkInfo 库存单据主
* @return 库存单据主集合
*/
public List<RkInfo> selectRkInfoList(RkInfo rkInfo);
/**
* 按 bill_no 分组返回单据列表(复用 selectRkInfoVoSQL 外层分组聚合)
* @param query 与 /list 相同的查询条件
*/
List<RkInfo> selectGroupedByBill(RkInfoQueryDTO query);
/**
* 修改库存单据主

View File

@@ -0,0 +1,40 @@
package com.zg.project.wisdom.service;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
import java.util.Map;
/**
* 【照片上传服务接口】
* - 定义:多图落盘 + URL入库 的能力
*/
public interface PhotoService {
/**
* 多图上传并落库返回每张图片的可访问URL
*
* @param files 图片文件列表(必传)
* @param photoType 照片业务类型0=入库相关1=出库相关(必填)
* @param billNo 单据号(必填,用于关联)
* @param xmMs 项目描述(用于文件命名,可为空)
* @return URLs 列表(每个元素都是可直接 <img src> 的地址)
*/
List<String> uploadAndSave(List<MultipartFile> files,
String photoType, String billNo,
String xmMs);
/**
* 按 billNo可选 photoType查询照片 URL 列表
*/
List<String> listUrlsByBill(String billNo, String photoType);
/**
* 按 URL 批量删除(仅数据库软删,不删除磁盘文件)
* @param urls 待删URL列表
* @return 结果统计dbUpdated(数据库更新行数), total(传入数)
*/
Map<String, Object> deleteByUrls(List<String> urls);
}

View File

@@ -80,4 +80,11 @@ public interface RkStatisticsService {
* 返回每个时间桶的一行数据(固定 7 行或 6 行)
*/
List<IOBucketVO> getIOBuckets(Integer range);
/**
* 根据仓库编码,返回各场景的可用库位(含数量与明细)
* 规则:排除 rk_info 中未出库is_chuku=0 或 NULL的占用库位
*/
List<SceneAvailableVO> listAvailableByWarehouse(String warehouseCode);
}

View File

@@ -0,0 +1,131 @@
package com.zg.project.wisdom.service.impl;
import com.zg.common.utils.LocalPhotoUtil;
import com.zg.common.utils.LocalPhotoUtil.SaveResult;
import com.zg.project.wisdom.domain.StockPhoto;
import com.zg.project.wisdom.mapper.StockPhotoMapper;
import com.zg.project.wisdom.service.PhotoService;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import javax.annotation.Resource;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 【照片上传服务实现】
* 核心流程:
* 1) 使用 LocalPhotoUtil 将文件保存到本地photo/yyyy-MM-dd/
* 2) 拼接可访问URL/photo/** 静态映射)
* 3) 批量写入 stock_photo只存一个 url
* 4) 返回 URL 列表
*/
@Service
public class PhotoServiceImpl implements PhotoService {
@Resource
private LocalPhotoUtil localPhotoUtil;
@Resource
private StockPhotoMapper stockPhotoMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public List<String> uploadAndSave(List<MultipartFile> files,
String photoType, String billNo,
String xmMs) {
// ========= 1. 入参校验 =========
if (files == null || files.isEmpty()) {
throw new IllegalArgumentException("请选择要上传的图片");
}
if (!("0".equals(photoType) || "1".equals(photoType))) {
throw new IllegalArgumentException("photoType 只能为 0(入库) 或 1(出库)");
}
if (StringUtils.isBlank(billNo)) {
throw new IllegalArgumentException("billNo 不能为空");
}
// 组装基础文件名billNo_sapNo_xmMs内部会做非法字符清洗
final String baseName = localPhotoUtil.buildBaseName(
StringUtils.defaultString(billNo),
StringUtils.defaultString(xmMs));
try {
// ========= 2. 本地落盘 =========
// 目录D:\photo\photo\yyyy-MM-dd\
List<SaveResult> saved = localPhotoUtil.saveBatch(files, "", baseName);
// ========= 3. 生成URL + 组装入库对象 =========
List<String> urls = new ArrayList<String>(saved.size());
List<StockPhoto> rows = new ArrayList<StockPhoto>(saved.size());
for (SaveResult s : saved) {
// 根据当前上下文拼 URL例如http://host:port/photo/photo/2025-09-01/xxx.jpg
String url = ServletUriComponentsBuilder.fromCurrentContextPath()
.path("/photo/") // 与 StaticResourceConfig.addResourceHandlers 保持一致
.path(s.getWebPath()) // 形如photo/2025-09-01/xxx.jpg
.toUriString();
urls.add(url);
// 只存一个URL到表中其余信息按你最简诉求不存
StockPhoto p = new StockPhoto();
p.setBillNo(billNo);
p.setPhotoType(photoType);
p.setFileName(s.getFileName());
p.setUrl(url);
// 如需记录创建人p.setCreateBy(SecurityUtils.getUserId().toString());
rows.add(p);
}
// ========= 4. 批量入库 =========
if (!rows.isEmpty()) {
stockPhotoMapper.batchInsert(rows);
}
return urls;
} catch (Exception e) {
// Service 层抛出 RuntimeException触发 @Transactional 回滚数据库写入
// (注意:文件已落盘无法自动回滚,必要时可加失败清理逻辑)
throw new RuntimeException("上传/保存失败:" + e.getMessage(), e);
}
}
@Override
public List<String> listUrlsByBill(String billNo, String photoType) {
if (StringUtils.isBlank(billNo)) {
throw new IllegalArgumentException("billNo 不能为空");
}
return stockPhotoMapper.selectByBillNo(billNo,
StringUtils.isBlank(photoType) ? null : photoType)
.stream()
.map(StockPhoto::getUrl)
.collect(Collectors.toList());
}
/**
* 批量删除
* @param urls 待删URL列表
* @return
*/
@Override
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> deleteByUrls(List<String> urls) {
if (urls == null || urls.isEmpty()) {
throw new IllegalArgumentException("urls 不能为空");
}
// 仅数据库软删
int dbUpdated = stockPhotoMapper.softDeleteByUrls(urls);
Map<String, Object> ret = new HashMap<String, Object>(2);
ret.put("total", urls.size());
ret.put("dbUpdated", dbUpdated);
return ret;
}
}

View File

@@ -125,6 +125,14 @@ public class RkInfoServiceImpl implements IRkInfoService
return list;
}
@Override
public List<RkInfo> selectGroupedByBill(RkInfoQueryDTO query) {
// 读取审核开关1=开启;其它=关闭)
boolean needAudit = "1".equals(configService.selectConfigByKey("rk.audit.enabled"));
// 直接传给 Mapper不改 DTO 结构,走多参数方式
return rkInfoMapper.selectGroupedByBill(query, needAudit ? 1 : 0);
}
/**
* 修改库存单据主
*

View File

@@ -388,4 +388,30 @@ public class RkStatisticsServiceImpl implements RkStatisticsService {
Set<String> inProjects = new HashSet<>();
Set<String> outProjects = new HashSet<>();
}
@Override
public List<SceneAvailableVO> listAvailableByWarehouse(String warehouseCode) {
List<SceneAvailableVO> list = rkStatisticsMapper.selectAvailableByWarehouse(warehouseCode);
if (list == null || list.isEmpty()) {
// 没有查到任何数据时,返回一个默认对象
SceneAvailableVO vo = new SceneAvailableVO();
// vo.setSceneCode("N/A"); // 默认场景编码
// vo.setSceneName("无可用场景"); // 默认场景名称
vo.setAvailableCount(0); // 可用数量为0
vo.setPositions(new ArrayList<>()); // 空数组
list = new ArrayList<>();
list.add(vo);
} else {
// 保证 positions 不为 null
for (SceneAvailableVO vo : list) {
if (vo.getPositions() == null) {
vo.setPositions(new ArrayList<>());
}
}
}
return list;
}
}

View File

@@ -56,9 +56,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
<if test="sapNo != null and sapNo != ''">
and sap_no like concat('%', #{sapNo}, '%')
</if>
<if test="status != null and status != ''">
and status = #{status}
</if>
and (status is null or trim(status) != '1')
<if test="isDelete != null and isDelete != ''">
and is_delete = #{isDelete}
</if>

View File

@@ -317,6 +317,244 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
ORDER BY ri.rk_time DESC
</select>
<!--
按单据分组bill_no列表
复用 <sql id="selectRkInfoVo"> 作为子查询,外层分组聚合
返回字段bill_no、bill_no_ck + 你指定的通用字段rk_type、wl_type、cangku、rk_time、lihuo_y、is_chuku、xj、
xm_no、xm_ms、xm_no_ck、xm_ms_ck、wl_no、wl_ms、gys_no、sap_no
-->
<select id="selectGroupedByBill" resultMap="RkInfoResult" parameterType="map">
SELECT
/* ============= 主键:取单据内任一(用最小 ID 代表) ============= */
MIN(t.id) AS id,
/* ============= 单据号 ============= */
t.bill_no AS bill_no, -- 入库单据号(分组键)
ANY_VALUE(t.bill_no_ck) AS bill_no_ck, -- 出库单据号(若单据发生过出库则可能有值)
/* ============= 通用字段(代表值/聚合) ============= */
ANY_VALUE(t.rk_type) AS rk_type, -- 入库类型编码type_code
-- 入库类型名称(由 stock_in_type 反查)
(SELECT si.type_name
FROM stock_in_type si
WHERE si.type_code = ANY_VALUE(t.rk_type)
LIMIT 1) AS rk_type_name,
ANY_VALUE(t.wl_type) AS wl_type, -- 物资类型
ANY_VALUE(t.cangku) AS cangku, -- 仓库(编码)
MIN(t.rk_time) AS rk_time, -- 入库时间(单据内最早)
ANY_VALUE(t.lihuo_y) AS lihuo_y, -- 理货员ID
MAX(t.is_chuku) AS is_chuku, -- 是否出库(单据内只要有出库,用最大值反映)
ANY_VALUE(t.xj) AS xj, -- 县局
ANY_VALUE(t.xm_no) AS xm_no, -- 入库项目号
ANY_VALUE(t.xm_ms) AS xm_ms, -- 入库项目描述
ANY_VALUE(t.xm_no_ck) AS xm_no_ck, -- 出库项目号
ANY_VALUE(t.xm_ms_ck) AS xm_ms_ck, -- 出库项目描述
ANY_VALUE(t.wl_no) AS wl_no, -- 物料号
ANY_VALUE(t.wl_ms) AS wl_ms, -- 物料描述
ANY_VALUE(t.gys_no) AS gys_no, -- 供应商编码
ANY_VALUE(t.sap_no) AS sap_no, -- SAP 订单号
/* ============= 出库附加信息 ============= */
ANY_VALUE(t.ck_type) AS ck_type, -- 出库类型编码
(SELECT s.type_name
FROM stock_out_type s
WHERE s.type_code = ANY_VALUE(t.ck_type)
LIMIT 1) AS ck_type_name, -- 出库类型名称
MAX(t.ly_time) AS ly_time, -- 领用时间(单据内最新)
ANY_VALUE(t.ck_lihuo_y) AS ck_lihuo_y, -- 出库理货员ID
(SELECT u.user_name
FROM sys_user u
WHERE u.user_id = ANY_VALUE(t.ck_lihuo_y)
LIMIT 1) AS ck_lihuo_y_name -- 出库理货员姓名
FROM (
<!-- 这里完全复用你已有的 SELECT + JOIN 片段,列别名与 RkInfoResult 一致 -->
<include refid="selectRkInfoVo"/>
) t
<where>
<!-- ================= 与 /list 相同的筛选条件(把 ri.* 改为 t.* ================= -->
<!-- is_chuku优先列表其次单值 -->
<choose>
<when test="q.isChukuList != null and q.isChukuList.size > 0">
AND t.is_chuku IN
<foreach collection="q.isChukuList" item="val" open="(" separator="," close=")">
#{val}
</foreach>
</when>
<when test="q.isChuku != null and q.isChuku != ''">
AND t.is_chuku = #{q.isChuku}
</when>
</choose>
<!-- 关键词模糊 -->
<if test="q.keyword != null and q.keyword != ''">
AND (
t.xm_no like concat('%', #{q.keyword}, '%')
or t.xm_ms like concat('%', #{q.keyword}, '%')
or t.wl_no like concat('%', #{q.keyword}, '%')
or t.wl_ms like concat('%', #{q.keyword}, '%')
or t.gys_no like concat('%', #{q.keyword}, '%')
or t.gys_mc like concat('%', #{q.keyword}, '%')
or t.sap_no like concat('%', #{q.keyword}, '%')
or t.bill_no like concat('%', #{q.keyword}, '%')
or t.bill_no_ck like concat('%', #{q.keyword}, '%')
or t.ck_type like concat('%', #{q.keyword}, '%')
or t.pcode like concat('%', #{q.keyword}, '%')
)
</if>
<!-- 维度筛选 -->
<if test="q.rkType != null and q.rkType != ''">
AND t.rk_type like concat('%', #{q.rkType}, '%')
</if>
<if test="q.wlType != null and q.wlType != ''">
AND t.wl_type like concat('%', #{q.wlType}, '%')
</if>
<if test="q.cangku != null and q.cangku != ''">
AND t.cangku like concat('%', #{q.cangku}, '%')
</if>
<!-- ID 列表 -->
<if test="q.ids != null and q.ids.size > 0">
AND t.id IN
<foreach collection="q.ids" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</if>
<!-- 入库时间范围(闭区间) -->
<if test="q.startTime != null"><![CDATA[
AND t.rk_time >= #{q.startTime}
]]></if>
<if test="q.endTime != null"><![CDATA[
AND t.rk_time <= #{q.endTime}
]]></if>
<!-- 领用时间范围(闭区间) -->
<if test="q.lyStartTime != null"><![CDATA[
AND t.ly_time >= #{q.lyStartTime}
]]></if>
<if test="q.lyEndTime != null"><![CDATA[
AND t.ly_time <= #{q.lyEndTime}
]]></if>
<!-- 其它模糊/等值 -->
<if test="q.lihuoY != null and q.lihuoY != ''">
AND t.lihuo_y like concat('%', #{q.lihuoY}, '%')
</if>
<if test="q.xj != null and q.xj != ''">
AND t.xj like concat('%', #{q.xj}, '%')
</if>
<if test="q.billNo != null and q.billNo != ''">
AND t.bill_no like concat('%', #{q.billNo}, '%')
</if>
<if test="q.billNoCk != null and q.billNoCk != ''">
AND t.bill_no_ck like concat('%', #{q.billNoCk}, '%')
</if>
<if test="q.xmNo != null and q.xmNo != ''">
AND t.xm_no like concat('%', #{q.xmNo}, '%')
</if>
<if test="q.xmMs != null and q.xmMs != ''">
AND t.xm_ms like concat('%', #{q.xmMs}, '%')
</if>
<if test="q.wlNo != null and q.wlNo != ''">
AND t.wl_no like concat('%', #{q.wlNo}, '%')
</if>
<if test="q.wlMs != null and q.wlMs != ''">
AND t.wl_ms like concat('%', #{q.wlMs}, '%')
</if>
<if test="q.gysNo != null and q.gysNo != ''">
AND t.gys_no like concat('%', #{q.gysNo}, '%')
</if>
<if test="q.gysMc != null and q.gysMc != ''">
AND t.gys_mc like concat('%', #{q.gysMc}, '%')
</if>
<if test="q.jhAmt != null">
AND t.jh_amt = #{q.jhAmt}
</if>
<if test="q.htDj != null">
AND t.ht_dj = #{q.htDj}
</if>
<if test="q.sapNo != null and q.sapNo != ''">
AND t.sap_no like concat('%', #{q.sapNo}, '%')
</if>
<if test="q.xh != null and q.xh != ''">
AND t.xh like concat('%', #{q.xh}, '%')
</if>
<if test="q.jhQty != null">
AND t.jh_qty = #{q.jhQty}
</if>
<if test="q.htQty != null">
AND t.ht_qty = #{q.htQty}
</if>
<if test="q.dw != null and q.dw != ''">
AND t.dw like concat('%', #{q.dw}, '%')
</if>
<if test="q.realQty != null">
AND t.real_qty = #{q.realQty}
</if>
<if test="q.pcode != null and q.pcode != ''">
AND t.pcode like concat('%', #{q.pcode}, '%')
</if>
<if test="q.lyTime != null">
AND t.ly_time = #{q.lyTime}
</if>
<if test="q.returnTime != null">
AND t.return_time = #{q.returnTime}
</if>
<if test="q.trayCode != null and q.trayCode != ''">
AND t.tray_code like concat('%', #{q.trayCode}, '%')
</if>
<if test="q.entityId != null and q.entityId != ''">
AND t.entity_id like concat('%', #{q.entityId}, '%')
</if>
<if test="q.ckType != null and q.ckType != ''">
AND t.ck_type like concat('%', #{q.ckType}, '%')
</if>
<!-- 若查询出库单据:单值 is_chuku=1 或 列表包含 1都要求已生成出库单号 -->
<if test="(q.isChuku != null and q.isChuku == 1)
or (q.isChukuList != null and q.isChukuList.size > 0 and q.isChukuList.contains(1))">
AND t.bill_no_ck IS NOT NULL
</if>
<!-- 删除标记(默认 0 -->
<choose>
<when test="q.isDelete != null and q.isDelete != ''">
AND t.is_delete = #{q.isDelete}
</when>
<otherwise>
AND t.is_delete = 0
</otherwise>
</choose>
<!-- 审核开启:剔除“审核失败”的明细(未审核/通过保留) -->
<if test="needAudit != null and needAudit == 1">
AND NOT EXISTS (
SELECT 1
FROM audit_signature asg
WHERE asg.rk_id = t.id
AND asg.approver_id IS NOT NULL
AND (asg.audit_result IS NOT NULL AND asg.audit_result != '1')
)
</if>
</where>
GROUP BY t.bill_no
ORDER BY MIN(t.rk_time) DESC
</select>
<select id="selectRkInfoById" parameterType="Long" resultMap="RkInfoResult">
<include refid="selectRkInfoVo"/>

View File

@@ -484,4 +484,59 @@
AND ri.update_time <![CDATA[<]]> #{endTs})
)
</select>
<!-- 外层聚合到 SceneAvailableVO并级联 positionsPcdeDetailSimpleVO 列表) -->
<resultMap id="SceneAvailableMap"
type="com.zg.project.wisdom.domain.vo.SceneAvailableVO">
<result property="sceneCode" column="sceneCode"/>
<result property="sceneName" column="sceneName"/>
<result property="availableCount" column="availableCount"/>
<!-- 把外层 select 的 warehouseCode、sceneCode 作为参数传给内层查询 -->
<collection property="positions"
ofType="com.zg.project.wisdom.domain.vo.PcdeDetailSimpleVO"
select="selectAvailablePositionsByWarehouseAndScene"
column="{warehouseCode=warehouseCode,sceneCode=sceneCode}"/>
</resultMap>
<!-- 外层:按场景统计可用数量(并输出 warehouseCode、sceneCode 提供给内层) -->
<select id="selectAvailableByWarehouse" parameterType="string"
resultMap="SceneAvailableMap">
SELECT
#{warehouseCode} AS warehouseCode, -- 传给内层用
p.scene AS sceneCode,
sm.scene_name AS sceneName,
COUNT(*) AS availableCount
FROM pcde_detail p
LEFT JOIN scene_mapping sm
ON sm.scene_code = p.scene
LEFT JOIN rk_info ri
ON ri.pcode = p.pcode
AND ri.is_delete = 0
AND (ri.is_chuku = 0 OR ri.is_chuku IS NULL) -- 未出库 => 占用
WHERE p.is_delete = '0'
AND p.warehouse = #{warehouseCode}
AND ri.id IS NULL -- 无占用 => 可用
GROUP BY p.scene, sm.scene_name
ORDER BY p.scene
</select>
<!-- 内层:指定仓库 + 场景,查询该场景的可用库位明细 -->
<select id="selectAvailablePositionsByWarehouseAndScene"
resultType="com.zg.project.wisdom.domain.vo.PcdeDetailSimpleVO">
SELECT
p.pcode,
p.tag,
p.encoded_id AS encodedId
FROM pcde_detail p
LEFT JOIN rk_info ri
ON ri.pcode = p.pcode
AND ri.is_delete = 0
AND (ri.is_chuku = 0 OR ri.is_chuku IS NULL)
WHERE p.is_delete = '0'
AND p.warehouse = #{warehouseCode}
AND p.scene = #{sceneCode}
AND ri.id IS NULL
ORDER BY p.pcode
</select>
</mapper>

View File

@@ -0,0 +1,72 @@
<?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 XML】
- 只存一个可访问URL
- 常用:批量插入 + 按 bill_no 查询
-->
<mapper namespace="com.zg.project.wisdom.mapper.StockPhotoMapper">
<!-- 实体映射 -->
<resultMap id="StockPhotoMap" type="com.zg.project.wisdom.domain.StockPhoto">
<id column="id" property="id"/>
<result column="bill_no" property="billNo"/>
<result column="photo_type" property="photoType"/>
<result column="file_name" property="fileName"/>
<result column="url" property="url"/>
<result column="is_delete" property="isDelete"/>
<result column="create_by" property="createBy"/>
<result column="create_time" property="createTime"/>
<result column="update_by" property="updateBy"/>
<result column="update_time" property="updateTime"/>
</resultMap>
<!-- 单条插入:用于调试或单图场景 -->
<insert id="insert" parameterType="com.zg.project.wisdom.domain.StockPhoto"
useGeneratedKeys="true" keyProperty="id">
INSERT INTO stock_photo (
bill_no, photo_type, file_name, url,
is_delete, create_by, create_time
) VALUES (
#{billNo}, #{photoType}, #{fileName}, #{url},
'0', #{createBy}, NOW()
)
</insert>
<!-- 批量插入:多图上传时使用 -->
<insert id="batchInsert" parameterType="java.util.List">
INSERT INTO stock_photo (
bill_no, photo_type, file_name, url,
is_delete, create_by, create_time
) VALUES
<foreach collection="list" item="it" separator=",">
(#{it.billNo}, #{it.photoType}, #{it.fileName}, #{it.url},
'0', #{it.createBy}, NOW())
</foreach>
</insert>
<!-- 按 bill_no 查询(可选按 photoType 过滤) -->
<select id="selectByBillNo" resultMap="StockPhotoMap">
SELECT *
FROM stock_photo
WHERE bill_no = #{billNo}
<if test="photoType != null and photoType != ''">
AND photo_type = #{photoType}
</if>
AND is_delete = '0'
ORDER BY create_time DESC
</select>
<update id="softDeleteByUrls">
UPDATE stock_photo
SET is_delete = '1',
update_time = NOW()
WHERE url IN
<foreach collection="urls" item="u" open="(" separator="," close=")">
#{u}
</foreach>
</update>
</mapper>