| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197 |
- <?php
- declare(strict_types=1);
- namespace plugin\saiadmin\app\service;
- use plugin\saiadmin\app\model\water\{WaterModel,WaterSku,WaterDelivery,WaterAccount,WaterDriver,WaterRoute,WaterDeliveryEvent,WaterLedger};
- use plugin\saiadmin\app\service\fulfillment\{DeliveryState,DeliverySettlement};
- use plugin\saiadmin\exception\ApiException;
- /** Transactional command service. State policy and balance calculations remain independently testable. */
- final class WaterFulfillment
- {
- private function text(array $data,string $key,int $max,bool $required=true): string
- {
- $value=trim((string)($data[$key]??''));
- $label=['receiver'=>'收货人','reason'=>'异常原因','note'=>'核对说明'][$key]??$key;
- if (($required && $value==='') || mb_strlen($value)>$max) throw new ApiException($label.'不能为空或超出长度');
- return $value;
- }
- private function replay(string $key,array $payload): bool
- {
- $event=WaterDeliveryEvent::query()->where('event_key',$key)->first();
- if (!$event) return false;
- if (!hash_equals($event->payload_hash,hash('sha256',json_encode($payload)))) throw new ApiException('该操作已登记且本次数量或内容不同,请查看履约记录');
- return true;
- }
- private function event(string $key,string $action,array $payload,array $snapshot,int $actor,?int $taskId,?int $routeId): void
- {
- WaterDeliveryEvent::query()->insert(['event_key'=>$key,'action'=>$action,'delivery_id'=>$taskId,'route_id'=>$routeId,
- 'payload_hash'=>hash('sha256',json_encode($payload)),'payload'=>json_encode($payload,JSON_UNESCAPED_UNICODE),
- 'snapshot'=>json_encode($snapshot,JSON_UNESCAPED_UNICODE),'actor_id'=>$actor,'created_at'=>date('Y-m-d H:i:s')]);
- }
- private function ledger(string $kind,array $task,int $quantity,string $note,int $actor): void
- {
- WaterLedger::query()->insert(['kind'=>$kind,'enterprise_id'=>$task['enterprise_id']??0,'sku_id'=>$task['sku_id'],
- 'quantity'=>$quantity,'reference'=>isset($task['id'])?'delivery:'.$task['id']:'route:'.$task['route_id'],
- 'note'=>$note,'created_by'=>$actor,'created_at'=>date('Y-m-d H:i:s')]);
- }
- /** Lock order: SKU, driver, task, route, account; shared with dispatch and optimization. */
- private function locked(int $id): array
- {
- $preview=WaterDelivery::query()->find($id);
- if (!$preview) throw new ApiException('配送任务不存在');
- $sku=WaterSku::query()->where('id',$preview->sku_id)->lockForUpdate()->first();
- if ($preview->driver_id) WaterDriver::query()->where('id',$preview->driver_id)->lockForUpdate()->first();
- $task=WaterDelivery::query()->where('id',$id)->lockForUpdate()->first();
- $route=$task->route_id?WaterRoute::query()->where('id',$task->route_id)->lockForUpdate()->first():null;
- if ($route && $route->settled_at) throw new ApiException('路线已结算,不能更改配送结果');
- $account=WaterAccount::query()->where(['enterprise_id'=>$task->enterprise_id,'sku_id'=>$task->sku_id])->lockForUpdate()->first();
- if (!$sku || !$account) throw new ApiException('库存或权益账户不存在');
- return [$task->toArray(),$sku->toArray(),$account->toArray(),$route?->toArray()];
- }
- public function receive(array $data,int $actor): array
- {
- $id=WaterBusiness::positive($data['id']??null,'配送任务');
- $payload=['accepted_qty'=>WaterBusiness::positive($data['accepted_qty']??null,'实收桶数',true),
- 'empty_returned'=>WaterBusiness::positive($data['empty_returned']??0,'回收空桶',true),
- 'receiver'=>$this->text($data,'receiver',60),'reason'=>$this->text($data,'reason',255,false)];
- return WaterModel::transaction(function () use ($id,$payload,$actor) {
- // Replays remain valid after route settlement; serialized by the SKU lock.
- $preview=WaterDelivery::query()->find($id);
- if (!$preview) throw new ApiException('配送任务不存在');
- WaterSku::query()->where('id',$preview->sku_id)->lockForUpdate()->first();
- if ($this->replay('receive:'.$id,$payload)) return ['id'=>$id,'replayed'=>true];
- [$task,$sku,$account,$route]=$this->locked($id);
- DeliveryState::requireAction($task['status'],'receive');
- $accepted=$payload['accepted_qty']; $empty=$payload['empty_returned'];
- if ($accepted<(int)$task['quantity'] && $payload['reason']==='') throw new ApiException('部分签收或拒收必须填写原因');
- $balances=DeliverySettlement::receive($task,$sku,$account,$accepted,$empty);
- if (!$route) $balances['sku']['empty_qty']=(int)$sku['empty_qty']+$empty;
- WaterSku::query()->where('id',$sku['id'])->update($balances['sku']);
- WaterAccount::query()->where('id',$account['id'])->update($balances['account']+['updated_at'=>date('Y-m-d H:i:s')]);
- $full=$accepted===(int)$task['quantity'];
- $values=['status'=>$full?'completed':'returning','delivered_qty'=>$accepted,'empty_returned'=>$empty,
- 'empty_in_vehicle'=>$route?$empty:0,'receiver'=>$payload['receiver'],'exception_reason'=>$payload['reason'],
- 'received_at'=>date('Y-m-d H:i:s'),'updated_at'=>date('Y-m-d H:i:s')];
- if ($full) $values+=['active_key'=>null,'completed_at'=>date('Y-m-d H:i:s')];
- WaterDelivery::query()->where('id',$id)->update($values);
- $this->ledger($full?'complete':'partial_receive',$task,$accepted,'实收'.$accepted.'桶;待退回'.((int)$task['quantity']-$accepted).'桶;回收空桶'.$empty.';'.$payload['reason'],$actor);
- $this->event('receive:'.$id,'receive',$payload,['before'=>compact('task','sku','account'),'after'=>['task'=>$values]+$balances],$actor,$id,$task['route_id']);
- return ['id'=>$id,'status'=>$values['status']];
- });
- }
- public function returnReceive(array $data,int $actor): array
- {
- $id=WaterBusiness::positive($data['id']??null,'配送任务');
- $payload=['good_qty'=>WaterBusiness::positive($data['good_qty']??null,'可用退回桶数',true),
- 'damaged_qty'=>WaterBusiness::positive($data['damaged_qty']??0,'破损隔离桶数',true),'note'=>$this->text($data,'note',255)];
- return WaterModel::transaction(function () use ($id,$payload,$actor) {
- $preview=WaterDelivery::query()->find($id);
- if (!$preview) throw new ApiException('配送任务不存在');
- WaterSku::query()->where('id',$preview->sku_id)->lockForUpdate()->first();
- if ($this->replay('return:'.$id,$payload)) return ['id'=>$id,'replayed'=>true];
- [$task,$sku,$account]=$this->locked($id);
- DeliveryState::requireAction($task['status'],'return_receive');
- $balances=DeliverySettlement::returned($task,$sku,$account,$payload['good_qty'],$payload['damaged_qty']);
- WaterSku::query()->where('id',$sku['id'])->update($balances['sku']);
- WaterAccount::query()->where('id',$account['id'])->update($balances['account']+['updated_at'=>date('Y-m-d H:i:s')]);
- $values=['status'=>'returned','return_good_qty'=>$payload['good_qty'],'return_damaged_qty'=>$payload['damaged_qty'],
- 'active_key'=>null,'returned_at'=>date('Y-m-d H:i:s'),'updated_at'=>date('Y-m-d H:i:s')];
- WaterDelivery::query()->where('id',$id)->update($values);
- $this->ledger('delivery_return',$task,$payload['good_qty'],'退回可用'.$payload['good_qty'].'桶,破损隔离'.$payload['damaged_qty'].'桶;'.$payload['note'],$actor);
- $this->event('return:'.$id,'return_receive',$payload,['before'=>compact('task','sku','account'),'after'=>['task'=>$values]+$balances],$actor,$id,$task['route_id']);
- return ['id'=>$id,'status'=>'returned'];
- });
- }
- public function redeliver(array $data,int $actor): array
- {
- $id=WaterBusiness::positive($data['id']??null,'原配送任务');
- $payload=['date'=>WaterDispatch::date($data['date']??''),'note'=>$this->text($data,'note',255)];
- return WaterModel::transaction(function () use ($id,$payload,$actor) {
- $preview=WaterDelivery::query()->find($id);
- if (!$preview) throw new ApiException('原配送任务不存在');
- WaterSku::query()->where('id',$preview->sku_id)->lockForUpdate()->first();
- if ($this->replay('redeliver:'.$id,$payload)) return ['id'=>(int)WaterDelivery::query()->where('redelivery_of',$id)->value('id'),'replayed'=>true];
- $task=WaterDelivery::query()->where('id',$id)->lockForUpdate()->first()->toArray();
- DeliveryState::requireAction($task['status'],'redeliver');
- if ($payload['date']<date('Y-m-d')) throw new ApiException('重送日期不能早于今天');
- $new=(new WaterBusiness())->createDelivery(['enterprise_id'=>$task['enterprise_id'],'sku_id'=>$task['sku_id'],
- 'quantity'=>(int)$task['quantity']-(int)$task['delivered_qty'],'date'=>$payload['date'],
- 'address_id'=>(int)($task['address_id'] ?? 0)],$actor);
- WaterDelivery::query()->where('id',$new['id'])->update(['redelivery_of'=>$id,'origin_address'=>$task['origin_address'],
- 'origin_longitude'=>$task['origin_longitude'],'origin_latitude'=>$task['origin_latitude']]);
- $this->event('redeliver:'.$id,'redeliver',$payload,['new_delivery_id'=>$new['id']],$actor,$id,$task['route_id']);
- return $new;
- });
- }
- public function routeSummary(array $route): array
- {
- $tasks=$route['tasks']; $empties=[];
- foreach ($tasks as $task) {
- $sku=(int)$task['sku_id'];
- if (!isset($empties[$sku])) $empties[$sku]=['sku_id'=>$sku,'sku_name'=>$task['sku_name'],'expected'=>0];
- $empties[$sku]['expected']+=(int)$task['empty_in_vehicle'];
- }
- $pending=count(array_filter($tasks,fn($t)=>!in_array($t['status'],DeliveryState::TERMINAL,true)));
- return ['state'=>$route['settled_at']?'settled':($pending?'executing':'awaiting_settlement'),
- 'can_settle'=>!$route['settled_at'] && $pending===0,'empty_items'=>array_values($empties)];
- }
- public function settleRoute(array $data,int $actor): array
- {
- $id=WaterBusiness::positive($data['route_id']??null,'路线');
- $items=$data['items']??null;
- if (!is_array($items) || !array_is_list($items) || count($items)>500) throw new ApiException('返仓空桶明细格式不正确');
- $normalized=[];
- foreach ($items as $item) {
- if (!is_array($item)) throw new ApiException('返仓空桶明细格式不正确');
- $sku=WaterBusiness::positive($item['sku_id']??null,'商品');
- if (isset($normalized[$sku])) throw new ApiException('商品明细不能重复');
- $normalized[$sku]=['sku_id'=>$sku,'actual'=>WaterBusiness::positive($item['actual']??null,'实交空桶',true),
- 'lost'=>WaterBusiness::positive($item['lost']??0,'差异损失',true)];
- }
- ksort($normalized);
- $payload=['items'=>array_values($normalized),'note'=>$this->text($data,'note',255)];
- return WaterModel::transaction(function () use ($id,$payload,$normalized,$actor) {
- $route=WaterRoute::query()->find($id);
- if (!$route) throw new ApiException('路线不存在');
- $skuIds=WaterDelivery::query()->where('route_id',$id)->pluck('sku_id')->all();
- $skus=WaterSku::query()->whereIn('id',$skuIds)->orderBy('id')->lockForUpdate()->get()->keyBy('id');
- WaterDriver::query()->where('id',$route->driver_id)->lockForUpdate()->first();
- $tasks=WaterDelivery::query()->where('route_id',$id)->orderBy('id')->lockForUpdate()->get();
- $route=WaterRoute::query()->where('id',$id)->lockForUpdate()->first();
- if ($this->replay('settle:'.$id,$payload)) return ['id'=>$id,'replayed'=>true];
- if ($route->settled_at) throw new ApiException('路线已结算');
- $expected=[];
- foreach ($tasks as $task) {
- if (!in_array($task->status,DeliveryState::TERMINAL,true)) throw new ApiException('还有未完成配送或待退回货物,不能结算路线');
- $expected[$task->sku_id]=($expected[$task->sku_id]??0)+(int)$task->empty_in_vehicle;
- }
- if (count($normalized)!==count($expected) || array_diff_key($expected,$normalized)) throw new ApiException('返仓商品范围已变化,请刷新路线');
- foreach ($expected as $skuId=>$quantity) {
- $item=$normalized[$skuId];
- if ($item['actual']+$item['lost']!==$quantity) throw new ApiException('实交空桶与差异损失合计必须等于车载待交空桶');
- $sku=$skus[$skuId];
- WaterSku::query()->where('id',$skuId)->update(['empty_qty'=>(int)$sku->empty_qty+$item['actual'],
- 'empty_lost_qty'=>(int)$sku->empty_lost_qty+$item['lost']]);
- $this->ledger('route_settle',['sku_id'=>$skuId,'route_id'=>$id],$item['actual'],
- '返仓空桶'.$item['actual'].',差异损失'.$item['lost'].';'.$payload['note'],$actor);
- }
- WaterDelivery::query()->where('route_id',$id)->update(['empty_in_vehicle'=>0]);
- WaterRoute::query()->where('id',$id)->update(['settled_at'=>date('Y-m-d H:i:s'),'settled_by'=>$actor,'settlement_note'=>$payload['note']]);
- $this->event('settle:'.$id,'route_settle',$payload,['expected'=>$expected,'sku_before'=>$skus->toArray()],$actor,null,$id);
- return ['id'=>$id,'state'=>'settled'];
- });
- }
- public function history(int $id): array
- {
- $task=WaterDelivery::query()->find($id);
- if (!$task) throw new ApiException('配送任务不存在');
- return WaterDeliveryEvent::query()->where(function ($query) use ($id,$task) {
- $query->where('delivery_id',$id);
- if ($task->route_id) $query->orWhere(fn($route)=>$route->where('route_id',$task->route_id)->whereNull('delivery_id'));
- })->orderBy('id')->get()->map(function ($event) {
- return ['id'=>$event->id,'action'=>$event->action,'payload'=>json_decode($event->payload,true),
- 'actor_id'=>$event->actor_id,'created_at'=>$event->created_at];
- })->all();
- }
- }
|