From 787a69c207c048909a205a1678715ea8cb117e46 Mon Sep 17 00:00:00 2001 From: tradewind Date: Fri, 22 May 2026 10:10:16 +0800 Subject: [PATCH] #feature: add test mail generator --- .env.example | 4 + app/Clients/CrmClient.php | 104 ++++ .../ProductionDiagnosisController.php | 46 ++ app/Http/Controllers/TestMailController.php | 460 ++++++++++++++ app/Providers/AppServiceProvider.php | 4 + app/Services/JiraService.php | 475 ++++++++++++++- app/Services/ProductionDiagnosisService.php | 564 ++++++++++++++++++ config/services.php | 5 + .../js/components/admin/AdminDashboard.vue | 23 + resources/js/components/admin/AdminLayout.vue | 53 ++ .../js/components/jira/TestMailGenerator.vue | 250 ++++++++ .../components/tools/ProductionDiagnosis.vue | 312 ++++++++++ routes/api.php | 18 + routes/web.php | 2 + tests/Unit/JiraServiceTest.php | 51 ++ 15 files changed, 2367 insertions(+), 4 deletions(-) create mode 100644 app/Clients/CrmClient.php create mode 100644 app/Http/Controllers/ProductionDiagnosisController.php create mode 100644 app/Http/Controllers/TestMailController.php create mode 100644 app/Services/ProductionDiagnosisService.php create mode 100644 resources/js/components/jira/TestMailGenerator.vue create mode 100644 resources/js/components/tools/ProductionDiagnosis.vue diff --git a/.env.example b/.env.example index 460fa93..629f149 100644 --- a/.env.example +++ b/.env.example @@ -101,6 +101,10 @@ AGENT_TIMEOUT=30 MONO_URL=http://localhost:8081 MONO_TIMEOUT=30 +# CRM Service Configuration (用于进产诊断中的一级代理账期判断) +CRM_SERVICE_BASE_URI= +CRM_SERVICE_TIMEOUT=15 + # Git Monitor Configuration GIT_MONITOR_PROJECTS="service,portal-be,agent-be" diff --git a/app/Clients/CrmClient.php b/app/Clients/CrmClient.php new file mode 100644 index 0000000..ad59936 --- /dev/null +++ b/app/Clients/CrmClient.php @@ -0,0 +1,104 @@ +baseUrl = rtrim((string) config('services.crm.base_uri'), '/'); + $this->timeout = (int) config('services.crm.timeout', 15); + } + + /** + * 是否已配置 CRM 接口 + */ + public function isConfigured(): bool + { + return $this->baseUrl !== ''; + } + + /** + * 获取一级代理详情,包含 productList[].productCode / agentAccountingPeriod + * + * 对应 agent-be Client::getAgentByCode -> GET /api/group/detail/{code} + * + * @return array|null 成功返回响应 JSON 解码数据;失败返回 null。 + */ + public function getAgentByCode(string $rootAgentCode): ?array + { + if (!$this->isConfigured()) { + return null; + } + + try { + $response = $this->http()->get($this->baseUrl.'/api/group/detail/'.$rootAgentCode); + + if (!$response->successful()) { + Log::warning('CRM getAgentByCode non-2xx', [ + 'code' => $rootAgentCode, + 'status' => $response->status(), + 'body' => $response->body(), + ]); + return null; + } + + return $response->json(); + } catch (\Throwable $e) { + Log::warning('CRM getAgentByCode failed', [ + 'code' => $rootAgentCode, + 'message' => $e->getMessage(), + ]); + return null; + } + } + + /** + * 解析 getAgentByCode 返回,构建 productCode => hasCredit 的映射 + */ + public function firstAgentCreditMap(string $rootAgentCode): array + { + $data = $this->getAgentByCode($rootAgentCode); + if (!is_array($data)) { + return []; + } + + // 与 agent-be AgentCredit::getFirstAgentCredit 保持一致:要求 code == 200 + if (!isset($data['code']) || (int) $data['code'] !== 200) { + return []; + } + + $productList = $data['data']['productList'] ?? []; + $map = []; + foreach ($productList as $product) { + $productCode = (string) ($product['productCode'] ?? ''); + if ($productCode === '') { + continue; + } + $map[$productCode] = ((int) ($product['agentAccountingPeriod'] ?? 0)) > 0; + } + + return $map; + } + + private function http(): PendingRequest + { + return Http::timeout($this->timeout) + ->withoutVerifying() + ->acceptJson(); + } +} diff --git a/app/Http/Controllers/ProductionDiagnosisController.php b/app/Http/Controllers/ProductionDiagnosisController.php new file mode 100644 index 0000000..90da8e7 --- /dev/null +++ b/app/Http/Controllers/ProductionDiagnosisController.php @@ -0,0 +1,46 @@ +validate([ + 'type' => 'required|in:case,business_document,sale_document', + 'code' => 'required|string|max:64', + ]); + + $result = $this->service->diagnose($validated['type'], trim($validated['code'])); + + return response()->json([ + 'success' => true, + 'data' => $result, + ]); + } catch (ValidationException $e) { + return response()->json([ + 'success' => false, + 'message' => '请求参数验证失败', + 'errors' => $e->errors(), + ], 422); + } catch (\Throwable $e) { + return response()->json([ + 'success' => false, + 'message' => '诊断失败: '.$e->getMessage(), + ], 500); + } + } +} diff --git a/app/Http/Controllers/TestMailController.php b/app/Http/Controllers/TestMailController.php new file mode 100644 index 0000000..6e35419 --- /dev/null +++ b/app/Http/Controllers/TestMailController.php @@ -0,0 +1,460 @@ +json([ + 'success' => true, + 'data' => [ + 'sprints' => $this->jiraService->getTestMailSprintOptions()->all(), + 'defaults' => $this->jiraService->getTestMailTemplateDefaults(), + ], + ]); + } + + public function data(Request $request): JsonResponse + { + $request->validate([ + 'sprint' => 'required|string|max:50', + ]); + + $sprint = trim((string) $request->query('sprint')); + $issues = $this->jiraService->getSprintTestIssues($sprint); + $period = $this->jiraService->resolveTestMailSprintPeriod($sprint, $issues); + + return response()->json([ + 'success' => true, + 'data' => [ + 'sprint' => $sprint, + 'sprint_period' => $period, + 'suggested_subject' => $this->buildTestMailSubject($period ?: 'Sprint'.$sprint), + 'jql' => sprintf('project in (WP, AM, TP) AND issuetype = Story AND status in (开发中, 测试中, 需求调研中, 需求已调研, 需求已评审, 已提测, 待上线, 需求已排期, 待提测, 产品验收) AND Sprint = %s ORDER BY priority DESC, cf[10004] ASC, key ASC', $sprint), + 'issues' => $issues->values()->all(), + 'defaults' => $this->jiraService->getTestMailTemplateDefaults(), + 'sprints' => $this->jiraService->getTestMailSprintOptions()->all(), + ], + ]); + } + + public function draftSections(Request $request): JsonResponse + { + $validated = $request->validate([ + 'issues' => 'array', + 'issues.*.key' => 'nullable|string|max:50', + 'issues.*.summary' => 'nullable|string|max:500', + 'issues.*.project_key' => 'nullable|string|max:50', + 'issues.*.project_name' => 'nullable|string|max:120', + 'issues.*.developer' => 'nullable|string|max:120', + 'issues.*.assignee' => 'nullable|string|max:120', + 'issues.*.requirement_type' => 'nullable|string|max:120', + 'tech_docs' => 'nullable|string|max:2000', + 'selected_containers' => 'array', + 'selected_containers.*' => 'string|max:120', + 'databases' => 'array', + 'databases.*.system' => 'nullable|string|max:50', + 'databases.*.has_database' => 'nullable|string|max:50', + 'databases.*.branch' => 'nullable|string|max:160', + ]); + + $fallback = $this->buildRuleBasedDraftSections($validated); + $source = 'rules'; + + if ($this->aiService->isConfigured()) { + try { + $aiDraft = $this->buildAiDraftSections($validated); + if (! empty($aiDraft['test_notes']) || ! empty($aiDraft['risks'])) { + $fallback = array_replace($fallback, array_filter($aiDraft)); + $source = 'ai'; + } + } catch (\Throwable) { + $source = 'rules'; + } + } + + return response()->json([ + 'success' => true, + 'data' => [ + 'test_notes' => $fallback['test_notes'], + 'risks' => $fallback['risks'], + 'source' => $source, + ], + ]); + } + + public function databases(Request $request): JsonResponse + { + $validated = $request->validate([ + 'selected_groups' => 'array', + 'selected_groups.*' => 'string', + 'versions' => 'array', + ]); + + return response()->json([ + 'success' => true, + 'data' => [ + 'databases' => $this->jiraService->buildTestMailDatabases( + $validated['selected_groups'] ?? [], + $validated['versions'] ?? [] + ), + ], + ]); + } + + public function download(Request $request) + { + $validated = $request->validate([ + 'subject' => 'required|string|max:255', + 'from' => 'nullable|string|max:255', + 'to' => 'nullable|string', + 'cc' => 'nullable|string', + 'html' => 'required|string', + 'text' => 'nullable|string', + 'images' => 'array', + 'images.*.cid' => 'required|string|max:180', + 'images.*.name' => 'nullable|string|max:180', + 'images.*.dataUrl' => 'required|string', + ]); + + $subject = $validated['subject']; + $eml = $this->buildEml( + $subject, + $validated['html'], + $validated['text'] ?? $this->htmlToText($validated['html']), + $validated['images'] ?? [], + $validated['from'] ?: '万文山 ', + $validated['to'] ?? '', + $validated['cc'] ?? '' + ); + + $filename = Str::slug(str_replace(['【', '】'], '', $subject), '_'); + if ($filename === '') { + $filename = 'test_mail'; + } + + return response($eml) + ->header('Content-Type', 'message/rfc822; charset=UTF-8') + ->header('Content-Disposition', 'attachment; filename="'.$filename.'.eml"'); + } + + public function openDraft(Request $request): JsonResponse + { + $validated = $request->validate([ + 'subject' => 'required|string|max:255', + 'from' => 'nullable|string|max:255', + 'to' => 'required|string', + 'cc' => 'nullable|string', + 'html' => 'required|string', + 'text' => 'required|string', + 'images' => 'array', + 'images.*.cid' => 'required|string|max:180', + 'images.*.name' => 'nullable|string|max:180', + 'images.*.dataUrl' => 'required|string', + ]); + + $thunderbird = trim((string) shell_exec('command -v thunderbird 2>/dev/null')); + if ($thunderbird === '') { + return response()->json([ + 'success' => false, + 'message' => '未找到 thunderbird 命令', + ], 422); + } + + $draftDir = storage_path('app/test-mail-drafts'); + if (! is_dir($draftDir)) { + mkdir($draftDir, 0775, true); + } + $html = $this->inlineDraftImages($validated['html'], $validated['images'] ?? []); + $filename = (Str::slug(str_replace(['【', '】'], '', $validated['subject']), '_') ?: 'test_mail').'_'.date('Ymd_His').'.html'; + $draftPath = $draftDir.DIRECTORY_SEPARATOR.$filename; + file_put_contents($draftPath, $html); + + $compose = implode(',', array_filter([ + ! empty($validated['from']) ? 'from='.$this->thunderbirdComposeValue($validated['from']) : null, + 'to='.$this->thunderbirdComposeValue($validated['to']), + ! empty($validated['cc']) ? 'cc='.$this->thunderbirdComposeValue($validated['cc']) : null, + 'subject='.$this->thunderbirdComposeValue($validated['subject']), + 'message='.$this->thunderbirdComposeValue($draftPath), + 'format=html', + ])); + + $uid = function_exists('posix_getuid') ? (string) posix_getuid() : trim((string) shell_exec('id -u 2>/dev/null')); + $xdgRuntimeDir = getenv('XDG_RUNTIME_DIR') ?: ($uid !== '' ? '/run/user/'.$uid : ''); + $env = array_filter([ + 'DISPLAY='.(getenv('DISPLAY') ?: ':0'), + 'WAYLAND_DISPLAY='.(getenv('WAYLAND_DISPLAY') ?: 'wayland-0'), + $xdgRuntimeDir !== '' ? 'XDG_RUNTIME_DIR='.$xdgRuntimeDir : null, + 'DBUS_SESSION_BUS_ADDRESS='.(getenv('DBUS_SESSION_BUS_ADDRESS') ?: ($xdgRuntimeDir !== '' ? 'unix:path='.$xdgRuntimeDir.'/bus' : '')), + ]); + + $command = sprintf( + 'timeout 10s env %s %s -compose %s 2>&1', + implode(' ', array_map('escapeshellarg', $env)), + escapeshellarg($thunderbird), + escapeshellarg($compose) + ); + exec($command, $output, $code); + + return response()->json([ + 'success' => $code === 0, + 'message' => $code === 0 ? '已向 Thunderbird 发送打开 HTML 邮件草稿请求' : ('打开 Thunderbird 失败:'.trim(implode("\n", $output))), + ], $code === 0 ? 200 : 500); + } + + private function buildTestMailSubject(string $period): string + { + return sprintf('【提测】%s需求提测(SP、PP、TP)', $period); + } + + private function buildRuleBasedDraftSections(array $context): array + { + $issues = collect($context['issues'] ?? []); + $containers = collect($context['selected_containers'] ?? [])->filter()->values(); + $databases = collect($context['databases'] ?? [])->filter(fn ($row) => ($row['has_database'] ?? '') === '有')->values(); + $owners = $issues->pluck('developer')->merge($issues->pluck('assignee'))->filter()->unique()->values(); + + $testNotes = []; + if (trim((string) ($context['tech_docs'] ?? '')) !== '' && trim((string) ($context['tech_docs'] ?? '')) !== '无') { + $testNotes[] = [ + 'type' => '测试注意事项', + 'issue' => $this->summarizeIssueKeys($issues), + 'system' => 'SP/PP/TP', + 'content' => '测试前请先阅读技术文档,重点关注接口、配置和兼容性说明。', + 'owner' => $owners->first() ?? '', + ]; + } + if ($containers->isNotEmpty()) { + $testNotes[] = [ + 'type' => '其他依赖项', + 'issue' => $this->summarizeIssueKeys($issues), + 'system' => $containers->take(4)->implode(', '), + 'content' => '请确认本次涉及容器已部署对应版本,部署完成后再开始主流程验证。', + 'owner' => $owners->first() ?? '', + ]; + } + foreach ($databases as $database) { + $testNotes[] = [ + 'type' => '脚本', + 'issue' => $this->summarizeIssueKeys($issues), + 'system' => $database['system'] ?? '', + 'content' => '请确认数据库脚本分支已合入并执行:'.($database['branch'] ?? ''), + 'owner' => $owners->first() ?? '', + ]; + } + if ($testNotes === []) { + $testNotes[] = [ + 'type' => '测试注意事项', + 'issue' => $this->summarizeIssueKeys($issues), + 'system' => 'SP/PP/TP', + 'content' => '按本次提测需求逐项回归,重点覆盖新增流程、状态流转和权限边界。', + 'owner' => $owners->first() ?? '', + ]; + } + + $risks = [[ + 'problem' => $databases->isNotEmpty() ? '涉及数据库变更,需确认脚本执行顺序和环境一致性。' : '暂无明确已知问题,测试过程中如发现阻塞需及时同步。', + 'impact' => $databases->isNotEmpty() ? '脚本遗漏或顺序错误可能影响相关需求验证。' : '可能影响本次提测范围内需求验收进度。', + 'action' => $databases->isNotEmpty() ? '部署前由研发确认脚本分支,测试开始前完成环境冒烟。' : '按需求清单跟踪问题,阻塞项及时拉群确认处理方案。', + 'owner' => $owners->first() ?? '', + ]]; + + return [ + 'test_notes' => array_slice($testNotes, 0, 5), + 'risks' => $risks, + ]; + } + + private function buildAiDraftSections(array $context): array + { + $content = json_encode([ + 'issues' => collect($context['issues'] ?? [])->take(20)->values(), + 'tech_docs' => $context['tech_docs'] ?? '', + 'selected_containers' => $context['selected_containers'] ?? [], + 'databases' => $context['databases'] ?? [], + ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + + $prompt = <<<'PROMPT' +请根据提测邮件上下文生成第九和第十部分的简略表格草稿。 +只输出 JSON,不要 Markdown,不要解释。格式: +{ + "test_notes": [{"type":"脚本|配置项|其他依赖项|测试注意事项","issue":"需求key或事项","system":"系统/容器","content":"简短说明","owner":"负责人"}], + "risks": [{"problem":"已知问题/风险","impact":"影响范围","action":"处理方案/规避措施","owner":"负责人"}] +} +要求:最多 5 条 test_notes,最多 3 条 risks;内容要短,适合直接放入提测邮件;没有明确风险时给一条“暂无明确已知问题”的保守项。 +PROMPT; + + $response = $this->aiService->analyze($content ?: '{}', $prompt); + $json = $this->extractJsonObject($response); + if ($json === null) { + return []; + } + + $decoded = json_decode($json, true); + if (! is_array($decoded)) { + return []; + } + + return [ + 'test_notes' => $this->normalizeRows($decoded['test_notes'] ?? [], ['type', 'issue', 'system', 'content', 'owner']), + 'risks' => $this->normalizeRows($decoded['risks'] ?? [], ['problem', 'impact', 'action', 'owner']), + ]; + } + + private function normalizeRows(array $rows, array $keys): array + { + return collect($rows)->filter(fn ($row) => is_array($row))->map(function ($row) use ($keys) { + $normalized = []; + foreach ($keys as $key) { + $normalized[$key] = trim((string) ($row[$key] ?? '')); + } + + return $normalized; + })->filter(fn ($row) => collect($row)->filter()->isNotEmpty())->values()->all(); + } + + private function extractJsonObject(string $response): ?string + { + $response = trim($response); + if ($response === '') { + return null; + } + if (str_starts_with($response, '```')) { + $response = preg_replace('/^```(?:json)?\s*|\s*```$/u', '', $response); + } + if (preg_match('/\{.*\}/su', $response, $matches)) { + return $matches[0]; + } + + return null; + } + + private function summarizeIssueKeys($issues): string + { + $keys = collect($issues)->pluck('key')->filter()->take(6)->implode(', '); + + return $keys ?: '本次提测需求'; + } + + private function buildEml(string $subject, string $html, string $text, array $images, string $from, string $to, string $cc): string + { + $mixedBoundary = '----=_Part_'.bin2hex(random_bytes(12)); + $relatedBoundary = '----=_Related_'.bin2hex(random_bytes(12)); + $alternativeBoundary = '----=_Alternative_'.bin2hex(random_bytes(12)); + + $headers = [ + 'From: '.$from, + 'To: '.$to, + 'Cc: '.$cc, + 'Subject: =?UTF-8?B?'.base64_encode($subject).'?=', + 'Date: '.date(DATE_RFC2822), + 'MIME-Version: 1.0', + 'Content-Type: multipart/mixed; boundary="'.$mixedBoundary.'"', + ]; + + $body = []; + $body[] = '--'.$mixedBoundary; + $body[] = 'Content-Type: multipart/alternative; boundary="'.$alternativeBoundary.'"'; + $body[] = ''; + $body[] = '--'.$alternativeBoundary; + $body[] = 'Content-Type: text/plain; charset=UTF-8'; + $body[] = 'Content-Transfer-Encoding: base64'; + $body[] = ''; + $body[] = chunk_split(base64_encode($text)); + $body[] = '--'.$alternativeBoundary; + $body[] = 'Content-Type: multipart/related; boundary="'.$relatedBoundary.'"'; + $body[] = ''; + $body[] = '--'.$relatedBoundary; + $body[] = 'Content-Type: text/html; charset=UTF-8'; + $body[] = 'Content-Transfer-Encoding: base64'; + $body[] = ''; + $body[] = chunk_split(base64_encode($html)); + + foreach ($images as $index => $image) { + $parsed = $this->parseDataUrl($image['dataUrl']); + if (! $parsed) { + continue; + } + $cid = trim($image['cid'], '<>'); + $name = $image['name'] ?? ('screenshot-'.($index + 1).'.png'); + $body[] = '--'.$relatedBoundary; + $body[] = 'Content-Type: '.$parsed['mime'].'; name="'.$this->escapeHeader($name).'"'; + $body[] = 'Content-Transfer-Encoding: base64'; + $body[] = 'Content-ID: <'.$cid.'>'; + $body[] = 'Content-Disposition: inline; filename="'.$this->escapeHeader($name).'"'; + $body[] = ''; + $body[] = chunk_split(base64_encode($parsed['bytes'])); + } + + $body[] = '--'.$relatedBoundary.'--'; + $body[] = ''; + $body[] = '--'.$alternativeBoundary.'--'; + $body[] = ''; + $body[] = '--'.$mixedBoundary.'--'; + $body[] = ''; + + return implode("\r\n", $headers)."\r\n\r\n".implode("\r\n", $body); + } + + private function parseDataUrl(string $dataUrl): ?array + { + if (! preg_match('/^data:([^;]+);base64,(.*)$/s', $dataUrl, $matches)) { + return null; + } + + $bytes = base64_decode($matches[2], true); + if ($bytes === false) { + return null; + } + + return ['mime' => $matches[1], 'bytes' => $bytes]; + } + + private function htmlToText(string $html): string + { + return trim(html_entity_decode(strip_tags(str_replace(['
', '
', '
'], "\n", $html)), ENT_QUOTES | ENT_HTML5, 'UTF-8')); + } + + private function escapeHeader(string $value): string + { + return str_replace(['"', "\r", "\n"], ['\\"', '', ''], $value); + } + + private function inlineDraftImages(string $html, array $images): string + { + foreach ($images as $image) { + $cid = trim((string) ($image['cid'] ?? ''), '<>'); + $dataUrl = (string) ($image['dataUrl'] ?? ''); + if ($cid === '' || $dataUrl === '') { + continue; + } + + $html = str_replace('cid:'.$cid, $dataUrl, $html); + } + + return $html; + } + + private function thunderbirdComposeValue(string $value): string + { + return "'".str_replace( + ["\\", "'"], + ["\\\\", "\\'"], + str_replace("\r\n", "\n", $value) + )."'"; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 7a83db0..1c86abe 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -4,6 +4,7 @@ namespace App\Providers; use App\Clients\AgentClient; use App\Clients\AiClient; +use App\Clients\CrmClient; use App\Clients\MonoClient; use App\Clients\SlsClient; use App\Services\AiService; @@ -14,6 +15,7 @@ use App\Services\EnvService; use App\Services\GitMonitorService; use App\Services\JiraService; use App\Services\LogAnalysisService; +use App\Services\ProductionDiagnosisService; use App\Services\SlsService; use Illuminate\Support\ServiceProvider; @@ -28,6 +30,7 @@ class AppServiceProvider extends ServiceProvider $this->app->singleton(AgentClient::class); $this->app->singleton(MonoClient::class); $this->app->singleton(SlsClient::class); + $this->app->singleton(CrmClient::class); $this->app->singleton(AiClient::class, fn ($app) => new AiClient($app->make(ConfigService::class))); // 注册 Services @@ -40,6 +43,7 @@ class AppServiceProvider extends ServiceProvider $this->app->singleton(AiService::class); $this->app->singleton(CodeContextService::class); $this->app->singleton(LogAnalysisService::class); + $this->app->singleton(ProductionDiagnosisService::class); } /** diff --git a/app/Services/JiraService.php b/app/Services/JiraService.php index 17c9ff7..f2518f3 100644 --- a/app/Services/JiraService.php +++ b/app/Services/JiraService.php @@ -2,9 +2,11 @@ namespace App\Services; +use App\Models\Project; use Carbon\Carbon; use Illuminate\Support\Collection; use JiraRestApi\Configuration\ArrayConfiguration; +use JiraRestApi\Field\FieldService; use JiraRestApi\Issue\IssueService; use JiraRestApi\JiraException; use JiraRestApi\Project\ProjectService; @@ -15,6 +17,8 @@ class JiraService private ProjectService $projectService; + private FieldService $fieldService; + private array $config; public function __construct() @@ -39,6 +43,7 @@ class JiraService $this->issueService = new IssueService($clientConfig); $this->projectService = new ProjectService($clientConfig); + $this->fieldService = new FieldService($clientConfig); } /** @@ -167,6 +172,452 @@ class JiraService return collect(); } + + + /** + * 获取 Jira Sprint 下拉选项(从最近 Story 的 Sprint 字段中提取) + */ + public function getTestMailSprintOptions(): Collection + { + $jql = 'project in (WP, AM, TP) AND issuetype = Story AND Sprint is not EMPTY ORDER BY updated DESC'; + + try { + $result = $this->issueService->search($jql, 0, 80, ['summary', 'customfield_10004']); + $options = collect(); + + foreach (($result->issues ?? []) as $issue) { + foreach ($this->extractSprintOptionsFromIssue($issue) as $option) { + $key = $option['id'] ?: $option['name']; + if (! $options->has($key)) { + $options->put($key, $option); + } + } + } + + return $options->values()->sortByDesc(function ($option) { + return (int) preg_replace('/\D+/', '', (string) ($option['id'] ?: $option['name'])); + })->values(); + } catch (JiraException) { + return collect(); + } + } + + /** + * 获取提测邮件需要的 Sprint 需求列表 + */ + public function getSprintTestIssues(string $sprint): Collection + { + $sprint = trim($sprint); + if ($sprint === '') { + throw new \InvalidArgumentException('Sprint 不能为空'); + } + + $jql = sprintf( + 'project in (WP, AM, TP) AND issuetype = Story AND status in (开发中, 测试中, 需求调研中, 需求已调研, 需求已评审, 已提测, 待上线, 需求已排期, 待提测, 产品验收) AND Sprint = %s ORDER BY priority DESC, cf[10004] ASC, key ASC', + $sprint + ); + + $dynamicFields = $this->resolveTestMailDynamicFieldIds(); + $fields = array_values(array_unique(array_filter(array_merge([ + 'summary', + 'status', + 'project', + 'issuetype', + 'priority', + 'assignee', + 'reporter', + 'fixVersions', + 'customfield_10004', + 'customfield_11000', + 'customfield_14305', + ], array_values($dynamicFields))))); + + try { + $issues = collect(); + $startAt = 0; + $maxResults = 100; + do { + $result = $this->issueService->search($jql, $startAt, $maxResults, $fields); + $pageIssues = $result->issues ?? []; + foreach ($pageIssues as $issue) { + $issues->push($this->formatSprintTestIssue($issue, $dynamicFields)); + } + $startAt += $maxResults; + $total = $result->total ?? $issues->count(); + } while ($issues->count() < $total && ! empty($pageIssues)); + return $issues; + } catch (JiraException $e) { + throw new \RuntimeException('获取 Sprint 需求失败: '.$e->getMessage()); + } + } + + /** + * 获取提测邮件默认模板数据,来源于 ~/Documents/提测邮件.xlsx 的当前结构 + */ + public function getTestMailTemplateDefaults(): array + { + $containerGroups = [ + 'agent' => [ + 'label' => 'agent', + 'system' => 'PP', + 'project_slug' => 'agent-be', + 'db_project' => '/home/tradewind/Projects/agent-db-version', + 'current_version' => $this->resolveTestMailCurrentVersion('agent-be', '2.69.0.0'), + 'containers' => [ + ['name' => 'agent-be-aplct', 'location' => '中国&美国'], + ['name' => 'agent-be-web', 'location' => '中国&美国'], + ['name' => 'agent-fe-web', 'location' => '中国&美国'], + ], + ], + 'portal' => [ + 'label' => 'portal', + 'system' => 'SP', + 'project_slug' => 'portal-be', + 'db_project' => '/home/tradewind/Projects/portal-db-version', + 'current_version' => $this->resolveTestMailCurrentVersion('portal-be', '2.64.0.0'), + 'containers' => [ + ['name' => 'portal-be-aplct', 'location' => '法兰克福&中国'], + ['name' => 'portal-be-web', 'location' => '法兰克福'], + ['name' => 'portal-fe-web', 'location' => '法兰克福'], + ], + ], + 'portal-ticket' => [ + 'label' => 'portal-ticket', + 'system' => 'TP', + 'project_slug' => 'ticket-be', + 'db_project' => '/home/tradewind/Projects/ticket-db-version', + 'current_version' => $this->resolveTestMailCurrentVersion('ticket-be', '1.42.0.0'), + 'containers' => [ + ['name' => 'portal-ticket-be-aplct', 'location' => '法兰克福&中国'], + ['name' => 'portal-ticket-be-web', 'location' => '法兰克福&中国'], + ['name' => 'portal-ticket-fe-web', 'location' => '法兰克福&中国'], + ], + ], + ]; + + foreach ($containerGroups as &$group) { + $group['default_version'] = $this->nextTestMailVersion($group['current_version']); + } + unset($group); + + return [ + 'container_groups' => [ + ...$containerGroups, + ], + 'scripts_template' => [ + ['issue' => '', 'description' => '', 'container' => '', 'script' => '', 'owner' => ''], + ], + 'configs_template' => [ + ['issue' => '', 'system' => '', 'key' => '', 'description' => '', 'owner' => ''], + ], + 'risks_template' => [ + ['problem' => '', 'impact' => '', 'action' => ''], + ], + ]; + } + + private function resolveTestMailCurrentVersion(string $projectSlug, string $fallback): string + { + try { + $version = Project::query() + ->where('slug', $projectSlug) + ->value('git_current_version'); + } catch (\Throwable) { + $version = null; + } + + return trim((string) ($version ?: $fallback)); + } + + private function nextTestMailVersion(string $version): string + { + $parts = explode('.', trim($version)); + if (count($parts) < 2 || ! ctype_digit($parts[1])) { + return $version; + } + + $parts[1] = (string) ((int) $parts[1] + 1); + + return implode('.', $parts); + } + + public function buildTestMailDatabases(array $selectedGroups, array $versions): array + { + $defaults = $this->getTestMailTemplateDefaults(); + $rows = []; + + foreach ($selectedGroups as $groupKey) { + if (! isset($defaults['container_groups'][$groupKey])) { + continue; + } + $group = $defaults['container_groups'][$groupKey]; + $version = trim((string) ($versions[$groupKey] ?? $group['default_version'] ?? '')); + $branch = $version !== '' ? 'release/'.$version : ''; + $exists = $branch !== '' && $this->gitBranchExists($group['db_project'], $branch); + + $rows[] = [ + 'system' => $group['system'], + 'has_database' => $exists ? '有' : '无', + 'branch' => $exists ? basename($group['db_project']).' '.$branch : '', + 'group' => $groupKey, + 'version' => $version, + ]; + } + + return $rows; + } + + private function gitBranchExists(string $repo, string $branch): bool + { + if (! is_dir($repo)) { + return false; + } + $command = sprintf('cd %s && git show-ref --verify --quiet %s', escapeshellarg($repo), escapeshellarg('refs/heads/'.$branch)); + exec($command, $output, $code); + if ($code === 0) { + return true; + } + + $command = sprintf('cd %s && git ls-remote --exit-code --heads origin %s >/dev/null 2>&1', escapeshellarg($repo), escapeshellarg($branch)); + exec($command, $output, $code); + + return $code === 0; + } + + private function formatSprintTestIssue($issue, array $dynamicFields = []): array + { + $fixVersions = []; + if (isset($issue->fields->fixVersions) && is_array($issue->fields->fixVersions)) { + foreach ($issue->fields->fixVersions as $version) { + if (isset($version->name)) { + $fixVersions[] = $version->name; + } + } + } + + $estimatedTestAtField = $dynamicFields['estimated_test_at'] ?? null; + $estimatedReleaseAtField = $dynamicFields['estimated_release_at'] ?? null; + $developerOwnerField = $dynamicFields['developer_owner'] ?? null; + + return [ + 'key' => $issue->key, + 'url' => $this->config['host'].'/browse/'.$issue->key, + 'summary' => $issue->fields->summary ?? '', + 'project_key' => $issue->fields->project->key ?? '', + 'project_name' => $issue->fields->project->name ?? '', + 'status' => $issue->fields->status->name ?? '', + 'priority' => $issue->fields->priority->name ?? '', + 'reporter' => $this->extractReporter($issue), + 'assignee' => $this->extractAssignee($issue), + 'developer' => $this->extractDeveloper($issue, $developerOwnerField, true), + 'requirement_type' => $this->extractRequirementType($issue), + 'sprint' => $this->extractSprintInfo($issue), + 'fix_versions' => $fixVersions, + 'estimated_test_at' => $estimatedTestAtField ? $this->extractCustomFieldText($issue, $estimatedTestAtField) : '', + 'estimated_release_at' => $estimatedReleaseAtField ? $this->extractCustomFieldText($issue, $estimatedReleaseAtField) : '', + ]; + } + + private function resolveTestMailDynamicFieldIds(): array + { + $fields = [ + 'estimated_test_at' => null, + 'estimated_release_at' => null, + 'developer_owner' => null, + ]; + + try { + foreach ($this->fieldService->getAllFields() as $field) { + $name = $field->name ?? ''; + $id = $field->id ?? ''; + if ($id === '') { + continue; + } + if ($fields['estimated_test_at'] === null && $this->isEstimatedTestAtFieldName($name)) { + $fields['estimated_test_at'] = $id; + } + if ($fields['estimated_release_at'] === null && preg_match('/预计.*发布|预计.*上线|发布.*时间|上线.*时间|计划.*发布|计划.*上线/u', $name)) { + $fields['estimated_release_at'] = $id; + } + if ($fields['developer_owner'] === null && $this->isDeveloperOwnerFieldName($name)) { + $fields['developer_owner'] = $id; + } + } + } catch (\Exception) { + // 字段发现失败时保持为空,页面允许后续手工校验。 + } + + return array_filter($fields); + } + + private function isEstimatedTestAtFieldName(string $name): bool + { + $normalized = mb_strtolower(preg_replace('/\s+/u', '', $name)); + + if (preg_match('/实际|完成/u', $normalized)) { + return false; + } + + return preg_match('/预计.*提测|计划.*提测/u', $normalized) === 1; + } + + private function isDeveloperOwnerFieldName(string $name): bool + { + $normalized = mb_strtolower(preg_replace('/\s+/u', '', $name)); + + return preg_match('/研发owner|开发owner|rdowner|研发负责人|开发负责人/u', $normalized) === 1; + } + + public function resolveTestMailSprintPeriod(string $sprint, array|Collection $issues = []): ?string + { + $candidates = [$sprint]; + + foreach ($issues instanceof Collection ? $issues->all() : $issues as $issue) { + if (is_array($issue)) { + $candidates[] = $issue['sprint'] ?? ''; + } elseif (is_object($issue)) { + $candidates[] = $issue->sprint ?? ''; + } + } + + foreach ($candidates as $candidate) { + $period = $this->normalizeTestMailSprintPeriod((string) $candidate); + if ($period !== null) { + return $period; + } + } + + return null; + } + + private function normalizeTestMailSprintPeriod(string $value): ?string + { + $value = trim($value); + if ($value === '') { + return null; + } + + if (preg_match('/[A-Z]+(\d{4})(中|底)迭代/iu', $value, $matches)) { + return sprintf('Sprint%s月%s', $matches[1], $matches[2]); + } + + if (preg_match('/(?:Sprint\s*)?(\d{4})\s*月\s*(中|底)/iu', $value, $matches)) { + return sprintf('Sprint%s月%s', $matches[1], $matches[2]); + } + + if (preg_match('/(?:20)?(\d{2})\s*年\s*0?([1-9]|1[0-2])\s*月\s*(中|底)/u', $value, $matches)) { + return sprintf('Sprint%s%02d月%s', $matches[1], (int) $matches[2], $matches[3]); + } + + return null; + } + + private function extractSprintOptionsFromIssue($issue): array + { + $value = $issue->fields->customFields['customfield_10004'] ?? null; + $items = is_array($value) ? $value : ($value ? [$value] : []); + $options = []; + + foreach ($items as $item) { + if (is_string($item)) { + $name = null; + $id = null; + if (preg_match('/name=([^,\]]+)/', $item, $m)) { + $name = $m[1]; + } + if (preg_match('/(?:id|sequence)=([0-9]+)/', $item, $m)) { + $id = $m[1]; + } + if ($name || $id) { + $options[] = [ + 'id' => $id ?: $name, + 'name' => $name ?: $id, + 'label' => ($id ? $id.' - ' : '').($name ?: $id), + 'period' => $name ? $this->normalizeTestMailSprintPeriod($name) : null, + ]; + } + } elseif (is_object($item)) { + $id = isset($item->id) ? (string) $item->id : (isset($item->sequence) ? (string) $item->sequence : ''); + $name = $item->name ?? $id; + if ($name || $id) { + $options[] = [ + 'id' => $id ?: $name, + 'name' => $name ?: $id, + 'label' => ($id ? $id.' - ' : '').($name ?: $id), + 'period' => $this->normalizeTestMailSprintPeriod((string) $name), + ]; + } + } + } + + return $options; + } + + private function extractReporter($issue): ?string + { + if (isset($issue->fields->reporter)) { + return $this->extractUserValue($issue->fields->reporter); + } + + return null; + } + + private function extractCustomFieldText($issue, string $fieldKey): string + { + if (! isset($issue->fields->customFields[$fieldKey])) { + return ''; + } + + $value = $issue->fields->customFields[$fieldKey]; + if ($value === null) { + return ''; + } + if (is_scalar($value)) { + return (string) $value; + } + if (is_object($value)) { + if (isset($value->value)) { + return (string) $value->value; + } + if (isset($value->name)) { + return (string) $value->name; + } + if (isset($value->displayName)) { + return (string) $value->displayName; + } + } + if (is_array($value)) { + return collect($value)->map(function ($item) { + if (is_scalar($item)) { + return (string) $item; + } + if (is_object($item)) { + return $item->value ?? $item->name ?? $item->displayName ?? ''; + } + if (is_array($item)) { + return $item['value'] ?? $item['name'] ?? $item['displayName'] ?? ''; + } + return ''; + })->filter()->implode(', '); + } + + return ''; + } + + private function extractUserValue($value): ?string + { + if (is_object($value)) { + return $value->displayName ?? $value->name ?? $value->key ?? null; + } + if (is_array($value)) { + return $value['displayName'] ?? $value['name'] ?? $value['key'] ?? null; + } + if (is_string($value) && $value !== '') { + return $value; + } + + return null; + } /** * 生成 Markdown 格式的周报 */ @@ -683,10 +1134,22 @@ class JiraService /** * 提取开发人 */ - private function extractDeveloper($issue): ?string + private function extractDeveloper($issue, ?string $developerOwnerField = null, bool $includePeopleFallback = false): ?string { + if ($developerOwnerField) { + $developer = $this->extractUserFieldName($issue, $developerOwnerField); + if ($developer) { + return $developer; + } + } + // 从customfield_11000获取开发人(User 类型字段返回对象,兼容字符串/数组) - return $this->extractUserFieldName($issue, 'customfield_11000'); + $developer = $this->extractUserFieldName($issue, 'customfield_11000'); + if ($developer || ! $includePeopleFallback) { + return $developer; + } + + return $this->extractAssignee($issue) ?: $this->extractReporter($issue); } /** @@ -710,12 +1173,16 @@ class JiraService $value = $issue->fields->customFields[$fieldKey]; if (is_object($value)) { - return $value->name ?? $value->key ?? null; + return $value->displayName ?? $value->name ?? $value->key ?? null; } if (is_array($value)) { + if (array_is_list($value)) { + return collect($value)->map(fn ($item) => $this->extractUserValue($item))->filter()->implode(', ') ?: null; + } + // JIRA 用户字段以关联数组形式返回时 - return $value['name'] ?? $value['key'] ?? null; + return $value['displayName'] ?? $value['name'] ?? $value['key'] ?? null; } if (is_string($value) && $value !== '') { diff --git a/app/Services/ProductionDiagnosisService.php b/app/Services/ProductionDiagnosisService.php new file mode 100644 index 0000000..37a4136 --- /dev/null +++ b/app/Services/ProductionDiagnosisService.php @@ -0,0 +1,564 @@ +findEntity($type, $code); + + if (!$entity) { + return [ + 'type' => $type, + 'type_label' => $this->typeLabel($type), + 'code' => $code, + 'found' => false, + 'message' => '未在 '.$this->tableFor($type).' 表中找到对应记录', + ]; + } + + $operatorCode = (string) $entity->agent_code; + $operatorAgent = $this->findAgent($operatorCode); + $checks = []; + + $checks['status'] = $this->checkStatus($type, $entity); + + if ($type === self::TYPE_CASE) { + $checks['need_pfp'] = $this->checkNeedPfp($entity); + } + + $checks['owner_agent'] = $this->checkOwnerAgent($type, $entity, $operatorCode); + $checks['credit'] = $this->checkCredit($entity, $operatorAgent, $operatorCode); + + $canProduce = collect($checks)->every(fn ($c) => ($c['pass'] ?? false) === true); + + return [ + 'type' => $type, + 'type_label' => $this->typeLabel($type), + 'code' => $code, + 'found' => true, + 'entity' => $this->normalizeEntity($type, $entity), + 'operating_agent_code' => $operatorCode, + 'operating_agent' => $operatorAgent ? [ + 'code' => (string) $operatorAgent->code, + 'name' => (string) ($operatorAgent->name ?? ''), + 'level' => isset($operatorAgent->level) ? (int) $operatorAgent->level : null, + ] : null, + 'checks' => array_values($checks), + 'can_production' => $canProduce, + ]; + } + + // ----------------------------------------------------------------- + // 实体查找 + // ----------------------------------------------------------------- + + private function findEntity(string $type, string $code): ?object + { + return match ($type) { + self::TYPE_CASE => DB::connection($this->connection) + ->table('cases') + ->where('case_code', $code) + ->where('deleted', 0) + ->first(), + self::TYPE_BUSINESS => DB::connection($this->connection) + ->table('business_documents') + ->where('code', $code) + ->where('deleted', 0) + ->first(), + self::TYPE_SALE => DB::connection($this->connection) + ->table('sale_documents') + ->where('code', $code) + ->where('deleted', 0) + ->first(), + default => null, + }; + } + + private function findAgent(string $agentCode): ?object + { + if ($agentCode === '') { + return null; + } + + return DB::connection($this->connection) + ->table('agents') + ->where('code', $agentCode) + ->where('deleted', 0) + ->first(); + } + + // ----------------------------------------------------------------- + // 各项检查 + // ----------------------------------------------------------------- + + /** + * 状态检查 - 三类单据的「期望状态」不同 + */ + private function checkStatus(string $type, object $entity): array + { + [$expectedStatus, $expectedLabel] = $this->expectedStatusFor($type); + $actualStatus = (int) $entity->status; + $pass = $actualStatus === $expectedStatus; + + return [ + 'key' => 'status', + 'label' => '状态检查', + 'pass' => $pass, + 'expected' => sprintf('%s (%d)', $expectedLabel, $expectedStatus), + 'actual' => sprintf('%s (%d)', $this->statusLabel($type, $actualStatus), $actualStatus), + 'detail' => $pass + ? '单据状态符合进产条件' + : '单据状态不在「'.$expectedLabel.'」,无法进产', + 'hint' => $pass ? null : '需等待单据流转至「'.$expectedLabel.'」后才能进产', + ]; + } + + /** + * 病例放行检查 - case.is_need_pfp > 0 + */ + private function checkNeedPfp(object $caseEntity): array + { + $isNeedPfp = (int) ($caseEntity->is_need_pfp ?? 0); + $isPfp = (int) ($caseEntity->is_pfp ?? 0); + $pass = $isNeedPfp > 0; + + $detail = $pass + ? '病例 is_need_pfp = '.$isNeedPfp.',命中放行节点' + : '病例 is_need_pfp = 0,不需要放行(非欠款病例,不会触发进产流程)'; + + return [ + 'key' => 'need_pfp', + 'label' => '放行节点检查 (is_need_pfp)', + 'pass' => $pass, + 'expected' => 'is_need_pfp > 0', + 'actual' => 'is_need_pfp = '.$isNeedPfp.', is_pfp = '.$isPfp, + 'detail' => $detail, + 'hint' => $pass ? null : '检查 stuck_payment_reason 配置以及病例账期推算逻辑', + ]; + } + + /** + * 归属代理检查 - 必须满足: + * 1) entity.agent_code === operatorCode + * 2) 结算代理表中存在 (code = entity.code, agent_code = operatorCode, deleted = 0) + */ + private function checkOwnerAgent(string $type, object $entity, string $operatorCode): array + { + $entityAgentCode = (string) $entity->agent_code; + $settlementTable = $this->settlementTableFor($type); + $entityCode = $this->primaryCodeOf($type, $entity); + + $exists = DB::connection($this->connection) + ->table($settlementTable) + ->where('code', $entityCode) + ->where('agent_code', $operatorCode) + ->where('deleted', 0) + ->exists(); + + $pass = $entityAgentCode === $operatorCode && $entityAgentCode !== '' && $exists; + + $detail = match (true) { + $entityAgentCode === '' => '归属代理 agent_code 为空,无法定位操作代理', + $entityAgentCode !== $operatorCode => '当前操作代理 '.$operatorCode.' 与单据归属代理 '.$entityAgentCode.' 不一致', + !$exists => '结算代理表 '.$settlementTable.' 中未找到 code='.$entityCode.', agent_code='.$operatorCode.' 的有效记录', + default => '操作代理为归属代理,且在结算代理表中存在有效记录', + }; + + return [ + 'key' => 'owner_agent', + 'label' => '归属代理权限', + 'pass' => $pass, + 'expected' => '操作代理 = 单据 agent_code,且在 '.$settlementTable.' 中 deleted=0 存在记录', + 'actual' => sprintf( + '单据 agent_code=%s,结算代理表存在=%s', + $entityAgentCode === '' ? '(空)' : $entityAgentCode, + $exists ? '是' : '否' + ), + 'detail' => $detail, + 'hint' => $pass ? null : '检查 '.$settlementTable.' 的记录是否被软删或代理归属是否被调整', + ]; + } + + /** + * 账期检查 - 完整复现 AgentCredit::getLastCreditAgentCode 逻辑 + * + * 通过 agent_agents 取链路(root → ... → operator),逐级查 contracts 表 + * 一级代理账期通过 CRM 接口 /api/group/detail/{code} 取得 + */ + private function checkCredit(object $entity, ?object $operatorAgent, string $operatorCode): array + { + $productCode = (string) ($entity->product_code ?? ''); + + if (!$operatorAgent) { + return [ + 'key' => 'credit', + 'label' => '账期检查', + 'pass' => false, + 'expected' => '最后一级有账期的代理 = 单据 agent_code', + 'actual' => '未找到归属代理 '.$operatorCode.' 的代理记录', + 'detail' => 'agents 表中查不到该代理,无法计算账期链路', + 'hint' => '确认 agents 表中是否存在该 code 且 deleted=0', + ]; + } + + if ($productCode === '') { + return [ + 'key' => 'credit', + 'label' => '账期检查', + 'pass' => false, + 'expected' => '存在 product_code 才能判断账期', + 'actual' => 'product_code 为空', + 'detail' => '单据未关联 product_code,账期无法判断', + 'hint' => '检查单据数据是否完整', + ]; + } + + $relation = DB::connection($this->connection) + ->table('agent_agents') + ->where('agent_code', $operatorCode) + ->where('deleted', 0) + ->first(); + + if (!$relation) { + return [ + 'key' => 'credit', + 'label' => '账期检查', + 'pass' => false, + 'expected' => '存在 agent_agents 链路', + 'actual' => 'agent_agents 中无 '.$operatorCode.' 的记录', + 'detail' => '无法构建代理链路,AgentCredit 直接返回空,账期判断必然失败', + 'hint' => '检查 agent_agents 数据是否同步', + ]; + } + + $rootAgentCode = (string) $relation->root_agent_code; + + // 取整条链路 root -> operator,按 lft 升序 + $chain = DB::connection($this->connection) + ->table('agent_agents') + ->where('root_agent_code', $rootAgentCode) + ->where('lft', '<=', (int) $relation->lft) + ->where('rgt', '>=', (int) $relation->rgt) + ->where('deleted', 0) + ->orderBy('lft') + ->get(); + + if ($chain->isEmpty()) { + return [ + 'key' => 'credit', + 'label' => '账期检查', + 'pass' => false, + 'expected' => '存在代理链路', + 'actual' => '代理链路为空', + 'detail' => '无法构建代理链路', + 'hint' => '检查 agent_agents 数据', + ]; + } + + // 一级代理账期 - 调 CRM + $firstAgentCreditMap = $this->crm->isConfigured() + ? $this->crm->firstAgentCreditMap($rootAgentCode) + : null; + + $crmConfigured = $this->crm->isConfigured(); + $firstAgentHasCredit = $firstAgentCreditMap !== null + && isset($firstAgentCreditMap[$productCode]) + && $firstAgentCreditMap[$productCode] === true; + + // 子级代理逐级取合同 is_credit + $subChain = $chain->slice(1)->values(); + $chainEvaluation = $this->evaluateChain($rootAgentCode, $subChain, $productCode, $firstAgentHasCredit); + + $lastCreditAgentCode = $chainEvaluation['last_credit_agent_code']; + $pass = $lastCreditAgentCode !== '' && $lastCreditAgentCode === $operatorCode; + + $detail = match (true) { + !$crmConfigured => 'CRM 接口未配置(CRM_SERVICE_BASE_URI),一级代理账期视为「未知」,链路计算可能与生产不一致', + $firstAgentCreditMap === null => '调用 CRM 一级代理详情失败,无法判断一级代理账期', + !$firstAgentHasCredit => '一级代理 '.$rootAgentCode.' 在产品 '.$productCode.' 上无账期,AgentCredit 返回空,账期判断必然失败', + $lastCreditAgentCode === '' => '账期链路计算结果为空', + $pass => '最后一级有账期的代理 = 单据归属代理('.$operatorCode.')', + default => '最后一级有账期的代理为 '.$lastCreditAgentCode.',与单据归属代理 '.$operatorCode.' 不一致', + }; + + return [ + 'key' => 'credit', + 'label' => '账期检查', + 'pass' => $pass, + 'expected' => '最后一级有账期的代理 = '.$operatorCode, + 'actual' => '最后一级有账期的代理 = '.($lastCreditAgentCode === '' ? '(空)' : $lastCreditAgentCode), + 'detail' => $detail, + 'hint' => $pass ? null : $this->creditHint($crmConfigured, $firstAgentCreditMap, $firstAgentHasCredit), + 'chain' => $chainEvaluation['chain'], + 'product_code' => $productCode, + 'root_agent_code' => $rootAgentCode, + 'crm_configured' => $crmConfigured, + 'first_agent_credit_resolved' => $firstAgentCreditMap !== null, + 'first_agent_has_credit' => $firstAgentHasCredit, + ]; + } + + /** + * 复现 AgentCredit::getLastCreditAgentCode 中遍历子代理的部分 + * + * @return array{last_credit_agent_code:string, chain:array>} + */ + private function evaluateChain(string $rootAgentCode, \Illuminate\Support\Collection $subChain, string $productCode, bool $firstAgentHasCredit): array + { + $chainView = []; + + // root 节点 + $rootAgent = $this->findAgent($rootAgentCode); + $chainView[] = [ + 'agent_code' => $rootAgentCode, + 'agent_name' => $rootAgent->name ?? null, + 'level' => $rootAgent ? (int) $rootAgent->level : null, + 'is_root' => true, + 'has_credit' => $firstAgentHasCredit, + 'credit_source' => 'crm:getAgentByCode', + ]; + + if (!$firstAgentHasCredit) { + return [ + 'last_credit_agent_code' => '', + 'chain' => $chainView, + ]; + } + + $lastCreditAgentCode = $rootAgentCode; + $broken = false; + + foreach ($subChain as $node) { + $agentCode = (string) $node->agent_code; + $agent = $this->findAgent($agentCode); + $hasCredit = $this->subAgentHasCredit($agentCode, $productCode); + + $chainView[] = [ + 'agent_code' => $agentCode, + 'agent_name' => $agent->name ?? null, + 'level' => $agent ? (int) $agent->level : null, + 'is_root' => false, + 'has_credit' => $hasCredit, + 'credit_source' => 'db:agent_contracts', + 'broken' => $broken, + ]; + + if ($broken) { + continue; + } + + if (!$hasCredit) { + $broken = true; + continue; + } + + $lastCreditAgentCode = $agentCode; + } + + return [ + 'last_credit_agent_code' => $lastCreditAgentCode, + 'chain' => $chainView, + ]; + } + + /** + * 子代理在某产品上是否有账期 - 复现 AgentCredit::get + * + * agent_contracts JOIN contracts WHERE contracts.status = ENABLE + * 然后取 product_code 对应的 is_credit > 0 + */ + private function subAgentHasCredit(string $agentCode, string $productCode): bool + { + // ContractModel::STATUS_ENABLE = 1 + $contracts = DB::connection($this->connection) + ->table('agent_contracts as ac') + ->join('contracts as c', 'c.id', '=', 'ac.contract_id') + ->where('ac.agent_code', $agentCode) + ->where('ac.deleted', 0) + ->where('c.deleted', 0) + ->where('c.status', 1) + ->where('c.product_code', $productCode) + ->select('c.is_credit') + ->get(); + + if ($contracts->isEmpty()) { + return false; + } + + // 与 AgentCredit::get 一致:取该 product 下最后一个值(map 覆盖) + $hasCredit = false; + foreach ($contracts as $row) { + $hasCredit = ((int) $row->is_credit) > 0; + } + + return $hasCredit; + } + + // ----------------------------------------------------------------- + // 标签与映射 + // ----------------------------------------------------------------- + + private function expectedStatusFor(string $type): array + { + return match ($type) { + // CaseEnum::STATUS_3D_CONFIRMED + self::TYPE_CASE => [12, '3D设计已确认'], + // BusinessDocumentEnum::STATUS_TO_BE_PAYMENT + self::TYPE_BUSINESS => [5, '异常暂停(款项待支付)'], + // SaleDocumentEnum::STATUS_WAIT_PERMIT + self::TYPE_SALE => [3, '待放行'], + }; + } + + private function statusLabel(string $type, int $status): string + { + $map = match ($type) { + self::TYPE_CASE => [ + 1 => '资料处理中', + 2 => '文字方案设计中', + 3 => '文字方案待确认', + 4 => '3D设计中', + 5 => '3D设计待确认', + 6 => '加工中', + 7 => '已发货', + 8 => '暂停', + 9 => '结束', + 10 => '不收治', + 11 => '文字方案已确认', + 12 => '3D设计已确认', + 20 => '目标位设计中', + 21 => '目标位待确认', + 22 => '目标位已确认', + 30 => '产品待确认', + 31 => '产品已确认', + ], + self::TYPE_BUSINESS => [ + 1 => '资料未收到', + 2 => '资料处理中', + 3 => '风险待确认', + 4 => '风险已确认', + 5 => '异常暂停(款项待支付)', + 9 => '加工中', + 10 => '已发货', + 11 => '暂停', + 12 => '终止', + ], + self::TYPE_SALE => [ + 1 => '新建', + 2 => '待付款', + 3 => '待放行', + 4 => '待发货', + 5 => '已发货', + 6 => '待审批', + 7 => '审批拒绝', + 8 => '部分发货', + ], + }; + + return $map[$status] ?? '未知状态'; + } + + private function typeLabel(string $type): string + { + return match ($type) { + self::TYPE_CASE => '病例', + self::TYPE_BUSINESS => '业务单据', + self::TYPE_SALE => '销售单据', + }; + } + + private function tableFor(string $type): string + { + return match ($type) { + self::TYPE_CASE => 'cases', + self::TYPE_BUSINESS => 'business_documents', + self::TYPE_SALE => 'sale_documents', + }; + } + + private function settlementTableFor(string $type): string + { + return match ($type) { + self::TYPE_CASE => 'settlement_agent_case', + self::TYPE_BUSINESS => 'settlement_agent_business_order', + self::TYPE_SALE => 'settlement_agent_sales_order', + }; + } + + private function primaryCodeOf(string $type, object $entity): string + { + return $type === self::TYPE_CASE ? (string) $entity->case_code : (string) $entity->code; + } + + private function normalizeEntity(string $type, object $entity): array + { + $base = [ + 'status' => (int) $entity->status, + 'status_label' => $this->statusLabel($type, (int) $entity->status), + 'agent_code' => (string) ($entity->agent_code ?? ''), + 'settlement_agent_code' => (string) ($entity->settlement_agent_code ?? ''), + 'product_code' => (string) ($entity->product_code ?? ''), + ]; + + if ($type === self::TYPE_CASE) { + return $base + [ + 'code' => (string) $entity->case_code, + 'hospital_code' => (string) ($entity->hospital_code ?? ''), + 'doctor_code' => (string) ($entity->doctor_code ?? ''), + 'patient_name' => (string) ($entity->patient_name ?? ''), + 'is_need_pfp' => (int) ($entity->is_need_pfp ?? 0), + 'is_pfp' => (int) ($entity->is_pfp ?? 0), + ]; + } + + return $base + [ + 'code' => (string) $entity->code, + 'hospital_code' => (string) ($entity->hospital_code ?? ''), + ]; + } + + private function creditHint(bool $crmConfigured, ?array $firstAgentCreditMap, bool $firstAgentHasCredit): string + { + if (!$crmConfigured) { + return '配置 .env 中的 CRM_SERVICE_BASE_URI 后可获得准确的一级代理账期判断'; + } + if ($firstAgentCreditMap === null) { + return 'CRM 接口调用失败,可查看 laravel.log'; + } + if (!$firstAgentHasCredit) { + return '需在 CRM 「集团详情」productList 中确认该产品的 agentAccountingPeriod > 0'; + } + + return '检查链路中各代理的 agent_contracts / contracts 是否启用了对应产品账期'; + } +} diff --git a/config/services.php b/config/services.php index 6635aaa..c2cae80 100644 --- a/config/services.php +++ b/config/services.php @@ -45,6 +45,11 @@ return [ 'timeout' => env('MONO_TIMEOUT', 30), ], + 'crm' => [ + 'base_uri' => env('CRM_SERVICE_BASE_URI'), + 'timeout' => (int) env('CRM_SERVICE_TIMEOUT', 15), + ], + 'dingtalk' => [ 'webhook' => env('DINGTALK_WEBHOOK'), 'secret' => env('DINGTALK_SECRET'), diff --git a/resources/js/components/admin/AdminDashboard.vue b/resources/js/components/admin/AdminDashboard.vue index 2274373..8614e77 100644 --- a/resources/js/components/admin/AdminDashboard.vue +++ b/resources/js/components/admin/AdminDashboard.vue @@ -16,12 +16,25 @@ ref="weeklyReport" /> + + + + + + + + + + + + 进产诊断 + + + + + + + 生成提测邮件 + + +
+
+
+
+
+

