2个文件已添加
9个文件已修改
1024 ■■■■■ 已修改文件
battery-business/src/main/java/com/battery/business/mapper/TDeviceIngestRecordMapper.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/service/impl/PushIngestAdapter.java 294 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/service/impl/RoutingTestDataObservationAdapter.java 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/service/impl/TestDataReceptionServiceImpl.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/resources/mapper/battery/TDeviceIngestRecordMapper.xml 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/test/java/com/battery/business/service/impl/FakeIngestLanding.java 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/test/java/com/battery/business/service/impl/PushIngestAdapterContractTest.java 147 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-api/src/main/java/com/battery/device/api/TestDataObservationAdapter.java 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/battery/business/runtime/PushIngestLandingMigrationContractTest.java 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/j22-introspect-files.ps1 259 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/j22-introspect.ps1 226 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/mapper/TDeviceIngestRecordMapper.java
@@ -41,6 +41,16 @@
            @Param("devtype") int devtype, @Param("devid") int devid, @Param("subdevid") int subdevid,
            @Param("chlid") int chlid);
    /**
     * 某个来源库(厂商侧一次测试)在该通道上的全部采样行,按 (设备时间, 协议序号) 升序。
     *
     * <p>推式来源没有设备侧工步层,工步与循环由这些采样行按「来源库 + 循环号 + 工步号」分段推导
     * (ADR-0067);读取范围限于一个来源库,也就是一次厂商测试,不随落地表无限增长。
     */
    List<TDeviceIngestRecord> selectSamplesOfSource(@Param("adapterCode") String adapterCode,
            @Param("devtype") int devtype, @Param("devid") int devid, @Param("subdevid") int subdevid,
            @Param("chlid") int chlid, @Param("sourceRef") String sourceRef);
    /** 严格晚于 (设备时间, 协议序号) 的采样行,按二元组升序;水位为空表示不取数据。 */
    List<TDeviceIngestRecord> selectSamplesSince(@Param("adapterCode") String adapterCode,
            @Param("devtype") int devtype, @Param("devid") int devid, @Param("subdevid") int subdevid,
battery-business/src/main/java/com/battery/business/service/impl/PushIngestAdapter.java
@@ -1,11 +1,13 @@
package com.battery.business.service.impl;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@@ -37,13 +39,15 @@
 * 不开入站端口、不在网络上传递厂商库凭据;解释(身份映射、时间换算、幂等键取值)全部在后端完成,
 * 所以这里只做投影:落地行的字段由接收端点解析好,本类不出现任何厂商列名。
 *
 * <p>三条本形态特有的事实:
 * <p>四条本形态特有的事实:
 * <ul>
 *   <li><b>来源是否在线由心跳判定</b>:落地表在本地随时可读,因此"能否读到表"不能表示来源可达。
 *       取数程序每轮推送一次心跳,心跳缺失即认为来源不可达——发现路径明确失败,
 *       已登记设备随之离线、旧快照被撤下(既有路径),页面不会显示伪造的实时值。</li>
 *   <li><b>最近数据时间只看采样行</b>:没有新采样行就是没有,不按拓扑上报或心跳时间代替(ADR-0063)。</li>
 *   <li><b>通道工作状态不可观测</b>:落地表里没有设备侧状态,报"无法观测"而不由数据行推断。</li>
 *   <li><b>工步与循环从采样段推导</b>:这类来源的厂商库里没有工步层也没有循环汇总表,工步号与循环号
 *       只在采样行上,所以按「来源库 + 循环号 + 工步号」分段推导(ADR-0067),推导是无状态重算。</li>
 * </ul>
 */
public final class PushIngestAdapter implements DeviceAdapter, TestDataObservationAdapter
@@ -68,6 +72,8 @@
     * 只取水位之后的新行,取数更勤不会产生重复记录。厂商写库节奏仍是 60 秒,值不会来得更快。
     */
    private static final long PERSISTENCE_INTERVAL_MS = 5000L;
    /** 厂商写库节奏:工步与循环的重算对齐它,来源的汇总产出不可能比这更快。 */
    private static final long AGGREGATE_INTERVAL_MS = 60000L;
    private static final int SAMPLE_BATCH_LIMIT = 500;
    /** 心跳容忍窗口的下限:写库与推送都是 60 秒一轮,窗口至少覆盖三轮。 */
    private static final Duration MINIMUM_STALE_AFTER = Duration.ofSeconds(90);
@@ -254,20 +260,37 @@
            String unit) { }
    /**
     * 22 所没有独立的工步层:工步与循环号只在采样行的 {@code Step_Index} / {@code Cycle_Index} 里,
     * 从采样推导工步是另一项决策(与"工步来自设备工步层"的既有口径冲突),不在本单范围,因此返回空。
     * 交出该通道已结束的工步。来源没有设备侧工步层,所以按本来源自己的采样行分段推导(ADR-0067):
     * 按「来源库 + 循环号 + 工步号」分段,只有最新一段视为进行中、不交出,其余各段各一条工步记录。
     */
    @Override
    public List<StepRecord> stepRecords(ChannelIdentity channel)
    {
        return List.of();
        Derivation derivation = derive(channel);
        List<StepRecord> records = new ArrayList<>();
        for (StepGroup group : derivation.closedSteps()) records.add(stepRecord(derivation.sourceRef(), group));
        return List.copyOf(records);
    }
    /** 同上:循环统计不从采样推导,本单不交出循环记录。 */
    /**
     * 交出该通道已结束的循环:来源没有循环汇总行,循环由已结束的工步按方向求和得出(ADR-0066 同一配方)。
     * 最新一段所属的循环仍在进行,整体不交出——半个循环不是事实。
     */
    @Override
    public List<CycleRecord> cycleRecords(ChannelIdentity channel)
    {
        return List.of();
        Derivation derivation = derive(channel);
        Map<Integer, List<StepGroup>> byCycle = new LinkedHashMap<>();
        for (StepGroup group : derivation.closedSteps())
        {
            // 仍在进行的那一段所属的循环整体排除:它自己的工步行还没写完。
            if (group.key().cycleNo() == derivation.openCycleNo()) continue;
            byCycle.computeIfAbsent(group.key().cycleNo(), ignored -> new ArrayList<>()).add(group);
        }
        List<CycleRecord> records = new ArrayList<>();
        for (Map.Entry<Integer, List<StepGroup>> entry : byCycle.entrySet())
            records.add(cycleRecord(derivation.sourceRef(), entry.getKey(), entry.getValue()));
        return List.copyOf(records);
    }
    /** 返回推式来源固定的持久化槽位间隔;采样行按来源自己的落库节奏到达。 */
@@ -275,6 +298,16 @@
    public long persistenceIntervalMillis(ChannelIdentity channel)
    {
        return PERSISTENCE_INTERVAL_MS;
    }
    /**
     * 工步与循环的重算跟着来源的写库节奏,不跟实时行的展示节奏:来源最快每 60 秒才产出一行新采样,
     * 而工步要等下一段出现才算结束,因此重算更勤只会空跑落地表(ADR-0067)。
     */
    @Override
    public long aggregateIntervalMillis(ChannelIdentity channel)
    {
        return AGGREGATE_INTERVAL_MS;
    }
    /** 水位是落地表里该通道已存在的最新一行:设备绝对时间与协议序号。 */
