云
13 小时以前 02a1ae81eba7b48223052fb833859f0a65dd7d21
feat(mes): 实现袋码大批量生成异步任务功能

- 新增袋码批量生成配置属性,支持同步/异步分界线设置
- 实现批量取码Redis脚本优化,减少网络往返次数
- 添加袋码生成任务数据对象和状态管理机制
- 实现分段落库和后台任务执行器,支持进度跟踪和取消
- 扩展袋码生成接口,支持同步/异步双模式自动切换
- 添加任务查询和取消API,支持前端轮询进度显示
- 实现生成失败自动清理机制,避免残留半成品数据
- 新增幂等键机制防止网络重试导致重复生成
- 添加相关错误码定义和文档说明
已添加26个文件
已修改12个文件
2409 ■■■■■ 文件已修改
TempDbAdmin.class 补丁 | 查看 | 原始文档 | blame | 历史
TempDbAdmin.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
TempDbCleanup.class 补丁 | 查看 | 原始文档 | blame | 历史
TempDbCleanup.java 232 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
db-backups/mom-thzynew-business-cleanup-20261019-171745.sql.gz 补丁 | 查看 | 原始文档 | blame | 历史
db-backups/mom-thzynew-business-cleanup-20261019-171745.sql.gz.manifest 125 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
docs/bag_code_async_generate_plan.md 309 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/mes_pro_bag_code_bag_no_index.sql 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/mes_pro_bag_code_generate_task.sql 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
sql/mysql/mes_pro_pallet_generate_uuid.sql 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/bagcode/MesProBagCodeController.java 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/bagcode/vo/MesProBagCodeGenerateReqVO.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/bagcode/vo/MesProBagCodeGenerateRespVO.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/bagcode/vo/MesProBagCodeGenerateTaskRespVO.java 63 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/bagcode/vo/MesProBagCodeRawGenerateReqVO.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pro/bagcode/MesProBagCodeGenerateTaskDO.java 105 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pro/pallet/MesProPalletDO.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/bagcode/MesProBagCodeGenerateTaskMapper.java 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/bagcode/MesProBagCodeMapper.java 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/pallet/MesProPalletMapper.java 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/redis/MesCodeRedisDAO.java 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/pro/MesProBagCodeGenerateBizTypeEnum.java 42 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/pro/MesProBagCodeGenerateTaskStatusEnum.java 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/bagcode/MesProBagCodeGenerateExecutorConfig.java 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/bagcode/MesProBagCodeGenerateProperties.java 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/bagcode/MesProBagCodeGenerateTaskRegistry.java 108 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/job/pro/MesProBagCodeGenerateCleanupTask.java 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/MesProBagCodeBatchGenerator.java 186 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/MesProBagCodeChunkWriter.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/MesProBagCodeGenerateCleaner.java 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/MesProBagCodeGenerateRunner.java 90 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/MesProBagCodeGenerateSpec.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/MesProBagCodeGenerateTaskService.java 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/MesProBagCodeGenerateTaskServiceImpl.java 216 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/MesProBagCodeServiceImpl.java 151 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/BagBusinessCodeTest.java 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/application.yaml 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
TempDbAdmin.class
Binary files differ
TempDbAdmin.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,34 @@
import java.nio.file.*;
import java.sql.*;
import java.util.*;
public class TempDbAdmin {
    public static void main(String[] args) throws Exception {
        String yaml = Files.readString(Path.of("yudao-server/src/main/resources/application-thzy.yaml"));
        String block = yaml.substring(yaml.indexOf("master:"));
        String[] lines = block.split("\\R");
        String user = null, pass = null;
        boolean master = false;
        for (String line : lines) {
            if (line.trim().equals("master:")) { master = true; continue; }
            if (!master) continue;
            if (line.trim().startsWith("username:")) user = line.trim().substring(9).trim().split("\\s+#", 2)[0];
            if (line.trim().startsWith("password:")) { pass = line.trim().substring(9).trim().split("\\s+#", 2)[0]; break; }
        }
        if (user == null || pass == null) throw new IllegalStateException("Missing database credentials");
        String url = "jdbc:mysql://1.95.78.124:9000/mom-thzynew?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true";
        try (Connection conn = DriverManager.getConnection(url, user, pass)) {
            System.out.println("DATABASE=" + conn.getCatalog());
            if (args.length == 0) return;
            try (Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery(args[0])) {
                ResultSetMetaData md = rs.getMetaData();
                for (int i = 1; i <= md.getColumnCount(); i++) System.out.print((i > 1 ? "\t" : "") + md.getColumnLabel(i));
                System.out.println();
                while (rs.next()) {
                    for (int i = 1; i <= md.getColumnCount(); i++) System.out.print((i > 1 ? "\t" : "") + Objects.toString(rs.getObject(i), "NULL"));
                    System.out.println();
                }
            }
        }
    }
}
TempDbCleanup.class
Binary files differ
TempDbCleanup.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,232 @@
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.security.MessageDigest;
import java.sql.*;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.zip.GZIPOutputStream;
/** Temporary, scoped administrator for the requested mom-thzynew data cleanup. */
public class TempDbCleanup {
    private static final String DB = "mom-thzynew";
    private static final Set<String> PRO_TABLES = Set.of(
            "mes_pro_bag_code", "mes_pro_bag_code_replace", "mes_pro_batch",
            "mes_pro_batch_worker", "mes_pro_pallet", "mes_pro_qr_export_task",
            "mes_pro_scan_event");
    private static final Set<String> KEEP = Set.of("mes_wm_warehouse", "mes_wm_warehouse_area",
            "mes_wm_warehouse_location", "mes_pro_line");
    public static void main(String[] args) throws Exception {
        if (args.length < 1 || !Set.of("inventory", "backup", "clean").contains(args[0])
                || (args[0].equals("clean") ? args.length != 2 : args.length != 1)) {
            throw new IllegalArgumentException("Usage: TempDbCleanup inventory|backup|clean <backup.sql.gz>");
        }
        String yaml = Files.readString(Path.of("yudao-server/src/main/resources/application-thzy.yaml"));
        String block = yaml.substring(yaml.indexOf("master:"));
        String user = null, pass = null;
        for (String line : block.split("\\R")) {
            if (line.trim().startsWith("username:")) user = value(line);
            if (line.trim().startsWith("password:")) { pass = value(line); break; }
        }
        if (user == null || pass == null) throw new IllegalStateException("Missing database credentials");
        String url = "jdbc:mysql://1.95.78.124:9000/" + DB
                + "?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&useCursorFetch=true";
        try (Connection conn = DriverManager.getConnection(url, user, pass)) {
            if (!DB.equals(conn.getCatalog())) throw new IllegalStateException("Wrong database: " + conn.getCatalog());
            List<String> tables = targetTables(conn);
            System.out.println("DATABASE=" + DB + " TARGET_TABLES=" + tables.size());
            if (args[0].equals("inventory")) inventory(conn, tables);
            if (args[0].equals("backup")) backup(conn, tables);
            if (args[0].equals("clean")) clean(conn, tables, Path.of(args[1]));
        }
    }
    private static String value(String line) {
        return line.trim().split(":", 2)[1].trim().split("\\s+#", 2)[0];
    }
    private static List<String> targetTables(Connection conn) throws SQLException {
        List<String> names = new ArrayList<>();
        try (Statement s = conn.createStatement(); ResultSet r = s.executeQuery(
                "SELECT table_name FROM information_schema.tables WHERE table_schema='" + DB + "' "
                + "AND table_type='BASE TABLE' ORDER BY table_name")) {
            while (r.next()) {
                String t = r.getString(1);
                if (KEEP.contains(t)) continue;
                if (PRO_TABLES.contains(t) || t.startsWith("mes_qc_") || t.startsWith("mes_wm_")
                        || t.startsWith("mes_trace_") || t.startsWith("crm_")) names.add(t);
            }
        }
        return names;
    }
    private static long count(Connection conn, String table) throws SQLException {
        try (Statement s = conn.createStatement(); ResultSet r = s.executeQuery("SELECT COUNT(*) FROM `" + table + "`")) {
            r.next(); return r.getLong(1);
        }
    }
    private static void inventory(Connection conn, List<String> tables) throws SQLException {
        for (String t : tables) {
            long n = count(conn, t);
            if (n > 0) System.out.println(t + "=" + n);
        }
        for (String t : KEEP) System.out.println("KEEP " + t + "=" + count(conn, t));
        try (Statement s = conn.createStatement(); ResultSet r = s.executeQuery(
                "SELECT status, COUNT(*), SUM(total_count-finished_count) FROM mes_pro_qr_export_task "
                        + "WHERE status IN (10,20) GROUP BY status")) {
            while (r.next()) System.out.println("ACTIVE_EXPORT status=" + r.getInt(1)
                    + " tasks=" + r.getLong(2) + " remaining=" + r.getLong(3));
        }
    }
    private static void requireIdle(Connection conn) throws SQLException {
        try (Statement s = conn.createStatement(); ResultSet r = s.executeQuery(
                "SELECT COUNT(*) FROM mes_pro_qr_export_task WHERE status IN (10,20)")) {
            r.next();
            if (r.getLong(1) != 0) throw new IllegalStateException("QR export task still active; aborting");
        }
    }
    private static void backup(Connection conn, List<String> tables) throws Exception {
        requireIdle(conn);
        Path dir = Path.of("db-backups");
        Files.createDirectories(dir);
        String stamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"));
        Path dest = dir.resolve(DB + "-business-cleanup-" + stamp + ".sql.gz");
        Path temp = dir.resolve(dest.getFileName() + ".part");
        long total = 0;
        Properties manifest = new Properties();
        manifest.setProperty("database", DB);
        manifest.setProperty("table.count", Integer.toString(tables.size()));
        conn.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ);
        conn.setAutoCommit(false);
        try (BufferedWriter out = new BufferedWriter(new OutputStreamWriter(
                new GZIPOutputStream(Files.newOutputStream(temp), 1024 * 1024), StandardCharsets.UTF_8), 1024 * 1024)) {
            out.write("-- mom-thzynew requested business-data backup; restore only to the intended database\n");
            out.write("SET NAMES utf8mb4;\nSET sql_mode='';\n");
            for (String table : tables) {
                long rows = 0;
                try (Statement s = conn.createStatement(ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY)) {
                    s.setFetchSize(1000);
                    try (ResultSet r = s.executeQuery("SELECT * FROM `" + table + "`")) {
                        ResultSetMetaData md = r.getMetaData();
                        int cols = md.getColumnCount();
                        StringJoiner columns = new StringJoiner(",");
                        for (int i = 1; i <= cols; i++) columns.add("`" + md.getColumnName(i) + "`");
                        String prefix = "INSERT INTO `" + table + "` (" + columns + ") VALUES (";
                        while (r.next()) {
                            out.write(prefix);
                            for (int i = 1; i <= cols; i++) {
                                if (i > 1) out.write(',');
                                writeSqlValue(out, r, md, i);
                            }
                            out.write(");\n");
                            rows++;
                        }
                    }
                }
                total += rows;
                manifest.setProperty("row." + table, Long.toString(rows));
                if (rows > 0) System.out.println("BACKED_UP " + table + "=" + rows);
            }
            out.write("-- backup complete: " + total + " rows\n");
            conn.rollback();
        } catch (Exception e) {
            conn.rollback();
            Files.deleteIfExists(temp);
            throw e;
        } finally {
            conn.setAutoCommit(true);
        }
        Files.move(temp, dest, StandardCopyOption.ATOMIC_MOVE);
        manifest.setProperty("sha256", sha256(dest));
        manifest.setProperty("rows.total", Long.toString(total));
        try (OutputStream m = Files.newOutputStream(Path.of(dest + ".manifest"))) {
            manifest.store(m, "mom-thzynew business data backup manifest");
        }
        System.out.println("BACKUP_FILE=" + dest.toAbsolutePath() + " ROWS=" + total
                + " BYTES=" + Files.size(dest));
    }
    private static void writeSqlValue(Writer out, ResultSet r, ResultSetMetaData md, int i) throws Exception {
        int type = md.getColumnType(i);
        if (type == Types.BINARY || type == Types.VARBINARY || type == Types.LONGVARBINARY
                || type == Types.BLOB || type == Types.BIT) {
            byte[] bytes = r.getBytes(i);
            if (bytes == null) { out.write("NULL"); return; }
            out.write("X'");
            for (byte b : bytes) out.write(String.format("%02X", b & 0xff));
            out.write("'");
            return;
        }
        String value = r.getString(i);
        if (value == null) { out.write("NULL"); return; }
        if (type == Types.INTEGER || type == Types.BIGINT || type == Types.SMALLINT
                || type == Types.TINYINT || type == Types.DECIMAL || type == Types.NUMERIC
                || type == Types.DOUBLE || type == Types.FLOAT || type == Types.REAL) {
            out.write(value); return;
        }
        out.write('\'');
        for (int p = 0; p < value.length(); p++) {
            char c = value.charAt(p);
            switch (c) {
                case '\\' -> out.write("\\\\");
                case '\'' -> out.write("\\'");
                case '\n' -> out.write("\\n");
                case '\r' -> out.write("\\r");
                case '\0' -> out.write("\\0");
                case 26 -> out.write("\\Z");
                default -> out.write(c);
            }
        }
        out.write('\'');
    }
    private static String sha256(Path path) throws Exception {
        MessageDigest digest = MessageDigest.getInstance("SHA-256");
        try (InputStream in = Files.newInputStream(path)) {
            byte[] buf = new byte[1024 * 1024];
            int n;
            while ((n = in.read(buf)) != -1) digest.update(buf, 0, n);
        }
        return HexFormat.of().formatHex(digest.digest());
    }
    private static void clean(Connection conn, List<String> tables, Path backup) throws Exception {
        requireIdle(conn);
        Properties manifest = new Properties();
        try (InputStream in = Files.newInputStream(Path.of(backup + ".manifest"))) { manifest.load(in); }
        if (!DB.equals(manifest.getProperty("database"))
                || tables.size() != Integer.parseInt(manifest.getProperty("table.count"))
                || !sha256(backup).equals(manifest.getProperty("sha256"))) {
            throw new IllegalStateException("Backup integrity or target mismatch");
        }
        // Verify every table before any destructive statement. New writes cause a hard abort.
        Map<String, Long> expected = new LinkedHashMap<>();
        for (String table : tables) {
            String raw = manifest.getProperty("row." + table);
            if (raw == null) throw new IllegalStateException("Missing backup count: " + table);
            long n = Long.parseLong(raw);
            long now = count(conn, table);
            if (n != now) throw new IllegalStateException("Table changed since backup: " + table
                    + " backup=" + n + " current=" + now);
            expected.put(table, n);
        }
        for (String table : List.of("mes_pro_line", "mes_wm_warehouse", "mes_wm_warehouse_area",
                "mes_wm_warehouse_location")) {
            System.out.println("PRESERVE " + table + "=" + count(conn, table));
        }
        for (Map.Entry<String, Long> entry : expected.entrySet()) {
            if (entry.getValue() == 0) continue;
            requireIdle(conn);
            try (Statement s = conn.createStatement()) {
                s.executeUpdate("TRUNCATE TABLE `" + entry.getKey() + "`");
            }
            System.out.println("CLEARED " + entry.getKey() + "=" + entry.getValue());
        }
        inventory(conn, tables);
    }
}
db-backups/mom-thzynew-business-cleanup-20261019-171745.sql.gz
Binary files differ
db-backups/mom-thzynew-business-cleanup-20261019-171745.sql.gz.manifest
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,125 @@
#mom-thzynew business data backup manifest
#Mon Oct 19 17:23:57 CST 2026
database=mom-thzynew
row.crm_business=0
row.crm_business_product=0
row.crm_business_status=0
row.crm_business_status_type=0
row.crm_clue=0
row.crm_contact=0
row.crm_contact_business=0
row.crm_contract=0
row.crm_contract_config=0
row.crm_contract_product=0
row.crm_customer=0
row.crm_customer_limit_config=0
row.crm_customer_pool_config=0
row.crm_follow_up_record=0
row.crm_invoice=0
row.crm_permission=0
row.crm_receivable=0
row.crm_receivable_plan=0
row.crm_sale_quotation=0
row.crm_sale_quotation_product=0
row.crm_sales_target=0
row.mes_pro_bag_code=1436003
row.mes_pro_bag_code_replace=0
row.mes_pro_batch=0
row.mes_pro_batch_worker=0
row.mes_pro_pallet=25600
row.mes_pro_qr_export_task=16
row.mes_pro_scan_event=0
row.mes_qc_approver_config=0
row.mes_qc_defect=0
row.mes_qc_defect_record=0
row.mes_qc_indicator=0
row.mes_qc_indicator_result=0
row.mes_qc_indicator_result_detail=0
row.mes_qc_ipqc=0
row.mes_qc_ipqc_line=0
row.mes_qc_iqc=0
row.mes_qc_iqc_line=0
row.mes_qc_ncr=0
row.mes_qc_oqc=0
row.mes_qc_oqc_action=0
row.mes_qc_oqc_line=0
row.mes_qc_rqc=0
row.mes_qc_rqc_line=0
row.mes_qc_seed_indicator_range=0
row.mes_qc_seed_quality=0
row.mes_qc_seed_quality_action=0
row.mes_qc_seed_quality_indicator=0
row.mes_qc_seed_quality_source=0
row.mes_qc_template=0
row.mes_qc_template_indicator=0
row.mes_qc_template_item=0
row.mes_trace_anti_config=1
row.mes_trace_consumer_scan_event=62
row.mes_trace_cross_region_warning=0
row.mes_trace_event=0
row.mes_trace_integrity_record=62
row.mes_wm_arrival_notice=0
row.mes_wm_arrival_notice_line=0
row.mes_wm_barcode=0
row.mes_wm_barcode_config=0
row.mes_wm_batch=0
row.mes_wm_item_consume=0
row.mes_wm_item_consume_detail=0
row.mes_wm_item_consume_line=0
row.mes_wm_item_receipt=0
row.mes_wm_item_receipt_detail=0
row.mes_wm_item_receipt_line=0
row.mes_wm_material_stock=0
row.mes_wm_material_stock_bag=0
row.mes_wm_misc_issue=0
row.mes_wm_misc_issue_detail=0
row.mes_wm_misc_issue_line=0
row.mes_wm_misc_receipt=0
row.mes_wm_misc_receipt_detail=0
row.mes_wm_misc_receipt_line=0
row.mes_wm_outsource_issue=0
row.mes_wm_outsource_issue_detail=0
row.mes_wm_outsource_issue_line=0
row.mes_wm_outsource_receipt=0
row.mes_wm_outsource_receipt_detail=0
row.mes_wm_outsource_receipt_line=0
row.mes_wm_package=0
row.mes_wm_package_line=0
row.mes_wm_product_issue=0
row.mes_wm_product_issue_detail=0
row.mes_wm_product_issue_line=0
row.mes_wm_product_produce=0
row.mes_wm_product_produce_detail=0
row.mes_wm_product_produce_line=0
row.mes_wm_product_receipt=0
row.mes_wm_product_receipt_detail=0
row.mes_wm_product_receipt_line=0
row.mes_wm_product_sales=0
row.mes_wm_product_sales_detail=0
row.mes_wm_product_sales_line=0
row.mes_wm_return_issue=0
row.mes_wm_return_issue_detail=0
row.mes_wm_return_issue_line=0
row.mes_wm_return_sales=0
row.mes_wm_return_sales_detail=0
row.mes_wm_return_sales_line=0
row.mes_wm_return_vendor=0
row.mes_wm_return_vendor_detail=0
row.mes_wm_return_vendor_line=0
row.mes_wm_sales_notice=0
row.mes_wm_sales_notice_line=0
row.mes_wm_sn=0
row.mes_wm_stock_reserve=0
row.mes_wm_stock_taking_plan=0
row.mes_wm_stock_taking_plan_param=0
row.mes_wm_stock_taking_task=0
row.mes_wm_stock_taking_task_line=0
row.mes_wm_stock_taking_task_result=0
row.mes_wm_stock_warning=0
row.mes_wm_transaction=0
row.mes_wm_transfer=0
row.mes_wm_transfer_detail=0
row.mes_wm_transfer_line=0
rows.total=1461744
sha256=13f2a900bfcfac80d6aec291b23750a2482ae988d3e8888dacd9f41e46eaa6f5
table.count=119
docs/bag_code_async_generate_plan.md
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,309 @@
# èµ‹ç ç®¡ç†å¤§æ‰¹é‡ç”Ÿæˆæ”¹é€ æ–¹æ¡ˆï¼ˆå¼‚步任务化)
## ä¸€ã€èƒŒæ™¯
「生成二维码数据包」(`POST /mes/pro/bag-code/generate`)与「生成原始数据」(`POST /mes/pro/bag-code/raw-generate`)在几十万条数据量下直接超时,前端收不到响应。
经代码定位,超时不是单一原因,而是三处叠加:
| ç“¶é¢ˆ | ä½ç½® | è¯´æ˜Ž |
|------|------|------|
| é€æ¡å‘ Redis è¦ç  | `MesProBagCodeServiceImpl.java:143-144`、`176-177` | æ¯ä¸ªè¢‹ç ç”Ÿæˆã€Œè¿½æº¯ç  + ä¸šåŠ¡ç ã€ä¸¤æ¬¡ï¼Œ`MesCodeRedisDAO.generate()` æ¯æ¬¡æ‰§è¡Œä¸€æ¬¡ Redis `SADD` å¾€è¿”(`MesCodeRedisDAO.java:53`)。30 ä¸‡è¢‹ = 60 ä¸‡æ¬¡ç½‘络往返,仅此一步即分钟级 |
| å•事务巨量插入 | `MesProBagCodeServiceImpl.java:154`、`185` | `insertBatch` é»˜è®¤ 1000 æ¡/批,但整批在同一个 `@Transactional` å†…完成,几十万行形成超大事务 |
| åŒæ­¥é•¿è¯·æ±‚ | Controller ç›´æŽ¥è¿”回生成结果 | è¯·æ±‚线程从头执行到尾;网关/浏览器超时后连接断开,但服务端仍继续写库。用户重试会再生成一整包重复数据,且无幂等保护 |
此外,`selectMaxBagNo()` å¯¹ `mes_pro_bag_code` åš `MAX(bag_no)`,当前表没有 `bag_no` ç´¢å¼•,数据量上来后每次生成都要全表扫描。
## äºŒã€ç›®æ ‡ä¸Žéžç›®æ ‡
### ç›®æ ‡
1. å‡ åä¸‡æ¡ï¼ˆå•次上限 100 ä¸‡ï¼‰ç”Ÿæˆä¸å†ä¾èµ–单次 HTTP è¯·æ±‚时长,提交后立即返回任务编号。
2. ç”Ÿæˆè¿‡ç¨‹å¯è§è¿›åº¦ã€å¯å–消;失败/取消不留下半成品数据包。
3. æ¶ˆé™¤é€æ¡ Redis å¾€è¿”与超大事务,生成吞吐与数据量解耦。
4. å°æ‰¹é‡ä¿æŒåŒæ­¥ç§’回,不改变使用体验。
5. æŽ¥å£å…·å¤‡å¹‚等性,网络重试不会重复生成。
### éžç›®æ ‡
- ä¸æ”¹é€ ã€Œç ´æŸæ›¿æ¢ã€ã€Œç»‘定批次/托盘」等其他赋码操作;它们本身是少量数据的短事务。
- ä¸åšæœåŠ¡é‡å¯åŽçš„æ–­ç‚¹ç»­è·‘ï¼ˆè§ 5.7 å–舍说明)。
- ä¸è°ƒæ•´ 16 ä½éšæœºç çš„生成规则与 `biz:code:used` é›†åˆæœºåˆ¶ã€‚
## ä¸‰ã€æ€»ä½“方案
采用与现有二维码导出任务(`MesProQrExportTask`)一致的「小批量同步、大批量异步」双路径:
```
前端提交生成
   â”‚
   â”œâ”€ è¢‹ç æ€»æ•° â‰¤ sync-threshold(默认 20000)
   â”‚     â””─ è¯·æ±‚线程内完成:批量取码 â†’ åˆ†æ®µè½åº“ â†’ è¿”回原有结果(async=false)
   â”‚
   â””─ è¢‹ç æ€»æ•° > sync-threshold
         â”œâ”€ è½ä»»åŠ¡è®°å½•ï¼Œæ³¨å†Œè¿è¡Œæ€ï¼ŒæŠ•é€’åŽå°çº¿ç¨‹æ± ï¼Œç«‹å³è¿”å›ž taskId + uuid(async=true)
         â””─ å‰ç«¯æŒ‰ taskId è½®è¯¢è¿›åº¦ï¼›å®ŒæˆåŽåˆ·æ–°åˆ—表;取消则删除已生成的部分数据
```
同步与异步共用同一套「分段生成」执行器,避免两份逻辑。
## å››ã€ä»»åŠ¡æ¨¡åž‹
### 4.1 çŠ¶æ€æœº
| çŠ¶æ€å€¼ | åç§° | è¯´æ˜Ž |
|--------|------|------|
| 10 | æŽ’队中 | å·²è½åº“,等待线程池接管 |
| 20 | ç”Ÿæˆä¸­ | åŽå°çº¿ç¨‹æ‰§è¡Œä¸­ |
| 25 | å–消中 | å·²æ”¶åˆ°å–消请求,正在停止并清理部分数据 |
| 30 | å·²å®Œæˆ | å…¨éƒ¨è¢‹ç ï¼ˆåŠæ‰˜ç›˜ï¼‰å·²è½åº“ |
| 40 | å·²å–消 | ç”¨æˆ·å–消,部分数据已清理 |
| 50 | å·²å¤±è´¥ | å¼‚常终止,部分数据已清理 |
终态:30 / 40 / 50。
### 4.2 è¡¨ç»“æž„
新增 `mes_pro_bag_code_generate_task`:
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| id | BIGINT | ä¸»é”® |
| request_id | VARCHAR(64) | å¹‚等键,唯一索引;前端每次提交生成一个 UUID |
| biz_type | VARCHAR(16) | `PACKAGE`(数据包)/ `RAW`(原始数据) |
| uuid | VARCHAR(32) | æœ¬æ¬¡ç”Ÿæˆç»„ UUID(提交时即生成,失败/取消后用于清理) |
| item_id / item_code / item_name | BIGINT / VARCHAR | å“ç§å¿«ç…§ |
| unit_type | VARCHAR(32) | è®¡é‡å•位类型(吨 / 48袋 / 100袋) |
| per_pallet | INT | æ¯æ‰˜è¢‹æ•°ï¼›å¨ç±»åž‹ä¸º 0 |
| pallet_count | INT | æ‰˜ç›˜æ•°ï¼›åŽŸå§‹æ•°æ®ä¸º 0 |
| total_count | INT | è¢‹ç æ€»æ•° |
| finished_count | INT | å·²ç”Ÿæˆè¢‹ç æ•° |
| status | TINYINT | çŠ¶æ€ï¼Œè§ 4.1 |
| error_msg | VARCHAR(500) | å¤±è´¥åŽŸå›  |
| finish_time | DATETIME | å®Œæˆ/终止时间 |
| creator / create_time / updater / update_time / deleted | â€” | æ²¿ç”¨ `BaseDO` |
索引:
- `UNIQUE KEY uk_request_id (request_id)`
- `KEY idx_status (status)`
- `KEY idx_uuid (uuid)`
迁移脚本:`sql/mysql/mes_pro_bag_code_generate_task.sql`。
### 4.3 è¢‹ç è¡¨ç´¢å¼•
新增 `KEY idx_bag_no (bag_no)`,用于 `selectMaxBagNo()` ä¸Žè¢‹åºå·æŽ’序。
迁移脚本:`sql/mysql/mes_pro_bag_code_bag_no_index.sql`(先查 `information_schema.statistics` å†å»ºï¼Œå…¼å®¹é‡å¤æ‰§è¡Œï¼‰ã€‚
## äº”、详细设计
### 5.1 æŽ¥å£
#### 1)生成数据包 / åŽŸå§‹æ•°æ®ï¼ˆæ²¿ç”¨åŽŸè·¯å¾„ï¼‰
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| POST | `/mes/pro/bag-code/generate` | åˆå¹¶åŽçš„æ•°æ®åŒ…生成入口 |
| POST | `/mes/pro/bag-code/raw-generate` | åŽŸå§‹è¢‹ç ç”Ÿæˆå…¥å£ |
请求参数不变。响应扩展:
| å­—段 | ç±»åž‹ | è¯´æ˜Ž |
|------|------|------|
| async | Boolean | true=已转后台任务;false=本次同步完成 |
| taskId | Long | å¼‚步任务编号;同步时为 null |
| uuid | String | ç”Ÿæˆç»„ UUID,两种模式都返回 |
| type / perPallet | String / Integer | æ•°æ®åŒ…模式下返回 |
| totalCount | Integer | è¢‹ç æ€»æ•° |
| palletCount | Integer | æ‰˜ç›˜æ€»æ•°ï¼›åŽŸå§‹æ•°æ®ä¸º 0 |
| palletIds | List\<Long\> | ä»…同步完成时返回;异步模式为 null,避免上千个 ID æ’‘大响应 |
后端按 `totalCount` è‡ªåŠ¨åˆ†æµï¼Œå‰ç«¯ä¸éœ€è¦å„è‡ªè®¡ç®—é˜ˆå€¼ã€‚
#### 2)任务查询(轮询)
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| GET | `/mes/pro/bag-code/generate-task/get?id=` | æŸ¥è¯¢ä»»åŠ¡çŠ¶æ€ä¸Žè¿›åº¦ |
响应字段:`id`、`bizType`、`status`、`statusName`、`progress`(0~100)、`totalCount`、`finishedCount`、`palletCount`、`itemId`、`itemCode`、`itemName`、`unitType`、`perPallet`、`uuid`、`errorMsg`、`createTime`、`finishTime`。
#### 3)任务取消
| æ–¹æ³• | è·¯å¾„ | è¯´æ˜Ž |
|------|------|------|
| POST | `/mes/pro/bag-code/generate-task/cancel?id=` | è¯·æ±‚取消;已终态任务拒绝 |
权限:提交/取消用 `mes:pro-bag-code:create`,查询用 `mes:pro-bag-code:query`。
### 5.2 æäº¤é˜¶æ®µ
1. æ ¡éªŒå“ç§ã€è®¡é‡å•位类型,换算袋码总数(与原逻辑一致)。
2. æ•°é‡æ ¡éªŒï¼š`totalCount â‰¤ maxTaskCount`(默认 1,000,000),超出直接报错。
3. å¹‚等处理:`requestId` éžç©ºæ—¶å…ˆæŸ¥ä»»åŠ¡è¡¨ï¼›å‘½ä¸­åˆ™ç›´æŽ¥è¿”å›žå·²æœ‰ä»»åŠ¡ï¼Œä¸é‡å¤ç”Ÿæˆã€‚å¹¶å‘é‡å¤æäº¤ç”± `uk_request_id` å”¯ä¸€ç´¢å¼•兜底,捕获冲突后回查。
4. æ•°é‡ â‰¤ `syncThreshold`:走同步执行,返回 `async=false`。
5. æ•°é‡ > `syncThreshold`:
   - ç”Ÿæˆä»»åŠ¡ UUID;
   - æ’入任务记录(状态=10);
   - å…ˆæ³¨å†Œå†…存运行态,再投递线程池(与导出任务一致,避免清理任务误回收);
   - è¿”回 `async=true`、`taskId`、`uuid`、`totalCount`、`palletCount`。
6. å¹¶å‘闸门:同一时间最多 `maxConcurrentTasks`(默认 1)个生成任务。占用失败时返回「已有生成任务进行中」并带上当前任务进度。
7. çº¿ç¨‹æ± æ‹’绝时把任务置为失败并归还并发许可。
### 5.3 æ‰¹é‡å–码(核心优化)
将「逐条 `SADD`」改为「批量脚本写入」:
1. Java ä¾§æŒ‰ 5000 ä¸ªä¸€æ‰¹ç”Ÿæˆå€™é€‰ç ï¼ˆ`ThreadLocalRandom` ç²¾ç¡®ç”Ÿæˆ 16 ä½æ•°å­—)。
2. ä¸€æ¬¡ Lua è„šæœ¬åŽŸå­å®Œæˆï¼šé€ä¸ª `SADD` åˆ° `biz:code:used`,返回本批实际写入成功的码。
3. è‹¥æˆåŠŸæ•°ä¸è¶³ï¼ˆéšæœºç¢°æ’žï¼Œæ¦‚çŽ‡æžä½Žï¼‰ï¼Œåªå¯¹ç¼ºé¢ç»§ç»­ç”Ÿæˆä¸‹ä¸€æ‰¹ï¼Œç›´åˆ°å‡‘é½ã€‚
4. è¿½æº¯ç ä¸Žä¸šåŠ¡ç å„èµ°ä¸€æ¬¡æ‰¹é‡æµç¨‹ã€‚
效果:30 ä¸‡è¢‹ä»Ž 60 ä¸‡æ¬¡ç½‘络往返降到约 120 æ¬¡ï¼ˆæ¯æ¬¡ 5000 ä¸ªï¼‰ã€‚
> è¢‹ç ä¸Žæ‰˜ç›˜ç éƒ½èµ°è¯¥æ‰¹é‡æ–¹æ³•ï¼›`generate()` ä¿ç•™ç»™å…¶ä»–少量场景。
### 5.4 åˆ†æ®µè½åº“
1. **一次性分配袋序号**:任务开始时调用一次 `allocateBagNoStart(totalCount, selectMaxBagNo())`,后续各段用 `startNo + offset` æŽ¨ç®—。取消产生的序号空洞允许存在(序号只要求唯一、不回退),因此不要按段重复分配。
2. **托盘**(仅数据包模式):按 1000 ä¸ª/批插入,保留 `palletId` ä¸Ž `tempCode` ä¸¤ä¸ªæ•°ç»„供袋码段引用,避免堆积整表 DO。
3. **袋码**:按 `chunkSize`(默认 5000)分段落库,每段落库后回写一次 `finished_count`。
4. **事务边界**:每个分段的插入在独立的 `REQUIRES_NEW` äº‹åŠ¡ä¸­å®Œæˆï¼Œé¿å…å‡ åä¸‡è¡Œå•äº‹åŠ¡ã€‚åˆ†æ®µæ–¹æ³•å¿…é¡»ç”±æ‰§è¡Œå™¨è·¨ Bean è°ƒç”¨ï¼Œä¿è¯äº‹åŠ¡ä»£ç†ç”Ÿæ•ˆã€‚
5. æ¯æ®µå¼€å§‹å‰æ£€æŸ¥å–消标记与最长运行时间,段内不再做数据库查询。
### 5.5 è¿›åº¦ä¸ŽèŠ‚æµ
- `finished_count` æ¯æ®µï¼ˆ5000 æ¡ï¼‰æ›´æ–°ä¸€æ¬¡ï¼Œ30 ä¸‡æ¡çº¦ 60 æ¬¡æ›´æ–°ï¼Œå¼€é”€å¯å¿½ç•¥ã€‚
- ç”Ÿæˆè¿‡ç¨‹ä»¥æ•°æ®åº“写入为主,不叠加 CPU å¯†é›†æ“ä½œï¼Œå› æ­¤ä¸åš CPU èŠ‚æµï¼›ä»…ä¿ç•™æœ€é•¿è¿è¡Œæ—¶é—´ï¼ˆé»˜è®¤ 30 åˆ†é’Ÿï¼‰å…œåº•。
- æ¯æ®µå†™å…¥å‰æ£€æŸ¥å–消标记。
### 5.6 å–消
1. å–消接口把内存中的取消标记置位,任务进入「取消中」。
2. æ‰§è¡Œçº¿ç¨‹åœ¨å½“前分段结束后停止,随后:
   - ç‰©ç†åˆ é™¤è¯¥ `uuid` ä¸‹å·²ç”Ÿæˆçš„袋码(`DELETE ... WHERE uuid = ? LIMIT 5000` å¾ªçŽ¯ï¼‰ï¼›
   - ç‰©ç†åˆ é™¤æœ¬æ¬¡å·²åˆ›å»ºçš„æ‰˜ç›˜ï¼šæ‰˜ç›˜è¡¨æ–°å¢ž `generate_uuid` è®°å½•生成组,按 `DELETE ... WHERE generate_uuid = ? AND batch_id IS NULL AND code IS NULL LIMIT 5000` å¾ªçŽ¯ã€‚è¯¥æ¡ä»¶ç¡®ä¿åªåˆ ä»Žæœªå…¥æ‰¹æ¬¡çš„ä¸´æ—¶æ‰˜ç›˜ï¼Œä¸ä¼šè¯¯åˆ ç”¨æˆ·åŽç»­å·²ç»‘å®šçš„æ‰˜ç›˜ï¼›
   - ä»»åŠ¡ç½®ä¸ºã€Œå·²å–æ¶ˆã€ã€‚
3. ä»»åŠ¡ä¸åœ¨æœ¬è¿›ç¨‹è¿è¡Œï¼ˆè¿›ç¨‹é‡å¯åŽçš„æ®‹ç•™ï¼‰æ—¶ï¼Œå–æ¶ˆæŽ¥å£ç›´æŽ¥æ¸…ç†æ®‹ç•™æ•°æ®å¹¶ç½®ä¸ºã€Œå·²å–æ¶ˆã€ã€‚
> è¯´æ˜Žï¼šç”Ÿæˆä¸€åŠçš„æ•°æ®åŒ…不可用,必须清理。这里采用物理删除而非逻辑删除,是因为这些行从未对外展示,逻辑删除只会留下几十万条垃圾并浪费索引空间;用户主动删除已生成数据包仍沿用原有逻辑删除逻辑。
### 5.7 å¤±è´¥ä¸Žé‡å¯å›žæ”¶
- æ‰§è¡Œå¼‚常:清理该 `uuid` çš„部分数据,任务置为「已失败」,`error_msg` æˆªæ–­åˆ° 500 å­—符。
- è¿›ç¨‹é‡å¯ï¼šå†…存运行态丢失。启动后与每 5 åˆ†é’Ÿä¸€æ¬¡çš„æ¸…理任务扫描非终态任务,凡不在运行态注册表中的,一律置为「已失败」并清理部分数据。
- **不实现断点续跑**:续跑需要精确处理分段事务边界、托盘映射与取消空洞,复杂度高;而重新生成一次的成本远低于维护续跑状态机。UI æ˜Žç¡®æç¤ºâ€œä»»åŠ¡ä¸­æ–­ï¼Œè¯·é‡æ–°ç”Ÿæˆâ€ã€‚
### 5.8 å¹‚等与重复提交
- å‰ç«¯æ¯æ¬¡æ‰“开生成弹窗生成一个 `requestId`,同一次提交的重试复用同一个值(如网络重试)。
- åŽç«¯ä»¥ `requestId` å”¯ä¸€ç´¢å¼•保证同一请求只落一个任务;重复提交直接返回已有任务。
- ä¸ä¼  `requestId` æ—¶ä»å¯ç”¨ï¼Œä½†å¤±åŽ»ç½‘ç»œçº§å¹‚ç­‰ä¿æŠ¤ï¼›å‰ç«¯å¿…é¡»ä¼ ã€‚
### 5.9 é…ç½®é¡¹
新增 `mes.bag-code.generate` å‰ç¼€é…ç½®ï¼š
| é…ç½® | é»˜è®¤å€¼ | è¯´æ˜Ž |
|------|--------|------|
| sync-threshold | 20000 | ä¸è¶…过该数量同步生成 |
| chunk-size | 5000 | æ¯æ®µè¢‹ç /每批取码数量 |
| max-concurrent-tasks | 1 | åŒæ—¶è¿è¡Œçš„后台生成任务数 |
| max-task-count | 1000000 | å•次生成袋码上限 |
| max-runtime-minutes | 30 | å•任务最长运行时间 |
| poll-interval-seconds | 2 | ä»…作为前端轮询建议值下发(可选) |
## å…­ã€å‰ç«¯è”调要点
> æœ¬èŠ‚åªæè¿°è¡Œä¸ºä¸Žå­—æ®µï¼Œä¸æä¾›å‰ç«¯ä»£ç ã€‚
### 6.1 æ¶‰åŠé¡µé¢
- èµ‹ç ç®¡ç†åˆ—表页(`mes/pro/bagCode`)
- ã€Œç”ŸæˆäºŒç»´ç æ•°æ®åŒ…」弹窗(`generate-form.vue`)
- ã€Œç”ŸæˆåŽŸå§‹æ•°æ®ã€å…¥å£ï¼ˆå¦‚å­˜åœ¨ç‹¬ç«‹å¼¹çª—ï¼ŒåŒæ ·å¤„ç†ï¼‰
### 6.2 æäº¤æµç¨‹
1. æ‰“开生成弹窗时生成一个 `requestId`(UUID),关闭重开则重新生成;同一次提交的自动重试复用同一个 `requestId`。
2. è°ƒç”¨åŽŸç”ŸæˆæŽ¥å£ï¼Œè¯·æ±‚ä½“å¢žåŠ  `requestId`。
3. å“åº” `async=false`:维持现有成功提示与列表刷新行为。
4. å“åº” `async=true`:弹窗不立即关闭,展示进度条,按 `poll-interval-seconds`(建议 2 ç§’)轮询任务查询接口,直到状态进入终态。
   - çŠ¶æ€ 30:提示“生成完成,共 N æ¡è¢‹ç â€ï¼Œå…³é—­å¼¹çª—并刷新列表。
   - çŠ¶æ€ 40:提示“任务已取消”。
   - çŠ¶æ€ 50:展示 `errorMsg`,允许用户重新提交(重新生成 `requestId`)。
5. è½®è¯¢æœŸé—´æä¾›â€œå–消生成”按钮,调用取消接口;按钮点击后置灰,等待任务进入终态。
6. é¡µé¢å…³é—­æˆ–组件卸载时停止轮询;重新进入页面时,可通过任务编号继续查询(任务编号可暂存在本地状态)。
### 6.3 å­—段展示
| å­—段 | å±•示位置 | è¯´æ˜Ž |
|------|----------|------|
| progress | ç”Ÿæˆå¼¹çª— | è¿›åº¦æ¡ç™¾åˆ†æ¯” |
| finishedCount / totalCount | ç”Ÿæˆå¼¹çª— | æ–‡æ¡ˆâ€œå·²ç”Ÿæˆ x / y æ¡â€ |
| statusName | ç”Ÿæˆå¼¹çª— | ä»»åŠ¡çŠ¶æ€æ ‡ç­¾ |
| palletCount | å®Œæˆæç¤º | æ•°æ®åŒ…模式提示自动生成的托盘数 |
| errorMsg | ç”Ÿæˆå¼¹çª— | å¤±è´¥åŽŸå›  |
### 6.4 æ³¨æ„äº‹é¡¹
- å¼‚步模式响应中 `palletIds` ä¸º null,前端不要再用 `palletIds.length` åˆ¤æ–­æ‰˜ç›˜æ•°ï¼Œæ”¹ç”¨ `palletCount`。
- åŒä¸€ç”¨æˆ·åŒæ—¶åªèƒ½è¿è¡Œä¸€ä¸ªåŽå°ç”Ÿæˆä»»åŠ¡ï¼›è¢«æ‹’ç»æ—¶åŽç«¯ä¼šè¿”å›žå½“å‰ä»»åŠ¡è¿›åº¦ï¼Œå‰ç«¯å¯ç›´æŽ¥åˆ‡æ¢ä¸ºæŸ¥è¯¢è¯¥ä»»åŠ¡ã€‚
- ä¸è¦ç”¨â€œè¯·æ±‚是否超时”来判断任务失败;提交接口在转异步后应快速返回。
## ä¸ƒã€æ€§èƒ½ä¼°ç®—与验收标准
### 7.1 ä¼°ç®—
| çŽ¯èŠ‚ | æ”¹é€ å‰ï¼ˆ30 ä¸‡è¢‹ï¼‰ | æ”¹é€ åŽ |
|------|------------------|--------|
| Redis å–码 | 60 ä¸‡æ¬¡ RTT,分钟级 | çº¦ 120 æ¬¡ RTT,秒级 |
| æ•°æ®åº“写入 | 1 ä¸ªè¶…大事务 | 60 ä¸ª 5000 è¡Œäº‹åŠ¡ |
| HTTP å“åº” | ç­‰å¾…全部完成,必然超时 | æäº¤åŽ 1 ç§’内返回 taskId |
| ç”¨æˆ·é‡è¯• | äº§ç”Ÿé‡å¤æ•°æ®åŒ… | å¹‚等键去重 |
### 7.2 éªŒæ”¶æ ‡å‡†
1. 30 ä¸‡è¢‹ç  + å¯¹åº”托盘可完整生成,进度可从 0 æŽ¨è¿›åˆ° 100。
2. æäº¤æŽ¥å£åœ¨ 1 ç§’内返回;生成过程中列表可正常查询其他数据。
3. å–消后数据库中该 `uuid` æ— æ®‹ç•™è¢‹ç ä¸Žæ‰˜ç›˜ï¼Œä»»åŠ¡çŠ¶æ€ä¸ºå·²å–æ¶ˆã€‚
4. æ¨¡æ‹Ÿè¿›ç¨‹é‡å¯ï¼šæ®‹ç•™ä»»åŠ¡åœ¨ä¸€æ¬¡æ¸…ç†å‘¨æœŸå†…å˜ä¸ºå·²å¤±è´¥ï¼Œéƒ¨åˆ†æ•°æ®è¢«æ¸…ç†ã€‚
5. ç›¸åŒ `requestId` é‡å¤æäº¤åªäº§ç”Ÿä¸€ä¸ªä»»åŠ¡ã€ä¸€ä¸ªç”Ÿæˆç»„ã€‚
6. 2 ä¸‡æ¡ä»¥å†…仍为同步返回,响应结构与旧版兼容(新增字段不影响旧前端解析)。
7. çŽ°æœ‰ã€Œç ´æŸæ›¿æ¢ / ç»‘定批次 / ç»‘定托盘 / å¯¼å‡ºã€å›žå½’通过。
## å…«ã€é£Žé™©ä¸Žå–舍
| é£Žé™© | åº”对 |
|------|------|
| Redis `biz:code:used` é›†åˆæŒç»­å¢žé•¿ï¼ˆå‡ åä¸‡çº§/次) | çŽ°ç½‘å·²æœ‰è¯¥æœºåˆ¶ï¼Œé›†åˆåªå­˜ 16 å­—符短串,单次几十万条约几十 MB;后续可评估按前缀分片,本次不动 |
| æ¸…理 30 ä¸‡è¡Œéƒ¨åˆ†æ•°æ®è€—æ—¶ | åˆ†æ®µ `DELETE ... LIMIT 5000`,避免长事务与主从延迟尖峰 |
| `bag_no` ä¸º INT,理论上限约 21 äº¿ | å•次上限 100 ä¸‡ï¼›æŒ‰å½“前量级可支撑多年,后续如需长期使用再迁移 BIGINT |
| å¼‚步任务期间服务重启 | ä¸ç»­è·‘,标记失败并清理,提示重新生成 |
| åŒæ­¥è·¯å¾„仍受网关超时约束 | é˜ˆå€¼å†…单次最多 2 ä¸‡æ¡ï¼Œæ‰¹é‡å–码后预计 1~3 ç§’,留足余量 |
## ä¹ã€å®žæ–½æ­¥éª¤ä¸ŽçŠ¶æ€è¿½è¸ª
| åºå· | å†…容 | çŠ¶æ€ |
|------|------|------|
| 1 | æ–¹æ¡ˆæ–‡æ¡£ç¡®è®¤ | å·²å®Œæˆ |
| 2 | æ–°å¢žä»»åŠ¡è¡¨ã€è¢‹ç  `bag_no` ç´¢å¼•、托盘 `generate_uuid` è¿ç§»è„šæœ¬ | å·²å®Œæˆ |
| 3 | æ–°å¢žä»»åŠ¡ DO / Mapper / çŠ¶æ€æžšä¸¾ / é…ç½®é¡¹ | å·²å®Œæˆ |
| 4 | `MesCodeRedisDAO` å¢žåŠ æ‰¹é‡å–ç æ–¹æ³• | å·²å®Œæˆ |
| 5 | æŠ½å–分段生成执行器(同步/异步共用) | å·²å®Œæˆ |
| 6 | æ–°å¢žä»»åŠ¡ Service(提交、查询、取消、回收)与线程池配置 | å·²å®Œæˆ |
| 7 | æ”¹é€ ä¸¤ä¸ªç”ŸæˆæŽ¥å£çš„同步/异步分流与响应字段 | å·²å®Œæˆ |
| 8 | æ–°å¢žä»»åŠ¡æŸ¥è¯¢/取消接口与权限 | å·²å®Œæˆ |
| 9 | å¯åŠ¨ä¸Žå®šæ—¶æ¸…ç†ä»»åŠ¡ | å·²å®Œæˆ |
| 10 | å‰ç«¯è”调(弹窗进度、轮询、取消) | å·²å®Œæˆ |
| 11 | ç¼–译检查与大批量实测 | å·²å®Œæˆï¼ˆæœ¬åœ° mom-thzy:30 ä¸‡æ¡çº¦ 76 ç§’生成完毕,提交响应 0.18 ç§’;取消清理无残留;幂等与并发闸门均验证通过) |
### è¿ç§»è„šæœ¬
| è„šæœ¬ | è¯´æ˜Ž |
|------|------|
| `sql/mysql/mes_pro_bag_code_generate_task.sql` | ä»»åŠ¡è¡¨ |
| `sql/mysql/mes_pro_bag_code_bag_no_index.sql` | è¢‹ç  `bag_no` ç´¢å¼• |
| `sql/mysql/mes_pro_pallet_generate_uuid.sql` | æ‰˜ç›˜ `generate_uuid` åˆ—与索引 |
sql/mysql/mes_pro_bag_code_bag_no_index.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
-- =============================================
-- èµ‹ç ç®¡ç† - è¢‹ç è¡¨ bag_no ç´¢å¼•
-- selectMaxBagNo() ä¾èµ– MAX(bag_no);大批量生成后无索引会全表扫描
-- MySQL 8.4 é€‚用
-- =============================================
SET NAMES utf8mb4;
SET @exists = (SELECT COUNT(*) FROM information_schema.statistics
               WHERE table_schema = DATABASE()
                 AND table_name = 'mes_pro_bag_code'
                 AND index_name = 'idx_bag_no');
