WaterBusiness.php 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  1. <?php
  2. declare(strict_types=1);
  3. namespace plugin\saiadmin\app\service;
  4. use plugin\saiadmin\exception\ApiException;
  5. use plugin\saiadmin\app\model\water\WaterModel;
  6. use plugin\saiadmin\app\model\water\WaterLedger as WaterLedgerModel;
  7. use plugin\saiadmin\app\model\water\WaterAccount;
  8. use plugin\saiadmin\app\model\water\WaterDelivery;
  9. use plugin\saiadmin\app\model\water\WaterDevice;
  10. use plugin\saiadmin\app\model\water\WaterOrder;
  11. use plugin\saiadmin\app\model\water\WaterSku;
  12. use plugin\saiadmin\app\model\water\WaterRoute;
  13. use plugin\saiadmin\app\model\water\WaterAgent;
  14. use plugin\saiadmin\app\model\water\WaterEnterprise;
  15. use plugin\saiadmin\app\model\water\WaterEnterpriseAddress;
  16. use plugin\saiadmin\app\model\water\WaterPackage;
  17. use plugin\saiadmin\app\model\water\WaterContent;
  18. use plugin\saiadmin\app\service\content\ContentInput;
  19. use plugin\saiadmin\app\service\content\ContentPolicy;
  20. use plugin\saiadmin\app\service\alerts\ReplenishmentPolicy;
  21. /** 供水运营。平台人员显式授权;实物、权益、在途分别记账。 */
  22. class WaterBusiness
  23. {
  24. public static function positive($value, string $label, bool $zero = false): int
  25. {
  26. if (filter_var($value, FILTER_VALIDATE_INT) === false || (int) $value < ($zero ? 0 : 1) || (int) $value > 10000000) {
  27. throw new ApiException($label . '必须是有效整数');
  28. }
  29. return (int) $value;
  30. }
  31. private function word(array $data, string $key, int $max = 120, bool $required = true): string
  32. {
  33. $value = trim((string) ($data[$key] ?? ''));
  34. if (($required && $value === '') || mb_strlen($value) > $max) {
  35. throw new ApiException($key . '不能为空或超出长度限制');
  36. }
  37. return $value;
  38. }
  39. private function now(): string
  40. {
  41. return date('Y-m-d H:i:s');
  42. }
  43. private function row(string $table, int $id, bool $lock = false): array
  44. {
  45. $row = WaterModel::forKind($table)->where('id', $id)->lock($lock)->first()?->toArray();
  46. if (!$row) {
  47. throw new ApiException('记录不存在');
  48. }
  49. return $row;
  50. }
  51. private function log(string $kind, int $actor, int $sku, int $enterprise, int $quantity, string $reference, string $note = '', int $amount = 0): void
  52. {
  53. WaterLedgerModel::query()->insert([
  54. 'kind' => $kind, 'created_by' => $actor, 'sku_id' => $sku, 'enterprise_id' => $enterprise,
  55. 'quantity' => $quantity, 'amount_cents' => $amount, 'reference' => $reference, 'note' => $note, 'created_at' => $this->now(),
  56. ]);
  57. }
  58. private function account(int $enterprise, int $sku): array
  59. {
  60. // 所有修改均先锁商品行,串行建立并锁定权益账户。
  61. $row = WaterAccount::query()->where(['enterprise_id' => $enterprise, 'sku_id' => $sku])->lock(true)->first()?->toArray();
  62. if (!$row) {
  63. $id = WaterAccount::query()->insertGetId(['enterprise_id' => $enterprise, 'sku_id' => $sku, 'updated_at' => $this->now()]);
  64. $row = $this->row('account', (int) $id, true);
  65. }
  66. return $row;
  67. }
  68. public function save(string $kind, array $data, int $actor): array
  69. {
  70. $id = isset($data['id']) ? self::positive($data['id'], 'ID', true) : 0;
  71. return WaterModel::transaction(function () use ($kind, $data, $actor, $id) {
  72. if ($id) {
  73. $this->row($kind, $id, true);
  74. }
  75. $status = (int) ($data['status'] ?? 1);
  76. if (!in_array($status, [1, 2], true)) {
  77. throw new ApiException('状态不支持');
  78. }
  79. $values = ['updated_at' => $this->now()];
  80. if ($kind === 'agent') {
  81. $values += ['name' => $this->word($data, 'name'), 'contact' => $this->word($data, 'contact', 60),
  82. 'phone' => $this->word($data, 'phone', 30), 'region' => $this->word($data, 'region', 160, false),
  83. 'address' => $this->word($data, 'address', 255, false), 'status' => $status];
  84. } elseif ($kind === 'enterprise') {
  85. $values += ['name' => $this->word($data, 'name'), 'contact' => $this->word($data, 'contact', 60),
  86. 'phone' => $this->word($data, 'phone', 30), 'address' => $this->word($data, 'address', 255),
  87. 'region' => $this->word($data, 'region', 80, false), 'status' => $status];
  88. if (array_key_exists('longitude', $data) || array_key_exists('latitude', $data)) {
  89. $values += WaterDispatch::coordinates($data);
  90. }
  91. if (array_key_exists('employee_count', $data)) {
  92. if ($data['employee_count'] !== null && !is_int($data['employee_count']) && !is_string($data['employee_count'])) {
  93. throw new ApiException('公司人数必须是有效整数');
  94. }
  95. $values['employee_count'] = $data['employee_count'] === null || $data['employee_count'] === ''
  96. ? null : self::positive($data['employee_count'], '公司人数');
  97. }
  98. if (array_key_exists('agent_id', $data)) {
  99. $agentId = self::positive($data['agent_id'], '所属代理', true);
  100. if ($agentId) {
  101. $agent = $this->row('agent', $agentId, true);
  102. $oldAgentId = $id ? (int) $this->row('enterprise', $id)['agent_id'] : 0;
  103. if ((int) $agent['status'] !== 1 && $agentId !== $oldAgentId) {
  104. throw new ApiException('不能分配给已停用代理');
  105. }
  106. }
  107. $values['agent_id'] = $agentId;
  108. }
  109. // 基础版本一企业一个供水地址。有在途任务时禁止改地址。
  110. if ($id && WaterDelivery::query()->where('enterprise_id', $id)->whereNotNull('active_key')->count()) {
  111. $old = $this->row('enterprise', $id);
  112. if ($old['address'] !== $values['address']) {
  113. throw new ApiException('存在未完成配送,请完成或取消后修改地址');
  114. }
  115. }
  116. } elseif ($kind === 'sku') {
  117. $liters = $data['liters'] ?? null;
  118. if (!is_numeric($liters) || (float) $liters <= 0 || (float) $liters > 1000) {
  119. throw new ApiException('规格容量必须在 0 到 1000 升之间');
  120. }
  121. $values += ['name' => $this->word($data, 'name'), 'liters' => round((float) $liters, 2),
  122. 'safety_stock' => self::positive($data['safety_stock'] ?? 0, '安全库存', true), 'status' => $status];
  123. if ($id && (float) $this->row('sku', $id)['liters'] !== (float) $values['liters'] && WaterAccount::query()->where('sku_id', $id)->count()) {
  124. throw new ApiException('已使用的规格不能修改容量,请新增规格');
  125. }
  126. } elseif ($kind === 'package') {
  127. $sku = $this->row('sku', self::positive($data['sku_id'] ?? null, '商品规格'));
  128. $values += ['name' => $this->word($data, 'name'), 'sku_id' => $sku['id'],
  129. 'buckets' => self::positive($data['buckets'] ?? null, '套餐桶数'),
  130. 'price_cents' => self::positive($data['price_cents'] ?? null, '套餐金额(分)'), 'status' => $status];
  131. } elseif ($kind === 'device') {
  132. $enterprise = $this->row('enterprise', self::positive($data['enterprise_id'] ?? null, '企业'));
  133. $sku = $this->row('sku', self::positive($data['sku_id'] ?? null, '商品规格'));
  134. $sn = $this->word($data, 'sn', 80);
  135. if (WaterDevice::query()->where('sn', $sn)->where('id', '<>', $id)->count()) {
  136. throw new ApiException('设备 SN 已存在');
  137. }
  138. $values += ['sn' => $sn, 'name' => $this->word($data, 'name'), 'enterprise_id' => $enterprise['id'],
  139. 'sku_id' => $sku['id'], 'firmware' => $this->word($data, 'firmware', 40, false)];
  140. } elseif ($kind === 'content') {
  141. $values += ContentInput::values($data);
  142. } else {
  143. throw new ApiException('不支持的业务类型');
  144. }
  145. if ($id) {
  146. WaterModel::forKind($kind)->where('id', $id)->update($values);
  147. } else {
  148. $idNew = WaterModel::forKind($kind)->insertGetId($values + ['created_at' => $this->now()]);
  149. }
  150. $resultId = $id ?: (int) $idNew;
  151. $this->log('archive', $actor, 0, 0, 0, $kind . ':' . $resultId, $id ? '修改档案' : '新增档案');
  152. return ['id' => $resultId];
  153. });
  154. }
  155. public function deletePackage(array $data, int $actor): array
  156. {
  157. $id = self::positive($data['id'] ?? null, '套餐ID');
  158. return WaterModel::transaction(function () use ($id, $actor) {
  159. $package = $this->row('package', $id, true);
  160. WaterPackage::query()->where('id', $id)->delete();
  161. $this->log('archive', $actor, (int) $package['sku_id'], 0, 0, 'package:' . $id, '删除套餐:' . $package['name']);
  162. return ['id' => $id];
  163. });
  164. }
  165. public function purchase(array $data, int $actor): array
  166. {
  167. $reference = $this->word($data, 'reference', 100);
  168. $enterpriseId = self::positive($data['enterprise_id'] ?? null, '企业');
  169. $packageId = self::positive($data['package_id'] ?? null, '套餐');
  170. return WaterModel::transaction(function () use ($enterpriseId, $packageId, $reference, $actor) {
  171. // 收款凭据全局唯一;重试不重复增加权益。
  172. $package = $this->row('package', $packageId, true);
  173. $sku = $this->row('sku', (int) $package['sku_id'], true);
  174. $enterprise = $this->row('enterprise', $enterpriseId, true);
  175. $existing = WaterOrder::query()->where('reference', $reference)->first()?->toArray();
  176. if ($existing) {
  177. if ((int) $existing['enterprise_id'] !== $enterpriseId || (int) $existing['package_id'] !== $packageId) {
  178. throw new ApiException('该收款凭据已被其他订单使用');
  179. }
  180. return ['id' => (int) $existing['id'], 'replayed' => true];
  181. }
  182. if ((int) $package['status'] !== 1 || (int) $sku['status'] !== 1 || (int) $enterprise['status'] !== 1) {
  183. throw new ApiException('企业、商品或套餐已停用');
  184. }
  185. return (new WaterPurchaseReceipt())->issue($enterpriseId, $package, $reference, $actor);
  186. });
  187. }
  188. public function stock(array $data, int $actor): array
  189. {
  190. $skuId = self::positive($data['sku_id'] ?? null, '商品规格');
  191. $enterpriseId = self::positive($data['enterprise_id'] ?? 0, '企业', true);
  192. $quantity = self::positive($data['quantity'] ?? null, '数量', true);
  193. $mode = $data['mode'] ?? '';
  194. if (!in_array($mode, ['inbound', 'warehouse_count', 'site_count', 'exchange'], true)) {
  195. throw new ApiException('库存操作不支持');
  196. }
  197. $note = $this->word($data, 'note', 255);
  198. $reference = $this->word($data, 'reference', 100);
  199. return WaterModel::transaction(function () use ($skuId, $enterpriseId, $quantity, $mode, $note, $reference, $actor) {
  200. $sku = $this->row('sku', $skuId, true);
  201. $previous = WaterLedgerModel::query()->where(['sku_id' => $skuId, 'kind' => $mode, 'reference' => $reference])->first()?->toArray();
  202. if ($previous) {
  203. throw new ApiException('该操作凭据已登记,请刷新查看库存');
  204. }
  205. if (in_array($mode, ['inbound', 'warehouse_count'], true)) {
  206. $target = $mode === 'inbound' ? (int) $sku['available'] + $quantity : $quantity;
  207. $delta = $target - (int) $sku['available'];
  208. WaterSku::query()->where('id', $skuId)->update(['available' => $target, 'updated_at' => $this->now()]);
  209. } else {
  210. $this->row('enterprise', $enterpriseId, true);
  211. $account = $this->account($enterpriseId, $skuId);
  212. $delta = $mode === 'exchange' ? -$quantity : $quantity - (int) $account['full_qty'];
  213. if ((int) $account['full_qty'] + $delta < 0) {
  214. throw new ApiException('现场满桶不足');
  215. }
  216. WaterAccount::query()->where('id', $account['id'])->update([
  217. 'full_qty' => (int) $account['full_qty'] + $delta,
  218. 'empty_qty' => (int) $account['empty_qty'] + ($mode === 'exchange' ? $quantity : 0), 'updated_at' => $this->now(),
  219. ]);
  220. }
  221. $this->log($mode, $actor, $skuId, $enterpriseId, $delta, $reference, $note);
  222. return ['quantity' => $quantity];
  223. });
  224. }
  225. /** Save platform-wide parameters; legacy enterprise overrides remain unsupported. */
  226. public function saveAlertPolicy(array $data, int $actor): array
  227. {
  228. return (new \plugin\saiadmin\app\service\alerts\AlertSettings())->save($data, $actor);
  229. }
  230. /** 整批事务复用单笔冻结规则;固定加锁顺序减少并发批次死锁。 */
  231. public function batchCreateDelivery(array $data, int $actor): array
  232. {
  233. $items = $data['items'] ?? null;
  234. if (!is_array($items) || !array_is_list($items) || !$items || count($items) > 50) {
  235. throw new ApiException('请选择 1 到 50 条补货记录');
  236. }
  237. $tasks = [];
  238. foreach ($items as $item) {
  239. if (!is_array($item)) {
  240. throw new ApiException('补货记录格式不正确');
  241. }
  242. $id = self::positive($item['account_id'] ?? null, '权益账户');
  243. if (isset($tasks[$id])) {
  244. throw new ApiException('同一权益账户不能重复选择');
  245. }
  246. $account = $this->row('account', $id);
  247. $tasks[$id] = ['account_id' => $id, 'enterprise_id' => (int) $account['enterprise_id'],
  248. 'sku_id' => (int) $account['sku_id'], 'quantity' => self::positive($item['quantity'] ?? null, '配送桶数')];
  249. }
  250. usort($tasks, fn ($a, $b) => [$a['sku_id'], $a['enterprise_id']] <=> [$b['sku_id'], $b['enterprise_id']]);
  251. return WaterModel::transaction(function () use ($tasks, $actor) {
  252. $created = [];
  253. foreach ($tasks as $task) {
  254. try {
  255. $created[] = $this->createDelivery($task, $actor) + ['account_id' => $task['account_id']];
  256. } catch (ApiException $e) {
  257. throw new ApiException('账户 #' . $task['account_id'] . ':' . $e->getMessage() . ';本批次未创建任何任务');
  258. }
  259. }
  260. return ['created' => $created, 'count' => count($created)];
  261. });
  262. }
  263. public function createDelivery(array $data, int $actor): array
  264. {
  265. $enterpriseId = self::positive($data['enterprise_id'] ?? null, '企业');
  266. $skuId = self::positive($data['sku_id'] ?? null, '商品规格');
  267. $quantity = self::positive($data['quantity'] ?? null, '配送桶数');
  268. return WaterModel::transaction(function () use ($enterpriseId, $skuId, $quantity, $actor, $data) {
  269. $sku = $this->row('sku', $skuId, true);
  270. $enterprise = $this->row('enterprise', $enterpriseId, true);
  271. if ((int) $sku['status'] !== 1 || (int) $enterprise['status'] !== 1) {
  272. throw new ApiException('企业或商品已停用');
  273. }
  274. $addressId = self::positive($data['address_id'] ?? 0, '配送地址', true);
  275. $address = $addressId
  276. ? WaterEnterpriseAddress::query()->where('enterprise_id', $enterpriseId)->where('status', 1)->find($addressId)
  277. : WaterEnterpriseAddress::query()->where('enterprise_id', $enterpriseId)->where('status', 1)->where('is_default', 1)->orderBy('id')->first();
  278. if ($addressId && !$address) {
  279. throw new ApiException('配送地址不存在或已停用');
  280. }
  281. if (WaterDelivery::query()->where('enterprise_id', $enterpriseId)->where('sku_id', $skuId)->whereNotNull('active_key')->exists()) {
  282. throw new ApiException('该企业及规格已有有效配送任务');
  283. }
  284. $key = $enterpriseId . ':' . ($address?->id ?? 0) . ':' . $skuId;
  285. $account = $this->account($enterpriseId, $skuId);
  286. if ((int) $account['entitlement'] - (int) $account['frozen'] < $quantity) {
  287. throw new ApiException('可用套餐权益不足');
  288. }
  289. if ((int) $sku['available'] < $quantity) {
  290. throw new ApiException('仓库可用库存不足');
  291. }
  292. WaterSku::query()->where('id', $skuId)->update([
  293. 'available' => (int) $sku['available'] - $quantity, 'locked' => (int) $sku['locked'] + $quantity, 'updated_at' => $this->now(),
  294. ]);
  295. WaterAccount::query()->where('id', $account['id'])->update(['frozen' => (int) $account['frozen'] + $quantity, 'updated_at' => $this->now()]);
  296. $id = WaterDelivery::query()->insertGetId([
  297. 'enterprise_id' => $enterpriseId, 'address_id' => $address?->id, 'sku_id' => $skuId, 'quantity' => $quantity,
  298. 'address' => $address?->address ?? $enterprise['address'], 'region' => $address?->region ?? $enterprise['region'],
  299. 'active_key' => $key, 'created_by' => $actor, 'created_at' => $this->now(), 'updated_at' => $this->now(),
  300. 'scheduled_date' => WaterDispatch::date($data['date'] ?? date('Y-m-d')),
  301. 'longitude' => $address?->longitude ?? ($enterprise['longitude'] ?? null), 'latitude' => $address?->latitude ?? ($enterprise['latitude'] ?? null),
  302. ]);
  303. $this->log('reserve', $actor, $skuId, $enterpriseId, $quantity, 'delivery:' . $id, '冻结库存及权益');
  304. return ['id' => (int) $id];
  305. });
  306. }
  307. public function transition(array $data, int $actor): array
  308. {
  309. $id = self::positive($data['id'] ?? null, '配送任务');
  310. $action = $data['action'] ?? '';
  311. if (!in_array($action, ['assign', 'depart', 'complete', 'cancel'], true)) {
  312. throw new ApiException('配送操作不支持');
  313. }
  314. $preview = $this->row('delivery', $id);
  315. if ($action === 'complete') {
  316. if ($preview['status'] === 'completed' && !$preview['received_at']) {
  317. return ['id' => $id, 'replayed' => true];
  318. }
  319. return (new WaterFulfillment())->receive(['id' => $id, 'accepted_qty' => (int) $preview['quantity'],
  320. 'empty_returned' => $data['empty_returned'] ?? 0, 'receiver' => $data['receiver'] ?? '后台确认', 'reason' => ''], $actor);
  321. }
  322. return WaterModel::transaction(function () use ($preview, $id, $action, $data, $actor) {
  323. $skuId = (int) $preview['sku_id'];
  324. $enterpriseId = (int) $preview['enterprise_id'];
  325. $sku = $this->row('sku', $skuId, true);
  326. $task = $this->row('delivery', $id, true);
  327. $account = $this->account($enterpriseId, $skuId);
  328. $quantity = (int) $task['quantity'];
  329. $next = ['assign' => 'assigned', 'depart' => 'transit', 'complete' => 'completed', 'cancel' => 'cancelled'][$action];
  330. if ($task['status'] === $next) {
  331. return ['id' => $id, 'replayed' => true];
  332. }
  333. $allowed = ['assign' => ['pending'], 'depart' => ['assigned'], 'complete' => ['transit'], 'cancel' => ['pending', 'assigned']];
  334. if (!in_array($task['status'], $allowed[$action], true)) {
  335. throw new ApiException('当前配送状态不允许此操作');
  336. }
  337. $values = ['status' => $next, 'updated_at' => $this->now()];
  338. if ($action === 'assign') {
  339. $values += ['driver' => $this->word($data, 'driver', 60), 'driver_phone' => $this->word($data, 'driver_phone', 30)];
  340. } elseif ($action === 'depart') {
  341. if ($task['route_id'] ?? null) {
  342. $routeDate = WaterRoute::query()->where('id', $task['route_id'])->value('work_date');
  343. if ($routeDate > date('Y-m-d')) {
  344. throw new ApiException('配送日期未到,不能提前出库');
  345. }
  346. }
  347. if ((int) $sku['locked'] < $quantity) {
  348. throw new ApiException('锁定库存异常,请核对');
  349. }
  350. WaterSku::query()->where('id', $skuId)->update(['locked' => (int) $sku['locked'] - $quantity, 'transit' => (int) $sku['transit'] + $quantity]);
  351. } else {
  352. if ((int) $sku['locked'] < $quantity || (int) $account['frozen'] < $quantity) {
  353. throw new ApiException('冻结数据异常,请核对');
  354. }
  355. WaterSku::query()->where('id', $skuId)->update(['locked' => (int) $sku['locked'] - $quantity, 'available' => (int) $sku['available'] + $quantity]);
  356. WaterAccount::query()->where('id', $account['id'])->update(['frozen' => (int) $account['frozen'] - $quantity, 'updated_at' => $this->now()]);
  357. $values['active_key'] = null;
  358. }
  359. WaterDelivery::query()->where('id', $id)->update($values);
  360. $this->log($action, $actor, $skuId, $enterpriseId, $quantity, 'delivery:' . $id);
  361. return ['id' => $id, 'status' => $next];
  362. });
  363. }
  364. public function listing(string $module, array $filters): array
  365. {
  366. $keyword = trim((string) ($filters['keyword'] ?? ''));
  367. if (mb_strlen($keyword) > 120) {
  368. throw new ApiException('搜索内容过长');
  369. }
  370. $page = max(1, (int) ($filters['page'] ?? 1));
  371. $limit = min(100, max(1, (int) ($filters['limit'] ?? 20)));
  372. switch ($module) {
  373. case 'agents':
  374. $query = WaterAgent::query()->alias('a')->selectRaw('a.id,a.user_id,a.name,a.contact,a.phone,a.region,a.address,a.status,a.created_at,a.updated_at');
  375. $search = 'a.name|a.contact|a.phone|a.region';
  376. $order = 'a.id';
  377. break;
  378. case 'enterprises':
  379. $query = WaterEnterprise::query()->alias('e')->selectRaw('e.*');
  380. $search = 'e.name|e.contact|e.phone';
  381. $order = 'e.id';
  382. break;
  383. case 'inventory':
  384. $query = WaterSku::query()->alias('s')->selectRaw('s.*');
  385. $search = 's.name';
  386. $order = 's.id';
  387. break;
  388. case 'packages':
  389. $query = WaterPackage::query()->alias('p')->leftJoin('sa_water_sku as s', 's.id', '=', 'p.sku_id')->selectRaw('p.*,s.name AS sku_name,s.liters');
  390. $search = 'p.name|s.name';
  391. $order = 'p.id';
  392. break;
  393. case 'dispatch':
  394. $query = WaterDelivery::query()->alias('d')->leftJoin('sa_water_enterprise as e', 'e.id', '=', 'd.enterprise_id')->leftJoin('sa_water_sku as s', 's.id', '=', 'd.sku_id')->selectRaw('d.*,e.name AS enterprise_name,s.name AS sku_name');
  395. $search = 'e.name|d.driver|d.region';
  396. $order = 'd.id';
  397. if (!empty($filters['status'])) {
  398. $query->where('d.status', (string) $filters['status']);
  399. }
  400. break;
  401. case 'devices':
  402. $query = WaterDevice::query()->alias('d')->leftJoin('sa_water_enterprise as e', 'e.id', '=', 'd.enterprise_id')->leftJoin('sa_water_sku as s', 's.id', '=', 'd.sku_id')->selectRaw('d.*,e.name AS enterprise_name,s.name AS sku_name');
  403. $search = 'd.sn|d.name|e.name';
  404. $order = 'd.id';
  405. break;
  406. case 'finance':
  407. $query = WaterOrder::query()->alias('o')->leftJoin('sa_water_enterprise as e', 'e.id', '=', 'o.enterprise_id')->selectRaw('o.*,e.name AS enterprise_name');
  408. $search = 'o.reference|e.name|o.package_name';
  409. $order = 'o.id';
  410. break;
  411. case 'content':
  412. $query = WaterContent::query()->alias('c')->selectRaw('c.*');
  413. $search = 'c.title|c.region';
  414. $order = 'c.sort';
  415. break;
  416. case 'alerts':
  417. return (new WaterAlertDashboard())->listing($filters);
  418. case 'accounts':
  419. $query = WaterAccount::query()->alias('a')->join('sa_water_enterprise as e', 'e.id', '=', 'a.enterprise_id')->join('sa_water_sku as s', 's.id', '=', 'a.sku_id')
  420. ->withActiveDelivery()
  421. ->selectRaw('a.*,e.name AS enterprise_name,e.region,e.address,e.employee_count,s.name AS sku_name,s.liters,d.id AS delivery_id,d.status AS delivery_status');
  422. if (!empty($filters['enterprise_id'])) {
  423. $query->where('a.enterprise_id', (int) $filters['enterprise_id']);
  424. }
  425. $search = 'e.name|e.region|s.name';
  426. $order = 'a.id';
  427. break;
  428. case 'ledger':
  429. return (new WaterLedger())->listing($filters);
  430. default: throw new ApiException('不支持的业务模块');
  431. }
  432. if ($keyword !== '') {
  433. $query->matching($search, '%' . $keyword . '%');
  434. }
  435. $result = $query->orderBy($order, 'desc')->pageResult($page, $limit);
  436. $policy = $module === 'accounts' ? new ReplenishmentPolicy() : null;
  437. foreach ($result['data'] as &$row) {
  438. if (in_array($module, ['alerts', 'accounts'], true)) {
  439. $row = array_merge($row, $policy->evaluate($row));
  440. unset($row['raw_days_left']);
  441. }
  442. if ($module === 'devices') {
  443. $row['connection_status'] = !$row['last_seen'] ? 'unconnected' : (strtotime($row['last_seen']) < time() - 900 ? 'offline' : 'online');
  444. if ($row['telemetry_date'] !== date('Y-m-d')) {
  445. $row['today_liters'] = null;
  446. }
  447. }
  448. if ($module === 'content') {
  449. $row['delivery_status'] = ContentPolicy::status($row, $this->now());
  450. }
  451. }
  452. return $result;
  453. }
  454. public function overview(): array
  455. {
  456. $today = date('Y-m-d 00:00:00');
  457. $counts = WaterDelivery::query()->selectRaw('status,COUNT(*) AS total')->groupByRaw('status')->get()->toArray();
  458. $states = array_fill_keys(['pending', 'assigned', 'transit', 'returning', 'returned', 'completed', 'cancelled'], 0);
  459. foreach ($counts as $count) {
  460. $states[$count['status']] = (int) $count['total'];
  461. }
  462. $todayStates = WaterDelivery::query()->where('created_at', '>=', $today)->selectRaw('status,COUNT(*) AS total')->groupByRaw('status')->get()->toArray();
  463. $fulfilment = ['completed' => 0, 'transit' => 0, 'pending' => 0, 'returning' => 0, 'returned' => 0];
  464. foreach ($todayStates as $count) {
  465. if ($count['status'] === 'cancelled') {
  466. continue;
  467. }
  468. $key = in_array($count['status'], ['pending', 'assigned']) ? 'pending' : $count['status'];
  469. $fulfilment[$key] += (int) $count['total'];
  470. }
  471. $stock = WaterSku::query()->selectRaw('COALESCE(SUM(available),0) AS available, COALESCE(SUM(locked),0) AS locked, COALESCE(SUM(transit),0) AS transit, COALESCE(SUM(empty_qty),0) AS empty_qty')->first()->toArray();
  472. $trend = WaterOrder::query()->where('created_at','>=',date('Y-m-d 00:00:00',strtotime('-29 days')))->selectRaw('DATE(created_at) AS day,SUM(amount_cents) AS amount_cents')->groupByRaw('DATE(created_at)')->get()->toArray();
  473. $online = WaterDevice::query()->where('last_seen','>=',date('Y-m-d H:i:s',time() - 900))->count();
  474. return ['today_cents' => (int) WaterOrder::query()->where('created_at','>=',$today)->sum('amount_cents'),
  475. 'enterprise_count' => (int) WaterEnterprise::query()->count(),
  476. 'device_count' => (int) WaterDevice::query()->count(), 'online_count' => (int) $online,
  477. 'states' => $states, 'fulfilment' => $fulfilment, 'stock' => $stock, 'trend' => $trend,
  478. 'alerts' => $this->listing('alerts',['limit' => 5]),
  479. ];
  480. }
  481. }