@@ -350,21 +383,266 @@
    private CabinetRealtime cabinetRealtime(TDeviceIngestRecord row)
    {
        // 充放电容量按电流方向取对应的一组:与直连来源同一口径(22 所的电流符号约定待现场对着上位机确认)。
        // 充放电容量按电流方向取对应的一组:与直连来源同一口径。电流符号的含义已用该来源的真实推送
        // 数据核对(恒正即充电、恒负即放电,ADR-0067),不再是待确认的约定。
        BigDecimal current = row.getCurrentA() == null ? BigDecimal.ZERO : row.getCurrentA();
        boolean charging = current.signum() >= 0;
        BigDecimal capacity = charging ? row.getChargeCapacityAh() : row.getDischargeCapacityAh();
        BigDecimal energy = charging ? row.getChargeEnergyWh() : row.getDischargeEnergyWh();
        // 实时行同样带上方向(类型与名称):甲方那一列「状态」显示的就是它,实时页的"工步名称"要用。
        StepDirection direction = directionOf(row);
        return new CabinetRealtime(row.getRecordSequence() == null ? 0L : row.getRecordSequence(),
                row.getDeviceTimeUtc() == null ? null : row.getDeviceTimeUtc().toInstant(),
                current.doubleValue(), decimal(row.getVoltageV()), decimal(capacity), decimal(energy),
                row.getCycleNo() == null ? 0 : row.getCycleNo(), row.getStepNo() == null ? 0 : row.getStepNo(),
                null, null, row.getReceivedAtUtc() == null ? null : row.getReceivedAtUtc().toInstant(),
                // 注意参数次序:CabinetRealtime 是 stepName 在前、stepType 在后。
                direction.displayName(), direction.label(),
                row.getReceivedAtUtc() == null ? null : row.getReceivedAtUtc().toInstant(),
                row.getDeviceTimeRaw(), row.getTimezoneId(),
                row.getRelativeTimeSeconds() == null ? null : row.getRelativeTimeSeconds().longValue(),
                "push:" + row.getRecordKey());
    }
    /**
     * 单行的方向:这一类来源没有设备侧工步类型,方向只能由电流符号判定(ADR-0067),
     * 恒正充电、恒负放电、恒为零搁置,电流缺失保持未知。
     */
    private static StepDirection directionOf(TDeviceIngestRecord row)
    {
        BigDecimal current = row.getCurrentA();
        if (current == null) return StepDirection.UNKNOWN;
        if (current.signum() > 0) return StepDirection.CHARGE;
        if (current.signum() < 0) return StepDirection.DISCHARGE;
        return StepDirection.REST;
    }
    // ------------------------------------------------------------------ 工步与循环的推导(ADR-0067)
    /** 分段推导的结果:来源库、已结束的各段,以及仍在进行的那一段所属的循环号。 */
    private record Derivation(String sourceRef, List<StepGroup> closedSteps, int openCycleNo) { }
    /** 一段采样行:同一个来源库里同一个「循环号 + 工步号」的全部行,按 (设备时间, 协议序号) 升序。 */
    private record StepGroup(StepKey key, List<TDeviceIngestRecord> rows)
    {
        TDeviceIngestRecord first() { return rows.get(0); }
        TDeviceIngestRecord last() { return rows.get(rows.size() - 1); }
    }
    private record StepKey(int cycleNo, int stepNo) { }
    /**
     * 该段本身的方向。来源没有设备侧工步类型列,方向由该段自己的电流符号判定——依据是用该来源真实推送的
     * 采样数据核对过的列语义(588 段零反例,见 ADR-0067),不是按数值大小猜方向。
     *
     * <p>段内电流正负混用、电流缺失,或恒为零却有容量增长时,方向不唯一:保持未知,明细照原样保留。
     */
    private enum StepDirection
    {
        CHARGE("charge", "充电"), DISCHARGE("discharge", "放电"), REST("rest", "搁置"), UNKNOWN(null, null);
        private final String label;
        private final String displayName;
        StepDirection(String label, String displayName) { this.label = label; this.displayName = displayName; }
        /** 写进工步记录的类型:来源没给类型时交出的是判定出的方向名称,未知方向留空。 */
        String label() { return label; }
        /**
         * 交出去的工步名称。这类来源的工步方案里只有「步_N」这种标号,没有描述性名称
         * (2026-10-09 现场核对 .pcj:<Step StepLabel="步_1"> + CtrlTypeName=Rest/Current);
         * 而甲方软件那一列「状态」显示的就是充电/放电/搁置——所以用同一套词当工步名称,
         * 值同样来自电流符号判定,**不是设备给的名称**(现场清单与 ADR-0067 都记了这一条)。
         */
        String displayName() { return displayName; }
        /** 该工步自身累计的单项值。搁置段实测两向都不增长,所以它的累计是 0 而不是缺失。 */
        BigDecimal own(BigDecimal charge, BigDecimal discharge)
        {
            return switch (this)
            {
                case CHARGE -> charge;
                case DISCHARGE -> discharge;
                case REST -> BigDecimal.ZERO;
                case UNKNOWN -> null;
            };
        }
    }
    /**
     * 从该通道的采样行推导「已结束的段」。只看该通道**最新的来源库**:来源库就是厂商侧一次测试
     * (22 所每次测试新建一个库),读取量因此限于一次厂商测试,分段也不会跨测试。
     *
     * <p>段是否结束只按一条依据判断:设备是否已经写下**后续工步**的行(与 ADR-0066 同一判据)。
     * 所以只有最新一段视为进行中;它所属的循环整体不交——半个循环不是事实。
     */
    private Derivation derive(ChannelIdentity channel)
    {
        TDeviceIngestRecord latest = latestSample(channel);
        if (latest == null || latest.getSourceRef() == null) return new Derivation(null, List.of(), Integer.MIN_VALUE);
        List<TDeviceIngestRecord> rows = records.selectSamplesOfSource(config.adapterCode(), latest.getDevtype(),
                latest.getDevid(), latest.getSubdevid(), latest.getChlid(), latest.getSourceRef());
        Map<StepKey, List<TDeviceIngestRecord>> grouped = new LinkedHashMap<>();
        for (TDeviceIngestRecord row : rows == null ? List.<TDeviceIngestRecord>of() : rows)
        {
            // 分段依据是设备给出的循环号与工步号;缺任何一个都无法归属到某一段,跳过并留痕。
            if (row.getCycleNo() == null || row.getStepNo() == null)
            {
                log.warn("落地行缺少循环号或工步号,不参与工步推导 ingestId={}", row.getIngestId());
                continue;
            }
            grouped.computeIfAbsent(new StepKey(row.getCycleNo(), row.getStepNo()), ignored -> new ArrayList<>())
                    .add(row);
        }
        List<StepGroup> closed = new ArrayList<>();
        int openCycleNo = Integer.MIN_VALUE;
        int index = 0;
        for (Map.Entry<StepKey, List<TDeviceIngestRecord>> entry : grouped.entrySet())
        {
            if (++index == grouped.size())
            {
                openCycleNo = entry.getKey().cycleNo();
                break;
            }
            closed.add(new StepGroup(entry.getKey(), entry.getValue()));
        }
        return new Derivation(latest.getSourceRef(), List.copyOf(closed), openCycleNo);
    }
    /**
     * 一条工步记录:容量与能量是**工步累计**(实测段内从未下降,末行即最大值),所以取该段末行的值;
     * 工步时间取末行的工步内相对时间(秒)。设备没给的项不写零、不伪造中值电压。
     */
    private StepRecord stepRecord(String sourceRef, StepGroup group)
    {
        TDeviceIngestRecord first = group.first();
        TDeviceIngestRecord last = group.last();
        StepDirection direction = direction(group);
        Map<String, BigDecimal> measurements = new LinkedHashMap<>();
        putIfPresent(measurements, "stepDuration", last.getRelativeTimeSeconds());
        putIfPresent(measurements, "startVoltage", first.getVoltageV());
        // 四组容量能量原样保留设备事实:方向未判定的段也留着明细供核对。
        putIfPresent(measurements, "chargeCapacity", last.getChargeCapacityAh());
        putIfPresent(measurements, "dischargeCapacity", last.getDischargeCapacityAh());
        putIfPresent(measurements, "chargeEnergy", last.getChargeEnergyWh());
        putIfPresent(measurements, "dischargeEnergy", last.getDischargeEnergyWh());
        BigDecimal capacity = direction.own(last.getChargeCapacityAh(), last.getDischargeCapacityAh());
        BigDecimal energy = direction.own(last.getChargeEnergyWh(), last.getDischargeEnergyWh());
        // 工步名称用判定出的方向(充电/放电/搁置):该来源的方案里只有「步_N」标号,没有描述性名称,
        // 而甲方软件自己那一列「状态」显示的就是这三个词。它和方向同源,不是设备给的名称。
        return new StepRecord(sourceRecordId(sourceRef, "step", group.key().cycleNo() + ":" + group.key().stepNo()),
                group.key().cycleNo(), group.key().stepNo(), direction.displayName(), direction.label(), deviceTime(first),
                deviceTime(last), decimal(last.getVoltageV()), decimal(last.getCurrentA()),
                capacity == null ? null : capacity.doubleValue(), energy == null ? null : energy.doubleValue(),
                last.getDeviceTimeRaw(), last.getTimezoneId(), receivedAt(last), measurements);
    }
    /**
     * 一条循环记录:该循环各已结束工步取自己的累计值一次,按方向归入充电或放电一侧并**求和**
     * (同一循环可能含多个同方向工步)。效率 = 放电容量 ÷ 充电容量,没有可用充电容量时留空,不写零。
     * 搁置段两向都不增长、未知方向不按哪一列有值去猜,两者都不进任何一侧。
     */
    private CycleRecord cycleRecord(String sourceRef, int cycleNo, List<StepGroup> groups)
    {
        BigDecimal chargeCapacity = BigDecimal.ZERO, dischargeCapacity = BigDecimal.ZERO;
        BigDecimal chargeEnergy = BigDecimal.ZERO, dischargeEnergy = BigDecimal.ZERO;
        for (StepGroup group : groups)
        {
            TDeviceIngestRecord last = group.last();
            switch (direction(group))
            {
                case CHARGE ->
                {
                    chargeCapacity = chargeCapacity.add(orZero(last.getChargeCapacityAh()));
                    chargeEnergy = chargeEnergy.add(orZero(last.getChargeEnergyWh()));
                }
                case DISCHARGE ->
                {
                    dischargeCapacity = dischargeCapacity.add(orZero(last.getDischargeCapacityAh()));
                    dischargeEnergy = dischargeEnergy.add(orZero(last.getDischargeEnergyWh()));
                }
                default -> { }
            }
        }
        TDeviceIngestRecord first = groups.get(0).first();
        TDeviceIngestRecord last = groups.get(groups.size() - 1).last();
        return new CycleRecord(sourceRecordId(sourceRef, "cycle", String.valueOf(cycleNo)), cycleNo,
                deviceTime(first), deviceTime(last), chargeCapacity.doubleValue(),
                dischargeCapacity.doubleValue(), chargeEnergy.doubleValue(), dischargeEnergy.doubleValue(),
                chargeCapacity.signum() == 0 ? null : dischargeCapacity.multiply(BigDecimal.valueOf(100))
                        .divide(chargeCapacity, 4, RoundingMode.HALF_UP).doubleValue(),
                last.getDeviceTimeRaw(), last.getTimezoneId(), receivedAt(last), CycleRecord.SOURCE_AGGREGATED);
    }
    /** 段内电流符号一致时给出该方向;正负混用或电流缺失保持未知。 */
    private static StepDirection direction(StepGroup group)
    {
        BigDecimal maximumCharge = group.first().getChargeCapacityAh();
        BigDecimal maximumDischarge = group.first().getDischargeCapacityAh();
        boolean positive = false, negative = false;
        for (TDeviceIngestRecord row : group.rows())
        {
            BigDecimal current = row.getCurrentA();
            // 电流缺失的段判不出方向:不拿同段其它行的符号代替。
            if (current == null) return StepDirection.UNKNOWN;
            positive |= current.signum() > 0;
            negative |= current.signum() < 0;
            maximumCharge = maximum(maximumCharge, row.getChargeCapacityAh());
            maximumDischarge = maximum(maximumDischarge, row.getDischargeCapacityAh());
        }
        if (positive && negative) return StepDirection.UNKNOWN;
        if (positive) return StepDirection.CHARGE;
        if (negative) return StepDirection.DISCHARGE;
        // 恒为零:搁置。但恒零却出现容量增长时方向未能判定——不按哪一列在涨去猜。
        boolean accumulated = raised(group.first().getChargeCapacityAh(), maximumCharge)
                || raised(group.first().getDischargeCapacityAh(), maximumDischarge);
        return accumulated ? StepDirection.UNKNOWN : StepDirection.REST;
    }
    /** 来源行身份:来源库 + 工步或循环标识。同一次测试重算多少次都得到同一个身份,重复交出因此幂等。 */
    private static String sourceRecordId(String sourceRef, String kind, String suffix)
    {
        return "push:" + sourceRef + ":" + kind + ":" + suffix;
    }
    /** 只放设备给了的项:缺列就是没有这一项,不写零代替缺失。 */
    private static void putIfPresent(Map<String, BigDecimal> target, String key, BigDecimal value)
    {
        if (value != null) target.put(key, value);
    }
    /** 求和时把缺列当没有:缺少的项不参与求和,也不写零占位。 */
    private static BigDecimal orZero(BigDecimal value)
    {
        return value == null ? BigDecimal.ZERO : value;
    }
    private static BigDecimal maximum(BigDecimal current, BigDecimal candidate)
    {
        if (candidate == null) return current;
        return current == null ? candidate : current.max(candidate);
    }
    private static boolean raised(BigDecimal first, BigDecimal maximum)
    {
        return first != null && maximum != null && maximum.compareTo(first) > 0;
    }
    private static Instant deviceTime(TDeviceIngestRecord row)
    {
        return row.getDeviceTimeUtc() == null ? null : row.getDeviceTimeUtc().toInstant();
    }
    /**
     * 接收时间取**我们收到这一行**的时刻,与推式来源的实时路径同一口径,也是接收边界判定的依据;
     * 直连来源没有这个独立取值,它沿用设备记录时刻(见 KNOWN-GAPS)。
     */
    private static Instant receivedAt(TDeviceIngestRecord row)
    {
        return row.getReceivedAtUtc() == null ? null : row.getReceivedAtUtc().toInstant();
    }
    private RealtimeSnapshot snapshot(ChannelIdentity channel, CabinetRealtime latest)
    {
        // 读落地表路线下通道工作状态不可观测(ADR-0063):不按数据行推断,也不沿用上一次状态。
battery-business/src/main/java/com/battery/business/service/impl/RoutingTestDataObservationAdapter.java
@@ -150,6 +150,22 @@
    }
    /**
     * 按通道所属来源实例取工步与循环的读取间隔。
     *
     * <p>必须在这里转交:不转交就会落到本类的默认实现,从而拿实时持久化间隔去重算汇总,
     * 来源自己声明的汇总节奏就被忽略了。
     *
     * @param channel 检测柜通道身份
     * @return 汇总记录的读取间隔,单位毫秒
     * @throws AdapterRegistryException 该通道所属的实例未启用,或实例未提供测试数据观测契约
     */
    @Override
    public long aggregateIntervalMillis(ChannelIdentity channel)
    {
        return registry.executeTestData(channel.adapterCode(), adapter -> adapter.aggregateIntervalMillis(channel));
    }
    /**
     * 按通道所属来源实例取设备计划方案,只用于实时展示。
     *
     * @param channel 检测柜通道身份
battery-business/src/main/java/com/battery/business/service/impl/TestDataReceptionServiceImpl.java
@@ -340,8 +340,9 @@
        if (adapter == null) return; TTestChannelBinding binding = battery.getBinding(); if (binding == null || binding.getDevid() == null || binding.getChlid() == null) return;
        String key = test.getTestId() + ":" + battery.getTestBatteryId();
        // 接收调度每秒跑一次,而来源按自己的写库节奏产出工步:不按来源节奏限流会每秒重查厂商库。
        // 汇总用来源声明的汇总间隔:它可能比实时行的间隔长(推式来源的落地表在本地)。
        Instant now = clock.instant(); Instant previous = lastAggregateAt.get(key);
        if (previous != null && now.isBefore(previous.plusMillis(persistenceInterval(binding)))) return;
        if (previous != null && now.isBefore(previous.plusMillis(aggregateInterval(binding)))) return;
        lastAggregateAt.put(key, now);
        // STEP 2: 读取来源的工步与循环汇总;来源不可达时本轮放弃,下一轮重试。
        ChannelIdentity channel = new ChannelIdentity(binding.getAdapterCode(), binding.getDevtype(), binding.getDevid(), binding.getSubdevid(), binding.getChlid());
@@ -410,6 +411,7 @@
        return String.join(":", parts);
    }
    private long persistenceInterval(TTestChannelBinding b) { try { return Math.max(100L, adapter.persistenceIntervalMillis(channelIdentity(b))); } catch (RuntimeException ex) { return 60000L; } }
    private long aggregateInterval(TTestChannelBinding b) { try { return Math.max(100L, adapter.aggregateIntervalMillis(channelIdentity(b))); } catch (RuntimeException ex) { return 60000L; } }
    private static String sourceIdentity(TTestChannelBinding b) { return String.join(":", String.valueOf(b.getAdapterCode()), String.valueOf(b.getDevtype()), String.valueOf(b.getDevid()), String.valueOf(b.getSubdevid()), String.valueOf(b.getChlid())); }
    private static String timezone(TTestChannelBinding b) { return b.getAdapterTimezoneId() == null || b.getAdapterTimezoneId().isBlank() ? "Asia/Hong_Kong" : b.getAdapterTimezoneId(); }
    private static String blankToNull(String value) { return value == null || value.isBlank() ? null : value; }
battery-business/src/main/resources/mapper/battery/TDeviceIngestRecordMapper.xml
@@ -129,6 +129,15 @@
        limit 1
    </select>
    <!-- 一个来源库(厂商侧一次测试)在该通道上的全部采样行,供分段推导工步与循环(ADR-0067)。 -->
    <select id="selectSamplesOfSource" resultMap="TDeviceIngestRecordResultMap">
        select <include refid="ingestColumns"/> from t_device_ingest_record
        where adapter_code = #{adapterCode} and payload_kind = 'SAMPLE'
          and devtype = #{devtype} and devid = #{devid} and subdevid = #{subdevid} and chlid = #{chlid}
          and source_ref = #{sourceRef}
        order by device_time_utc, record_sequence
    </select>
    <!-- 只取严格晚于水位的行;同秒多行按协议序号各自成立,不按秒合并。 -->
    <select id="selectSamplesSince" resultMap="TDeviceIngestRecordResultMap">
        select <include refid="ingestColumns"/> from t_device_ingest_record
battery-business/src/test/java/com/battery/business/service/impl/FakeIngestLanding.java
@@ -97,6 +97,17 @@
    }
    @Override
    public List<TDeviceIngestRecord> selectSamplesOfSource(String adapterCode, int devtype, int devid, int subdevid,
            int chlid, String sourceRef)
    {
        return samples(adapterCode, devtype, devid, subdevid, chlid).stream()
                .filter(row -> sourceRef.equals(row.getSourceRef()))
                .sorted(java.util.Comparator.comparing(TDeviceIngestRecord::getDeviceTimeUtc)
                        .thenComparing(TDeviceIngestRecord::getRecordSequence))
                .toList();
    }
    @Override
    public List<TDeviceIngestRecord> selectSamplesSince(String adapterCode, int devtype, int devid, int subdevid,
            int chlid, Date watermarkTime, long watermarkSequence, int limit)
    {
battery-business/src/test/java/com/battery/business/service/impl/PushIngestAdapterContractTest.java
@@ -16,7 +16,9 @@
import org.junit.jupiter.api.Test;
import com.battery.device.api.DeviceAdapterException;
import com.battery.device.api.TestDataObservationAdapter.CabinetRealtime;
import com.battery.device.api.TestDataObservationAdapter.CycleRecord;
import com.battery.device.api.TestDataObservationAdapter.SampleWatermark;
import com.battery.device.api.TestDataObservationAdapter.StepRecord;
import com.battery.device.api.model.AdapterCapabilities;
import com.battery.device.api.model.ChannelIdentity;
import com.battery.device.api.model.DetectedChannel;
@@ -45,6 +47,37 @@
               "Cycle_Index":1,"Voltage":1.2701,"eCurrent":0.5,"ChargeCapacity":0.121,"ChargeEnergy":0.31},
              {"record_time":"134349698000000000","Data_Point":422,"Step_Time":600.0,"Step_Index":5,
               "Cycle_Index":1,"Voltage":1.2702,"eCurrent":-0.4,"DischargeCapacity":0.05,"DischargeEnergy":0.1}]}
            """;
    /**
     * 两个循环的采样行:循环 1 是充电、搁置、放电三段(都结束了),循环 2 的第一段还在进行。
     * 容量列是工步累计:充电段从 0.10 涨到 0.12,放电段从 0.05 涨到 0.09。
     */
    private static final String TWO_CYCLES = """
            {"kind":"SAMPLE","database":"DB_20260927_155209","table":"Ws26_IV_0","channel":26,"rows":[
              {"record_time":"134349692000000000","Data_Point":420,"Step_Time":540.0,"Step_Index":1,"Cycle_Index":1,
               "Voltage":1.20,"eCurrent":1.0,"ChargeCapacity":0.10,"ChargeEnergy":0.30},
              {"record_time":"134349698000000000","Data_Point":421,"Step_Time":600.0,"Step_Index":1,"Cycle_Index":1,
               "Voltage":1.25,"eCurrent":1.0,"ChargeCapacity":0.12,"ChargeEnergy":0.36},
              {"record_time":"134349704000000000","Data_Point":422,"Step_Time":1200.0,"Step_Index":2,"Cycle_Index":1,
               "Voltage":1.25,"eCurrent":0.0,"ChargeCapacity":0.12,"DischargeCapacity":0.0},
              {"record_time":"134349764000000000","Data_Point":423,"Step_Time":1800.0,"Step_Index":3,"Cycle_Index":1,
               "Voltage":1.24,"eCurrent":-1.0,"ChargeCapacity":0.12,"DischargeCapacity":0.05,"DischargeEnergy":0.15},
              {"record_time":"134349824000000000","Data_Point":424,"Step_Time":3600.0,"Step_Index":3,"Cycle_Index":1,
               "Voltage":1.20,"eCurrent":-1.0,"ChargeCapacity":0.12,"DischargeCapacity":0.09,"DischargeEnergy":0.27},
              {"record_time":"134349830000000000","Data_Point":425,"Step_Time":60.0,"Step_Index":1,"Cycle_Index":2,
               "Voltage":1.18,"eCurrent":2.0,"ChargeCapacity":0.01,"ChargeEnergy":0.02}]}
            """;
    /** 第一段的电流正负混用:方向判不出来,明细照留但不进循环的任何一侧。 */
    private static final String MIXED_SIGN = """
            {"kind":"SAMPLE","database":"DB_20260927_155209","table":"Ws26_IV_0","channel":26,"rows":[
              {"record_time":"134349692000000000","Data_Point":420,"Step_Index":1,"Cycle_Index":1,
               "Voltage":1.20,"eCurrent":1.0,"ChargeCapacity":0.10},
              {"record_time":"134349698000000000","Data_Point":421,"Step_Index":1,"Cycle_Index":1,
               "Voltage":1.20,"eCurrent":-1.0,"ChargeCapacity":0.10,"DischargeCapacity":0.02},
              {"record_time":"134349704000000000","Data_Point":422,"Step_Index":2,"Cycle_Index":1,
               "Voltage":1.25,"eCurrent":1.0,"ChargeCapacity":0.12,"ChargeEnergy":0.36},
              {"record_time":"134349830000000000","Data_Point":425,"Step_Index":1,"Cycle_Index":2,
               "Voltage":1.18,"eCurrent":2.0,"ChargeCapacity":0.01}]}
            """;
    private final FakeIngestLanding landing = new FakeIngestLanding();