SET @sql = IF(@exists = 0,
              'ALTER TABLE mes_pro_bag_code ADD INDEX idx_bag_no (bag_no)',
              'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
sql/mysql/mes_pro_bag_code_generate_task.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,35 @@
-- =============================================
-- èµ‹ç ç®¡ç† - å¤§æ‰¹é‡ç”Ÿæˆä»»åŠ¡è¡¨
-- ç”¨äºŽæ‰¿æŽ¥å‡ åä¸‡æ¡è¢‹ç çš„后台生成、进度展示、取消与失败回收
-- MySQL 8.4 é€‚用
-- =============================================
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS `mes_pro_bag_code_generate_task` (
    `id`             BIGINT       NOT NULL AUTO_INCREMENT COMMENT '编号',
    `request_id`     VARCHAR(64)  DEFAULT NULL COMMENT '幂等键(前端每次提交生成一个 UUID,重试复用)',
    `biz_type`       VARCHAR(16)  NOT NULL COMMENT '业务类型(PACKAGE æ•°æ®åŒ… / RAW åŽŸå§‹æ•°æ®ï¼‰',
    `uuid`           VARCHAR(32)  NOT NULL COMMENT '生成组 UUID(提交时生成,失败/取消后用于清理)',
    `item_id`        BIGINT       NOT NULL COMMENT '品种(物料)编号,关联 mdm_item.id',
    `item_code`      VARCHAR(64)  DEFAULT NULL COMMENT '品种编码(快照)',
    `item_name`      VARCHAR(255) DEFAULT NULL COMMENT '品种名称(快照)',
    `unit_type`      VARCHAR(32)  DEFAULT NULL COMMENT '计量单位类型(吨 / 48袋 / 100袋)',
    `per_pallet`     INT          NOT NULL DEFAULT 0 COMMENT '每托盘袋数(吨类型为 0)',
    `pallet_count`   INT          NOT NULL DEFAULT 0 COMMENT '托盘数(原始数据为 0)',
    `total_count`    INT          NOT NULL COMMENT '袋码总数',
    `finished_count` INT          NOT NULL DEFAULT 0 COMMENT '已生成袋码数',
    `status`         TINYINT      NOT NULL DEFAULT 10 COMMENT '状态(10 æŽ’队中 20 ç”Ÿæˆä¸­ 25 å–消中 30 å·²å®Œæˆ 40 å·²å–消 50 å·²å¤±è´¥ï¼‰',
    `remark`         VARCHAR(255) DEFAULT NULL COMMENT '备注(写入生成的袋码)',
    `error_msg`      VARCHAR(500) DEFAULT NULL COMMENT '失败原因',
    `finish_time`    DATETIME     DEFAULT NULL COMMENT '完成/终止时间',
    `creator`        VARCHAR(64)  DEFAULT '' COMMENT '创建者',
    `create_time`    DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    `updater`        VARCHAR(64)  DEFAULT '' COMMENT '更新者',
    `update_time`    DATETIME     NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    `deleted`        BIT(1)       NOT NULL DEFAULT b'0' COMMENT '是否删除',
    PRIMARY KEY (`id`),
    UNIQUE KEY `uk_request_id` (`request_id`),
    KEY `idx_status` (`status`),
    KEY `idx_uuid` (`uuid`)
) ENGINE = InnoDB COMMENT = 'MES è¢‹ç æ‰¹é‡ç”Ÿæˆä»»åŠ¡';
sql/mysql/mes_pro_pallet_generate_uuid.sql
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
-- =============================================
-- èµ‹ç ç®¡ç† - æ‰˜ç›˜è¡¨æ–°å¢žç”Ÿæˆç»„标记
-- æ‰¹é‡ç”Ÿæˆè¢‹ç æ•°æ®åŒ…时写入,用于任务失败/取消后清理未绑定的临时托盘
-- MySQL 8.4 é€‚用(ADD COLUMN ä¸æ”¯æŒ IF NOT EXISTS,先查 information_schema)
-- =============================================
SET NAMES utf8mb4;
SET @exists = (SELECT COUNT(*) FROM information_schema.columns
               WHERE table_schema = DATABASE()
                 AND table_name = 'mes_pro_pallet'
                 AND column_name = 'generate_uuid');
SET @sql = IF(@exists = 0,
              'ALTER TABLE mes_pro_pallet ADD COLUMN generate_uuid VARCHAR(32) DEFAULT NULL COMMENT ''生成组 UUID(批量生成时写入,用于失败/取消清理)'' AFTER temp_code',
              'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
SET @indexExists = (SELECT COUNT(*) FROM information_schema.statistics
                    WHERE table_schema = DATABASE()
                      AND table_name = 'mes_pro_pallet'
                      AND index_name = 'idx_generate_uuid');
SET @sql = IF(@indexExists = 0,
              'ALTER TABLE mes_pro_pallet ADD INDEX idx_generate_uuid (generate_uuid)',
              'SELECT 1');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/bagcode/MesProBagCodeController.java
@@ -13,6 +13,7 @@
import cn.iocoder.yudao.module.mes.controller.admin.pro.bagcode.vo.MesProBagCodeBindReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.bagcode.vo.MesProBagCodeGenerateRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.bagcode.vo.MesProBagCodeGenerateReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.bagcode.vo.MesProBagCodeGenerateTaskRespVO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.bagcode.vo.MesProBagCodePageReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.bagcode.vo.MesProBagCodeRawGenerateReqVO;
import cn.iocoder.yudao.module.mes.controller.admin.pro.bagcode.vo.MesProBagCodeReplaceReqVO;
@@ -21,10 +22,13 @@
import cn.iocoder.yudao.module.mes.controller.admin.pro.bagcode.vo.MesProBagCodeTraceRespVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.mdm.MesMdmItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.bagcode.MesProBagCodeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.bagcode.MesProBagCodeGenerateTaskDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.batch.MesProBatchDO;
import cn.iocoder.yudao.module.mes.enums.pro.MesProBagCodeGenerateTaskStatusEnum;
import cn.iocoder.yudao.module.mes.framework.qrcode.QrCodeImageUtils;
import cn.iocoder.yudao.module.mes.framework.qrexport.MesProQrExportProperties;
import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService;
import cn.iocoder.yudao.module.mes.service.pro.bagcode.MesProBagCodeGenerateTaskService;
import cn.iocoder.yudao.module.mes.service.pro.bagcode.MesProBagCodeService;
import cn.iocoder.yudao.module.mes.service.pro.batch.MesProBatchService;
import cn.iocoder.yudao.module.mes.service.pro.qrexport.MesProQrLabelAssembler;
@@ -65,6 +69,8 @@
    @Resource
    private MesProBagCodeService bagCodeService;
    @Resource
    private MesProBagCodeGenerateTaskService generateTaskService;
    @Resource
    private MesProBatchService batchService;
    @Resource
    private AdminUserApi adminUserApi;
@@ -88,6 +94,23 @@
    public CommonResult<MesProBagCodeGenerateRespVO> generateRawBagCodes(
            @Valid @RequestBody MesProBagCodeRawGenerateReqVO rawGenerateReqVO) {
        return success(bagCodeService.generateRawBagCodes(rawGenerateReqVO));
    }
    @GetMapping("/generate-task/get")
    @Operation(summary = "获得袋码生成任务(大批量生成的进度轮询)")
    @Parameter(name = "id", description = "任务编号", required = true, example = "1024")
    @PreAuthorize("@ss.hasPermission('mes:pro-bag-code:query')")
    public CommonResult<MesProBagCodeGenerateTaskRespVO> getGenerateTask(@RequestParam("id") Long id) {
        return success(buildGenerateTaskRespVO(generateTaskService.getTask(id)));
    }
    @PostMapping("/generate-task/cancel")
    @Operation(summary = "取消袋码生成任务(停止生成并清理半成品)")
    @Parameter(name = "id", description = "任务编号", required = true, example = "1024")
    @PreAuthorize("@ss.hasPermission('mes:pro-bag-code:create')")
    public CommonResult<Boolean> cancelGenerateTask(@RequestParam("id") Long id) {
        generateTaskService.cancel(id);
        return success(true);
    }
    @PostMapping("/replace")
@@ -251,6 +274,24 @@
                labelAssembler.buildBagBusinessLabels(list));
    }
    private MesProBagCodeGenerateTaskRespVO buildGenerateTaskRespVO(MesProBagCodeGenerateTaskDO task) {
        MesProBagCodeGenerateTaskRespVO respVO = BeanUtils.toBean(task, MesProBagCodeGenerateTaskRespVO.class);
        for (MesProBagCodeGenerateTaskStatusEnum value : MesProBagCodeGenerateTaskStatusEnum.values()) {
            if (value.getStatus().equals(task.getStatus())) {
                respVO.setStatusName(value.getName());
                break;
            }
        }
        Integer total = task.getTotalCount();
        if (total == null || total <= 0) {
            respVO.setProgress(0);
        } else {
            int finished = task.getFinishedCount() == null ? 0 : task.getFinishedCount();
            respVO.setProgress((int) Math.min(100L, (long) finished * 100 / total));
        }
        return respVO;
    }
    private List<MesProBagCodeRespVO> buildBagCodeRespVOList(List<MesProBagCodeDO> list) {
        if (CollUtil.isEmpty(list)) {
            return Collections.emptyList();
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/bagcode/vo/MesProBagCodeGenerateReqVO.java
@@ -3,6 +3,7 @@
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Schema(description = "管理后台 - MES è¢‹ç ï¼ˆäºŒç»´ç æ•°æ®åŒ…)生成 Request VO")
@@ -24,4 +25,8 @@
    @Schema(description = "备注")
    private String remark;
    @Schema(description = "幂等键:前端每次提交生成一个 UUID,网络重试复用同一个值", example = "a1b2c3")
    @Size(max = 64, message = "幂等键长度不能超过 64 ä¸ªå­—符")
    private String requestId;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/bagcode/vo/MesProBagCodeGenerateRespVO.java
@@ -21,6 +21,15 @@
    @Schema(description = "本次生成的袋码总数", example = "240")
    private Integer totalCount;
    @Schema(description = "自动生成的托盘编号列表(类型为 å¨ æ—¶ä¸ºç©ºï¼‰")
    @Schema(description = "自动生成的托盘编号列表(仅同步完成时返回,类型为 å¨ æ—¶ä¸ºç©ºï¼‰")
    private List<Long> palletIds;
    @Schema(description = "是否已转为后台任务", example = "true")
    private Boolean async;
    @Schema(description = "后台任务编号(异步模式返回)", example = "1024")
    private Long taskId;
    @Schema(description = "托盘总数(原始数据为 0)", example = "5")
    private Integer palletCount;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/bagcode/vo/MesProBagCodeGenerateTaskRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,63 @@
package cn.iocoder.yudao.module.mes.controller.admin.pro.bagcode.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - MES è¢‹ç ç”Ÿæˆä»»åŠ¡ Response VO")
@Data
public class MesProBagCodeGenerateTaskRespVO {
    @Schema(description = "任务编号", example = "1024")
    private Long id;
    @Schema(description = "业务类型:PACKAGE(数据包)/ RAW(原始数据)", example = "PACKAGE")
    private String bizType;
    @Schema(description = "任务状态", example = "20")
    private Integer status;
    @Schema(description = "任务状态名称", example = "生成中")
    private String statusName;
    @Schema(description = "进度百分比(0~100)", example = "35")
    private Integer progress;
    @Schema(description = "生成组 UUID")
    private String uuid;
    @Schema(description = "品种编号", example = "1")
    private Long itemId;
    @Schema(description = "品种编码", example = "ITEM001")
    private String itemCode;
    @Schema(description = "品种名称", example = "种子")
    private String itemName;
    @Schema(description = "计量单位类型", example = "48袋")
    private String unitType;
    @Schema(description = "每托盘袋数(吨类型为 0)", example = "48")
    private Integer perPallet;
    @Schema(description = "托盘数(原始数据为 0)", example = "5")
    private Integer palletCount;
    @Schema(description = "袋码总数", example = "240")
    private Integer totalCount;
    @Schema(description = "已生成袋码数", example = "120")
    private Integer finishedCount;
    @Schema(description = "失败原因")
    private String errorMsg;
    @Schema(description = "创建时间")
    private LocalDateTime createTime;
    @Schema(description = "完成/终止时间")
    private LocalDateTime finishTime;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/controller/admin/pro/bagcode/vo/MesProBagCodeRawGenerateReqVO.java
@@ -3,6 +3,7 @@
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Schema(description = "管理后台 - MES åŽŸå§‹è¢‹ç ç”Ÿæˆ Request VO(仅品种+数量,不关联计量单位类型、不生成托盘)")
@@ -21,4 +22,8 @@
    @Schema(description = "备注")
    private String remark;
    @Schema(description = "幂等键:前端每次提交生成一个 UUID,网络重试复用同一个值", example = "a1b2c3")
    @Size(max = 64, message = "幂等键长度不能超过 64 ä¸ªå­—符")
    private String requestId;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pro/bagcode/MesProBagCodeGenerateTaskDO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,105 @@
package cn.iocoder.yudao.module.mes.dal.dataobject.pro.bagcode;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import java.time.LocalDateTime;
/**
 * MES è¢‹ç æ‰¹é‡ç”Ÿæˆä»»åŠ¡ DO
 * <p>
 * å‡ åä¸‡æ¡è¢‹ç ä¸åœ¨è¯·æ±‚线程内完成:提交后落任务,由后台线程分段落库,
 * å‰ç«¯æ® {@link #finishedCount}/{@link #totalCount} å±•示进度,并支持取消。
 */
@TableName("mes_pro_bag_code_generate_task")
@KeySequence("mes_pro_bag_code_generate_task_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class MesProBagCodeGenerateTaskDO extends BaseDO {
    @TableId
    private Long id;
    /**
     * å¹‚等键:前端每次提交生成一个 UUID,网络重试复用同一个值
     */
    private String requestId;
    /**
     * ä¸šåŠ¡ç±»åž‹ï¼Œè§ {@link cn.iocoder.yudao.module.mes.enums.pro.MesProBagCodeGenerateBizTypeEnum}
     */
    private String bizType;
    /**
     * ç”Ÿæˆç»„ UUID(提交时生成,失败/取消后据此清理部分数据)
     */
    private String uuid;
    /**
     * å“ç§ç¼–号
     */
    private Long itemId;
    /**
     * å“ç§ç¼–码(快照)
     */
    private String itemCode;
    /**
     * å“ç§åç§°ï¼ˆå¿«ç…§ï¼‰
     */
    private String itemName;
    /**
     * è®¡é‡å•位类型(吨 / 48袋 / 100袋)
     */
    private String unitType;
    /**
     * æ¯æ‰˜ç›˜è¢‹æ•°ï¼ˆå¨ç±»åž‹ä¸º 0)
     */
    private Integer perPallet;
    /**
     * æ‰˜ç›˜æ•°ï¼ˆåŽŸå§‹æ•°æ®ä¸º 0)
     */
    private Integer palletCount;
    /**
     * è¢‹ç æ€»æ•°
     */
    private Integer totalCount;
    /**
     * å·²ç”Ÿæˆè¢‹ç æ•°
     */
    private Integer finishedCount;
    /**
     * çŠ¶æ€ï¼Œè§ {@link cn.iocoder.yudao.module.mes.enums.pro.MesProBagCodeGenerateTaskStatusEnum}
     */
    private Integer status;
    /**
     * å¤‡æ³¨ï¼ˆå†™å…¥ç”Ÿæˆçš„袋码)
     */
    private String remark;
    /**
     * å¤±è´¥åŽŸå› 
     */
    private String errorMsg;
    /**
     * å®Œæˆ/终止时间
     */
    private LocalDateTime finishTime;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/dataobject/pro/pallet/MesProPalletDO.java
@@ -41,6 +41,10 @@
     * ä¸´æ—¶æ‰˜ç›˜ç ï¼ˆç å€¼ï¼šPL-8位全局序号,预生成二维码阶段使用;绑定批次后仍可扫码追溯)
     */
    private String tempCode;
    /**
     * ç”Ÿæˆç»„ UUID(批量生成袋码数据包时写入,用于任务失败/取消后清理未绑定的临时托盘)
     */
    private String generateUuid;
    /** æ‰˜ç›˜å“ç§ç¼–号,生成或首次装袋时带入。 */
    private Long itemId;
    /**
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/bagcode/MesProBagCodeGenerateTaskMapper.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,55 @@
package cn.iocoder.yudao.module.mes.dal.mysql.pro.bagcode;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.bagcode.MesProBagCodeGenerateTaskDO;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import org.apache.ibatis.annotations.Mapper;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
/**
 * MES è¢‹ç æ‰¹é‡ç”Ÿæˆä»»åŠ¡ Mapper
 */
@Mapper
public interface MesProBagCodeGenerateTaskMapper extends BaseMapperX<MesProBagCodeGenerateTaskDO> {
    default MesProBagCodeGenerateTaskDO selectByRequestId(String requestId) {
        return selectOne(MesProBagCodeGenerateTaskDO::getRequestId, requestId);
    }
    /**
     * ä»…更新进度,避免把整行字段都写回(生成过程中高频调用)
     */
    default int updateProgress(Long id, Integer finishedCount) {
        return update(null, new LambdaUpdateWrapper<MesProBagCodeGenerateTaskDO>()
                .eq(MesProBagCodeGenerateTaskDO::getId, id)
                .set(MesProBagCodeGenerateTaskDO::getFinishedCount, finishedCount));
    }
    /**
     * æ›´æ–°ä»»åŠ¡ç»ˆæ€ï¼›null å­—段不覆盖
     */
    default int updateStatus(Long id, Integer status, String errorMsg, LocalDateTime finishTime) {
        return update(null, new LambdaUpdateWrapper<MesProBagCodeGenerateTaskDO>()
                .eq(MesProBagCodeGenerateTaskDO::getId, id)
                .set(MesProBagCodeGenerateTaskDO::getStatus, status)
                .set(errorMsg != null, MesProBagCodeGenerateTaskDO::getErrorMsg, errorMsg)
                .set(finishTime != null, MesProBagCodeGenerateTaskDO::getFinishTime, finishTime));
    }
    /**
     * æŸ¥è¯¢æ‰€æœ‰éžç»ˆæ€ä»»åŠ¡ï¼Œç”¨äºŽå›žæ”¶è¿›ç¨‹é‡å¯åŽæ®‹ç•™çš„è®°å½•
     * <p>
     * æ˜¯å¦ä»åœ¨æ‰§è¡Œç”±è¿è¡Œæ€æ³¨å†Œè¡¨åˆ¤å®šï¼šè¡¨é‡Œçš„非终态记录若不在注册表中,
     * å°±ä¸€å®šæ²¡æœ‰æ‰§è¡Œçº¿ç¨‹åœ¨æŽ¨è¿›å®ƒã€‚
     */
    default List<MesProBagCodeGenerateTaskDO> selectUnfinishedList(Collection<Integer> statuses) {
        return selectList(new LambdaQueryWrapperX<MesProBagCodeGenerateTaskDO>()
                .in(MesProBagCodeGenerateTaskDO::getStatus, statuses)
                .orderByAsc(MesProBagCodeGenerateTaskDO::getId));
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/bagcode/MesProBagCodeMapper.java
@@ -275,6 +275,18 @@
            "WHERE deleted = 0 AND status <> 30 AND batch_id = #{batchId} AND item_id IS NOT NULL")
    List<Long> selectDistinctItemIdsByBatchId(@Param("batchId") Long batchId);
    /**
     * ç‰©ç†åˆ é™¤ç”Ÿæˆç»„下的一批袋码(仅用于生成失败/取消后的半成品清理)
     * <p>
     * åŠæˆå“ä»Žæœªå¯¹å¤–展示,逻辑删除只会留下几十万条垃圾并占用索引空间,因此这里直接物理删除。
     *
     * @param uuid  ç”Ÿæˆç»„ UUID
     * @param limit å•次最多删除行数
     * @return å®žé™…删除行数
     */
    @org.apache.ibatis.annotations.Delete("DELETE FROM mes_pro_bag_code WHERE uuid = #{uuid} LIMIT #{limit}")
    int deletePhysicallyByUuidChunk(@Param("uuid") String uuid, @Param("limit") int limit);
    default List<MesProBagCodeDO> selectListByIdsForUpdate(Collection<Long> ids) {
        return selectList(new LambdaQueryWrapperX<MesProBagCodeDO>()
                .in(MesProBagCodeDO::getId, ids)
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/mysql/pro/pallet/MesProPalletMapper.java
@@ -96,6 +96,19 @@
    @Select("SELECT IFNULL(MAX(pallet_no), 0) FROM mes_pro_pallet WHERE batch_id = #{batchId}")
    Integer selectMaxPalletNo(@Param("batchId") Long batchId);
    /**
     * ç‰©ç†åˆ é™¤æŸä¸ªç”Ÿæˆç»„下尚未绑定批次的临时托盘(仅用于生成失败/取消后的半成品清理)
     * <p>
     * å·²ç»‘定批次的托盘可能已被后续业务使用,即使带有生成组标记也不能删。
     *
     * @param generateUuid ç”Ÿæˆç»„ UUID
     * @param limit        å•次最多删除行数
     * @return å®žé™…删除行数
     */
    @org.apache.ibatis.annotations.Delete("DELETE FROM mes_pro_pallet WHERE generate_uuid = #{generateUuid} "
            + "AND batch_id IS NULL AND code IS NULL LIMIT #{limit}")
    int deletePhysicallyByGenerateUuidChunk(@Param("generateUuid") String generateUuid, @Param("limit") int limit);
    @org.apache.ibatis.annotations.Update("UPDATE mes_pro_pallet SET sales_id = #{salesId}, outbound_time = #{outboundTime}, update_time = NOW() "
            + "WHERE id = #{id} AND deleted = 0")
    void markOutbound(@Param("id") Long id, @Param("salesId") Long salesId,
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/dal/redis/MesCodeRedisDAO.java
@@ -7,6 +7,7 @@
import org.springframework.data.redis.core.script.RedisScript;
import org.springframework.stereotype.Repository;
import java.util.ArrayList;
import java.util.List;
/**
@@ -22,6 +23,29 @@
     * è¢‹åºå·è‡ªå¢ž KEY(全局袋序号,保证同一批/跨批生成时不重复)
     */
    private static final String BAG_NO_SEQ_KEY = "mes:bag_no:seq";
    /**
     * å•次批量取码的最大个数
     * <p>
     * å— Redis å•条命令参数规模与响应体大小约束,过大反而增加单次阻塞时间。
     */
    public static final int MAX_BATCH_SIZE = 5000;
    /**
     * æ‰¹é‡å†™å…¥å¹¶è¿”回实际生效的编码。
     * <p>
     * é€ä¸ª SADD åœ¨åŒä¸€è„šæœ¬å†…å®Œæˆï¼šä¸Žè°ƒç”¨æ–¹é€æ¡ SADD ç›¸æ¯”,网络往返从 N æ¬¡é™ä¸º 1 æ¬¡ï¼›
     * åŒæ—¶å› ä¸ºè„šæœ¬åŽŸå­æ‰§è¡Œï¼Œå¹¶å‘è°ƒç”¨ä¹‹é—´ä¹Ÿä¸ä¼šäº§ç”Ÿé‡å¤ç¼–ç ã€‚
     */
    private static final RedisScript<List> ADD_UNIQUE_CODES_SCRIPT = new DefaultRedisScript<>(
            "local res = {} "
                    + "for i = 1, #ARGV do "
                    + "  if redis.call('SADD', KEYS[1], ARGV[i]) == 1 then "
                    + "    res[#res + 1] = ARGV[i] "
                    + "  end "
                    + "end "
                    + "return res",
            List.class);
    /**
     * åŽŸå­åˆ†é…è¢‹åºå·åŒºé—´çš„ Lua è„šæœ¬ã€‚
@@ -59,6 +83,40 @@
    }
    /**
     * æ‰¹é‡ç”Ÿæˆ count ä¸ªå”¯ä¸€çš„ 16 ä½çº¯æ•°å­—业务编码(全系统唯一)
     * <p>
     * å¤§æ‰¹é‡ç”Ÿæˆæ—¶ä¸è¦å¾ªçŽ¯è°ƒç”¨ {@link #generate()}:那会产生 N æ¬¡ Redis å¾€è¿”,
     * å‡ åä¸‡æ¡å³å¯æŠŠæŽ¥å£æ‹–到分钟级。这里每批最多 {@link #MAX_BATCH_SIZE} ä¸ªå€™é€‰ç 
     * ä¸€æ¬¡è„šæœ¬å†™å…¥ï¼Œç¢°æ’žï¼ˆæ¦‚çŽ‡æžä½Žï¼‰çš„ç¼ºé¢å†è¡¥ä¸€æ‰¹ã€‚
     *
     * @param count éœ€è¦çš„编码个数
     * @return å”¯ä¸€ç¼–码列表,顺序无保证但数量等于 count
     */
    public List<String> generateBatch(int count) {
        if (count <= 0) {
            return List.of();
        }
        List<String> result = new ArrayList<>(Math.min(count, 1024));
        // 16 ä½éšæœºç ç©ºé—´ä¸º 10^16,正常情况下一轮即可全部写入;限制轮次只用于兜底异常
        for (int round = 0; round < 100 && result.size() < count; round++) {
            int batchSize = Math.min(count - result.size(), MAX_BATCH_SIZE);
            List<String> candidates = new ArrayList<>(batchSize);
            for (int i = 0; i < batchSize; i++) {
                candidates.add(BizNoUtils.randomCode());
            }
            List<?> added = stringRedisTemplate.execute(ADD_UNIQUE_CODES_SCRIPT,
                    List.of(BizNoUtils.USED_SET_KEY), candidates.toArray(new String[0]));
            if (added != null) {
                added.forEach(code -> result.add(String.valueOf(code)));
            }
        }
        if (result.size() < count) {
            throw new IllegalStateException("批量生成唯一业务编码失败,请重试");
        }
        return result;
    }
    /**
     * åŽŸå­åˆ†é…ä¸€æ®µè¿žç»­çš„è¢‹åºå·ï¼Œè¿”å›žåŒºé—´èµ·å§‹å€¼ã€‚
     * <p>
     * è¢‹åºå·åŽŸå…ˆç”±ã€ŒæŸ¥åº“å†…æœ€å¤§å€¼å† +1」得到,并发生成时两个请求会读到同一个最大值,
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/ErrorCodeConstants.java
@@ -869,6 +869,10 @@
    ErrorCode PRO_BAG_CODE_ITEM_TYPE_NOT_CONFIGURED = new ErrorCode(1_040_752_012, "品种主计量单位未配置类型,仅支持 48袋/100袋/吨");
    ErrorCode PRO_BAG_CODE_PALLET_COUNT_REQUIRED = new ErrorCode(1_040_752_013, "该品种按托盘生成袋码,托盘数必须大于 0");
    ErrorCode PRO_BAG_CODE_BIND_BATCH_ITEM_MISMATCH = new ErrorCode(1_040_752_014, "录入批次的袋码必须属于同一品种");
    ErrorCode PRO_BAG_CODE_GENERATE_COUNT_TOO_MANY = new ErrorCode(1_040_752_015, "单次生成袋码数量不能超过 {} æ¡");
    ErrorCode PRO_BAG_CODE_GENERATE_TASK_BUSY = new ErrorCode(1_040_752_016, "已有生成任务进行中({}%),请等待完成或先取消");
    ErrorCode PRO_BAG_CODE_GENERATE_TASK_NOT_EXISTS = new ErrorCode(1_040_752_017, "生成任务不存在");
    ErrorCode PRO_BAG_CODE_GENERATE_TASK_STATUS_INVALID = new ErrorCode(1_040_752_018, "当前生成任务状态不允许该操作");
    // ========== MES ç§å­æ ‡ç­¾å›½æ ‡å››è¦ç´ ï¼ˆ1-040-750-020) ==========
    ErrorCode PRO_QR_LABEL_TRACE_URL_NOT_CONFIGURED = new ErrorCode(1_040_750_020, "未配置追溯网址,无法生成二维码标签");
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/pro/MesProBagCodeGenerateBizTypeEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,42 @@
package cn.iocoder.yudao.module.mes.enums.pro;
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
 * MES è¢‹ç æ‰¹é‡ç”Ÿæˆä¸šåŠ¡ç±»åž‹æžšä¸¾
 */
@Getter
@AllArgsConstructor
public enum MesProBagCodeGenerateBizTypeEnum implements ArrayValuable<String> {
    /**
     * æ•°æ®åŒ…:按计量单位类型换算,N è¢‹ç±»åž‹åŒæ—¶ç”Ÿæˆæ‰˜ç›˜
     */
    PACKAGE("PACKAGE", "数据包"),
    /**
     * åŽŸå§‹æ•°æ®ï¼šä»…æŒ‰æ•°é‡ç”Ÿæˆï¼Œä¸ç”Ÿæˆæ‰˜ç›˜
     */
    RAW("RAW", "原始数据");
    public static final String[] ARRAYS = Arrays.stream(values())
            .map(MesProBagCodeGenerateBizTypeEnum::getType).toArray(String[]::new);
    /**
     * ç±»åž‹æ ‡è¯†
     */
    private final String type;
    /**
     * ç±»åž‹åç§°
     */
    private final String name;
    @Override
    public String[] array() {
        return ARRAYS;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/enums/pro/MesProBagCodeGenerateTaskStatusEnum.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,65 @@
package cn.iocoder.yudao.module.mes.enums.pro;
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.util.Arrays;
/**
 * MES è¢‹ç æ‰¹é‡ç”Ÿæˆä»»åŠ¡çŠ¶æ€æžšä¸¾
 */
@Getter
@AllArgsConstructor
public enum MesProBagCodeGenerateTaskStatusEnum implements ArrayValuable<Integer> {
    /**
     * å·²åˆ›å»ºå¾…执行(受并发闸门限制时短暂停留该状态)
     */
    PENDING(10, "排队中"),
    /**
     * ç”Ÿæˆä¸­
     */
    RUNNING(20, "生成中"),
    /**
     * å·²æ”¶åˆ°å–消请求,正在停止并清理已生成数据
     */
    CANCELING(25, "取消中"),
    /**
     * å…¨éƒ¨æ•°æ®ç”Ÿæˆå®Œæˆ
     */
    FINISHED(30, "已完成"),
    /**
     * ç”¨æˆ·å–消,部分数据已清理
     */
    CANCELED(40, "已取消"),
    /**
     * å¼‚常终止,部分数据已清理
     */
    FAILED(50, "已失败");
    public static final Integer[] ARRAYS = Arrays.stream(values())
            .map(MesProBagCodeGenerateTaskStatusEnum::getStatus).toArray(Integer[]::new);
    /**
     * çŠ¶æ€
     */
    private final Integer status;
    /**
     * çŠ¶æ€åç§°
     */
    private final String name;
    /**
     * æ˜¯å¦ä¸ºç»ˆæ€ï¼ˆä¸å†å˜åŒ–)
     */
    public boolean isFinal() {
        return this == FINISHED || this == CANCELED || this == FAILED;
    }
    @Override
    public Integer[] array() {
        return ARRAYS;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/bagcode/MesProBagCodeGenerateExecutorConfig.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,38 @@
package cn.iocoder.yudao.module.mes.framework.bagcode;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.ThreadPoolExecutor;
/**
 * è¢‹ç åŽå°ç”Ÿæˆçº¿ç¨‹æ± 
 * <p>
 * å•独建池而不是复用公共异步池:大批量落库是长时间、数据库密集的任务,
 * æ··è¿›å…¬å…±æ± ä¼šæŠŠå…¶ä»–异步业务一起堵住。
 */
@Configuration(proxyBeanMethods = false)
public class MesProBagCodeGenerateExecutorConfig {
    private static final String THREAD_NAME_PREFIX = "mes-bag-code-generate-";
    @Resource
    private MesProBagCodeGenerateProperties properties;
    @Bean(name = "mesBagCodeGenerateExecutor")
    public ThreadPoolTaskExecutor mesBagCodeGenerateExecutor() {
        int concurrency = Math.max(1, properties.getMaxConcurrentTasks());
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(concurrency);
        executor.setMaxPoolSize(concurrency);
        executor.setQueueCapacity(concurrency);
        executor.setThreadNamePrefix(THREAD_NAME_PREFIX);
        // é˜Ÿåˆ—满即拒绝,由调用方把任务置为失败,避免任务无声地堆在队列里
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
        executor.initialize();
        return executor;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/bagcode/MesProBagCodeGenerateProperties.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,56 @@
package cn.iocoder.yudao.module.mes.framework.bagcode;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
 * MES è¢‹ç æ‰¹é‡ç”Ÿæˆé…ç½®é¡¹
 * <p>
 * å°æ‰¹é‡ä»åœ¨è¯·æ±‚线程内同步完成,保证秒回;大批量转后台任务,分段生成、可取消。
 */
@Data
@Component
@ConfigurationProperties(prefix = "mes.bag-code.generate")
public class MesProBagCodeGenerateProperties {
    /**
     * åŒæ­¥/异步的分界线(袋码数)
     * <p>
     * ä¸è¶…过该数量在请求线程内完成;超过则落任务转后台生成。
     */
    private int syncThreshold = 20000;
    /**
     * æ¯æ®µè½åº“的袋码数量(同时也是每批取码数量)
     */
    private int chunkSize = 5000;
    /**
     * åŒæ—¶å…è®¸è¿è¡Œçš„后台生成任务数
     */
    private int maxConcurrentTasks = 1;
    /**
     * å•次生成袋码上限
     */
    private int maxTaskCount = 1000000;
    /**
     * å•个后台任务的最长运行时间(分钟),超时自动终止并清理
     */
    private int maxRuntimeMinutes = 30;
    /**
     * å‰ç«¯è½®è¯¢å»ºè®®é—´éš”(秒),仅用于下发给前端
     */
    private int pollIntervalSeconds = 2;
    /**
     * æ¸…理任务每轮最多删除的行数,避免长时间占用数据库
     * <p>
     * åˆ†æ‰¹åˆ é™¤ï¼Œå•次清理上限用于控制删除任务对线上写入的影响。
     */
    private int cleanupBatchSize = 5000;
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/bagcode/MesProBagCodeGenerateTaskRegistry.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,108 @@
package cn.iocoder.yudao.module.mes.framework.bagcode;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicBoolean;
/**
 * è¢‹ç ç”Ÿæˆä»»åŠ¡è¿è¡Œæ€æ³¨å†Œè¡¨
 * <p>
 * ä¿å­˜ã€Œå“ªäº›ä»»åŠ¡æ­£åœ¨ç”Ÿæˆã€ä¸Žã€Œæ˜¯å¦å·²è¯·æ±‚å–æ¶ˆã€ã€‚å–æ¶ˆæ ‡è®°æ”¾å†…å­˜è€Œéžæ•°æ®åº“ï¼Œ
 * æ˜¯å› ä¸ºç”Ÿæˆçº¿ç¨‹éœ€è¦æ¯æ®µè½æ£€æŸ¥ä¸€æ¬¡å–消,走数据库会产生大量无谓查询;本项目为单机部署,两者同进程。
 * <p>
 * è¿›ç¨‹é‡å¯ä¼šè®©æ‰€æœ‰è¿è¡Œæ€ä¸¢å¤±ï¼Œæ­¤æ—¶ç”±æ¸…理任务把残留的非终态任务回收为失败。
 */
@Component
public class MesProBagCodeGenerateTaskRegistry {
    @Resource
    private MesProBagCodeGenerateProperties properties;
    /**
     * æ­£åœ¨è¿è¡Œçš„任务:任务编号 â†’ å–消标记
     */
    private final Map<Long, AtomicBoolean> runningTasks = new ConcurrentHashMap<>();
    /**
     * å¹¶å‘许可:约束后台生成任务数
     */
    private Semaphore permits;
    @PostConstruct
    public void init() {
        permits = new Semaphore(Math.max(1, properties.getMaxConcurrentTasks()));
    }
    /**
     * å°è¯•占用一个运行名额
     */
    public boolean tryAcquire() {
        return permits.tryAcquire();
    }
    /**
     * é‡Šæ”¾è¿è¡Œåé¢ï¼ˆå®Œæˆã€å¤±è´¥ã€å–消、提交失败均需调用)
     */
    public void release() {
        permits.release();
    }
    /**
     * å½“前由本进程持有的任务数(含刚建好、尚未被线程接管的)
     */
    public int getRunningCount() {
        return runningTasks.size();
    }
    /**
     * æ³¨å†Œä»»åŠ¡å¹¶è¿”å›žå…¶å–æ¶ˆæ ‡è®°
     * <p>
     * å¿…须在任务落库后、投递线程池前调用,避免清理任务把尚未注册的记录误判为重启残留。
     */
    public AtomicBoolean register(Long taskId) {
        AtomicBoolean flag = new AtomicBoolean(false);
        runningTasks.put(taskId, flag);
        return flag;
    }
    /**
     * èŽ·å–ä»»åŠ¡çš„å–æ¶ˆæ ‡è®°ï¼›ä»»åŠ¡ä¸å±žäºŽæœ¬è¿›ç¨‹æ—¶è¿”å›ž null
     */
    public AtomicBoolean get(Long taskId) {
        return runningTasks.get(taskId);
    }
    /**
     * ä»»åŠ¡æ˜¯å¦ç”±æœ¬è¿›ç¨‹æŒæœ‰
     */
    public boolean isRunning(Long taskId) {
        return runningTasks.containsKey(taskId);
    }
    /**
     * æ³¨é”€ä»»åŠ¡
     */
    public void unregister(Long taskId) {
        runningTasks.remove(taskId);
    }
    /**
     * è¯·æ±‚取消:任务在跑则置位取消标记
     *
     * @return true è¡¨ç¤ºå–消请求已送达;false è¡¨ç¤ºä»»åŠ¡ä¸åœ¨æœ¬è¿›ç¨‹è¿è¡Œ
     */
    public boolean requestCancel(Long taskId) {
        AtomicBoolean flag = runningTasks.get(taskId);
        if (flag == null) {
            return false;
        }
        flag.set(true);
        return true;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/job/pro/MesProBagCodeGenerateCleanupTask.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,55 @@
package cn.iocoder.yudao.module.mes.job.pro;
import cn.iocoder.yudao.module.mes.service.pro.bagcode.MesProBagCodeGenerateTaskService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
 * è¢‹ç ç”Ÿæˆä»»åŠ¡æ¸…ç†
 * <p>
 * è¿›ç¨‹é‡å¯åŽå†…存运行态丢失,表中停在「排队中/生成中/取消中」的记录不会再有线程推进,
 * ç”±æœ¬ä»»åŠ¡å›žæ”¶ä¸ºå¤±è´¥å¹¶æ¸…ç†å…¶åŠæˆå“æ•°æ®ã€‚ç”¨ Spring è‡ªå¸¦çš„ {@code @Scheduled} è€Œéž Quartz JobHandler:
 * åŽè€…需要在管理后台手工登记,漏登记就会静默不执行,这里的清理属于必须开箱即跑的兜底逻辑。
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class MesProBagCodeGenerateCleanupTask {
    private final MesProBagCodeGenerateTaskService taskService;
    /**
     * æ¯ 5 åˆ†é’Ÿå›žæ”¶ä¸€æ¬¡å¼‚常中断的任务
     */
    @Scheduled(cron = "0 */5 * * * ?")
    public void cleanup() {
        try {
            int tasks = taskService.recycleStaleTasks();
            if (tasks > 0) {
                log.info("[cleanup][回收残留袋码生成任务 {} ä¸ª]", tasks);
            }
        } catch (Exception e) {
            log.error("[cleanup][回收残留袋码生成任务失败]", e);
        }
    }
    /**
     * å¯åŠ¨æ—¶ç«‹åˆ»å›žæ”¶ä¸€æ¬¡ï¼Œé¿å…æ®‹ç•™ä»»åŠ¡ä»¥ã€Œç”Ÿæˆä¸­ã€çŠ¶æ€ä¸€ç›´æŒ‚åœ¨ç•Œé¢ä¸Š
     */
    @EventListener(ApplicationReadyEvent.class)
    public void recycleOnStartup() {
        try {
            int tasks = taskService.recycleStaleTasks();
            if (tasks > 0) {
                log.info("[recycleOnStartup][启动回收残留袋码生成任务 {} ä¸ª]", tasks);
            }
        } catch (Exception e) {
            log.error("[recycleOnStartup][启动回收袋码生成任务失败]", e);
        }
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/MesProBagCodeBatchGenerator.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,186 @@
package cn.iocoder.yudao.module.mes.service.pro.bagcode;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.bagcode.MesProBagCodeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.pallet.MesProPalletDO;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.bagcode.MesProBagCodeGenerateTaskMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.bagcode.MesProBagCodeMapper;
import cn.iocoder.yudao.module.mes.dal.redis.MesCodeRedisDAO;
import cn.iocoder.yudao.module.mes.enums.pro.MesProBagCodeGenerateBizTypeEnum;
import cn.iocoder.yudao.module.mes.enums.pro.MesProBagCodeStatusEnum;
import cn.iocoder.yudao.module.mes.enums.pro.MesProPalletStatusEnum;
import cn.iocoder.yudao.module.mes.framework.bagcode.MesProBagCodeGenerateProperties;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
/**
 * è¢‹ç æ‰¹é‡ç”Ÿæˆæ‰§è¡Œå™¨ï¼ˆåŒæ­¥ä¸Žå¼‚步共用)
 * <p>
 * ä¸Žæ—§å®žçŽ°çš„å…³é”®å·®å¼‚ï¼š
 * <ul>
 *     <li>编码批量向 Redis ç”³è¯·ï¼Œé¿å…æ¯ä¸ªè¢‹ç ä¸¤æ¬¡ç½‘络往返;</li>
 *     <li>袋序号一次性分配,分段只做偏移计算;</li>
 *     <li>每段独立事务落库,并在段间回写进度、检查取消与超时。</li>
 * </ul>
 */
@Slf4j
@Component
public class MesProBagCodeBatchGenerator {
    @Resource
    private MesProBagCodeMapper bagCodeMapper;
    @Resource
    private MesProBagCodeGenerateTaskMapper taskMapper;
    @Resource
    private MesCodeRedisDAO codeRedisDao;
    @Resource
    private MesProBagCodeChunkWriter chunkWriter;
    @Resource
    private MesProBagCodeGenerateProperties properties;
    /**
     * æ‰§è¡Œç”Ÿæˆ
     *
     * @param spec       ç”Ÿæˆå‚æ•°
     * @param taskId     ä»»åŠ¡ç¼–å·ï¼›ä¸º null è¡¨ç¤ºåŒæ­¥ç”Ÿæˆï¼Œä¸å›žå†™è¿›åº¦
     * @param cancelFlag å–消标记
     * @return ç”Ÿæˆç»“果(袋码数 + æ‰˜ç›˜ç¼–号)
     */
    public GenerateResult generate(MesProBagCodeGenerateSpec spec, Long taskId, AtomicBoolean cancelFlag) {
        assertNotCancelled(cancelFlag);
        long deadline = System.currentTimeMillis()
                + Duration.ofMinutes(Math.max(1, properties.getMaxRuntimeMinutes())).toMillis();
        // 1. å…¨å±€è¢‹åºå·ä¸€æ¬¡æ€§åˆ†é…ï¼šåŽç»­åˆ†æ®µåªåšåç§»ï¼Œé¿å…æ¯æ®µéƒ½æŸ¥åº“内最大值
        int startNo = codeRedisDao.allocateBagNoStart(spec.totalCount(), bagCodeMapper.selectMaxBagNo());
        // 2. æ‰˜ç›˜ï¼ˆä»…数据包模式且按 N è¢‹æ¢ç®—时生成)
        PalletRefs palletRefs = createPallets(spec, cancelFlag, deadline);
        // 3. è¢‹ç åˆ†æ®µè½åº“
        int chunkSize = Math.max(1, properties.getChunkSize());
        int finished = 0;
        while (finished < spec.totalCount()) {
            assertNotCancelled(cancelFlag);
            assertNotTimeout(deadline);
            int size = Math.min(chunkSize, spec.totalCount() - finished);
            // æ¯ä¸ªè¢‹ç éœ€è¦è¿½æº¯ç  + ä¸šåŠ¡ç 
            List<String> codes = codeRedisDao.generateBatch(size * 2);
            List<MesProBagCodeDO> chunk = new ArrayList<>(size);
            for (int i = 0; i < size; i++) {
                int index = finished + i;
                PalletRef pallet = palletRefs.resolve(spec, index);
                chunk.add(new MesProBagCodeDO()
                        .setUuid(spec.uuid())
                        .setCode(codes.get(i * 2))
                        .setBusinessCode(codes.get(i * 2 + 1))
                        .setBagNo(startNo + index)
                        .setItemId(spec.itemId())
                        .setItemCode(spec.itemCode())
                        .setItemName(spec.itemName())
                        .setPalletId(pallet == null ? null : pallet.id())
                        .setPalletCode(pallet == null ? null : pallet.tempCode())
                        .setStatus(MesProBagCodeStatusEnum.GENERATED.getStatus())
                        .setRemark(spec.remark()));
            }
            chunkWriter.insertBagCodes(chunk);
            finished += size;
            if (taskId != null) {
                taskMapper.updateProgress(taskId, finished);
            }
        }
        return new GenerateResult(finished, palletRefs.ids());
    }
    /**
     * æŒ‰éœ€åˆ›å»ºæ‰˜ç›˜ï¼šæ•°æ®åŒ…模式下袋码按「每托 N è¢‹ã€å‡åˆ†ç»‘定
     */
    private PalletRefs createPallets(MesProBagCodeGenerateSpec spec,
                                     AtomicBoolean cancelFlag, long deadline) {
        if (spec.bizType() != MesProBagCodeGenerateBizTypeEnum.PACKAGE || spec.palletCount() <= 0) {
            return PalletRefs.EMPTY;
        }
        int chunkSize = Math.max(1, properties.getChunkSize());
        List<Long> ids = new ArrayList<>(spec.palletCount());
        List<String> tempCodes = new ArrayList<>(spec.palletCount());
        int created = 0;
        while (created < spec.palletCount()) {
            assertNotCancelled(cancelFlag);
            assertNotTimeout(deadline);
            int size = Math.min(chunkSize, spec.palletCount() - created);
            // æ¯ä¸ªæ‰˜ç›˜éœ€è¦ä¸´æ—¶ç  + ä¸šåŠ¡ç 
            List<String> codes = codeRedisDao.generateBatch(size * 2);
            List<MesProPalletDO> pallets = new ArrayList<>(size);
            for (int i = 0; i < size; i++) {
                pallets.add(new MesProPalletDO()
                        .setTempCode(codes.get(i * 2))
                        .setBusinessCode(codes.get(i * 2 + 1))
                        .setBagCount(spec.perPallet())
                        .setAutoGenerated(true)
                        .setItemId(spec.itemId())
                        .setGenerateUuid(spec.uuid())
                        .setStatus(MesProPalletStatusEnum.IN_USE.getStatus()));
            }
            chunkWriter.insertPallets(pallets);
            pallets.forEach(pallet -> {
                ids.add(pallet.getId());
                tempCodes.add(pallet.getTempCode());
            });
            created += size;
        }
        return new PalletRefs(ids, tempCodes);
    }
    private void assertNotCancelled(AtomicBoolean cancelFlag) {
        if (cancelFlag != null && cancelFlag.get()) {
            throw new CancelledException();
        }
    }
    private void assertNotTimeout(long deadline) {
        if (System.currentTimeMillis() > deadline) {
            throw new IllegalStateException("生成任务超过最长运行时间("
                    + properties.getMaxRuntimeMinutes() + " åˆ†é’Ÿï¼‰");
        }
    }
    /**
     * æ‰˜ç›˜å¼•用:只保留袋码需要回写的两个字段,避免在内存中堆积整表 DO
     */
    private record PalletRefs(List<Long> ids, List<String> tempCodes) {
        private static final PalletRefs EMPTY = new PalletRefs(List.of(), List.of());
        private PalletRef resolve(MesProBagCodeGenerateSpec spec, int bagIndex) {
            if (ids.isEmpty() || spec.perPallet() <= 0) {
                return null;
            }
            int palletIndex = bagIndex / spec.perPallet();
            return new PalletRef(ids.get(palletIndex), tempCodes.get(palletIndex));
        }
    }
    private record PalletRef(Long id, String tempCode) {
    }
    /**
     * ç”Ÿæˆç»“果:同步路径需要托盘编号用于响应,异步路径只关心完成数量
     */
    public record GenerateResult(int finishedCount, List<Long> palletIds) {
    }
    /**
     * å–消信号:生成线程检测到取消时抛出,由调用方统一清理半成品
     */
    public static class CancelledException extends RuntimeException {
        public CancelledException() {
            super("生成任务已取消");
        }
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/MesProBagCodeChunkWriter.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,51 @@
package cn.iocoder.yudao.module.mes.service.pro.bagcode;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.bagcode.MesProBagCodeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.pallet.MesProPalletDO;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.bagcode.MesProBagCodeMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.pallet.MesProPalletMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
 * è¢‹ç /托盘分段写入器
 * <p>
 * æ¯æ®µä½¿ç”¨ç‹¬ç«‹äº‹åŠ¡ï¼ˆREQUIRES_NEW):几十万行放在同一个事务里会形成超大事务,
 * é•¿æ—¶é—´å ç”¨ undo/redo ä¸Žè¡Œé”ï¼Œå¤±è´¥æ—¶å›žæ»šæˆæœ¬ä¹Ÿæžé«˜ã€‚
 * <p>
 * ç‹¬ç«‹æˆ Bean æ˜¯ä¸ºäº†è®© {@code REQUIRES_NEW} ç» Spring ä»£ç†ç”Ÿæ•ˆï¼Œé¿å…åŒç±»è‡ªè°ƒç”¨ç»•过事务。
 */
@Component
public class MesProBagCodeChunkWriter {
    /**
     * å•条 INSERT è¯­å¥çš„行数;与分段大小分开,控制单条 SQL çš„包体大小
     */
    private static final int INSERT_BATCH_SIZE = 1000;
    @Resource
    private MesProBagCodeMapper bagCodeMapper;
    @Resource
    private MesProPalletMapper palletMapper;
    /**
     * å†™å…¥ä¸€æ®µè¢‹ç 
     */
    @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
    public void insertBagCodes(List<MesProBagCodeDO> bagCodes) {
        bagCodeMapper.insertBatch(bagCodes, INSERT_BATCH_SIZE);
    }
    /**
     * å†™å…¥ä¸€æ®µæ‰˜ç›˜ï¼ˆæ’入后 ID ä¼šå›žå¡«åˆ°å®žä½“上)
     */
    @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class)
    public void insertPallets(List<MesProPalletDO> pallets) {
        palletMapper.insertBatch(pallets, INSERT_BATCH_SIZE);
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/MesProBagCodeGenerateCleaner.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,46 @@
package cn.iocoder.yudao.module.mes.service.pro.bagcode;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.bagcode.MesProBagCodeMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.pallet.MesProPalletMapper;
import cn.iocoder.yudao.module.mes.framework.bagcode.MesProBagCodeGenerateProperties;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
 * è¢‹ç ç”ŸæˆåŠæˆå“æ¸…理器
 * <p>
 * ç”Ÿæˆå¤±è´¥æˆ–取消时会留下部分袋码与托盘。这些数据从未对外展示,不做逻辑删除,
 * ç›´æŽ¥æŒ‰ {@code uuid} åˆ†æ®µç‰©ç†åˆ é™¤ï¼Œé¿å…ç•™ä¸‹å‡ åä¸‡æ¡åžƒåœ¾è¡Œã€‚
 * <p>
 * å•条 DELETE è‡ªå¸¦åŽŸå­æ€§ï¼Œä¸”å·²ç”¨ LIMIT æŽ§åˆ¶å•次影响行数,因此不需要额外事务。
 */
@Slf4j
@Component
public class MesProBagCodeGenerateCleaner {
    @Resource
    private MesProBagCodeMapper bagCodeMapper;
    @Resource
    private MesProPalletMapper palletMapper;
    @Resource
    private MesProBagCodeGenerateProperties properties;
    /**
     * æ¸…理某个生成组的所有半成品数据
     *
     * @param uuid ç”Ÿæˆç»„ UUID
     */
    public void cleanup(String uuid) {
        int batchSize = Math.max(1, properties.getCleanupBatchSize());
        int deleted;
        do {
            deleted = bagCodeMapper.deletePhysicallyByUuidChunk(uuid, batchSize);
        } while (deleted >= batchSize);
        do {
            deleted = palletMapper.deletePhysicallyByGenerateUuidChunk(uuid, batchSize);
        } while (deleted >= batchSize);
        log.info("[cleanup][生成组 {} åŠæˆå“æ¸…理完成]", uuid);
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/MesProBagCodeGenerateRunner.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,90 @@
package cn.iocoder.yudao.module.mes.service.pro.bagcode;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.bagcode.MesProBagCodeGenerateTaskMapper;
import cn.iocoder.yudao.module.mes.enums.pro.MesProBagCodeGenerateTaskStatusEnum;
import cn.iocoder.yudao.module.mes.framework.bagcode.MesProBagCodeGenerateTaskRegistry;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.concurrent.atomic.AtomicBoolean;
/**
 * è¢‹ç ç”Ÿæˆä»»åŠ¡æ‰§è¡Œå™¨
 * <p>
 * åœ¨åŽå°çº¿ç¨‹å†…完成分段生成,并负责:回写进度、响应取消、失败/取消后清理半成品。
 */
@Slf4j
@Component
public class MesProBagCodeGenerateRunner {
    @Resource
    private MesProBagCodeGenerateTaskMapper taskMapper;
    @Resource
    private MesProBagCodeBatchGenerator generator;
    @Resource
    private MesProBagCodeGenerateCleaner cleaner;
    @Resource
    private MesProBagCodeGenerateTaskRegistry registry;
    /**
     * æ‰§è¡Œç”Ÿæˆä»»åŠ¡ï¼ˆç”±çº¿ç¨‹æ± è°ƒç”¨ï¼Œå‹¿åœ¨è¯·æ±‚çº¿ç¨‹å†…ç›´æŽ¥è°ƒç”¨ï¼‰
     *
     * @param taskId ä»»åŠ¡ç¼–å·
     * @param spec   ç”Ÿæˆå‚æ•°
     */
    public void run(Long taskId, MesProBagCodeGenerateSpec spec) {
        // å–消标记在提交时已注册,这里只取用;取不到说明注册环节被绕过,补一个以保证取消可用
        AtomicBoolean cancelFlag = registry.get(taskId);
        if (cancelFlag == null) {
            cancelFlag = registry.register(taskId);
        }
        try {
            taskMapper.updateStatus(taskId, MesProBagCodeGenerateTaskStatusEnum.RUNNING.getStatus(),
                    null, null);
            int finished = generator.generate(spec, taskId, cancelFlag).finishedCount();
            // æœ€åŽä¸€æ®µå†™å…¥åŽã€ä»»åŠ¡æ”¶å°¾å‰æ‰æ”¶åˆ°å–æ¶ˆï¼šä»æŒ‰å–æ¶ˆå¤„ç†ï¼Œä¿è¯"取消后无半成品"
            if (cancelFlag.get()) {
                throw new MesProBagCodeBatchGenerator.CancelledException();
            }
            taskMapper.updateProgress(taskId, finished);
            taskMapper.updateStatus(taskId, MesProBagCodeGenerateTaskStatusEnum.FINISHED.getStatus(),
                    null, LocalDateTime.now());
            log.info("[run][生成任务 {} å®Œæˆï¼Œå…± {} æ¡è¢‹ç ]", taskId, finished);
        } catch (MesProBagCodeBatchGenerator.CancelledException e) {
            cleanup(taskId, spec.uuid());
            taskMapper.updateStatus(taskId, MesProBagCodeGenerateTaskStatusEnum.CANCELED.getStatus(),
                    null, LocalDateTime.now());
            log.info("[run][生成任务 {} å·²å–消]", taskId);
        } catch (Exception e) {
            cleanup(taskId, spec.uuid());
            taskMapper.updateStatus(taskId, MesProBagCodeGenerateTaskStatusEnum.FAILED.getStatus(),
                    truncateError(e), LocalDateTime.now());
            log.error("[run][生成任务 {} ç”Ÿæˆå¤±è´¥]", taskId, e);
        } finally {
            registry.unregister(taskId);
            registry.release();
        }
    }
    /**
     * æ¸…理半成品:清理失败只记录日志,不能影响任务终态回写,否则任务会一直挂在"生成中"
     */
    private void cleanup(Long taskId, String uuid) {
        try {
            cleaner.cleanup(uuid);
        } catch (Exception e) {
            log.error("[cleanup][生成任务 {} åŠæˆå“æ¸…理失败,uuid={}]", taskId, uuid, e);
        }
    }
    private String truncateError(Exception e) {
        String message = e.getMessage();
        if (message == null || message.isBlank()) {
            message = e.getClass().getSimpleName();
        }
        return message.length() > 500 ? message.substring(0, 500) : message;
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/MesProBagCodeGenerateSpec.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,30 @@
package cn.iocoder.yudao.module.mes.service.pro.bagcode;
import cn.iocoder.yudao.module.mes.enums.pro.MesProBagCodeGenerateBizTypeEnum;
/**
 * è¢‹ç æ‰¹é‡ç”Ÿæˆå‚数(同步与异步共用)
 *
 * @param bizType    ä¸šåŠ¡ç±»åž‹
 * @param uuid       ç”Ÿæˆç»„ UUID
 * @param itemId     å“ç§ç¼–号
 * @param itemCode   å“ç§ç¼–码
 * @param itemName   å“ç§åç§°
 * @param unitType   è®¡é‡å•位类型
 * @param perPallet  æ¯æ‰˜ç›˜è¢‹æ•°ï¼ˆå¨ç±»åž‹ä¸º 0)
 * @param palletCount æ‰˜ç›˜æ•°ï¼ˆåŽŸå§‹æ•°æ®ä¸º 0)
 * @param totalCount è¢‹ç æ€»æ•°
 * @param remark     å¤‡æ³¨
 */
public record MesProBagCodeGenerateSpec(
        MesProBagCodeGenerateBizTypeEnum bizType,
        String uuid,
        Long itemId,
        String itemCode,
        String itemName,
        String unitType,
        int perPallet,
        int palletCount,
        int totalCount,
        String remark) {
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/MesProBagCodeGenerateTaskService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,54 @@
package cn.iocoder.yudao.module.mes.service.pro.bagcode;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.bagcode.MesProBagCodeGenerateTaskDO;
/**
 * è¢‹ç æ‰¹é‡ç”Ÿæˆä»»åŠ¡ Service
 * <p>
 * åªæ‰¿æ‹…超出同步阈值的生成请求:小批量仍在请求线程内同步完成,不进任务、不排队。
 */
public interface MesProBagCodeGenerateTaskService {
    /**
     * æäº¤åŽå°ç”Ÿæˆä»»åŠ¡
     *
     * @param spec      ç”Ÿæˆå‚æ•°
     * @param requestId å¹‚等键,可为空;非空时同一键只会创建一个任务
     * @return å·²åˆ›å»ºæˆ–已存在的任务
     */
    MesProBagCodeGenerateTaskDO submit(MesProBagCodeGenerateSpec spec, String requestId);
    /**
     * èŽ·å¾—ç”Ÿæˆä»»åŠ¡
     *
     * @param id ä»»åŠ¡ç¼–å·
     * @return ä»»åŠ¡ï¼›ä¸å­˜åœ¨æ—¶æŠ›ä¸šåŠ¡å¼‚å¸¸
     */
    MesProBagCodeGenerateTaskDO getTask(Long id);
    /**
     * æŒ‰å¹‚等键获得生成任务
     *
     * @param requestId å¹‚等键
     * @return ä»»åŠ¡ï¼›ä¸å­˜åœ¨æ—¶è¿”å›ž null
     */
    MesProBagCodeGenerateTaskDO getTaskByRequestId(String requestId);
    /**
     * å–消生成任务
     * <p>
     * ä»»åŠ¡æ­£åœ¨æœ¬è¿›ç¨‹æ‰§è¡Œæ—¶ç½®ä½å–æ¶ˆæ ‡è®°ï¼Œç”±æ‰§è¡Œçº¿ç¨‹åœæ­¢å¹¶æ¸…ç†éƒ¨åˆ†æ•°æ®ï¼›
     * å·²å¤„于终态的任务不允许取消。
     *
     * @param id ä»»åŠ¡ç¼–å·
     */
    void cancel(Long id);
    /**
     * å›žæ”¶å› æœåŠ¡é‡å¯æˆ–å¼‚å¸¸ä¸­æ–­è€Œæ®‹ç•™åœ¨éžç»ˆæ€çš„ä»»åŠ¡
     *
     * @return å›žæ”¶çš„任务数
     */
    int recycleStaleTasks();
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/MesProBagCodeGenerateTaskServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,216 @@
package cn.iocoder.yudao.module.mes.service.pro.bagcode;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.bagcode.MesProBagCodeGenerateTaskDO;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.bagcode.MesProBagCodeGenerateTaskMapper;
import cn.iocoder.yudao.module.mes.enums.pro.MesProBagCodeGenerateTaskStatusEnum;
import cn.iocoder.yudao.module.mes.framework.bagcode.MesProBagCodeGenerateTaskRegistry;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.RejectedExecutionException;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_BAG_CODE_GENERATE_TASK_BUSY;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_BAG_CODE_GENERATE_TASK_NOT_EXISTS;
import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.PRO_BAG_CODE_GENERATE_TASK_STATUS_INVALID;
/**
 * è¢‹ç æ‰¹é‡ç”Ÿæˆä»»åŠ¡ Service å®žçް
 */
@Slf4j
@Service
@Validated
public class MesProBagCodeGenerateTaskServiceImpl implements MesProBagCodeGenerateTaskService {
    /**
     * éžç»ˆæ€çŠ¶æ€é›†åˆ
     */
    private static final List<Integer> UNFINISHED_STATUSES = List.of(
            MesProBagCodeGenerateTaskStatusEnum.PENDING.getStatus(),
            MesProBagCodeGenerateTaskStatusEnum.RUNNING.getStatus(),
            MesProBagCodeGenerateTaskStatusEnum.CANCELING.getStatus());
    /**
     * ç»ˆæ€çŠ¶æ€é›†åˆ
     */
    private static final List<Integer> FINAL_STATUSES = List.of(
            MesProBagCodeGenerateTaskStatusEnum.FINISHED.getStatus(),
            MesProBagCodeGenerateTaskStatusEnum.CANCELED.getStatus(),
            MesProBagCodeGenerateTaskStatusEnum.FAILED.getStatus());
    @Resource
    private MesProBagCodeGenerateTaskMapper taskMapper;
    @Resource
    private MesProBagCodeGenerateRunner runner;
    @Resource
    private MesProBagCodeGenerateCleaner cleaner;
    @Resource
    private MesProBagCodeGenerateTaskRegistry registry;
    @Resource(name = "mesBagCodeGenerateExecutor")
    private ThreadPoolTaskExecutor executor;
    @Override
    public MesProBagCodeGenerateTaskDO submit(MesProBagCodeGenerateSpec spec, String requestId) {
        // 1. å¹‚等:网络重试复用同一个 requestId æ—¶ç›´æŽ¥è¿”回已有任务,不再生成
        MesProBagCodeGenerateTaskDO existing = findExisting(requestId);
        if (existing != null) {
            return existing;
        }
        // 2. å¹¶å‘闸门:同一时间只跑一个后台生成任务;被挡住时带上当前任务进度
        if (!registry.tryAcquire()) {
            // å¹¶å‘重复提交时,另一请求可能已抢先落库
            existing = findExisting(requestId);
            if (existing != null) {
                return existing;
            }
            throw exception(PRO_BAG_CODE_GENERATE_TASK_BUSY, resolveRunningProgress());
        }
        // æƒé™ä¸€æ—¦äº¤ç»™æ‰§è¡Œå™¨ï¼Œå°±ç”±æ‰§è¡Œçº¿ç¨‹åœ¨æ”¶å°¾æ—¶å½’还;此前任何失败都必须在这里归还
        boolean handedOver = false;
        try {
            MesProBagCodeGenerateTaskDO task = MesProBagCodeGenerateTaskDO.builder()
                    .requestId(requestId)
                    .bizType(spec.bizType().getType())
                    .uuid(spec.uuid())
                    .itemId(spec.itemId())
                    .itemCode(spec.itemCode())
                    .itemName(spec.itemName())
                    .unitType(spec.unitType())
                    .perPallet(spec.perPallet())
                    .palletCount(spec.palletCount())
                    .totalCount(spec.totalCount())
                    .finishedCount(0)
                    .status(MesProBagCodeGenerateTaskStatusEnum.PENDING.getStatus())
                    .remark(spec.remark())
                    .build();
            try {
                taskMapper.insert(task);
            } catch (DuplicateKeyException e) {
                // å”¯ä¸€ç´¢å¼•兜底并发重复提交
                existing = findExisting(requestId);
                if (existing != null) {
                    return existing;
                }
                throw e;
            }
            // 3. å…ˆæ³¨å†Œè¿è¡Œæ€å†æŠ•递:避免「已落库但未注册」的窗口被清理任务误回收
            registry.register(task.getId());
            handedOver = true;
            try {
                executor.execute(() -> runner.run(task.getId(), spec));
            } catch (RejectedExecutionException e) {
                registry.unregister(task.getId());
                handedOver = false;
                taskMapper.updateStatus(task.getId(),
                        MesProBagCodeGenerateTaskStatusEnum.FAILED.getStatus(),
                        "生成线程繁忙,请稍后重试", LocalDateTime.now());
                throw exception(PRO_BAG_CODE_GENERATE_TASK_BUSY, 0);
            }
            return task;
        } finally {
            if (!handedOver) {
                registry.release();
            }
        }
    }
    @Override
    public MesProBagCodeGenerateTaskDO getTask(Long id) {
        MesProBagCodeGenerateTaskDO task = taskMapper.selectById(id);
        if (task == null) {
            throw exception(PRO_BAG_CODE_GENERATE_TASK_NOT_EXISTS);
        }
        return task;
    }
    @Override
    public MesProBagCodeGenerateTaskDO getTaskByRequestId(String requestId) {
        return findExisting(requestId);
    }
    @Override
    public void cancel(Long id) {
        MesProBagCodeGenerateTaskDO task = getTask(id);
        if (FINAL_STATUSES.contains(task.getStatus())) {
            throw exception(PRO_BAG_CODE_GENERATE_TASK_STATUS_INVALID);
        }
        if (registry.requestCancel(id)) {
            // æ‰§è¡Œçº¿ç¨‹ä¼šåœ¨å½“前分段结束后停止、清理半成品并置为已取消
            taskMapper.updateStatus(id, MesProBagCodeGenerateTaskStatusEnum.CANCELING.getStatus(),
                    null, null);
            return;
        }
        // ä¸åœ¨æœ¬è¿›ç¨‹è¿è¡Œï¼ˆè¿›ç¨‹é‡å¯åŽçš„æ®‹ç•™ä»»åŠ¡ï¼‰ï¼šç›´æŽ¥æ¸…ç†æ®‹ç•™å¹¶æ”¶å°¾
        cleanupQuietly(task);
        taskMapper.updateStatus(id, MesProBagCodeGenerateTaskStatusEnum.CANCELED.getStatus(),
                null, LocalDateTime.now());
    }
    @Override
    public int recycleStaleTasks() {
        List<MesProBagCodeGenerateTaskDO> list = taskMapper.selectUnfinishedList(UNFINISHED_STATUSES);
        // æäº¤çº¿ç¨‹ã€Œè½åº“ â†’ æ³¨å†Œè¿è¡Œæ€ã€ä¹‹é—´å­˜åœ¨æžçŸ­çª—口,跳过刚创建的任务避免误回收
        LocalDateTime recent = LocalDateTime.now().minusMinutes(1);
        int count = 0;
        for (MesProBagCodeGenerateTaskDO task : list) {
            if (task.getCreateTime() != null && task.getCreateTime().isAfter(recent)) {
                continue;
            }
            if (registry.isRunning(task.getId())) {
                continue;
            }
            cleanupQuietly(task);
            taskMapper.updateStatus(task.getId(), MesProBagCodeGenerateTaskStatusEnum.FAILED.getStatus(),
                    "任务因服务重启或异常中断而终止,请重新生成", LocalDateTime.now());
            count++;
        }
        return count;
    }
    // ==================== ç§æœ‰æ–¹æ³• ====================
    private MesProBagCodeGenerateTaskDO findExisting(String requestId) {
        if (StrUtil.isBlank(requestId)) {
            return null;
        }
        return taskMapper.selectByRequestId(requestId);
    }
    /**
     * æ¸…理半成品:清理失败不能阻断任务终态回写,否则任务会一直挂在非终态
     */
    private void cleanupQuietly(MesProBagCodeGenerateTaskDO task) {
        try {
            cleaner.cleanup(task.getUuid());
        } catch (Exception e) {
            log.error("[cleanupQuietly][生成任务 {} åŠæˆå“æ¸…理失败,uuid={}]",
                    task.getId(), task.getUuid(), e);
        }
    }
    /**
     * æ­£åœ¨è¿è¡Œä»»åŠ¡çš„è¿›åº¦ç™¾åˆ†æ¯”ï¼Œç”¨äºŽã€Œå·²æœ‰ä»»åŠ¡è¿›è¡Œä¸­ã€çš„æç¤º
     */
    private int resolveRunningProgress() {
        List<MesProBagCodeGenerateTaskDO> tasks = taskMapper.selectUnfinishedList(UNFINISHED_STATUSES);
        return tasks.stream().findFirst().map(this::calcProgress).orElse(0);
    }
    private int calcProgress(MesProBagCodeGenerateTaskDO task) {
        Integer total = task.getTotalCount();
        if (total == null || total <= 0) {
            return 0;
        }
        int finished = task.getFinishedCount() == null ? 0 : task.getFinishedCount();
        return (int) Math.min(100L, (long) finished * 100 / total);
    }
}
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/MesProBagCodeServiceImpl.java
@@ -18,6 +18,7 @@
import cn.iocoder.yudao.module.mes.controller.admin.pro.bagcode.vo.MesProBagCodeReplaceRespVO;
import cn.iocoder.yudao.module.mes.dal.dataobject.mdm.MesMdmItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.bagcode.MesProBagCodeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.bagcode.MesProBagCodeGenerateTaskDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.bagcode.MesProBagCodeReplaceDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.batch.MesProBatchDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.pallet.MesProPalletDO;
@@ -26,9 +27,11 @@
import cn.iocoder.yudao.module.mes.dal.mysql.pro.batch.MesProBatchMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.pallet.MesProPalletMapper;
import cn.iocoder.yudao.module.mes.dal.redis.MesCodeRedisDAO;
import cn.iocoder.yudao.module.mes.enums.pro.MesProBagCodeGenerateBizTypeEnum;
import cn.iocoder.yudao.module.mes.enums.pro.MesProBagCodeStatusEnum;
import cn.iocoder.yudao.module.mes.enums.pro.MesProBatchStatusEnum;
import cn.iocoder.yudao.module.mes.enums.pro.MesProPalletStatusEnum;
import cn.iocoder.yudao.module.mes.framework.bagcode.MesProBagCodeGenerateProperties;
import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService;
import cn.iocoder.yudao.module.mes.service.pro.pallet.MesProPalletService;
import jakarta.annotation.Resource;
@@ -46,6 +49,7 @@
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@@ -92,8 +96,16 @@
    @Resource
    private cn.iocoder.yudao.module.mes.service.trace.integrity.MesTraceIntegrityService integrityService;
    @Resource
    private MesProBagCodeBatchGenerator batchGenerator;
    @Resource
    private MesProBagCodeGenerateCleaner generateCleaner;
    @Resource
    private MesProBagCodeGenerateTaskService generateTaskService;
    @Resource
    private MesProBagCodeGenerateProperties generateProperties;
    @Override
    @Transactional(rollbackFor = Exception.class)
    public MesProBagCodeGenerateRespVO generateBagCodes(MesProBagCodeGenerateReqVO generateReqVO) {
        // 1. æ ¡éªŒå“ç§å¹¶è¯»å–主计量单位类型
        MesMdmItemDO item = mdmItemService.validateItemExists(generateReqVO.getItemId());
@@ -102,7 +114,7 @@
        // 2. æŒ‰ç±»åž‹æ¢ç®—袋码数量:N袋 = æ¯æ‰˜ N è¢‹ Ã— æ‰˜ç›˜æ•°ï¼›å¨ = ç›´æŽ¥è¾“入袋码数(无托盘)
        boolean ton = UNIT_TYPE_TON.equals(type);
        int perPallet = ton ? 0 : parseBagsPerPallet(type);
        int totalCount;
        long totalCount;
        int palletCount = 0;
        if (ton) {
            if (generateReqVO.getBagCount() == null || generateReqVO.getBagCount() <= 0) {
@@ -114,79 +126,94 @@
                throw exception(PRO_BAG_CODE_PALLET_COUNT_REQUIRED);
            }
            palletCount = generateReqVO.getPalletCount();
            totalCount = perPallet * palletCount;
            // ç”¨ long è®¡ç®—,避免大托盘数相乘溢出 int
            totalCount = (long) perPallet * palletCount;
        }
        assertGenerateCountAllowed(totalCount);
        // 3. ç”Ÿæˆé˜¶æ®µï¼šè¢‹ç ç›´æŽ¥ç»‘定品种;生产批次在后续录入
        String uuid = IdUtil.fastSimpleUUID();
        List<MesProPalletDO> pallets = new ArrayList<>(palletCount);
        if (!ton) {
            for (int i = 0; i < palletCount; i++) {
                pallets.add(new MesProPalletDO()
                        .setTempCode(codeRedisDao.generate())
                        .setBusinessCode(codeRedisDao.generate())
                        .setBagCount(perPallet)
                        .setAutoGenerated(true)
                        .setItemId(item.getId())
                        .setStatus(MesProPalletStatusEnum.IN_USE.getStatus()));
            }
            palletMapper.insertBatch(pallets);
        }
        // è¢‹åºå·åŽŸå­åˆ†é…ï¼šå…ˆå–åº“å†…æœ€å¤§åºå·ä½œåŸºå‡†ï¼Œå†ç”± Redis åŽŸå­è‡ªå¢žï¼Œé¿å…å¹¶å‘ç”Ÿæˆäº§ç”Ÿé‡å¤è¢‹åºå·
        int startNo = codeRedisDao.allocateBagNoStart(totalCount, bagCodeMapper.selectMaxBagNo());
        List<MesProBagCodeDO> bagCodes = new ArrayList<>(totalCount);
        for (int i = 0; i < totalCount; i++) {
            int bagNo = startNo + i;
            MesProPalletDO pallet = ton ? null : pallets.get(i / perPallet);
            bagCodes.add(new MesProBagCodeDO()
                    .setUuid(uuid)
                    .setCode(codeRedisDao.generate())
                    .setBusinessCode(codeRedisDao.generate())
                    .setBagNo(bagNo)
                    .setItemId(item.getId())
                    .setItemCode(item.getCode())
                    .setItemName(item.getName())
                    .setPalletId(pallet == null ? null : pallet.getId())
                    .setPalletCode(pallet == null ? null : pallet.getTempCode())
                    .setStatus(MesProBagCodeStatusEnum.GENERATED.getStatus())
                    .setRemark(generateReqVO.getRemark()));
        }
        bagCodeMapper.insertBatch(bagCodes);
        MesProBagCodeGenerateSpec spec = new MesProBagCodeGenerateSpec(
                MesProBagCodeGenerateBizTypeEnum.PACKAGE, IdUtil.fastSimpleUUID(),
                item.getId(), item.getCode(), item.getName(), type,
                perPallet, palletCount, (int) totalCount, generateReqVO.getRemark());
        // 4. å°æ‰¹é‡åŒæ­¥å®Œæˆï¼Œå¤§æ‰¹é‡è½¬åŽå°ä»»åŠ¡
        if (totalCount <= generateProperties.getSyncThreshold()) {
            MesProBagCodeBatchGenerator.GenerateResult result = runSyncGenerate(spec);
        return new MesProBagCodeGenerateRespVO()
                .setUuid(uuid)
                    .setUuid(spec.uuid())
                .setType(type)
                .setPerPallet(perPallet)
                .setTotalCount(totalCount)
                .setPalletIds(pallets.stream().map(MesProPalletDO::getId).toList());
                    .setTotalCount(result.finishedCount())
                    .setPalletCount(result.palletIds().size())
                    .setPalletIds(result.palletIds())
                    .setAsync(false);
        }
        MesProBagCodeGenerateTaskDO task = generateTaskService.submit(spec, generateReqVO.getRequestId());
        return new MesProBagCodeGenerateRespVO()
                .setUuid(task.getUuid())
                .setType(task.getUnitType())
                .setPerPallet(task.getPerPallet())
                .setTotalCount(task.getTotalCount())
                .setPalletCount(task.getPalletCount())
                .setAsync(true)
                .setTaskId(task.getId());
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    public MesProBagCodeGenerateRespVO generateRawBagCodes(MesProBagCodeRawGenerateReqVO generateReqVO) {
        // åŽŸå§‹è¢‹ç ä¸ä¾èµ–è®¡é‡å•ä½ç±»åž‹ä¸Žæ‰˜ç›˜ï¼šä»…æŒ‰æ•°é‡ç”Ÿæˆï¼Œè¢‹ç å¸¦å“ç§å†—ä½™ï¼ŒåŽç»­å¯æ­£å¸¸å½•å…¥æ‰¹æ¬¡/绑定托盘
        MesMdmItemDO item = mdmItemService.validateItemExists(generateReqVO.getItemId());
        String uuid = IdUtil.fastSimpleUUID();
        // è¢‹åºå·åŽŸå­åˆ†é…ï¼šå…ˆå–åº“å†…æœ€å¤§åºå·ä½œåŸºå‡†ï¼Œå†ç”± Redis åŽŸå­è‡ªå¢žï¼Œé¿å…å¹¶å‘ç”Ÿæˆäº§ç”Ÿé‡å¤è¢‹åºå·
        int startNo = codeRedisDao.allocateBagNoStart(generateReqVO.getCount(), bagCodeMapper.selectMaxBagNo());
        List<MesProBagCodeDO> bagCodes = new ArrayList<>(generateReqVO.getCount());
        for (int i = 0; i < generateReqVO.getCount(); i++) {
            int bagNo = startNo + i;
            bagCodes.add(new MesProBagCodeDO()
                    .setUuid(uuid)
                    .setCode(codeRedisDao.generate())
                    .setBusinessCode(codeRedisDao.generate())
                    .setBagNo(bagNo)
                    .setItemId(item.getId())
                    .setItemCode(item.getCode())
                    .setItemName(item.getName())
                    .setStatus(MesProBagCodeStatusEnum.GENERATED.getStatus())
                    .setRemark(generateReqVO.getRemark()));
        }
        bagCodeMapper.insertBatch(bagCodes);
        long totalCount = generateReqVO.getCount();
        assertGenerateCountAllowed(totalCount);
        MesProBagCodeGenerateSpec spec = new MesProBagCodeGenerateSpec(
                MesProBagCodeGenerateBizTypeEnum.RAW, IdUtil.fastSimpleUUID(),
                item.getId(), item.getCode(), item.getName(), null,
                0, 0, (int) totalCount, generateReqVO.getRemark());
        if (totalCount <= generateProperties.getSyncThreshold()) {
            MesProBagCodeBatchGenerator.GenerateResult result = runSyncGenerate(spec);
        return new MesProBagCodeGenerateRespVO()
                .setUuid(uuid)
                .setTotalCount(bagCodes.size())
                .setPalletIds(List.of());
                    .setUuid(spec.uuid())
                    .setTotalCount(result.finishedCount())
                    .setPalletCount(0)
                    .setPalletIds(List.of())
                    .setAsync(false);
        }
        MesProBagCodeGenerateTaskDO task = generateTaskService.submit(spec, generateReqVO.getRequestId());
        return new MesProBagCodeGenerateRespVO()
                .setUuid(task.getUuid())
                .setTotalCount(task.getTotalCount())
                .setPalletCount(task.getPalletCount())
                .setAsync(true)
                .setTaskId(task.getId());
    }
    /**
     * åŒæ­¥ç”Ÿæˆï¼šå¤±è´¥æ—¶æ¸…理已写入的分段,避免留下不完整的数据包
     */
    private MesProBagCodeBatchGenerator.GenerateResult runSyncGenerate(MesProBagCodeGenerateSpec spec) {
        try {
            return batchGenerator.generate(spec, null, new AtomicBoolean(false));
        } catch (RuntimeException e) {
            try {
                generateCleaner.cleanup(spec.uuid());
            } catch (Exception cleanupException) {
                log.error("[runSyncGenerate][生成组 {} åŠæˆå“æ¸…理失败]", spec.uuid(), cleanupException);
            }
            throw e;
        }
    }
    /**
     * æ ¡éªŒå•次生成袋码数量不超过上限
     */
    private void assertGenerateCountAllowed(long totalCount) {
        int maxCount = generateProperties.getMaxTaskCount();
        if (totalCount > maxCount) {
            throw exception(PRO_BAG_CODE_GENERATE_COUNT_TOO_MANY, maxCount);
        }
    }
    /**
yudao-module-mes/src/test/java/cn/iocoder/yudao/module/mes/service/pro/bagcode/BagBusinessCodeTest.java
@@ -8,10 +8,13 @@
import cn.iocoder.yudao.module.mes.dal.dataobject.mdm.MesMdmItemDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.bagcode.MesProBagCodeDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.batch.MesProBatchDO;
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.pallet.MesProPalletDO;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.bagcode.MesProBagCodeGenerateTaskMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.bagcode.MesProBagCodeMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.bagcode.MesProBagCodeReplaceMapper;
import cn.iocoder.yudao.module.mes.dal.mysql.pro.batch.MesProBatchMapper;
import cn.iocoder.yudao.module.mes.dal.redis.MesCodeRedisDAO;
import cn.iocoder.yudao.module.mes.framework.bagcode.MesProBagCodeGenerateProperties;
import cn.iocoder.yudao.module.mes.framework.qrcode.MesProQrLabelBuilder;
import cn.iocoder.yudao.module.mes.framework.qrcode.QrCodeImageUtils;
import cn.iocoder.yudao.module.mes.service.mdm.MesMdmItemService;
@@ -20,6 +23,7 @@
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
@@ -35,6 +39,9 @@
    private final MesProBatchMapper batches = mock(MesProBatchMapper.class);
    private final MesProBagCodeReplaceMapper replacements = mock(MesProBagCodeReplaceMapper.class);
    private final MdmUnitMeasureApi units = mock(MdmUnitMeasureApi.class);
    private final MesProBagCodeChunkWriter writer = mock(MesProBagCodeChunkWriter.class);
    private final MesProBagCodeBatchGenerator generator = new MesProBagCodeBatchGenerator();
    private final MesProBagCodeGenerateProperties properties = new MesProBagCodeGenerateProperties();
    @BeforeEach void setup() {
        ReflectionTestUtils.setField(service, "bagCodeMapper", bags);
@@ -43,9 +50,39 @@
        ReflectionTestUtils.setField(service, "batchMapper", batches);
        ReflectionTestUtils.setField(service, "replaceMapper", replacements);
        ReflectionTestUtils.setField(service, "unitMeasureApi", units);
        ReflectionTestUtils.setField(service, "batchGenerator", generator);
        ReflectionTestUtils.setField(service, "generateProperties", properties);
        ReflectionTestUtils.setField(service, "generateCleaner", mock(MesProBagCodeGenerateCleaner.class));
        ReflectionTestUtils.setField(service, "generateTaskService", mock(MesProBagCodeGenerateTaskService.class));
        // åˆ†æ®µæ‰§è¡Œå™¨ä½¿ç”¨çœŸå®žå®žçŽ°ï¼Œä»…æŠŠäº‹åŠ¡å†™å…¥å™¨ä¸Ž Redis å–码替换为 mock
        ReflectionTestUtils.setField(generator, "bagCodeMapper", bags);
        ReflectionTestUtils.setField(generator, "taskMapper", mock(MesProBagCodeGenerateTaskMapper.class));
        ReflectionTestUtils.setField(generator, "codeRedisDao", codes);
        ReflectionTestUtils.setField(generator, "chunkWriter", writer);
        ReflectionTestUtils.setField(generator, "properties", properties);
        doAnswer(invocation -> {
            bags.insertBatch(invocation.getArgument(0));
            return null;
        }).when(writer).insertBagCodes(anyList());
        doAnswer(invocation -> {
            List<MesProPalletDO> pallets = invocation.getArgument(0);
            for (int i = 0; i < pallets.size(); i++) {
                pallets.get(i).setId((long) (i + 1));
            }
            return null;
        }).when(writer).insertPallets(anyList());
        when(items.validateItemExists(1L)).thenReturn(new MesMdmItemDO().setId(1L).setUnitMeasureId(2L));
        AtomicLong sequence = new AtomicLong(1000000000000000L);
        when(codes.generate()).thenAnswer(invocation -> String.valueOf(sequence.incrementAndGet()));
        when(codes.generateBatch(anyInt())).thenAnswer(invocation -> {
            int count = invocation.getArgument(0);
            List<String> codes = new ArrayList<>(count);
            for (int i = 0; i < count; i++) {
                codes.add(String.valueOf(sequence.incrementAndGet()));
            }
            return codes;
        });
        when(codes.allocateBagNoStart(anyInt(), any())).thenReturn(1);
    }
@@ -65,13 +102,11 @@
    }
    @Test @SuppressWarnings({"unchecked", "rawtypes"}) void automaticPalletCarriesVariety() {
        var pallets=mock(cn.iocoder.yudao.module.mes.dal.mysql.pro.pallet.MesProPalletMapper.class);
        ReflectionTestUtils.setField(service,"palletMapper",pallets);
        when(units.getUnitMeasure(2L)).thenReturn(CommonResult.success(new MdmUnitMeasureRespDTO().setType("2千克")));
        when(units.getUnitMeasure(2L)).thenReturn(CommonResult.success(new MdmUnitMeasureRespDTO().setType("48袋")));
        var req=new MesProBagCodeGenerateReqVO();req.setItemId(1L);req.setPalletCount(1);
        service.generateBagCodes(req);
        ArgumentCaptor<Collection<cn.iocoder.yudao.module.mes.dal.dataobject.pro.pallet.MesProPalletDO>> saved=ArgumentCaptor.forClass((Class)Collection.class);
        verify(pallets).insertBatch(saved.capture());assertEquals(1L,saved.getValue().iterator().next().getItemId());
        ArgumentCaptor<List<MesProPalletDO>> saved=ArgumentCaptor.forClass((Class)List.class);
        verify(writer).insertPallets(saved.capture());assertEquals(1L,saved.getValue().iterator().next().getItemId());
    }
    @Test void replacementIssuesNewPairInsteadOfReusingOldBusinessCode() {
yudao-server/src/main/resources/application.yaml
@@ -33,6 +33,24 @@
    # ä¸´æ—¶ ZIP ç›®å½•,留空则使用 ${java.io.tmpdir}/mes-qr-export
    temp-dir: ''
  # è¢‹ç æ‰¹é‡ç”Ÿæˆï¼šå°æ‰¹é‡åŒæ­¥å®Œæˆï¼Œå¤§æ‰¹é‡è½¬åŽå°ä»»åŠ¡å¹¶åˆ†æ®µè½åº“
  bag-code:
    generate:
      # åŒæ­¥/后台分界线(袋码数)。不超过则请求线程内完成
      sync-threshold: 20000
      # æ¯æ®µè¢‹ç /每批取码数量
      chunk-size: 5000
      # åŒæ—¶å…è®¸è¿è¡Œçš„后台生成任务数
      max-concurrent-tasks: 1
      # å•次生成袋码上限
      max-task-count: 1000000
      # å•个后台任务最长运行时间(分钟),超时自动终止并清理
      max-runtime-minutes: 30
      # å‰ç«¯è½®è¯¢å»ºè®®é—´éš”(秒)
      poll-interval-seconds: 2
      # åŠæˆå“æ¸…理每轮删除行数
      cleanup-batch-size: 5000
spring:
  application:
    name: yudao-server