生成提测邮件

+

动线:Sprint → 收件人 → Jira需求 → 容器/数据库 → 八截图与九/十表格 → 预览下载

+
+
+ + + +
+
+
+
{{ step }}
+
+
+
+ +
+
+
+
1. Sprint 与邮件基础信息
+
+ + + +
+
{{ error }}
+
JQL:{{ jql }}
+
+ +
+
2. 收件人
+
+ + + +
+
+ +
+
+
3. Jira 需求表格(自动生成,{{ issues.length }} 条)
+
邮件一、需求内容直接使用此表格;已去掉第一点截图入口
+
+
+ + + + + + + + + + +
{{ h }}
{{ issue.key }}{{ issue.summary }}{{ issue.reporter || '-' }}{{ issue.status }}{{ issue.developer || '-' }}{{ issue.assignee || '-' }}{{ issue.sprint || '-' }}{{ issue.estimated_test_at || '' }}{{ issue.estimated_release_at || '' }}
请选择 Sprint 后拉取 Jira 数据
+
+
+ +
+
4. 容器部署和版本 / 数据库自动探测
+
+
+
+ + +
+
+ +
+
+
+
+ + agent / portal / portal-ticket 版本号可各自调整 +
+
+
系统是否有数据库分支
{{ row.system }}{{ row.has_database }}{{ row.branch || '-' }}
+
+
+ +
+
5. 可编辑补充内容
+
+ + + + + +
+
+ +
+
八、环境部署准备清单截图
+
+

