MerchantLogic.php 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000
  1. <?php
  2. // +----------------------------------------------------------------------
  3. // | saiadmin [ saiadmin快速开发框架 ]
  4. // +----------------------------------------------------------------------
  5. namespace plugin\saiadmin\app\logic\merchant;
  6. use plugin\saiadmin\app\model\merchant\Merchant;
  7. use plugin\saiadmin\app\model\merchant\MerchantDevice;
  8. use plugin\saiadmin\app\model\system\SystemDept;
  9. use plugin\saiadmin\app\model\system\SystemUser;
  10. use plugin\saiadmin\basic\think\BaseLogic;
  11. use plugin\saiadmin\exception\ApiException;
  12. use plugin\saiadmin\service\OpenSpoutWriter;
  13. use support\Response;
  14. use support\think\Db;
  15. /**
  16. * 商户业务逻辑
  17. *
  18. * 旧系统通过 admin_user_dependen.pid 查询直属商户;新系统将同一关系收敛为
  19. * sa_merchant.agent_id,并继续在所有读写操作中校验当前代理的数据边界。
  20. */
  21. class MerchantLogic extends BaseLogic
  22. {
  23. private const PRODUCT_CODES = [
  24. 'vehicle' => '车载充电器',
  25. 'k-battery' => 'K霸宝',
  26. 'bluetooth' => '蓝牙线充',
  27. 'power-bank' => '充电宝',
  28. 'password' => '密码线充',
  29. 'password-bluetooth' => '密码蓝牙线充',
  30. ];
  31. /**
  32. * 旧项目 device_depend_type_deal 中与当前平台产品对应的设备类型。
  33. */
  34. private const DEVICE_TYPE_NAMES = [
  35. 1 => '充电底座',
  36. 100 => 'K霸宝',
  37. 101 => '充电宝',
  38. 102 => 'K霸宝 Blue',
  39. 103 => '密码线充',
  40. 104 => '蓝牙线充',
  41. 105 => '密码蓝牙线充',
  42. 107 => '车载充电器',
  43. ];
  44. private const WRITABLE_FIELDS = [
  45. 'name', 'store_phone', 'storefront_image', 'industry', 'region', 'address',
  46. 'description', 'products', 'profit_method', 'profit_rate', 'contact', 'phone',
  47. 'permissions', 'distributed', 'status', 'agent_id', 'agent_type', 'parent_merchant_id',
  48. ];
  49. public function __construct()
  50. {
  51. $this->model = new Merchant();
  52. }
  53. /**
  54. * 商户分页列表。
  55. */
  56. public function indexList(array $where): array
  57. {
  58. $query = $this->buildListQuery($where);
  59. [$orderField, $orderType] = $this->resolveOrder($where);
  60. $page = max(1, (int) request()->input('page', 1));
  61. $limit = min(100, max(1, (int) request()->input('limit', 10)));
  62. $result = $query->order($orderField, $orderType)->paginate([
  63. 'page' => $page,
  64. 'list_rows' => $limit,
  65. ])->toArray();
  66. $result['summary'] = $this->typeSummary($where);
  67. return $this->hydratePage($result);
  68. }
  69. /**
  70. * 按当前筛选条件导出全部有权访问的商户。
  71. */
  72. public function export(array $where): Response
  73. {
  74. $query = $this->buildListQuery($where);
  75. [$orderField, $orderType] = $this->resolveOrder($where);
  76. $result = $this->hydratePage([
  77. 'data' => $query->order($orderField, $orderType)->select()->toArray(),
  78. ]);
  79. $data = [];
  80. foreach ($result['data'] as $row) {
  81. $data[] = [
  82. 'id' => (int) $row['id'],
  83. 'name' => $this->excelSafe($row['name']),
  84. 'store_phone' => $this->excelSafe($row['store_phone']),
  85. 'phone' => $this->excelSafe($row['phone']),
  86. 'address' => $this->excelSafe($row['address']),
  87. 'total_amount' => (float) $row['total_amount'],
  88. 'total_income' => (float) $row['total_income'],
  89. 'withdrawable_amount' => (float) $row['withdrawable_amount'],
  90. 'order_count' => (int) $row['order_count'],
  91. 'device_count' => (int) $row['device_count'],
  92. 'yesterday_usage_rate' => (float) $row['yesterday_usage_rate'],
  93. 'three_day_usage_rate' => (float) $row['three_day_usage_rate'],
  94. 'seven_day_usage_rate' => (float) $row['seven_day_usage_rate'],
  95. 'agent_name' => $this->excelSafe($row['agent_name']),
  96. 'agent_phone' => $this->excelSafe($row['agent_phone']),
  97. 'profit_user_name' => $this->excelSafe($row['profit_user_name']),
  98. 'profit_user_phone' => $this->excelSafe($row['profit_user_phone']),
  99. 'profit_method' => $row['profit_method'],
  100. 'profit_rate' => (float) $row['profit_rate'],
  101. 'region' => $this->excelSafe($row['region']),
  102. 'industry' => $this->excelSafe($row['industry']),
  103. 'device_type' => $this->excelSafe($row['device_type']),
  104. 'distributed' => (int) $row['distributed'] === 1 ? '已铺货' : '未铺货',
  105. 'status' => (int) $row['status'] === 1 ? '启用' : '停用',
  106. 'created_at' => $row['created_at'],
  107. ];
  108. }
  109. $fileName = '商户数据_' . date('Ymd_His') . '.xlsx';
  110. $writer = new OpenSpoutWriter($fileName);
  111. $writer->setWidth([10, 24, 16, 16, 36, 15, 15, 15, 12, 12, 14, 14, 14, 18, 16, 18, 16, 14, 12, 24, 20, 24, 12, 10, 22]);
  112. $writer->setHeader([
  113. '商户ID', '商户名称', '商户电话', '登录手机号', '商户地址',
  114. '总交易额', '总收益', '可提现金额', '订单量', '设备数',
  115. '昨日使用率(%)', '近三天使用率(%)', '近七天使用率(%)',
  116. '上级代理', '代理电话', '分润人', '分润人电话', '分成方式', '分润比例(%)',
  117. '城市区域', '行业分类', '设备类型', '铺货状态', '商户状态', '创建时间',
  118. ]);
  119. $writer->setData($data);
  120. return response()->download($writer->returnFile(), urlencode($fileName));
  121. }
  122. /**
  123. * 读取一个当前用户有权访问的商户。
  124. */
  125. public function readAccessible(int $id): array
  126. {
  127. return $this->formatMerchant($this->findAccessible($id)->toArray(), $this->userMapForMerchant($id));
  128. }
  129. /**
  130. * 新建直属商户。
  131. */
  132. public function addMerchant(array $data): int
  133. {
  134. $payload = $this->sanitize($data);
  135. $payload['agent_id'] = $this->resolveAgentId((int) ($data['agent_id'] ?? 0));
  136. $payload['parent_merchant_id'] = $this->resolveParentMerchantId(
  137. (int) ($data['parent_merchant_id'] ?? 0)
  138. );
  139. if ($payload['parent_merchant_id'] !== null) {
  140. $parent = $this->findAccessible((int) $payload['parent_merchant_id']);
  141. $payload['agent_id'] = (int) $parent->agent_id;
  142. }
  143. $this->assertPhoneUnique($payload['phone']);
  144. $payload['password'] = password_hash((string) $data['password'], PASSWORD_DEFAULT);
  145. return (int) $this->add($payload);
  146. }
  147. /**
  148. * 修改商户基础资料和分润资料。
  149. */
  150. public function editMerchant(int $id, array $data): bool
  151. {
  152. $model = $this->findAccessible($id);
  153. $payload = $this->sanitize($data);
  154. unset($payload['parent_merchant_id']);
  155. if ($this->currentAdminId() !== 1) {
  156. unset($payload['agent_id']);
  157. } elseif (isset($payload['agent_id'])) {
  158. $payload['agent_id'] = $this->resolveAgentId((int) $payload['agent_id']);
  159. }
  160. $this->assertPhoneUnique($payload['phone'], $id);
  161. return $model->save($payload) !== false;
  162. }
  163. /**
  164. * 重置商户登录密码。
  165. */
  166. public function resetPassword(int $id, string $password): bool
  167. {
  168. $model = $this->findAccessible($id);
  169. return $model->save(['password' => password_hash($password, PASSWORD_DEFAULT)]) !== false;
  170. }
  171. /**
  172. * 查询商户当前绑定的设备。
  173. */
  174. public function boundDevices(int $merchantId): array
  175. {
  176. $this->findAccessible($merchantId);
  177. $bindings = Db::table('sa_merchant_device_binding')
  178. ->where('merchant_id', $merchantId)
  179. ->order('bind_time', 'desc')
  180. ->select()
  181. ->toArray();
  182. if ($bindings === []) {
  183. return [];
  184. }
  185. $deviceIds = array_map('intval', array_column($bindings, 'device_id'));
  186. $devices = MerchantDevice::whereIn('id', $deviceIds)->select()->toArray();
  187. $deviceMap = [];
  188. foreach ($devices as $device) {
  189. $deviceMap[(int) $device['id']] = $device;
  190. }
  191. $result = [];
  192. foreach ($bindings as $binding) {
  193. $device = $deviceMap[(int) $binding['device_id']] ?? [];
  194. $type = (int) ($device['goods_type'] ?? $binding['depend_type'] ?? 0);
  195. $result[] = [
  196. 'id' => (int) $binding['device_id'],
  197. 'name' => (string) ($device['goods_name'] ?? ''),
  198. 'sn' => (string) $binding['device_sn'],
  199. 'device_code' => (string) ($device['device_code'] ?? ''),
  200. 'model' => (string) ($device['model'] ?? ''),
  201. 'type' => $type,
  202. 'type_name' => self::DEVICE_TYPE_NAMES[$type] ?? '其他设备',
  203. 'status' => (int) ($device['status'] ?? 0),
  204. 'status_name' => $this->deviceStatusName((int) ($device['status'] ?? 0)),
  205. 'online_status' => (int) ($device['device_status'] ?? 0),
  206. 'bind_time' => (string) $binding['bind_time'],
  207. ];
  208. }
  209. return $result;
  210. }
  211. /**
  212. * 将设备绑定到商户。
  213. *
  214. * 迁移自旧系统 Store::bindZuo2Store:校验设备存在和管理权,阻止设备重复铺货,
  215. * 写入当前关系及铺货流水,并同步商户设备数与铺货状态。
  216. */
  217. public function bindDevices(int $merchantId, array $deviceSns): array
  218. {
  219. $merchant = $this->findAccessible($merchantId);
  220. $sns = $this->normalizeDeviceSns($deviceSns);
  221. $operatorId = $this->currentAdminId();
  222. return $this->transaction(function () use ($merchant, $merchantId, $sns, $operatorId) {
  223. $deviceRows = Db::table('sa_device')
  224. ->whereIn('goods_sn', $sns)
  225. ->whereNull('delete_time')
  226. ->lock(true)
  227. ->select()
  228. ->toArray();
  229. $deviceMap = [];
  230. foreach ($deviceRows as $device) {
  231. $deviceMap[(string) $device['goods_sn']] = $device;
  232. }
  233. $deviceIds = array_map('intval', array_column($deviceRows, 'id'));
  234. $bindingMap = [];
  235. if ($deviceIds !== []) {
  236. $bindingRows = Db::table('sa_merchant_device_binding')
  237. ->whereIn('device_id', $deviceIds)
  238. ->lock(true)
  239. ->select()
  240. ->toArray();
  241. foreach ($bindingRows as $binding) {
  242. $bindingMap[(int) $binding['device_id']] = $binding;
  243. }
  244. }
  245. $now = date('Y-m-d H:i:s');
  246. $bindings = [];
  247. $records = [];
  248. $success = [];
  249. $alreadyBound = [];
  250. $boundElsewhere = [];
  251. $notFound = [];
  252. $notOwned = [];
  253. foreach ($sns as $sn) {
  254. if (!isset($deviceMap[$sn])) {
  255. $notFound[] = $sn;
  256. continue;
  257. }
  258. $device = $deviceMap[$sn];
  259. if ($operatorId !== 1 && (int) $device['manage_agent_id'] !== $operatorId) {
  260. $notOwned[] = $sn;
  261. continue;
  262. }
  263. $deviceId = (int) $device['id'];
  264. if (isset($bindingMap[$deviceId])) {
  265. if ((int) $bindingMap[$deviceId]['merchant_id'] === $merchantId) {
  266. $alreadyBound[] = $sn;
  267. } else {
  268. $boundElsewhere[] = $sn;
  269. }
  270. continue;
  271. }
  272. $type = (int) $device['goods_type'];
  273. $bindings[] = [
  274. 'device_id' => $deviceId,
  275. 'device_sn' => $sn,
  276. 'depend_type' => $type,
  277. 'merchant_id' => $merchantId,
  278. 'status' => 1,
  279. 'operator_id' => $operatorId,
  280. 'bind_time' => $now,
  281. 'create_time' => $now,
  282. 'update_time' => $now,
  283. ];
  284. $records[] = [
  285. 'device_id' => $deviceId,
  286. 'device_sn' => $sn,
  287. 'depend_type' => $type,
  288. 'merchant_id' => $merchantId,
  289. 'action_type' => 1,
  290. 'operator_id' => $operatorId,
  291. 'action_time' => $now,
  292. 'created_by' => $operatorId,
  293. 'create_time' => $now,
  294. ];
  295. $success[] = $sn;
  296. }
  297. if ($bindings !== []) {
  298. Db::table('sa_merchant_device_binding')->insertAll($bindings);
  299. Db::table('sa_merchant_device_binding_record')->insertAll($records);
  300. $this->syncMerchantDeviceSnapshot($merchant);
  301. }
  302. $message = $success === [] ? '没有可绑定的新设备' : '成功绑定 ' . count($success) . ' 台设备';
  303. return [
  304. 'message' => $message,
  305. 'bind_success_sn' => $success,
  306. 'has_binded_sn' => $alreadyBound,
  307. 'binded_other_store_sn' => $boundElsewhere,
  308. 'not_find_sn' => $notFound,
  309. 'not_owned_sn' => $notOwned,
  310. 'all_action_sn' => $sns,
  311. ];
  312. });
  313. }
  314. /**
  315. * 解除设备与指定商户的绑定,并保留解绑流水。
  316. */
  317. public function unbindDevice(int $merchantId, int $deviceId): bool
  318. {
  319. $merchant = $this->findAccessible($merchantId);
  320. $operatorId = $this->currentAdminId();
  321. return (bool) $this->transaction(function () use ($merchant, $merchantId, $deviceId, $operatorId) {
  322. $binding = Db::table('sa_merchant_device_binding')
  323. ->where('device_id', $deviceId)
  324. ->where('merchant_id', $merchantId)
  325. ->lock(true)
  326. ->find();
  327. if (empty($binding)) {
  328. throw new ApiException('设备未绑定到当前商户');
  329. }
  330. $device = Db::table('sa_device')->where('id', $deviceId)->whereNull('delete_time')->find();
  331. if (empty($device)) {
  332. throw new ApiException('设备不存在');
  333. }
  334. if ($operatorId !== 1 && (int) $device['manage_agent_id'] !== $operatorId) {
  335. throw new ApiException('无权解绑该设备');
  336. }
  337. $now = date('Y-m-d H:i:s');
  338. Db::table('sa_merchant_device_binding')
  339. ->where('device_id', $deviceId)
  340. ->where('merchant_id', $merchantId)
  341. ->delete();
  342. Db::table('sa_merchant_device_binding_record')->insert([
  343. 'device_id' => $deviceId,
  344. 'device_sn' => (string) $binding['device_sn'],
  345. 'depend_type' => (int) $binding['depend_type'],
  346. 'merchant_id' => $merchantId,
  347. 'action_type' => 2,
  348. 'operator_id' => $operatorId,
  349. 'action_time' => $now,
  350. 'created_by' => $operatorId,
  351. 'create_time' => $now,
  352. ]);
  353. Db::table('sa_device')->where('id', $deviceId)->update([
  354. 'is_active' => 0,
  355. 'active_time' => null,
  356. 'updated_by' => $operatorId,
  357. 'update_time' => $now,
  358. ]);
  359. $this->syncMerchantDeviceSnapshot($merchant);
  360. return true;
  361. });
  362. }
  363. /**
  364. * 获取商户可提现金额更新方式。
  365. *
  366. * update_mode 对应旧系统 crond_money_type:0 实时更新,1 定时更新。
  367. * update_day 为 1 至 31;0 在定时模式下表示每月最后一天。
  368. */
  369. public function readWithdrawalSetting(int $merchantId): array
  370. {
  371. $merchant = $this->findAccessible($merchantId);
  372. return [
  373. 'merchant_id' => (int) $merchant->id,
  374. 'update_mode' => (int) ($merchant->withdraw_update_mode ?? 0),
  375. 'update_day' => (int) ($merchant->withdraw_update_day ?? 0),
  376. ];
  377. }
  378. /**
  379. * 保存商户可提现金额更新方式。
  380. */
  381. public function updateWithdrawalSetting(int $merchantId, int $mode, int $day): bool
  382. {
  383. $merchant = $this->findAccessible($merchantId);
  384. if (!in_array($mode, [0, 1], true)) {
  385. throw new ApiException('请选择正确的更新方式');
  386. }
  387. if ($mode === 1 && ($day < 0 || $day > 31)) {
  388. throw new ApiException('请选择正确的更新日期');
  389. }
  390. return $merchant->save([
  391. 'withdraw_update_mode' => $mode,
  392. 'withdraw_update_day' => $mode === 0 ? 0 : $day,
  393. ]) !== false;
  394. }
  395. /**
  396. * 获取商户营业时间描述。
  397. *
  398. * 旧系统将营业时间作为展示文本保存,默认营业时间为 09:00-22:00。
  399. */
  400. public function readBusinessTime(int $merchantId): array
  401. {
  402. $merchant = $this->findAccessible($merchantId);
  403. $businessTime = trim((string) ($merchant->business_time_des ?? ''));
  404. return [
  405. 'merchant_id' => (int) $merchant->id,
  406. 'business_time_des' => $businessTime !== '' ? $businessTime : '09:00-22:00',
  407. ];
  408. }
  409. /**
  410. * 保存商户营业时间描述。
  411. */
  412. public function updateBusinessTime(int $merchantId, string $businessTime): bool
  413. {
  414. $merchant = $this->findAccessible($merchantId);
  415. $businessTime = trim($businessTime);
  416. if ($businessTime === '') {
  417. throw new ApiException('请填写营业时间');
  418. }
  419. if (mb_strlen($businessTime) > 64) {
  420. throw new ApiException('营业时间不能超过 64 个字符');
  421. }
  422. return $merchant->save(['business_time_des' => $businessTime]) !== false;
  423. }
  424. /**
  425. * 获取商户按日统计。
  426. *
  427. * 数据口径迁移自旧项目 statistics_shop_oneday:金额以分存储、按日汇总,
  428. * 同时保留微信、支付宝、余额三种支付方式的金额和订单量拆分。
  429. */
  430. public function statistics(int $merchantId, string $startDate = '', string $endDate = ''): array
  431. {
  432. $merchant = $this->findAccessible($merchantId);
  433. [$start, $end] = $this->normalizeStatisticsRange($startDate, $endDate);
  434. $rows = Db::table('sa_merchant_daily_statistics')
  435. ->where('merchant_id', $merchantId)
  436. ->where('stat_date', 'between', [$start, $end])
  437. ->order('stat_date', 'asc')
  438. ->select()
  439. ->toArray();
  440. $rowMap = [];
  441. foreach ($rows as $row) {
  442. $rowMap[(string) $row['stat_date']] = $row;
  443. }
  444. $daily = [];
  445. $summary = [
  446. 'total_amount_cents' => 0,
  447. 'total_income_cents' => 0,
  448. 'total_orders' => 0,
  449. 'usage_rate_sum' => 0.0,
  450. ];
  451. $payment = [
  452. 'wechat' => ['amount_cents' => 0, 'orders' => 0],
  453. 'alipay' => ['amount_cents' => 0, 'orders' => 0],
  454. 'balance' => ['amount_cents' => 0, 'orders' => 0],
  455. ];
  456. $cursor = new \DateTimeImmutable($start);
  457. $lastDay = new \DateTimeImmutable($end);
  458. while ($cursor <= $lastDay) {
  459. $date = $cursor->format('Y-m-d');
  460. $row = $rowMap[$date] ?? [];
  461. $amountCents = (int) ($row['turnover_cents'] ?? 0);
  462. $incomeCents = (int) ($row['income_cents'] ?? 0);
  463. $orders = (int) ($row['order_count'] ?? 0);
  464. $usageRate = round((float) ($row['device_usage_rate'] ?? 0), 2);
  465. $wechatAmountCents = (int) ($row['wechat_turnover_cents'] ?? 0);
  466. $alipayAmountCents = (int) ($row['alipay_turnover_cents'] ?? 0);
  467. $balanceAmountCents = (int) ($row['balance_turnover_cents'] ?? 0);
  468. $wechatOrders = (int) ($row['wechat_order_count'] ?? 0);
  469. $alipayOrders = (int) ($row['alipay_order_count'] ?? 0);
  470. $balanceOrders = (int) ($row['balance_order_count'] ?? 0);
  471. $daily[] = [
  472. 'date' => $date,
  473. 'amount' => $this->centsToMoney($amountCents),
  474. 'income' => $this->centsToMoney($incomeCents),
  475. 'orders' => $orders,
  476. 'usage_rate' => $usageRate,
  477. 'device_count' => (int) ($row['device_count'] ?? $merchant->device_count ?? 0),
  478. 'wechat_amount' => $this->centsToMoney($wechatAmountCents),
  479. 'alipay_amount' => $this->centsToMoney($alipayAmountCents),
  480. 'balance_amount' => $this->centsToMoney($balanceAmountCents),
  481. 'wechat_orders' => $wechatOrders,
  482. 'alipay_orders' => $alipayOrders,
  483. 'balance_orders' => $balanceOrders,
  484. ];
  485. $summary['total_amount_cents'] += $amountCents;
  486. $summary['total_income_cents'] += $incomeCents;
  487. $summary['total_orders'] += $orders;
  488. $summary['usage_rate_sum'] += $usageRate;
  489. $payment['wechat']['amount_cents'] += $wechatAmountCents;
  490. $payment['wechat']['orders'] += $wechatOrders;
  491. $payment['alipay']['amount_cents'] += $alipayAmountCents;
  492. $payment['alipay']['orders'] += $alipayOrders;
  493. $payment['balance']['amount_cents'] += $balanceAmountCents;
  494. $payment['balance']['orders'] += $balanceOrders;
  495. $cursor = $cursor->modify('+1 day');
  496. }
  497. $dayCount = count($daily);
  498. return [
  499. 'merchant' => [
  500. 'id' => (int) $merchant->id,
  501. 'name' => (string) $merchant->name,
  502. ],
  503. 'range' => ['start_date' => $start, 'end_date' => $end, 'day_count' => $dayCount],
  504. 'summary' => [
  505. 'total_amount' => $this->centsToMoney($summary['total_amount_cents']),
  506. 'total_income' => $this->centsToMoney($summary['total_income_cents']),
  507. 'total_orders' => $summary['total_orders'],
  508. 'average_usage_rate' => $dayCount > 0
  509. ? round($summary['usage_rate_sum'] / $dayCount, 2)
  510. : 0.0,
  511. ],
  512. 'payment' => [
  513. 'wechat' => [
  514. 'amount' => $this->centsToMoney($payment['wechat']['amount_cents']),
  515. 'orders' => $payment['wechat']['orders'],
  516. ],
  517. 'alipay' => [
  518. 'amount' => $this->centsToMoney($payment['alipay']['amount_cents']),
  519. 'orders' => $payment['alipay']['orders'],
  520. ],
  521. 'balance' => [
  522. 'amount' => $this->centsToMoney($payment['balance']['amount_cents']),
  523. 'orders' => $payment['balance']['orders'],
  524. ],
  525. ],
  526. 'daily' => $daily,
  527. ];
  528. }
  529. /**
  530. * 导出所选时间范围内的商户每日统计明细。
  531. */
  532. public function statisticsExport(int $merchantId, string $startDate = '', string $endDate = ''): Response
  533. {
  534. $statistics = $this->statistics($merchantId, $startDate, $endDate);
  535. $data = [];
  536. foreach ($statistics['daily'] as $row) {
  537. $data[] = [
  538. 'date' => $row['date'],
  539. 'amount' => $row['amount'],
  540. 'income' => $row['income'],
  541. 'orders' => $row['orders'],
  542. 'usage_rate' => $row['usage_rate'],
  543. 'wechat_amount' => $row['wechat_amount'],
  544. 'wechat_orders' => $row['wechat_orders'],
  545. 'alipay_amount' => $row['alipay_amount'],
  546. 'alipay_orders' => $row['alipay_orders'],
  547. 'balance_amount' => $row['balance_amount'],
  548. 'balance_orders' => $row['balance_orders'],
  549. 'device_count' => $row['device_count'],
  550. ];
  551. }
  552. $safeMerchantName = preg_replace('/[\\\\\/:*?"<>|]+/u', '_', $statistics['merchant']['name']);
  553. $fileName = sprintf(
  554. '%s_数据统计_%s_%s.xlsx',
  555. $safeMerchantName ?: '商户',
  556. str_replace('-', '', $statistics['range']['start_date']),
  557. str_replace('-', '', $statistics['range']['end_date'])
  558. );
  559. $writer = new OpenSpoutWriter($fileName);
  560. $writer->setWidth([14, 14, 14, 12, 14, 14, 12, 14, 12, 14, 12, 12]);
  561. $writer->setHeader([
  562. '日期', '交易额(元)', '收益额(元)', '订单量', '使用率(%)',
  563. '微信金额(元)', '微信订单量', '支付宝金额(元)', '支付宝订单量',
  564. '余额金额(元)', '余额订单量', '设备数',
  565. ]);
  566. $writer->setData($data);
  567. return response()->download($writer->returnFile(), urlencode($fileName));
  568. }
  569. /**
  570. * 软删除商户。禁止跨代理越权删除。
  571. */
  572. public function destroyAccessible(array|int|string $ids): bool
  573. {
  574. $idList = is_array($ids) ? $ids : explode(',', (string) $ids);
  575. $idList = array_values(array_unique(array_filter(array_map('intval', $idList))));
  576. if ($idList === []) {
  577. throw new ApiException('请选择要删除的商户');
  578. }
  579. return (bool) $this->transaction(function () use ($idList) {
  580. foreach ($idList as $id) {
  581. $this->findAccessible($id)->delete();
  582. }
  583. return true;
  584. });
  585. }
  586. private function normalizeDeviceSns(array $deviceSns): array
  587. {
  588. $sns = [];
  589. foreach ($deviceSns as $value) {
  590. $sn = trim((string) $value);
  591. if ($sn === '') {
  592. continue;
  593. }
  594. if (strlen($sn) > 60) {
  595. throw new ApiException('设备 SN 码不能超过 60 个字符');
  596. }
  597. $sns[$sn] = $sn;
  598. }
  599. $sns = array_values($sns);
  600. if ($sns === []) {
  601. throw new ApiException('请至少输入一个设备 SN 码');
  602. }
  603. if (count($sns) > 100) {
  604. throw new ApiException('单次最多绑定 100 台设备');
  605. }
  606. return $sns;
  607. }
  608. private function syncMerchantDeviceSnapshot(Merchant $merchant): void
  609. {
  610. $count = Db::table('sa_merchant_device_binding')
  611. ->where('merchant_id', (int) $merchant->id)
  612. ->count();
  613. $merchant->save([
  614. 'device_count' => $count,
  615. 'distributed' => $count > 0 ? 1 : 0,
  616. ]);
  617. }
  618. private function deviceStatusName(int $status): string
  619. {
  620. return match ($status) {
  621. 1 => '运营中',
  622. 2 => '报修',
  623. default => '未启用',
  624. };
  625. }
  626. private function buildListQuery(array $where)
  627. {
  628. $query = $this->model->where('id', '>', 0);
  629. $this->applyDataScope(
  630. $query,
  631. (string) ($where['scope'] ?? 'own'),
  632. (int) ($where['agent_id'] ?? 0)
  633. );
  634. $name = trim((string) ($where['name'] ?? ''));
  635. if ($name !== '') {
  636. $query->whereLike('name', '%' . $name . '%');
  637. }
  638. $phone = trim((string) ($where['phone'] ?? ''));
  639. if ($phone !== '') {
  640. $query->where(function ($subQuery) use ($phone) {
  641. $subQuery->whereLike('store_phone', '%' . $phone . '%')
  642. ->whereOr('phone', 'like', '%' . $phone . '%');
  643. });
  644. }
  645. if (($where['distributed'] ?? '') !== '') {
  646. $query->where('distributed', (int) $where['distributed']);
  647. }
  648. if (($where['status'] ?? '') !== '') {
  649. $query->where('status', (int) $where['status']);
  650. }
  651. $profitMethod = trim((string) ($where['profit_method'] ?? ''));
  652. if ($profitMethod !== '') {
  653. $query->where('profit_method', $profitMethod);
  654. }
  655. $agentType = trim((string) ($where['agent_type'] ?? ''));
  656. if ($agentType !== '') {
  657. $query->where('agent_type', $agentType);
  658. }
  659. $deviceCategory = trim((string) ($where['device_category'] ?? ''));
  660. if ($deviceCategory !== '' && $deviceCategory !== 'all' && isset(self::PRODUCT_CODES[$deviceCategory])) {
  661. $query->whereLike('products', '%"' . self::PRODUCT_CODES[$deviceCategory] . '"%');
  662. }
  663. return $query;
  664. }
  665. /**
  666. * 返回当前数据范围和筛选条件下的代理类型数量。
  667. * agent_type 本身不参与统计,避免切换分类后其它分类数量变成 0。
  668. */
  669. private function typeSummary(array $where): array
  670. {
  671. $countWhere = $where;
  672. $countWhere['agent_type'] = '';
  673. $summary = [
  674. 'all' => $this->buildListQuery($countWhere)->count(),
  675. 'city-partner' => 0,
  676. 'agent' => 0,
  677. 'salesperson' => 0,
  678. ];
  679. foreach (['city-partner', 'agent', 'salesperson'] as $type) {
  680. $typedWhere = $countWhere;
  681. $typedWhere['agent_type'] = $type;
  682. $summary[$type] = $this->buildListQuery($typedWhere)->count();
  683. }
  684. return array_map('intval', $summary);
  685. }
  686. private function resolveOrder(array $where): array
  687. {
  688. return match ((string) ($where['sort'] ?? 'default')) {
  689. 'amount_desc' => ['total_amount', 'DESC'],
  690. 'income_desc' => ['total_income', 'DESC'],
  691. 'created_asc' => ['id', 'ASC'],
  692. default => ['id', 'DESC'],
  693. };
  694. }
  695. private function excelSafe(mixed $value): string
  696. {
  697. $value = (string) $value;
  698. return preg_match('/^[=+\-@]/u', $value) === 1 ? "'" . $value : $value;
  699. }
  700. private function normalizeStatisticsRange(string $startDate, string $endDate): array
  701. {
  702. if ($startDate === '' && $endDate === '') {
  703. $startDate = date('Y-m-01');
  704. $endDate = date('Y-m-d');
  705. }
  706. if (!$this->isValidDate($startDate) || !$this->isValidDate($endDate)) {
  707. throw new ApiException('请选择正确的统计日期');
  708. }
  709. $start = new \DateTimeImmutable($startDate);
  710. $end = new \DateTimeImmutable($endDate);
  711. if ($start > $end) {
  712. throw new ApiException('开始日期不能晚于结束日期');
  713. }
  714. $dayCount = (int) $start->diff($end)->days + 1;
  715. if ($dayCount > 366) {
  716. throw new ApiException('单次最多查询 366 天数据');
  717. }
  718. return [$start->format('Y-m-d'), $end->format('Y-m-d')];
  719. }
  720. private function isValidDate(string $value): bool
  721. {
  722. $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value);
  723. $errors = \DateTimeImmutable::getLastErrors();
  724. return $date !== false
  725. && ($errors === false || ($errors['warning_count'] === 0 && $errors['error_count'] === 0))
  726. && $date->format('Y-m-d') === $value;
  727. }
  728. private function centsToMoney(int $cents): string
  729. {
  730. return number_format($cents / 100, 2, '.', '');
  731. }
  732. private function sanitize(array $data): array
  733. {
  734. $payload = [];
  735. foreach (self::WRITABLE_FIELDS as $field) {
  736. if (array_key_exists($field, $data)) {
  737. $payload[$field] = $data[$field];
  738. }
  739. }
  740. foreach (['name', 'store_phone', 'storefront_image', 'industry', 'region', 'address', 'contact', 'phone', 'agent_type'] as $field) {
  741. if (array_key_exists($field, $payload)) {
  742. $payload[$field] = trim((string) $payload[$field]);
  743. }
  744. }
  745. $payload['description'] = (string) ($payload['description'] ?? '');
  746. $payload['products'] = array_values(array_unique(array_filter(array_map('strval', $payload['products'] ?? []))));
  747. $payload['permissions'] = array_values(array_unique(array_filter(array_map('strval', $payload['permissions'] ?? []))));
  748. $payload['profit_method'] = trim((string) ($payload['profit_method'] ?? ''));
  749. if (!in_array($payload['profit_method'], ['比例分成', '不分成', '入驻费'], true)) {
  750. throw new ApiException('请选择正确的分成方式');
  751. }
  752. $payload['profit_rate'] = (float) ($payload['profit_rate'] ?? 0);
  753. $payload['distributed'] = (int) ($payload['distributed'] ?? 0);
  754. $payload['status'] = (int) ($payload['status'] ?? 1);
  755. $payload['agent_type'] = $payload['agent_type'] !== '' ? $payload['agent_type'] : 'agent';
  756. return $payload;
  757. }
  758. private function assertPhoneUnique(string $phone, int $exceptId = 0): void
  759. {
  760. $query = $this->model->where('phone', $phone);
  761. if ($exceptId > 0) {
  762. $query->where('id', '<>', $exceptId);
  763. }
  764. if ($query->count() > 0) {
  765. throw new ApiException('该手机号码已绑定其他商户');
  766. }
  767. }
  768. private function resolveAgentId(int $requestedAgentId): int
  769. {
  770. $currentId = $this->currentAdminId();
  771. if ($currentId !== 1) {
  772. return $currentId;
  773. }
  774. $agentId = $requestedAgentId > 0 ? $requestedAgentId : $currentId;
  775. if (SystemUser::where('id', $agentId)->count() === 0) {
  776. throw new ApiException('直属代理不存在');
  777. }
  778. return $agentId;
  779. }
  780. private function resolveParentMerchantId(int $parentMerchantId): ?int
  781. {
  782. if ($parentMerchantId <= 0) {
  783. return null;
  784. }
  785. $parent = $this->findAccessible($parentMerchantId);
  786. if ((int) ($parent->parent_merchant_id ?? 0) > 0) {
  787. throw new ApiException('只能在主商户下添加分店');
  788. }
  789. return (int) $parent->id;
  790. }
  791. private function findAccessible(int $id): Merchant
  792. {
  793. $query = $this->model->where('id', $id);
  794. if ($this->currentAdminId() !== 1) {
  795. $allowedAgentIds = array_merge([$this->currentAdminId()], $this->subordinateAgentIds());
  796. $query->whereIn('agent_id', array_values(array_unique($allowedAgentIds)));
  797. }
  798. $model = $query->findOrEmpty();
  799. if ($model->isEmpty()) {
  800. throw new ApiException('商户不存在或无权操作');
  801. }
  802. return $model;
  803. }
  804. private function applyDataScope($query, string $scope, int $requestedAgentId): void
  805. {
  806. $currentId = $this->currentAdminId();
  807. if ($currentId === 1) {
  808. if ($requestedAgentId > 0) {
  809. $query->where('agent_id', $requestedAgentId);
  810. }
  811. return;
  812. }
  813. if ($scope !== 'subordinate') {
  814. $query->where('agent_id', $currentId);
  815. return;
  816. }
  817. $agentIds = $this->subordinateAgentIds();
  818. if ($requestedAgentId > 0) {
  819. $agentIds = in_array($requestedAgentId, $agentIds, true) ? [$requestedAgentId] : [];
  820. }
  821. if ($agentIds === []) {
  822. $query->where('agent_id', 0);
  823. return;
  824. }
  825. $query->whereIn('agent_id', $agentIds);
  826. }
  827. /**
  828. * SaiAdmin 使用部门层级表达代理上下级,等价替代旧表 admin_user_dependen.pid。
  829. */
  830. private function subordinateAgentIds(): array
  831. {
  832. $dept = $this->adminInfo['deptList'] ?? [];
  833. if (empty($dept['id']) || !isset($dept['level'])) {
  834. return [];
  835. }
  836. $levelPrefix = (string) $dept['level'] . (int) $dept['id'] . ',';
  837. $deptIds = SystemDept::whereLike('level', $levelPrefix . '%')->column('id');
  838. if ($deptIds === []) {
  839. return [];
  840. }
  841. return array_map('intval', SystemUser::whereIn('dept_id', $deptIds)->column('id'));
  842. }
  843. private function currentAdminId(): int
  844. {
  845. return (int) ($this->adminInfo['id'] ?? 0);
  846. }
  847. private function hydratePage(array $result): array
  848. {
  849. $rows = $result['data'] ?? [];
  850. $userIds = [];
  851. foreach ($rows as $row) {
  852. $userIds[] = (int) ($row['agent_id'] ?? 0);
  853. $userIds[] = (int) ($row['profit_user_id'] ?? 0);
  854. }
  855. $userMap = $this->loadUserMap($userIds);
  856. foreach ($rows as $index => $row) {
  857. $rows[$index] = $this->formatMerchant($row, $userMap);
  858. }
  859. $result['data'] = $rows;
  860. return $result;
  861. }
  862. private function userMapForMerchant(int $merchantId): array
  863. {
  864. $merchant = $this->model->where('id', $merchantId)->field('agent_id,profit_user_id')->findOrEmpty();
  865. if ($merchant->isEmpty()) {
  866. return [];
  867. }
  868. return $this->loadUserMap([(int) $merchant->agent_id, (int) $merchant->profit_user_id]);
  869. }
  870. private function loadUserMap(array $ids): array
  871. {
  872. $ids = array_values(array_unique(array_filter(array_map('intval', $ids))));
  873. if ($ids === []) {
  874. return [];
  875. }
  876. $users = SystemUser::whereIn('id', $ids)->field('id,username,realname,phone')->select()->toArray();
  877. $map = [];
  878. foreach ($users as $user) {
  879. $map[(int) $user['id']] = $user;
  880. }
  881. return $map;
  882. }
  883. private function formatMerchant(array $row, array $userMap): array
  884. {
  885. $agent = $userMap[(int) ($row['agent_id'] ?? 0)] ?? [];
  886. $profitUser = $userMap[(int) ($row['profit_user_id'] ?? 0)] ?? [];
  887. $products = $row['products'] ?? [];
  888. if (is_string($products)) {
  889. $products = json_decode($products, true) ?: [];
  890. }
  891. $permissions = $row['permissions'] ?? [];
  892. if (is_string($permissions)) {
  893. $permissions = json_decode($permissions, true) ?: [];
  894. }
  895. $row['products'] = $products;
  896. $row['permissions'] = $permissions;
  897. $row['device_type'] = implode('、', $products);
  898. $row['agent_name'] = (string) ($agent['realname'] ?? $agent['username'] ?? '');
  899. $row['agent_phone'] = (string) ($agent['phone'] ?? '');
  900. $row['profit_user_name'] = (string) ($profitUser['realname'] ?? $profitUser['username'] ?? $row['contact'] ?? '');
  901. $row['profit_user_phone'] = (string) ($profitUser['phone'] ?? $row['phone'] ?? '');
  902. $row['created_at'] = (string) ($row['create_time'] ?? '');
  903. unset($row['password']);
  904. return $row;
  905. }
  906. }