MerchantAlertLogic.php 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. <?php
  2. namespace plugin\saiadmin\app\logic\operation;
  3. use plugin\saiadmin\app\model\system\SystemUser;
  4. use plugin\saiadmin\basic\think\BaseLogic;
  5. use plugin\saiadmin\exception\ApiException;
  6. use plugin\saiadmin\service\OpenSpoutWriter;
  7. use support\Response;
  8. use support\think\Db;
  9. /**
  10. * 商户连续无交易警报。
  11. *
  12. * 规则沿用旧系统:最近完成交易距今满 2 天进入警报,满 3 天归入
  13. * “连续3天及以上无交易”,从未产生完成订单的商户归入“一直无交易”。
  14. */
  15. class MerchantAlertLogic extends BaseLogic
  16. {
  17. private const ALERT_TWO_DAYS = 'two_days';
  18. private const ALERT_THREE_DAYS = 'three_days';
  19. private const ALERT_NEVER = 'never';
  20. private const EXPORT_LIMIT = 50000;
  21. private const LAST_TRANSACTION_SQL = '(SELECT MAX(s.stat_date) FROM sa_merchant_daily_statistics s'
  22. . ' WHERE s.merchant_id = m.id AND s.order_count > 0)';
  23. private const LAST_DISTRIBUTION_SQL = '(SELECT MAX(r.action_time) FROM sa_merchant_device_binding_record r'
  24. . ' WHERE r.merchant_id = m.id AND r.action_type = 1)';
  25. public function indexList(array $filters): array
  26. {
  27. $page = max(1, (int) request()->input('page', 1));
  28. $limit = min(100, max(1, (int) request()->input('limit', 20)));
  29. $result = $this->buildQuery($filters)
  30. ->orderRaw($this->alertWeightSql() . ' ASC')
  31. ->order('m.id', 'asc')
  32. ->paginate(['page' => $page, 'list_rows' => $limit])
  33. ->toArray();
  34. $result['data'] = array_map(
  35. fn (array $row): array => $this->formatAlert($row),
  36. $result['data'] ?? []
  37. );
  38. return $result;
  39. }
  40. public function export(array $filters): Response
  41. {
  42. $query = $this->buildQuery($filters);
  43. $count = (int) (clone $query)->count();
  44. if ($count > self::EXPORT_LIMIT) {
  45. throw new ApiException('导出记录超过 50000 条,请缩小筛选范围');
  46. }
  47. $rows = $query
  48. ->orderRaw($this->alertWeightSql() . ' ASC')
  49. ->order('m.id', 'asc')
  50. ->select()
  51. ->toArray();
  52. $data = [];
  53. foreach ($rows as $row) {
  54. $alert = $this->formatAlert($row);
  55. $data[] = [
  56. 'merchant_id' => $alert['merchant_id'],
  57. 'merchant_name' => $this->excelSafe($alert['merchant_name']),
  58. 'created_at' => $alert['created_at'],
  59. 'last_login_at' => $alert['last_login_at'],
  60. 'last_distribution_at' => $alert['last_distribution_at'],
  61. 'reason' => $alert['reason'],
  62. 'contact' => $this->excelSafe($alert['contact']),
  63. 'phone' => $this->excelSafe($alert['phone']),
  64. ];
  65. }
  66. $fileName = '商户警报_' . date('Ymd_His') . '.xlsx';
  67. $writer = new OpenSpoutWriter($fileName);
  68. $writer->setWidth([12, 28, 22, 22, 22, 24, 18, 20]);
  69. $writer->setHeader([
  70. '商户ID', '商户名称', '创建时间', '最新登录时间',
  71. '最新铺货时间', '交易停止原因', '联系人', '联系电话',
  72. ]);
  73. $writer->setData($data);
  74. return response()->download($writer->returnFile(), urlencode($fileName));
  75. }
  76. private function buildQuery(array $filters)
  77. {
  78. $query = Db::table('sa_merchant')->alias('m')
  79. ->whereNull('m.delete_time')
  80. ->whereRaw(
  81. '(' . self::LAST_TRANSACTION_SQL . ' IS NULL OR '
  82. . self::LAST_TRANSACTION_SQL . ' <= DATE_SUB(CURDATE(), INTERVAL 2 DAY))'
  83. )
  84. ->field(implode(',', [
  85. 'm.id AS merchant_id', 'm.name AS merchant_name', 'm.create_time AS created_at',
  86. 'm.contact', 'm.phone', self::LAST_TRANSACTION_SQL . ' AS last_transaction_date',
  87. self::LAST_DISTRIBUTION_SQL . ' AS last_distribution_at',
  88. ]));
  89. $managerIds = $this->accessibleManagerIds();
  90. if (is_array($managerIds)) {
  91. if ($managerIds === []) {
  92. $query->where('m.id', 0);
  93. } else {
  94. $query->whereIn('m.agent_id', $managerIds);
  95. }
  96. }
  97. $merchantName = trim((string) ($filters['merchant_name'] ?? ''));
  98. if ($merchantName !== '') {
  99. $query->whereLike('m.name', '%' . $merchantName . '%');
  100. }
  101. $alertType = trim((string) ($filters['alert_type'] ?? ''));
  102. if ($alertType !== '') {
  103. $this->applyAlertType($query, $alertType);
  104. }
  105. return $query;
  106. }
  107. private function applyAlertType($query, string $alertType): void
  108. {
  109. if ($alertType === self::ALERT_NEVER) {
  110. $query->whereRaw(self::LAST_TRANSACTION_SQL . ' IS NULL');
  111. return;
  112. }
  113. if ($alertType === self::ALERT_THREE_DAYS) {
  114. $query->whereRaw(
  115. self::LAST_TRANSACTION_SQL . ' IS NOT NULL AND '
  116. . self::LAST_TRANSACTION_SQL . ' <= DATE_SUB(CURDATE(), INTERVAL 3 DAY)'
  117. );
  118. return;
  119. }
  120. if ($alertType === self::ALERT_TWO_DAYS) {
  121. $query->whereRaw(
  122. self::LAST_TRANSACTION_SQL . ' > DATE_SUB(CURDATE(), INTERVAL 3 DAY) AND '
  123. . self::LAST_TRANSACTION_SQL . ' <= DATE_SUB(CURDATE(), INTERVAL 2 DAY)'
  124. );
  125. return;
  126. }
  127. throw new ApiException('警报类型不支持');
  128. }
  129. private function alertWeightSql(): string
  130. {
  131. return 'CASE WHEN ' . self::LAST_TRANSACTION_SQL . ' IS NULL THEN 99 '
  132. . 'WHEN ' . self::LAST_TRANSACTION_SQL . ' <= DATE_SUB(CURDATE(), INTERVAL 3 DAY) THEN 3 '
  133. . 'ELSE 2 END';
  134. }
  135. private function formatAlert(array $row): array
  136. {
  137. $lastTransactionDate = (string) ($row['last_transaction_date'] ?? '');
  138. if ($lastTransactionDate === '') {
  139. $alertType = self::ALERT_NEVER;
  140. $reason = '一直无交易';
  141. } elseif ($lastTransactionDate <= date('Y-m-d', strtotime('-3 days'))) {
  142. $alertType = self::ALERT_THREE_DAYS;
  143. $reason = '连续3天及以上无交易';
  144. } else {
  145. $alertType = self::ALERT_TWO_DAYS;
  146. $reason = '连续2天无交易';
  147. }
  148. return [
  149. 'merchant_id' => (int) ($row['merchant_id'] ?? 0),
  150. 'merchant_name' => (string) ($row['merchant_name'] ?? ''),
  151. 'created_at' => (string) ($row['created_at'] ?? ''),
  152. // 新系统尚未开放商户登录入口,没有可信的登录流水,不能伪造该时间。
  153. 'last_login_at' => '',
  154. 'last_distribution_at' => (string) ($row['last_distribution_at'] ?? ''),
  155. 'last_transaction_date' => $lastTransactionDate,
  156. 'alert_type' => $alertType,
  157. 'reason' => $reason,
  158. 'contact' => (string) ($row['contact'] ?? ''),
  159. 'phone' => (string) ($row['phone'] ?? ''),
  160. ];
  161. }
  162. /** @return int[]|null */
  163. private function accessibleManagerIds(): ?array
  164. {
  165. $currentId = (int) ($this->adminInfo['id'] ?? 0);
  166. if ($currentId === 1) {
  167. return null;
  168. }
  169. if ($currentId <= 0) {
  170. return [];
  171. }
  172. $managerIds = [$currentId];
  173. $dept = $this->adminInfo['deptList'] ?? [];
  174. if (empty($dept['id']) || !isset($dept['level'])) {
  175. return $managerIds;
  176. }
  177. $prefix = (string) $dept['level'] . (int) $dept['id'] . ',';
  178. $deptIds = Db::table('sa_system_dept')->whereNull('delete_time')
  179. ->whereLike('level', $prefix . '%')->column('id');
  180. if ($deptIds !== []) {
  181. $managerIds = array_merge($managerIds, array_map('intval', SystemUser::whereIn('dept_id', $deptIds)
  182. ->whereNull('delete_time')->column('id')));
  183. }
  184. return array_values(array_unique($managerIds));
  185. }
  186. private function excelSafe(mixed $value): string
  187. {
  188. $value = (string) $value;
  189. return preg_match('/^[=+\-@]/u', $value) === 1 ? "'" . $value : $value;
  190. }
  191. }