@@ -124,6 +157,10 @@
        assertEquals(5, latest.stepNo());
        // 放电容量的取法按电流方向,与直连来源同一口径;来源行身份供幂等与冲突判定。
        assertEquals(0.05, latest.capacityAh(), 1e-9);
        // 实时行也带方向:类型与名称都按电流符号判定(该行电流为负 → 放电),
        // 实时页的"工步名称"列用的就是它(用户对照甲方那一列「状态」)。
        assertEquals("discharge", latest.stepType());
        assertEquals("放电", latest.stepName());
        assertEquals("push:DB_20260927_155209/Ws26_IV_0:422", latest.sourceRecordId());
        // 实时快照:通道工作状态在读落地表路线下不可观测(ADR-0063)。
@@ -172,21 +209,125 @@
    /** 未声明辅助测量列就不交出测量值:确认之前不按列名推断语义,也不写零占位。 */
    @Test
    void collectorAndStepCycleObservationsStayEmptyWithoutDeclaredAuxiliaryMetrics()
    void collectorObservationsStayEmptyWithoutDeclaredAuxiliaryMetrics()
    {
        ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26}]}");
        ingest(BATCH);
        PushIngestAdapter adapter = adapter(NOW);
        assertNull(adapter.latestCollector(ADAPTER, "collector-1"));
        assertTrue(adapter.stepRecords(channel()).isEmpty());
        assertTrue(adapter.cycleRecords(channel()).isEmpty());
        // 落地表在本地,取数不产生厂商负载,所以按展示节奏取数;厂商写库节奏仍是 60 秒。
        assertEquals(5000L, adapter.persistenceIntervalMillis(channel()));
        // 工步与循环的重算跟着来源的写库节奏走,不跟实时行的展示节奏:源头最快 60 秒才产出一行。
        assertEquals(60000L, adapter.aggregateIntervalMillis(channel()));
        AdapterCapabilities capabilities = adapter.capabilities();
        assertTrue(capabilities.discovery() && capabilities.realtimeObservation() && !capabilities.control());
    }
    /**
     * 来源没有工步层,工步与循环由采样行分段推导(ADR-0067):已结束的段各交出一条工步记录,
     * 取值来自该段末行,方向由该段自己的电流符号判定;最新一段仍在进行,不交出去。
     */
    @Test
    void closedSampleSegmentsBecomeStepRecordsWhileTheNewestSegmentStaysUnpublished()
    {
        ingest(BATCH);
        PushIngestAdapter adapter = adapter(NOW);
        List<StepRecord> steps = adapter.stepRecords(channel());
        // 三行落地行分成两段:(循环 1, 工步 4) 已结束,(循环 1, 工步 5) 还在进行。
        assertEquals(1, steps.size());
        StepRecord step = steps.get(0);
        assertEquals(1, step.cycleNo());
        assertEquals(4, step.stepNo());
        // 方向取自该段电流符号:段内两行恒正即充电。
        assertEquals("charge", step.stepType());
        // 工步名称用同一套词(甲方软件那一列「状态」显示的就是充电/放电/搁置):该来源的方案里
        // 只有「步_N」标号,这个名称是判定出的方向,不是设备给的名称。
        assertEquals("充电", step.stepName());
        // 容量与能量是工步累计,取该段末行的值;工步时间是工步内相对时间(秒)。
        assertEquals(0.121, step.capacityAh(), 1e-9);
        assertEquals(0.31, step.energyWh(), 1e-9);
        assertEquals(600.0, step.measurements().get("stepDuration").doubleValue(), 1e-9);
        assertEquals(1.26778, step.measurements().get("startVoltage").doubleValue(), 1e-9);
        // 设备没给的列不写零占位:该段只有充电侧。
        assertFalse(step.measurements().containsKey("dischargeCapacity"));
        assertEquals(1.2701, step.endVoltageV(), 1e-9);
        assertEquals(0.5, step.endCurrentA(), 1e-9);
        assertEquals(Instant.parse("2026-09-27T07:53:20Z"), step.startedAt());
        assertEquals(Instant.parse("2026-09-27T08:03:20Z"), step.endedAt());
        // 接收时间取我们收到该段末行的时刻(与推式实时路径同一口径),设备原始时间照留。
        assertEquals(NOW, step.serverReceivedAt());
        assertEquals("134349698000000000", step.deviceTimeRaw());
        // 来源行身份含来源库与段标识:同一次测试重算多少次都得到同一个身份。
        assertEquals("push:DB_20260927_155209/Ws26_IV_0:step:1:4", step.sourceRecordId());
        // 进行中的那一段所属的循环整体不交:半个循环不是事实。
        assertTrue(adapter.cycleRecords(channel()).isEmpty());
    }
    /**
     * 循环由已结束的工步按方向求和(搁置段两向都不增长,贡献 0),效率是放电容量与充电容量之比;
     * 推导值带来源类别,界面与导出据此标注它是聚合出来的。
     */
    @Test
    void cyclesAggregateClosedStepsByDirectionAndSkipTheRunningCycle()
    {
        ingest(TWO_CYCLES);
        PushIngestAdapter adapter = adapter(NOW);
        List<StepRecord> steps = adapter.stepRecords(channel());
        List<CycleRecord> cycles = adapter.cycleRecords(channel());
        // 循环 1 的三段都已结束(后面已经出现循环 2 的第一段);循环 2 的第一段仍在进行。
        assertEquals(List.of("1:1:charge", "1:2:rest", "1:3:discharge"), steps.stream()
                .map(step -> step.cycleNo() + ":" + step.stepNo() + ":" + step.stepType()).toList());
        // 搁置段自身的累计是 0(该段什么都没充放),不是缺失。
        assertEquals(0.0, steps.get(1).capacityAh(), 1e-9);
        // 搁置段的名称同样是判定出来的。
        assertEquals("搁置", steps.get(1).stepName());
        assertEquals(0.0, steps.get(1).energyWh(), 1e-9);
        assertEquals(List.of(1), cycles.stream().map(CycleRecord::cycleNo).toList());
        CycleRecord cycle = cycles.get(0);
        assertEquals(0.12, cycle.chargeCapacityAh(), 1e-9);
        assertEquals(0.09, cycle.dischargeCapacityAh(), 1e-9);
        assertEquals(0.36, cycle.chargeEnergyWh(), 1e-9);
        assertEquals(0.27, cycle.dischargeEnergyWh(), 1e-9);
        // 效率是百分数:0.09 / 0.12。
        assertEquals(75.0, cycle.efficiency(), 1e-9);
        assertEquals(CycleRecord.SOURCE_AGGREGATED, cycle.sourceKind());
        assertEquals("push:DB_20260927_155209/Ws26_IV_0:cycle:1", cycle.sourceRecordId());
    }
    /** 方向判不出来时(段内电流正负混用)明细照留、单项容量留空,也不进循环的任何一侧。 */
    @Test
    void segmentsWithMixedCurrentSignsStayOutOfTheCycleAggregate()
    {
        ingest(MIXED_SIGN);
        PushIngestAdapter adapter = adapter(NOW);
        List<StepRecord> steps = adapter.stepRecords(channel());
        List<CycleRecord> cycles = adapter.cycleRecords(channel());
        StepRecord undecided = steps.get(0);
        assertEquals(1, undecided.cycleNo());
        assertEquals(1, undecided.stepNo());
        // 方向不唯一时不猜:类型留空、单项容量与能量留空。
        assertNull(undecided.stepType());
        // 方向判不出来时名称也留空:不写一个没根据的名字。
        assertNull(undecided.stepName());
        assertNull(undecided.capacityAh());
        assertNull(undecided.energyWh());
        // 四组容量能量作为设备事实照原样保留,供核对。
        assertEquals(0.02, undecided.measurements().get("dischargeCapacity").doubleValue(), 1e-9);
        // 该段不进任何一侧:循环 1 的放电侧算出来是 0,而不是把这一段里的 0.02 算进去。
        assertEquals(1, cycles.size());
        assertEquals(0.12, cycles.get(0).chargeCapacityAh(), 1e-9);
        assertEquals(0.0, cycles.get(0).dischargeCapacityAh(), 1e-9);
    }
    /** 环境量与量纲确认后落到配置:列名、指标编码、单位、采集器通道都由声明给出,代码里没有厂商列名。 */
    @Test
    void declaredAuxiliaryMetricIsProjectedWithItsOwnSourceIdentityValueUnitAndDeviceTime()
