'收货人','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']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(); } }