点击这里后直接粘贴「八、环境部署准备清单」截图;当前 {{ environmentImages.length }} 张

+
+
+ +
+
+
九、测试注意事项 / 其他依赖项(表格填写)
+
+ 草稿来源:{{ draftSourceLabel }} + + +
+
+ +
+ +
+
十、已知问题与风险(表格填写)
+ +
+
+ + +
+
+ + + + + diff --git a/resources/js/components/tools/ProductionDiagnosis.vue b/resources/js/components/tools/ProductionDiagnosis.vue new file mode 100644 index 0000000..387521d --- /dev/null +++ b/resources/js/components/tools/ProductionDiagnosis.vue @@ -0,0 +1,312 @@ + + + + + diff --git a/routes/api.php b/routes/api.php index 40ec2ce..e183be3 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,9 +3,11 @@ use Illuminate\Support\Facades\Route; use App\Http\Controllers\EnvController; use App\Http\Controllers\JiraController; +use App\Http\Controllers\TestMailController; use App\Http\Controllers\LogAnalysisController; use App\Http\Controllers\MessageSyncController; use App\Http\Controllers\MessageDispatchController; +use App\Http\Controllers\ProductionDiagnosisController; use App\Http\Controllers\SqlGeneratorController; use App\Http\Controllers\Admin\AdminMetaController; use App\Http\Controllers\Admin\ConfigController; @@ -36,6 +38,11 @@ Route::prefix('sql-generator')->group(function () { Route::post('/production-countries/check', [SqlGeneratorController::class, 'checkProductionCountries']); }); +// 进产诊断 API 路由 +Route::prefix('production-diagnosis')->group(function () { + Route::post('/diagnose', [ProductionDiagnosisController::class, 'diagnose']); +}); + // JIRA API路由 Route::prefix('jira')->group(function () { Route::get('/config', [JiraController::class, 'getConfig']); @@ -44,6 +51,17 @@ Route::prefix('jira')->group(function () { Route::get('/weekly-report/download', [JiraController::class, 'downloadWeeklyReport']); }); + +// 提测邮件 API 路由 +Route::prefix('test-mail')->group(function () { + Route::get('/sprints', [TestMailController::class, 'sprints']); + Route::get('/data', [TestMailController::class, 'data']); + Route::post('/databases', [TestMailController::class, 'databases']); + Route::post('/draft-sections', [TestMailController::class, 'draftSections']); + Route::post('/open-draft', [TestMailController::class, 'openDraft'])->middleware('admin.ip'); + Route::post('/download', [TestMailController::class, 'download']); +}); + // 消息同步API路由 Route::prefix('message-sync')->group(function () { Route::post('/query', [MessageSyncController::class, 'queryMessages']); diff --git a/routes/web.php b/routes/web.php index 672969b..36988e2 100644 --- a/routes/web.php +++ b/routes/web.php @@ -9,8 +9,10 @@ Route::get('/', [AdminController::class, 'index'])->name('home'); // 前端路由 - 所有页面都通过admin框架显示 Route::get('/env', [AdminController::class, 'index'])->name('admin.env'); Route::get('/sql-generator', [AdminController::class, 'index'])->name('admin.sql-generator'); +Route::get('/production-diagnosis', [AdminController::class, 'index'])->name('admin.production-diagnosis'); Route::get('/weekly-report', [AdminController::class, 'index'])->name('admin.weekly-report'); Route::get('/worklog', [AdminController::class, 'index'])->name('admin.worklog'); +Route::get('/test-mail', [AdminController::class, 'index'])->name('admin.test-mail'); Route::get('/message-sync', [AdminController::class, 'index'])->name('admin.message-sync'); Route::get('/event-consumer-sync', [AdminController::class, 'index'])->name('admin.event-consumer-sync'); Route::get('/message-dispatch', [AdminController::class, 'index'])->name('admin.message-dispatch'); diff --git a/tests/Unit/JiraServiceTest.php b/tests/Unit/JiraServiceTest.php index 9edd908..e00da82 100644 --- a/tests/Unit/JiraServiceTest.php +++ b/tests/Unit/JiraServiceTest.php @@ -195,6 +195,57 @@ class JiraServiceTest extends TestCase $this->assertEquals('十月中需求', $result); } + public function test_resolve_test_mail_sprint_period_normalizes_sprint_name() + { + $result = $this->jiraService->resolveTestMailSprintPeriod('CRM2603中迭代'); + + $this->assertEquals('Sprint2603月中', $result); + } + + public function test_resolve_test_mail_sprint_period_uses_issue_sprint_name() + { + $issues = collect([ + ['sprint' => 'CRM2605底迭代'], + ]); + + $result = $this->jiraService->resolveTestMailSprintPeriod('2324', $issues); + + $this->assertEquals('Sprint2605月底', $result); + } + + public function test_resolve_test_mail_sprint_period_handles_year_month_name() + { + $result = $this->jiraService->resolveTestMailSprintPeriod('2026年5月中需求'); + + $this->assertEquals('Sprint2605月中', $result); + } + + public function test_estimated_test_at_field_name_does_not_match_actual_test_at() + { + $reflection = new \ReflectionClass($this->jiraService); + $method = $reflection->getMethod('isEstimatedTestAtFieldName'); + + $this->assertFalse($method->invoke($this->jiraService, '实际提测时间')); + $this->assertTrue($method->invoke($this->jiraService, '预计提测时间')); + } + + public function test_next_test_mail_version_increments_second_segment() + { + $reflection = new \ReflectionClass($this->jiraService); + $method = $reflection->getMethod('nextTestMailVersion'); + + $this->assertEquals('2.70.0.0', $method->invoke($this->jiraService, '2.69.0.0')); + } + + public function test_test_mail_template_defaults_use_next_versions() + { + $defaults = $this->jiraService->getTestMailTemplateDefaults(); + + $this->assertEquals('2.70.0.0', $defaults['container_groups']['agent']['default_version']); + $this->assertEquals('2.65.0.0', $defaults['container_groups']['portal']['default_version']); + $this->assertEquals('1.43.0.0', $defaults['container_groups']['portal-ticket']['default_version']); + } + public function test_extract_bug_stage_from_labels() { $reflection = new \ReflectionClass($this->jiraService);