battery-device-api/src/main/java/com/battery/device/api/TestDataObservationAdapter.java
@@ -19,7 +19,11 @@
     */
    CollectorRealtime latestCollector(String adapterCode, String collectorCode);
    /** 返回设备直接提供的工步统计,不从实时采样推导。 */
    /**
     * 返回该通道的工步统计。来源有工步层时读设备工步层并原样交出;来源没有工步层时由适配器
     * 从采样段推导(ADR-0067),此时记录里的方向是推导出的方向名称(见 {@link StepRecord})。
     * 未结束的段不交出,也不推导来源根本没有的工步层。
     */
    List<StepRecord> stepRecords(ChannelIdentity channel);
    /**
@@ -31,6 +35,13 @@
    /** 返回适配器定义的实时持久化槽位间隔。 */
    long persistenceIntervalMillis(ChannelIdentity channel);
    /**
     * 返回工步与循环这些汇总记录的读取间隔。默认与实时持久化间隔一致;来源的汇总产出节奏与
     * 数据行节奏不同的实现覆盖它——推式来源的落地表在本地(实时行按展示节奏取),
     * 而汇总最快也要等来源写下下一行才可能变化,按展示节奏重算只是空跑。
     */
    default long aggregateIntervalMillis(ChannelIdentity channel) { return persistenceIntervalMillis(channel); }
    /** 返回设备计划方案;设备未提供时保持为空,不推导计划。 */
    default TestPlan testPlan(ChannelIdentity channel) { return null; }
