Просмотр исходного кода

零售端-附加费收取间隔支持设置N小时

ouyang 14 часов назад
Родитель
Сommit
9b746b2a87

+ 3 - 1
app-mall/controllers/OrderController.php

@@ -1546,7 +1546,7 @@ class OrderController extends BaseController
         $connection = Yii::$app->db;
         $transaction = $connection->beginTransaction();
         try {
-            $fields = 'id,orderSn,shopId,sjId,mainId,customId,hdId,customName,shopAdminId,shopAdminName,status,payStatus,deadline,actPrice,realPrice,mainPay,cash,remainDebtPrice,onlinePay,fromType,sendType,showAddress,packingFee,groupBuyId,thirdNo,hbId,hbAmount';
+            $fields = 'id,orderSn,shopId,sjId,mainId,customId,hdId,customName,shopAdminId,shopAdminName,status,payStatus,deadline,actPrice,realPrice,mainPay,cash,remainDebtPrice,onlinePay,fromType,sendType,showAddress,packingFee,sendCost,groupBuyId,thirdNo,hbId,hbAmount';
             $order = OrderClass::getByCondition(['orderSn' => $orderSn], true, null, $fields);
             if (empty($order)) {
                 util::fail('没有找到订单');
@@ -1673,6 +1673,8 @@ class OrderController extends BaseController
             $orderSn = orderSn::getOrderSn();
             $updateData['orderSn'] = $orderSn;
             OrderItemClass::updateByCondition(['orderSn' => $oldOrderSn], ['orderSn' => $orderSn]);
+            // 附加运费缓存挂在下单号上,换号后需迁移,否则支付成功无法写入附加费冷却锁
+            \bizHd\order\classes\PsMethodClass::migrateUnMeetSendCostCache($oldOrderSn, $orderSn);
         } else {
             //已请求的不能再修改价格
             $updateData['modPrice'] = 0;

+ 415 - 35
biz-hd/order/classes/PsMethodClass.php

@@ -1,8 +1,8 @@
 <?php
 /**
  * 用途:花店配送方式配置业务逻辑类
- * 谁用:花店系统 hdApp 配送方式设置页
- * 解决什么问题:读写 xhPsMethod / xhPsExplain / xhPsReduceRule,与供货商 xhSh* 分离
+ * 谁用:花店系统 hdApp 配送方式设置页、商城混合结算附加费预览/下单
+ * 解决什么问题:读写 xhPsMethod / xhPsExplain / xhPsReduceRule;附加费支持按自然日或冷却秒重收
  */
 
 namespace bizHd\order\classes;
@@ -417,6 +417,9 @@ class PsMethodClass extends BaseClass
             'minNum' => isset($data['minNum']) ? (int)$data['minNum'] : 0,
             'unMeet' => isset($data['unMeet']) ? (int)$data['unMeet'] : 0,
             'unMeetFee' => isset($data['unMeetFee']) ? (float)$data['unMeetFee'] : 0.00,
+            // 附加费重收:0 自然日;1 冷却秒(配置页按小时录入后换算)
+            'unMeetLockType' => (isset($data['unMeetLockType']) && (int)$data['unMeetLockType'] === 1) ? 1 : 0,
+            'unMeetLockSeconds' => self::normalizeUnMeetLockSeconds($data['unMeetLockSeconds'] ?? 1800),
             'calcType' => isset($data['calcType']) ? (int)$data['calcType'] : 0,
             'startKm' => isset($data['startKm']) ? (float)$data['startKm'] : 0.00,
             'startPrice' => isset($data['startPrice']) ? (float)$data['startPrice'] : 0.00,
@@ -571,7 +574,307 @@ class PsMethodClass extends BaseClass
     }
 
     /**
-     * 判断当天是否已收过包装类附加费(xhPsMethod unMeet=2)
+     * 规范化冷却秒数(至少 1 秒;非法则回退默认 1800)
+     * @param mixed $seconds
+     * @return int
+     */
+    public static function normalizeUnMeetLockSeconds($seconds)
+    {
+        $n = (int)$seconds;
+        if ($n <= 0) {
+            return 1800;
+        }
+        return $n;
+    }
+
+    /**
+     * 从 xhPsMethod 配置解析附加费重收策略
+     * @param array|null $config
+     * @return array ['type'=>0|1, 'seconds'=>int] type:0自然日 1冷却秒
+     */
+    public static function resolveUnMeetLockOptions($config = null)
+    {
+        $type = 0;
+        $seconds = 1800;
+        if (is_array($config)) {
+            $type = (int)($config['unMeetLockType'] ?? 0);
+            $seconds = self::normalizeUnMeetLockSeconds($config['unMeetLockSeconds'] ?? 1800);
+        }
+        if ($type !== 1) {
+            $type = 0;
+        }
+        return ['type' => $type, 'seconds' => $seconds];
+    }
+
+    /**
+     * 按配置判断运费类附加费是否仍在锁定期
+     * - 间隔模式(type=1):只按「收款时间 + 当前配置秒数」,到期即再收;不看自然日/今日订单
+     * - 自然日(type=0):当日 Redis 决策 + 间隔痕迹互认 + 今日已付订单兜底
+     * @param int $customId
+     * @param int $sendType
+     * @param array|null $config xhPsMethod 行;缺省按自然日
+     * @return bool
+     */
+    public static function isUnMeetSendFeeLocked($customId, $sendType = 0, $config = null)
+    {
+        $customId = (int)$customId;
+        if ($customId <= 0) {
+            // 无客户身份无法记锁,每次都会算附加费(与 BaseController 未绑定 hd.customId 时一致)
+            return false;
+        }
+        $lock = self::resolveUnMeetLockOptions($config);
+        // 间隔:严格按时长,改成 4 秒后过期即应再收,不能被「今日已收」挡住
+        if ($lock['type'] === 1) {
+            return self::isSendCostInCooldown($customId, $sendType, $lock['seconds']);
+        }
+        if (self::hasSendFeeDecisionToday($customId, $sendType)) {
+            return true;
+        }
+        if (self::hasIntervalSendFeeCollectedToday($customId, $sendType)) {
+            // 间隔痕迹还在:补写自然日 key,避免后续再丢
+            self::ensureDaySendFeePaidMarked($customId, $sendType);
+            return true;
+        }
+        return self::hasPaidUnMeetFeeOrderToday($customId, $sendType, 0);
+    }
+
+    /**
+     * 按配置判断包装类附加费是否仍在锁定期
+     * @param int $customId
+     * @param int $sendType
+     * @param array|null $config
+     * @return bool
+     */
+    public static function isUnMeetPackFeeLocked($customId, $sendType = 0, $config = null)
+    {
+        $customId = (int)$customId;
+        if ($customId <= 0) {
+            return false;
+        }
+        $lock = self::resolveUnMeetLockOptions($config);
+        if ($lock['type'] === 1) {
+            return self::isPackCostInCooldown($customId, $sendType, $lock['seconds']);
+        }
+        if (self::hasPackFeeDecisionToday($customId, $sendType)) {
+            return true;
+        }
+        if (self::hasIntervalPackFeeCollectedToday($customId, $sendType)) {
+            self::ensureDayPackFeePaidMarked($customId, $sendType);
+            return true;
+        }
+        return self::hasPaidUnMeetFeeOrderToday($customId, $sendType, 2);
+    }
+
+    /**
+     * 补写自然日「运费附加费已收」标记
+     */
+    protected static function ensureDaySendFeePaidMarked($customId, $sendType)
+    {
+        if (self::isSendCostPaidToday($customId, $sendType)) {
+            return;
+        }
+        $key = 'hdCustom_hasGetSendCost:' . (int)$sendType . '_' . date('Y_m_d') . ':' . (int)$customId;
+        Yii::$app->redis->executeCommand('SETEX', [$key, 86400, '1']);
+    }
+
+    /**
+     * 补写自然日「包装附加费已收」标记
+     */
+    protected static function ensureDayPackFeePaidMarked($customId, $sendType)
+    {
+        if (self::isPackPaidToday($customId, $sendType)) {
+            return;
+        }
+        $key = 'hdCustom_hasGetPackCost:' . (int)$sendType . '_' . date('Y_m_d') . ':' . (int)$customId;
+        Yii::$app->redis->executeCommand('SETEX', [$key, 86400, '1']);
+    }
+
+    /**
+     * Redis 无锁时:回查今日已支付订单是否已收过对应附加费(修复短间隔逻辑过期删 key / 切换模式丢痕迹)
+     * @param int $customId
+     * @param int $sendType
+     * @param int $unMeetKind 0=运费附加费(送货/物流/快递看 sendCost) 2=包装费(packingFee)
+     * @return bool
+     */
+    protected static function hasPaidUnMeetFeeOrderToday($customId, $sendType, $unMeetKind = 0)
+    {
+        $customId = (int)$customId;
+        $sendType = (int)$sendType;
+        if ($customId <= 0) {
+            return false;
+        }
+        $start = date('Y-m-d 00:00:00');
+        $end = date('Y-m-d 23:59:59');
+        try {
+            $query = \bizHd\order\models\Order::find()
+                ->where([
+                    'customId' => $customId,
+                    'sendType' => $sendType,
+                    'payStatus' => 1,
+                    'fromType' => (int)dict::getDict('fromType', 'mall'),
+                ])
+                ->andWhere(['>=', 'payTime', $start])
+                ->andWhere(['<=', 'payTime', $end]);
+            if ((int)$unMeetKind === 2) {
+                $query->andWhere(['>', 'packingFee', 0]);
+            } elseif (in_array($sendType, [0, 3, 4], true)) {
+                // 送货/物流/快递:页内 sendCost 基本即未达门槛附加运费
+                $query->andWhere([
+                    'or',
+                    ['>', 'packingFee', 0],
+                    ['>', 'sendCost', 0],
+                ]);
+            } else {
+                // 跑腿等:仅包装附加费可明确识别
+                $query->andWhere(['>', 'packingFee', 0]);
+            }
+            $exists = $query->exists();
+            if ($exists) {
+                // 回写 Redis,后续 calc-mix-fee-batch 不再打库
+                if ((int)$unMeetKind === 2) {
+                    self::ensureDayPackFeePaidMarked($customId, $sendType);
+                } else {
+                    self::ensureDaySendFeePaidMarked($customId, $sendType);
+                }
+            }
+            return $exists;
+        } catch (\Exception $e) {
+            Yii::error('hasPaidUnMeetFeeOrderToday failed: ' . $e->getMessage(), __METHOD__);
+            return false;
+        }
+    }
+
+    /**
+     * 冷却 Redis key 最长保留时间(秒):逻辑是否锁定用「收款时间+当前配置间隔」算,
+     * key 本身多留几天,避免改长间隔时 key 已被 Redis 删掉导致无法按新间隔生效
+     */
+    const UNMEET_COOLDOWN_REDIS_KEEP_SECONDS = 604800; // 7 天
+
+    /**
+     * 冷却模式下:运费附加费 Redis key(无日期)
+     */
+    protected static function buildSendCostCooldownKey($sendType, $customId)
+    {
+        return 'hdCustom_hasGetSendCost:interval:' . (int)$sendType . ':' . (int)$customId;
+    }
+
+    /**
+     * 冷却模式下:包装附加费 Redis key
+     */
+    protected static function buildPackCostCooldownKey($sendType, $customId)
+    {
+        return 'hdCustom_hasGetPackCost:interval:' . (int)$sendType . ':' . (int)$customId;
+    }
+
+    /**
+     * 间隔冷却 key 是否记录了「今天」收过运费附加费(供改成自然日后互认)
+     * @param int $customId
+     * @param int $sendType
+     * @return bool
+     */
+    protected static function hasIntervalSendFeeCollectedToday($customId, $sendType)
+    {
+        return self::hasIntervalFeeCollectedToday(self::buildSendCostCooldownKey($sendType, $customId));
+    }
+
+    /**
+     * 间隔冷却 key 是否记录了「今天」收过包装附加费
+     * @param int $customId
+     * @param int $sendType
+     * @return bool
+     */
+    protected static function hasIntervalPackFeeCollectedToday($customId, $sendType)
+    {
+        return self::hasIntervalFeeCollectedToday(self::buildPackCostCooldownKey($sendType, $customId));
+    }
+
+    /**
+     * interval key 存在且收款日为今天则视为今日已收过
+     * @param string $key
+     * @return bool
+     */
+    protected static function hasIntervalFeeCollectedToday($key)
+    {
+        $has = Yii::$app->redis->executeCommand('GET', [$key]);
+        if ($has === null || $has === false || $has === '') {
+            return false;
+        }
+        $val = (int)$has;
+        // 新格式:收款时间戳落在当天
+        if ($val > 1000000000) {
+            return date('Y-m-d', $val) === date('Y-m-d');
+        }
+        // 旧格式:key 仍在则保守视为今日已收,避免切换自然日后立刻再收
+        return true;
+    }
+
+    /**
+     * 解析冷却 Redis TTL(至少覆盖当前间隔,且不少于 7 天便于改配置后仍能读到收款时间)
+     * @param int $lockSeconds
+     * @return int
+     */
+    protected static function resolveCooldownRedisTtl($lockSeconds)
+    {
+        return max((int)$lockSeconds, self::UNMEET_COOLDOWN_REDIS_KEEP_SECONDS, 1);
+    }
+
+    /**
+     * 判断间隔冷却是否仍有效(仅用于 unMeetLockType=1)
+     * value 必须是收款时间戳;按「距收款 < 当前配置秒数」判断,改间隔立即生效
+     * @param string $key
+     * @param int $expectedSeconds 当前配置的冷却秒数
+     * @return bool
+     */
+    protected static function isIntervalCooldownActive($key, $expectedSeconds)
+    {
+        $has = Yii::$app->redis->executeCommand('GET', [$key]);
+        if ($has === null || $has === false || $has === '') {
+            return false;
+        }
+        $expected = max(1, (int)$expectedSeconds);
+        $val = (int)$has;
+        // 仅新格式(unix 收款时间)可按当前间隔秒判断
+        if ($val > 1000000000) {
+            return (time() - $val) < $expected;
+        }
+        // 旧格式无时间戳:不能按 4 秒/2 小时判断,避免 key 残留导致永远不收费
+        return false;
+    }
+
+    /**
+     * 冷却秒模式:是否仍在运费附加费冷却期内(按当前配置间隔即时计算)
+     * @param int $customId
+     * @param int $sendType
+     * @param int|null $expectedSeconds 当前配置冷却秒数,缺省按 1800
+     * @return bool
+     */
+    public static function isSendCostInCooldown($customId, $sendType = 0, $expectedSeconds = null)
+    {
+        if (empty($customId)) {
+            return false;
+        }
+        $seconds = $expectedSeconds !== null ? (int)$expectedSeconds : 1800;
+        return self::isIntervalCooldownActive(self::buildSendCostCooldownKey($sendType, $customId), $seconds);
+    }
+
+    /**
+     * 冷却秒模式:是否仍在包装附加费冷却期内(按当前配置间隔即时计算)
+     * @param int $customId
+     * @param int $sendType
+     * @param int|null $expectedSeconds
+     * @return bool
+     */
+    public static function isPackCostInCooldown($customId, $sendType = 0, $expectedSeconds = null)
+    {
+        if (empty($customId)) {
+            return false;
+        }
+        $seconds = $expectedSeconds !== null ? (int)$expectedSeconds : 1800;
+        return self::isIntervalCooldownActive(self::buildPackCostCooldownKey($sendType, $customId), $seconds);
+    }
+
+    /**
+     * 判断当天是否已收过包装类附加费(xhPsMethod unMeet=2,自然日模式)
      */
     public static function isPackPaidToday($customId, $sendType = 0)
     {
@@ -607,7 +910,7 @@ class PsMethodClass extends BaseClass
     }
 
     /**
-     * 判断当天是否已收过运费类附加费(xhPsMethod unMeet=0)
+     * 判断当天是否已收过运费类附加费(xhPsMethod unMeet=0,自然日模式
      */
     public static function isSendCostPaidToday($customId, $sendType = 0)
     {
@@ -643,48 +946,74 @@ class PsMethodClass extends BaseClass
     }
 
     /**
-     * 支付成功后标记当天已收附加费,保证同一客户同配送方式每日只收一次
+     * 支付成功后标记已收附加费(兼容旧调用;优先走 markUnMeetFeePaidFromOrder)
+     * @param int $customId
+     * @param int $sendType
+     * @param float $packingFee
+     * @param float $unMeetSendCost
+     * @param array|null $config xhPsMethod,用于区分自然日/冷却秒
      */
-    public static function markUnMeetFeePaid($customId, $sendType, $packingFee = 0, $unMeetSendCost = 0)
+    public static function markUnMeetFeePaid($customId, $sendType, $packingFee = 0, $unMeetSendCost = 0, $config = null)
     {
-        self::recordDailyUnMeetFeeState($customId, $sendType, $packingFee, $unMeetSendCost);
+        self::recordUnMeetFeeLockState($customId, $sendType, $packingFee, $unMeetSendCost, $config);
     }
 
     /**
-     * 首单支付后记录当日附加费决策:收过则不再收;首单未收则当日后续也不收
+     * 写入附加费锁定态:实收时「间隔时间戳 + 自然日已收」双写,切换模式可互认
+     * @param int $customId
+     * @param int $sendType
+     * @param float $packingFee
+     * @param float $unMeetSendCost
+     * @param array|null $config
      */
-    protected static function recordDailyUnMeetFeeState($customId, $sendType, $packingFee = 0, $unMeetSendCost = 0)
+    protected static function recordUnMeetFeeLockState($customId, $sendType, $packingFee = 0, $unMeetSendCost = 0, $config = null)
     {
         if (empty($customId)) {
             return;
         }
-        $current = date('Y_m_d');
         $style = (int)$sendType;
-        $ttl = 86400;
+        $lock = self::resolveUnMeetLockOptions($config);
+        $current = date('Y_m_d');
+        $dayTtl = 86400;
+        $lockedAt = (string)time();
+        $intervalTtl = self::resolveCooldownRedisTtl(max(1, (int)($lock['seconds'] ?? 1800)));
+
+        $collectedSend = (float)$unMeetSendCost > 0;
+        $collectedPack = (float)$packingFee > 0;
 
-        if (!self::hasSendFeeDecisionToday($customId, $style)) {
-            if ((float)$unMeetSendCost > 0) {
+        // 实收运费附加费:间隔 key(时间戳)+ 自然日已收 key 一并写入
+        if ($collectedSend) {
+            Yii::$app->redis->executeCommand('SETEX', [self::buildSendCostCooldownKey($style, $customId), $intervalTtl, $lockedAt]);
+            if (!self::isSendCostPaidToday($customId, $style)) {
                 $sendKey = 'hdCustom_hasGetSendCost:' . $style . '_' . $current . ':' . $customId;
-                Yii::$app->redis->executeCommand('SETEX', [$sendKey, $ttl, '1']);
-            } else {
-                $skipKey = 'hdCustom_skipSendCost:' . $style . '_' . $current . ':' . $customId;
-                Yii::$app->redis->executeCommand('SETEX', [$skipKey, $ttl, '1']);
+                Yii::$app->redis->executeCommand('SETEX', [$sendKey, $dayTtl, '1']);
             }
+        } elseif ((int)$lock['type'] !== 1 && !self::hasSendFeeDecisionToday($customId, $style)) {
+            // 仅自然日模式:首单未收附加运费则记 skip,当日不再收
+            $skipKey = 'hdCustom_skipSendCost:' . $style . '_' . $current . ':' . $customId;
+            Yii::$app->redis->executeCommand('SETEX', [$skipKey, $dayTtl, '1']);
         }
 
-        if (!self::hasPackFeeDecisionToday($customId, $style)) {
-            if ((float)$packingFee > 0) {
+        // 实收包装附加费:同样双写
+        if ($collectedPack) {
+            Yii::$app->redis->executeCommand('SETEX', [self::buildPackCostCooldownKey($style, $customId), $intervalTtl, $lockedAt]);
+            if (!self::isPackPaidToday($customId, $style)) {
                 $packKey = 'hdCustom_hasGetPackCost:' . $style . '_' . $current . ':' . $customId;
-                Yii::$app->redis->executeCommand('SETEX', [$packKey, $ttl, '1']);
-            } else {
-                $skipKey = 'hdCustom_skipPackCost:' . $style . '_' . $current . ':' . $customId;
-                Yii::$app->redis->executeCommand('SETEX', [$skipKey, $ttl, '1']);
+                Yii::$app->redis->executeCommand('SETEX', [$packKey, $dayTtl, '1']);
             }
+        } elseif ((int)$lock['type'] !== 1 && !self::hasPackFeeDecisionToday($customId, $style)) {
+            $skipKey = 'hdCustom_skipPackCost:' . $style . '_' . $current . ':' . $customId;
+            Yii::$app->redis->executeCommand('SETEX', [$skipKey, $dayTtl, '1']);
         }
     }
 
     /**
-     * 根据订单信息标记当日附加费(支付成功回调/余额支付)
+     * 根据订单信息标记附加费锁定(支付成功回调/余额支付)
+     * 会按订单 mainId+sendType 读取 xhPsMethod 的重收规则
+     *
+     * 注意:附加运费金额优先读 redis hd_order_unmeet_send:{orderSn};
+     * 微信拉起支付可能更换 orderSn,导致 redis 读空,需用订单 sendCost + 配置回退,
+     * 否则冷却/自然日锁永远写不进去,结算页会反复加收。
      */
     public static function markUnMeetFeePaidFromOrder($order)
     {
@@ -694,7 +1023,22 @@ class PsMethodClass extends BaseClass
         $orderSn = is_object($order) ? ($order->orderSn ?? '') : ($order['orderSn'] ?? '');
         $customId = (int)(is_object($order) ? ($order->customId ?? 0) : ($order['customId'] ?? 0));
         $sendType = (int)(is_object($order) ? ($order->sendType ?? 0) : ($order['sendType'] ?? 0));
+        $shopId = (int)(is_object($order) ? ($order->shopId ?? 0) : ($order['shopId'] ?? 0));
+        $mainId = (int)(is_object($order) ? ($order->mainId ?? 0) : ($order['mainId'] ?? 0));
         $packingFee = (float)(is_object($order) ? ($order->packingFee ?? 0) : ($order['packingFee'] ?? 0));
+        $orderSendCost = (float)(is_object($order) ? ($order->sendCost ?? 0) : ($order['sendCost'] ?? 0));
+
+        $config = null;
+        if ($mainId > 0) {
+            // 支付回写只需 lock / unMeet 字段;无完整 explains 亦可
+            $row = self::getByCondition(['mainId' => $mainId, 'style' => $sendType]);
+            if (!empty($row)) {
+                $config = is_object($row) && method_exists($row, 'toArray') ? $row->toArray() : (array)$row;
+            } elseif ($shopId > 0) {
+                $config = self::getConfig($shopId, $mainId, $sendType);
+            }
+        }
+
         $unMeetSendCost = 0;
         if (!empty($orderSn)) {
             $unMeetSendKey = 'hd_order_unmeet_send:' . $orderSn;
@@ -703,11 +1047,47 @@ class PsMethodClass extends BaseClass
                 $unMeetSendCost = (float)$cached;
             }
         }
+
+        // redis 丢失回退:送货/物流/快递的 sendCost 基本就是未达门槛附加运费(无跑腿距离费)
+        if ($unMeetSendCost <= 0 && !empty($config) && in_array($sendType, [0, 3, 4], true)) {
+            $unMeet = (int)($config['unMeet'] ?? 0);
+            $unMeetFee = (float)($config['unMeetFee'] ?? 0);
+            if ($unMeet === 0 && $unMeetFee > 0 && $orderSendCost > 0) {
+                $unMeetSendCost = $unMeetFee;
+            }
+        }
+
         // 无附加包装费/附加运费时不写入(避免满足门槛的订单误记 skip)
         if ($packingFee <= 0 && $unMeetSendCost <= 0) {
             return;
         }
-        self::recordDailyUnMeetFeeState($customId, $sendType, $packingFee, $unMeetSendCost);
+        self::recordUnMeetFeeLockState($customId, $sendType, $packingFee, $unMeetSendCost, $config);
+    }
+
+    /**
+     * 微信支付换单号时迁移附加运费缓存,避免支付成功读不到金额导致不写冷却锁
+     * @param string $oldOrderSn
+     * @param string $newOrderSn
+     */
+    public static function migrateUnMeetSendCostCache($oldOrderSn, $newOrderSn)
+    {
+        $oldOrderSn = (string)$oldOrderSn;
+        $newOrderSn = (string)$newOrderSn;
+        if ($oldOrderSn === '' || $newOrderSn === '' || $oldOrderSn === $newOrderSn) {
+            return;
+        }
+        $oldKey = 'hd_order_unmeet_send:' . $oldOrderSn;
+        $cached = Yii::$app->redis->executeCommand('GET', [$oldKey]);
+        if ($cached === null || $cached === false || $cached === '') {
+            return;
+        }
+        $ttl = Yii::$app->redis->executeCommand('TTL', [$oldKey]);
+        $ttl = (int)$ttl;
+        if ($ttl <= 0) {
+            $ttl = 864000;
+        }
+        $newKey = 'hd_order_unmeet_send:' . $newOrderSn;
+        Yii::$app->redis->executeCommand('SETEX', [$newKey, $ttl, (string)$cached]);
     }
 
     /**
@@ -766,7 +1146,7 @@ class PsMethodClass extends BaseClass
     }
 
     /**
-     * 标记当日附加费是否已决策(供商城 getAllMethod 下发锁定态
+     * 标记附加费是否仍在锁定期(供商城 getAllMethod 下发锁定态;兼容自然日与冷却秒
      * @param array $configs 配送方式列表
      * @param int $customId 花店客户 id
      * @return array
@@ -780,8 +1160,8 @@ class PsMethodClass extends BaseClass
         foreach ($configs as &$config) {
             $style = (int)($config['style'] ?? 0);
             $unMeet = (int)($config['unMeet'] ?? 0);
-            $hasSendDecision = self::hasSendFeeDecisionToday($customId, $style);
-            $hasPackDecision = self::hasPackFeeDecisionToday($customId, $style);
+            $hasSendDecision = self::isUnMeetSendFeeLocked($customId, $style, $config);
+            $hasPackDecision = self::isUnMeetPackFeeLocked($customId, $style, $config);
             $config['todaySendFeeLocked'] = $hasSendDecision ? 1 : 0;
             $config['todayPackFeeLocked'] = $hasPackDecision ? 1 : 0;
             if ($unMeet === 0 && $hasSendDecision) {
@@ -839,8 +1219,8 @@ class PsMethodClass extends BaseClass
                 $canOrder = false;
                 $tip = $conditionText !== '' ? "订单不满{$conditionText},不能下单" : '未达到最低消费,不能下单';
             } elseif (($unMeet === 0 || $unMeet === 2) && $unMeetFee > 0) {
-                $locked = ($unMeet === 0 && self::hasSendFeeDecisionToday($customId, $style))
-                    || ($unMeet === 2 && self::hasPackFeeDecisionToday($customId, $style));
+                $locked = ($unMeet === 0 && self::isUnMeetSendFeeLocked($customId, $style, $config))
+                    || ($unMeet === 2 && self::isUnMeetPackFeeLocked($customId, $style, $config));
                 if (!$locked) {
                     $feeLabel = $unMeet === 2 ? '包装费' : '运费';
                     // 展示还差多少可免附加费,便于用户凑单
@@ -926,10 +1306,10 @@ class PsMethodClass extends BaseClass
             return $costs;
         }
 
-        // unMeet: 0 加收运费,2 加收包装费(每客户每购买方式每日至多收一次;首单未收则当日不再收
-        if ($unMeet === 0 && !self::hasSendFeeDecisionToday($customId, $style)) {
+        // unMeet: 0 加收运费,2 加收包装费;锁定策略见 unMeetLockType(自然日或冷却秒
+        if ($unMeet === 0 && !self::isUnMeetSendFeeLocked($customId, $style, $config)) {
             $costs['sendCost'] = $unMeetFee;
-        } elseif ($unMeet === 2 && !self::hasPackFeeDecisionToday($customId, $style)) {
+        } elseif ($unMeet === 2 && !self::isUnMeetPackFeeLocked($customId, $style, $config)) {
             $costs['packCost'] = $unMeetFee;
         }
 
@@ -979,10 +1359,10 @@ class PsMethodClass extends BaseClass
 
         if (($unMeet === 0 || $unMeet === 2) && $unMeetFee > 0) {
             $feeToCheck = $unMeet === 2 ? $packCost : $unMeetSendCost;
-            if ($unMeet === 2 && self::hasPackFeeDecisionToday($customId, $style)) {
+            if ($unMeet === 2 && self::isUnMeetPackFeeLocked($customId, $style, $config)) {
                 return '';
             }
-            if ($unMeet === 0 && self::hasSendFeeDecisionToday($customId, $style)) {
+            if ($unMeet === 0 && self::isUnMeetSendFeeLocked($customId, $style, $config)) {
                 return '';
             }
             if (bccomp((string)$feeToCheck, (string)$unMeetFee, 2) < 0) {

+ 11 - 0
common/components/dict.php

@@ -20,6 +20,9 @@ class dict
                 'minNum' => 0,
                 'unMeet' => 0,
                 'unMeetFee' => 0.00,
+                // 附加费重收:0 按自然日;1 按冷却秒数(默认 1800=0.5 小时)
+                'unMeetLockType' => 0,
+                'unMeetLockSeconds' => 1800,
                 'startKm' => 0.00,
                 'startPrice' => 0.00,
                 'perKmPrice' => 0.00,
@@ -35,6 +38,8 @@ class dict
                 'minNum' => 0,
                 'unMeet' => 0,
                 'unMeetFee' => 0.00,
+                'unMeetLockType' => 0,
+                'unMeetLockSeconds' => 1800,
                 'startKm' => 0.00,
                 'startPrice' => 0.00,
                 'perKmPrice' => 0.00,
@@ -50,6 +55,8 @@ class dict
                 'minNum' => 0,
                 'unMeet' => 0,
                 'unMeetFee' => 0.00,
+                'unMeetLockType' => 0,
+                'unMeetLockSeconds' => 1800,
                 'startKm' => 0.00,
                 'startPrice' => 0.00,
                 'perKmPrice' => 0.00,
@@ -65,6 +72,8 @@ class dict
                 'minNum' => 0,
                 'unMeet' => 0,
                 'unMeetFee' => 0.00,
+                'unMeetLockType' => 0,
+                'unMeetLockSeconds' => 1800,
                 'startKm' => 0.00,
                 'startPrice' => 0.00,
                 'perKmPrice' => 0.00,
@@ -80,6 +89,8 @@ class dict
                 'minNum' => 0,
                 'unMeet' => 0,
                 'unMeetFee' => 0.00,
+                'unMeetLockType' => 0,
+                'unMeetLockSeconds' => 1800,
                 'startKm' => 0.00,
                 'startPrice' => 0.00,
                 'perKmPrice' => 0.00,

+ 4 - 0
sql/20260706_redesign.sql

@@ -478,3 +478,7 @@ WHERE NOT EXISTS (
     SELECT 1 FROM xhUserAddress a WHERE a.userId = c.userId
 );
 COMMIT;
+
+ALTER TABLE `xhPsMethod`
+    ADD COLUMN `unMeetLockType` tinyint(3) unsigned NOT NULL DEFAULT 0 COMMENT '附加费重收:0按自然日 1按冷却秒数' AFTER `unMeetFee`,
+  ADD COLUMN `unMeetLockSeconds` int(11) unsigned NOT NULL DEFAULT 1800 COMMENT '冷却秒数,仅 unMeetLockType=1 生效,默认0.5小时' AFTER `unMeetLockType`;