input('keyword', '')); $taskStage = trim((string)$request->input('task_stage', '')); $status = trim((string)$request->input('status', '')); $serviceProvider = trim((string)$request->input('service_provider', '')); $scope = trim((string)$request->input('scope', '')); $paginationEnabled = $request->input('page', null) !== null || $request->input('page_size', null) !== null; $page = max(1, (int)$request->input('page', 1)); $pageSize = max(1, min(100, (int)$request->input('page_size', 20))); $query = $this->buildTaskBaseQuery() ->whereRaw($this->workbenchVisibleOrderStatusSql()); $this->applyTaskScopeFilter($query, $request, $scope); if ($keyword !== '') { $query->where(function ($builder) use ($keyword) { $builder->whereRaw( '(o.order_no LIKE :keyword_order OR o.appraisal_no LIKE :keyword_appraisal OR ecr.external_order_no LIKE :keyword_external OR p.product_name LIKE :keyword_product)', [ 'keyword_order' => "%{$keyword}%", 'keyword_appraisal' => "%{$keyword}%", 'keyword_external' => "%{$keyword}%", 'keyword_product' => "%{$keyword}%", ] ); }); } if ($taskStage !== '') { $query->where('t.task_stage', $taskStage); } if ($status !== '') { $query->where('t.status', $status); } if ($serviceProvider !== '') { $query->where('o.service_provider', $serviceProvider); } $matchedRows = $query->select()->toArray(); if (!$matchedRows) { return api_success($paginationEnabled ? [ 'list' => [], 'total' => 0, 'page' => $page, 'page_size' => $pageSize, ] : ['list' => []]); } $orderIds = array_values(array_unique(array_map(fn (array $item) => (int)$item['order_id'], $matchedRows))); $reportMap = $this->buildAppraisalReportMap($orderIds); $allRows = $this->buildTaskBaseQuery() ->whereRaw($this->workbenchVisibleOrderStatusSql()) ->whereIn('t.order_id', $orderIds) ->group('t.id') ->order('t.order_id', 'desc') ->order('t.id', 'desc') ->select() ->toArray(); $this->applyTaskScopeFilterRows($allRows, $request, $scope); $this->attachTransferFlowToRows($allRows); $list = $this->buildGroupedTaskList($allRows, $reportMap); $total = count($list); if ($paginationEnabled) { $offset = ($page - 1) * $pageSize; $list = array_slice($list, $offset, $pageSize); return api_success([ 'list' => $list, 'total' => $total, 'page' => $page, 'page_size' => $pageSize, ]); } return api_success(['list' => $list]); } public function detail(Request $request) { $id = (int)$request->input('id', 0); if (!$id) { return api_error('任务 ID 不能为空', 422); } $task = Db::name('appraisal_tasks') ->alias('t') ->leftJoin('orders o', 'o.id = t.order_id') ->leftJoin('order_products p', 'p.order_id = t.order_id') ->leftJoin('order_extras e', 'e.order_id = t.order_id') ->leftJoin('appraisal_task_results r', 'r.task_id = t.id') ->leftJoin('reports rp', 'rp.order_id = t.order_id AND rp.report_type = "appraisal"') ->leftJoin('report_contents rc', 'rc.report_id = rp.id') ->leftJoin('enterprise_customer_order_refs ecr', 'ecr.order_id = t.order_id') ->order('rp.id', 'desc') ->field([ 't.id', 't.order_id', 't.task_stage', 't.status', 't.assignee_id', 't.assignee_name', 't.started_at', 't.submitted_at', 't.sla_deadline', 't.is_overtime', 'o.order_no', 'o.appraisal_no', 'ecr.external_order_no', 'o.service_provider', 'o.order_status', 'o.display_status', 'p.product_name', 'p.category_id', 'p.category_name', 'p.brand_id', 'p.brand_name', 'p.color', 'p.size_spec', 'p.serial_no', 'e.purchase_channel', 'e.purchase_price', 'e.usage_status', 'e.condition_desc as extra_condition_desc', 'e.remark', 'r.id as result_id', 'r.result_text', 'r.result_desc', 'r.condition_grade', 'r.condition_desc as result_condition_desc', 'r.valuation_min', 'r.valuation_max', 'r.valuation_desc as result_valuation_desc', 'r.attachments_json as result_attachments_json', 'r.external_remark', 'r.internal_remark', 'rp.zhongjian_report_no', 'rp.report_entry_admin_id', 'rp.report_entry_admin_name', 'rp.report_entered_at', 'rc.zhongjian_report_files_json', ]) ->where('t.id', $id) ->find(); if (!$task) { return api_error('任务不存在', 404); } $report = $this->findLatestAppraisalReport((int)$task['order_id']); $materialTag = $report ? (new MaterialTagService())->findBoundTagForReport((int)$report['id']) : null; $transferFlow = $this->latestTransferFlowForOrder((int)$task['order_id']); $effectiveStatus = $this->effectiveTaskStatus($task, $report); if ($effectiveStatus !== $task['status']) { Db::name('appraisal_tasks')->where('id', (int)$task['id'])->update([ 'status' => $effectiveStatus, 'updated_at' => date('Y-m-d H:i:s'), ]); $task['status'] = $effectiveStatus; } $timeline = Db::name('order_timelines') ->where('order_id', $task['order_id']) ->order('occurred_at', 'asc') ->select() ->toArray(); $materials = Db::name('order_upload_items') ->where('order_id', $task['order_id']) ->select() ->toArray(); $materials = array_values(array_filter(array_map(function (array $item) use ($request) { $files = Db::name('order_upload_files') ->where('order_upload_item_id', $item['id']) ->order('id', 'asc') ->select() ->toArray(); if (!$files) { return null; } return [ 'item_name' => $item['item_name'], 'status' => $item['status'], 'source_type' => $item['source_type'], 'files' => array_map(fn (array $file) => [ 'file_id' => $file['file_id'], 'file_url' => $this->assetUrlService()->normalizeUrl((string)$file['file_url'], $request), 'thumbnail_url' => $this->assetUrlService()->normalizeUrl((string)$file['thumbnail_url'], $request), ], $files), ]; }, $materials))); $supplementTask = Db::name('order_supplement_tasks') ->where('order_id', $task['order_id']) ->where('status', 'pending') ->order('id', 'desc') ->find(); $supplementItems = []; if ($supplementTask) { $supplementItems = Db::name('order_supplement_task_items') ->where('task_id', $supplementTask['id']) ->order('id', 'asc') ->select() ->toArray(); } $stageReportMap = $this->buildAppraisalReportMap([(int)$task['order_id']]); $stageTaskRows = $this->buildTaskBaseQuery() ->where('t.order_id', (int)$task['order_id']) ->group('t.id') ->order('t.id', 'asc') ->select() ->toArray(); $this->attachTransferFlowToRows($stageTaskRows); $stageTasks = array_map(function (array $item) use ($id, $stageReportMap) { $row = $this->normalizeTaskListRow($item, $stageReportMap[(int)$item['order_id']] ?? null); $row['is_current'] = (int)$row['id'] === $id; return $row; }, $stageTaskRows); usort($stageTasks, fn (array $a, array $b) => $this->stagePriority($a['task_stage']) <=> $this->stagePriority($b['task_stage'])); $currentResultInfo = $this->formatResultInfo($task, $request); $prefillResultInfo = null; if ($task['task_stage'] === 'final_review') { $prefillTask = Db::name('appraisal_tasks') ->alias('t') ->leftJoin('appraisal_task_results r', 'r.task_id = t.id') ->field([ 't.id', 't.task_stage', 'r.id as result_id', 'r.result_text', 'r.result_desc', 'r.condition_grade', 'r.condition_desc', 'r.valuation_min', 'r.valuation_max', 'r.valuation_desc', 'r.attachments_json', 'r.external_remark', 'r.internal_remark', ]) ->where('t.order_id', (int)$task['order_id']) ->where('t.task_stage', 'first_review') ->order('t.id', 'desc') ->find(); if ($prefillTask) { $prefillPayload = $this->formatResultInfo($prefillTask, $request); if ($this->hasResultData($prefillPayload)) { $prefillResultInfo = array_merge($prefillPayload, [ 'source_task_id' => (int)$prefillTask['id'], 'source_stage' => 'first_review', 'source_stage_text' => '鉴定', ]); } } } $appraisalTemplate = $this->resolveAppraisalTemplate( (int)($task['category_id'] ?? 0), (string)($task['service_provider'] ?? 'anxinyan'), $currentResultInfo['key_points'] ?? [] ); return api_success([ 'task_info' => [ 'id' => (int)$task['id'], 'order_id' => (int)$task['order_id'], 'order_no' => $task['order_no'], 'appraisal_no' => $task['appraisal_no'], 'external_order_no' => $task['external_order_no'] ?: '', 'service_provider' => $task['service_provider'], 'service_provider_text' => $task['service_provider'] === 'zhongjian' ? '中检鉴定' : '实物鉴定', 'task_stage' => $task['task_stage'], 'task_stage_text' => '鉴定', 'status' => $task['status'], 'status_text' => $this->taskStatusText($task['status']), 'assignee_id' => (int)($task['assignee_id'] ?? 0), 'assignee_name' => $task['assignee_name'] ?: '未分配', 'started_at' => $task['started_at'], 'submitted_at' => $task['submitted_at'], 'sla_deadline' => $task['sla_deadline'], 'is_overtime' => (bool)$task['is_overtime'], 'internal_tag_no' => (string)($transferFlow['internal_tag_no'] ?? ''), ], 'report_summary' => $report ? [ 'id' => (int)$report['id'], 'report_no' => $report['report_no'], 'report_status' => $report['report_status'], 'report_status_text' => $this->reportStatusText($report['report_status']), ] : null, 'material_tag' => $materialTag, 'zhongjian_report' => [ 'report_no' => (string)($task['zhongjian_report_no'] ?? ''), 'report_entry_admin_id' => (int)($task['report_entry_admin_id'] ?? 0), 'report_entry_admin_name' => (string)($task['report_entry_admin_name'] ?? ''), 'report_entered_at' => (string)($task['report_entered_at'] ?? ''), 'files' => $this->evidenceService()->normalize($task['zhongjian_report_files_json'] ?? null, $request), ], 'product_info' => [ 'product_name' => $task['product_name'] ?: '', 'category_id' => (int)($task['category_id'] ?? 0), 'category_name' => $task['category_name'] ?: '', 'brand_id' => (int)($task['brand_id'] ?? 0), 'brand_name' => $task['brand_name'] ?: '', 'color' => $task['color'] ?: '', 'size_spec' => $task['size_spec'] ?: '', 'serial_no' => $task['serial_no'] ?: '', ], 'extra_info' => [ 'purchase_channel' => $task['purchase_channel'], 'purchase_price' => (float)$task['purchase_price'], 'usage_status' => $task['usage_status'], 'condition_desc' => $task['extra_condition_desc'], 'remark' => $task['remark'], ], 'result_info' => $currentResultInfo, 'prefill_result_info' => $prefillResultInfo, 'appraisal_template' => $appraisalTemplate, 'timeline' => array_map(fn (array $item) => [ 'node_text' => $item['node_text'], 'node_desc' => $item['node_desc'], 'occurred_at' => $item['occurred_at'], ], $timeline), 'stage_tasks' => $stageTasks, 'materials' => $materials, 'supplement_task' => $supplementTask ? [ 'id' => (int)$supplementTask['id'], 'reason' => $supplementTask['reason'], 'deadline' => $supplementTask['deadline'], 'status' => $supplementTask['status'], 'items' => array_map(fn (array $item) => [ 'item_name' => $item['item_name'], 'guide_text' => $item['guide_text'], 'is_required' => (bool)$item['is_required'], ], $supplementItems), ] : null, ]); } public function bindMaterialTag(Request $request) { $id = (int)$request->input('id', 0); $qrInput = trim((string)$request->input('qr_input', '')); if ($id <= 0 || $qrInput === '') { return api_error('任务 ID 和吊牌二维码不能为空', 422); } $task = Db::name('appraisal_tasks')->where('id', $id)->find(); if (!$task) { return api_error('任务不存在', 404); } $operatorGuard = $this->guardTaskOperator($request, $task); if ($operatorGuard['error']) { return $operatorGuard['error']; } try { $tag = (new MaterialTagService())->bindTagToReportByTask($id, $qrInput, $request); } catch (\InvalidArgumentException $e) { return api_error($e->getMessage(), 422); } catch (\RuntimeException $e) { return api_error($e->getMessage(), $e->getCode() ?: 404); } catch (\Throwable $e) { return api_error('吊牌绑定失败', 500, ['detail' => $e->getMessage()]); } return api_success([ 'id' => $id, 'material_tag' => $tag, ], '吊牌已绑定'); } public function scanTransferTag(Request $request) { try { return api_success((new FulfillmentFlowService())->scanTransferForAppraisal( (string)$request->input('internal_tag_no', ''), $request )); } catch (\InvalidArgumentException $e) { return api_error($e->getMessage(), 422); } catch (\RuntimeException $e) { return api_error($e->getMessage(), $e->getCode() ?: 404); } catch (\Throwable $e) { return api_error('内部流转码识别失败', 500, ['detail' => $e->getMessage()]); } } public function publishWithMaterialTag(Request $request) { $id = (int)$request->input('id', 0); $qrInput = trim((string)$request->input('qr_input', '')); if ($id <= 0 || $qrInput === '') { return api_error('任务 ID 和验真吊牌不能为空', 422); } $task = Db::name('appraisal_tasks')->where('id', $id)->find(); if (!$task) { return api_error('任务不存在', 404); } Db::startTrans(); try { $tag = (new MaterialTagService())->bindTagToReportByTask($id, $qrInput, $request); $report = $this->findLatestAppraisalReport((int)$task['order_id']); if (!$report) { Db::rollback(); return api_error('请先提交鉴定结论生成报告草稿', 422); } $publish = $this->publishReportRecord($report, $request, false); (new FulfillmentFlowService())->markReportPublished((int)$task['order_id'], $request); Db::commit(); } catch (\InvalidArgumentException $e) { Db::rollback(); return api_error($e->getMessage(), 422); } catch (\RuntimeException $e) { Db::rollback(); return api_error($e->getMessage(), $e->getCode() ?: 404); } catch (\Throwable $e) { Db::rollback(); return api_error('验真吊牌绑定或报告发布失败', 500, ['detail' => $e->getMessage()]); } return api_success([ 'id' => $id, 'material_tag' => $tag, 'report' => $publish, ], '验真吊牌已绑定,报告已发布'); } public function saveZhongjianReport(Request $request) { $id = (int)$request->input('id', 0); $reportNo = trim((string)$request->input('zhongjian_report_no', '')); $qrInput = trim((string)$request->input('qr_input', '')); $files = $this->evidenceService()->normalize($request->input('report_files', []), $request, true); $productInput = $request->input('product_info', null); $productPayload = is_array($productInput) ? $this->normalizeProductInput($productInput) : null; $attachments = $this->evidenceService()->normalize($request->input('attachments', []), $request, true); $keyPoints = $this->normalizeKeyPointInput($request->input('key_points', [])); $resultText = trim((string)$request->input('result_text', '')); $resultDesc = trim((string)$request->input('result_desc', '')); if ($id <= 0) { return api_error('任务 ID 不能为空', 422); } if ($reportNo === '') { return api_error('中检报告编号不能为空', 422); } if ($resultText === '') { return api_error('鉴定结论不能为空', 422); } if (!$files) { return api_error('请至少上传 1 个中检报告文件', 422); } if ($qrInput === '') { return api_error('请扫描验真吊牌二维码', 422); } $task = Db::name('appraisal_tasks')->where('id', $id)->find(); if (!$task) { return api_error('任务不存在', 404); } if (($task['service_provider'] ?? '') !== 'zhongjian') { return api_error('非中检订单不能录入中检报告', 422); } $order = Db::name('orders')->where('id', (int)$task['order_id'])->find() ?: []; $task['order_status'] = $order['order_status'] ?? ''; $report = $this->findLatestAppraisalReport((int)$task['order_id']); $effectiveStatus = $this->effectiveTaskStatus($task, $report); if ($effectiveStatus !== $task['status']) { Db::name('appraisal_tasks')->where('id', $id)->update([ 'status' => $effectiveStatus, 'updated_at' => date('Y-m-d H:i:s'), ]); $task['status'] = $effectiveStatus; } if (in_array($effectiveStatus, ['submitted', 'completed'], true)) { return api_error('当前任务已流转完成,不能再录入中检报告', 422); } $operatorGuard = $this->guardTaskOperator($request, $task); if ($operatorGuard['error']) { return $operatorGuard['error']; } $operatorId = (int)$request->header('x-admin-id', 0); $operatorName = trim((string)$request->header('x-admin-name', '')); $now = date('Y-m-d H:i:s'); Db::startTrans(); try { if ($operatorGuard['task_update']) { Db::name('appraisal_tasks')->where('id', $id)->update(array_merge($operatorGuard['task_update'], [ 'updated_at' => $now, ])); $task = array_merge($task, $operatorGuard['task_update']); } if ($productPayload !== null) { $this->saveOrderProductSnapshot((int)$task['order_id'], $productPayload, $now); } if (!$this->hasSubmittableProductInfo((int)$task['order_id'], $productPayload)) { Db::rollback(); return api_error('提交中检报告前请先完善物品信息', 422); } Db::name('appraisal_tasks')->where('id', $id)->update([ 'status' => 'completed', 'started_at' => $task['started_at'] ?: $now, 'submitted_at' => $now, 'updated_at' => $now, ]); Db::name('orders')->where('id', (int)$task['order_id'])->update([ 'order_status' => 'generating_report', 'display_status' => '正在生成报告', 'updated_at' => $now, ]); $resultPayload = [ 'task_id' => $id, 'order_id' => (int)$task['order_id'], 'result_status' => 'zhongjian_report', 'result_text' => $resultText, 'result_desc' => $resultDesc, 'condition_grade' => '', 'condition_desc' => '', 'valuation_min' => 0, 'valuation_max' => 0, 'valuation_desc' => '', 'attachments_json' => $attachments ? json_encode($attachments, JSON_UNESCAPED_UNICODE) : null, 'external_remark' => '', 'internal_remark' => '中检报告编号:' . $reportNo, 'updated_at' => $now, ]; $resultId = Db::name('appraisal_task_results')->where('task_id', $id)->value('id'); if ($resultId) { Db::name('appraisal_task_results')->where('id', (int)$resultId)->update($resultPayload); $savedResultId = (int)$resultId; } else { $resultPayload['created_at'] = $now; $savedResultId = (int)Db::name('appraisal_task_results')->insertGetId($resultPayload); } $this->saveTaskKeyPoints($savedResultId, $keyPoints, $now); $this->createOrUpdateReportDraft((int)$task['order_id'], $task, $resultPayload, $now); $report = $this->findLatestAppraisalReport((int)$task['order_id']); if (!$report) { Db::rollback(); return api_error('中检报告草稿生成失败', 500); } Db::name('reports')->where('id', (int)$report['id'])->update([ 'zhongjian_report_no' => $reportNo, 'report_entry_admin_id' => $operatorId, 'report_entry_admin_name' => $operatorName, 'report_entered_at' => $now, 'updated_at' => $now, ]); $content = Db::name('report_contents')->where('report_id', (int)$report['id'])->find(); if ($content) { Db::name('report_contents')->where('id', (int)$content['id'])->update([ 'zhongjian_report_files_json' => json_encode($files, JSON_UNESCAPED_UNICODE), 'updated_at' => $now, ]); } Db::name('order_timelines')->insert([ 'order_id' => (int)$task['order_id'], 'node_code' => 'zhongjian_report_entered', 'node_text' => '中检报告已录入', 'node_desc' => '报告录入人已补全报告信息、录入中检报告编号并上传报告文件。', 'operator_type' => 'admin', 'operator_id' => $operatorId, 'occurred_at' => $now, 'created_at' => $now, ]); $freshReport = $this->findLatestAppraisalReport((int)$task['order_id']); if (!$freshReport) { Db::rollback(); return api_error('中检报告草稿生成失败', 500); } $tag = (new MaterialTagService())->bindTagToReportByTask($id, $qrInput, $request); $publish = $this->publishReportRecord($freshReport, $request, false); (new FulfillmentFlowService())->markReportPublished((int)$task['order_id'], $request); Db::commit(); return api_success([ 'id' => $id, 'material_tag' => $tag, 'report' => $publish, ], '验真吊牌已绑定,报告已发布'); } catch (\Throwable $e) { Db::rollback(); return api_error('中检报告录入失败', 500, ['detail' => $e->getMessage()]); } } public function assignableAdmins(Request $request) { $id = (int)$request->input('id', 0); if (!$id) { return api_error('任务 ID 不能为空', 422); } $task = Db::name('appraisal_tasks')->where('id', $id)->find(); if (!$task) { return api_error('任务不存在', 404); } return api_success([ 'list' => $this->findAssignableAdmins((string)$task['task_stage']), ]); } public function assign(Request $request) { $id = (int)$request->input('id', 0); $assigneeId = (int)$request->input('assignee_id', 0); if (!$id || !$assigneeId) { return api_error('任务 ID 和处理人不能为空', 422); } $task = Db::name('appraisal_tasks')->where('id', $id)->find(); if (!$task) { return api_error('任务不存在', 404); } if (in_array((string)($task['status'] ?? ''), ['submitted', 'completed'], true)) { return api_error('当前任务已流转完成,不能再修改处理人', 422); } $candidate = $this->findAssignableAdminById($assigneeId, (string)$task['task_stage']); if (!$candidate) { return api_error('所选管理员角色不适用于当前任务阶段', 422); } Db::name('appraisal_tasks')->where('id', $id)->update([ 'assignee_id' => (int)$candidate['id'], 'assignee_name' => (string)$candidate['name'], 'updated_at' => date('Y-m-d H:i:s'), ]); return api_success([ 'id' => $id, 'assignee_id' => (int)$candidate['id'], 'assignee_name' => (string)$candidate['name'], ], '处理人已分配'); } public function saveResult(Request $request) { $id = (int)$request->input('id', 0); $action = trim((string)$request->input('action', 'save')); $qrInput = trim((string)$request->input('qr_input', '')); if (!$id) { return api_error('任务 ID 不能为空', 422); } $task = Db::name('appraisal_tasks')->where('id', $id)->find(); if (!$task) { return api_error('任务不存在', 404); } $order = Db::name('orders')->where('id', (int)$task['order_id'])->find() ?: []; $task['order_status'] = $order['order_status'] ?? ''; $report = $this->findLatestAppraisalReport((int)$task['order_id']); $effectiveStatus = $this->effectiveTaskStatus($task, $report); if ($effectiveStatus !== $task['status']) { Db::name('appraisal_tasks')->where('id', $id)->update([ 'status' => $effectiveStatus, 'updated_at' => date('Y-m-d H:i:s'), ]); $task['status'] = $effectiveStatus; } if (in_array($effectiveStatus, ['submitted', 'completed'], true)) { return api_error('当前任务已流转完成,不能再编辑或提交', 422); } $operatorGuard = $this->guardTaskOperator($request, $task); if ($operatorGuard['error']) { return $operatorGuard['error']; } $resultText = trim((string)$request->input('result_text', '')); if ($action !== 'save' && $resultText === '') { return api_error('鉴定结论不能为空', 422); } if ($action !== 'save' && $qrInput === '') { return api_error('请扫描验真吊牌二维码', 422); } $productInput = $request->input('product_info', null); $productPayload = is_array($productInput) ? $this->normalizeProductInput($productInput) : null; $attachments = $this->evidenceService()->normalize($request->input('attachments', []), $request, true); $keyPoints = $this->normalizeKeyPointInput($request->input('key_points', [])); $payload = [ 'task_id' => $id, 'order_id' => (int)$task['order_id'], 'result_status' => $this->mapResultStatus($resultText), 'result_text' => $resultText, 'result_desc' => trim((string)$request->input('result_desc', '')), 'condition_grade' => trim((string)$request->input('condition_grade', '')), 'condition_desc' => trim((string)$request->input('condition_desc', '')), 'valuation_min' => (float)$request->input('valuation_min', 0), 'valuation_max' => (float)$request->input('valuation_max', 0), 'valuation_desc' => trim((string)$request->input('valuation_desc', '')), 'attachments_json' => $attachments ? json_encode($attachments, JSON_UNESCAPED_UNICODE) : null, 'external_remark' => trim((string)$request->input('external_remark', '')), 'internal_remark' => trim((string)$request->input('internal_remark', '')), 'updated_at' => date('Y-m-d H:i:s'), ]; if ($action !== 'save' && ($task['order_status'] ?? '') === 'pending_shipping') { return api_error('订单尚未确认到仓,不能提交鉴定结论', 422); } $now = date('Y-m-d H:i:s'); Db::startTrans(); try { if ($operatorGuard['task_update']) { Db::name('appraisal_tasks')->where('id', $id)->update(array_merge($operatorGuard['task_update'], [ 'updated_at' => $now, ])); $task = array_merge($task, $operatorGuard['task_update']); } if ($productPayload !== null) { $this->saveOrderProductSnapshot((int)$task['order_id'], $productPayload, $now); } $resultId = Db::name('appraisal_task_results')->where('task_id', $id)->value('id'); if ($resultId) { Db::name('appraisal_task_results')->where('id', $resultId)->update($payload); $savedResultId = (int)$resultId; } else { $payload['created_at'] = $now; $savedResultId = (int)Db::name('appraisal_task_results')->insertGetId($payload); } $this->saveTaskKeyPoints($savedResultId, $keyPoints, $now); if ($action === 'save') { $taskUpdate = [ 'status' => 'processing', 'updated_at' => $now, ]; if (!$task['started_at']) { $taskUpdate['started_at'] = $now; } Db::name('appraisal_tasks')->where('id', $id)->update($taskUpdate); Db::commit(); return api_success(['id' => $id], '结论已保存'); } if ($resultText === '') { Db::rollback(); return api_error('提交结论前请选择鉴定结论', 422); } if (!$this->hasSubmittableProductInfo((int)$task['order_id'], $productPayload)) { Db::rollback(); return api_error('提交结论前请先完善物品信息', 422); } Db::name('appraisal_tasks')->where('id', $id)->update([ 'status' => 'completed', 'started_at' => $task['started_at'] ?: $now, 'submitted_at' => $now, 'updated_at' => $now, ]); Db::name('orders')->where('id', $task['order_id'])->update([ 'order_status' => 'generating_report', 'display_status' => '正在生成报告', 'updated_at' => $now, ]); Db::name('order_timelines')->insert([ 'order_id' => $task['order_id'], 'node_code' => 'generating_report', 'node_text' => '正在生成报告', 'node_desc' => '鉴定已完成,系统正在生成正式报告草稿', 'operator_type' => 'admin', 'operator_id' => (int)$request->header('x-admin-id', 0), 'occurred_at' => $now, 'created_at' => $now, ]); $this->createOrUpdateReportDraft((int)$task['order_id'], $task, $payload, $now); $report = $this->findLatestAppraisalReport((int)$task['order_id']); if (!$report) { Db::rollback(); return api_error('报告草稿生成失败', 500); } $tag = (new MaterialTagService())->bindTagToReportByTask($id, $qrInput, $request); $publish = $this->publishReportRecord($report, $request, false); (new FulfillmentFlowService())->markReportPublished((int)$task['order_id'], $request); Db::commit(); (new EnterpriseWebhookService())->recordOrderEvent((int)$task['order_id'], 'appraisal_finished', [ 'task_id' => $id, 'task_stage' => $task['task_stage'], 'finished_at' => $now, ]); return api_success([ 'id' => $id, 'material_tag' => $tag, 'report' => $publish, ], '验真吊牌已绑定,报告已发布'); } catch (\Throwable $e) { Db::rollback(); return api_error('结论保存失败', 500, [ 'detail' => $e->getMessage(), ]); } } public function requestSupplement(Request $request) { $id = (int)$request->input('id', 0); $reason = trim((string)$request->input('reason', '')); $deadline = trim((string)$request->input('deadline', '')); $items = $request->input('items', []); if (!$id) { return api_error('任务 ID 不能为空', 422); } if ($reason === '') { return api_error('补资料原因不能为空', 422); } $task = Db::name('appraisal_tasks')->where('id', $id)->find(); if (!$task) { return api_error('任务不存在', 404); } $order = Db::name('orders')->where('id', (int)$task['order_id'])->find() ?: []; $task['order_status'] = $order['order_status'] ?? ''; $report = $this->findLatestAppraisalReport((int)$task['order_id']); $effectiveStatus = $this->effectiveTaskStatus($task, $report); if ($effectiveStatus !== $task['status']) { Db::name('appraisal_tasks')->where('id', $id)->update([ 'status' => $effectiveStatus, 'updated_at' => date('Y-m-d H:i:s'), ]); $task['status'] = $effectiveStatus; } if (in_array($effectiveStatus, ['submitted', 'completed'], true)) { return api_error('当前任务已流转完成,不能再发起补资料', 422); } $operatorGuard = $this->guardTaskOperator($request, $task); if ($operatorGuard['error']) { return $operatorGuard['error']; } if (!is_array($items) || !$items) { return api_error('请至少填写一项补资料要求', 422); } $normalizedItems = []; foreach ($items as $index => $item) { if (!is_array($item)) { continue; } $itemName = trim((string)($item['item_name'] ?? '')); $guideText = trim((string)($item['guide_text'] ?? '')); $isRequired = array_key_exists('is_required', $item) ? (bool)$item['is_required'] : true; if ($itemName === '') { continue; } $normalizedItems[] = [ 'item_code' => 'supplement_' . ($index + 1), 'item_name' => $itemName, 'guide_text' => $guideText, 'is_required' => $isRequired ? 1 : 0, ]; } if (!$normalizedItems) { return api_error('请至少填写一项有效的补资料要求', 422); } $now = date('Y-m-d H:i:s'); Db::startTrans(); try { if ($operatorGuard['task_update']) { Db::name('appraisal_tasks')->where('id', $id)->update(array_merge($operatorGuard['task_update'], [ 'updated_at' => $now, ])); $task = array_merge($task, $operatorGuard['task_update']); } Db::name('order_supplement_tasks') ->where('order_id', $task['order_id']) ->where('status', 'pending') ->update([ 'status' => 'closed', 'updated_at' => $now, ]); $supplementTaskId = (int)Db::name('order_supplement_tasks')->insertGetId([ 'order_id' => $task['order_id'], 'reason' => $reason, 'deadline' => $deadline !== '' ? $deadline : null, 'status' => 'pending', 'created_by' => (int)$request->header('x-admin-id', 0), 'submitted_at' => null, 'approved_at' => null, 'created_at' => $now, 'updated_at' => $now, ]); foreach ($normalizedItems as $item) { Db::name('order_supplement_task_items')->insert([ 'task_id' => $supplementTaskId, 'item_code' => $item['item_code'], 'item_name' => $item['item_name'], 'guide_text' => $item['guide_text'], 'sample_image_url' => '', 'is_required' => $item['is_required'], 'created_at' => $now, 'updated_at' => $now, ]); Db::name('order_upload_items')->insert([ 'order_id' => $task['order_id'], 'template_id' => null, 'item_code' => $item['item_code'], 'item_name' => $item['item_name'], 'is_required' => $item['is_required'], 'source_type' => 'supplement', 'status' => 'pending', 'created_at' => $now, 'updated_at' => $now, ]); } Db::name('appraisal_tasks')->where('id', $id)->update([ 'status' => 'returned', 'started_at' => $task['started_at'] ?: $now, 'updated_at' => $now, ]); Db::name('orders')->where('id', $task['order_id'])->update([ 'order_status' => 'pending_supplement', 'display_status' => '等待您补充资料', 'updated_at' => $now, ]); Db::name('order_timelines')->insert([ 'order_id' => $task['order_id'], 'node_code' => 'supplement', 'node_text' => '待补资料', 'node_desc' => $reason, 'operator_type' => 'admin', 'operator_id' => (int)$request->header('x-admin-id', 0), 'occurred_at' => $now, 'created_at' => $now, ]); $order = Db::name('orders')->where('id', $task['order_id'])->find(); (new MessageDispatcher())->sendInboxEvent('supplement_required', [ 'user_id' => (int)($order['user_id'] ?? 0), 'biz_type' => 'supplement', 'biz_id' => $supplementTaskId, 'reason' => $reason, 'deadline' => $deadline, 'fallback_title' => '请补充鉴定资料', 'fallback_content' => '鉴定师需要您补充资料后继续处理,请尽快进入订单详情查看。', ]); Db::commit(); (new EnterpriseWebhookService())->recordOrderEvent((int)$task['order_id'], 'supplement_required', [ 'task_id' => $id, 'supplement_task_id' => $supplementTaskId, 'reason' => $reason, 'deadline' => $deadline, 'items' => $normalizedItems, ]); return api_success([ 'id' => $id, 'supplement_task_id' => $supplementTaskId, ], '已发起补资料要求'); } catch (\Throwable $e) { Db::rollback(); return api_error('发起补资料失败', 500, [ 'detail' => $e->getMessage(), ]); } } public function uploadEvidenceFile(Request $request) { $taskId = (int)$request->input('task_id', 0); if ($taskId <= 0) { return api_error('任务 ID 不能为空', 422); } $editableGuard = $this->guardTaskEditable($taskId, '当前任务已流转完成,不能再上传附件'); if ($editableGuard) { return $editableGuard; } try { $asset = $this->evidenceService()->upload($request); return api_success($asset); } catch (\Throwable $e) { return api_error($e->getMessage(), 422); } } public function deleteEvidenceFile(Request $request) { $taskId = (int)$request->input('task_id', 0); if ($taskId <= 0) { return api_error('任务 ID 不能为空', 422); } $editableGuard = $this->guardTaskEditable($taskId, '当前任务已流转完成,不能再删除附件'); if ($editableGuard) { return $editableGuard; } $fileUrl = trim((string)$request->input('file_url', '')); if ($fileUrl === '') { return api_error('文件地址不能为空', 422); } $this->evidenceService()->delete($fileUrl); return api_success([ 'file_url' => $fileUrl, ], '删除成功'); } private function buildTaskBaseQuery() { return Db::name('appraisal_tasks') ->alias('t') ->leftJoin('orders o', 'o.id = t.order_id') ->leftJoin('order_products p', 'p.order_id = t.order_id') ->leftJoin('appraisal_task_results r', 'r.task_id = t.id') ->leftJoin('enterprise_customer_order_refs ecr', 'ecr.order_id = t.order_id') ->field([ 't.id', 't.order_id', 't.task_stage', 't.status', 't.assignee_id', 't.assignee_name', 't.started_at', 't.submitted_at', 't.sla_deadline', 't.is_overtime', 'o.order_no', 'o.appraisal_no', 'ecr.external_order_no', 'o.service_provider', 'o.order_status', 'o.display_status', 'p.product_name', 'p.category_id', 'p.category_name', 'p.brand_id', 'p.brand_name', 'r.result_text', ]); } private function applyTaskScopeFilter($query, Request $request, string $scope): void { if ($scope !== 'my') { return; } $adminId = (int)$request->header('x-admin-id', 0); if ($adminId <= 0) { return; } $query->whereRaw('(t.assignee_id = :scope_admin_id OR t.assignee_id IS NULL OR t.assignee_id = 0)', [ 'scope_admin_id' => $adminId, ]); } private function applyTaskScopeFilterRows(array &$rows, Request $request, string $scope): void { if ($scope !== 'my') { return; } $adminId = (int)$request->header('x-admin-id', 0); if ($adminId <= 0) { return; } $rows = array_values(array_filter($rows, function (array $row) use ($adminId) { $assigneeId = (int)($row['assignee_id'] ?? 0); return $assigneeId <= 0 || $assigneeId === $adminId; })); } private function normalizeTaskListRow(array $item, ?array $report = null): array { $effectiveStatus = $this->effectiveTaskStatus($item, $report); return [ 'id' => (int)$item['id'], 'order_id' => (int)$item['order_id'], 'order_no' => $item['order_no'], 'appraisal_no' => $item['appraisal_no'], 'external_order_no' => $item['external_order_no'] ?: '', 'service_provider' => $item['service_provider'], 'service_provider_text' => $item['service_provider'] === 'zhongjian' ? '中检鉴定' : '实物鉴定', 'task_stage' => $item['task_stage'], 'task_stage_text' => '鉴定', 'status' => $effectiveStatus, 'status_text' => $this->taskStatusText($effectiveStatus), 'assignee_id' => (int)($item['assignee_id'] ?? 0), 'product_name' => $item['product_name'] ?: '待完善物品信息', 'category_id' => (int)($item['category_id'] ?? 0), 'category_name' => $item['category_name'] ?: '', 'brand_id' => (int)($item['brand_id'] ?? 0), 'brand_name' => $item['brand_name'] ?: '', 'assignee_name' => $item['assignee_name'] ?: '未分配', 'result_text' => $item['result_text'] ?: '', 'started_at' => $item['started_at'], 'submitted_at' => $item['submitted_at'], 'sla_deadline' => $item['sla_deadline'], 'is_overtime' => (bool)$item['is_overtime'], 'display_status' => $item['display_status'], 'internal_tag_no' => (string)($item['internal_tag_no'] ?? ''), ]; } private function attachTransferFlowToRows(array &$rows): void { $orderIds = array_values(array_unique(array_filter(array_map(fn (array $item) => (int)($item['order_id'] ?? 0), $rows)))); if (!$orderIds) { return; } $flowMap = $this->latestTransferFlowMap($orderIds); foreach ($rows as &$row) { $orderId = (int)($row['order_id'] ?? 0); $row['internal_tag_no'] = (string)($flowMap[$orderId]['internal_tag_no'] ?? ''); } unset($row); } private function latestTransferFlowForOrder(int $orderId): ?array { if ($orderId <= 0) { return null; } return Db::name('order_transfer_flows') ->where('order_id', $orderId) ->order('id', 'desc') ->find() ?: null; } private function latestTransferFlowMap(array $orderIds): array { $orderIds = array_values(array_unique(array_filter(array_map('intval', $orderIds)))); if (!$orderIds) { return []; } $rows = Db::name('order_transfer_flows') ->whereIn('order_id', $orderIds) ->order('id', 'desc') ->select() ->toArray(); $map = []; foreach ($rows as $row) { $orderId = (int)($row['order_id'] ?? 0); if ($orderId > 0 && !isset($map[$orderId])) { $map[$orderId] = [ 'internal_tag_no' => (string)($row['internal_tag_no'] ?? ''), ]; } } return $map; } private function guardTaskEditable(int $taskId, string $message) { $task = Db::name('appraisal_tasks')->where('id', $taskId)->find(); if (!$task) { return api_error('任务不存在', 404); } $order = Db::name('orders')->where('id', (int)$task['order_id'])->find() ?: []; $task['order_status'] = $order['order_status'] ?? ''; $report = $this->findLatestAppraisalReport((int)$task['order_id']); $effectiveStatus = $this->effectiveTaskStatus($task, $report); if ($effectiveStatus !== (string)$task['status']) { Db::name('appraisal_tasks')->where('id', $taskId)->update([ 'status' => $effectiveStatus, 'updated_at' => date('Y-m-d H:i:s'), ]); } return in_array($effectiveStatus, ['submitted', 'completed'], true) ? api_error($message, 422) : null; } private function formatResultInfo(array $task, ?Request $request = null): array { $resultId = 0; if (!empty($task['result_id'])) { $resultId = (int)$task['result_id']; } elseif (!empty($task['id'])) { $resultId = (int)Db::name('appraisal_task_results')->where('task_id', (int)$task['id'])->value('id'); } return [ 'result_text' => $task['result_text'] ?: '', 'result_desc' => $task['result_desc'] ?: '', 'condition_grade' => $task['condition_grade'] ?: '', 'condition_desc' => ($task['result_condition_desc'] ?? $task['condition_desc'] ?? '') ?: '', 'valuation_min' => (float)($task['valuation_min'] ?? 0), 'valuation_max' => (float)($task['valuation_max'] ?? 0), 'valuation_desc' => ($task['result_valuation_desc'] ?? $task['valuation_desc'] ?? '') ?: '', 'attachments' => $this->evidenceService()->normalize($task['result_attachments_json'] ?? $task['attachments_json'] ?? null, $request), 'external_remark' => $task['external_remark'] ?: '', 'internal_remark' => $task['internal_remark'] ?: '', 'key_points' => $resultId > 0 ? $this->loadTaskKeyPoints($resultId) : [], ]; } private function hasResultData(array $resultInfo): bool { return $resultInfo['result_text'] !== '' || $resultInfo['result_desc'] !== '' || $resultInfo['condition_grade'] !== '' || $resultInfo['condition_desc'] !== '' || (float)$resultInfo['valuation_min'] > 0 || (float)$resultInfo['valuation_max'] > 0 || $resultInfo['valuation_desc'] !== '' || !empty($resultInfo['attachments']) || $this->hasKeyPointData($resultInfo['key_points'] ?? []) || $resultInfo['external_remark'] !== '' || $resultInfo['internal_remark'] !== ''; } private function hasKeyPointData(array $keyPoints): bool { foreach ($keyPoints as $point) { if (!is_array($point)) { continue; } if (trim((string)($point['point_value'] ?? '')) !== '' || trim((string)($point['point_remark'] ?? '')) !== '') { return true; } } return false; } private function buildGroupedTaskList(array $rows, array $reportMap = []): array { $grouped = []; foreach ($rows as $item) { $row = $this->normalizeTaskListRow($item, $reportMap[(int)$item['order_id']] ?? null); $grouped[$row['order_id']][] = $row; } $list = []; foreach ($grouped as $tasks) { usort($tasks, fn (array $a, array $b) => $this->stagePriority($a['task_stage']) <=> $this->stagePriority($b['task_stage'])); $currentTask = $this->selectCurrentTask($tasks); $latestResultText = $this->latestResultText($tasks); $latestSubmittedAt = $this->latestSubmittedAt($tasks); $list[] = array_merge($currentTask, [ 'result_text' => $latestResultText, 'submitted_at' => $latestSubmittedAt, 'stage_tasks' => array_map(function (array $task) use ($currentTask) { $task['is_current'] = $task['id'] === $currentTask['id']; return $task; }, $tasks), ]); } usort($list, fn (array $a, array $b) => $b['id'] <=> $a['id']); return $list; } private function selectCurrentTask(array $tasks): array { $sorted = $tasks; usort($sorted, function (array $a, array $b) { $stageCompare = $this->stagePriority($b['task_stage']) <=> $this->stagePriority($a['task_stage']); if ($stageCompare !== 0) { return $stageCompare; } $statusCompare = $this->statusPriority($b['status']) <=> $this->statusPriority($a['status']); if ($statusCompare !== 0) { return $statusCompare; } return $b['id'] <=> $a['id']; }); return $sorted[0]; } private function latestResultText(array $tasks): string { $sorted = $tasks; usort($sorted, function (array $a, array $b) { $stageCompare = $this->stagePriority($b['task_stage']) <=> $this->stagePriority($a['task_stage']); if ($stageCompare !== 0) { return $stageCompare; } return $b['id'] <=> $a['id']; }); foreach ($sorted as $task) { if ($task['result_text'] !== '') { return $task['result_text']; } } return ''; } private function latestSubmittedAt(array $tasks): ?string { $submitted = array_values(array_filter(array_map(fn (array $task) => $task['submitted_at'], $tasks))); if (!$submitted) { return null; } rsort($submitted); return $submitted[0]; } private function stagePriority(string $stage): int { return match ($stage) { 'first_review' => 1, 'final_review' => 2, default => 0, }; } private function statusPriority(string $status): int { return match ($status) { 'processing' => 5, 'pending' => 4, 'returned' => 3, 'submitted' => 2, 'completed' => 1, default => 0, }; } private function buildAppraisalReportMap(array $orderIds): array { if (!$orderIds) { return []; } $rows = Db::name('reports') ->whereIn('order_id', $orderIds) ->where('report_type', 'appraisal') ->order('id', 'desc') ->select() ->toArray(); $map = []; foreach ($rows as $row) { $orderId = (int)$row['order_id']; if (!isset($map[$orderId])) { $map[$orderId] = $row; } } return $map; } private function workbenchVisibleOrderStatuses(): array { return [ 'received', 'in_first_review', 'pending_supplement', 'in_final_review', 'generating_report', 'report_published', 'completed', ]; } private function workbenchVisibleOrderStatusSql(): string { $quoted = array_map(fn (string $status) => "'" . addslashes($status) . "'", $this->workbenchVisibleOrderStatuses()); return '(o.order_status IN (' . implode(', ', $quoted) . ") OR (o.order_status = 'pending_shipping' AND o.source_channel = 'enterprise_push'))"; } private function findLatestAppraisalReport(int $orderId): ?array { return Db::name('reports') ->where('order_id', $orderId) ->where('report_type', 'appraisal') ->order('id', 'desc') ->find() ?: null; } private function effectiveTaskStatus(array $task, ?array $report = null): string { $status = (string)($task['status'] ?? ''); $stage = (string)($task['task_stage'] ?? ''); $submittedAt = (string)($task['submitted_at'] ?? ''); $orderStatus = (string)($task['order_status'] ?? ''); if ( $submittedAt !== '' && ( $status === 'completed' || $report || in_array($orderStatus, ['generating_report', 'report_published', 'completed'], true) ) ) { return 'completed'; } if ($submittedAt !== '' && $status !== 'completed') { return 'submitted'; } return $status; } private function taskStatusText(string $status): string { return match ($status) { 'pending' => '待处理', 'processing' => '处理中', 'submitted' => '已提交', 'returned' => '待用户补料', 'completed' => '已完成', default => $status, }; } private function reportStatusText(string $status): string { return match ($status) { 'draft' => '草稿中', 'pending_publish' => '待发布', 'published' => '已发布', 'updated' => '已更新', 'invalid' => '已作废', default => $status, }; } private function mapResultStatus(string $resultText): string { return match ($resultText) { '正品' => 'authentic', '不符合正品特征' => 'non_authentic', '存疑' => 'suspicious', '暂无法明确判断' => 'inconclusive', default => '', }; } 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 serviceProviderText(string $serviceProvider): string { return $serviceProvider === 'zhongjian' ? '中检鉴定' : '实物鉴定'; } private function normalizeProductInput(mixed $input): array { $product = is_array($input) ? $input : []; $categoryId = !empty($product['category_id']) ? (int)$product['category_id'] : null; $brandId = !empty($product['brand_id']) ? (int)$product['brand_id'] : null; $categoryName = trim((string)($product['category_name'] ?? '')); if ($categoryName === '' && $categoryId) { $categoryName = $this->lookupName('catalog_categories', 'name', $categoryId); } $brandName = trim((string)($product['brand_name'] ?? '')); if ($brandName === '' && $brandId) { $brandName = $this->lookupName('catalog_brands', 'name', $brandId); } $productName = trim((string)($product['product_name'] ?? '')); if ($productName === '') { $productName = trim($categoryName . ' ' . $brandName); } return [ 'category_id' => $categoryId, 'category_name' => $categoryName, 'brand_id' => $brandId, 'brand_name' => $brandName, 'color' => trim((string)($product['color'] ?? '')), 'size_spec' => trim((string)($product['size_spec'] ?? '')), 'serial_no' => trim((string)($product['serial_no'] ?? '')), 'product_name' => $productName, ]; } private function saveOrderProductSnapshot(int $orderId, array $product, string $now): void { $exists = Db::name('order_products')->where('order_id', $orderId)->find(); $payload = array_merge($product, [ 'updated_at' => $now, ]); if ($exists) { foreach (['category_id', 'brand_id'] as $key) { if (empty($payload[$key]) && !empty($exists[$key])) { $payload[$key] = (int)$exists[$key]; } } foreach (['category_name', 'brand_name', 'color', 'size_spec', 'serial_no', 'product_name'] as $key) { if (($payload[$key] ?? '') === '' && ($exists[$key] ?? '') !== '') { $payload[$key] = $exists[$key]; } } if (($payload['product_name'] ?? '') === '') { $payload['product_name'] = trim(($payload['category_name'] ?? '') . ' ' . ($payload['brand_name'] ?? '')); } Db::name('order_products')->where('order_id', $orderId)->update($payload); return; } if (($payload['product_name'] ?? '') === '') { $payload['product_name'] = trim(($payload['category_name'] ?? '') . ' ' . ($payload['brand_name'] ?? '')); } Db::name('order_products')->insert(array_merge($payload, [ 'order_id' => $orderId, 'product_cover' => '', 'created_at' => $now, ])); } private function hasSubmittableProductInfo(int $orderId, ?array $incomingProduct = null): bool { $product = $incomingProduct; if ($product === null) { $product = Db::name('order_products')->where('order_id', $orderId)->find() ?: []; } $name = trim((string)($product['product_name'] ?? '')); $categoryName = trim((string)($product['category_name'] ?? '')); $brandName = trim((string)($product['brand_name'] ?? '')); return $name !== '' || $categoryName !== '' || $brandName !== ''; } private function resolveAppraisalTemplate(int $categoryId, string $serviceProvider, array $savedKeyPoints = []): ?array { if ($categoryId <= 0) { return null; } $template = Db::name('appraisal_templates') ->where('scope_type', 'category') ->where('scope_id', $categoryId) ->where('is_enabled', 1) ->order('is_default', 'desc') ->order('id', 'desc') ->find(); $category = null; if (!$template) { $category = Db::name('catalog_categories')->field(['id', 'name'])->where('id', $categoryId)->find(); if (!$category) { return null; } } $savedMap = []; foreach ($savedKeyPoints as $point) { if (!is_array($point)) { continue; } $code = (string)($point['point_code'] ?? ''); if ($code === '') { continue; } $savedMap[$code] = $point; } $pointRows = $template ? Db::name('appraisal_template_key_points') ->where('template_id', (int)$template['id']) ->order('sort_order', 'asc') ->order('id', 'asc') ->select() ->toArray() : []; return [ 'id' => $template ? (int)$template['id'] : 0, 'name' => (string)($template['name'] ?? sprintf('%s鉴定模板', (string)$category['name'])), 'code' => (string)($template['code'] ?? sprintf('appraisal_category_%d', $categoryId)), 'service_provider' => 'category', 'service_provider_text' => '通用品类模板', 'result_options' => [], 'condition_options' => [], 'valuation_hint' => '', 'key_points' => array_map(function (array $point) use ($savedMap) { $pointCode = (string)$point['point_code']; $saved = $savedMap[$pointCode] ?? []; return [ 'point_code' => $pointCode, 'point_name' => (string)$point['point_name'], 'point_type' => (string)$point['point_type'], 'options' => $this->decodeJsonArray($point['options_json'] ?? null), 'sort_order' => (int)$point['sort_order'], 'is_required' => (bool)$point['is_required'], 'point_value' => (string)($saved['point_value'] ?? ''), 'point_remark' => (string)($saved['point_remark'] ?? ''), ]; }, $pointRows), ]; } private function normalizeKeyPointInput(mixed $input): array { if (!is_array($input)) { return []; } $list = []; foreach ($input as $item) { if (!is_array($item)) { continue; } $pointCode = trim((string)($item['point_code'] ?? '')); $pointName = trim((string)($item['point_name'] ?? '')); if ($pointCode === '' || $pointName === '') { continue; } $list[] = [ 'point_code' => $pointCode, 'point_name' => $pointName, 'point_value' => trim((string)($item['point_value'] ?? '')), 'point_remark' => trim((string)($item['point_remark'] ?? '')), ]; } return $list; } private function saveTaskKeyPoints(int $taskResultId, array $keyPoints, string $now): void { Db::name('appraisal_task_key_points')->where('task_result_id', $taskResultId)->delete(); if (!$keyPoints) { return; } $rows = array_map(fn (array $point) => [ 'task_result_id' => $taskResultId, 'point_code' => $point['point_code'], 'point_name' => $point['point_name'], 'point_value' => $point['point_value'], 'point_remark' => $point['point_remark'], 'created_at' => $now, 'updated_at' => $now, ], $keyPoints); Db::name('appraisal_task_key_points')->insertAll($rows); } private function loadTaskKeyPoints(int $taskResultId): array { if ($taskResultId <= 0) { return []; } $rows = Db::name('appraisal_task_key_points') ->where('task_result_id', $taskResultId) ->order('id', 'asc') ->select() ->toArray(); return array_map(fn (array $item) => [ 'point_code' => (string)$item['point_code'], 'point_name' => (string)$item['point_name'], 'point_value' => (string)$item['point_value'], 'point_remark' => (string)$item['point_remark'], ], $rows); } private function loadLatestOrderKeyPoints(int $orderId): array { $result = Db::name('appraisal_task_results') ->alias('r') ->leftJoin('appraisal_tasks t', 't.id = r.task_id') ->field([ 'r.id', 't.task_stage', 't.id as task_id', ]) ->where('r.order_id', $orderId) ->orderRaw("CASE WHEN t.task_stage = 'final_review' THEN 2 WHEN t.task_stage = 'first_review' THEN 1 ELSE 0 END DESC") ->order('r.id', 'desc') ->find(); if (!$result) { return []; } return $this->loadTaskKeyPoints((int)$result['id']); } private function lookupName(string $table, string $field, ?int $id): string { if (!$id) { return ''; } return (string)Db::name($table)->where('id', $id)->value($field); } private function createOrUpdateReportDraft(int $orderId, array $task, array $resultPayload, string $now): void { $report = Db::name('reports')->where('order_id', $orderId)->order('id', 'desc')->find(); $order = Db::name('orders')->where('id', $orderId)->find(); $product = Db::name('order_products')->where('order_id', $orderId)->find(); $extra = Db::name('order_extras')->where('order_id', $orderId)->find(); $stageTasks = Db::name('appraisal_tasks') ->where('order_id', $orderId) ->select() ->toArray(); $firstReviewTask = null; $finalReviewTask = null; foreach ($stageTasks as $stageTask) { if (($stageTask['task_stage'] ?? '') === 'first_review') { $firstReviewTask = $stageTask; } if (($stageTask['task_stage'] ?? '') === 'final_review') { $finalReviewTask = $stageTask; } } $appraisalSnapshot = $this->buildAppraisalSnapshot( $task['service_provider'], $now, $firstReviewTask, $finalReviewTask ); $reportData = [ 'order_id' => $orderId, 'appraisal_no' => $order['appraisal_no'] ?? '', 'report_type' => 'appraisal', 'service_provider' => $task['service_provider'], 'institution_name' => $this->displayInstitutionName((string)$task['service_provider']), 'report_title' => $task['service_provider'] === 'zhongjian' ? '中检鉴定报告' : '安心验鉴定报告', 'report_status' => 'pending_publish', 'publish_time' => null, 'updated_at' => $now, ]; if ($report) { Db::name('reports')->where('id', $report['id'])->update($reportData); $reportId = (int)$report['id']; } else { $reportData['report_no'] = 'AXY-R-' . date('Ymd') . '-' . mt_rand(1000, 9999); $reportData['report_version'] = 1; $reportData['created_at'] = $now; $reportId = (int)Db::name('reports')->insertGetId($reportData); } $contentPayload = [ 'report_id' => $reportId, 'product_snapshot_json' => json_encode([ 'product_name' => $product['product_name'] ?? '', 'category_name' => $product['category_name'] ?? '', 'brand_name' => $product['brand_name'] ?? '', 'color' => $product['color'] ?? '', 'size_spec' => $product['size_spec'] ?? '', ], JSON_UNESCAPED_UNICODE), 'result_snapshot_json' => json_encode([ 'result_status' => $resultPayload['result_status'], 'result_text' => $resultPayload['result_text'], 'result_desc' => $resultPayload['result_desc'], 'key_points' => $this->loadLatestOrderKeyPoints($orderId), ], JSON_UNESCAPED_UNICODE), 'appraisal_snapshot_json' => json_encode($appraisalSnapshot, JSON_UNESCAPED_UNICODE), 'valuation_snapshot_json' => json_encode([ 'condition_grade' => $resultPayload['condition_grade'], 'condition_desc' => $resultPayload['condition_desc'] ?: ($extra['condition_desc'] ?? ''), 'valuation_min' => $resultPayload['valuation_min'], 'valuation_max' => $resultPayload['valuation_max'], 'valuation_desc' => $resultPayload['valuation_desc'], ], JSON_UNESCAPED_UNICODE), 'evidence_attachments_json' => $resultPayload['attachments_json'] ?? null, 'risk_notice_text' => (new ContentService())->getReportRiskNotice('appraisal'), 'updated_at' => $now, ]; $content = Db::name('report_contents')->where('report_id', $reportId)->find(); if ($content) { Db::name('report_contents')->where('report_id', $reportId)->update($contentPayload); } else { $contentPayload['created_at'] = $now; Db::name('report_contents')->insert($contentPayload); } } private function buildAppraisalSnapshot(string $serviceProvider, string $fallbackTime, ?array $firstReviewTask, ?array $finalReviewTask): array { $institutionName = $this->displayInstitutionName($serviceProvider); $appraiserName = $this->normalizeAssigneeName($firstReviewTask['assignee_name'] ?? '') ?: $this->normalizeAssigneeName($finalReviewTask['assignee_name'] ?? ''); $reviewerName = $appraiserName; $appraisalTime = $firstReviewTask['submitted_at'] ?? $firstReviewTask['started_at'] ?? $finalReviewTask['submitted_at'] ?? $finalReviewTask['started_at'] ?? $fallbackTime; return [ 'service_provider' => $serviceProvider, 'institution_name' => $institutionName, 'appraiser_name' => $appraiserName, 'reviewer_name' => $reviewerName, 'appraisal_time' => $appraisalTime, ]; } private function normalizeAssigneeName(?string $value): string { $name = trim((string)$value); if ($name === '' || $name === '未分配') { return ''; } return $name; } private function displayInstitutionName(string $serviceProvider): string { return $serviceProvider === 'zhongjian' ? '中检鉴定中心' : '安心检验'; } private function guardTaskOperator(Request $request, array $task): array { $adminId = (int)$request->header('x-admin-id', 0); $adminName = trim((string)$request->header('x-admin-name', '')); if ($adminId <= 0 || $adminName === '') { return ['error' => api_error('当前登录管理员信息异常', 401), 'task_update' => []]; } $roleCodes = $this->adminRoleCodes($adminId); if (!$this->canOperateTaskStage((string)$task['task_stage'], $roleCodes)) { return ['error' => api_error('当前账号未配置适用于该任务阶段的鉴定角色', 403), 'task_update' => []]; } $isSuperAdmin = in_array('super_admin', $roleCodes, true); $assigneeId = (int)($task['assignee_id'] ?? 0); $assigneeName = trim((string)($task['assignee_name'] ?? '')); if ($assigneeId > 0 && $assigneeId !== $adminId && !$isSuperAdmin) { return ['error' => api_error("当前任务已分配给 {$assigneeName},请勿越权处理", 422), 'task_update' => []]; } if ($assigneeId <= 0 || $assigneeName === '' || $assigneeName === '未分配') { return [ 'error' => null, 'task_update' => [ 'assignee_id' => $adminId, 'assignee_name' => $adminName, ], ]; } return ['error' => null, 'task_update' => []]; } private function assignableRoleCodesForStage(string $taskStage): array { return ['appraiser', 'super_admin']; } private function canOperateTaskStage(string $taskStage, array $roleCodes): bool { return (bool)array_intersect($roleCodes, $this->assignableRoleCodesForStage($taskStage)); } private function adminRoleCodes(int $adminId): array { $roleIds = Db::name('admin_role_relations')->where('admin_user_id', $adminId)->column('role_id'); if (!$roleIds) { return []; } return array_values(Db::name('admin_roles')->whereIn('id', $roleIds)->column('code')); } private function findAssignableAdmins(string $taskStage): array { $expectedRoleCodes = $this->assignableRoleCodesForStage($taskStage); $admins = Db::name('admin_users') ->where('status', 'enabled') ->order('id', 'asc') ->select() ->toArray(); $list = []; foreach ($admins as $admin) { $roleCodes = $this->adminRoleCodes((int)$admin['id']); if (!$this->canOperateTaskStage($taskStage, $roleCodes)) { continue; } $roleIds = Db::name('admin_role_relations')->where('admin_user_id', (int)$admin['id'])->column('role_id'); $roleNames = $roleIds ? Db::name('admin_roles')->whereIn('id', $roleIds)->column('name') : []; $list[] = [ 'id' => (int)$admin['id'], 'name' => $admin['name'], 'mobile' => $admin['mobile'], 'role_names' => array_values($roleNames), 'role_codes' => $roleCodes, ]; } return $list; } private function findAssignableAdminById(int $adminId, string $taskStage): ?array { $admin = Db::name('admin_users')->where('id', $adminId)->where('status', 'enabled')->find(); if (!$admin) { return null; } $roleCodes = $this->adminRoleCodes($adminId); if (!$this->canOperateTaskStage($taskStage, $roleCodes)) { return null; } $admin['role_codes'] = $roleCodes; return $admin; } private function publishReportRecord(array $report, Request $request, bool $wrapTransaction = true): array { if (!$report) { throw new \RuntimeException('报告不存在', 404); } if (!in_array((string)$report['report_status'], ['draft', 'pending_publish', 'updated', 'published'], true)) { throw new \InvalidArgumentException('当前报告状态不支持发布'); } $operatorId = (int)$request->header('x-admin-id', 0); $now = date('Y-m-d H:i:s'); $effectivePublishTime = $report['publish_time'] ?: $now; $verify = []; if ($wrapTransaction) { Db::startTrans(); } try { if (($report['report_status'] ?? '') !== 'published') { Db::name('reports')->where('id', (int)$report['id'])->update([ 'report_status' => 'published', 'publish_time' => $effectivePublishTime, 'updated_at' => $now, ]); $report['report_status'] = 'published'; $report['publish_time'] = $effectivePublishTime; } $verify = $this->createOrUpdateVerifyRecord($report, $now); if (($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0) { Db::name('orders')->where('id', (int)$report['order_id'])->update([ 'order_status' => 'report_published', 'display_status' => '报告已出具', 'updated_at' => $now, ]); $order = Db::name('orders')->where('id', (int)$report['order_id'])->find(); $product = Db::name('order_products')->where('order_id', (int)$report['order_id'])->find(); $timelineExists = Db::name('order_timelines') ->where('order_id', (int)$report['order_id']) ->where('node_code', 'report_published') ->where('node_text', '报告已出具') ->find(); if (!$timelineExists) { Db::name('order_timelines')->insert([ 'order_id' => (int)$report['order_id'], 'node_code' => 'report_published', 'node_text' => '报告已出具', 'node_desc' => '正式报告已发布,用户可查看报告。', 'operator_type' => 'admin', 'operator_id' => $operatorId ?: null, 'occurred_at' => $now, 'created_at' => $now, ]); } (new MessageDispatcher())->sendInboxEvent('report_published', [ 'user_id' => (int)($order['user_id'] ?? 0), 'biz_type' => 'report', 'biz_id' => (int)$report['id'], 'report_no' => (string)$report['report_no'], 'report_title' => (string)$report['report_title'], 'product_name' => $product['product_name'] ?? '', 'publish_time' => $effectivePublishTime, 'verify_url' => (string)($verify['verify_url'] ?? ''), 'fallback_title' => '报告已出具', 'fallback_content' => '您的正式报告已生成,可前往报告中心查看。', ]); } if ($wrapTransaction) { Db::commit(); } } catch (\Throwable $e) { if ($wrapTransaction) { Db::rollback(); } throw $e; } if (($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0) { (new EnterpriseWebhookService())->recordOrderEvent((int)$report['order_id'], 'report_published', [ 'report_id' => (int)$report['id'], 'report_no' => (string)$report['report_no'], 'report_title' => (string)$report['report_title'], 'publish_time' => $effectivePublishTime, 'verify_url' => (string)($verify['verify_url'] ?? ''), 'report_page_url' => (string)($verify['report_page_url'] ?? ''), ]); } return [ 'id' => (int)$report['id'], 'report_status' => 'published', 'publish_time' => $effectivePublishTime, 'verify_url' => (string)($verify['verify_url'] ?? ''), 'report_page_url' => (string)($verify['report_page_url'] ?? ''), ]; } private function createOrUpdateVerifyRecord(array $report, string $now): array { $reportNo = (string)$report['report_no']; $verifyToken = 'verify_' . strtolower((string)preg_replace('/[^a-zA-Z0-9]/', '', $reportNo)); $verifyUrl = $this->buildPublicPageUrl('/pages/verify/result', ['report_no' => $reportNo]); $reportPageUrl = $this->buildPublicPageUrl('/pages/report/detail', ['report_no' => $reportNo]); $payload = [ 'report_id' => (int)$report['id'], 'report_no' => $reportNo, 'verify_token' => $verifyToken, 'verify_qrcode_url' => $reportPageUrl, 'verify_url' => $verifyUrl, 'verify_status' => 'valid', 'updated_at' => $now, ]; $verify = Db::name('report_verifies')->where('report_id', (int)$report['id'])->find(); if ($verify) { Db::name('report_verifies')->where('id', (int)$verify['id'])->update($payload); } else { $payload['last_verified_at'] = null; $payload['verify_count'] = 0; $payload['created_at'] = $now; Db::name('report_verifies')->insert($payload); } $fresh = Db::name('report_verifies')->where('report_id', (int)$report['id'])->find() ?: $payload; $fresh['report_page_url'] = $reportPageUrl; return $fresh; } private function buildPublicPageUrl(string $pagePath, array $query = []): string { $baseUrl = $this->normalizeH5BaseUrl($this->getSystemConfigValue('h5', 'page_base_url')); $page = ltrim($pagePath, '/'); $queryString = http_build_query($query); $hashPath = '/#/' . $page; if ($queryString !== '') { $hashPath .= '?' . $queryString; } return $baseUrl === '' ? $hashPath : $baseUrl . $hashPath; } private function normalizeH5BaseUrl(string $value): string { $baseUrl = trim($value); if ($baseUrl === '') { return ''; } $hashPos = strpos($baseUrl, '#'); if ($hashPos !== false) { $baseUrl = substr($baseUrl, 0, $hashPos); } if (!preg_match('/^https?:\/\//i', $baseUrl)) { $baseUrl = 'https://' . ltrim($baseUrl, '/'); } return rtrim($baseUrl, '/'); } private function getSystemConfigValue(string $groupCode, string $configKey): string { $row = Db::name('system_configs') ->where('config_group', $groupCode) ->where('config_key', $configKey) ->find(); return trim((string)($row['config_value'] ?? '')); } private function evidenceService(): AppraisalEvidenceService { return new AppraisalEvidenceService(); } private function assetUrlService(): PublicAssetUrlService { return new PublicAssetUrlService(); } }