@@ -89,8 +100,12 @@
            Instant serverReceivedAt, String metricCode, BigDecimal value, String unit) { }
    /**
     * 一条设备工步层记录。{@code stepType} 保留设备给出的原始类型(新威为数字枚举),
     * 方向由调用方按该类型解释,不由适配器按电流符号推断。
     * 一条设备工步层记录。来源给出工步类型时 {@code stepType} 原样保留(新威为数字枚举),
     * 方向由调用方按该类型解释,适配器不按电流符号改写它。
     *
     * <p>来源**没有**工步类型列时(推式来源的厂商库里既没有工步层也没有循环汇总表,ADR-0067),
     * 适配器交出的是按已核对列语义判定出的方向名称({@code charge} / {@code discharge} / {@code rest}),
     * 判定不出来时留空;此时该工步的其余一切仍是设备采样行上的原文事实。
     *
     * <p>{@code endVoltageV} 与 {@code endCurrentA} 是该工步结束时的设备读数。
     * {@code capacityAh} 与 {@code energyWh} 是该工步自身累计的单项值,只在方向已确认时给出;
ruoyi-admin/src/test/java/com/battery/business/runtime/PushIngestLandingMigrationContractTest.java
@@ -207,6 +207,33 @@
        assertTrue(latest.getPayloadJson().contains("Voltage"));
    }
    /**
     * 工步与循环的分段推导读的就是这条查询(ADR-0067):某一个来源库在该通道上的采样行,
     * 按 (设备时间, 协议序号) 升序——推导依赖这个次序判断哪一段还在进行;其他来源库、
     * 其他通道与其他来源的行都不混进来。
     */
    @Test
    void samplesOfOneSourceRefComeBackInWatermarkOrderAndNothingElseDoes()
    {
        mapper.insertIngestRecord(sample(26, "DB_20260927_155209/Ws26_IV_0:12", 12L, "{\"Voltage\":1.3}",
                Instant.parse("2026-09-27T07:53:20Z")));
        mapper.insertIngestRecord(sample(26, "DB_20260927_155209/Ws26_IV_0:11", 11L, "{\"Voltage\":1.2}",
                Instant.parse("2026-09-27T07:52:20Z")));
        // 换了来源库(厂商侧另一次测试)、另一个通道:都不属于本次推导范围。
        TDeviceIngestRecord laterTest = sample(26, "DB_20260928_101010/Ws26_IV_0:1", 1L, "{\"Voltage\":1.4}",
                Instant.parse("2026-09-28T02:10:10Z"));
        laterTest.setSourceRef("DB_20260928_101010/Ws26_IV_0");
        mapper.insertIngestRecord(laterTest);
        mapper.insertIngestRecord(sample(18, "DB_20260923_161840/Ws18_IV_0:5", 5L, "{\"Voltage\":1.1}",
                Instant.parse("2026-09-23T08:18:40Z")));
        List<TDeviceIngestRecord> rows = mapper.selectSamplesOfSource(ADAPTER, 0, 0, 1, 26,
                "DB_20260927_155209/Ws26_IV_0");
        assertEquals(List.of(11L, 12L), rows.stream().map(TDeviceIngestRecord::getRecordSequence).toList());
        assertTrue(mapper.selectSamplesOfSource(ADAPTER, 0, 0, 1, 26, "DB_20261001_000000/Ws26_IV_0").isEmpty());
    }
    /** 供 H2 别名使用的 HEX 等价实现;H2 没有 MySQL 的 HEX()。 */
    public static String hexOf(byte[] value)
    {
scripts/j22-introspect-files.ps1
New file
@@ -0,0 +1,259 @@
<#
  22 所上位机文件侧只读探查:工步方案(含可能的工步名称)在不在软件目录的文件里。
  为什么走文件侧:该机 SQL Server 的 5141 个用户库**全部**是 DB_<时间> 测试库、其他库 0 个,
  所以工步方案不可能在库里。
  方案文件是 `.pcj`(2026-10-09 现场确认):`SysSchedule` 格式的 XML、**没有压缩**,声明 utf-8,
  每个工步一条 `<Step>`,带 `CtrlTypeName`(控制类型名)、`CtrlValue`(设定值)、`Limit`(截止条件)、
  `GotoStep`(跳转),方案名在 `<ScheduleName>`。遗留的 `.sto`(gzip 压缩的 XML)也一并看。
  只读纪律:只列目录、只读文件内容;不写、不改厂商目录与文件;报告只写到本脚本所在目录。
  用法(在那台机器上):
      powershell -ExecutionPolicy Bypass -File .\j22-introspect-files.ps1
      powershell -ExecutionPolicy Bypass -File .\j22-introspect-files.ps1 -Roots 'D:\22所5v10A测试柜'
      powershell -ExecutionPolicy Bypass -File .\j22-introspect-files.ps1 -SearchRoot 'D:\' -MaxDepth 4
#>
param(
    # 要探查的目录;留空则自动找(见下)。
    [string[]]$Roots = @(),
    [string]$SearchRoot = 'D:\',
    [int]$MaxDepth = 3,
    # 最多深入解析几个方案文件(只打印结构,不倒内容)。
    [int]$MaxFiles = 3,
    [int]$MaxHeadChars = 400,
    [int]$MaxNames = 25,
    # 工步方案里打印前几个与后几个工步(中间省略),以及每个工步最多打印多少字。
    [int]$StepPreview = 4,
    [int]$MaxStepChars = 300,
    [string]$ReportFile = ''
)
$ErrorActionPreference = 'Stop'
if (-not $ReportFile) {
    $base = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path }
    $ReportFile = Join-Path $base 'j22-introspect-files-report.txt'
}
$report = New-Object System.Collections.ArrayList
function Say([string]$text) {
    Write-Host $text
    [void]$report.Add($text)
}
# 方案文件的判据:.pcj 是这台机器上软件的工步方案(SysSchedule XML,2026-10-09 现场确认),
# .sto 是遗留方案(gzip 压缩的 XML);名字里带 step/plan/task/recipe/scheme/program/工步/方案的也算。
# 其余文件(设备配置、单位换算表等)不看——它们存不下工步名称。
function Test-PlanFile([string]$name) {
    $extension = [System.IO.Path]::GetExtension($name)
    if ($extension -ieq '.pcj' -or $extension -ieq '.sto') { return $true }
    return $name -imatch '(step|plan|task|recipe|scheme|program|工步|方案)'
}
# 取一个 XML 标签的文本值;没有这个标签就返回空串。
function Get-TagValue([string]$text, [string]$tag) {
    return [regex]::Match($text, '<' + $tag + '>([^<]*)</' + $tag + '>').Groups[1].Value
}
# 读文件文本:gzip(.sto 是 gzip 压缩的 XML)自动解压;只读前若干字符,避免把大文件全读进来。
# 编码:有 BOM 按 BOM,没有 BOM 按 GBK(Windows 上的国产上位机多半是 GBK),否则中文名称会读成乱码。
function Read-PlanText([string]$path, [int]$maxChars = 200000) {
    $stream = [System.IO.File]::OpenRead($path)
    try {
        $head = New-Object byte[] 2
        $read = $stream.Read($head, 0, 2)
        $stream.Position = 0
        $maxBytes = [Math]::Max(4096, $maxChars * 3)
        if ($read -eq 2 -and $head[0] -eq 0x1F -and $head[1] -eq 0x8B) {
            $gzip = New-Object System.IO.Compression.GZipStream($stream, [System.IO.Compression.CompressionMode]::Decompress)
            try { $bytes = Read-Bytes $gzip $maxBytes }
            finally { $gzip.Dispose() }
        }
        else { $bytes = Read-Bytes $stream $maxBytes }
        return Convert-PlanBytes $bytes
    }
    finally { $stream.Dispose() }
}
# 最多读 $maxBytes 个字节;读到多少算多少(文件可能比上限大,也可能很小)。非文本文件也照读,
# 由调用方按"开头是不是 <"判断它是不是 XML,不在这里猜。
function Read-Bytes($stream, [int]$maxBytes) {
    $buffer = New-Object byte[] 8192
    $memory = New-Object System.IO.MemoryStream
    try {
        while ($memory.Length -lt $maxBytes) {
            $wanted = [Math]::Min($buffer.Length, $maxBytes - $memory.Length)
            $count = $stream.Read($buffer, 0, $wanted)
            if ($count -le 0) { break }
            $memory.Write($buffer, 0, $count)
        }
        return $memory.ToArray()
    }
    finally { $memory.Dispose() }
}
# 编码判定:先看 BOM;再看 XML 声明里的 encoding;都没有按 GBK 兜底——国产上位机在中文 Windows 上
# 多半写 GBK,按 UTF-8 硬解会把中文工步名称读成乱码(.sto 是 gzip,流不可回退,所以必须先读再判)。
function Convert-PlanBytes([byte[]]$bytes) {
    if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
        return [System.Text.Encoding]::UTF8.GetString($bytes, 3, $bytes.Length - 3)
    }
    if ($bytes.Length -ge 2 -and $bytes[0] -eq 0xFF -and $bytes[1] -eq 0xFE) {
        return [System.Text.Encoding]::Unicode.GetString($bytes, 2, $bytes.Length - 2)
    }
    $asciiHead = [System.Text.Encoding]::ASCII.GetString($bytes, 0, [Math]::Min(200, $bytes.Length))
    if ($asciiHead -match '(?i)encoding\s*=\s*"([\w\-]+)"') {
        try { return [System.Text.Encoding]::GetEncoding($Matches[1]).GetString($bytes) }
        catch { }
    }
    return [System.Text.Encoding]::GetEncoding(936).GetString($bytes)
}
# XML 里出现过哪些元素名:只看结构,不打印内容。判断"有没有名称字段"靠这个。
function Get-ElementNames([string]$text) {
    $names = New-Object System.Collections.ArrayList
    foreach ($match in [regex]::Matches($text, '<\s*([A-Za-z_][\w.\-]*)')) {
        $name = [string]$match.Groups[1].Value
        if (-not $names.Contains($name)) { [void]$names.Add($name) }
    }
    return , $names
}
# 名称类元素或属性及其取值:这是"方案里到底有没有工步名称"的直接证据。
# 先取名字再筛关键字——不能把关键字塞进正则的主体里:属性名就叫 Name 时关键字前面没有字符,
# 那种写法一条也匹配不到(演练里踩过)。
function Get-NameEvidence([string]$text) {
    $evidence = New-Object System.Collections.ArrayList
    $keyword = '(?i)(name|desc|title|remark|说明|名称|备注)'
    foreach ($match in [regex]::Matches($text, '<\s*([A-Za-z_][\w.\-]*)\s*>([^<]{1,200})</')) {
        $name = [string]$match.Groups[1].Value
        if ($name -match $keyword) {
            [void]$evidence.Add('<' + $name + '> = ' + [string]$match.Groups[2].Value)
        }
    }
    foreach ($match in [regex]::Matches($text, '([A-Za-z_][\w.\-]*)\s*=\s*"([^"]{1,200})"')) {
        $name = [string]$match.Groups[1].Value
        if ($name -match $keyword) {
            [void]$evidence.Add($name + ' = ' + [string]$match.Groups[2].Value)
        }
    }
    return , $evidence
}
try {
    # ---------------------------------------------------------------- 1. 要找哪些目录
    # 要探查的目录放进自己的列表:-Roots 是 [string[]] 定长数组,不能往里 Add。
    $targets = New-Object System.Collections.ArrayList
    if (-not $Roots -or $Roots.Count -eq 0) {
        Say '=== 1. 自动查找目录 ==='
        $found = New-Object System.Collections.ArrayList
        # 运行目录的特征文件:VariableMap.xml / TaskModel.xml / config.ini。
        foreach ($marker in @('VariableMap.xml', 'TaskModel.xml', 'config.ini')) {
            try {
                $hits = Get-ChildItem -LiteralPath $SearchRoot -Recurse -Depth $MaxDepth -Filter $marker -File `
                    -ErrorAction SilentlyContinue | Select-Object -First 5
            }
            catch { $hits = @() }
            foreach ($hit in $hits) {
                $directory = $hit.DirectoryName
                if (-not $found.Contains($directory)) { [void]$found.Add($directory) }
            }
        }
        # 现场的遗留方案目录(存在就一起看)。
        foreach ($legacy in @('D:\22所5v10A测试柜', 'D:\22所5v10A测试柜\')) {
            if (Test-Path -LiteralPath $legacy) { if (-not $found.Contains($legacy)) { [void]$found.Add($legacy) } }
        }
        foreach ($directory in $found) {
            Say ("  候选目录:" + $directory)
            [void]$targets.Add($directory)
        }
        if ($targets.Count -eq 0) {
            Say '  没有找到任何候选目录。请手工指定,例如:-Roots ''D:\<软件目录>'''
        }
    }
    else {
        Say '=== 1. 指定目录 ==='
        foreach ($root in $Roots) {
            # 用 -File 从命令行传数组时,多个目录可能被当成一个字符串('D:\a,D:\b'),按逗号再拆一次。
            foreach ($single in @($root -split ',')) {
                $trimmed = $single.Trim().Trim('"')
                if ($trimmed) { Say ("  指定目录:" + $trimmed); [void]$targets.Add($trimmed) }
            }
        }
    }
    # ---------------------------------------------------------------- 2. 方案候选文件
    $candidates = New-Object System.Collections.ArrayList
    foreach ($root in $targets) {
        if (-not (Test-Path -LiteralPath $root)) { Say ("  目录不存在:" + $root); continue }
        Say ''
        Say ("=== 2. 方案候选文件:" + $root + " ===")
        $files = Get-ChildItem -LiteralPath $root -Recurse -Depth 1 -File -ErrorAction SilentlyContinue |
            Where-Object { Test-PlanFile $_.Name } | Sort-Object LastWriteTime -Descending
        $shown = 0
        foreach ($file in $files) {
            if ($shown -lt 30) { Say ("  " + $file.Name + "  " + [math]::Round($file.Length / 1KB, 1) + " KB  " + $file.LastWriteTime) }
            $shown++
            [void]$candidates.Add($file)
        }
        if ($shown -eq 0) { Say '  (没有匹配到方案候选文件)' }
        elseif ($shown -gt 30) { Say ("  …… 共 " + $shown + " 个,只列了前 30 个") }
    }
    # ---------------------------------------------------------------- 3. 解析最像方案的几个
    Say ''
    Say ("=== 3. 结构解析(最多 " + $MaxFiles + " 个,按修改时间倒序) ===")
    $parsed = 0
    foreach ($file in ($candidates | Sort-Object LastWriteTime -Descending)) {
        if ($parsed -ge $MaxFiles) { break }
        $parsed++
        Say ''
        Say ("  " + $file.FullName + "  (" + [math]::Round($file.Length / 1KB, 1) + " KB)")
        try { $text = Read-PlanText $file.FullName }
        catch { Say ('    读取失败:' + $_.Exception.Message); continue }
        if ([string]::IsNullOrWhiteSpace($text)) { Say '    (空文件或解压后为空)'; continue }
        if (-not $text.TrimStart().StartsWith('<')) { Say '    (不是 XML 文本,可能不是方案文件)' }
        $names = Get-ElementNames $text
        $limited = @($names | Select-Object -First $MaxNames)
        Say ('    元素名(前 ' + $limited.Count + ' / 共 ' + $names.Count + '):' + ($limited -join ', '))
        $evidence = Get-NameEvidence $text
        if ($evidence.Count -gt 0) {
            Say ('    名称类元素或属性(最多 5 条)——**有名称就直接抄下来**:')
            foreach ($line in @($evidence | Select-Object -First 5)) { Say ('      ' + $line) }
        }
        else { Say '    没有发现名称类元素或属性。' }
        $head = $text.Trim()
        if ($head.Length -gt $MaxHeadChars) { $head = $head.Substring(0, $MaxHeadChars) + ' …' }
        Say ('    文本开头:' + $head)
        # 工步方案(.pcj / SysSchedule):逐工步的设定值、截止条件与跳转都在这里。
        # 工步的"名称"通常就是每个工步的 CtrlTypeName(例如"恒流充电""搁置")——这一节就是要看它。
        if ($text -match '<Steps>') {
            $steps = [regex]::Matches($text, '(?s)<Step>.*?</Step>')
            Say ('    方案名: ' + (Get-TagValue $text 'ScheduleName') + '    任务目录: ' + (Get-TagValue $text 'SchedulePath'))
            Say ('    方案标题: ' + (Get-TagValue $text 'Caption') + '    说明: ' + (Get-TagValue $text 'Description') + '    最后修改: ' + (Get-TagValue $text 'LastUpdateTime'))
            Say ('    工步数: ' + $steps.Count)
            $index = 0
            foreach ($step in $steps) {
                $index++
                # 只打印前几个与后几个:中间的省略,否则一个方案就能刷满屏。
                if ($index -gt $StepPreview -and $index -le ($steps.Count - $StepPreview)) { continue }
                $flat = $step.Value -replace '\s+', ' '
                if ($flat.Length -gt $MaxStepChars) { $flat = $flat.Substring(0, $MaxStepChars) + ' …' }
                Say ('      工步 ' + $index + ': ' + $flat)
            }
        }
    }
    Say ''
    if ($parsed -eq 0) { Say '=== 结论:没有可解析的方案候选文件 ===' }
    else { Say '=== 结论:见上面各文件的"名称类元素或属性"与"元素名"两行 ===' }
    Say '  如果方案里根本没有名称,就只剩两条路:① 用推导方向当名称(标注"推导");'
    Say '  ② 在来源配置里声明「工步号 → 名称」,把甲方界面上对得上的名字配上。'
    Say ('报告同时写在:' + $ReportFile)
}
finally {
    # 中途出错也把已有内容落盘:控制台刷不完,文件可以慢慢复制。
    $report | Set-Content -LiteralPath $ReportFile -Encoding UTF8
}
scripts/j22-introspect.ps1
New file
@@ -0,0 +1,226 @@
<#
  22 所上位机 SQL Server 只读探查:找出「工步方案」在哪里(工步名称可能只存在于方案里)。
  v2:那台实例有几千个库,全量罗列没法看,所以本版**只报结论**——不列库、不列表,
  只找"像方案的表"与"同时含工步列和名称列的表",命中项才打印细节;其余只给计数。
  只读纪律与取数程序一致:只执行 SELECT,不写、不建、不改、不调用存储过程,每条查询都显式设超时。
  甲方可自行审计本文件。
  用法(在那台机器上,Windows 认证免密码):
      powershell -ExecutionPolicy Bypass -File .\j22-introspect.ps1
      powershell -ExecutionPolicy Bypass -File .\j22-introspect.ps1 -SqlInstance '.\SQLEXPRESS'
      powershell -ExecutionPolicy Bypass -File .\j22-introspect.ps1 -Database 'SomeDb'   # 只看指定库
#>
param(
    [string]$SqlInstance = '.\SQLEXPRESS',
    [int]$ConnectTimeoutSec = 10,
    [int]$CommandTimeoutSec = 15,
    # 非 DB_ 库最多探查多少个(按创建时间倒序,最早的先跳过)。几千个库时防止跑太久。
    [int]$MaxDatabases = 100,
    [int]$SampleRows = 3,
    # 最多打印多少张命中表:命中太多就没法复制了,其余只计数。
    [int]$MaxHits = 25,
    [string]$Database = '',
    [string]$ReportFile = ''
)
$ErrorActionPreference = 'Stop'
# 报告同时写文件:控制台一次刷不完,文件可以慢慢复制。
if (-not $ReportFile) {
    $base = if ($PSScriptRoot) { $PSScriptRoot } else { (Get-Location).Path }
    $ReportFile = Join-Path $base 'j22-introspect-report.txt'
}
$report = New-Object System.Collections.ArrayList
function Say([string]$text) {
    Write-Host $text
    [void]$report.Add($text)
}
# 只允许常规库名/表名:库名与表名会被拼进查询(标识符不能用参数),异常字符一律跳过。
function Test-SafeName([string]$name) { return $name -match '^[A-Za-z0-9_$]+$' }
# 测试库是 DB_<起始时间>:取数程序只读这些,其余库从来没有看过,所以本探查只看其余库。
# 注意这里用的是 PowerShell 通配符:* 才是"任意字符",% 只是普通字符——不要照抄 SQL 的 LIKE 写法。
function Test-TestDatabase([string]$name) { return $name -like 'DB_*' }
# 所有查询都经过这里:显式设 CommandTimeout,读满即断,不发加锁读、不开启事务。
function Invoke-ReadOnlyQuery($connection, [string]$sql) {
    $command = $connection.CreateCommand()
    $command.CommandText = $sql
    $command.CommandTimeout = $CommandTimeoutSec
    $reader = $command.ExecuteReader()
    try {
        $rows = New-Object System.Collections.ArrayList
        while ($reader.Read()) {
            $values = New-Object System.Collections.ArrayList
            for ($i = 0; $i -lt $reader.FieldCount; $i++) {
                if ($reader.IsDBNull($i)) { [void]$values.Add($null) }
                else { [void]$values.Add($reader.GetValue($i)) }
            }
            [void]$rows.Add($values)
        }
        return , $rows
    }
    finally { $reader.Close(); $command.Dispose() }
}
# 表名像"方案/配方/任务"的,以及同时含工步列与名称列的,都算候选。
$planishTable = "(name like '%Step%' or name like '%Recipe%' or name like '%Task%' or name like '%Plan%'" +
    " or name like '%Program%' or name like '%Scheme%' or name like '%Procedure%' or name like '%Formula%')"
$stepishColumn = "(c.name like '%Step%' or c.name like '%工步%')"
$nameishColumn = "(c.name like '%Name%' or c.name like '%Desc%' or c.name like '%Title%'" +
    " or c.name like '%Remark%' or c.name like '%说明%' or c.name like '%名称%' or c.name like '%备注%')"
$nameishLike = "(?i)(name|desc|title|remark|说明|名称|备注)"
$connectionString = "Server=$SqlInstance;Integrated Security=True;Connect Timeout=$ConnectTimeoutSec;" +
    "Application Name=j22-introspect"
$connection = New-Object System.Data.SqlClient.SqlConnection $connectionString
$connection.Open()
try {
    Say "已连接:$SqlInstance(只读探查,不写任何数据)"
    Say ''
    # ---------------------------------------------------------------- 1. 要探查哪些库
    $all = Invoke-ReadOnlyQuery $connection @'
select name, create_date
from sys.databases
where database_id > 4
order by create_date desc
'@
    $tests = New-Object System.Collections.ArrayList
    $others = New-Object System.Collections.ArrayList
    foreach ($row in $all) {
        if (Test-TestDatabase ([string]$row[0])) { [void]$tests.Add($row) } else { [void]$others.Add($row) }
    }
    if ($Database) {
        $targets = New-Object System.Collections.ArrayList
        [void]$targets.Add($Database)
        Say "=== 1. 指定库探查:[$Database] ==="
    }
    else {
        Say "=== 1. 探查范围 ==="
        Say ("  用户库共 " + $all.Count + " 个;其中 DB_ 开头的测试库(取数程序已在读)" + $tests.Count +
            " 个,本次不看")
        Say ("  其他库 " + $others.Count + " 个,按创建时间倒序探查前 " + $MaxDatabases + " 个")
        $targets = New-Object System.Collections.ArrayList
        $index = 0
        foreach ($row in $others) {
            if ($index -ge $MaxDatabases) { break }
            [void]$targets.Add([string]$row[0])
            $index++
        }
        if ($others.Count -gt 0) {
            # 前几个名字一定打出来:库名本身就能看出这批库是干什么的。
            $preview = ($others | Select-Object -First 10 | ForEach-Object { [string]$_[0] }) -join '、'
            $suffix = if ($others.Count -gt 10) { ' …' } else { '' }
            Say ("  其他库前 10 个:" + $preview + $suffix)
        }
    }
    if ($targets.Count -eq 0) {
        Say ''
        Say '=== 结论:库里没有其他库,工步方案不在数据库中 ==='
        Say '  下一步走文件侧:软件目录里的工步方案文件(.sto 方案、TaskModel.xml 等)。'
    }
    # ---------------------------------------------------------------- 2. 找候选表
    $candidates = New-Object System.Collections.ArrayList
    $tableCount = 0
    foreach ($database in $targets) {
        if (-not (Test-SafeName $database)) { Say "  跳过库名异常的库:$database"; continue }
        try {
            $byName = Invoke-ReadOnlyQuery $connection (
                "select name from [$database].sys.tables where $planishTable")
            $byColumn = Invoke-ReadOnlyQuery $connection @"
select tb.name
from [$database].sys.tables tb
where exists (select 1 from [$database].sys.columns c
              where c.object_id = tb.object_id and $stepishColumn)
  and exists (select 1 from [$database].sys.columns c
              where c.object_id = tb.object_id and $nameishColumn)
"@
            $tableCount += (Invoke-ReadOnlyQuery $connection "select count(*) from [$database].sys.tables")[0][0]
        }
        catch { Say ("  读取 [$database] 失败:" + $_.Exception.Message); continue }
        $seen = @{}
        foreach ($row in $byName) {
            $table = [string]$row[0]
            if ($seen.ContainsKey($table)) { continue }
            $seen[$table] = $true
            [void]$candidates.Add([pscustomobject]@{ Database = $database; Table = $table; Why = '表名像方案' })
        }
        foreach ($row in $byColumn) {
            $table = [string]$row[0]
            if ($seen.ContainsKey($table)) { continue }
            $seen[$table] = $true
            [void]$candidates.Add([pscustomobject]@{ Database = $database; Table = $table; Why = '含工步列+名称列' })
        }
    }
    Say ''
    Say '=== 2. 候选表统计 ==='
    Say ("  探查 " + $targets.Count + " 个库、共 " + $tableCount + " 张表;命中候选 " + $candidates.Count + " 张")
    # ---------------------------------------------------------------- 3. 候选表的列与样例行
    $hits = 0
    foreach ($candidate in $candidates) {
        $database = $candidate.Database
        $table = $candidate.Table
        if (-not (Test-SafeName $table)) { continue }
        try {
            # 带上 schema:表不一定在 dbo 下,用真实 schema 拼三段名,否则计数与取样会失败。
            $columns = Invoke-ReadOnlyQuery $connection @"
select c.name, schema_name(tb.schema_id) as schema_name
from [$database].sys.columns c
join [$database].sys.tables tb on tb.object_id = c.object_id
where tb.name = '$table'
order by c.column_id
"@
            $schema = [string]$columns[0][1]
            if (-not (Test-SafeName $schema)) { continue }
            $rows = (Invoke-ReadOnlyQuery $connection "select count(*) from [$database].[$schema].[$table]")[0][0]
        }
        catch { continue }
        $names = @()
        $allColumns = @()
        foreach ($column in $columns) {
            $columnName = [string]$column[0]
            $allColumns += $columnName
            if ($columnName -match $nameishLike) { $names += $columnName }
        }
        # 只报"有名称类列"的表:没有名称列的表存不下工步名称,不是我们要找的。
        if ($names.Count -eq 0) { continue }
        if ($rows -le 0) { continue }
        $hits++
        # 命中太多就没法复制了:只打印前 -MaxHits 条,其余只计数(报告文件里也只有这些)。
        if ($hits -gt $MaxHits) { continue }
        Say ''
        Say ("  [$database].[$schema].[$table]  行数 $rows  命中原因:" + $candidate.Why)
        Say ("    列:" + ($allColumns -join ', '))
        $projection = ($names | Select-Object -First 6 | ForEach-Object { "[$_]" }) -join ', '
        try {
            $sample = Invoke-ReadOnlyQuery $connection "select top $SampleRows $projection from [$database].[$schema].[$table]"
            foreach ($line in $sample) {
                Say ('    样例:' + (($line | ForEach-Object { if ($null -eq $_) { '<null>' } else { [string]$_ } }) -join ' | '))
            }
        }
        catch { Say ('    取样失败:' + $_.Exception.Message) }
    }
    Say ''
    if ($hits -eq 0) {
        Say '=== 结论:没有找到"带工步名称的表" ==='
        Say '  下一步:① 提高 -MaxDatabases 再看一遍;② 转文件侧,查软件目录里的工步方案文件。'
    }
    else {
        Say ("=== 结论:命中 " + $hits + " 张带名称列的候选表(最多打印 " + $MaxHits + " 张,见上),把上面这段发回即可 ===")
    }
    Say ('报告同时写在:' + $ReportFile)
}
finally {
    $connection.Close(); $connection.Dispose()
    # 中途出错也把已有内容落盘:控制台刷不完,文件可以慢慢复制。
    $report | Set-Content -LiteralPath $ReportFile -Encoding UTF8
}