PsMethodClass.php 53 KB

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