PsMethodClass.php 61 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555
  1. <?php
  2. /**
  3. * 用途:花店配送方式配置业务逻辑类
  4. * 谁用:花店系统 hdApp 配送方式设置页、商城混合结算/团购开团参团附加费预览与跑腿计费
  5. * 解决什么问题:读写 xhPsMethod / xhPsExplain / xhPsReduceRule;附加费支持按自然日或冷却秒重收;混合单与团购单共用跑腿距离运费
  6. */
  7. namespace bizHd\order\classes;
  8. use bizGhs\express\classes\DeliveryAuthTokenClass;
  9. use bizHd\base\classes\BaseClass;
  10. use common\components\delivery\util\DeliveryQuoteUtil;
  11. use common\components\dict;
  12. use common\components\mapUtil;
  13. use common\components\noticeUtil;
  14. use common\components\util;
  15. use Yii;
  16. class PsMethodClass extends BaseClass
  17. {
  18. public static $baseFile = '\bizHd\order\models\PsMethod';
  19. /**
  20. * 获取所有配送方式的排序和别名信息
  21. * @param int $mainId 商户ID
  22. * @return array
  23. */
  24. public static function getSorts($mainId)
  25. {
  26. $list = [];
  27. $aliases = [
  28. 0 => '送货',
  29. 1 => '自取',
  30. 2 => '跑腿',
  31. 3 => '物流',
  32. 4 => '快递'
  33. ];
  34. for ($style = 0; $style <= 4; $style++) {
  35. $config = self::getByCondition(['mainId' => $mainId, 'style' => $style]);
  36. $sort = 0;
  37. $alias = $aliases[$style];
  38. $status = 1;
  39. if (!empty($config)) {
  40. $sort = isset($config['sort']) ? (int)$config['sort'] : 0;
  41. $alias = !empty($config['name']) ? $config['name'] : $aliases[$style];
  42. $status = isset($config['status']) ? (int)$config['status'] : 1;
  43. }
  44. $list[] = [
  45. 'style' => $style,
  46. 'name' => $alias,
  47. 'sort' => $sort,
  48. 'status' => $status
  49. ];
  50. }
  51. return $list;
  52. }
  53. /**
  54. * 获取指定配送方式的配置(不存在则按字典 psMethod 默认初始化)
  55. * @param int $shopId 门店ID
  56. * @param int $mainId 商户ID
  57. * @param int $style 配送类型 0-4
  58. * @return array
  59. */
  60. public static function getConfig($shopId, $mainId, $style)
  61. {
  62. // 无记录时按 dict.psMethod 写入:新店默认仅跑腿(自定义)+自取启用
  63. $config = self::getByCondition(['mainId' => $mainId, 'style' => $style]);
  64. if (empty($config)) {
  65. $defaultConfig = dict::getDict('psMethod', $style);
  66. $initData = array_merge([
  67. 'shopId' => $shopId,
  68. 'mainId' => $mainId,
  69. 'style' => $style,
  70. ], $defaultConfig);
  71. $config = self::add($initData, true);
  72. $config = $config->toArray();
  73. }
  74. // 关联说明项 xhPsExplain
  75. $config['explains'] = PsExplainClass::getAllByCondition(
  76. ['methodId' => $config['id']],
  77. 'sort ASC, id ASC'
  78. );
  79. // 跑腿 style=2 时加载满减规则 xhPsReduceRule
  80. if ($style == 2) {
  81. $config['reduceRules'] = PsReduceRuleClass::getAllByCondition(
  82. ['methodId' => $config['id']],
  83. 'sort ASC, id ASC'
  84. );
  85. } else {
  86. $config['reduceRules'] = [];
  87. }
  88. return $config;
  89. }
  90. /**
  91. * 新店开店时初始化五种 xhPsMethod(按 dict.psMethod,仅缺的 style 才插入)
  92. * 谁用:ShopClass::addShop;保证注册当下落库,不必等首次打开配送设置
  93. * @param int $shopId
  94. * @param int $mainId
  95. * @return void
  96. */
  97. public static function initDefaultMethods($shopId, $mainId)
  98. {
  99. $shopId = (int)$shopId;
  100. $mainId = (int)$mainId;
  101. if ($shopId <= 0 || $mainId <= 0) {
  102. return;
  103. }
  104. self::getAllConfigs($shopId, $mainId);
  105. }
  106. /**
  107. * 客户 homeRule=1 时,用 xhCustom/xhHd 上的送货上门字段覆盖 style=0 配置
  108. * @param array $config xhPsMethod 送货配置
  109. * @param array $customInfo 客户信息(含 home、homeRule、homeAmount 等)
  110. * @return array
  111. */
  112. public static function applyCustomHomeRule($config, $customInfo)
  113. {
  114. if (empty($config) || empty($customInfo)) {
  115. return $config;
  116. }
  117. if (!isset($customInfo['homeRule']) || (int)$customInfo['homeRule'] !== 1) {
  118. return $config;
  119. }
  120. $config['status'] = isset($customInfo['home']) ? (int)$customInfo['home'] : ($config['status'] ?? 1);
  121. $config['minAmount'] = $customInfo['homeAmount'] ?? ($config['minAmount'] ?? 0);
  122. $config['minNum'] = $customInfo['homeNum'] ?? ($config['minNum'] ?? 0);
  123. $config['unMeet'] = $customInfo['homeUnMeet'] ?? ($config['unMeet'] ?? 0);
  124. $config['unMeetFee'] = $customInfo['homeUnFee'] ?? ($config['unMeetFee'] ?? 0);
  125. return $config;
  126. }
  127. /**
  128. * 一次性获取商户全部配送方式配置(含 xhPsExplain / xhPsReduceRule)
  129. * 缺哪种 style 就按 dict.psMethod 补齐写入 xhPsMethod
  130. * @param int $shopId 门店ID
  131. * @param int $mainId 商户ID
  132. * @return array
  133. */
  134. public static function getAllConfigs($shopId, $mainId)
  135. {
  136. $configsMap = self::getAllByCondition(['mainId' => $mainId], 'sort asc', '*', 'style', true);
  137. // 补齐缺失 style:新店默认仅跑腿+自取启用,见 dict.psMethod
  138. for ($style = 0; $style <= 4; $style++) {
  139. if (!isset($configsMap[$style])) {
  140. $defaultConfig = dict::getDict('psMethod', $style);
  141. $initData = array_merge([
  142. 'shopId' => $shopId,
  143. 'mainId' => $mainId,
  144. 'style' => $style,
  145. ], $defaultConfig);
  146. $configObj = self::add($initData, true);
  147. $configsMap[$style] = $configObj;
  148. }
  149. }
  150. $configsArray = [];
  151. $methodIds = [];
  152. foreach ($configsMap as $style => $configObj) {
  153. $configArr = $configObj instanceof \yii\db\ActiveRecord ? $configObj->toArray() : $configObj;
  154. $configsArray[$style] = $configArr;
  155. $methodIds[] = (int)$configArr['id'];
  156. }
  157. $allExplains = [];
  158. if (!empty($methodIds)) {
  159. $allExplains = PsExplainClass::getAllByCondition(['methodId' => ['in', $methodIds]], 'sort ASC, id ASC');
  160. }
  161. $explainsMap = [];
  162. foreach ($allExplains as $exp) {
  163. $explainsMap[(int)$exp['methodId']][] = $exp;
  164. }
  165. $allReduceRules = [];
  166. if (!empty($methodIds)) {
  167. $allReduceRules = PsReduceRuleClass::getAllByCondition(['methodId' => ['in', $methodIds]], 'sort ASC, id ASC');
  168. }
  169. $reduceRulesMap = [];
  170. foreach ($allReduceRules as $rule) {
  171. $reduceRulesMap[(int)$rule['methodId']][] = $rule;
  172. }
  173. $result = [];
  174. for ($style = 0; $style <= 4; $style++) {
  175. $config = $configsArray[$style];
  176. $methodId = (int)$config['id'];
  177. $config['explains'] = $explainsMap[$methodId] ?? [];
  178. $config['reduceRules'] = ($style == 2) ? ($reduceRulesMap[$methodId] ?? []) : [];
  179. $result[] = $config;
  180. }
  181. usort($result, function ($a, $b) {
  182. $sortCompare = (int)($a['sort'] ?? 0) <=> (int)($b['sort'] ?? 0);
  183. if ($sortCompare !== 0) {
  184. return $sortCompare;
  185. }
  186. return (int)($a['style'] ?? 0) <=> (int)($b['style'] ?? 0);
  187. });
  188. return $result;
  189. }
  190. /**
  191. * 自定义计费三项是否均已有效填写(起步公里/起步价/超出每公里价均大于 0)
  192. * @param array $config
  193. * @return bool
  194. */
  195. public static function isCustomConfigValid(array $config)
  196. {
  197. $startKm = (float)($config['startKm'] ?? 0);
  198. $startPrice = (float)($config['startPrice'] ?? 0);
  199. $perKmPrice = (float)($config['perKmPrice'] ?? 0);
  200. return $startKm > 0 && $startPrice > 0 && $perKmPrice > 0;
  201. }
  202. /**
  203. * 跑腿 style=2 是否允许启用:已绑定跑腿 或 自定义计费配置完整
  204. * @param int $mainId 商户 ID
  205. * @param array $config 待保存的 xhPsMethod 配置
  206. * @return bool
  207. */
  208. public static function canEnableRunnerMethod($mainId, array $config)
  209. {
  210. if (self::isCustomConfigValid($config)) {
  211. return true;
  212. }
  213. $hasAuth = DeliveryAuthTokenClass::getByCondition(['mainId' => (int)$mainId], true);
  214. return !empty($hasAuth);
  215. }
  216. /**
  217. * 按自定义规则计距算运费(公里)
  218. * 超出起步部分:不足 1 公里按 1 公里(向上取整)
  219. * @param array $config xhPsMethod
  220. * @param float $distanceKm 配送距离(公里)
  221. * @return float
  222. * @throws \Exception
  223. */
  224. public static function calcCustomFreightByDistance(array $config, $distanceKm)
  225. {
  226. $startKm = (float)($config['startKm'] ?? 0);
  227. $startPrice = (float)($config['startPrice'] ?? 0);
  228. $perKmPrice = (float)($config['perKmPrice'] ?? 0);
  229. $changeRate = (float)($config['changeRate'] ?? 0);
  230. if ($startKm <= 0 || $startPrice <= 0 || $perKmPrice <= 0) {
  231. throw new \Exception('自定义计费规则未配置完整');
  232. }
  233. // 与前端一致保留两位公里
  234. $distanceKm = round((float)$distanceKm, 2);
  235. if ($distanceKm <= 0) {
  236. throw new \Exception('算运费失败,距离无效');
  237. }
  238. $sendCost = $startPrice;
  239. if ($distanceKm > $startKm) {
  240. $extraDistance = (float)bcsub((string)$distanceKm, (string)$startKm, 2);
  241. // 不足 1 公里按 1 公里
  242. $billableExtra = (int)ceil($extraDistance - 1e-9);
  243. if ($billableExtra < 0) {
  244. $billableExtra = 0;
  245. }
  246. $extraCost = bcmul((string)$billableExtra, (string)$perKmPrice, 2);
  247. $sendCost = bcadd((string)$sendCost, $extraCost, 2);
  248. }
  249. if ($changeRate != 0) {
  250. $rate = bcadd('1', bcdiv((string)$changeRate, '100', 4), 4);
  251. $sendCost = bcmul((string)$sendCost, $rate, 2);
  252. }
  253. return (float)$sendCost;
  254. }
  255. /**
  256. * 花束包运费:hsFreeKm 内免费,超出按 hsPerKmPrice 每公里加收(不足 1 公里按 1 公里)
  257. * @param array $config xhPsMethod
  258. * @param float $distanceMeters 距离(米)
  259. * @return float
  260. */
  261. public static function calcBouquetFreightByDistance(array $config, $distanceMeters)
  262. {
  263. $hsFreeKm = (float)($config['hsFreeKm'] ?? 0);
  264. $hsPerKmPrice = (float)($config['hsPerKmPrice'] ?? 0);
  265. $freeMeters = $hsFreeKm * 1000;
  266. $distanceMeters = (float)$distanceMeters;
  267. if ($distanceMeters <= $freeMeters) {
  268. return 0.0;
  269. }
  270. $diff = $distanceMeters - $freeMeters;
  271. $km = (int)ceil($diff / 1000);
  272. if ($km <= 0 || $hsPerKmPrice <= 0) {
  273. return 0.0;
  274. }
  275. return (float)bcmul((string)$km, (string)$hsPerKmPrice, 2);
  276. }
  277. /**
  278. * 纯花材是否命中 xhPsReduceRule 免跑腿
  279. * @param array $config 含 reduceRules
  280. * @param float $distanceMeters
  281. * @param float $bigNum 花材扎数
  282. * @param float $itemTotalAmount 商品金额
  283. * @return bool
  284. */
  285. public static function matchReduceRuleFree(array $config, $distanceMeters, $bigNum, $itemTotalAmount)
  286. {
  287. $rules = $config['reduceRules'] ?? [];
  288. if (!is_array($rules) || empty($rules)) {
  289. return false;
  290. }
  291. $distanceMeters = (float)$distanceMeters;
  292. $bigNum = (float)$bigNum;
  293. $itemTotalAmount = (float)$itemTotalAmount;
  294. foreach ($rules as $rule) {
  295. $meetNum = (float)($rule['meetNum'] ?? 0);
  296. $meetAmount = (float)($rule['meetAmount'] ?? 0);
  297. $freeKm = (float)($rule['freeKm'] ?? 0);
  298. if ($freeKm <= 0) {
  299. continue;
  300. }
  301. $freeMeters = $freeKm * 1000;
  302. $numOk = $meetNum <= 0 || $bigNum >= $meetNum;
  303. $amountOk = $meetAmount <= 0 || $itemTotalAmount >= $meetAmount;
  304. if ($numOk && $amountOk && $distanceMeters <= $freeMeters) {
  305. return true;
  306. }
  307. }
  308. return false;
  309. }
  310. /**
  311. * 混合单跑腿运费模式:bouquetIncluded / bouquetNotIncluded / flowerOnly
  312. * @param array $productList [['property'=>0|1,'freightType'=>0|1], ...]
  313. * @return string
  314. */
  315. public static function resolveMixRunnerFreightMode(array $productList)
  316. {
  317. $hasBouquet = false;
  318. $hasIncluded = false;
  319. foreach ($productList as $row) {
  320. if ((int)($row['property'] ?? 1) !== 0) {
  321. continue;
  322. }
  323. $hasBouquet = true;
  324. if ((int)($row['freightType'] ?? 0) === 1) {
  325. $hasIncluded = true;
  326. }
  327. }
  328. if ($hasBouquet && $hasIncluded) {
  329. return 'bouquetIncluded';
  330. }
  331. if ($hasBouquet) {
  332. return 'bouquetNotIncluded';
  333. }
  334. return 'flowerOnly';
  335. }
  336. /**
  337. * 商城混合单/团购单共用:计算跑腿距离运费(不含未达门槛附加运费)
  338. * 谁用:OrderController::actionCreateMixOrder、GroupBuyController::createGroupBuyOrder
  339. * 解决:包运费花束 / 不包运费花束 / 纯花材满减 + 平台报价或自定义(异常兜底),两处口径必须一致
  340. *
  341. * @param object $shop 门店(需 lat/long)
  342. * @param int $shopId 门店 ID
  343. * @param int $mainId 商户 ID
  344. * @param array $post 下单字段:收货坐标、deliveryPlatform、deliveryBracketContent、remark
  345. * @param array $resolvedProduct 已核价商品行,需含 property、freightType
  346. * @param float $itemBigNum 花材扎数(纯花材满减用,团购花束传 0)
  347. * @param float $goodsOnlyPrice 纯商品金额(满减门槛、平台报价用)
  348. * @param int $openIntraCity ShopClass::hasIntraCity 的 0/1/2
  349. * @param int $quoteGoodsType 平台报价商品类型:0 花束 1 花材
  350. * @param float $totalNum 商品总数量
  351. * @param string $quoteOrderPrefix 平台询价临时单号前缀,区分混合单/团购单
  352. * @param string $quoteSceneLabel 报价异常通知文案场景,如「混合单」「团购单」
  353. * @return array ['sendCost'=>float,'sendDistance'=>float,'freightSource'=>string]
  354. */
  355. public static function calcMixRunnerFreight(
  356. $shop,
  357. $shopId,
  358. $mainId,
  359. array $post,
  360. array $resolvedProduct,
  361. $itemBigNum,
  362. $goodsOnlyPrice,
  363. $openIntraCity,
  364. $quoteGoodsType,
  365. $totalNum,
  366. $quoteOrderPrefix = 'PT-MIX-',
  367. $quoteSceneLabel = '混合单'
  368. ) {
  369. if ((int)$openIntraCity === 2) {
  370. util::fail('不能使用跑腿');
  371. }
  372. if (empty($post['long']) || empty($post['lat'])) {
  373. util::fail('位置错误');
  374. }
  375. $shopLat = $shop->lat ?? '';
  376. $shopLong = $shop->long ?? '';
  377. if (empty($shopLat) || empty($shopLong)) {
  378. util::fail('商家门店地址定位未设置,编号263');
  379. }
  380. $psConfig = self::getConfig($shopId, $mainId, 2);
  381. if (empty($psConfig) || (int)($psConfig['status'] ?? 0) !== 1) {
  382. util::fail('跑腿配送方式未启用');
  383. }
  384. $freightMode = self::resolveMixRunnerFreightMode($resolvedProduct);
  385. $calcType = (int)($psConfig['calcType'] ?? 0);
  386. $quoteCustom = (object)[
  387. 'name' => $post['receiveUserName'] ?? '',
  388. 'mobile' => $post['receiveMobile'] ?? '',
  389. 'fullAddress' => $post['address'] ?? ($post['fullAddress'] ?? ''),
  390. 'floor' => $post['floor'] ?? '',
  391. 'dist' => $post['dist'] ?? '',
  392. 'lat' => $post['lat'],
  393. 'long' => $post['long'],
  394. 'address' => $post['address'] ?? '',
  395. 'city' => $post['city'] ?? '',
  396. ];
  397. $distanceRespond = mapUtil::calcShopDistanceByEleBike($quoteCustom, $shop, 0);
  398. $distanceMeters = (float)($distanceRespond['distance'] ?? 0);
  399. $distanceKm = (float)($distanceRespond['distanceKm'] ?? 0);
  400. if ($distanceMeters <= 0 && $distanceKm <= 0) {
  401. util::fail('算运费失败,请完善收货地址');
  402. }
  403. if ($distanceMeters <= 0 && $distanceKm > 0) {
  404. $distanceMeters = $distanceKm * 1000;
  405. }
  406. $sendCost = 0;
  407. $freightSource = '';
  408. if ($freightMode === 'bouquetIncluded') {
  409. // 含包运费花束:按 hsFreeKm / hsPerKmPrice,不再询跑腿平台
  410. $sendCost = self::calcBouquetFreightByDistance($psConfig, $distanceMeters);
  411. $freightSource = 'bouquetIncluded';
  412. } elseif ($freightMode === 'flowerOnly'
  413. && self::matchReduceRuleFree($psConfig, $distanceMeters, $itemBigNum, $goodsOnlyPrice)) {
  414. // 纯花材命中满减:免跑腿
  415. $sendCost = 0;
  416. $freightSource = 'reduceFree';
  417. } else {
  418. // 不包运费花束,或未满减的纯花材:calcType=1 自定义,否则平台报价、异常自定义兜底
  419. if ($calcType === 1) {
  420. try {
  421. $sendCost = self::calcCustomFreightByDistance(
  422. $psConfig,
  423. $distanceKm > 0 ? $distanceKm : ($distanceMeters / 1000)
  424. );
  425. $freightSource = 'custom';
  426. } catch (\Exception $e) {
  427. util::fail($e->getMessage());
  428. }
  429. } else {
  430. $runnerOk = false;
  431. if ((int)$openIntraCity === 1 && !empty($post['deliveryPlatform'])) {
  432. $prefix = $quoteOrderPrefix . $mainId . '-';
  433. $orderSn = $prefix . round(microtime(true) * 1000);
  434. $ghsInfo = ['mainId' => $mainId, 'shopId' => $shopId];
  435. try {
  436. $quoteResult = DeliveryQuoteUtil::getDeliveryQuote([
  437. 'productList' => $resolvedProduct,
  438. 'deliveryPlatform' => $post['deliveryPlatform'],
  439. 'deliveryBracketContent' => $post['deliveryBracketContent'] ?? '',
  440. 'ghsInfo' => $ghsInfo,
  441. 'custom' => $quoteCustom,
  442. 'order' => [
  443. 'orderSn' => $orderSn,
  444. 'goodsType' => $quoteGoodsType,
  445. 'itemTotalAmount' => $goodsOnlyPrice,
  446. 'remark' => $post['remark'] ?? '',
  447. ],
  448. 'mainId' => $mainId,
  449. 'productCount' => $totalNum,
  450. 'shConfig' => $psConfig,
  451. ]);
  452. $sendCost = $quoteResult['sendCost'];
  453. $distanceMeters = (float)($quoteResult['sendDistance'] ?? $distanceMeters);
  454. $freightSource = 'platform';
  455. $runnerOk = true;
  456. } catch (\Exception $e) {
  457. $runnerOk = false;
  458. noticeUtil::push('商城' . $quoteSceneLabel . '跑腿报价异常,改走自定义:' . $e->getMessage());
  459. }
  460. }
  461. if (!$runnerOk) {
  462. try {
  463. $sendCost = self::calcCustomFreightByDistance(
  464. $psConfig,
  465. $distanceKm > 0 ? $distanceKm : ($distanceMeters / 1000)
  466. );
  467. $freightSource = 'customFallback';
  468. } catch (\Exception $e) {
  469. util::fail('跑腿计算异常且自定义计费不可用,请更换配送方式');
  470. }
  471. }
  472. }
  473. }
  474. return [
  475. 'sendCost' => $sendCost,
  476. 'sendDistance' => $distanceMeters,
  477. 'freightSource' => $freightSource,
  478. ];
  479. }
  480. /**
  481. * 跑腿保存/启用前校验,不通过时抛异常
  482. * @param int $mainId 商户 ID
  483. * @param array $data 前端提交的配置
  484. * @throws \Exception
  485. */
  486. public static function validateRunnerSave($mainId, array $data)
  487. {
  488. $style = (int)($data['style'] ?? 0);
  489. if ($style !== 2) {
  490. return;
  491. }
  492. $status = (int)($data['status'] ?? 1);
  493. // 无论 calcType=0 用跑腿还是 =1 用自定义,都必须填写自定义计费(跑腿异常时兜底)
  494. if (!self::isCustomConfigValid($data)) {
  495. throw new \Exception('请完整填写自定义计费规则(起步公里、起步价、超出每公里价)');
  496. }
  497. if ($status === 1 && !self::canEnableRunnerMethod($mainId, $data)) {
  498. throw new \Exception('请先绑定跑腿商户或配置完整的自定义计费后再启用');
  499. }
  500. }
  501. /**
  502. * 取消全部跑腿授权后:若自定义计费也不完整则禁用 style=2;
  503. * 自定义完整则保持启用(calcType=1 主路径,或 calcType=0 跑腿异常时兜底)
  504. * @param int $mainId 商户 ID
  505. */
  506. public static function disableRunnerIfNoAuthLeft($mainId)
  507. {
  508. $remaining = DeliveryAuthTokenClass::getCount(['mainId' => (int)$mainId]);
  509. if ($remaining > 0) {
  510. return;
  511. }
  512. $ps = self::getByCondition(['mainId' => (int)$mainId, 'style' => 2]);
  513. if (empty($ps)) {
  514. return;
  515. }
  516. $psArr = is_object($ps) ? $ps->toArray() : $ps;
  517. // 仍有完整自定义配置时不禁用,结算可走自定义或兜底
  518. if (self::isCustomConfigValid($psArr)) {
  519. return;
  520. }
  521. self::updateByCondition(['id' => (int)$psArr['id']], ['status' => 0]);
  522. }
  523. /**
  524. * 保存指定配送方式配置及关联说明项、满减规则
  525. * @param int $shopId 门店ID
  526. * @param int $mainId 商户ID
  527. * @param array $data 配置数据
  528. * @return bool
  529. * @throws \Exception
  530. */
  531. public static function saveConfig($shopId, $mainId, $data)
  532. {
  533. $id = $data['id'] ?? 0;
  534. $style = $data['style'] ?? 0;
  535. self::validateRunnerSave($mainId, $data);
  536. $config = null;
  537. if ($id > 0) {
  538. $config = self::getByCondition(['id' => $id, 'mainId' => $mainId], true);
  539. }
  540. if (empty($config)) {
  541. $config = self::getByCondition(['mainId' => $mainId, 'style' => $style], true);
  542. }
  543. $saveData = [
  544. 'name' => $data['name'] ?? '',
  545. 'status' => isset($data['status']) ? (int)$data['status'] : 1,
  546. 'sort' => isset($data['sort']) ? (int)$data['sort'] : 0,
  547. 'minAmount' => isset($data['minAmount']) ? (float)$data['minAmount'] : 0.00,
  548. 'minNum' => isset($data['minNum']) ? (int)$data['minNum'] : 0,
  549. 'unMeet' => isset($data['unMeet']) ? (int)$data['unMeet'] : 0,
  550. 'unMeetFee' => isset($data['unMeetFee']) ? (float)$data['unMeetFee'] : 0.00,
  551. // 附加费重收:0 自然日;1 冷却秒(配置页按小时录入后换算)
  552. 'unMeetLockType' => (isset($data['unMeetLockType']) && (int)$data['unMeetLockType'] === 1) ? 1 : 0,
  553. 'unMeetLockSeconds' => self::normalizeUnMeetLockSeconds($data['unMeetLockSeconds'] ?? 1800),
  554. 'calcType' => isset($data['calcType']) ? (int)$data['calcType'] : 0,
  555. 'startKm' => isset($data['startKm']) ? (float)$data['startKm'] : 0.00,
  556. 'startPrice' => isset($data['startPrice']) ? (float)$data['startPrice'] : 0.00,
  557. 'perKmPrice' => isset($data['perKmPrice']) ? (float)$data['perKmPrice'] : 0.00,
  558. 'changeRate' => isset($data['changeRate']) ? (float)$data['changeRate'] : 0.00,
  559. 'hsFreeKm' => isset($data['hsFreeKm']) ? (float)$data['hsFreeKm'] : 0.00,
  560. 'hsPerKmPrice' => isset($data['hsPerKmPrice']) ? (float)$data['hsPerKmPrice'] : 0.00,
  561. ];
  562. $transaction = Yii::$app->db->beginTransaction();
  563. try {
  564. if (empty($config)) {
  565. $saveData['shopId'] = $shopId;
  566. $saveData['mainId'] = $mainId;
  567. $saveData['style'] = $style;
  568. $defaultConfig = dict::getDict('psMethod', $style);
  569. if (!empty($defaultConfig['originName'])) {
  570. $saveData['originName'] = $defaultConfig['originName'];
  571. }
  572. $config = self::add($saveData, true);
  573. } else {
  574. self::updateByCondition(['id' => $config->id], $saveData);
  575. }
  576. $methodId = $config->id;
  577. // 1. 保存说明项 xhPsExplain
  578. PsExplainClass::deleteByCondition(['methodId' => $methodId]);
  579. if (!empty($data['explains']) && is_array($data['explains'])) {
  580. $explainRows = [];
  581. foreach ($data['explains'] as $index => $exp) {
  582. if (empty($exp['explain'])) {
  583. continue;
  584. }
  585. $explainRows[] = [
  586. 'methodId' => $methodId,
  587. 'explain' => $exp['explain'],
  588. 'color' => isset($exp['color']) ? (int)$exp['color'] : 1,
  589. 'fontWeight' => isset($exp['fontWeight']) ? (int)$exp['fontWeight'] : 1,
  590. 'sort' => isset($exp['sort']) ? (int)$exp['sort'] : $index,
  591. ];
  592. }
  593. if (!empty($explainRows)) {
  594. PsExplainClass::batchAdd($explainRows);
  595. }
  596. }
  597. // 2. 保存跑腿满减规则 xhPsReduceRule
  598. PsReduceRuleClass::deleteByCondition(['methodId' => $methodId]);
  599. if ($style == 2 && !empty($data['reduceRules']) && is_array($data['reduceRules'])) {
  600. $ruleRows = [];
  601. foreach ($data['reduceRules'] as $index => $rule) {
  602. $ruleRows[] = [
  603. 'methodId' => $methodId,
  604. 'meetNum' => isset($rule['meetNum']) ? (int)$rule['meetNum'] : 0,
  605. 'meetAmount' => isset($rule['meetAmount']) ? (float)$rule['meetAmount'] : 0.00,
  606. 'freeKm' => isset($rule['freeKm']) ? (float)$rule['freeKm'] : 0.00,
  607. 'sort' => isset($rule['sort']) ? (int)$rule['sort'] : $index,
  608. ];
  609. }
  610. if (!empty($ruleRows)) {
  611. PsReduceRuleClass::batchAdd($ruleRows);
  612. }
  613. }
  614. $transaction->commit();
  615. return true;
  616. } catch (\Exception $e) {
  617. $transaction->rollBack();
  618. throw $e;
  619. }
  620. }
  621. /**
  622. * 合并客户送货上门单独规则(style=0 且 homeRule=1)
  623. * @param array $config xhPsMethod 配置
  624. * @param int $style 配送方式
  625. * @param array $customInfo 客户+花店关系信息
  626. * @return array
  627. */
  628. protected static function mergeCustomHomeRule($config, $style, $customInfo)
  629. {
  630. if ((int)$style === 0 && !empty($customInfo)) {
  631. return self::applyCustomHomeRule($config, $customInfo);
  632. }
  633. return $config;
  634. }
  635. /**
  636. * 判断当前订单是否未达最低消费
  637. * 业务规则:已配置的最低金额、最低数量须同时满足才算达标;任一未达(填 0 表示不限)即收附加费/限制下单
  638. * 供 mall affirmMix 预览、calcFee、checkLimit 共用
  639. */
  640. public static function isBelowMinimum($itemTotalAmount, $bigNum, $config)
  641. {
  642. $minAmount = isset($config['minAmount']) ? (float)$config['minAmount'] : 0.00;
  643. $minNum = isset($config['minNum']) ? (int)$config['minNum'] : 0;
  644. if ($minAmount <= 0 && $minNum <= 0) {
  645. return false;
  646. }
  647. // 金额、数量各自「未配置则视为该项通过」;两项都通过才不算低于最低消费
  648. $amountOk = $minAmount <= 0 || (float)$itemTotalAmount >= $minAmount;
  649. $numOk = $minNum <= 0 || (float)$bigNum >= $minNum;
  650. return !($amountOk && $numOk);
  651. }
  652. /**
  653. * 拼接最低消费门槛文案,如 200元和30扎(同时满足)
  654. */
  655. protected static function buildUnMeetConditionText($config)
  656. {
  657. $parts = [];
  658. $minAmount = isset($config['minAmount']) ? (float)$config['minAmount'] : 0.00;
  659. $minNum = isset($config['minNum']) ? (int)$config['minNum'] : 0;
  660. if ($minAmount > 0) {
  661. $parts[] = $minAmount . '元';
  662. }
  663. if ($minNum > 0) {
  664. $parts[] = $minNum . '扎';
  665. }
  666. return implode('和', $parts);
  667. }
  668. /**
  669. * 未达门槛时「还差多少可免」提示,如:订单还差20元和3扎可免包装费
  670. * @param array $config
  671. * @param float $itemTotalAmount
  672. * @param float $bigNum
  673. * @param string $feeLabel 包装费|运费
  674. * @return string
  675. */
  676. protected static function buildUnMeetShortTip($config, $itemTotalAmount, $bigNum, $feeLabel)
  677. {
  678. $minAmount = isset($config['minAmount']) ? (float)$config['minAmount'] : 0.00;
  679. $minNum = isset($config['minNum']) ? (int)$config['minNum'] : 0;
  680. $parts = [];
  681. if ($minAmount > 0) {
  682. $shortAmount = round(max(0, $minAmount - (float)$itemTotalAmount), 2);
  683. if ($shortAmount > 0) {
  684. $parts[] = $shortAmount . '元';
  685. }
  686. }
  687. if ($minNum > 0) {
  688. $shortNum = max(0, $minNum - (float)$bigNum);
  689. // 扎数展示取整友好值
  690. if ($shortNum > 0) {
  691. $parts[] = (fmod($shortNum, 1) == 0 ? (int)$shortNum : round($shortNum, 2)) . '扎';
  692. }
  693. }
  694. if (empty($parts)) {
  695. return '';
  696. }
  697. return '订单还差' . implode('和', $parts) . '可免' . $feeLabel;
  698. }
  699. /**
  700. * 规范化冷却秒数(至少 1 秒;非法则回退默认 1800)
  701. * @param mixed $seconds
  702. * @return int
  703. */
  704. public static function normalizeUnMeetLockSeconds($seconds)
  705. {
  706. $n = (int)$seconds;
  707. if ($n <= 0) {
  708. return 1800;
  709. }
  710. return $n;
  711. }
  712. /**
  713. * 从 xhPsMethod 配置解析附加费重收策略
  714. * @param array|null $config
  715. * @return array ['type'=>0|1, 'seconds'=>int] type:0自然日 1冷却秒
  716. */
  717. public static function resolveUnMeetLockOptions($config = null)
  718. {
  719. $type = 0;
  720. $seconds = 1800;
  721. if (is_array($config)) {
  722. $type = (int)($config['unMeetLockType'] ?? 0);
  723. $seconds = self::normalizeUnMeetLockSeconds($config['unMeetLockSeconds'] ?? 1800);
  724. }
  725. if ($type !== 1) {
  726. $type = 0;
  727. }
  728. return ['type' => $type, 'seconds' => $seconds];
  729. }
  730. /**
  731. * 按配置判断运费类附加费是否仍在锁定期
  732. * - 间隔模式(type=1):只按「收款时间 + 当前配置秒数」,到期即再收;不看自然日/今日订单
  733. * - 自然日(type=0):当日 Redis 决策 + 间隔痕迹互认 + 今日已付订单兜底
  734. * @param int $customId
  735. * @param int $sendType
  736. * @param array|null $config xhPsMethod 行;缺省按自然日
  737. * @return bool
  738. */
  739. public static function isUnMeetSendFeeLocked($customId, $sendType = 0, $config = null)
  740. {
  741. $customId = (int)$customId;
  742. if ($customId <= 0) {
  743. // 无客户身份无法记锁,每次都会算附加费(与 BaseController 未绑定 hd.customId 时一致)
  744. return false;
  745. }
  746. $lock = self::resolveUnMeetLockOptions($config);
  747. // 间隔:严格按时长,改成 4 秒后过期即应再收,不能被「今日已收」挡住
  748. if ($lock['type'] === 1) {
  749. return self::isSendCostInCooldown($customId, $sendType, $lock['seconds']);
  750. }
  751. if (self::hasSendFeeDecisionToday($customId, $sendType)) {
  752. return true;
  753. }
  754. if (self::hasIntervalSendFeeCollectedToday($customId, $sendType)) {
  755. // 间隔痕迹还在:补写自然日 key,避免后续再丢
  756. self::ensureDaySendFeePaidMarked($customId, $sendType);
  757. return true;
  758. }
  759. return self::hasPaidUnMeetFeeOrderToday($customId, $sendType, 0);
  760. }
  761. /**
  762. * 按配置判断包装类附加费是否仍在锁定期
  763. * @param int $customId
  764. * @param int $sendType
  765. * @param array|null $config
  766. * @return bool
  767. */
  768. public static function isUnMeetPackFeeLocked($customId, $sendType = 0, $config = null)
  769. {
  770. $customId = (int)$customId;
  771. if ($customId <= 0) {
  772. return false;
  773. }
  774. $lock = self::resolveUnMeetLockOptions($config);
  775. if ($lock['type'] === 1) {
  776. return self::isPackCostInCooldown($customId, $sendType, $lock['seconds']);
  777. }
  778. if (self::hasPackFeeDecisionToday($customId, $sendType)) {
  779. return true;
  780. }
  781. if (self::hasIntervalPackFeeCollectedToday($customId, $sendType)) {
  782. self::ensureDayPackFeePaidMarked($customId, $sendType);
  783. return true;
  784. }
  785. return self::hasPaidUnMeetFeeOrderToday($customId, $sendType, 2);
  786. }
  787. /**
  788. * 补写自然日「运费附加费已收」标记
  789. */
  790. protected static function ensureDaySendFeePaidMarked($customId, $sendType)
  791. {
  792. if (self::isSendCostPaidToday($customId, $sendType)) {
  793. return;
  794. }
  795. $key = 'hdCustom_hasGetSendCost:' . (int)$sendType . '_' . date('Y_m_d') . ':' . (int)$customId;
  796. Yii::$app->redis->executeCommand('SETEX', [$key, 86400, '1']);
  797. }
  798. /**
  799. * 补写自然日「包装附加费已收」标记
  800. */
  801. protected static function ensureDayPackFeePaidMarked($customId, $sendType)
  802. {
  803. if (self::isPackPaidToday($customId, $sendType)) {
  804. return;
  805. }
  806. $key = 'hdCustom_hasGetPackCost:' . (int)$sendType . '_' . date('Y_m_d') . ':' . (int)$customId;
  807. Yii::$app->redis->executeCommand('SETEX', [$key, 86400, '1']);
  808. }
  809. /**
  810. * Redis 无锁时:回查今日已支付订单是否已收过对应附加费(修复短间隔逻辑过期删 key / 切换模式丢痕迹)
  811. * @param int $customId
  812. * @param int $sendType
  813. * @param int $unMeetKind 0=运费附加费(送货/物流/快递看 sendCost) 2=包装费(packingFee)
  814. * @return bool
  815. */
  816. protected static function hasPaidUnMeetFeeOrderToday($customId, $sendType, $unMeetKind = 0)
  817. {
  818. $customId = (int)$customId;
  819. $sendType = (int)$sendType;
  820. if ($customId <= 0) {
  821. return false;
  822. }
  823. $start = date('Y-m-d 00:00:00');
  824. $end = date('Y-m-d 23:59:59');
  825. try {
  826. $query = \bizHd\order\models\Order::find()
  827. ->where([
  828. 'customId' => $customId,
  829. 'sendType' => $sendType,
  830. 'payStatus' => 1,
  831. 'fromType' => (int)dict::getDict('fromType', 'mall'),
  832. ])
  833. ->andWhere(['>=', 'payTime', $start])
  834. ->andWhere(['<=', 'payTime', $end]);
  835. if ((int)$unMeetKind === 2) {
  836. $query->andWhere(['>', 'packingFee', 0]);
  837. } elseif (in_array($sendType, [0, 3, 4], true)) {
  838. // 送货/物流/快递:页内 sendCost 基本即未达门槛附加运费
  839. $query->andWhere([
  840. 'or',
  841. ['>', 'packingFee', 0],
  842. ['>', 'sendCost', 0],
  843. ]);
  844. } else {
  845. // 跑腿等:仅包装附加费可明确识别
  846. $query->andWhere(['>', 'packingFee', 0]);
  847. }
  848. $exists = $query->exists();
  849. if ($exists) {
  850. // 回写 Redis,后续 calc-mix-fee-batch 不再打库
  851. if ((int)$unMeetKind === 2) {
  852. self::ensureDayPackFeePaidMarked($customId, $sendType);
  853. } else {
  854. self::ensureDaySendFeePaidMarked($customId, $sendType);
  855. }
  856. }
  857. return $exists;
  858. } catch (\Exception $e) {
  859. Yii::error('hasPaidUnMeetFeeOrderToday failed: ' . $e->getMessage(), __METHOD__);
  860. return false;
  861. }
  862. }
  863. /**
  864. * 冷却 Redis key 最长保留时间(秒):逻辑是否锁定用「收款时间+当前配置间隔」算,
  865. * key 本身多留几天,避免改长间隔时 key 已被 Redis 删掉导致无法按新间隔生效
  866. */
  867. const UNMEET_COOLDOWN_REDIS_KEEP_SECONDS = 604800; // 7 天
  868. /**
  869. * 冷却模式下:运费附加费 Redis key(无日期)
  870. */
  871. protected static function buildSendCostCooldownKey($sendType, $customId)
  872. {
  873. return 'hdCustom_hasGetSendCost:interval:' . (int)$sendType . ':' . (int)$customId;
  874. }
  875. /**
  876. * 冷却模式下:包装附加费 Redis key
  877. */
  878. protected static function buildPackCostCooldownKey($sendType, $customId)
  879. {
  880. return 'hdCustom_hasGetPackCost:interval:' . (int)$sendType . ':' . (int)$customId;
  881. }
  882. /**
  883. * 间隔冷却 key 是否记录了「今天」收过运费附加费(供改成自然日后互认)
  884. * @param int $customId
  885. * @param int $sendType
  886. * @return bool
  887. */
  888. protected static function hasIntervalSendFeeCollectedToday($customId, $sendType)
  889. {
  890. return self::hasIntervalFeeCollectedToday(self::buildSendCostCooldownKey($sendType, $customId));
  891. }
  892. /**
  893. * 间隔冷却 key 是否记录了「今天」收过包装附加费
  894. * @param int $customId
  895. * @param int $sendType
  896. * @return bool
  897. */
  898. protected static function hasIntervalPackFeeCollectedToday($customId, $sendType)
  899. {
  900. return self::hasIntervalFeeCollectedToday(self::buildPackCostCooldownKey($sendType, $customId));
  901. }
  902. /**
  903. * interval key 存在且收款日为今天则视为今日已收过
  904. * @param string $key
  905. * @return bool
  906. */
  907. protected static function hasIntervalFeeCollectedToday($key)
  908. {
  909. $has = Yii::$app->redis->executeCommand('GET', [$key]);
  910. if ($has === null || $has === false || $has === '') {
  911. return false;
  912. }
  913. $val = (int)$has;
  914. // 新格式:收款时间戳落在当天
  915. if ($val > 1000000000) {
  916. return date('Y-m-d', $val) === date('Y-m-d');
  917. }
  918. // 旧格式:key 仍在则保守视为今日已收,避免切换自然日后立刻再收
  919. return true;
  920. }
  921. /**
  922. * 解析冷却 Redis TTL(至少覆盖当前间隔,且不少于 7 天便于改配置后仍能读到收款时间)
  923. * @param int $lockSeconds
  924. * @return int
  925. */
  926. protected static function resolveCooldownRedisTtl($lockSeconds)
  927. {
  928. return max((int)$lockSeconds, self::UNMEET_COOLDOWN_REDIS_KEEP_SECONDS, 1);
  929. }
  930. /**
  931. * 判断间隔冷却是否仍有效(仅用于 unMeetLockType=1)
  932. * value 必须是收款时间戳;按「距收款 < 当前配置秒数」判断,改间隔立即生效
  933. * @param string $key
  934. * @param int $expectedSeconds 当前配置的冷却秒数
  935. * @return bool
  936. */
  937. protected static function isIntervalCooldownActive($key, $expectedSeconds)
  938. {
  939. $has = Yii::$app->redis->executeCommand('GET', [$key]);
  940. if ($has === null || $has === false || $has === '') {
  941. return false;
  942. }
  943. $expected = max(1, (int)$expectedSeconds);
  944. $val = (int)$has;
  945. // 仅新格式(unix 收款时间)可按当前间隔秒判断
  946. if ($val > 1000000000) {
  947. return (time() - $val) < $expected;
  948. }
  949. // 旧格式无时间戳:不能按 4 秒/2 小时判断,避免 key 残留导致永远不收费
  950. return false;
  951. }
  952. /**
  953. * 冷却秒模式:是否仍在运费附加费冷却期内(按当前配置间隔即时计算)
  954. * @param int $customId
  955. * @param int $sendType
  956. * @param int|null $expectedSeconds 当前配置冷却秒数,缺省按 1800
  957. * @return bool
  958. */
  959. public static function isSendCostInCooldown($customId, $sendType = 0, $expectedSeconds = null)
  960. {
  961. if (empty($customId)) {
  962. return false;
  963. }
  964. $seconds = $expectedSeconds !== null ? (int)$expectedSeconds : 1800;
  965. return self::isIntervalCooldownActive(self::buildSendCostCooldownKey($sendType, $customId), $seconds);
  966. }
  967. /**
  968. * 冷却秒模式:是否仍在包装附加费冷却期内(按当前配置间隔即时计算)
  969. * @param int $customId
  970. * @param int $sendType
  971. * @param int|null $expectedSeconds
  972. * @return bool
  973. */
  974. public static function isPackCostInCooldown($customId, $sendType = 0, $expectedSeconds = null)
  975. {
  976. if (empty($customId)) {
  977. return false;
  978. }
  979. $seconds = $expectedSeconds !== null ? (int)$expectedSeconds : 1800;
  980. return self::isIntervalCooldownActive(self::buildPackCostCooldownKey($sendType, $customId), $seconds);
  981. }
  982. /**
  983. * 判断当天是否已收过包装类附加费(xhPsMethod unMeet=2,自然日模式)
  984. */
  985. public static function isPackPaidToday($customId, $sendType = 0)
  986. {
  987. if (empty($customId)) {
  988. return false;
  989. }
  990. $current = date('Y_m_d');
  991. $key = 'hdCustom_hasGetPackCost:' . (int)$sendType . '_' . $current . ':' . $customId;
  992. $has = Yii::$app->redis->executeCommand('GET', [$key]);
  993. return (!empty($has) && $has == 1);
  994. }
  995. /**
  996. * 当天首单未收包装费时标记(后续单不再收)
  997. */
  998. public static function isPackFeeSkippedToday($customId, $sendType = 0)
  999. {
  1000. if (empty($customId)) {
  1001. return false;
  1002. }
  1003. $current = date('Y_m_d');
  1004. $key = 'hdCustom_skipPackCost:' . (int)$sendType . '_' . $current . ':' . $customId;
  1005. $has = Yii::$app->redis->executeCommand('GET', [$key]);
  1006. return (!empty($has) && $has == 1);
  1007. }
  1008. /**
  1009. * 当天是否已对包装附加费做过决策(已收或首单免收)
  1010. */
  1011. public static function hasPackFeeDecisionToday($customId, $sendType = 0)
  1012. {
  1013. return self::isPackPaidToday($customId, $sendType) || self::isPackFeeSkippedToday($customId, $sendType);
  1014. }
  1015. /**
  1016. * 判断当天是否已收过运费类附加费(xhPsMethod unMeet=0,自然日模式)
  1017. */
  1018. public static function isSendCostPaidToday($customId, $sendType = 0)
  1019. {
  1020. if (empty($customId)) {
  1021. return false;
  1022. }
  1023. $current = date('Y_m_d');
  1024. $key = 'hdCustom_hasGetSendCost:' . (int)$sendType . '_' . $current . ':' . $customId;
  1025. $has = Yii::$app->redis->executeCommand('GET', [$key]);
  1026. return (!empty($has) && $has == 1);
  1027. }
  1028. /**
  1029. * 当天首单未收附加运费时标记(后续单不再收)
  1030. */
  1031. public static function isSendFeeSkippedToday($customId, $sendType = 0)
  1032. {
  1033. if (empty($customId)) {
  1034. return false;
  1035. }
  1036. $current = date('Y_m_d');
  1037. $key = 'hdCustom_skipSendCost:' . (int)$sendType . '_' . $current . ':' . $customId;
  1038. $has = Yii::$app->redis->executeCommand('GET', [$key]);
  1039. return (!empty($has) && $has == 1);
  1040. }
  1041. /**
  1042. * 当天是否已对附加运费做过决策(已收或首单免收)
  1043. */
  1044. public static function hasSendFeeDecisionToday($customId, $sendType = 0)
  1045. {
  1046. return self::isSendCostPaidToday($customId, $sendType) || self::isSendFeeSkippedToday($customId, $sendType);
  1047. }
  1048. /**
  1049. * 支付成功后标记已收附加费(兼容旧调用;优先走 markUnMeetFeePaidFromOrder)
  1050. * @param int $customId
  1051. * @param int $sendType
  1052. * @param float $packingFee
  1053. * @param float $unMeetSendCost
  1054. * @param array|null $config xhPsMethod,用于区分自然日/冷却秒
  1055. */
  1056. public static function markUnMeetFeePaid($customId, $sendType, $packingFee = 0, $unMeetSendCost = 0, $config = null)
  1057. {
  1058. self::recordUnMeetFeeLockState($customId, $sendType, $packingFee, $unMeetSendCost, $config);
  1059. }
  1060. /**
  1061. * 写入附加费锁定态:实收时「间隔时间戳 + 自然日已收」双写,切换模式可互认
  1062. * @param int $customId
  1063. * @param int $sendType
  1064. * @param float $packingFee
  1065. * @param float $unMeetSendCost
  1066. * @param array|null $config
  1067. */
  1068. protected static function recordUnMeetFeeLockState($customId, $sendType, $packingFee = 0, $unMeetSendCost = 0, $config = null)
  1069. {
  1070. if (empty($customId)) {
  1071. return;
  1072. }
  1073. $style = (int)$sendType;
  1074. $lock = self::resolveUnMeetLockOptions($config);
  1075. $current = date('Y_m_d');
  1076. $dayTtl = 86400;
  1077. $lockedAt = (string)time();
  1078. $intervalTtl = self::resolveCooldownRedisTtl(max(1, (int)($lock['seconds'] ?? 1800)));
  1079. $collectedSend = (float)$unMeetSendCost > 0;
  1080. $collectedPack = (float)$packingFee > 0;
  1081. // 实收运费附加费:间隔 key(时间戳)+ 自然日已收 key 一并写入
  1082. if ($collectedSend) {
  1083. Yii::$app->redis->executeCommand('SETEX', [self::buildSendCostCooldownKey($style, $customId), $intervalTtl, $lockedAt]);
  1084. if (!self::isSendCostPaidToday($customId, $style)) {
  1085. $sendKey = 'hdCustom_hasGetSendCost:' . $style . '_' . $current . ':' . $customId;
  1086. Yii::$app->redis->executeCommand('SETEX', [$sendKey, $dayTtl, '1']);
  1087. }
  1088. } elseif ((int)$lock['type'] !== 1 && !self::hasSendFeeDecisionToday($customId, $style)) {
  1089. // 仅自然日模式:首单未收附加运费则记 skip,当日不再收
  1090. $skipKey = 'hdCustom_skipSendCost:' . $style . '_' . $current . ':' . $customId;
  1091. Yii::$app->redis->executeCommand('SETEX', [$skipKey, $dayTtl, '1']);
  1092. }
  1093. // 实收包装附加费:同样双写
  1094. if ($collectedPack) {
  1095. Yii::$app->redis->executeCommand('SETEX', [self::buildPackCostCooldownKey($style, $customId), $intervalTtl, $lockedAt]);
  1096. if (!self::isPackPaidToday($customId, $style)) {
  1097. $packKey = 'hdCustom_hasGetPackCost:' . $style . '_' . $current . ':' . $customId;
  1098. Yii::$app->redis->executeCommand('SETEX', [$packKey, $dayTtl, '1']);
  1099. }
  1100. } elseif ((int)$lock['type'] !== 1 && !self::hasPackFeeDecisionToday($customId, $style)) {
  1101. $skipKey = 'hdCustom_skipPackCost:' . $style . '_' . $current . ':' . $customId;
  1102. Yii::$app->redis->executeCommand('SETEX', [$skipKey, $dayTtl, '1']);
  1103. }
  1104. }
  1105. /**
  1106. * 根据订单信息标记附加费锁定(支付成功回调/余额支付)
  1107. * 会按订单 mainId+sendType 读取 xhPsMethod 的重收规则
  1108. *
  1109. * 注意:附加运费金额优先读 redis hd_order_unmeet_send:{orderSn};
  1110. * 微信拉起支付可能更换 orderSn,导致 redis 读空,需用订单 sendCost + 配置回退,
  1111. * 否则冷却/自然日锁永远写不进去,结算页会反复加收。
  1112. */
  1113. public static function markUnMeetFeePaidFromOrder($order)
  1114. {
  1115. if (empty($order)) {
  1116. return;
  1117. }
  1118. $orderSn = is_object($order) ? ($order->orderSn ?? '') : ($order['orderSn'] ?? '');
  1119. $customId = (int)(is_object($order) ? ($order->customId ?? 0) : ($order['customId'] ?? 0));
  1120. $sendType = (int)(is_object($order) ? ($order->sendType ?? 0) : ($order['sendType'] ?? 0));
  1121. $shopId = (int)(is_object($order) ? ($order->shopId ?? 0) : ($order['shopId'] ?? 0));
  1122. $mainId = (int)(is_object($order) ? ($order->mainId ?? 0) : ($order['mainId'] ?? 0));
  1123. $packingFee = (float)(is_object($order) ? ($order->packingFee ?? 0) : ($order['packingFee'] ?? 0));
  1124. $orderSendCost = (float)(is_object($order) ? ($order->sendCost ?? 0) : ($order['sendCost'] ?? 0));
  1125. $config = null;
  1126. if ($mainId > 0) {
  1127. // 支付回写只需 lock / unMeet 字段;无完整 explains 亦可
  1128. $row = self::getByCondition(['mainId' => $mainId, 'style' => $sendType]);
  1129. if (!empty($row)) {
  1130. $config = is_object($row) && method_exists($row, 'toArray') ? $row->toArray() : (array)$row;
  1131. } elseif ($shopId > 0) {
  1132. $config = self::getConfig($shopId, $mainId, $sendType);
  1133. }
  1134. }
  1135. $unMeetSendCost = 0;
  1136. if (!empty($orderSn)) {
  1137. $unMeetSendKey = 'hd_order_unmeet_send:' . $orderSn;
  1138. $cached = Yii::$app->redis->executeCommand('GET', [$unMeetSendKey]);
  1139. if (!empty($cached)) {
  1140. $unMeetSendCost = (float)$cached;
  1141. }
  1142. }
  1143. // redis 丢失回退:送货/物流/快递的 sendCost 基本就是未达门槛附加运费(无跑腿距离费)
  1144. if ($unMeetSendCost <= 0 && !empty($config) && in_array($sendType, [0, 3, 4], true)) {
  1145. $unMeet = (int)($config['unMeet'] ?? 0);
  1146. $unMeetFee = (float)($config['unMeetFee'] ?? 0);
  1147. if ($unMeet === 0 && $unMeetFee > 0 && $orderSendCost > 0) {
  1148. $unMeetSendCost = $unMeetFee;
  1149. }
  1150. }
  1151. // 无附加包装费/附加运费时不写入(避免满足门槛的订单误记 skip)
  1152. if ($packingFee <= 0 && $unMeetSendCost <= 0) {
  1153. return;
  1154. }
  1155. self::recordUnMeetFeeLockState($customId, $sendType, $packingFee, $unMeetSendCost, $config);
  1156. }
  1157. /**
  1158. * 微信支付换单号时迁移附加运费缓存,避免支付成功读不到金额导致不写冷却锁
  1159. * @param string $oldOrderSn
  1160. * @param string $newOrderSn
  1161. */
  1162. public static function migrateUnMeetSendCostCache($oldOrderSn, $newOrderSn)
  1163. {
  1164. $oldOrderSn = (string)$oldOrderSn;
  1165. $newOrderSn = (string)$newOrderSn;
  1166. if ($oldOrderSn === '' || $newOrderSn === '' || $oldOrderSn === $newOrderSn) {
  1167. return;
  1168. }
  1169. $oldKey = 'hd_order_unmeet_send:' . $oldOrderSn;
  1170. $cached = Yii::$app->redis->executeCommand('GET', [$oldKey]);
  1171. if ($cached === null || $cached === false || $cached === '') {
  1172. return;
  1173. }
  1174. $ttl = Yii::$app->redis->executeCommand('TTL', [$oldKey]);
  1175. $ttl = (int)$ttl;
  1176. if ($ttl <= 0) {
  1177. $ttl = 864000;
  1178. }
  1179. $newKey = 'hd_order_unmeet_send:' . $newOrderSn;
  1180. Yii::$app->redis->executeCommand('SETEX', [$newKey, $ttl, (string)$cached]);
  1181. }
  1182. /**
  1183. * 合并 hd + custom 为 calcFee / applyCustomHomeRule 使用的客户信息(与商城 getAllMethod 一致)
  1184. * @param mixed $hd 花店关系 xhHd
  1185. * @param mixed $custom 客户 xhCustom
  1186. * @return array
  1187. */
  1188. public static function buildCustomInfo($hd = null, $custom = null)
  1189. {
  1190. $customInfo = [];
  1191. if (!empty($hd)) {
  1192. if (is_array($hd)) {
  1193. $customInfo = $hd;
  1194. } elseif (is_object($hd) && method_exists($hd, 'toArray')) {
  1195. $customInfo = $hd->toArray();
  1196. } elseif (is_object($hd) && isset($hd->attributes)) {
  1197. $customInfo = $hd->attributes;
  1198. } else {
  1199. $customInfo = (array)$hd;
  1200. }
  1201. }
  1202. if (!empty($custom)) {
  1203. if (is_array($custom)) {
  1204. $customArr = $custom;
  1205. } elseif (is_object($custom) && method_exists($custom, 'toArray')) {
  1206. $customArr = $custom->toArray();
  1207. } elseif (is_object($custom) && isset($custom->attributes)) {
  1208. $customArr = $custom->attributes;
  1209. } else {
  1210. $customArr = (array)$custom;
  1211. }
  1212. $customInfo = array_merge($customInfo, $customArr);
  1213. }
  1214. return $customInfo;
  1215. }
  1216. /**
  1217. * 对配送方式列表应用 style=0 客户单独送货上门规则
  1218. * @param array $configs getAllConfigs 结果
  1219. * @param array $customInfo buildCustomInfo 结果
  1220. * @return array
  1221. */
  1222. public static function applyCustomHomeRuleToConfigs(array $configs, array $customInfo)
  1223. {
  1224. if (empty($customInfo)) {
  1225. return $configs;
  1226. }
  1227. foreach ($configs as &$config) {
  1228. if ((int)($config['style'] ?? 0) === 0) {
  1229. $config = self::applyCustomHomeRule($config, $customInfo);
  1230. }
  1231. }
  1232. unset($config);
  1233. return $configs;
  1234. }
  1235. /**
  1236. * 标记附加费是否仍在锁定期(供商城 getAllMethod 下发锁定态;兼容自然日与冷却秒)
  1237. * @param array $configs 配送方式列表
  1238. * @param int $customId 花店客户 id
  1239. * @return array
  1240. */
  1241. public static function applyDailyFeeLockFlags(array $configs, $customId)
  1242. {
  1243. $customId = (int)$customId;
  1244. if ($customId <= 0) {
  1245. return $configs;
  1246. }
  1247. foreach ($configs as &$config) {
  1248. $style = (int)($config['style'] ?? 0);
  1249. $unMeet = (int)($config['unMeet'] ?? 0);
  1250. $hasSendDecision = self::isUnMeetSendFeeLocked($customId, $style, $config);
  1251. $hasPackDecision = self::isUnMeetPackFeeLocked($customId, $style, $config);
  1252. $config['todaySendFeeLocked'] = $hasSendDecision ? 1 : 0;
  1253. $config['todayPackFeeLocked'] = $hasPackDecision ? 1 : 0;
  1254. if ($unMeet === 0 && $hasSendDecision) {
  1255. $config['minAmount'] = 0;
  1256. $config['minNum'] = 0;
  1257. $config['unMeetFee'] = 0;
  1258. } elseif ($unMeet === 2 && $hasPackDecision) {
  1259. $config['minAmount'] = 0;
  1260. $config['minNum'] = 0;
  1261. $config['unMeetFee'] = 0;
  1262. }
  1263. }
  1264. unset($config);
  1265. return $configs;
  1266. }
  1267. /**
  1268. * 商城混合结算:统一入口调用 calcFee(预览与下单共用)
  1269. */
  1270. public static function calcFeeForMallContext($shopId, $mainId, $sendType, $itemTotalAmount, $bigNum, $customId = 0, $customInfo = [])
  1271. {
  1272. return self::calcFee($shopId, $mainId, $sendType, $itemTotalAmount, $bigNum, $customId, is_array($customInfo) ? $customInfo : []);
  1273. }
  1274. /**
  1275. * 商城结算页预览未达门槛附加运费/包装费(与 create-mix-order 内 calcFee 同源)
  1276. * @return array
  1277. */
  1278. public static function previewMixUnMeetFee($shopId, $mainId, $sendType, $itemTotalAmount, $bigNum, $customId = 0, $customInfo = [])
  1279. {
  1280. $costs = self::calcFeeForMallContext($shopId, $mainId, $sendType, $itemTotalAmount, $bigNum, $customId, $customInfo);
  1281. $style = (int)$sendType;
  1282. $config = self::getConfig($shopId, $mainId, $style);
  1283. if (empty($config)) {
  1284. return [
  1285. 'sendCost' => 0,
  1286. 'packCost' => 0,
  1287. 'unMeetSendCost' => 0,
  1288. 'packingFee' => 0,
  1289. 'belowMinimum' => false,
  1290. 'canOrder' => true,
  1291. 'tip' => '',
  1292. ];
  1293. }
  1294. $config = self::mergeCustomHomeRule($config, $style, $customInfo);
  1295. $belowMinimum = self::isBelowMinimum($itemTotalAmount, $bigNum, $config);
  1296. $tip = '';
  1297. $canOrder = true;
  1298. if ($belowMinimum) {
  1299. $unMeet = (int)($config['unMeet'] ?? 0);
  1300. $unMeetFee = (float)($config['unMeetFee'] ?? 0);
  1301. $conditionText = self::buildUnMeetConditionText($config);
  1302. if ($unMeet === 1) {
  1303. $canOrder = false;
  1304. $tip = $conditionText !== '' ? "订单不满{$conditionText},不能下单" : '未达到最低消费,不能下单';
  1305. } elseif (($unMeet === 0 || $unMeet === 2) && $unMeetFee > 0) {
  1306. $locked = ($unMeet === 0 && self::isUnMeetSendFeeLocked($customId, $style, $config))
  1307. || ($unMeet === 2 && self::isUnMeetPackFeeLocked($customId, $style, $config));
  1308. if (!$locked) {
  1309. $feeLabel = $unMeet === 2 ? '包装费' : '运费';
  1310. // 展示还差多少可免附加费,便于用户凑单
  1311. $tip = self::buildUnMeetShortTip($config, $itemTotalAmount, $bigNum, $feeLabel);
  1312. }
  1313. }
  1314. }
  1315. $checkErr = self::checkLimit(
  1316. $shopId,
  1317. $mainId,
  1318. $sendType,
  1319. $itemTotalAmount,
  1320. $bigNum,
  1321. $costs['packCost'],
  1322. $costs['sendCost'],
  1323. $customId,
  1324. $customInfo
  1325. );
  1326. if ($checkErr !== '') {
  1327. $canOrder = false;
  1328. $tip = $checkErr;
  1329. }
  1330. return [
  1331. 'sendCost' => (float)$costs['sendCost'],
  1332. 'packCost' => (float)$costs['packCost'],
  1333. 'unMeetSendCost' => (float)$costs['sendCost'],
  1334. 'packingFee' => (float)$costs['packCost'],
  1335. 'belowMinimum' => $belowMinimum,
  1336. 'canOrder' => $canOrder,
  1337. 'tip' => $tip,
  1338. ];
  1339. }
  1340. /**
  1341. * 混合结算页:一次预览各购买方式未达门槛附加费(不含 style=2 跑腿距离运费)
  1342. * @return array 键为 style 字符串,如 ['0' => [...], '1' => [...]]
  1343. */
  1344. public static function previewMixUnMeetFeeBatch($shopId, $mainId, $itemTotalAmount, $bigNum, $customId = 0, $hd = null, $custom = null)
  1345. {
  1346. $customInfo = self::buildCustomInfo($hd, $custom);
  1347. $fees = [];
  1348. for ($style = 0; $style <= 4; $style++) {
  1349. $fees[(string)$style] = self::previewMixUnMeetFee(
  1350. $shopId,
  1351. $mainId,
  1352. $style,
  1353. $itemTotalAmount,
  1354. $bigNum,
  1355. (int)$customId,
  1356. $customInfo
  1357. );
  1358. }
  1359. return $fees;
  1360. }
  1361. /**
  1362. * 计算未达最低消费时应写入订单的附加运费/包装费
  1363. * @return array ['sendCost' => 附加运费, 'packCost' => 附加包装费]
  1364. */
  1365. public static function calcFee($shopId, $mainId, $sendType, $itemTotalAmount, $bigNum, $customId = 0, $customInfo = [])
  1366. {
  1367. $costs = ['sendCost' => 0, 'packCost' => 0];
  1368. $style = (int)$sendType;
  1369. if ($style < 0 || $style > 4) {
  1370. return $costs;
  1371. }
  1372. $config = self::getConfig($shopId, $mainId, $style);
  1373. if (empty($config)) {
  1374. return $costs;
  1375. }
  1376. $config = self::mergeCustomHomeRule($config, $style, $customInfo);
  1377. if (!self::isBelowMinimum($itemTotalAmount, $bigNum, $config)) {
  1378. return $costs;
  1379. }
  1380. $unMeet = isset($config['unMeet']) ? (int)$config['unMeet'] : 0;
  1381. $unMeetFee = isset($config['unMeetFee']) ? (float)$config['unMeetFee'] : 0;
  1382. if ($unMeetFee <= 0) {
  1383. return $costs;
  1384. }
  1385. // unMeet: 0 加收运费,2 加收包装费;锁定策略见 unMeetLockType(自然日或冷却秒)
  1386. if ($unMeet === 0 && !self::isUnMeetSendFeeLocked($customId, $style, $config)) {
  1387. $costs['sendCost'] = $unMeetFee;
  1388. } elseif ($unMeet === 2 && !self::isUnMeetPackFeeLocked($customId, $style, $config)) {
  1389. $costs['packCost'] = $unMeetFee;
  1390. }
  1391. return $costs;
  1392. }
  1393. /**
  1394. * 下单前校验配送方式最低消费与附加费是否已正确写入
  1395. * @param float $packCost 附加包装费
  1396. * @param float $unMeetSendCost 未达门槛附加运费(不含跑腿报价)
  1397. * @return string 空字符串表示通过
  1398. */
  1399. public static function checkLimit($shopId, $mainId, $sendType, $actPrice, $bigNum, $packCost = 0, $unMeetSendCost = 0, $customId = 0, $customInfo = [])
  1400. {
  1401. $style = (int)$sendType;
  1402. if ($style < 0 || $style > 4) {
  1403. return '配送方式无效';
  1404. }
  1405. $config = self::getConfig($shopId, $mainId, $style);
  1406. if (empty($config)) {
  1407. return '配送方式配置异常';
  1408. }
  1409. $config = self::mergeCustomHomeRule($config, $style, $customInfo);
  1410. $methodName = !empty($config['name']) ? $config['name'] : '该配送方式';
  1411. if (isset($config['status']) && (int)$config['status'] === 0) {
  1412. return "暂不支持{$methodName},请选其它配送方式";
  1413. }
  1414. if (!self::isBelowMinimum($actPrice, $bigNum, $config)) {
  1415. return '';
  1416. }
  1417. $minAmount = isset($config['minAmount']) ? (float)$config['minAmount'] : 0.00;
  1418. $minNum = isset($config['minNum']) ? (int)$config['minNum'] : 0;
  1419. $unMeet = isset($config['unMeet']) ? (int)$config['unMeet'] : 0;
  1420. $unMeetFee = isset($config['unMeetFee']) ? (float)$config['unMeetFee'] : 0;
  1421. if ($unMeet === 1) {
  1422. $conditionText = self::buildUnMeetConditionText($config);
  1423. if ($conditionText !== '') {
  1424. return "订单不满{$conditionText},不能下单";
  1425. }
  1426. return '未达到最低消费,不能下单';
  1427. }
  1428. if (($unMeet === 0 || $unMeet === 2) && $unMeetFee > 0) {
  1429. $feeToCheck = $unMeet === 2 ? $packCost : $unMeetSendCost;
  1430. if ($unMeet === 2 && self::isUnMeetPackFeeLocked($customId, $style, $config)) {
  1431. return '';
  1432. }
  1433. if ($unMeet === 0 && self::isUnMeetSendFeeLocked($customId, $style, $config)) {
  1434. return '';
  1435. }
  1436. if (bccomp((string)$feeToCheck, (string)$unMeetFee, 2) < 0) {
  1437. $feeLabel = $unMeet === 2 ? '包装费' : '运费';
  1438. $conditionText = self::buildUnMeetConditionText($config);
  1439. if ($conditionText !== '') {
  1440. return "订单不满{$conditionText},加{$feeLabel}{$unMeetFee}元";
  1441. }
  1442. return "未达最低消费,需加收{$feeLabel}{$unMeetFee}元";
  1443. }
  1444. }
  1445. return '';
  1446. }
  1447. }