WaterFulfillment.php 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. <?php
  2. declare(strict_types=1);
  3. namespace plugin\saiadmin\app\service;
  4. use plugin\saiadmin\app\model\water\{WaterModel,WaterSku,WaterDelivery,WaterAccount,WaterDriver,WaterRoute,WaterDeliveryEvent,WaterLedger};
  5. use plugin\saiadmin\app\service\fulfillment\{DeliveryState,DeliverySettlement};
  6. use plugin\saiadmin\exception\ApiException;
  7. /** Transactional command service. State policy and balance calculations remain independently testable. */
  8. final class WaterFulfillment
  9. {
  10. private function text(array $data,string $key,int $max,bool $required=true): string
  11. {
  12. $value=trim((string)($data[$key]??''));
  13. $label=['receiver'=>'收货人','reason'=>'异常原因','note'=>'核对说明'][$key]??$key;
  14. if (($required && $value==='') || mb_strlen($value)>$max) throw new ApiException($label.'不能为空或超出长度');
  15. return $value;
  16. }
  17. private function replay(string $key,array $payload): bool
  18. {
  19. $event=WaterDeliveryEvent::query()->where('event_key',$key)->first();
  20. if (!$event) return false;
  21. if (!hash_equals($event->payload_hash,hash('sha256',json_encode($payload)))) throw new ApiException('该操作已登记且本次数量或内容不同,请查看履约记录');
  22. return true;
  23. }
  24. private function event(string $key,string $action,array $payload,array $snapshot,int $actor,?int $taskId,?int $routeId): void
  25. {
  26. WaterDeliveryEvent::query()->insert(['event_key'=>$key,'action'=>$action,'delivery_id'=>$taskId,'route_id'=>$routeId,
  27. 'payload_hash'=>hash('sha256',json_encode($payload)),'payload'=>json_encode($payload,JSON_UNESCAPED_UNICODE),
  28. 'snapshot'=>json_encode($snapshot,JSON_UNESCAPED_UNICODE),'actor_id'=>$actor,'created_at'=>date('Y-m-d H:i:s')]);
  29. }
  30. private function ledger(string $kind,array $task,int $quantity,string $note,int $actor): void
  31. {
  32. WaterLedger::query()->insert(['kind'=>$kind,'enterprise_id'=>$task['enterprise_id']??0,'sku_id'=>$task['sku_id'],
  33. 'quantity'=>$quantity,'reference'=>isset($task['id'])?'delivery:'.$task['id']:'route:'.$task['route_id'],
  34. 'note'=>$note,'created_by'=>$actor,'created_at'=>date('Y-m-d H:i:s')]);
  35. }
  36. /** Lock order: SKU, driver, task, route, account; shared with dispatch and optimization. */
  37. private function locked(int $id): array
  38. {
  39. $preview=WaterDelivery::query()->find($id);
  40. if (!$preview) throw new ApiException('配送任务不存在');
  41. $sku=WaterSku::query()->where('id',$preview->sku_id)->lockForUpdate()->first();
  42. if ($preview->driver_id) WaterDriver::query()->where('id',$preview->driver_id)->lockForUpdate()->first();
  43. $task=WaterDelivery::query()->where('id',$id)->lockForUpdate()->first();
  44. $route=$task->route_id?WaterRoute::query()->where('id',$task->route_id)->lockForUpdate()->first():null;
  45. if ($route && $route->settled_at) throw new ApiException('路线已结算,不能更改配送结果');
  46. $account=WaterAccount::query()->where(['enterprise_id'=>$task->enterprise_id,'sku_id'=>$task->sku_id])->lockForUpdate()->first();
  47. if (!$sku || !$account) throw new ApiException('库存或权益账户不存在');
  48. return [$task->toArray(),$sku->toArray(),$account->toArray(),$route?->toArray()];
  49. }
  50. public function receive(array $data,int $actor): array
  51. {
  52. $id=WaterBusiness::positive($data['id']??null,'配送任务');
  53. $payload=['accepted_qty'=>WaterBusiness::positive($data['accepted_qty']??null,'实收桶数',true),
  54. 'empty_returned'=>WaterBusiness::positive($data['empty_returned']??0,'回收空桶',true),
  55. 'receiver'=>$this->text($data,'receiver',60),'reason'=>$this->text($data,'reason',255,false)];
  56. return WaterModel::transaction(function () use ($id,$payload,$actor) {
  57. // Replays remain valid after route settlement; serialized by the SKU lock.
  58. $preview=WaterDelivery::query()->find($id);
  59. if (!$preview) throw new ApiException('配送任务不存在');
  60. WaterSku::query()->where('id',$preview->sku_id)->lockForUpdate()->first();
  61. if ($this->replay('receive:'.$id,$payload)) return ['id'=>$id,'replayed'=>true];
  62. [$task,$sku,$account,$route]=$this->locked($id);
  63. DeliveryState::requireAction($task['status'],'receive');
  64. $accepted=$payload['accepted_qty']; $empty=$payload['empty_returned'];
  65. if ($accepted<(int)$task['quantity'] && $payload['reason']==='') throw new ApiException('部分签收或拒收必须填写原因');
  66. $balances=DeliverySettlement::receive($task,$sku,$account,$accepted,$empty);
  67. if (!$route) $balances['sku']['empty_qty']=(int)$sku['empty_qty']+$empty;
  68. WaterSku::query()->where('id',$sku['id'])->update($balances['sku']);
  69. WaterAccount::query()->where('id',$account['id'])->update($balances['account']+['updated_at'=>date('Y-m-d H:i:s')]);
  70. $full=$accepted===(int)$task['quantity'];
  71. $values=['status'=>$full?'completed':'returning','delivered_qty'=>$accepted,'empty_returned'=>$empty,
  72. 'empty_in_vehicle'=>$route?$empty:0,'receiver'=>$payload['receiver'],'exception_reason'=>$payload['reason'],
  73. 'received_at'=>date('Y-m-d H:i:s'),'updated_at'=>date('Y-m-d H:i:s')];
  74. if ($full) $values+=['active_key'=>null,'completed_at'=>date('Y-m-d H:i:s')];
  75. WaterDelivery::query()->where('id',$id)->update($values);
  76. $this->ledger($full?'complete':'partial_receive',$task,$accepted,'实收'.$accepted.'桶;待退回'.((int)$task['quantity']-$accepted).'桶;回收空桶'.$empty.';'.$payload['reason'],$actor);
  77. $this->event('receive:'.$id,'receive',$payload,['before'=>compact('task','sku','account'),'after'=>['task'=>$values]+$balances],$actor,$id,$task['route_id']);
  78. return ['id'=>$id,'status'=>$values['status']];
  79. });
  80. }
  81. public function returnReceive(array $data,int $actor): array
  82. {
  83. $id=WaterBusiness::positive($data['id']??null,'配送任务');
  84. $payload=['good_qty'=>WaterBusiness::positive($data['good_qty']??null,'可用退回桶数',true),
  85. 'damaged_qty'=>WaterBusiness::positive($data['damaged_qty']??0,'破损隔离桶数',true),'note'=>$this->text($data,'note',255)];
  86. return WaterModel::transaction(function () use ($id,$payload,$actor) {
  87. $preview=WaterDelivery::query()->find($id);
  88. if (!$preview) throw new ApiException('配送任务不存在');
  89. WaterSku::query()->where('id',$preview->sku_id)->lockForUpdate()->first();
  90. if ($this->replay('return:'.$id,$payload)) return ['id'=>$id,'replayed'=>true];
  91. [$task,$sku,$account]=$this->locked($id);
  92. DeliveryState::requireAction($task['status'],'return_receive');
  93. $balances=DeliverySettlement::returned($task,$sku,$account,$payload['good_qty'],$payload['damaged_qty']);
  94. WaterSku::query()->where('id',$sku['id'])->update($balances['sku']);
  95. WaterAccount::query()->where('id',$account['id'])->update($balances['account']+['updated_at'=>date('Y-m-d H:i:s')]);
  96. $values=['status'=>'returned','return_good_qty'=>$payload['good_qty'],'return_damaged_qty'=>$payload['damaged_qty'],
  97. 'active_key'=>null,'returned_at'=>date('Y-m-d H:i:s'),'updated_at'=>date('Y-m-d H:i:s')];
  98. WaterDelivery::query()->where('id',$id)->update($values);
  99. $this->ledger('delivery_return',$task,$payload['good_qty'],'退回可用'.$payload['good_qty'].'桶,破损隔离'.$payload['damaged_qty'].'桶;'.$payload['note'],$actor);
  100. $this->event('return:'.$id,'return_receive',$payload,['before'=>compact('task','sku','account'),'after'=>['task'=>$values]+$balances],$actor,$id,$task['route_id']);
  101. return ['id'=>$id,'status'=>'returned'];
  102. });
  103. }
  104. public function redeliver(array $data,int $actor): array
  105. {
  106. $id=WaterBusiness::positive($data['id']??null,'原配送任务');
  107. $payload=['date'=>WaterDispatch::date($data['date']??''),'note'=>$this->text($data,'note',255)];
  108. return WaterModel::transaction(function () use ($id,$payload,$actor) {
  109. $preview=WaterDelivery::query()->find($id);
  110. if (!$preview) throw new ApiException('原配送任务不存在');
  111. WaterSku::query()->where('id',$preview->sku_id)->lockForUpdate()->first();
  112. if ($this->replay('redeliver:'.$id,$payload)) return ['id'=>(int)WaterDelivery::query()->where('redelivery_of',$id)->value('id'),'replayed'=>true];
  113. $task=WaterDelivery::query()->where('id',$id)->lockForUpdate()->first()->toArray();
  114. DeliveryState::requireAction($task['status'],'redeliver');
  115. if ($payload['date']<date('Y-m-d')) throw new ApiException('重送日期不能早于今天');
  116. $new=(new WaterBusiness())->createDelivery(['enterprise_id'=>$task['enterprise_id'],'sku_id'=>$task['sku_id'],
  117. 'quantity'=>(int)$task['quantity']-(int)$task['delivered_qty'],'date'=>$payload['date'],
  118. 'address_id'=>(int)($task['address_id'] ?? 0)],$actor);
  119. WaterDelivery::query()->where('id',$new['id'])->update(['redelivery_of'=>$id,'origin_address'=>$task['origin_address'],
  120. 'origin_longitude'=>$task['origin_longitude'],'origin_latitude'=>$task['origin_latitude']]);
  121. $this->event('redeliver:'.$id,'redeliver',$payload,['new_delivery_id'=>$new['id']],$actor,$id,$task['route_id']);
  122. return $new;
  123. });
  124. }
  125. public function routeSummary(array $route): array
  126. {
  127. $tasks=$route['tasks']; $empties=[];
  128. foreach ($tasks as $task) {
  129. $sku=(int)$task['sku_id'];
  130. if (!isset($empties[$sku])) $empties[$sku]=['sku_id'=>$sku,'sku_name'=>$task['sku_name'],'expected'=>0];
  131. $empties[$sku]['expected']+=(int)$task['empty_in_vehicle'];
  132. }
  133. $pending=count(array_filter($tasks,fn($t)=>!in_array($t['status'],DeliveryState::TERMINAL,true)));
  134. return ['state'=>$route['settled_at']?'settled':($pending?'executing':'awaiting_settlement'),
  135. 'can_settle'=>!$route['settled_at'] && $pending===0,'empty_items'=>array_values($empties)];
  136. }
  137. public function settleRoute(array $data,int $actor): array
  138. {
  139. $id=WaterBusiness::positive($data['route_id']??null,'路线');
  140. $items=$data['items']??null;
  141. if (!is_array($items) || !array_is_list($items) || count($items)>500) throw new ApiException('返仓空桶明细格式不正确');
  142. $normalized=[];
  143. foreach ($items as $item) {
  144. if (!is_array($item)) throw new ApiException('返仓空桶明细格式不正确');
  145. $sku=WaterBusiness::positive($item['sku_id']??null,'商品');
  146. if (isset($normalized[$sku])) throw new ApiException('商品明细不能重复');
  147. $normalized[$sku]=['sku_id'=>$sku,'actual'=>WaterBusiness::positive($item['actual']??null,'实交空桶',true),
  148. 'lost'=>WaterBusiness::positive($item['lost']??0,'差异损失',true)];
  149. }
  150. ksort($normalized);
  151. $payload=['items'=>array_values($normalized),'note'=>$this->text($data,'note',255)];
  152. return WaterModel::transaction(function () use ($id,$payload,$normalized,$actor) {
  153. $route=WaterRoute::query()->find($id);
  154. if (!$route) throw new ApiException('路线不存在');
  155. $skuIds=WaterDelivery::query()->where('route_id',$id)->pluck('sku_id')->all();
  156. $skus=WaterSku::query()->whereIn('id',$skuIds)->orderBy('id')->lockForUpdate()->get()->keyBy('id');
  157. WaterDriver::query()->where('id',$route->driver_id)->lockForUpdate()->first();
  158. $tasks=WaterDelivery::query()->where('route_id',$id)->orderBy('id')->lockForUpdate()->get();
  159. $route=WaterRoute::query()->where('id',$id)->lockForUpdate()->first();
  160. if ($this->replay('settle:'.$id,$payload)) return ['id'=>$id,'replayed'=>true];
  161. if ($route->settled_at) throw new ApiException('路线已结算');
  162. $expected=[];
  163. foreach ($tasks as $task) {
  164. if (!in_array($task->status,DeliveryState::TERMINAL,true)) throw new ApiException('还有未完成配送或待退回货物,不能结算路线');
  165. $expected[$task->sku_id]=($expected[$task->sku_id]??0)+(int)$task->empty_in_vehicle;
  166. }
  167. if (count($normalized)!==count($expected) || array_diff_key($expected,$normalized)) throw new ApiException('返仓商品范围已变化,请刷新路线');
  168. foreach ($expected as $skuId=>$quantity) {
  169. $item=$normalized[$skuId];
  170. if ($item['actual']+$item['lost']!==$quantity) throw new ApiException('实交空桶与差异损失合计必须等于车载待交空桶');
  171. $sku=$skus[$skuId];
  172. WaterSku::query()->where('id',$skuId)->update(['empty_qty'=>(int)$sku->empty_qty+$item['actual'],
  173. 'empty_lost_qty'=>(int)$sku->empty_lost_qty+$item['lost']]);
  174. $this->ledger('route_settle',['sku_id'=>$skuId,'route_id'=>$id],$item['actual'],
  175. '返仓空桶'.$item['actual'].',差异损失'.$item['lost'].';'.$payload['note'],$actor);
  176. }
  177. WaterDelivery::query()->where('route_id',$id)->update(['empty_in_vehicle'=>0]);
  178. WaterRoute::query()->where('id',$id)->update(['settled_at'=>date('Y-m-d H:i:s'),'settled_by'=>$actor,'settlement_note'=>$payload['note']]);
  179. $this->event('settle:'.$id,'route_settle',$payload,['expected'=>$expected,'sku_before'=>$skus->toArray()],$actor,null,$id);
  180. return ['id'=>$id,'state'=>'settled'];
  181. });
  182. }
  183. public function history(int $id): array
  184. {
  185. $task=WaterDelivery::query()->find($id);
  186. if (!$task) throw new ApiException('配送任务不存在');
  187. return WaterDeliveryEvent::query()->where(function ($query) use ($id,$task) {
  188. $query->where('delivery_id',$id);
  189. if ($task->route_id) $query->orWhere(fn($route)=>$route->where('route_id',$task->route_id)->whereNull('delivery_id'));
  190. })->orderBy('id')->get()->map(function ($event) {
  191. return ['id'=>$event->id,'action'=>$event->action,'payload'=>json_decode($event->payload,true),
  192. 'actor_id'=>$event->actor_id,'created_at'=>$event->created_at];
  193. })->all();
  194. }
  195. }