HdRefundMallClass.php 41 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104
  1. <?php
  2. /**
  3. * 用途:商城顾客售后申请(落 xhHdRefund 待审核)
  4. * 谁用:app-mall RefundController 提交/列表/撤销、app-hd RefundController 审核通过/驳回
  5. * 解决:申请只落待审核记录不动资金库存;审核通过时复用 HdRefundService 执行真实退款
  6. */
  7. namespace bizHd\refund\classes;
  8. use bizHd\custom\classes\CustomClass;
  9. use bizHd\custom\classes\HdClass;
  10. use bizHd\hb\classes\HbClass;
  11. use bizHd\order\classes\OrderClass;
  12. use bizHd\order\classes\OrderGoodsClass;
  13. use bizHd\order\classes\OrderItemClass;
  14. use bizHd\refund\services\HdRefundService;
  15. use bizHd\shop\classes\ShopClass;
  16. use biz\shop\classes\MainClass;
  17. use biz\shop\classes\ShopClass as SharedShopClass;
  18. use common\components\imgUtil;
  19. use common\components\orderSn;
  20. use common\components\util;
  21. class HdRefundMallClass
  22. {
  23. /**
  24. * 创建售后申请(仅落库待审核,不触达资金/库存)
  25. * 若该订单最新顾客申请为已取消,则复用该记录把状态改回待审核
  26. * @param array $post 含 refundType/product/price/remark 等
  27. * @param object $order xhOrder 对象
  28. * @param array $extra 含 userId/hdId/customId/shopId/mainId/sjId 等上下文
  29. * @return object
  30. */
  31. public static function createApply($post, $order, $extra = [])
  32. {
  33. $refundType = (int)($post['refundType'] ?? HdRefundClass::REFUND_TYPE_MONEY_GOOD);
  34. if (!in_array($refundType, [HdRefundClass::REFUND_TYPE_MONEY_GOOD, HdRefundClass::REFUND_TYPE_MONEY], true)) {
  35. util::fail('退款方式不正确');
  36. }
  37. $productList = [];
  38. if ($refundType === HdRefundClass::REFUND_TYPE_MONEY_GOOD) {
  39. $productJson = $post['product'] ?? '';
  40. if (is_string($productJson)) {
  41. $productList = json_decode($productJson, true);
  42. } elseif (is_array($productJson)) {
  43. $productList = $productJson;
  44. }
  45. if (empty($productList) || !is_array($productList)) {
  46. util::fail('请选择要退的商品');
  47. }
  48. $productList = self::validateAndEnrichProduct($productList, $order);
  49. // 退货并退款由服务端按秒杀/团购活动价 + 普通商品分摊优惠重算,不信任前端 price
  50. $refundPrice = self::calcReturnRefundPrice($productList, $order);
  51. } else {
  52. $refundPrice = $post['price'] ?? 0;
  53. }
  54. $couldRefund = self::remainRefundCash($order);
  55. // 红包全额抵扣后实付为 0:允许提交 0 元申请,审核通过后退回红包;其它 0 元仍拦截
  56. if (!is_numeric($refundPrice) || bccomp((string)$refundPrice, '0', 2) < 0) {
  57. util::fail('退款金额不正确');
  58. }
  59. if (bccomp((string)$refundPrice, '0', 2) === 0 && !self::allowZeroCashRefundByHb($order)) {
  60. util::fail('退款金额必须大于0');
  61. }
  62. if (bccomp((string)$refundPrice, $couldRefund, 2) === 1) {
  63. util::fail('退款金额超过可退金额');
  64. }
  65. // 申请时写入原单支付快照,待审核详情/列表即可展示退回方式;通过时 addRefund 会再按原单刷新
  66. $snap = HdNextDayRefundClass::buildRefundPaySnapshot($order, true);
  67. // 预判当天/封账退款口径,便于待审核展示;最终以审核通过时 mustUseNextDay 为准
  68. $mainId = (int)($extra['mainId'] ?? $order->mainId ?? 0);
  69. $main = $mainId > 0 ? MainClass::getById($mainId, true) : null;
  70. $sameDay = HdNextDayRefundClass::mustUseNextDay($order, $main)
  71. ? HdRefundClass::SAME_DAY_NO
  72. : HdRefundClass::SAME_DAY_YES;
  73. $data = [
  74. 'mainId' => $mainId,
  75. 'shopId' => (int)($extra['shopId'] ?? $order->shopId ?? 0),
  76. 'sjId' => (int)($extra['sjId'] ?? $order->sjId ?? 0),
  77. 'hdId' => (int)($extra['hdId'] ?? $order->hdId ?? 0),
  78. 'customId' => self::resolveApplyCustomId($extra, $order),
  79. 'customName' => $order->customName ?? '',
  80. 'userId' => (int)($extra['userId'] ?? 0),
  81. 'orderId' => (int)($order->id ?? 0),
  82. 'orderSn' => $order->orderSn ?? '',
  83. 'refundSn' => orderSn::getHdRefundSn(),
  84. 'refundType' => $refundType,
  85. 'product' => !empty($productList) ? json_encode($productList, JSON_UNESCAPED_UNICODE) : '',
  86. // 顾客上传售后图短路径 JSON,审核页展开为 smallImgList
  87. 'imgList' => self::normalizeImgListJson($post['imgList'] ?? ''),
  88. 'refundPrice' => round((float)$refundPrice, 2),
  89. 'remark' => trim((string)($post['remark'] ?? '')),
  90. 'status' => HdRefundClass::STATUS_PENDING,
  91. 'shopAdminId' => 0,
  92. 'shopAdminName' => '',
  93. 'passTime' => null,
  94. 'rejectReason' => '',
  95. 'sameDay' => $sameDay,
  96. 'onlinePay' => $snap['onlinePay'],
  97. 'payWay' => $snap['payWay'],
  98. 'hasReturn' => $snap['hasReturn'],
  99. 'returnBalance' => $snap['returnBalance'],
  100. 'couldReturn' => $snap['couldReturn'],
  101. ];
  102. // 撤销后重新申请:复用已取消记录,避免同一订单堆多条取消单
  103. $latest = HdRefundClass::getLatestByOrderId((int)($order->id ?? 0));
  104. if (!empty($latest) && (int)($latest['status'] ?? -1) === HdRefundClass::STATUS_CANCEL) {
  105. unset($data['refundSn']);
  106. return self::reopenCancelledApply((int)$latest['id'], $data);
  107. }
  108. return HdRefundClass::add($data, true);
  109. }
  110. /**
  111. * 规范化售后图片字段:只保留短路径字符串列表再 JSON 编码
  112. * @param string|array $imgList 前端 JSON 或数组
  113. * @return string 空串或 JSON 数组字符串
  114. */
  115. public static function normalizeImgListJson($imgList)
  116. {
  117. $arr = [];
  118. if (is_string($imgList)) {
  119. $imgList = trim($imgList);
  120. if ($imgList === '') {
  121. return '';
  122. }
  123. $decoded = json_decode($imgList, true);
  124. $arr = is_array($decoded) ? $decoded : [];
  125. } elseif (is_array($imgList)) {
  126. $arr = $imgList;
  127. }
  128. $paths = [];
  129. foreach ($arr as $item) {
  130. if (!is_string($item)) {
  131. continue;
  132. }
  133. $path = trim($item);
  134. // 若误传完整 URL,尽量截成短路径
  135. if (strpos($path, 'http://') === 0 || strpos($path, 'https://') === 0) {
  136. $parts = parse_url($path);
  137. $path = ltrim((string)($parts['path'] ?? ''), '/');
  138. }
  139. if ($path === '') {
  140. continue;
  141. }
  142. $paths[] = $path;
  143. if (count($paths) >= 6) {
  144. break;
  145. }
  146. }
  147. if (empty($paths)) {
  148. return '';
  149. }
  150. return json_encode($paths, JSON_UNESCAPED_UNICODE);
  151. }
  152. /**
  153. * 给售后行挂上可预览的缩略图/大图列表(对齐 ghs refundDetail)
  154. * @param array $row
  155. * @return array
  156. */
  157. public static function attachImgDisplayFields($row)
  158. {
  159. if (!is_array($row)) {
  160. return $row;
  161. }
  162. $imgString = $row['imgList'] ?? '';
  163. $imgArr = [];
  164. if (is_string($imgString) && $imgString !== '') {
  165. $decoded = json_decode($imgString, true);
  166. if (is_array($decoded)) {
  167. $imgArr = $decoded;
  168. }
  169. } elseif (is_array($imgString)) {
  170. $imgArr = $imgString;
  171. }
  172. $smallImgList = [];
  173. $bigImgList = [];
  174. foreach ($imgArr as $image) {
  175. if (!is_string($image) || trim($image) === '') {
  176. continue;
  177. }
  178. $short = trim($image);
  179. $smallImgList[] = imgUtil::groupImg($short) . '?x-oss-process=image/resize,m_fill,h_130,w_130';
  180. $bigImgList[] = imgUtil::groupImg($short) . '?x-oss-process=image/resize,m_fill,h_700,w_700';
  181. }
  182. $row['imgList'] = $imgArr;
  183. $row['smallImgList'] = $smallImgList;
  184. $row['bigImgList'] = $bigImgList;
  185. return $row;
  186. }
  187. /**
  188. * 将已取消申请改回待审核
  189. * 顾客撤销后再次提交时复用同一条记录,刷新申请内容并清掉审核痕迹
  190. * @param int $id 已取消申请 id
  191. * @param array $data 新的申请字段
  192. * @return object 更新后的售后对象
  193. */
  194. protected static function reopenCancelledApply($id, $data)
  195. {
  196. $refund = HdRefundClass::getById($id, true);
  197. if (empty($refund) || (int)$refund->status !== HdRefundClass::STATUS_CANCEL) {
  198. util::fail('申请状态已变化,请刷新后重试');
  199. }
  200. foreach ($data as $key => $val) {
  201. if ($key === 'id') {
  202. continue;
  203. }
  204. $refund->$key = $val;
  205. }
  206. $refund->status = HdRefundClass::STATUS_PENDING;
  207. $refund->rejectReason = '';
  208. $refund->shopAdminId = 0;
  209. $refund->shopAdminName = '';
  210. $refund->passTime = null;
  211. $refund->addTime = date('Y-m-d H:i:s');
  212. if (!$refund->save()) {
  213. throw new \Exception('重新申请失败');
  214. }
  215. return $refund;
  216. }
  217. /**
  218. * 申请客户 id:请求上下文 customId 为 0 时回退到原订单,保证联系客服能进会话
  219. * @param array $extra
  220. * @param object $order
  221. * @return int
  222. */
  223. protected static function resolveApplyCustomId($extra, $order)
  224. {
  225. $customId = (int)($extra['customId'] ?? 0);
  226. if ($customId > 0) {
  227. return $customId;
  228. }
  229. return (int)($order->customId ?? 0);
  230. }
  231. /**
  232. * 校验退货数量不超过可退数量,并补齐商品名、订单行单价快照
  233. * @param array $productList [{productId,num,unitPrice,property,name?}] property 0商品 1花材
  234. * @param object $order
  235. * @return array 补齐 name/unitPrice 后的列表
  236. */
  237. protected static function validateAndEnrichProduct($productList, $order)
  238. {
  239. $orderSn = $order->orderSn ?? '';
  240. $goodsMap = OrderGoodsClass::getAllByCondition(
  241. ['orderSn' => $orderSn],
  242. null,
  243. '*',
  244. 'goodsId',
  245. true
  246. );
  247. $itemMap = OrderItemClass::getAllByCondition(
  248. ['orderSn' => $orderSn],
  249. null,
  250. '*',
  251. 'itemId',
  252. true
  253. );
  254. $enriched = [];
  255. foreach ($productList as $row) {
  256. $num = (float)($row['num'] ?? 0);
  257. if ($num <= 0) {
  258. util::fail('退货数量必须大于0');
  259. }
  260. $productId = (int)($row['productId'] ?? 0);
  261. $property = (int)($row['property'] ?? 0);
  262. if ($property === 0) {
  263. $line = $goodsMap[$productId] ?? null;
  264. if (empty($line)) {
  265. util::fail('没有找到退款商品');
  266. }
  267. $remain = bcsub((string)($line->num ?? 0), (string)($line->refundNum ?? 0), 2);
  268. if (bccomp((string)$num, $remain, 2) === 1) {
  269. $name = $line->name ?? '商品';
  270. util::fail("{$name}超过可退数量");
  271. }
  272. $row['name'] = $row['name'] ?? ($line->name ?? '');
  273. $row['unitPrice'] = $line->unitPrice ?? ($row['unitPrice'] ?? 0);
  274. $row['seckillGoodsId'] = (int)($line->seckillGoodsId ?? 0);
  275. if (empty($row['cover'])) {
  276. $row['cover'] = $line->cover ?? '';
  277. }
  278. if (empty($row['styleName'])) {
  279. $row['styleName'] = $line->styleName ?? '';
  280. }
  281. } else {
  282. $line = $itemMap[$productId] ?? null;
  283. if (empty($line)) {
  284. util::fail('没有找到退款花材');
  285. }
  286. $remain = bcsub((string)($line->num ?? 0), (string)($line->refundNum ?? 0), 2);
  287. if (bccomp((string)$num, $remain, 2) === 1) {
  288. $name = $line->name ?? '花材';
  289. util::fail("{$name}超过可退数量");
  290. }
  291. $row['name'] = $row['name'] ?? ($line->name ?? '');
  292. $row['unitPrice'] = $line->unitPrice ?? ($row['unitPrice'] ?? 0);
  293. if (empty($row['unitName'])) {
  294. $row['unitName'] = $line->unitName ?? '';
  295. }
  296. if (empty($row['cover'])) {
  297. $row['cover'] = $line->cover ?? '';
  298. }
  299. }
  300. $enriched[] = $row;
  301. }
  302. return $enriched;
  303. }
  304. /**
  305. * 退货并退款应退金额:秒杀/团购按行上活动单价;普通商品再按整单优惠占比扣除会员折、满减、红包
  306. * @param array $productList 已用订单行单价、seckillGoodsId 补齐的勾选商品
  307. * @param object $order xhOrder
  308. * @return float 不超过可退上限的应退金额
  309. */
  310. protected static function calcReturnRefundPrice($productList, $order)
  311. {
  312. $isGroupBuy = (int)($order->groupBuyId ?? 0) > 0;
  313. $selectedActivity = 0.0;
  314. $selectedNormal = 0.0;
  315. foreach ($productList as $row) {
  316. $num = $row['num'] ?? 0;
  317. $unitPrice = $row['unitPrice'] ?? 0;
  318. if (!is_numeric($num) || !is_numeric($unitPrice)) {
  319. continue;
  320. }
  321. $line = (float)$num * (float)$unitPrice;
  322. if ($isGroupBuy || (int)($row['seckillGoodsId'] ?? 0) > 0) {
  323. $selectedActivity += $line;
  324. } else {
  325. $selectedNormal += $line;
  326. }
  327. }
  328. $hbAmount = self::toMoney($order->hbAmount ?? 0);
  329. $prePrice = self::toMoney($order->prePrice ?? 0);
  330. if (bccomp($prePrice, '0', 2) > 0) {
  331. $allocBase = bcadd($prePrice, $hbAmount, 2);
  332. } else {
  333. $allocBase = self::toMoney($order->goodsPrice ?? 0);
  334. $allocBase = bcadd($allocBase, self::toMoney($order->sendCost ?? 0), 2);
  335. $allocBase = bcadd($allocBase, self::toMoney($order->packingFee ?? 0), 2);
  336. $allocBase = bcadd($allocBase, self::toMoney($order->labourCost ?? 0), 2);
  337. $allocBase = bcadd($allocBase, self::toMoney($order->serviceFee ?? 0), 2);
  338. }
  339. $activityOriginal = self::sumOrderActivityOriginal($order, $isGroupBuy);
  340. $normalBase = (float)$allocBase - (float)$activityOriginal;
  341. if ($normalBase < 0) {
  342. $normalBase = 0;
  343. }
  344. $discountTotal = self::toMoney($order->discountAmount ?? 0);
  345. $discountTotal = bcadd($discountTotal, self::toMoney($order->orderReachDiscountPrice ?? 0), 2);
  346. $discountTotal = bcadd($discountTotal, $hbAmount, 2);
  347. $normalPrice = $selectedNormal;
  348. if ($selectedNormal > 0 && $normalBase > 0 && (float)$discountTotal > 0) {
  349. $normalPrice = $selectedNormal - ($selectedNormal * (float)$discountTotal / $normalBase);
  350. }
  351. $price = number_format(round($selectedActivity + $normalPrice, 2), 2, '.', '');
  352. if (bccomp($price, '0', 2) < 0) {
  353. $price = '0.00';
  354. }
  355. $couldRefund = self::remainRefundCash($order);
  356. if (bccomp($price, $couldRefund, 2) === 1) {
  357. $price = $couldRefund;
  358. }
  359. return round((float)$price, 2);
  360. }
  361. /**
  362. * 整单秒杀/团购商品原价合计,供从优惠分摊基数中剔除
  363. * @param object $order
  364. * @param bool $isGroupBuy 拼团整单则商品总额都是活动价
  365. * @return float
  366. */
  367. protected static function sumOrderActivityOriginal($order, $isGroupBuy)
  368. {
  369. if ($isGroupBuy) {
  370. return (float)self::toMoney($order->goodsPrice ?? 0);
  371. }
  372. $orderSn = $order->orderSn ?? '';
  373. if ($orderSn === '') {
  374. return 0.0;
  375. }
  376. $goodsRows = OrderGoodsClass::getAllByCondition(['orderSn' => $orderSn]) ?: [];
  377. $total = 0.0;
  378. foreach ($goodsRows as $goods) {
  379. $seckillGoodsId = is_array($goods)
  380. ? (int)($goods['seckillGoodsId'] ?? 0)
  381. : (int)($goods->seckillGoodsId ?? 0);
  382. if ($seckillGoodsId <= 0) {
  383. continue;
  384. }
  385. $num = is_array($goods) ? ($goods['num'] ?? 0) : ($goods->num ?? 0);
  386. $unitPrice = is_array($goods) ? ($goods['unitPrice'] ?? 0) : ($goods->unitPrice ?? 0);
  387. if (!is_numeric($num) || !is_numeric($unitPrice)) {
  388. continue;
  389. }
  390. $total += (float)$num * (float)$unitPrice;
  391. }
  392. return round($total, 2);
  393. }
  394. /**
  395. * 金额转两位小数字符串,供 bcmath 分摊,避免浮点误差
  396. * @param mixed $value
  397. * @return string
  398. */
  399. protected static function toMoney($value)
  400. {
  401. if ($value === null || $value === '' || !is_numeric($value)) {
  402. return '0.00';
  403. }
  404. return number_format((float)$value, 2, '.', '');
  405. }
  406. /**
  407. * 从订单数组或对象取字段;详情接口和申请页传入形态不同
  408. * @param array|object $order
  409. * @param string $key
  410. * @param mixed $default
  411. * @return mixed
  412. */
  413. protected static function orderField($order, $key, $default = 0)
  414. {
  415. if (is_object($order)) {
  416. return $order->$key ?? $default;
  417. }
  418. if (is_array($order)) {
  419. return $order[$key] ?? $default;
  420. }
  421. return $default;
  422. }
  423. /**
  424. * 剩余可退现金:实付合计 orderPrice 减当天已退 tkPrice 与封账退款 nextDayTkPrice
  425. * 红包抵扣已计入 orderPrice,所以红包全额单这里为 0
  426. * @param array|object $order
  427. * @return string 两位小数字符串,不会为负
  428. */
  429. public static function remainRefundCash($order)
  430. {
  431. $already = bcadd(
  432. self::toMoney(self::orderField($order, 'tkPrice', 0)),
  433. self::toMoney(self::orderField($order, 'nextDayTkPrice', 0)),
  434. 2
  435. );
  436. $could = bcsub(
  437. self::toMoney(self::orderField($order, 'orderPrice', 0)),
  438. $already,
  439. 2
  440. );
  441. if (bccomp($could, '0', 2) < 0) {
  442. return '0.00';
  443. }
  444. return $could;
  445. }
  446. /**
  447. * 红包是否仍占用本单未退回:下单写正数 hbId,退回后改成负数
  448. * @param array|object $order
  449. * @return bool
  450. */
  451. public static function hasUnreturnedHb($order)
  452. {
  453. return (int)self::orderField($order, 'hbId', 0) > 0;
  454. }
  455. /**
  456. * 订单实付现金:优先 mainPay(详情「订单合计」),否则 actPrice / orderPrice
  457. * @param array|object $order
  458. * @return string 两位小数字符串
  459. */
  460. public static function orderPayAmount($order)
  461. {
  462. foreach (['mainPay', 'actPrice', 'orderPrice'] as $key) {
  463. if (self::orderFieldExists($order, $key)) {
  464. return self::toMoney(self::orderField($order, $key, 0));
  465. }
  466. }
  467. return '0.00';
  468. }
  469. /**
  470. * 字段是否存在于订单数组或对象上;mainPay=0 也算有值,不能当缺失
  471. * @param array|object $order
  472. * @param string $key
  473. * @return bool
  474. */
  475. protected static function orderFieldExists($order, $key)
  476. {
  477. if (is_object($order)) {
  478. return isset($order->$key);
  479. }
  480. if (is_array($order)) {
  481. return array_key_exists($key, $order);
  482. }
  483. return false;
  484. }
  485. /**
  486. * 现金为 0 时是否仍可申请:红包未退回,且红包金额大于订单实付
  487. * 红包把合计抵成 0 时 hbAmount > mainPay;红包已退回或金额不足以覆盖实付则不允许
  488. * @param array|object $order
  489. * @return bool
  490. */
  491. public static function allowZeroCashRefundByHb($order)
  492. {
  493. if (!self::hasUnreturnedHb($order)) {
  494. return false;
  495. }
  496. $hbAmount = self::toMoney(self::orderField($order, 'hbAmount', 0));
  497. $payAmount = self::orderPayAmount($order);
  498. return bccomp($hbAmount, $payAmount, 2) === 1;
  499. }
  500. /**
  501. * 是否还有可售后标的:剩余现金>0,或现金为 0 但红包未退回且红包金额大于实付
  502. * @param array|object $order
  503. * @return bool
  504. */
  505. public static function hasRefundableAmount($order)
  506. {
  507. if (bccomp(self::remainRefundCash($order), '0', 2) > 0) {
  508. return true;
  509. }
  510. return self::allowZeroCashRefundByHb($order);
  511. }
  512. /**
  513. * 商家审核通过:复用已有待审核行执行真实退款 + 红包按过期退回 + 消费回退
  514. * @param object $refund xhHdRefund 待审核对象
  515. * @param object $order xhOrder(需可写)
  516. * @param array $shopAdmin 审核人信息 [id, name, shopId, sjId, mainId]
  517. * @return object 更新后的售后单
  518. */
  519. public static function approve($refund, $order, $shopAdmin = [])
  520. {
  521. if ((int)$refund->status !== HdRefundClass::STATUS_PENDING) {
  522. util::fail('仅待审核申请可通过');
  523. }
  524. $productList = [];
  525. if (!empty($refund->product)) {
  526. $decoded = json_decode($refund->product, true);
  527. if (is_array($decoded)) {
  528. $productList = $decoded;
  529. }
  530. }
  531. $post = [
  532. 'price' => $refund->refundPrice,
  533. 'refundType' => (int)$refund->refundType,
  534. 'product' => $productList,
  535. 'remark' => $refund->remark ?? '',
  536. 'shopId' => (int)($shopAdmin['shopId'] ?? $refund->shopId),
  537. 'sjId' => (int)($shopAdmin['sjId'] ?? $order->sjId ?? 0),
  538. 'mainId' => (int)($shopAdmin['mainId'] ?? $refund->mainId),
  539. 'shopAdminId' => (int)($shopAdmin['id'] ?? 0),
  540. 'shopAdminName' => $shopAdmin['name'] ?? '',
  541. ];
  542. // 复用待审核行写明细/回库/资金,避免再插一条
  543. $refund = HdRefundService::addRefund($post, $order, $refund);
  544. // 当天售后才在此回退累计消费;封账/隔天已在 applyHdAmountAndFund 扣过,避免重复
  545. $sameDay = intval($refund->sameDay ?? HdRefundClass::SAME_DAY_YES);
  546. if ($sameDay === HdRefundClass::SAME_DAY_YES) {
  547. $custom = CustomClass::getLockById($order->customId);
  548. if (!empty($custom)) {
  549. $custom->buyAmount = bcsub((string)$custom->buyAmount, (string)$refund->refundPrice, 2);
  550. $customName = $custom->name ?? $order->customName ?? '';
  551. $orderSnText = $order->orderSn ?? '';
  552. $eventPrefix = $customName !== '' ? $customName : '客户';
  553. $custom->eventRemark = $eventPrefix . '商城售后' . $orderSnText;
  554. $custom->relateId = $order->id;
  555. $custom->relateType = 1;
  556. $custom->staffId = (int)($shopAdmin['id'] ?? 0);
  557. $custom->staffName = $shopAdmin['name'] ?? '';
  558. $custom->mainId = (int)($shopAdmin['mainId'] ?? $refund->mainId);
  559. $custom->save();
  560. $hd = HdClass::getLockById($custom->hdId);
  561. if (!empty($hd)) {
  562. $hd->expendAmount = bcsub((string)$hd->expendAmount, (string)$refund->refundPrice, 2);
  563. $hd->save();
  564. }
  565. }
  566. }
  567. $updatedOrder = OrderClass::getById($order->id, true, 'id, actPrice, hbId, hbAmount');
  568. if (!empty($updatedOrder) && (int)$updatedOrder->hbId > 0 && bccomp((string)$updatedOrder->actPrice, '0', 2) <= 0) {
  569. HbClass::hbBackIfNotExpired($updatedOrder);
  570. }
  571. return $refund;
  572. }
  573. /**
  574. * 商家驳回申请
  575. * @param object $refund
  576. * @param string $reason
  577. * @param array $shopAdmin
  578. * @return object
  579. */
  580. public static function reject($refund, $reason, $shopAdmin = [])
  581. {
  582. if ((int)$refund->status !== HdRefundClass::STATUS_PENDING) {
  583. util::fail('仅待审核申请可驳回');
  584. }
  585. $reason = trim((string)$reason);
  586. if ($reason === '') {
  587. util::fail('请填写驳回原因');
  588. }
  589. $refund->status = HdRefundClass::STATUS_REJECT;
  590. $refund->rejectReason = mb_substr($reason, 0, 250);
  591. $refund->shopAdminId = (int)($shopAdmin['id'] ?? 0);
  592. $refund->shopAdminName = $shopAdmin['name'] ?? '';
  593. $refund->passTime = date('Y-m-d H:i:s');
  594. $refund->save();
  595. return $refund;
  596. }
  597. /**
  598. * 顾客撤销待审核申请
  599. * @param object $refund
  600. * @return object
  601. */
  602. public static function cancel($refund)
  603. {
  604. if ((int)$refund->status !== HdRefundClass::STATUS_PENDING) {
  605. util::fail('仅待审核申请可撤销');
  606. }
  607. $refund->status = HdRefundClass::STATUS_CANCEL;
  608. $refund->save();
  609. return $refund;
  610. }
  611. /**
  612. * 顾客端售后列表(mallApp 退款售后页,跨店;可按订单收窄)
  613. * @param int $userId 当前登录商城用户
  614. * @param string $tab all|pending|0|refunded|1|closed|2|3
  615. * @param int $orderId 可选,>0 时只返回该订单的顾客申请(订单详情「申请记录」)
  616. * @return array 分页 list + pendingCount
  617. */
  618. public static function getCustomApplyList($userId, $tab = 'all', $orderId = 0)
  619. {
  620. $userId = (int)$userId;
  621. $orderId = (int)$orderId;
  622. $where = ['userId' => $userId];
  623. // 订单详情进入时只看本单,避免混入其它订单售后
  624. if ($orderId > 0) {
  625. $where['orderId'] = $orderId;
  626. }
  627. $tab = (string)$tab;
  628. if ($tab === 'pending' || $tab === '0') {
  629. $where['status'] = HdRefundClass::STATUS_PENDING;
  630. } elseif ($tab === 'refunded' || $tab === '1') {
  631. $where['status'] = HdRefundClass::STATUS_PASS;
  632. } elseif ($tab === 'closed') {
  633. $where['status'] = ['in', [HdRefundClass::STATUS_REJECT, HdRefundClass::STATUS_CANCEL]];
  634. } elseif ($tab !== '' && $tab !== 'all' && is_numeric($tab)) {
  635. $where['status'] = (int)$tab;
  636. }
  637. $data = HdRefundClass::getList('*', $where, 'addTime DESC');
  638. $pendingWhere = [
  639. 'userId' => $userId,
  640. 'status' => HdRefundClass::STATUS_PENDING,
  641. ];
  642. if ($orderId > 0) {
  643. $pendingWhere['orderId'] = $orderId;
  644. }
  645. $data['pendingCount'] = (int)HdRefundClass::getCount($pendingWhere);
  646. if (!empty($data['list'])) {
  647. $data['list'] = self::formatCustomList($data['list']);
  648. } else {
  649. $data['list'] = [];
  650. }
  651. return $data;
  652. }
  653. /**
  654. * 顾客端售后详情:单条申请补齐与列表相同的展示字段
  655. * @param array $row xhHdRefund 行
  656. * @return array
  657. */
  658. public static function formatCustomDetail($row)
  659. {
  660. $list = self::formatCustomList([$row]);
  661. $detail = $list[0] ?? $row;
  662. // 详情再补图片预览字段,列表可不带大图
  663. return self::attachImgDisplayFields($detail);
  664. }
  665. /**
  666. * 状态短文案(审核语义)
  667. * @param int $status
  668. * @return string
  669. */
  670. public static function statusText($status)
  671. {
  672. $map = [
  673. HdRefundClass::STATUS_PENDING => '待审核',
  674. HdRefundClass::STATUS_PASS => '已通过',
  675. HdRefundClass::STATUS_REJECT => '已驳回',
  676. HdRefundClass::STATUS_CANCEL => '已取消',
  677. ];
  678. return $map[(int)$status] ?? '';
  679. }
  680. /**
  681. * 列表卡片主状态文案,对齐设计稿「退款中·待商家处理 / 退款成功」
  682. * 已返充时用「退款成功·已返充」,列表右上角也能看清
  683. * @param int $status
  684. * @param int $refundType 1退货退款 2仅退款
  685. * @param array $row 可选,用于通过后按资金态细化标题
  686. * @return string
  687. */
  688. public static function displayStatusTitle($status, $refundType, $row = [])
  689. {
  690. $status = (int)$status;
  691. if ($status === HdRefundClass::STATUS_PENDING) {
  692. return (int)$refundType === HdRefundClass::REFUND_TYPE_MONEY_GOOD
  693. ? '退货中·待商家处理'
  694. : '退款中·待商家处理';
  695. }
  696. if ($status === HdRefundClass::STATUS_PASS) {
  697. if ((int)($row['returnBalance'] ?? 0) === HdNextDayRefundClass::FLAG_YES) {
  698. return '退款成功·已返充';
  699. }
  700. if ((int)($row['hasReturn'] ?? 0) === HdNextDayRefundClass::FLAG_YES) {
  701. return '退款成功';
  702. }
  703. if ((int)($row['couldReturn'] ?? 0) === HdNextDayRefundClass::FLAG_YES) {
  704. return '退款成功·待退还';
  705. }
  706. return '退款成功';
  707. }
  708. if ($status === HdRefundClass::STATUS_REJECT) {
  709. return '已驳回';
  710. }
  711. if ($status === HdRefundClass::STATUS_CANCEL) {
  712. return '已取消';
  713. }
  714. return self::statusText($status);
  715. }
  716. /**
  717. * 资金落地文案:返充 / 原路 / 待商家退还(供列表 tip 与详情「退回方式」)
  718. * @param array $row
  719. * @return string
  720. */
  721. public static function fundStatusText($row)
  722. {
  723. $status = (int)($row['status'] ?? 0);
  724. if ($status !== HdRefundClass::STATUS_PASS) {
  725. return '';
  726. }
  727. if ((int)($row['returnBalance'] ?? 0) === HdNextDayRefundClass::FLAG_YES) {
  728. return '已返充到余额';
  729. }
  730. if ((int)($row['hasReturn'] ?? 0) === HdNextDayRefundClass::FLAG_YES) {
  731. return '已原路退回';
  732. }
  733. // 审核通过但线下等尚未选手选落地
  734. if ((int)($row['couldReturn'] ?? 0) === HdNextDayRefundClass::FLAG_YES) {
  735. return '等待商家退还';
  736. }
  737. return '退款处理中';
  738. }
  739. /**
  740. * 卡片底部提示:处理中给审核时效;通过后按返充/原路/待退还区分,避免一律写「原路退回」
  741. * @param array $row
  742. * @return string
  743. */
  744. public static function displayStatusTip($row)
  745. {
  746. $status = (int)($row['status'] ?? 0);
  747. if ($status === HdRefundClass::STATUS_PENDING) {
  748. return '商家审核中·预计 24 小时内处理';
  749. }
  750. if ($status === HdRefundClass::STATUS_PASS) {
  751. $fundText = self::fundStatusText($row);
  752. $time = trim((string)($row['passTime'] ?? ''));
  753. $timeOk = $time !== '' && $time !== '0000-00-00 00:00:00';
  754. if ((int)($row['returnBalance'] ?? 0) === HdNextDayRefundClass::FLAG_YES) {
  755. return $timeOk
  756. ? ('退款已到账·已返充到余额 ' . $time)
  757. : '退款已到账·已返充到余额';
  758. }
  759. if ((int)($row['hasReturn'] ?? 0) === HdNextDayRefundClass::FLAG_YES) {
  760. return $timeOk
  761. ? ('退款已到账·原路退回 ' . $time)
  762. : '退款已到账·原路退回';
  763. }
  764. if ($fundText !== '') {
  765. return $timeOk ? ($fundText . ' ' . $time) : $fundText;
  766. }
  767. return '退款已通过';
  768. }
  769. if ($status === HdRefundClass::STATUS_REJECT) {
  770. $reason = trim((string)($row['rejectReason'] ?? ''));
  771. return $reason !== '' ? ('商家已驳回·' . $reason) : '商家已驳回';
  772. }
  773. if ($status === HdRefundClass::STATUS_CANCEL) {
  774. return '申请已取消';
  775. }
  776. return '';
  777. }
  778. /**
  779. * 退款类型标签:1退货退款 2仅退款
  780. * @param int $refundType
  781. * @return string
  782. */
  783. public static function refundTypeText($refundType)
  784. {
  785. return (int)$refundType === HdRefundClass::REFUND_TYPE_MONEY_GOOD ? '退货退款' : '仅退款';
  786. }
  787. /**
  788. * 批量补齐店铺名、商品封面/规格,避免列表 N+1
  789. * @param array $list
  790. * @return array
  791. */
  792. public static function formatCustomList($list)
  793. {
  794. if (empty($list) || !is_array($list)) {
  795. return [];
  796. }
  797. $shopIds = [];
  798. $hdIds = [];
  799. $orderSns = [];
  800. foreach ($list as $row) {
  801. $shopId = (int)($row['shopId'] ?? 0);
  802. if ($shopId > 0) {
  803. $shopIds[$shopId] = $shopId;
  804. }
  805. $hdId = (int)($row['hdId'] ?? 0);
  806. if ($hdId > 0) {
  807. $hdIds[$hdId] = $hdId;
  808. }
  809. $sn = trim((string)($row['orderSn'] ?? ''));
  810. if ($sn !== '') {
  811. $orderSns[$sn] = $sn;
  812. }
  813. }
  814. $shopMap = [];
  815. if (!empty($shopIds)) {
  816. $shopMap = ShopClass::getByIds(array_values($shopIds), null, 'id', 'id,shopName,merchantName,avatar') ?: [];
  817. }
  818. $hdCustomMap = [];
  819. if (!empty($hdIds)) {
  820. $hdRows = HdClass::getByIds(array_values($hdIds), null, 'id', 'id,customId') ?: [];
  821. foreach ($hdRows as $id => $hd) {
  822. $hdCustomMap[(int)$id] = (int)($hd['customId'] ?? 0);
  823. }
  824. }
  825. $orderCustomMap = [];
  826. $goodsBySn = [];
  827. $itemBySn = [];
  828. $firstGoodsBySn = [];
  829. if (!empty($orderSns)) {
  830. $snList = array_values($orderSns);
  831. $orderRows = OrderClass::getAllByCondition(['orderSn' => ['in', $snList]], null, 'orderSn,customId') ?: [];
  832. foreach ($orderRows as $order) {
  833. $sn = $order['orderSn'] ?? '';
  834. $cid = (int)($order['customId'] ?? 0);
  835. if ($sn !== '' && $cid > 0) {
  836. $orderCustomMap[$sn] = $cid;
  837. }
  838. }
  839. $goodsRows = OrderGoodsClass::getAllByCondition(['orderSn' => ['in', $snList]]) ?: [];
  840. foreach ($goodsRows as $goods) {
  841. $sn = $goods['orderSn'] ?? '';
  842. $gid = (int)($goods['goodsId'] ?? 0);
  843. if ($sn === '') {
  844. continue;
  845. }
  846. $goodsBySn[$sn][$gid] = $goods;
  847. if (!isset($firstGoodsBySn[$sn])) {
  848. $firstGoodsBySn[$sn] = $goods;
  849. }
  850. }
  851. $itemRows = OrderItemClass::getAllByCondition(['orderSn' => ['in', $snList]]) ?: [];
  852. foreach ($itemRows as $item) {
  853. $sn = $item['orderSn'] ?? '';
  854. $itemId = (int)($item['itemId'] ?? 0);
  855. if ($sn === '' || $itemId <= 0) {
  856. continue;
  857. }
  858. $itemBySn[$sn][$itemId] = $item;
  859. }
  860. }
  861. $formatted = [];
  862. foreach ($list as $row) {
  863. $formatted[] = self::formatCustomRow($row, $shopMap, $goodsBySn, $itemBySn, $firstGoodsBySn, $hdCustomMap, $orderCustomMap);
  864. }
  865. return $formatted;
  866. }
  867. /**
  868. * 单条申请转列表/详情展示结构
  869. * @param array $row
  870. * @param array $shopMap
  871. * @param array $goodsBySn
  872. * @param array $itemBySn
  873. * @param array $firstGoodsBySn
  874. * @param array $hdCustomMap
  875. * @param array $orderCustomMap
  876. * @return array
  877. */
  878. public static function formatCustomRow($row, $shopMap = [], $goodsBySn = [], $itemBySn = [], $firstGoodsBySn = [], $hdCustomMap = [], $orderCustomMap = [])
  879. {
  880. $status = (int)($row['status'] ?? 0);
  881. $refundType = (int)($row['refundType'] ?? HdRefundClass::REFUND_TYPE_MONEY);
  882. $shopId = (int)($row['shopId'] ?? 0);
  883. $orderSn = trim((string)($row['orderSn'] ?? ''));
  884. $shop = $shopMap[$shopId] ?? [];
  885. $row['customId'] = self::resolveChatCustomId($row, $hdCustomMap, $orderCustomMap);
  886. $productList = [];
  887. if (!empty($row['product'])) {
  888. $decoded = is_array($row['product']) ? $row['product'] : json_decode($row['product'], true);
  889. if (is_array($decoded)) {
  890. $productList = $decoded;
  891. }
  892. }
  893. $goodsList = [];
  894. foreach ($productList as $product) {
  895. $property = (int)($product['property'] ?? 0);
  896. $productId = (int)($product['productId'] ?? 0);
  897. $line = [];
  898. if ($property === 1) {
  899. $line = $itemBySn[$orderSn][$productId] ?? [];
  900. } else {
  901. $line = $goodsBySn[$orderSn][$productId] ?? [];
  902. }
  903. $goodsList[] = self::buildGoodsView($product, $line);
  904. }
  905. if (empty($goodsList) && isset($firstGoodsBySn[$orderSn])) {
  906. $goodsList[] = self::buildGoodsView([], $firstGoodsBySn[$orderSn]);
  907. }
  908. $row['productList'] = $productList;
  909. $row['goodsList'] = $goodsList;
  910. $row['goods'] = $goodsList[0] ?? self::emptyGoodsView();
  911. $displayName = SharedShopClass::formatDisplayShopName($shop);
  912. $row['hdName'] = $displayName !== '' ? $displayName : '花店';
  913. $row['hdAvatar'] = self::coverUrl($shop['avatar'] ?? '');
  914. $row['statusText'] = self::statusText($status);
  915. $row['statusTitle'] = self::displayStatusTitle($status, $refundType, $row);
  916. $row['statusTip'] = self::displayStatusTip($row);
  917. $row['fundStatusText'] = self::fundStatusText($row);
  918. $row['statusTone'] = self::statusTone($status);
  919. $row['refundTypeText'] = self::refundTypeText($refundType);
  920. $row['reasonText'] = self::reasonText($row);
  921. $row['canModify'] = $status === HdRefundClass::STATUS_PENDING ? 1 : 0;
  922. $row['refundPrice'] = round((float)($row['refundPrice'] ?? 0), 2);
  923. // 详情页「审核时间」读 auditTime;库字段是 passTime
  924. $passTime = trim((string)($row['passTime'] ?? ''));
  925. $row['auditTime'] = ($passTime !== '' && $passTime !== '0000-00-00 00:00:00') ? $passTime : '';
  926. // 资金三态显式转 int,前端判断返充/原路更稳
  927. $row['returnBalance'] = (int)($row['returnBalance'] ?? 0);
  928. $row['hasReturn'] = (int)($row['hasReturn'] ?? 0);
  929. $row['couldReturn'] = (int)($row['couldReturn'] ?? 0);
  930. return $row;
  931. }
  932. /**
  933. * 联系客服用的 customId:申请表可能为 0,依次用申请本身、xhHd、原订单补齐
  934. * @param array $row
  935. * @param array $hdCustomMap
  936. * @param array $orderCustomMap
  937. * @return int
  938. */
  939. protected static function resolveChatCustomId($row, $hdCustomMap, $orderCustomMap)
  940. {
  941. $applyCustomId = (int)($row['customId'] ?? 0);
  942. if ($applyCustomId > 0) {
  943. return $applyCustomId;
  944. }
  945. $hdId = (int)($row['hdId'] ?? 0);
  946. if ($hdId > 0 && !empty($hdCustomMap[$hdId])) {
  947. return (int)$hdCustomMap[$hdId];
  948. }
  949. $orderSn = trim((string)($row['orderSn'] ?? ''));
  950. if ($orderSn !== '' && !empty($orderCustomMap[$orderSn])) {
  951. return (int)$orderCustomMap[$orderSn];
  952. }
  953. return 0;
  954. }
  955. /**
  956. * 处理中橙色、成功绿色、关闭灰色,供前端绑 class
  957. * @param int $status
  958. * @return string pending|success|closed
  959. */
  960. protected static function statusTone($status)
  961. {
  962. $status = (int)$status;
  963. if ($status === HdRefundClass::STATUS_PENDING) {
  964. return 'pending';
  965. }
  966. if ($status === HdRefundClass::STATUS_PASS) {
  967. return 'success';
  968. }
  969. return 'closed';
  970. }
  971. /**
  972. * 售后原因:优先顾客备注
  973. * @param array $row
  974. * @return string
  975. */
  976. protected static function reasonText($row)
  977. {
  978. $remark = trim((string)($row['remark'] ?? ''));
  979. if ($remark !== '') {
  980. return $remark;
  981. }
  982. return '顾客申请售后';
  983. }
  984. /**
  985. * 把申请商品快照与订单行封面/规格拼成卡片商品
  986. * @param array $product
  987. * @param array $line
  988. * @return array
  989. */
  990. protected static function buildGoodsView($product, $line)
  991. {
  992. $name = $product['name'] ?? ($line['name'] ?? '商品');
  993. $cover = $product['cover'] ?? ($line['cover'] ?? '');
  994. $spec = $product['styleName'] ?? ($product['unitName'] ?? '');
  995. if ($spec === '') {
  996. $spec = $line['styleName'] ?? ($line['unitName'] ?? '');
  997. }
  998. $price = $product['unitPrice'] ?? ($line['unitPrice'] ?? 0);
  999. $num = $product['num'] ?? ($line['num'] ?? 1);
  1000. return [
  1001. 'name' => $name,
  1002. 'cover' => self::coverUrl($cover),
  1003. 'spec' => $spec,
  1004. 'price' => round((float)$price, 2),
  1005. 'num' => $num,
  1006. ];
  1007. }
  1008. /**
  1009. * 无商品时的占位,避免前端取 goods 空指针
  1010. * @return array
  1011. */
  1012. protected static function emptyGoodsView()
  1013. {
  1014. return [
  1015. 'name' => '售后商品',
  1016. 'cover' => self::coverUrl(''),
  1017. 'spec' => '',
  1018. 'price' => 0,
  1019. 'num' => 1,
  1020. ];
  1021. }
  1022. /**
  1023. * 相对路径转可访问封面;已是 http(s) 则原样返回
  1024. * @param string $short
  1025. * @return string
  1026. */
  1027. protected static function coverUrl($short)
  1028. {
  1029. $short = trim((string)$short);
  1030. if ($short === '') {
  1031. return imgUtil::groupImg('retail/default-img.png');
  1032. }
  1033. if (strpos($short, 'http://') === 0 || strpos($short, 'https://') === 0) {
  1034. return $short;
  1035. }
  1036. return imgUtil::groupImg($short) . '?x-oss-process=image/resize,m_fill,h_200,w_200';
  1037. }
  1038. }