PsMethodClass.php 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000
  1. <?php
  2. /**
  3. * 用途:花店配送方式配置业务逻辑类
  4. * 谁用:花店系统 hdApp 配送方式设置页
  5. * 解决什么问题:读写 xhPsMethod / xhPsExplain / xhPsReduceRule,与供货商 xhSh* 分离
  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. 'calcType' => isset($data['calcType']) ? (int)$data['calcType'] : 0,
  386. 'startKm' => isset($data['startKm']) ? (float)$data['startKm'] : 0.00,
  387. 'startPrice' => isset($data['startPrice']) ? (float)$data['startPrice'] : 0.00,
  388. 'perKmPrice' => isset($data['perKmPrice']) ? (float)$data['perKmPrice'] : 0.00,
  389. 'changeRate' => isset($data['changeRate']) ? (float)$data['changeRate'] : 0.00,
  390. 'hsFreeKm' => isset($data['hsFreeKm']) ? (float)$data['hsFreeKm'] : 0.00,
  391. 'hsPerKmPrice' => isset($data['hsPerKmPrice']) ? (float)$data['hsPerKmPrice'] : 0.00,
  392. ];
  393. $transaction = Yii::$app->db->beginTransaction();
  394. try {
  395. if (empty($config)) {
  396. $saveData['shopId'] = $shopId;
  397. $saveData['mainId'] = $mainId;
  398. $saveData['style'] = $style;
  399. $defaultConfig = dict::getDict('shMethod', $style);
  400. if (!empty($defaultConfig['originName'])) {
  401. $saveData['originName'] = $defaultConfig['originName'];
  402. }
  403. $config = self::add($saveData, true);
  404. } else {
  405. self::updateByCondition(['id' => $config->id], $saveData);
  406. }
  407. $methodId = $config->id;
  408. // 1. 保存说明项 xhPsExplain
  409. PsExplainClass::deleteByCondition(['methodId' => $methodId]);
  410. if (!empty($data['explains']) && is_array($data['explains'])) {
  411. $explainRows = [];
  412. foreach ($data['explains'] as $index => $exp) {
  413. if (empty($exp['explain'])) {
  414. continue;
  415. }
  416. $explainRows[] = [
  417. 'methodId' => $methodId,
  418. 'explain' => $exp['explain'],
  419. 'color' => isset($exp['color']) ? (int)$exp['color'] : 1,
  420. 'fontWeight' => isset($exp['fontWeight']) ? (int)$exp['fontWeight'] : 1,
  421. 'sort' => isset($exp['sort']) ? (int)$exp['sort'] : $index,
  422. ];
  423. }
  424. if (!empty($explainRows)) {
  425. PsExplainClass::batchAdd($explainRows);
  426. }
  427. }
  428. // 2. 保存跑腿满减规则 xhPsReduceRule
  429. PsReduceRuleClass::deleteByCondition(['methodId' => $methodId]);
  430. if ($style == 2 && !empty($data['reduceRules']) && is_array($data['reduceRules'])) {
  431. $ruleRows = [];
  432. foreach ($data['reduceRules'] as $index => $rule) {
  433. $ruleRows[] = [
  434. 'methodId' => $methodId,
  435. 'meetNum' => isset($rule['meetNum']) ? (int)$rule['meetNum'] : 0,
  436. 'meetAmount' => isset($rule['meetAmount']) ? (float)$rule['meetAmount'] : 0.00,
  437. 'freeKm' => isset($rule['freeKm']) ? (float)$rule['freeKm'] : 0.00,
  438. 'sort' => isset($rule['sort']) ? (int)$rule['sort'] : $index,
  439. ];
  440. }
  441. if (!empty($ruleRows)) {
  442. PsReduceRuleClass::batchAdd($ruleRows);
  443. }
  444. }
  445. $transaction->commit();
  446. return true;
  447. } catch (\Exception $e) {
  448. $transaction->rollBack();
  449. throw $e;
  450. }
  451. }
  452. /**
  453. * 合并客户送货上门单独规则(style=0 且 homeRule=1)
  454. * @param array $config xhPsMethod 配置
  455. * @param int $style 配送方式
  456. * @param array $customInfo 客户+花店关系信息
  457. * @return array
  458. */
  459. protected static function mergeCustomHomeRule($config, $style, $customInfo)
  460. {
  461. if ((int)$style === 0 && !empty($customInfo)) {
  462. return self::applyCustomHomeRule($config, $customInfo);
  463. }
  464. return $config;
  465. }
  466. /**
  467. * 判断当前订单是否未达最低消费
  468. * 规则:满 minAmount 或 minNum 任一达标即视为达标;两者均未达标才返回 true
  469. */
  470. public static function isBelowMinimum($itemTotalAmount, $bigNum, $config)
  471. {
  472. $minAmount = isset($config['minAmount']) ? (float)$config['minAmount'] : 0.00;
  473. $minNum = isset($config['minNum']) ? (int)$config['minNum'] : 0;
  474. if ($minAmount <= 0 && $minNum <= 0) {
  475. return false;
  476. }
  477. $amountOk = $minAmount <= 0 || (float)$itemTotalAmount >= $minAmount;
  478. $numOk = $minNum <= 0 || (float)$bigNum >= $minNum;
  479. return !($amountOk || $numOk);
  480. }
  481. /**
  482. * 拼接最低消费门槛文案,如 200元或30扎
  483. */
  484. protected static function buildUnMeetConditionText($config)
  485. {
  486. $parts = [];
  487. $minAmount = isset($config['minAmount']) ? (float)$config['minAmount'] : 0.00;
  488. $minNum = isset($config['minNum']) ? (int)$config['minNum'] : 0;
  489. if ($minAmount > 0) {
  490. $parts[] = $minAmount . '元';
  491. }
  492. if ($minNum > 0) {
  493. $parts[] = $minNum . '扎';
  494. }
  495. return implode('或', $parts);
  496. }
  497. /**
  498. * 未达门槛时「还差多少可免」提示,如:订单还差20元和3扎可免包装费
  499. * @param array $config
  500. * @param float $itemTotalAmount
  501. * @param float $bigNum
  502. * @param string $feeLabel 包装费|运费
  503. * @return string
  504. */
  505. protected static function buildUnMeetShortTip($config, $itemTotalAmount, $bigNum, $feeLabel)
  506. {
  507. $minAmount = isset($config['minAmount']) ? (float)$config['minAmount'] : 0.00;
  508. $minNum = isset($config['minNum']) ? (int)$config['minNum'] : 0;
  509. $parts = [];
  510. if ($minAmount > 0) {
  511. $shortAmount = round(max(0, $minAmount - (float)$itemTotalAmount), 2);
  512. if ($shortAmount > 0) {
  513. $parts[] = $shortAmount . '元';
  514. }
  515. }
  516. if ($minNum > 0) {
  517. $shortNum = max(0, $minNum - (float)$bigNum);
  518. // 扎数展示取整友好值
  519. if ($shortNum > 0) {
  520. $parts[] = (fmod($shortNum, 1) == 0 ? (int)$shortNum : round($shortNum, 2)) . '扎';
  521. }
  522. }
  523. if (empty($parts)) {
  524. return '';
  525. }
  526. return '订单还差' . implode('和', $parts) . '可免' . $feeLabel;
  527. }
  528. /**
  529. * 判断当天是否已收过包装类附加费(xhPsMethod unMeet=2)
  530. */
  531. public static function isPackPaidToday($customId, $sendType = 0)
  532. {
  533. if (empty($customId)) {
  534. return false;
  535. }
  536. $current = date('Y_m_d');
  537. $key = 'hdCustom_hasGetPackCost:' . (int)$sendType . '_' . $current . ':' . $customId;
  538. $has = Yii::$app->redis->executeCommand('GET', [$key]);
  539. return (!empty($has) && $has == 1);
  540. }
  541. /**
  542. * 当天首单未收包装费时标记(后续单不再收)
  543. */
  544. public static function isPackFeeSkippedToday($customId, $sendType = 0)
  545. {
  546. if (empty($customId)) {
  547. return false;
  548. }
  549. $current = date('Y_m_d');
  550. $key = 'hdCustom_skipPackCost:' . (int)$sendType . '_' . $current . ':' . $customId;
  551. $has = Yii::$app->redis->executeCommand('GET', [$key]);
  552. return (!empty($has) && $has == 1);
  553. }
  554. /**
  555. * 当天是否已对包装附加费做过决策(已收或首单免收)
  556. */
  557. public static function hasPackFeeDecisionToday($customId, $sendType = 0)
  558. {
  559. return self::isPackPaidToday($customId, $sendType) || self::isPackFeeSkippedToday($customId, $sendType);
  560. }
  561. /**
  562. * 判断当天是否已收过运费类附加费(xhPsMethod unMeet=0)
  563. */
  564. public static function isSendCostPaidToday($customId, $sendType = 0)
  565. {
  566. if (empty($customId)) {
  567. return false;
  568. }
  569. $current = date('Y_m_d');
  570. $key = 'hdCustom_hasGetSendCost:' . (int)$sendType . '_' . $current . ':' . $customId;
  571. $has = Yii::$app->redis->executeCommand('GET', [$key]);
  572. return (!empty($has) && $has == 1);
  573. }
  574. /**
  575. * 当天首单未收附加运费时标记(后续单不再收)
  576. */
  577. public static function isSendFeeSkippedToday($customId, $sendType = 0)
  578. {
  579. if (empty($customId)) {
  580. return false;
  581. }
  582. $current = date('Y_m_d');
  583. $key = 'hdCustom_skipSendCost:' . (int)$sendType . '_' . $current . ':' . $customId;
  584. $has = Yii::$app->redis->executeCommand('GET', [$key]);
  585. return (!empty($has) && $has == 1);
  586. }
  587. /**
  588. * 当天是否已对附加运费做过决策(已收或首单免收)
  589. */
  590. public static function hasSendFeeDecisionToday($customId, $sendType = 0)
  591. {
  592. return self::isSendCostPaidToday($customId, $sendType) || self::isSendFeeSkippedToday($customId, $sendType);
  593. }
  594. /**
  595. * 支付成功后标记当天已收附加费,保证同一客户同配送方式每日只收一次
  596. */
  597. public static function markUnMeetFeePaid($customId, $sendType, $packingFee = 0, $unMeetSendCost = 0)
  598. {
  599. self::recordDailyUnMeetFeeState($customId, $sendType, $packingFee, $unMeetSendCost);
  600. }
  601. /**
  602. * 首单支付后记录当日附加费决策:收过则不再收;首单未收则当日后续也不收
  603. */
  604. protected static function recordDailyUnMeetFeeState($customId, $sendType, $packingFee = 0, $unMeetSendCost = 0)
  605. {
  606. if (empty($customId)) {
  607. return;
  608. }
  609. $current = date('Y_m_d');
  610. $style = (int)$sendType;
  611. $ttl = 86400;
  612. if (!self::hasSendFeeDecisionToday($customId, $style)) {
  613. if ((float)$unMeetSendCost > 0) {
  614. $sendKey = 'hdCustom_hasGetSendCost:' . $style . '_' . $current . ':' . $customId;
  615. Yii::$app->redis->executeCommand('SETEX', [$sendKey, $ttl, '1']);
  616. } else {
  617. $skipKey = 'hdCustom_skipSendCost:' . $style . '_' . $current . ':' . $customId;
  618. Yii::$app->redis->executeCommand('SETEX', [$skipKey, $ttl, '1']);
  619. }
  620. }
  621. if (!self::hasPackFeeDecisionToday($customId, $style)) {
  622. if ((float)$packingFee > 0) {
  623. $packKey = 'hdCustom_hasGetPackCost:' . $style . '_' . $current . ':' . $customId;
  624. Yii::$app->redis->executeCommand('SETEX', [$packKey, $ttl, '1']);
  625. } else {
  626. $skipKey = 'hdCustom_skipPackCost:' . $style . '_' . $current . ':' . $customId;
  627. Yii::$app->redis->executeCommand('SETEX', [$skipKey, $ttl, '1']);
  628. }
  629. }
  630. }
  631. /**
  632. * 根据订单信息标记当日附加费(支付成功回调/余额支付)
  633. */
  634. public static function markUnMeetFeePaidFromOrder($order)
  635. {
  636. if (empty($order)) {
  637. return;
  638. }
  639. $orderSn = is_object($order) ? ($order->orderSn ?? '') : ($order['orderSn'] ?? '');
  640. $customId = (int)(is_object($order) ? ($order->customId ?? 0) : ($order['customId'] ?? 0));
  641. $sendType = (int)(is_object($order) ? ($order->sendType ?? 0) : ($order['sendType'] ?? 0));
  642. $packingFee = (float)(is_object($order) ? ($order->packingFee ?? 0) : ($order['packingFee'] ?? 0));
  643. $unMeetSendCost = 0;
  644. if (!empty($orderSn)) {
  645. $unMeetSendKey = 'hd_order_unmeet_send:' . $orderSn;
  646. $cached = Yii::$app->redis->executeCommand('GET', [$unMeetSendKey]);
  647. if (!empty($cached)) {
  648. $unMeetSendCost = (float)$cached;
  649. }
  650. }
  651. // 无附加包装费/附加运费时不写入(避免满足门槛的订单误记 skip)
  652. if ($packingFee <= 0 && $unMeetSendCost <= 0) {
  653. return;
  654. }
  655. self::recordDailyUnMeetFeeState($customId, $sendType, $packingFee, $unMeetSendCost);
  656. }
  657. /**
  658. * 合并 hd + custom 为 calcFee / applyCustomHomeRule 使用的客户信息(与商城 getAllMethod 一致)
  659. * @param mixed $hd 花店关系 xhHd
  660. * @param mixed $custom 客户 xhCustom
  661. * @return array
  662. */
  663. public static function buildCustomInfo($hd = null, $custom = null)
  664. {
  665. $customInfo = [];
  666. if (!empty($hd)) {
  667. if (is_array($hd)) {
  668. $customInfo = $hd;
  669. } elseif (is_object($hd) && method_exists($hd, 'toArray')) {
  670. $customInfo = $hd->toArray();
  671. } elseif (is_object($hd) && isset($hd->attributes)) {
  672. $customInfo = $hd->attributes;
  673. } else {
  674. $customInfo = (array)$hd;
  675. }
  676. }
  677. if (!empty($custom)) {
  678. if (is_array($custom)) {
  679. $customArr = $custom;
  680. } elseif (is_object($custom) && method_exists($custom, 'toArray')) {
  681. $customArr = $custom->toArray();
  682. } elseif (is_object($custom) && isset($custom->attributes)) {
  683. $customArr = $custom->attributes;
  684. } else {
  685. $customArr = (array)$custom;
  686. }
  687. $customInfo = array_merge($customInfo, $customArr);
  688. }
  689. return $customInfo;
  690. }
  691. /**
  692. * 对配送方式列表应用 style=0 客户单独送货上门规则
  693. * @param array $configs getAllConfigs 结果
  694. * @param array $customInfo buildCustomInfo 结果
  695. * @return array
  696. */
  697. public static function applyCustomHomeRuleToConfigs(array $configs, array $customInfo)
  698. {
  699. if (empty($customInfo)) {
  700. return $configs;
  701. }
  702. foreach ($configs as &$config) {
  703. if ((int)($config['style'] ?? 0) === 0) {
  704. $config = self::applyCustomHomeRule($config, $customInfo);
  705. }
  706. }
  707. unset($config);
  708. return $configs;
  709. }
  710. /**
  711. * 标记当日附加费是否已决策(供商城 getAllMethod 下发锁定态)
  712. * @param array $configs 配送方式列表
  713. * @param int $customId 花店客户 id
  714. * @return array
  715. */
  716. public static function applyDailyFeeLockFlags(array $configs, $customId)
  717. {
  718. $customId = (int)$customId;
  719. if ($customId <= 0) {
  720. return $configs;
  721. }
  722. foreach ($configs as &$config) {
  723. $style = (int)($config['style'] ?? 0);
  724. $unMeet = (int)($config['unMeet'] ?? 0);
  725. $hasSendDecision = self::hasSendFeeDecisionToday($customId, $style);
  726. $hasPackDecision = self::hasPackFeeDecisionToday($customId, $style);
  727. $config['todaySendFeeLocked'] = $hasSendDecision ? 1 : 0;
  728. $config['todayPackFeeLocked'] = $hasPackDecision ? 1 : 0;
  729. if ($unMeet === 0 && $hasSendDecision) {
  730. $config['minAmount'] = 0;
  731. $config['minNum'] = 0;
  732. $config['unMeetFee'] = 0;
  733. } elseif ($unMeet === 2 && $hasPackDecision) {
  734. $config['minAmount'] = 0;
  735. $config['minNum'] = 0;
  736. $config['unMeetFee'] = 0;
  737. }
  738. }
  739. unset($config);
  740. return $configs;
  741. }
  742. /**
  743. * 商城混合结算:统一入口调用 calcFee(预览与下单共用)
  744. */
  745. public static function calcFeeForMallContext($shopId, $mainId, $sendType, $itemTotalAmount, $bigNum, $customId = 0, $customInfo = [])
  746. {
  747. return self::calcFee($shopId, $mainId, $sendType, $itemTotalAmount, $bigNum, $customId, is_array($customInfo) ? $customInfo : []);
  748. }
  749. /**
  750. * 商城结算页预览未达门槛附加运费/包装费(与 create-mix-order 内 calcFee 同源)
  751. * @return array
  752. */
  753. public static function previewMixUnMeetFee($shopId, $mainId, $sendType, $itemTotalAmount, $bigNum, $customId = 0, $customInfo = [])
  754. {
  755. $costs = self::calcFeeForMallContext($shopId, $mainId, $sendType, $itemTotalAmount, $bigNum, $customId, $customInfo);
  756. $style = (int)$sendType;
  757. $config = self::getConfig($shopId, $mainId, $style);
  758. if (empty($config)) {
  759. return [
  760. 'sendCost' => 0,
  761. 'packCost' => 0,
  762. 'unMeetSendCost' => 0,
  763. 'packingFee' => 0,
  764. 'belowMinimum' => false,
  765. 'canOrder' => true,
  766. 'tip' => '',
  767. ];
  768. }
  769. $config = self::mergeCustomHomeRule($config, $style, $customInfo);
  770. $belowMinimum = self::isBelowMinimum($itemTotalAmount, $bigNum, $config);
  771. $tip = '';
  772. $canOrder = true;
  773. if ($belowMinimum) {
  774. $unMeet = (int)($config['unMeet'] ?? 0);
  775. $unMeetFee = (float)($config['unMeetFee'] ?? 0);
  776. $conditionText = self::buildUnMeetConditionText($config);
  777. if ($unMeet === 1) {
  778. $canOrder = false;
  779. $tip = $conditionText !== '' ? "订单不满{$conditionText},不能下单" : '未达到最低消费,不能下单';
  780. } elseif (($unMeet === 0 || $unMeet === 2) && $unMeetFee > 0) {
  781. $locked = ($unMeet === 0 && self::hasSendFeeDecisionToday($customId, $style))
  782. || ($unMeet === 2 && self::hasPackFeeDecisionToday($customId, $style));
  783. if (!$locked) {
  784. $feeLabel = $unMeet === 2 ? '包装费' : '运费';
  785. // 展示还差多少可免附加费,便于用户凑单
  786. $tip = self::buildUnMeetShortTip($config, $itemTotalAmount, $bigNum, $feeLabel);
  787. }
  788. }
  789. }
  790. $checkErr = self::checkLimit(
  791. $shopId,
  792. $mainId,
  793. $sendType,
  794. $itemTotalAmount,
  795. $bigNum,
  796. $costs['packCost'],
  797. $costs['sendCost'],
  798. $customId,
  799. $customInfo
  800. );
  801. if ($checkErr !== '') {
  802. $canOrder = false;
  803. $tip = $checkErr;
  804. }
  805. return [
  806. 'sendCost' => (float)$costs['sendCost'],
  807. 'packCost' => (float)$costs['packCost'],
  808. 'unMeetSendCost' => (float)$costs['sendCost'],
  809. 'packingFee' => (float)$costs['packCost'],
  810. 'belowMinimum' => $belowMinimum,
  811. 'canOrder' => $canOrder,
  812. 'tip' => $tip,
  813. ];
  814. }
  815. /**
  816. * 混合结算页:一次预览各购买方式未达门槛附加费(不含 style=2 跑腿距离运费)
  817. * @return array 键为 style 字符串,如 ['0' => [...], '1' => [...]]
  818. */
  819. public static function previewMixUnMeetFeeBatch($shopId, $mainId, $itemTotalAmount, $bigNum, $customId = 0, $hd = null, $custom = null)
  820. {
  821. $customInfo = self::buildCustomInfo($hd, $custom);
  822. $fees = [];
  823. for ($style = 0; $style <= 4; $style++) {
  824. $fees[(string)$style] = self::previewMixUnMeetFee(
  825. $shopId,
  826. $mainId,
  827. $style,
  828. $itemTotalAmount,
  829. $bigNum,
  830. (int)$customId,
  831. $customInfo
  832. );
  833. }
  834. return $fees;
  835. }
  836. /**
  837. * 计算未达最低消费时应写入订单的附加运费/包装费
  838. * @return array ['sendCost' => 附加运费, 'packCost' => 附加包装费]
  839. */
  840. public static function calcFee($shopId, $mainId, $sendType, $itemTotalAmount, $bigNum, $customId = 0, $customInfo = [])
  841. {
  842. $costs = ['sendCost' => 0, 'packCost' => 0];
  843. $style = (int)$sendType;
  844. if ($style < 0 || $style > 4) {
  845. return $costs;
  846. }
  847. $config = self::getConfig($shopId, $mainId, $style);
  848. if (empty($config)) {
  849. return $costs;
  850. }
  851. $config = self::mergeCustomHomeRule($config, $style, $customInfo);
  852. if (!self::isBelowMinimum($itemTotalAmount, $bigNum, $config)) {
  853. return $costs;
  854. }
  855. $unMeet = isset($config['unMeet']) ? (int)$config['unMeet'] : 0;
  856. $unMeetFee = isset($config['unMeetFee']) ? (float)$config['unMeetFee'] : 0;
  857. if ($unMeetFee <= 0) {
  858. return $costs;
  859. }
  860. // unMeet: 0 加收运费,2 加收包装费(每客户每购买方式每日至多收一次;首单未收则当日不再收)
  861. if ($unMeet === 0 && !self::hasSendFeeDecisionToday($customId, $style)) {
  862. $costs['sendCost'] = $unMeetFee;
  863. } elseif ($unMeet === 2 && !self::hasPackFeeDecisionToday($customId, $style)) {
  864. $costs['packCost'] = $unMeetFee;
  865. }
  866. return $costs;
  867. }
  868. /**
  869. * 下单前校验配送方式最低消费与附加费是否已正确写入
  870. * @param float $packCost 附加包装费
  871. * @param float $unMeetSendCost 未达门槛附加运费(不含跑腿报价)
  872. * @return string 空字符串表示通过
  873. */
  874. public static function checkLimit($shopId, $mainId, $sendType, $actPrice, $bigNum, $packCost = 0, $unMeetSendCost = 0, $customId = 0, $customInfo = [])
  875. {
  876. $style = (int)$sendType;
  877. if ($style < 0 || $style > 4) {
  878. return '配送方式无效';
  879. }
  880. $config = self::getConfig($shopId, $mainId, $style);
  881. if (empty($config)) {
  882. return '配送方式配置异常';
  883. }
  884. $config = self::mergeCustomHomeRule($config, $style, $customInfo);
  885. $methodName = !empty($config['name']) ? $config['name'] : '该配送方式';
  886. if (isset($config['status']) && (int)$config['status'] === 0) {
  887. return "暂不支持{$methodName},请选其它配送方式";
  888. }
  889. if (!self::isBelowMinimum($actPrice, $bigNum, $config)) {
  890. return '';
  891. }
  892. $minAmount = isset($config['minAmount']) ? (float)$config['minAmount'] : 0.00;
  893. $minNum = isset($config['minNum']) ? (int)$config['minNum'] : 0;
  894. $unMeet = isset($config['unMeet']) ? (int)$config['unMeet'] : 0;
  895. $unMeetFee = isset($config['unMeetFee']) ? (float)$config['unMeetFee'] : 0;
  896. if ($unMeet === 1) {
  897. $conditionText = self::buildUnMeetConditionText($config);
  898. if ($conditionText !== '') {
  899. return "订单不满{$conditionText},不能下单";
  900. }
  901. return '未达到最低消费,不能下单';
  902. }
  903. if (($unMeet === 0 || $unMeet === 2) && $unMeetFee > 0) {
  904. $feeToCheck = $unMeet === 2 ? $packCost : $unMeetSendCost;
  905. if ($unMeet === 2 && self::hasPackFeeDecisionToday($customId, $style)) {
  906. return '';
  907. }
  908. if ($unMeet === 0 && self::hasSendFeeDecisionToday($customId, $style)) {
  909. return '';
  910. }
  911. if (bccomp((string)$feeToCheck, (string)$unMeetFee, 2) < 0) {
  912. $feeLabel = $unMeet === 2 ? '包装费' : '运费';
  913. $conditionText = self::buildUnMeetConditionText($config);
  914. if ($conditionText !== '') {
  915. return "订单不满{$conditionText},加{$feeLabel}{$unMeetFee}元";
  916. }
  917. return "未达最低消费,需加收{$feeLabel}{$unMeetFee}元";
  918. }
  919. }
  920. return '';
  921. }
  922. }