WaterEnterpriseDashboard.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290
  1. <?php
  2. declare(strict_types=1);
  3. namespace plugin\saiadmin\app\service;
  4. use Illuminate\Database\Eloquent\Builder;
  5. use OpenSpout\Common\Entity\Row;
  6. use OpenSpout\Writer\XLSX\Writer;
  7. use plugin\saiadmin\app\model\water\WaterModel;
  8. use plugin\saiadmin\app\model\water\WaterEnterprise;
  9. use plugin\saiadmin\app\model\water\WaterEnterpriseAddress;
  10. use plugin\saiadmin\app\model\water\WaterAccount;
  11. use plugin\saiadmin\app\model\water\WaterAgent;
  12. use plugin\saiadmin\app\model\water\WaterOrder;
  13. use plugin\saiadmin\app\model\water\WaterDevice;
  14. use plugin\saiadmin\app\model\water\WaterDeviceUsage;
  15. use plugin\saiadmin\app\model\water\WaterDelivery;
  16. use plugin\saiadmin\app\model\water\WaterLedger;
  17. use plugin\saiadmin\app\service\enterprise\EnterpriseFilters;
  18. use plugin\saiadmin\app\service\enterprise\EnterpriseReplenishmentPolicy;
  19. use plugin\saiadmin\app\service\device\DeviceStatusPolicy;
  20. use plugin\saiadmin\exception\ApiException;
  21. /** Application facade: model-backed reads, status policy and transactional batch commands. */
  22. final class WaterEnterpriseDashboard
  23. {
  24. private readonly \DateTimeImmutable $now;
  25. private readonly EnterpriseReplenishmentPolicy $replenishment;
  26. public function __construct(?\DateTimeImmutable $now = null)
  27. {
  28. $this->now = $now ?? new \DateTimeImmutable('now', new \DateTimeZone('Asia/Shanghai'));
  29. $this->replenishment = new EnterpriseReplenishmentPolicy();
  30. }
  31. private function query(?EnterpriseFilters $filters = null): Builder
  32. {
  33. // Aggregate each one-to-many source before joining: no duplicated rights or stock.
  34. $alert = $this->replenishment->alertExpression();
  35. $urgent = $this->replenishment->urgentExpression();
  36. $accounts = WaterAccount::query()->alias('a')->join('sa_water_sku as s', 's.id', '=', 'a.sku_id')
  37. ->withActiveDelivery('task')
  38. ->selectRaw("a.enterprise_id, SUM(a.entitlement) AS entitlement, SUM(a.frozen) AS frozen,
  39. SUM(a.full_qty) AS full_qty, SUM(a.empty_qty) AS empty_qty,
  40. SUM(s.status = 1) AS enabled_accounts,
  41. SUM(s.status = 1 AND $alert AND $urgent AND task.id IS NULL) AS urgent_count,
  42. SUM(s.status = 1 AND $alert AND task.id IS NULL) AS pending_count")
  43. ->groupBy('a.enterprise_id');
  44. $deviceState = (new DeviceStatusPolicy($this->now))->expression();
  45. $devices = WaterDevice::query()->alias('d')->selectRaw("d.enterprise_id, COUNT(*) AS device_count,
  46. SUM(($deviceState) = 'online') AS online_count,
  47. SUM(($deviceState) = 'unconnected') AS unconnected_count")->groupBy('d.enterprise_id');
  48. $tasks = WaterDelivery::query()->whereNotNull('active_key')
  49. ->selectRaw('enterprise_id, COUNT(*) AS active_deliveries')->groupBy('enterprise_id');
  50. $query = WaterEnterprise::query()->alias('e')
  51. ->leftJoin('sa_water_agent as agent', 'agent.id', '=', 'e.agent_id')
  52. ->leftJoinSub($accounts, 'account_totals', 'account_totals.enterprise_id', '=', 'e.id')
  53. ->leftJoinSub($devices, 'device_totals', 'device_totals.enterprise_id', '=', 'e.id')
  54. ->leftJoinSub($tasks, 'task_totals', 'task_totals.enterprise_id', '=', 'e.id')
  55. ->selectRaw('e.*, agent.name AS agent_name, COALESCE(account_totals.entitlement,0) AS entitlement,
  56. COALESCE(account_totals.frozen,0) AS frozen, COALESCE(account_totals.full_qty,0) AS full_qty,
  57. COALESCE(account_totals.empty_qty,0) AS empty_qty,
  58. COALESCE(account_totals.entitlement - account_totals.frozen,0) AS available_entitlement,
  59. COALESCE(account_totals.enabled_accounts,0) AS enabled_accounts,
  60. COALESCE(account_totals.urgent_count,0) AS urgent_count,
  61. COALESCE(account_totals.pending_count,0) AS pending_count,
  62. COALESCE(device_totals.device_count,0) AS device_count,
  63. COALESCE(device_totals.online_count,0) AS online_count,
  64. COALESCE(device_totals.unconnected_count,0) AS unconnected_count,
  65. COALESCE(task_totals.active_deliveries,0) AS active_deliveries');
  66. foreach (['created_at' => 'latest_order_at', 'amount_cents' => 'latest_order_amount'] as $column => $alias) {
  67. $query->selectSub(WaterOrder::query()->whereColumn('enterprise_id', 'e.id')
  68. ->where('created_at', '<=', $this->now->format('Y-m-d H:i:s'))
  69. ->orderByDesc('created_at')->orderByDesc('id')->limit(1)->select($column), $alias);
  70. }
  71. if ($filters) {
  72. if ($filters->keyword !== '') {
  73. $query->matching('e.name|e.contact|e.phone', '%' . $filters->keyword . '%');
  74. }
  75. if ($filters->status !== '') {
  76. $query->where('e.status', (int) $filters->status);
  77. }
  78. if ($filters->agentId !== null) {
  79. $query->where('e.agent_id', $filters->agentId);
  80. }
  81. $balance = 'COALESCE(account_totals.entitlement - account_totals.frozen,0)';
  82. if ($filters->balance === 'empty') {
  83. $query->whereRaw("$balance <= 0");
  84. }
  85. if ($filters->balance === 'low') {
  86. $query->whereRaw("$balance > 0 AND $balance <= 10");
  87. }
  88. if ($filters->balance === 'sufficient') {
  89. $query->whereRaw("$balance > 10");
  90. }
  91. }
  92. return $query;
  93. }
  94. private function present(array $row): array
  95. {
  96. foreach (['id', 'agent_id', 'status', 'entitlement', 'frozen', 'full_qty', 'empty_qty', 'available_entitlement',
  97. 'enabled_accounts', 'urgent_count', 'pending_count', 'device_count', 'online_count', 'unconnected_count', 'active_deliveries'] as $key) {
  98. $row[$key] = (int) $row[$key];
  99. }
  100. $row['employee_count'] = $row['employee_count'] === null ? null : (int) $row['employee_count'];
  101. $row['latest_order_amount'] = $row['latest_order_amount'] === null ? null : (int) $row['latest_order_amount'];
  102. $row['replenishment_status'] = $this->replenishment->resolve($row);
  103. return $row;
  104. }
  105. public function dashboard(array $input): array
  106. {
  107. $filters = new EnterpriseFilters($input);
  108. return WaterModel::transaction(function () use ($filters) {
  109. $list = $this->query($filters)->orderByDesc('e.id')->pageResult($filters->page, $filters->limit);
  110. $list['data'] = array_map($this->present(...), $list['data']);
  111. return ['list' => $list, 'summary' => $this->summary(), 'trend' => $this->trend(), 'funnel' => $this->funnel(),
  112. 'agents' => WaterAgent::query()->orderBy('name')->get(['id', 'name', 'status'])->toArray(),
  113. 'updated_at' => $this->now->format('Y-m-d H:i:s')];
  114. });
  115. }
  116. private function repurchase(\DateTimeImmutable $start, \DateTimeImmutable $end): array
  117. {
  118. $orders = WaterOrder::query()->whereBetween('created_at', [$start->format('Y-m-d H:i:s'), $end->format('Y-m-d H:i:s')]);
  119. $buyers = (clone $orders)->distinct()->count('enterprise_id');
  120. $repeatIds = WaterOrder::query()->where('created_at', '<=', $end->format('Y-m-d H:i:s'))
  121. ->select('enterprise_id')->groupBy('enterprise_id')->havingRaw('COUNT(*) >= 2');
  122. $repeat = (clone $orders)->whereIn('enterprise_id', $repeatIds)->distinct()->count('enterprise_id');
  123. return ['buyers' => $buyers, 'repeat' => $repeat, 'rate' => $buyers ? round(100 * $repeat / $buyers, 1) : null];
  124. }
  125. private function summary(): array
  126. {
  127. $start = $this->now->modify('first day of this month')->setTime(0, 0);
  128. $month = $this->repurchase($start, $this->now);
  129. $previous = $this->repurchase($start->modify('-1 month'), $start->modify('-1 second'));
  130. $since = $this->now->modify('-29 days')->setTime(0, 0)->format('Y-m-d H:i:s');
  131. $telemetry = WaterDeviceUsage::query()->alias('usage')->join('sa_water_device as d', 'd.id', '=', 'usage.device_id')
  132. ->whereBetween('usage.day', [substr($since, 0, 10), $this->now->format('Y-m-d')])->where('usage.liters', '>', 0)->select('d.enterprise_id');
  133. $exchanges = WaterLedger::query()->where('kind', 'exchange')->where('quantity', '<', 0)
  134. ->whereBetween('created_at', [$since, $this->now->format('Y-m-d H:i:s')])->select('enterprise_id');
  135. $active = WaterEnterprise::query()->where('status', 1)->where(function (Builder $q) use ($telemetry, $exchanges) {
  136. $q->whereIn('id', $telemetry)->orWhereIn('id', $exchanges);
  137. })->count();
  138. return ['total' => WaterEnterprise::query()->count(),
  139. 'new_month' => WaterEnterprise::query()->whereBetween('created_at', [$start->format('Y-m-d H:i:s'), $this->now->format('Y-m-d H:i:s')])->count(),
  140. 'active' => $active, 'entitlement' => (int) WaterAccount::query()->sum('entitlement'),
  141. 'repurchase_rate' => $month['rate'], 'repurchase_buyers' => $month['buyers'], 'repeat_buyers' => $month['repeat'],
  142. 'repurchase_change' => $month['rate'] !== null && $previous['rate'] !== null ? round($month['rate'] - $previous['rate'], 1) : null];
  143. }
  144. private function trend(): array
  145. {
  146. $start = $this->now->modify('-29 days')->setTime(0, 0);
  147. $base = WaterEnterprise::query()->where('created_at', '<', $start->format('Y-m-d H:i:s'))->count();
  148. $daily = WaterEnterprise::query()->whereBetween('created_at', [$start->format('Y-m-d H:i:s'), $this->now->format('Y-m-d H:i:s')])
  149. ->selectRaw('DATE(created_at) AS day, COUNT(*) AS total')->groupByRaw('DATE(created_at)')->pluck('total', 'day');
  150. $rows = [];
  151. for ($i = 0;$i < 30;$i++) {
  152. $day = $start->modify("+$i days")->format('Y-m-d');
  153. $new = (int) ($daily[$day] ?? 0);
  154. $base += $new;
  155. $rows[] = ['day' => $day, 'new' => $new, 'total' => $base];
  156. }
  157. return $rows;
  158. }
  159. private function funnel(): array
  160. {
  161. // One acquisition cohort, so conversion counts are nested and comparable.
  162. $cohort = WaterEnterprise::query()->whereBetween('created_at', [$this->now->format('Y-m-01 00:00:00'), $this->now->format('Y-m-d H:i:s')]);
  163. $orders = WaterOrder::query()->where('created_at', '<=', $this->now->format('Y-m-d H:i:s'))
  164. ->select('enterprise_id')->groupBy('enterprise_id');
  165. return ['registered' => (clone $cohort)->count(),
  166. 'purchased' => (clone $cohort)->whereIn('id', (clone $orders)->havingRaw('COUNT(*) >= 1'))->count(),
  167. 'repeated' => (clone $cohort)->whereIn('id', (clone $orders)->havingRaw('COUNT(*) >= 2'))->count()];
  168. }
  169. public function detail(int $id): array
  170. {
  171. return WaterModel::transaction(function () use ($id) {
  172. $row = $this->query()->where('e.id', $id)->first();
  173. if (!$row) {
  174. throw new ApiException('企业不存在');
  175. }
  176. return ['enterprise' => $this->present($row->toArray()),
  177. 'addresses' => WaterEnterpriseAddress::query()->where('enterprise_id', $id)->orderByDesc('is_default')->orderBy('id')->get()->map(fn ($address) => [
  178. 'id' => (int) $address->id, 'label' => $address->label, 'contact' => $address->contact, 'phone' => $address->phone,
  179. 'region' => $address->region, 'address' => $address->address, 'longitude' => $address->longitude === null ? null : (float) $address->longitude,
  180. 'latitude' => $address->latitude === null ? null : (float) $address->latitude, 'is_default' => (int) $address->is_default === 1,
  181. 'status' => (int) $address->status, 'updated_at' => $address->updated_at,
  182. ])->all(),
  183. 'accounts' => WaterAccount::query()->where('enterprise_id', $id)->with('sku:id,name,liters,status')->orderBy('id')->get()->toArray(),
  184. 'orders' => WaterOrder::query()->where('enterprise_id', $id)->orderByDesc('created_at')->orderByDesc('id')->limit(10)->get()->toArray()];
  185. });
  186. }
  187. public function batchStatus(array $input, int $actor): array
  188. {
  189. $ids = $input['ids'] ?? null;
  190. if (!is_array($ids) || !array_is_list($ids) || !$ids || count($ids) > 100) {
  191. throw new ApiException('请选择1至100家企业');
  192. }
  193. $ids = array_values(array_unique(array_map(fn ($id) => WaterBusiness::positive($id, '企业'), $ids)));
  194. sort($ids);
  195. $status = WaterBusiness::positive($input['status'] ?? null, '客户状态');
  196. if (!in_array($status, [1, 2], true)) {
  197. throw new ApiException('客户状态不正确');
  198. }
  199. return WaterModel::transaction(function () use ($ids, $status, $actor) {
  200. $rows = WaterEnterprise::query()->whereIn('id', $ids)->orderBy('id')->lockForUpdate()->get();
  201. if (count($rows) !== count($ids)) {
  202. throw new ApiException('部分企业已不存在,请刷新列表');
  203. }
  204. $changed = 0;
  205. foreach ($rows as $row) {
  206. if ((int) $row->status === $status) {
  207. continue;
  208. }
  209. $row->status = $status;
  210. $row->updated_at = $this->now->format('Y-m-d H:i:s');
  211. $row->save();
  212. WaterLedger::query()->create(['kind' => 'archive', 'created_by' => $actor, 'sku_id' => 0, 'enterprise_id' => $row->id,
  213. 'quantity' => 0, 'reference' => 'enterprise:' . $row->id, 'note' => $status === 1 ? '批量启用企业' : '批量停用企业',
  214. 'created_at' => $this->now->format('Y-m-d H:i:s')]);
  215. $changed++;
  216. }
  217. return ['changed' => $changed];
  218. });
  219. }
  220. public function export(array $input): string
  221. {
  222. $rows = $this->query(new EnterpriseFilters($input))->orderByDesc('e.id')->limit(50001)->get();
  223. if (count($rows) > 50000) {
  224. throw new ApiException('导出超过50000条,请缩小筛选范围');
  225. }
  226. $path = tempnam(sys_get_temp_dir(), 'water-enterprises-');
  227. if ($path === false) {
  228. throw new ApiException('无法创建导出文件');
  229. }
  230. $writer = new Writer();
  231. $opened = false;
  232. try {
  233. $writer->openToFile($path);
  234. $opened = true;
  235. $writer->addRow(Row::fromValues(['企业', '联系人', '手机', '区域', '代理', '供水地址', '客户状态', '套餐剩余权益/桶', '冻结权益/桶', '可用权益/桶', '现场满桶', '现场空桶', '设备数', '在线设备', '最近订单时间', '最近订单金额/元', '补货状态']));
  236. foreach ($rows as $model) {
  237. $r = $this->present($model->toArray());
  238. $values = [$r['name'], $r['contact'], $r['phone'], $r['region'], $r['agent_id'] ? ($r['agent_name'] ?? '代理不存在') : '直营客户',
  239. $r['address'], $r['status'] === 1 ? '启用' : '停用', $r['entitlement'], $r['frozen'], $r['available_entitlement'], $r['full_qty'], $r['empty_qty'],
  240. $r['device_count'], $r['online_count'], $r['latest_order_at'], $r['latest_order_amount'] === null ? null : $r['latest_order_amount'] / 100,
  241. EnterpriseReplenishmentPolicy::LABELS[$r['replenishment_status']]];
  242. $writer->addRow(Row::fromValues(array_map(fn ($v) => is_string($v) && preg_match('/^[=+@\-\t\r\n]/u', $v) ? "'" . $v : $v, $values)));
  243. }
  244. $writer->close();
  245. $opened = false;
  246. $content = file_get_contents($path);
  247. if ($content === false) {
  248. throw new ApiException('读取客户导出失败');
  249. }
  250. return $content;
  251. } finally {
  252. try {
  253. if ($opened) {
  254. $writer->close();
  255. }
  256. } finally {
  257. if (is_file($path)) {
  258. unlink($path);
  259. }
  260. }
  261. }
  262. }
  263. }