Files
anxinyan/server-api/app/support/FulfillmentFlowService.php
2026-05-26 17:08:33 +08:00

1076 lines
49 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace app\support;
use support\Request;
use support\think\Db;
class FulfillmentFlowService
{
public function lookupInboundByTrackingNo(string $trackingNo): array
{
return $this->lookupInboundByInboundNo($trackingNo);
}
public function lookupInboundByInboundNo(string $inboundNo): array
{
$match = $this->resolveInboundOrder($inboundNo);
return $this->formatOrderContext((int)$match['order_id']);
}
public function receiveInbound(string $inboundNo, string $tagNo, Request $request, mixed $attachments = []): array
{
$operator = $this->operator($request);
$match = $this->resolveInboundOrder($inboundNo);
$context = $this->formatOrderContext((int)$match['order_id']);
$order = $context['order_info'];
$orderId = (int)$order['id'];
if (!in_array((string)$order['order_status'], ['pending_shipping', 'received'], true)) {
throw new \InvalidArgumentException('当前订单状态不支持入库绑定');
}
$tagNo = $this->normalizeTagNo($tagNo);
if ($tagNo === '') {
throw new \InvalidArgumentException('请扫描或输入内部流转挂牌编号');
}
$now = date('Y-m-d H:i:s');
$inboundAttachments = (new AppraisalEvidenceService())->normalize($attachments, $request, true);
$attachmentCount = count($inboundAttachments);
$inboundRemark = $this->inboundMatchRemark($match);
if ($attachmentCount > 0) {
$inboundRemark .= sprintf(',已上传拆包附件 %d 个', $attachmentCount);
}
Db::startTrans();
try {
$tag = $this->ensureTransferTag($tagNo, $operator, $now);
$activeFlow = $this->findActiveFlowByTagId((int)$tag['id']);
if ($activeFlow && (int)$activeFlow['order_id'] !== $orderId) {
Db::rollback();
throw new \InvalidArgumentException('该内部流转挂牌已绑定其他未结束订单');
}
$flow = Db::name('order_transfer_flows')
->where('order_id', $orderId)
->where('flow_status', '<>', 'ended')
->order('id', 'desc')
->find();
if ($flow && (int)$flow['internal_tag_id'] !== (int)$tag['id']) {
Db::rollback();
throw new \InvalidArgumentException('当前订单已绑定其他内部流转挂牌');
}
if (!$flow) {
$flowId = (int)Db::name('order_transfer_flows')->insertGetId([
'order_id' => $orderId,
'internal_tag_id' => (int)$tag['id'],
'internal_tag_no' => $tagNo,
'service_provider' => (string)$order['service_provider'],
'flow_status' => 'active',
'current_stage' => 'warehouse_received',
'current_location' => 'warehouse_pending_inspection',
'inbound_by' => $operator['id'],
'inbound_by_name' => $operator['name'],
'inbound_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
$flow = Db::name('order_transfer_flows')->where('id', $flowId)->find();
} else {
Db::name('order_transfer_flows')->where('id', (int)$flow['id'])->update([
'current_stage' => 'warehouse_received',
'current_location' => 'warehouse_pending_inspection',
'inbound_by' => $flow['inbound_by'] ?: $operator['id'],
'inbound_by_name' => $flow['inbound_by_name'] ?: $operator['name'],
'inbound_at' => $flow['inbound_at'] ?: $now,
'updated_at' => $now,
]);
$flow = Db::name('order_transfer_flows')->where('id', (int)$flow['id'])->find();
}
Db::name('internal_transfer_tags')->where('id', (int)$tag['id'])->update([
'bind_status' => 'bound',
'current_order_id' => $orderId,
'current_flow_id' => (int)$flow['id'],
'current_stage' => 'warehouse_received',
'current_location' => 'warehouse_pending_inspection',
'bound_by' => $operator['id'],
'bound_by_name' => $operator['name'],
'bound_at' => $now,
'released_by' => null,
'released_by_name' => '',
'released_at' => null,
'updated_at' => $now,
]);
$logistics = Db::name('order_logistics')
->where('order_id', $orderId)
->where('logistics_type', 'send_to_center')
->order('id', 'desc')
->when(($match['match_type'] ?? '') === 'tracking_no', fn ($query) => $query->where('tracking_no', (string)$match['match_no']))
->find();
if ($logistics) {
Db::name('order_logistics')->where('id', (int)$logistics['id'])->update([
'tracking_status' => 'received',
'latest_desc' => '仓库已扫描寄入包裹并完成入库。',
'latest_time' => $now,
'updated_at' => $now,
]);
Db::name('order_logistics_nodes')->insert([
'logistics_id' => (int)$logistics['id'],
'node_time' => $now,
'node_desc' => '仓库已扫描寄入包裹并完成入库。',
'node_location' => '仓库',
'created_at' => $now,
]);
}
Db::name('orders')->where('id', $orderId)->update([
'order_status' => 'received',
'display_status' => '已入仓待检',
'updated_at' => $now,
]);
$this->insertTimeline($orderId, 'inbound_received', '已入仓待检', '仓管扫描寄入运单并完成物品入库。', $operator, $now);
$this->insertFlowLog($flow, 'inbound_received', '入库完成', '', '', 'warehouse_received', 'warehouse_pending_inspection', $operator, $inboundRemark, $now, [
'match_type' => (string)($match['match_type'] ?? ''),
'match_no' => (string)($match['match_no'] ?? ''),
'inbound_attachments' => $inboundAttachments,
]);
$this->insertFlowLog($flow, 'internal_tag_bound', '绑定内部流转挂牌', 'warehouse_received', 'warehouse_pending_inspection', 'warehouse_received', 'warehouse_pending_inspection', $operator, $tagNo, $now);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
return $this->formatOrderContext($orderId, $request);
}
public function scanTransferForAppraisal(string $tagNo, Request $request): array
{
$operator = $this->operator($request);
$flow = $this->findActiveFlowByTagNo($tagNo);
if (!$flow) {
throw new \RuntimeException('未找到可用的内部流转挂牌', 404);
}
$order = Db::name('orders')->where('id', (int)$flow['order_id'])->find();
if (!$order) {
throw new \RuntimeException('订单不存在', 404);
}
$serviceProvider = (string)($order['service_provider'] ?? '');
$stage = (string)($flow['current_stage'] ?? '');
if ($serviceProvider === 'zhongjian' && !in_array($stage, ['zhongjian_returned', 'appraising'], true)) {
throw new \InvalidArgumentException('中检订单需完成送检入库后才能录入报告');
}
if ($serviceProvider !== 'zhongjian' && !in_array($stage, ['warehouse_received', 'appraising'], true)) {
throw new \InvalidArgumentException('当前流转状态不支持进入鉴定作业');
}
$task = Db::name('appraisal_tasks')
->where('order_id', (int)$flow['order_id'])
->where('task_stage', 'first_review')
->order('id', 'asc')
->find();
if (!$task) {
throw new \RuntimeException('鉴定任务不存在', 404);
}
$now = date('Y-m-d H:i:s');
Db::startTrans();
try {
$taskUpdate = [
'status' => 'processing',
'started_at' => $task['started_at'] ?: $now,
'updated_at' => $now,
];
if (empty($task['assignee_id']) || empty($task['assignee_name']) || $task['assignee_name'] === '未分配') {
$taskUpdate['assignee_id'] = $operator['id'];
$taskUpdate['assignee_name'] = $operator['name'];
}
Db::name('appraisal_tasks')->where('id', (int)$task['id'])->update($taskUpdate);
Db::name('orders')->where('id', (int)$flow['order_id'])->update([
'order_status' => 'in_first_review',
'display_status' => $serviceProvider === 'zhongjian' ? '中检报告录入中' : '鉴定中',
'updated_at' => $now,
]);
$this->updateFlowStage($flow, 'appraising', $serviceProvider === 'zhongjian' ? 'zhongjian_report_entry' : 'appraiser_workbench', [
'appraisal_started_by' => $flow['appraisal_started_by'] ?: $operator['id'],
'appraisal_started_by_name' => $flow['appraisal_started_by_name'] ?: $operator['name'],
'appraisal_started_at' => $flow['appraisal_started_at'] ?: $now,
], $now);
$this->insertTimeline((int)$flow['order_id'], 'appraisal_started', $serviceProvider === 'zhongjian' ? '中检报告录入中' : '鉴定中', $serviceProvider === 'zhongjian' ? '报告录入人已扫描内部流转码进入中检报告录入。' : '鉴定师已扫描内部流转码进入鉴定作业。', $operator, $now);
$this->insertFlowLog($flow, 'appraisal_started', $serviceProvider === 'zhongjian' ? '中检报告录入开始' : '鉴定开始', (string)$flow['current_stage'], (string)$flow['current_location'], 'appraising', $serviceProvider === 'zhongjian' ? 'zhongjian_report_entry' : 'appraiser_workbench', $operator, '', $now);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
return [
'task_id' => (int)$task['id'],
'order_id' => (int)$flow['order_id'],
'service_provider' => $serviceProvider,
'service_provider_text' => $serviceProvider === 'zhongjian' ? '中检鉴定' : '实物鉴定',
];
}
public function lookupZhongjianTransfer(string $tagNo): array
{
$flow = $this->findActiveFlowByTagNo($tagNo);
if (!$flow) {
throw new \RuntimeException('未找到可用的内部流转挂牌', 404);
}
if (($flow['service_provider'] ?? '') !== 'zhongjian') {
throw new \InvalidArgumentException('非中检订单不能进行送检出入库');
}
$stage = (string)$flow['current_stage'];
$nextAction = match ($stage) {
'warehouse_received' => 'outbound',
'sent_to_zhongjian' => 'inbound',
default => '',
};
return array_merge($this->formatOrderContext((int)$flow['order_id']), [
'next_action' => $nextAction,
'next_action_text' => $nextAction === 'outbound' ? '送检出库' : ($nextAction === 'inbound' ? '送检入库' : '暂无可执行送检动作'),
]);
}
public function zhongjianOutbound(string $tagNo, Request $request): array
{
return $this->moveZhongjian($tagNo, 'warehouse_received', 'sent_to_zhongjian', 'zhongjian_institution', 'zhongjian_outbound', '送检出库', '仓管扫描内部流转码,物品已送出至中检机构。', $request);
}
public function zhongjianInbound(string $tagNo, Request $request): array
{
return $this->moveZhongjian($tagNo, 'sent_to_zhongjian', 'zhongjian_returned', 'warehouse_pending_report_entry', 'zhongjian_inbound', '送检入库', '仓管扫描内部流转码,中检物品已回收入库。', $request);
}
public function lookupReturn(string $tagNo, Request $request): array
{
$flow = $this->findActiveFlowByTagNo($tagNo);
if (!$flow) {
throw new \RuntimeException('未找到可用的内部流转挂牌', 404);
}
$context = $this->formatOrderContext((int)$flow['order_id'], $request);
$report = $context['report_info'] ?? null;
if (!$report || ($report['report_status'] ?? '') !== 'published') {
throw new \InvalidArgumentException('订单报告未发布,不能进入寄回流程');
}
return $context + [
'return_confirmation' => [
'confirmed' => (string)($flow['current_stage'] ?? '') === 'return_confirmed',
'confirmed_by_name' => (string)($flow['return_confirmed_by_name'] ?? ''),
'confirmed_at' => (string)($flow['return_confirmed_at'] ?? ''),
],
];
}
public function verifyReturnMaterialTag(string $tagNo, string $qrInput, Request $request): array
{
$flow = $this->findActiveFlowByTagNo($tagNo);
if (!$flow) {
throw new \RuntimeException('未找到可用的内部流转挂牌', 404);
}
$report = $this->latestReport((int)$flow['order_id']);
if (!$report || ($report['report_status'] ?? '') !== 'published') {
throw new \InvalidArgumentException('订单报告未发布,不能确认寄回');
}
$tag = (new MaterialTagService())->findTagByInput($qrInput);
if (!$tag || (int)($tag['report_id'] ?? 0) !== (int)$report['id']) {
throw new \InvalidArgumentException('验真吊牌与当前订单报告不匹配');
}
return array_merge($this->formatOrderContext((int)$flow['order_id'], $request), [
'return_verification' => [
'verified' => true,
'report_id' => (int)$report['id'],
'report_no' => (string)$report['report_no'],
],
]);
}
public function confirmZhongjianReturn(string $tagNo, Request $request): array
{
$operator = $this->operator($request);
$flow = $this->findActiveFlowByTagNo($tagNo);
if (!$flow) {
throw new \RuntimeException('未找到可用的内部流转挂牌', 404);
}
if (($flow['service_provider'] ?? '') !== 'zhongjian') {
throw new \InvalidArgumentException('非中检订单需扫描平台验真吊牌确认');
}
$report = $this->latestReport((int)$flow['order_id']);
$content = $report ? Db::name('report_contents')->where('report_id', (int)$report['id'])->find() : null;
$files = $this->decodeJsonArray($content['zhongjian_report_files_json'] ?? null);
if (!$report || ($report['report_status'] ?? '') !== 'published' || trim((string)($report['zhongjian_report_no'] ?? '')) === '' || !$files) {
throw new \InvalidArgumentException('中检报告未完整录入,不能确认寄回');
}
return $this->markReturnConfirmed($flow, $operator, 'return_confirmed', '中检报告已确认', '仓管已查看中检报告编号和报告文件。');
}
public function confirmReturnReport(string $tagNo, int $reportId, Request $request): array
{
$operator = $this->operator($request);
$flow = $this->findActiveFlowByTagNo($tagNo);
if (!$flow) {
throw new \RuntimeException('未找到可用的内部流转挂牌', 404);
}
$report = $this->latestReport((int)$flow['order_id']);
if (!$report || ($report['report_status'] ?? '') !== 'published') {
throw new \InvalidArgumentException('订单报告未发布,不能确认寄回');
}
if ((int)$report['id'] !== $reportId) {
throw new \InvalidArgumentException('确认的报告与当前订单报告不匹配');
}
if ((string)($flow['current_stage'] ?? '') === 'return_confirmed') {
return $this->formatOrderContext((int)$flow['order_id'], $request);
}
if (($flow['service_provider'] ?? '') === 'zhongjian') {
$content = Db::name('report_contents')->where('report_id', (int)$report['id'])->find();
$files = $this->decodeJsonArray($content['zhongjian_report_files_json'] ?? null);
if (trim((string)($report['zhongjian_report_no'] ?? '')) === '' || !$files) {
throw new \InvalidArgumentException('中检报告未完整录入,不能确认寄回');
}
return $this->markReturnConfirmed($flow, $operator, 'return_confirmed', '中检报告已确认', '仓管已查看中检报告编号和报告文件。');
}
$boundTag = (new MaterialTagService())->findBoundTagForReport((int)$report['id']);
if (!$boundTag) {
throw new \InvalidArgumentException('当前报告未绑定验真吊牌,不能确认寄回');
}
return $this->markReturnConfirmed($flow, $operator, 'return_tag_verified', '验真吊牌确认', '仓管已核对验真吊牌与报告信息。');
}
public function shipReturn(string $tagNo, string $expressCompany, string $trackingNo, Request $request, array $packingAttachments = []): array
{
$operator = $this->operator($request);
$flow = $this->findActiveFlowByTagNo($tagNo);
if (!$flow) {
throw new \RuntimeException('未找到可用的内部流转挂牌', 404);
}
if ((string)($flow['current_stage'] ?? '') !== 'return_confirmed') {
throw new \InvalidArgumentException('请先完成报告确认,再登记回寄运单');
}
$expressCompany = trim($expressCompany);
$trackingNo = trim($trackingNo);
if ($expressCompany === '' || $trackingNo === '') {
throw new \InvalidArgumentException('请填写回寄快递公司和运单号');
}
$packingAttachments = $this->normalizeAssetList($packingAttachments, $request);
foreach ($packingAttachments as $asset) {
if (!in_array((string)($asset['file_type'] ?? ''), ['image', 'video'], true)) {
throw new \InvalidArgumentException('打包装箱附件仅支持图片或视频');
}
}
$orderId = (int)$flow['order_id'];
$order = Db::name('orders')->where('id', $orderId)->find();
if (!$order) {
throw new \RuntimeException('订单不存在', 404);
}
$returnAddress = $this->returnAddressForOrder($order);
if (!$returnAddress) {
throw new \InvalidArgumentException('当前订单尚未确认寄回地址,且用户账户下没有可用地址');
}
$now = date('Y-m-d H:i:s');
$latestDesc = sprintf('平台已通过 %s 回寄商品,运单号 %s。', $expressCompany, $trackingNo);
Db::startTrans();
try {
$this->ensureReturnAddressSnapshot($orderId, $returnAddress, $now);
$existing = Db::name('order_logistics')
->where('order_id', $orderId)
->where('logistics_type', 'return_to_user')
->order('id', 'desc')
->find();
if ($existing) {
Db::name('order_logistics')->where('id', (int)$existing['id'])->update([
'express_company' => $expressCompany,
'tracking_no' => $trackingNo,
'tracking_status' => 'in_transit',
'latest_desc' => $latestDesc,
'latest_time' => $now,
'updated_at' => $now,
]);
$logisticsId = (int)$existing['id'];
$nodeText = '已更新回寄运单';
$nodeDesc = sprintf('平台更新回寄运单:%s %s', $expressCompany, $trackingNo);
} else {
$logisticsId = (int)Db::name('order_logistics')->insertGetId([
'order_id' => $orderId,
'logistics_type' => 'return_to_user',
'express_company' => $expressCompany,
'tracking_no' => $trackingNo,
'tracking_status' => 'in_transit',
'latest_desc' => $latestDesc,
'latest_time' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
$nodeText = '已寄回用户';
$nodeDesc = sprintf('平台已通过 %s 回寄商品,运单号 %s', $expressCompany, $trackingNo);
}
Db::name('order_logistics_nodes')->insert([
'logistics_id' => $logisticsId,
'node_time' => $now,
'node_desc' => $latestDesc,
'node_location' => $returnAddress['city'] ?? '用户地址',
'created_at' => $now,
]);
Db::name('orders')->where('id', $orderId)->update([
'order_status' => 'completed',
'display_status' => '物品已寄回',
'updated_at' => $now,
]);
Db::name('order_transfer_flows')->where('id', (int)$flow['id'])->update([
'flow_status' => 'ended',
'current_stage' => 'return_shipped',
'current_location' => 'ended',
'return_shipped_by' => $operator['id'],
'return_shipped_by_name' => $operator['name'],
'return_shipped_at' => $now,
'ended_at' => $now,
'updated_at' => $now,
]);
Db::name('internal_transfer_tags')->where('id', (int)$flow['internal_tag_id'])->update([
'bind_status' => 'released',
'current_order_id' => null,
'current_flow_id' => null,
'current_stage' => 'idle',
'current_location' => 'warehouse',
'released_by' => $operator['id'],
'released_by_name' => $operator['name'],
'released_at' => $now,
'updated_at' => $now,
]);
$this->insertTimeline($orderId, 'return_shipped', $nodeText, $nodeDesc, $operator, $now);
$this->insertFlowLog($flow, 'return_shipped', '物品寄回', 'return_confirmed', (string)$flow['current_location'], 'return_shipped', 'ended', $operator, $trackingNo, $now, [
'packing_attachments' => $packingAttachments,
]);
(new MessageDispatcher())->sendInboxEvent('return_shipped', [
'user_id' => (int)($order['user_id'] ?? 0),
'biz_type' => 'return_shipped',
'biz_id' => $orderId,
'express_company' => $expressCompany,
'tracking_no' => $trackingNo,
'fallback_title' => '鉴定物品已寄回',
'fallback_content' => sprintf('平台已通过%s回寄鉴定物品运单号 %s可前往订单详情查看物流进度。', $expressCompany, $trackingNo),
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
(new EnterpriseWebhookService())->recordOrderEvent($orderId, 'return_shipped', [
'express_company' => $expressCompany,
'tracking_no' => $trackingNo,
'shipped_at' => $now,
]);
(new OrderLogisticsSyncService())->subscribeAsync($logisticsId);
return $this->formatOrderContext($orderId);
}
public function markReportPublished(int $orderId, Request $request): void
{
$flow = Db::name('order_transfer_flows')
->where('order_id', $orderId)
->where('flow_status', '<>', 'ended')
->order('id', 'desc')
->find();
if (!$flow) {
return;
}
$operator = $this->operator($request);
$now = date('Y-m-d H:i:s');
$this->updateFlowStage($flow, 'report_published', 'warehouse_return_pending', [
'report_published_by' => $operator['id'],
'report_published_by_name' => $operator['name'],
'report_published_at' => $now,
], $now);
$this->insertFlowLog($flow, 'report_published', '报告已发布', (string)$flow['current_stage'], (string)$flow['current_location'], 'report_published', 'warehouse_return_pending', $operator, '', $now);
}
public function formatOrderContext(int $orderId, ?Request $request = null): array
{
$order = Db::name('orders')->where('id', $orderId)->find();
if (!$order) {
throw new \RuntimeException('订单不存在', 404);
}
$product = Db::name('order_products')->where('order_id', $orderId)->find() ?: [];
$sendLogistics = Db::name('order_logistics')->where('order_id', $orderId)->where('logistics_type', 'send_to_center')->order('id', 'desc')->find();
$returnLogistics = Db::name('order_logistics')->where('order_id', $orderId)->where('logistics_type', 'return_to_user')->order('id', 'desc')->find();
$syncService = new OrderLogisticsSyncService();
$sendSyncStatus = $sendLogistics ? $syncService->formatSyncStatus((int)$sendLogistics['id']) : ['provider_status_text' => '', 'sync_status_text' => '未同步', 'sync_error' => ''];
$returnSyncStatus = $returnLogistics ? $syncService->formatSyncStatus((int)$returnLogistics['id']) : ['provider_status_text' => '', 'sync_status_text' => '未同步', 'sync_error' => ''];
$flow = Db::name('order_transfer_flows')->where('order_id', $orderId)->order('id', 'desc')->find();
$report = $this->latestReport($orderId);
$content = $report ? Db::name('report_contents')->where('report_id', (int)$report['id'])->find() : null;
$returnAddress = $this->returnAddressForOrder($order);
$flowLogs = $flow ? Db::name('order_transfer_flow_logs')
->where('flow_id', (int)$flow['id'])
->order('id', 'asc')
->select()
->toArray() : [];
return [
'order_info' => [
'id' => (int)$order['id'],
'order_no' => (string)$order['order_no'],
'appraisal_no' => (string)$order['appraisal_no'],
'service_provider' => (string)$order['service_provider'],
'service_provider_text' => $this->serviceProviderText((string)$order['service_provider']),
'source_channel' => (string)($order['source_channel'] ?? ''),
'source_channel_text' => $this->sourceChannelText((string)($order['source_channel'] ?? '')),
'source_customer_id' => (string)($order['source_customer_id'] ?? ''),
'order_status' => (string)$order['order_status'],
'display_status' => (string)$order['display_status'],
],
'product_info' => [
'product_name' => (string)($product['product_name'] ?? ''),
'category_name' => (string)($product['category_name'] ?? ''),
'brand_name' => (string)($product['brand_name'] ?? ''),
'color' => (string)($product['color'] ?? ''),
'size_spec' => (string)($product['size_spec'] ?? ''),
'serial_no' => (string)($product['serial_no'] ?? ''),
],
'logistics_info' => $sendLogistics ? [
'express_company' => (string)$sendLogistics['express_company'],
'tracking_no' => (string)$sendLogistics['tracking_no'],
'tracking_status' => (string)$sendLogistics['tracking_status'],
'tracking_status_text' => $this->trackingStatusText((string)$sendLogistics['tracking_status'], 'send_to_center'),
'provider_status_text' => $sendSyncStatus['provider_status_text'],
'sync_status_text' => $sendSyncStatus['sync_status_text'],
'sync_error' => $sendSyncStatus['sync_error'],
'latest_desc' => (string)($sendLogistics['latest_desc'] ?? ''),
'latest_time' => (string)($sendLogistics['latest_time'] ?? ''),
'nodes' => array_map(fn (array $item) => [
'node_time' => (string)$item['node_time'],
'node_desc' => (string)$item['node_desc'],
'node_location' => (string)$item['node_location'],
], $syncService->nodesForLogistics((int)$sendLogistics['id'])),
] : null,
'return_address' => $returnAddress ? [
'consignee' => (string)($returnAddress['consignee'] ?? ''),
'mobile' => (string)($returnAddress['mobile'] ?? ''),
'full_address' => trim(sprintf('%s%s%s%s', $returnAddress['province'] ?? '', $returnAddress['city'] ?? '', $returnAddress['district'] ?? '', $returnAddress['detail_address'] ?? '')),
] : null,
'return_logistics' => $returnLogistics ? [
'express_company' => (string)$returnLogistics['express_company'],
'tracking_no' => (string)$returnLogistics['tracking_no'],
'tracking_status' => (string)$returnLogistics['tracking_status'],
'tracking_status_text' => $this->trackingStatusText((string)$returnLogistics['tracking_status'], 'return_to_user'),
'provider_status_text' => $returnSyncStatus['provider_status_text'],
'sync_status_text' => $returnSyncStatus['sync_status_text'],
'sync_error' => $returnSyncStatus['sync_error'],
'latest_desc' => (string)($returnLogistics['latest_desc'] ?? ''),
'latest_time' => (string)($returnLogistics['latest_time'] ?? ''),
'nodes' => array_map(fn (array $item) => [
'node_time' => (string)$item['node_time'],
'node_desc' => (string)$item['node_desc'],
'node_location' => (string)$item['node_location'],
], $syncService->nodesForLogistics((int)$returnLogistics['id'])),
] : null,
'transfer_flow' => $flow ? $this->formatFlow($flow) : null,
'report_info' => $report ? [
'id' => (int)$report['id'],
'report_no' => (string)$report['report_no'],
'report_title' => (string)$report['report_title'],
'report_status' => (string)$report['report_status'],
'publish_time' => (string)($report['publish_time'] ?? ''),
'zhongjian_report_no' => (string)($report['zhongjian_report_no'] ?? ''),
'report_entry_admin_name' => (string)($report['report_entry_admin_name'] ?? ''),
'report_entered_at' => (string)($report['report_entered_at'] ?? ''),
'zhongjian_report_files' => $this->normalizeAssetList($this->decodeJsonArray($content['zhongjian_report_files_json'] ?? null), $request),
] : null,
'flow_logs' => array_map(fn (array $log) => $this->formatFlowLog($log, $request), $flowLogs),
];
}
private function moveZhongjian(string $tagNo, string $expectedStage, string $nextStage, string $nextLocation, string $actionCode, string $actionText, string $desc, Request $request): array
{
$operator = $this->operator($request);
$flow = $this->findActiveFlowByTagNo($tagNo);
if (!$flow) {
throw new \RuntimeException('未找到可用的内部流转挂牌', 404);
}
if (($flow['service_provider'] ?? '') !== 'zhongjian') {
throw new \InvalidArgumentException('非中检订单不能进行送检出入库');
}
if ((string)$flow['current_stage'] !== $expectedStage) {
throw new \InvalidArgumentException('当前流转状态不支持该送检动作');
}
$now = date('Y-m-d H:i:s');
$auditFields = $actionCode === 'zhongjian_outbound'
? ['zhongjian_outbound_by' => $operator['id'], 'zhongjian_outbound_by_name' => $operator['name'], 'zhongjian_outbound_at' => $now]
: ['zhongjian_inbound_by' => $operator['id'], 'zhongjian_inbound_by_name' => $operator['name'], 'zhongjian_inbound_at' => $now];
Db::startTrans();
try {
$this->updateFlowStage($flow, $nextStage, $nextLocation, $auditFields, $now);
Db::name('orders')->where('id', (int)$flow['order_id'])->update([
'display_status' => $actionCode === 'zhongjian_outbound' ? '中检送检中' : '中检待录入报告',
'updated_at' => $now,
]);
$this->insertTimeline((int)$flow['order_id'], $actionCode, $actionText, $desc, $operator, $now);
$this->insertFlowLog($flow, $actionCode, $actionText, (string)$flow['current_stage'], (string)$flow['current_location'], $nextStage, $nextLocation, $operator, '', $now);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
return $this->lookupZhongjianTransfer($tagNo);
}
private function markReturnConfirmed(array $flow, array $operator, string $actionCode, string $actionText, string $timelineDesc): array
{
$now = date('Y-m-d H:i:s');
Db::startTrans();
try {
$this->updateFlowStage($flow, 'return_confirmed', 'warehouse_return_pending', [
'return_confirmed_by' => $operator['id'],
'return_confirmed_by_name' => $operator['name'],
'return_confirmed_at' => $now,
], $now);
$this->insertTimeline((int)$flow['order_id'], $actionCode, $actionText, $timelineDesc, $operator, $now);
$this->insertFlowLog($flow, $actionCode, $actionText, (string)$flow['current_stage'], (string)$flow['current_location'], 'return_confirmed', 'warehouse_return_pending', $operator, '', $now);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
return $this->formatOrderContext((int)$flow['order_id']);
}
private function updateFlowStage(array $flow, string $stage, string $location, array $extra, string $now): void
{
Db::name('order_transfer_flows')->where('id', (int)$flow['id'])->update(array_merge([
'current_stage' => $stage,
'current_location' => $location,
'updated_at' => $now,
], $extra));
Db::name('internal_transfer_tags')->where('id', (int)$flow['internal_tag_id'])->update([
'current_stage' => $stage,
'current_location' => $location,
'updated_at' => $now,
]);
}
private function insertTimeline(int $orderId, string $code, string $text, string $desc, array $operator, string $now): void
{
Db::name('order_timelines')->insert([
'order_id' => $orderId,
'node_code' => $code,
'node_text' => $text,
'node_desc' => $desc,
'operator_type' => 'admin',
'operator_id' => $operator['id'],
'occurred_at' => $now,
'created_at' => $now,
]);
}
private function insertFlowLog(array $flow, string $code, string $text, string $beforeStage, string $beforeLocation, string $afterStage, string $afterLocation, array $operator, string $remark, string $now, ?array $payload = null): void
{
Db::name('order_transfer_flow_logs')->insert([
'flow_id' => (int)$flow['id'],
'order_id' => (int)$flow['order_id'],
'internal_tag_id' => (int)$flow['internal_tag_id'],
'internal_tag_no' => (string)$flow['internal_tag_no'],
'action_code' => $code,
'action_text' => $text,
'before_stage' => $beforeStage,
'before_location' => $beforeLocation,
'after_stage' => $afterStage,
'after_location' => $afterLocation,
'operator_id' => $operator['id'],
'operator_name' => $operator['name'],
'remark' => mb_substr($remark, 0, 500),
'payload_json' => $payload ? json_encode($payload, JSON_UNESCAPED_UNICODE) : null,
'created_at' => $now,
]);
}
private function formatFlowLog(array $log, ?Request $request): array
{
$payload = $this->decodeJsonObject($log['payload_json'] ?? null);
$attachments = $this->normalizeAssetList($this->decodeJsonArray($payload['inbound_attachments'] ?? []), $request);
$packingAttachments = $this->normalizeAssetList($this->decodeJsonArray($payload['packing_attachments'] ?? []), $request);
return [
'id' => (int)$log['id'],
'action_code' => (string)$log['action_code'],
'action_text' => (string)$log['action_text'],
'before_stage' => $this->stageText((string)($log['before_stage'] ?? '')),
'before_location' => $this->locationText((string)($log['before_location'] ?? '')),
'after_stage' => $this->stageText((string)($log['after_stage'] ?? '')),
'after_location' => $this->locationText((string)($log['after_location'] ?? '')),
'operator_name' => (string)($log['operator_name'] ?? ''),
'remark' => (string)($log['remark'] ?? ''),
'created_at' => (string)($log['created_at'] ?? ''),
'inbound_attachments' => $attachments,
'packing_attachments' => $packingAttachments,
];
}
private function ensureTransferTag(string $tagNo, array $operator, string $now): array
{
$tag = Db::name('internal_transfer_tags')->where('tag_no', $tagNo)->find();
if ($tag) {
if (($tag['status'] ?? 'active') === 'invalid') {
throw new \InvalidArgumentException('该内部流转挂牌已失效');
}
return $tag;
}
$id = (int)Db::name('internal_transfer_tags')->insertGetId([
'tag_no' => $tagNo,
'status' => 'active',
'bind_status' => 'free',
'current_stage' => 'idle',
'current_location' => 'warehouse',
'created_by' => $operator['id'],
'created_by_name' => $operator['name'],
'created_at' => $now,
'updated_at' => $now,
]);
return Db::name('internal_transfer_tags')->where('id', $id)->find();
}
private function resolveInboundOrder(string $inboundNo): array
{
$inboundNo = trim($inboundNo);
if ($inboundNo === '') {
throw new \InvalidArgumentException('请先扫描快递单号或输入鉴定订单号');
}
$logisticsRows = Db::name('order_logistics')
->where('logistics_type', 'send_to_center')
->where('tracking_no', $inboundNo)
->select()
->toArray();
if ($logisticsRows) {
if (count($logisticsRows) > 1) {
throw new \RuntimeException('该快递单号匹配到多笔订单,请人工核查后处理', 409);
}
return [
'order_id' => (int)$logisticsRows[0]['order_id'],
'match_type' => 'tracking_no',
'match_no' => $inboundNo,
];
}
$orderRows = Db::name('orders')
->where(function ($builder) use ($inboundNo) {
$builder->whereRaw(
'(order_no = :order_no OR appraisal_no = :appraisal_no)',
[
'order_no' => $inboundNo,
'appraisal_no' => $inboundNo,
]
);
})
->field(['id', 'order_no', 'appraisal_no'])
->select()
->toArray();
if ($orderRows) {
if (count($orderRows) > 1) {
throw new \RuntimeException('该订单号匹配到多笔订单,请人工核查后处理', 409);
}
$order = $orderRows[0];
return [
'order_id' => (int)$order['id'],
'match_type' => $inboundNo === (string)($order['appraisal_no'] ?? '') ? 'appraisal_no' : 'order_no',
'match_no' => $inboundNo,
];
}
$externalRows = Db::name('enterprise_customer_order_refs')
->where('external_order_no', $inboundNo)
->field(['order_id'])
->select()
->toArray();
if ($externalRows) {
if (count($externalRows) > 1) {
throw new \RuntimeException('该外部订单号匹配到多笔订单,请人工核查后处理', 409);
}
return [
'order_id' => (int)$externalRows[0]['order_id'],
'match_type' => 'external_order_no',
'match_no' => $inboundNo,
];
}
throw new \RuntimeException('未匹配到待入库订单', 404);
}
private function inboundMatchRemark(array $match): string
{
$label = match ((string)($match['match_type'] ?? '')) {
'tracking_no' => '快递单号',
'appraisal_no' => '鉴定单号',
'order_no' => '订单号',
'external_order_no' => '外部订单号',
default => '入库编号',
};
return sprintf('扫描%s入库%s', $label, (string)($match['match_no'] ?? ''));
}
private function findActiveFlowByTagNo(string $tagNo): ?array
{
$tagNo = $this->normalizeTagNo($tagNo);
if ($tagNo === '') {
throw new \InvalidArgumentException('请扫描内部流转挂牌编号');
}
return Db::name('order_transfer_flows')
->where('internal_tag_no', $tagNo)
->where('flow_status', '<>', 'ended')
->order('id', 'desc')
->find() ?: null;
}
private function findActiveFlowByTagId(int $tagId): ?array
{
return Db::name('order_transfer_flows')
->where('internal_tag_id', $tagId)
->where('flow_status', '<>', 'ended')
->order('id', 'desc')
->find() ?: null;
}
private function latestReport(int $orderId): ?array
{
return Db::name('reports')
->where('order_id', $orderId)
->where('report_type', 'appraisal')
->order('id', 'desc')
->find() ?: null;
}
private function returnAddressForOrder(array $order): ?array
{
$orderId = (int)($order['id'] ?? 0);
$address = Db::name('order_return_addresses')->where('order_id', $orderId)->find();
if ($address) {
return $address;
}
return Db::name('user_addresses')
->where('user_id', (int)($order['user_id'] ?? 0))
->where('is_default', 1)
->order('id', 'desc')
->find()
?: Db::name('user_addresses')
->where('user_id', (int)($order['user_id'] ?? 0))
->order('id', 'desc')
->find()
?: null;
}
private function ensureReturnAddressSnapshot(int $orderId, array $address, string $now): void
{
if (Db::name('order_return_addresses')->where('order_id', $orderId)->find()) {
return;
}
Db::name('order_return_addresses')->insert([
'order_id' => $orderId,
'user_address_id' => $address['user_address_id'] ?? ($address['id'] ?? null),
'consignee' => $address['consignee'] ?? '',
'mobile' => $address['mobile'] ?? '',
'province' => $address['province'] ?? '',
'city' => $address['city'] ?? '',
'district' => $address['district'] ?? '',
'detail_address' => $address['detail_address'] ?? '',
'created_at' => $now,
'updated_at' => $now,
]);
}
private function operator(Request $request): array
{
$id = (int)$request->header('x-admin-id', 0);
$name = trim((string)$request->header('x-admin-name', ''));
if ($id <= 0 || $name === '') {
throw new \RuntimeException('当前登录管理员信息异常', 401);
}
return ['id' => $id, 'name' => $name];
}
private function normalizeTagNo(string $tagNo): string
{
return mb_substr(trim($tagNo), 0, 80);
}
private function formatFlow(array $flow): array
{
return [
'id' => (int)$flow['id'],
'internal_tag_no' => (string)$flow['internal_tag_no'],
'flow_status' => (string)$flow['flow_status'],
'current_stage' => (string)$flow['current_stage'],
'current_stage_text' => $this->stageText((string)$flow['current_stage']),
'current_location' => (string)$flow['current_location'],
'current_location_text' => $this->locationText((string)$flow['current_location']),
'inbound_by_name' => (string)($flow['inbound_by_name'] ?? ''),
'inbound_at' => (string)($flow['inbound_at'] ?? ''),
'zhongjian_outbound_by_name' => (string)($flow['zhongjian_outbound_by_name'] ?? ''),
'zhongjian_outbound_at' => (string)($flow['zhongjian_outbound_at'] ?? ''),
'zhongjian_inbound_by_name' => (string)($flow['zhongjian_inbound_by_name'] ?? ''),
'zhongjian_inbound_at' => (string)($flow['zhongjian_inbound_at'] ?? ''),
'appraisal_started_by_name' => (string)($flow['appraisal_started_by_name'] ?? ''),
'appraisal_started_at' => (string)($flow['appraisal_started_at'] ?? ''),
'report_published_by_name' => (string)($flow['report_published_by_name'] ?? ''),
'report_published_at' => (string)($flow['report_published_at'] ?? ''),
'return_confirmed_by_name' => (string)($flow['return_confirmed_by_name'] ?? ''),
'return_confirmed_at' => (string)($flow['return_confirmed_at'] ?? ''),
'return_shipped_by_name' => (string)($flow['return_shipped_by_name'] ?? ''),
'return_shipped_at' => (string)($flow['return_shipped_at'] ?? ''),
];
}
private function stageText(string $stage): string
{
return match ($stage) {
'warehouse_received' => '已入仓待检',
'sent_to_zhongjian' => '中检送检出库',
'zhongjian_returned' => '中检送检入库',
'appraising' => '鉴定/报告录入中',
'report_published' => '报告已发布待寄回',
'return_confirmed' => '寄回确认完成',
'return_shipped' => '已寄回',
default => $stage,
};
}
private function locationText(string $location): string
{
return match ($location) {
'warehouse_pending_inspection' => '仓库待检区',
'zhongjian_institution' => '中检机构',
'warehouse_pending_report_entry' => '仓库中检回收区',
'appraiser_workbench' => '鉴定师作业区',
'zhongjian_report_entry' => '中检报告录入区',
'warehouse_return_pending' => '仓库待寄回区',
'ended' => '流转结束',
default => $location,
};
}
private function serviceProviderText(string $serviceProvider): string
{
return $serviceProvider === 'zhongjian' ? '中检鉴定' : '实物鉴定';
}
private function sourceChannelText(string $sourceChannel): string
{
return match ($sourceChannel) {
'mini_program' => '小程序',
'h5' => 'H5',
'enterprise_push' => '大客户推送订单',
'manual_entry' => '后台补录订单',
default => $sourceChannel ?: '未知渠道',
};
}
private function trackingStatusText(string $status, string $logisticsType): string
{
if ($logisticsType === 'return_to_user') {
return match ($status) {
'submitted' => '已登记回寄运单',
'in_transit' => '回寄途中',
'received' => '用户已签收',
default => $status === '' ? '待回寄' : $status,
};
}
return match ($status) {
'submitted' => '用户已提交运单',
'in_transit' => '用户已寄出,运输中',
'received' => '鉴定中心已签收',
default => $status === '' ? '待提交' : $status,
};
}
private function decodeJsonArray(mixed $value): array
{
if (is_array($value)) {
return array_values($value);
}
if (is_string($value) && $value !== '') {
$decoded = json_decode($value, true);
return is_array($decoded) ? array_values($decoded) : [];
}
return [];
}
private function decodeJsonObject(mixed $value): array
{
if (is_array($value)) {
return $value;
}
if (is_string($value) && $value !== '') {
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : [];
}
return [];
}
private function normalizeAssetList(array $files, ?Request $request): array
{
if (!$request) {
return $files;
}
return (new AppraisalEvidenceService())->normalize($files, $request);
}
}