#feature: add test mail generator
This commit is contained in:
@@ -101,6 +101,10 @@ AGENT_TIMEOUT=30
|
|||||||
MONO_URL=http://localhost:8081
|
MONO_URL=http://localhost:8081
|
||||||
MONO_TIMEOUT=30
|
MONO_TIMEOUT=30
|
||||||
|
|
||||||
|
# CRM Service Configuration (用于进产诊断中的一级代理账期判断)
|
||||||
|
CRM_SERVICE_BASE_URI=
|
||||||
|
CRM_SERVICE_TIMEOUT=15
|
||||||
|
|
||||||
# Git Monitor Configuration
|
# Git Monitor Configuration
|
||||||
GIT_MONITOR_PROJECTS="service,portal-be,agent-be"
|
GIT_MONITOR_PROJECTS="service,portal-be,agent-be"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Clients;
|
||||||
|
|
||||||
|
use Illuminate\Http\Client\PendingRequest;
|
||||||
|
use Illuminate\Http\Client\Response;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CRM Service HTTP 客户端
|
||||||
|
*
|
||||||
|
* 用于调用 agent-be 依赖的 CRM 服务接口,主要用于进产诊断中的一级代理账期判断。
|
||||||
|
*/
|
||||||
|
class CrmClient
|
||||||
|
{
|
||||||
|
private string $baseUrl;
|
||||||
|
|
||||||
|
private int $timeout;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Services\ProductionDiagnosisService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class ProductionDiagnosisController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly ProductionDiagnosisService $service)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单条进产诊断
|
||||||
|
*/
|
||||||
|
public function diagnose(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$validated = $request->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Services\AiService;
|
||||||
|
use App\Services\JiraService;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class TestMailController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private JiraService $jiraService,
|
||||||
|
private AiService $aiService
|
||||||
|
)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function sprints(): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->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'] ?: '万文山 <wanwenshan@angelalign.com>',
|
||||||
|
$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(['<br>', '<br/>', '<br />'], "\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)
|
||||||
|
)."'";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace App\Providers;
|
|||||||
|
|
||||||
use App\Clients\AgentClient;
|
use App\Clients\AgentClient;
|
||||||
use App\Clients\AiClient;
|
use App\Clients\AiClient;
|
||||||
|
use App\Clients\CrmClient;
|
||||||
use App\Clients\MonoClient;
|
use App\Clients\MonoClient;
|
||||||
use App\Clients\SlsClient;
|
use App\Clients\SlsClient;
|
||||||
use App\Services\AiService;
|
use App\Services\AiService;
|
||||||
@@ -14,6 +15,7 @@ use App\Services\EnvService;
|
|||||||
use App\Services\GitMonitorService;
|
use App\Services\GitMonitorService;
|
||||||
use App\Services\JiraService;
|
use App\Services\JiraService;
|
||||||
use App\Services\LogAnalysisService;
|
use App\Services\LogAnalysisService;
|
||||||
|
use App\Services\ProductionDiagnosisService;
|
||||||
use App\Services\SlsService;
|
use App\Services\SlsService;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
@@ -28,6 +30,7 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
$this->app->singleton(AgentClient::class);
|
$this->app->singleton(AgentClient::class);
|
||||||
$this->app->singleton(MonoClient::class);
|
$this->app->singleton(MonoClient::class);
|
||||||
$this->app->singleton(SlsClient::class);
|
$this->app->singleton(SlsClient::class);
|
||||||
|
$this->app->singleton(CrmClient::class);
|
||||||
$this->app->singleton(AiClient::class, fn ($app) => new AiClient($app->make(ConfigService::class)));
|
$this->app->singleton(AiClient::class, fn ($app) => new AiClient($app->make(ConfigService::class)));
|
||||||
|
|
||||||
// 注册 Services
|
// 注册 Services
|
||||||
@@ -40,6 +43,7 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
$this->app->singleton(AiService::class);
|
$this->app->singleton(AiService::class);
|
||||||
$this->app->singleton(CodeContextService::class);
|
$this->app->singleton(CodeContextService::class);
|
||||||
$this->app->singleton(LogAnalysisService::class);
|
$this->app->singleton(LogAnalysisService::class);
|
||||||
|
$this->app->singleton(ProductionDiagnosisService::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
namespace App\Services;
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use JiraRestApi\Configuration\ArrayConfiguration;
|
use JiraRestApi\Configuration\ArrayConfiguration;
|
||||||
|
use JiraRestApi\Field\FieldService;
|
||||||
use JiraRestApi\Issue\IssueService;
|
use JiraRestApi\Issue\IssueService;
|
||||||
use JiraRestApi\JiraException;
|
use JiraRestApi\JiraException;
|
||||||
use JiraRestApi\Project\ProjectService;
|
use JiraRestApi\Project\ProjectService;
|
||||||
@@ -15,6 +17,8 @@ class JiraService
|
|||||||
|
|
||||||
private ProjectService $projectService;
|
private ProjectService $projectService;
|
||||||
|
|
||||||
|
private FieldService $fieldService;
|
||||||
|
|
||||||
private array $config;
|
private array $config;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
@@ -39,6 +43,7 @@ class JiraService
|
|||||||
|
|
||||||
$this->issueService = new IssueService($clientConfig);
|
$this->issueService = new IssueService($clientConfig);
|
||||||
$this->projectService = new ProjectService($clientConfig);
|
$this->projectService = new ProjectService($clientConfig);
|
||||||
|
$this->fieldService = new FieldService($clientConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -167,6 +172,452 @@ class JiraService
|
|||||||
return collect();
|
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 格式的周报
|
* 生成 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 类型字段返回对象,兼容字符串/数组)
|
// 从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];
|
$value = $issue->fields->customFields[$fieldKey];
|
||||||
|
|
||||||
if (is_object($value)) {
|
if (is_object($value)) {
|
||||||
return $value->name ?? $value->key ?? null;
|
return $value->displayName ?? $value->name ?? $value->key ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (is_array($value)) {
|
if (is_array($value)) {
|
||||||
|
if (array_is_list($value)) {
|
||||||
|
return collect($value)->map(fn ($item) => $this->extractUserValue($item))->filter()->implode(', ') ?: null;
|
||||||
|
}
|
||||||
|
|
||||||
// JIRA 用户字段以关联数组形式返回时
|
// JIRA 用户字段以关联数组形式返回时
|
||||||
return $value['name'] ?? $value['key'] ?? null;
|
return $value['displayName'] ?? $value['name'] ?? $value['key'] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (is_string($value) && $value !== '') {
|
if (is_string($value) && $value !== '') {
|
||||||
|
|||||||
@@ -0,0 +1,564 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Clients\CrmClient;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 进产诊断服务
|
||||||
|
*
|
||||||
|
* 复刻 agent-be 中以下三处进产/放行判断逻辑,给出失败原因:
|
||||||
|
* - App\Services\AgentCase\ConfirmProduction::canProduction
|
||||||
|
* - App\Services\AgentBusinessDocument\ConfirmProduction::canProduction
|
||||||
|
* - App\Services\AgentSaleDocument\ConfirmPermit::canPermit
|
||||||
|
*
|
||||||
|
* 通过 agentslave 库直查所需表,并通过 CrmClient 调用 CRM 接口判断一级代理账期。
|
||||||
|
*/
|
||||||
|
class ProductionDiagnosisService
|
||||||
|
{
|
||||||
|
public const TYPE_CASE = 'case';
|
||||||
|
public const TYPE_BUSINESS = 'business_document';
|
||||||
|
public const TYPE_SALE = 'sale_document';
|
||||||
|
|
||||||
|
/** @var string agent-be 数据库连接名 */
|
||||||
|
private string $connection = 'agentslave';
|
||||||
|
|
||||||
|
public function __construct(private readonly CrmClient $crm)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行单次诊断
|
||||||
|
*/
|
||||||
|
public function diagnose(string $type, string $code): array
|
||||||
|
{
|
||||||
|
$code = trim($code);
|
||||||
|
$entity = $this->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<int,array<string,mixed>>}
|
||||||
|
*/
|
||||||
|
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 是否启用了对应产品账期';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,11 @@ return [
|
|||||||
'timeout' => env('MONO_TIMEOUT', 30),
|
'timeout' => env('MONO_TIMEOUT', 30),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'crm' => [
|
||||||
|
'base_uri' => env('CRM_SERVICE_BASE_URI'),
|
||||||
|
'timeout' => (int) env('CRM_SERVICE_TIMEOUT', 15),
|
||||||
|
],
|
||||||
|
|
||||||
'dingtalk' => [
|
'dingtalk' => [
|
||||||
'webhook' => env('DINGTALK_WEBHOOK'),
|
'webhook' => env('DINGTALK_WEBHOOK'),
|
||||||
'secret' => env('DINGTALK_SECRET'),
|
'secret' => env('DINGTALK_SECRET'),
|
||||||
|
|||||||
@@ -16,12 +16,25 @@
|
|||||||
ref="weeklyReport"
|
ref="weeklyReport"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 提测邮件生成页面 -->
|
||||||
|
<test-mail-generator
|
||||||
|
v-else-if="currentPage === 'test-mail'"
|
||||||
|
ref="testMailGenerator"
|
||||||
|
:is-admin="isAdmin"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- SQL 生成页面 -->
|
<!-- SQL 生成页面 -->
|
||||||
<sql-generator
|
<sql-generator
|
||||||
v-else-if="currentPage === 'sql-generator'"
|
v-else-if="currentPage === 'sql-generator'"
|
||||||
ref="sqlGenerator"
|
ref="sqlGenerator"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- 进产诊断页面 -->
|
||||||
|
<production-diagnosis
|
||||||
|
v-else-if="currentPage === 'production-diagnosis'"
|
||||||
|
ref="productionDiagnosis"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- JIRA 工时查询页面 -->
|
<!-- JIRA 工时查询页面 -->
|
||||||
<jira-worklog
|
<jira-worklog
|
||||||
v-else-if="currentPage === 'worklog'"
|
v-else-if="currentPage === 'worklog'"
|
||||||
@@ -73,7 +86,9 @@
|
|||||||
import AdminLayout from './AdminLayout.vue';
|
import AdminLayout from './AdminLayout.vue';
|
||||||
import EnvManagement from '../env/EnvManagement.vue';
|
import EnvManagement from '../env/EnvManagement.vue';
|
||||||
import WeeklyReport from '../jira/WeeklyReport.vue';
|
import WeeklyReport from '../jira/WeeklyReport.vue';
|
||||||
|
import TestMailGenerator from '../jira/TestMailGenerator.vue';
|
||||||
import SqlGenerator from '../tools/SqlGenerator.vue';
|
import SqlGenerator from '../tools/SqlGenerator.vue';
|
||||||
|
import ProductionDiagnosis from '../tools/ProductionDiagnosis.vue';
|
||||||
import JiraWorklog from '../jira/JiraWorklog.vue';
|
import JiraWorklog from '../jira/JiraWorklog.vue';
|
||||||
import MessageSync from '../message-sync/MessageSync.vue';
|
import MessageSync from '../message-sync/MessageSync.vue';
|
||||||
import EventConsumerSync from '../message-sync/EventConsumerSync.vue';
|
import EventConsumerSync from '../message-sync/EventConsumerSync.vue';
|
||||||
@@ -91,7 +106,9 @@ export default {
|
|||||||
AdminLayout,
|
AdminLayout,
|
||||||
EnvManagement,
|
EnvManagement,
|
||||||
WeeklyReport,
|
WeeklyReport,
|
||||||
|
TestMailGenerator,
|
||||||
SqlGenerator,
|
SqlGenerator,
|
||||||
|
ProductionDiagnosis,
|
||||||
JiraWorklog,
|
JiraWorklog,
|
||||||
MessageSync,
|
MessageSync,
|
||||||
EventConsumerSync,
|
EventConsumerSync,
|
||||||
@@ -142,7 +159,9 @@ export default {
|
|||||||
const titles = {
|
const titles = {
|
||||||
'env': '环境配置管理',
|
'env': '环境配置管理',
|
||||||
'weekly-report': '生成周报',
|
'weekly-report': '生成周报',
|
||||||
|
'test-mail': '生成提测邮件',
|
||||||
'sql-generator': '生成SQL',
|
'sql-generator': '生成SQL',
|
||||||
|
'production-diagnosis': '进产诊断',
|
||||||
'worklog': 'JIRA 工时查询',
|
'worklog': 'JIRA 工时查询',
|
||||||
'message-sync': '消息同步',
|
'message-sync': '消息同步',
|
||||||
'event-consumer-sync': '事件消费者同步对比',
|
'event-consumer-sync': '事件消费者同步对比',
|
||||||
@@ -166,8 +185,12 @@ export default {
|
|||||||
page = 'env';
|
page = 'env';
|
||||||
} else if (path === '/sql-generator') {
|
} else if (path === '/sql-generator') {
|
||||||
page = 'sql-generator';
|
page = 'sql-generator';
|
||||||
|
} else if (path === '/production-diagnosis') {
|
||||||
|
page = 'production-diagnosis';
|
||||||
} else if (path === '/weekly-report') {
|
} else if (path === '/weekly-report') {
|
||||||
page = 'weekly-report';
|
page = 'weekly-report';
|
||||||
|
} else if (path === '/test-mail') {
|
||||||
|
page = 'test-mail';
|
||||||
} else if (path === '/worklog') {
|
} else if (path === '/worklog') {
|
||||||
page = 'worklog';
|
page = 'worklog';
|
||||||
} else if (path === '/message-sync') {
|
} else if (path === '/message-sync') {
|
||||||
|
|||||||
@@ -120,6 +120,31 @@
|
|||||||
生成SQL
|
生成SQL
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
@click.prevent="setActiveMenu('production-diagnosis')"
|
||||||
|
:class="[
|
||||||
|
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
|
||||||
|
activeMenu === 'production-diagnosis'
|
||||||
|
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
|
||||||
|
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
:class="[
|
||||||
|
'mr-3 h-5 w-5 transition-colors duration-200',
|
||||||
|
activeMenu === 'production-diagnosis' ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
|
||||||
|
]"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6M5 5h14a2 2 0 012 2v10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2z"/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01"/>
|
||||||
|
</svg>
|
||||||
|
进产诊断
|
||||||
|
</a>
|
||||||
|
|
||||||
<!-- JIRA 相关菜单项 -->
|
<!-- JIRA 相关菜单项 -->
|
||||||
|
|
||||||
<a
|
<a
|
||||||
@@ -146,6 +171,30 @@
|
|||||||
生成周报
|
生成周报
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
@click.prevent="setActiveMenu('test-mail')"
|
||||||
|
:class="[
|
||||||
|
'group flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors duration-200',
|
||||||
|
activeMenu === 'test-mail'
|
||||||
|
? 'bg-blue-50 text-blue-700 border-r-2 border-blue-700'
|
||||||
|
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
:class="[
|
||||||
|
'mr-3 h-5 w-5 transition-colors duration-200',
|
||||||
|
activeMenu === 'test-mail' ? 'text-blue-500' : 'text-gray-400 group-hover:text-gray-500'
|
||||||
|
]"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
生成提测邮件
|
||||||
|
</a>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
href="#"
|
href="#"
|
||||||
@click.prevent="setActiveMenu('worklog')"
|
@click.prevent="setActiveMenu('worklog')"
|
||||||
@@ -417,8 +466,12 @@ export default {
|
|||||||
menu = 'env';
|
menu = 'env';
|
||||||
} else if (path === '/sql-generator') {
|
} else if (path === '/sql-generator') {
|
||||||
menu = 'sql-generator';
|
menu = 'sql-generator';
|
||||||
|
} else if (path === '/production-diagnosis') {
|
||||||
|
menu = 'production-diagnosis';
|
||||||
} else if (path === '/weekly-report') {
|
} else if (path === '/weekly-report') {
|
||||||
menu = 'weekly-report';
|
menu = 'weekly-report';
|
||||||
|
} else if (path === '/test-mail') {
|
||||||
|
menu = 'test-mail';
|
||||||
} else if (path === '/worklog') {
|
} else if (path === '/worklog') {
|
||||||
menu = 'worklog';
|
menu = 'worklog';
|
||||||
} else if (path === '/message-sync') {
|
} else if (path === '/message-sync') {
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
<template>
|
||||||
|
<div class="h-full overflow-y-auto bg-gray-50">
|
||||||
|
<div class="sticky top-0 z-20 border-b border-gray-200 bg-white/95 backdrop-blur">
|
||||||
|
<div class="px-4 py-3">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-bold text-gray-900">生成提测邮件</h1>
|
||||||
|
<p class="text-xs text-gray-500">动线:Sprint → 收件人 → Jira需求 → 容器/数据库 → 八截图与九/十表格 → 预览下载</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button @click="loadData" :disabled="loading" class="btn-primary">{{ loading ? '拉取中...' : '刷新 Jira' }}</button>
|
||||||
|
<button v-if="isAdmin" @click="openMailDraft" :disabled="draftOpening" class="btn-secondary">{{ draftOpening ? '打开中...' : '打开邮件草稿' }}</button>
|
||||||
|
<button @click="downloadEml" :disabled="downloading" class="btn-success">{{ downloading ? '生成中...' : '下载 .eml' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 grid grid-cols-2 gap-2 text-xs text-gray-600 md:grid-cols-6">
|
||||||
|
<div v-for="step in steps" :key="step" class="rounded bg-blue-50 px-2 py-1 text-blue-700">{{ step }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 p-4 2xl:grid-cols-[minmax(0,1fr)_520px]">
|
||||||
|
<main class="space-y-4">
|
||||||
|
<section class="dense-card">
|
||||||
|
<div class="dense-title">1. Sprint 与邮件基础信息</div>
|
||||||
|
<div class="grid grid-cols-1 gap-3 lg:grid-cols-12">
|
||||||
|
<label class="lg:col-span-3 compact-field">
|
||||||
|
<span>Sprint(下拉可选)</span>
|
||||||
|
<select v-model="sprint" @change="handleSprintSelection" class="control">
|
||||||
|
<option value="">请选择 Sprint</option>
|
||||||
|
<option v-for="option in sprintOptions" :key="option.id || option.name" :value="option.id || option.name">{{ option.label || option.name || option.id }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="lg:col-span-2 compact-field"><span>手工 Sprint ID</span><input v-model="sprint" class="control" placeholder="如 2324"></label>
|
||||||
|
<label class="lg:col-span-7 compact-field"><span>邮件主题</span><input v-model="subject" class="control"></label>
|
||||||
|
</div>
|
||||||
|
<div v-if="error" class="mt-3 rounded border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ error }}</div>
|
||||||
|
<div v-if="jql" class="mt-2 truncate text-xs text-gray-400" :title="jql">JQL:{{ jql }}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="dense-card">
|
||||||
|
<div class="dense-title">2. 收件人</div>
|
||||||
|
<div class="grid grid-cols-1 gap-3 xl:grid-cols-12">
|
||||||
|
<label class="xl:col-span-3 compact-field"><span>From</span><input v-model="from" class="control"></label>
|
||||||
|
<label class="xl:col-span-4 compact-field"><span>To</span><textarea v-model="to" rows="3" class="control"></textarea></label>
|
||||||
|
<label class="xl:col-span-5 compact-field"><span>Cc</span><textarea v-model="cc" rows="3" class="control"></textarea></label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="dense-card">
|
||||||
|
<div class="mb-2 flex items-center justify-between gap-3">
|
||||||
|
<div class="dense-title !mb-0">3. Jira 需求表格(自动生成,{{ issues.length }} 条)</div>
|
||||||
|
<div class="text-xs text-gray-500">邮件一、需求内容直接使用此表格;已去掉第一点截图入口</div>
|
||||||
|
</div>
|
||||||
|
<div class="max-h-[360px] overflow-auto rounded border">
|
||||||
|
<table class="dense-table min-w-[1180px]">
|
||||||
|
<thead><tr><th v-for="h in issueHeaders" :key="h">{{ h }}</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="issue in issues" :key="issue.key">
|
||||||
|
<td><a :href="issue.url" target="_blank" class="text-blue-600">{{ issue.key }}</a></td>
|
||||||
|
<td class="min-w-72">{{ issue.summary }}</td>
|
||||||
|
<td>{{ issue.reporter || '-' }}</td><td>{{ issue.status }}</td><td>{{ issue.developer || '-' }}</td><td>{{ issue.assignee || '-' }}</td><td>{{ issue.sprint || '-' }}</td><td>{{ issue.estimated_test_at || '' }}</td><td>{{ issue.estimated_release_at || '' }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="!issues.length"><td colspan="9" class="text-center text-gray-400">请选择 Sprint 后拉取 Jira 数据</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="dense-card">
|
||||||
|
<div class="dense-title">4. 容器部署和版本 / 数据库自动探测</div>
|
||||||
|
<div class="grid grid-cols-1 gap-3 xl:grid-cols-3">
|
||||||
|
<div v-for="group in containerGroups" :key="group.key" class="rounded-lg border border-gray-200 bg-gray-50 p-3">
|
||||||
|
<div class="mb-2 flex items-center justify-between gap-2">
|
||||||
|
<label class="text-sm font-semibold text-gray-800"><input type="checkbox" :checked="isGroupSelected(group.key)" @change="toggleGroup(group.key, $event.target.checked)"> {{ group.label }}</label>
|
||||||
|
<input v-model="versions[group.key]" @change="refreshDatabases" class="w-28 rounded border px-2 py-1 text-xs" placeholder="版本号">
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<label v-for="container in group.containers" :key="container.name" class="flex items-center justify-between gap-2 text-xs text-gray-700">
|
||||||
|
<span><input type="checkbox" :value="container.name" v-model="selectedContainers"> {{ container.name }}</span>
|
||||||
|
<span class="text-gray-400">{{ container.location }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex items-center justify-between gap-2">
|
||||||
|
<button @click="refreshDatabases" class="rounded bg-blue-100 px-3 py-1.5 text-sm text-blue-700 hover:bg-blue-200">刷新数据库分支</button>
|
||||||
|
<span class="text-xs text-gray-500">agent / portal / portal-ticket 版本号可各自调整</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 overflow-auto rounded border">
|
||||||
|
<table class="dense-table min-w-[520px]"><thead><tr><th>系统</th><th>是否有数据库</th><th>分支</th></tr></thead><tbody><tr v-for="row in databases" :key="row.group"><td>{{ row.system }}</td><td>{{ row.has_database }}</td><td>{{ row.branch || '-' }}</td></tr></tbody></table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="dense-card">
|
||||||
|
<div class="dense-title">5. 可编辑补充内容</div>
|
||||||
|
<div class="grid grid-cols-1 gap-3 lg:grid-cols-12">
|
||||||
|
<label class="lg:col-span-2 compact-field"><span>冒烟通过率</span><input v-model="smokeRate" class="control"></label>
|
||||||
|
<label class="lg:col-span-5 compact-field"><span>冒烟链接</span><textarea v-model="smokeUrl" rows="2" class="control" placeholder="支持多行,每行一个链接或说明"></textarea></label>
|
||||||
|
<label class="lg:col-span-5 compact-field"><span>二、技术文档</span><input v-model="techDocs" class="control" placeholder="链接或说明"></label>
|
||||||
|
<label class="lg:col-span-6 compact-field"><span>紧急需求</span><textarea v-model="urgentItems" rows="2" class="control"></textarea></label>
|
||||||
|
<label class="lg:col-span-6 compact-field"><span>延期需求</span><textarea v-model="delayedItems" rows="2" class="control"></textarea></label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="dense-card">
|
||||||
|
<div class="mb-2 flex items-center justify-between"><div class="dense-title !mb-0">八、环境部署准备清单截图</div><button @click="clearEnvironmentScreenshots" class="rounded bg-gray-100 px-2 py-1 text-xs hover:bg-gray-200">清空截图</button></div>
|
||||||
|
<div ref="environmentPasteBox" contenteditable="true" @paste="handlePaste($event, 'environment')" class="min-h-28 rounded-lg border-2 border-dashed border-green-300 bg-green-50 p-3 text-sm focus:outline-none focus:ring-2 focus:ring-green-500">
|
||||||
|
<p class="text-gray-500">点击这里后直接粘贴「八、环境部署准备清单」截图;当前 {{ environmentImages.length }} 张</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="dense-card">
|
||||||
|
<div class="mb-2 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div class="dense-title !mb-0">九、测试注意事项 / 其他依赖项(表格填写)</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span v-if="draftSource" class="text-xs text-gray-400">草稿来源:{{ draftSourceLabel }}</span>
|
||||||
|
<button @click="generateDraftSections" :disabled="draftLoading" class="rounded bg-green-100 px-2 py-1 text-xs text-green-700 hover:bg-green-200 disabled:opacity-50">{{ draftLoading ? '生成中...' : '生成九/十草稿' }}</button>
|
||||||
|
<button @click="addNoteRow" class="rounded bg-blue-100 px-2 py-1 text-xs text-blue-700 hover:bg-blue-200">新增一行</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<EditableDenseTable :headers="noteHeaders" :rows="testNoteRows" :columns="noteColumns" @remove="removeNoteRow" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="dense-card">
|
||||||
|
<div class="mb-2 flex items-center justify-between gap-2"><div class="dense-title !mb-0">十、已知问题与风险(表格填写)</div><button @click="addRiskRow" class="rounded bg-blue-100 px-2 py-1 text-xs text-blue-700 hover:bg-blue-200">新增一行</button></div>
|
||||||
|
<EditableDenseTable :headers="riskHeaders" :rows="riskRows" :columns="riskColumns" @remove="removeRiskRow" />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside class="2xl:sticky 2xl:top-[104px] 2xl:h-[calc(100vh-120px)]">
|
||||||
|
<section class="dense-card flex h-full flex-col">
|
||||||
|
<div class="mb-3 flex items-center justify-between"><div class="dense-title !mb-0">邮件预览</div><span class="text-xs text-gray-500">Thunderbird 打开前快速校验</span></div>
|
||||||
|
<div class="min-h-0 flex-1 overflow-auto rounded-md border bg-white p-4" v-html="mailHtml"></div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function normalizeTestMailSprintPeriod(value) {
|
||||||
|
const text = String(value || '').trim();
|
||||||
|
if (!text) return '';
|
||||||
|
|
||||||
|
let match = text.match(/[A-Z]+(\d{4})(中|底)迭代/iu);
|
||||||
|
if (match) return `Sprint${match[1]}月${match[2]}`;
|
||||||
|
|
||||||
|
match = text.match(/(?:Sprint\s*)?(\d{4})\s*月\s*(中|底)/iu);
|
||||||
|
if (match) return `Sprint${match[1]}月${match[2]}`;
|
||||||
|
|
||||||
|
match = text.match(/(?:20)?(\d{2})\s*年\s*0?([1-9]|1[0-2])\s*月\s*(中|底)/u);
|
||||||
|
if (match) return `Sprint${match[1]}${String(Number(match[2])).padStart(2, '0')}月${match[3]}`;
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const LAST_SPRINT_STORAGE_KEY = 'toolbox.testMail.lastSprint';
|
||||||
|
|
||||||
|
const EditableDenseTable = {
|
||||||
|
props: ['headers', 'rows', 'columns'], emits: ['remove'],
|
||||||
|
template: `<div class="overflow-auto rounded border"><table class="dense-table min-w-[900px]"><thead><tr><th v-for="h in headers" :key="h">{{ h }}</th><th class="w-14">操作</th></tr></thead><tbody><tr v-for="(row, index) in rows" :key="row._id || index"><td v-for="col in columns" :key="col.key"><select v-if="col.type === 'select'" v-model="row[col.key]" class="table-control"><option v-for="option in col.options" :key="option" :value="option">{{ option }}</option></select><textarea v-else-if="col.type === 'textarea'" v-model="row[col.key]" rows="2" class="table-control resize-y"></textarea><input v-else v-model="row[col.key]" class="table-control"></td><td><button @click="$emit('remove', index)" class="text-xs text-red-600 hover:underline">删除</button></td></tr></tbody></table></div>`
|
||||||
|
};
|
||||||
|
export default {
|
||||||
|
name: 'TestMailGenerator', components: { EditableDenseTable },
|
||||||
|
props: {
|
||||||
|
isAdmin: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data() { return {
|
||||||
|
steps: ['1 Sprint','2 收件人','3 Jira 表格','4 容器/数据库','5 八截图+九/十表格','6 下载'],
|
||||||
|
sprint: '', sprintOptions: [], loading: false, downloading: false, draftLoading: false, draftOpening: false, draftSource: '', error: '', jql: '', issues: [], defaults: {}, images: [],
|
||||||
|
from: '万文山 <wanwenshan@angelalign.com>',
|
||||||
|
to: '"ouyangxiaowen@angelalign.com" <ouyangxiaowen@angelalign.com>, "yaowenying@angelalign.com" <yaowenying@angelalign.com>, "guoziliang@angelalign.com" <guoziliang@angelalign.com>, chenhui7@angelalign.com, leyunpeng@angelalign.com',
|
||||||
|
cc: '黄宇 <huangyu@angelalign.com>, "yujie2@angelalign.com" <yujie2@angelalign.com>, "lizhongyuan@angelalign.com" <lizhongyuan@angelalign.com>, "huangfang2@angelalign.com" <huangfang2@angelalign.com>, 周国辉 <zhouguohui@angelalign.com>, "yuxinli@angelalign.com" <yuxinli@angelalign.com>, "zhangzhen3@angelalign.com" <zhangzhen3@angelalign.com>, "renzhaochun@angelalign.com" <renzhaochun@angelalign.com>, "yangyunhao@angelalign.com" <yangyunhao@angelalign.com>, "xiangshang@angelalign.com" <xiangshang@angelalign.com>, "liuyuan1@angelalign.com" <liuyuan1@angelalign.com>, zhangyuan1@angelalign.com, yangjuan1@angelalign.com, wanghe2@angelalign.com',
|
||||||
|
subject: '【提测】需求提测(SP、PP、TP)', smokeRate: '100%', smokeUrl: '', techDocs: '无', urgentItems: '无', delayedItems: '无',
|
||||||
|
selectedContainers: [], selectedGroups: [], versions: {}, databases: [],
|
||||||
|
testNoteRows: [{_id:1,type:'脚本',issue:'',system:'',content:'',owner:''},{_id:2,type:'配置项',issue:'',system:'',content:'',owner:''}],
|
||||||
|
riskRows: [{_id:1,problem:'',impact:'',action:'',owner:''}],
|
||||||
|
}; },
|
||||||
|
computed: {
|
||||||
|
issueHeaders() { return ['关键字','主题','报告人','状态','研发owner','经办人','Sprint','预计提测时间','预计发布时间']; },
|
||||||
|
noteHeaders() { return ['类型','需求/事项','系统/容器','内容(脚本、配置项、依赖说明)','负责人']; },
|
||||||
|
noteColumns() { return [{key:'type',type:'select',options:['脚本','配置项','其他依赖项','测试注意事项']},{key:'issue'},{key:'system'},{key:'content',type:'textarea'},{key:'owner'}]; },
|
||||||
|
riskHeaders() { return ['已知问题/风险','影响范围','处理方案/规避措施','负责人']; },
|
||||||
|
riskColumns() { return [{key:'problem',type:'textarea'},{key:'impact',type:'textarea'},{key:'action',type:'textarea'},{key:'owner'}]; },
|
||||||
|
containerGroups() { const groups = this.defaults.container_groups || {}; return Object.keys(groups).map(key => ({ key, ...groups[key] })); },
|
||||||
|
environmentImages() { return this.images.filter(i => i.section === 'environment'); },
|
||||||
|
selectedContainerRows() { const rows=[]; for (const group of this.containerGroups) for (const c of group.containers || []) if (this.selectedContainers.includes(c.name)) rows.push({name:c.name, version:this.versions[group.key] || group.default_version || '', location:c.location}); return rows; },
|
||||||
|
draftSourceLabel() { return this.draftSource === 'ai' ? 'AI' : '规则默认'; },
|
||||||
|
mailHtml() { return `<div style="font-family:'Microsoft YaHei UI',Arial,sans-serif;font-size:14px;color:#000;line-height:1.5">${this.section('一、需求内容')}${this.issueTableHtml()}${this.section('二、技术文档')}${this.multiline(this.techDocs)}${this.section('三、冒烟测试情况:')}${this.paragraph(`冒烟通过率:${this.escape(this.smokeRate)}`)}${this.smokeLinksHtml()}${this.section('四、计划异常情况')}${this.paragraph('紧急需求:<br> ' + this.escape(this.urgentItems).replace(/\n/g, '<br> '))}${this.paragraph('延期需求:<br> ' + this.escape(this.delayedItems).replace(/\n/g, '<br> '))}${this.section('五、容器部署和版本')}${this.simpleTable(['容器','版本号','服务器所在地'], this.selectedContainerRows, ['name','version','location'])}${this.section('六、数据库')}${this.simpleTable(['系统','是否有数据库','分支'], this.databases, ['system','has_database','branch'])}${this.section('七、是否涉及合规')}${this.paragraph(' 不涉及')}${this.section('八、环境部署准备清单:')}${this.screenshotHtml('environment')}${this.section('九、测试注意事项/其他依赖项')}${this.noteTableHtml()}${this.section('十、已知问题与风险')}${this.riskTableHtml()}</div>`; }
|
||||||
|
},
|
||||||
|
watch: { sprint(value) { this.rememberSprint(value); this.updateSubjectFromSprint(); } },
|
||||||
|
async mounted() { await this.loadSprints(); await this.loadData(); },
|
||||||
|
methods: {
|
||||||
|
csrf() { return document.querySelector('meta[name="csrf-token"]').getAttribute('content'); },
|
||||||
|
async loadSprints() { try { const data = await (await fetch('/api/test-mail/sprints')).json(); if (data.success) { this.sprintOptions = data.data.sprints || []; const savedSprint = this.restoreSprint(); if (!this.sprint && savedSprint) this.sprint = savedSprint; if (!this.sprint && this.sprintOptions.length) this.sprint = this.sprintOptions[0].id || this.sprintOptions[0].name || ''; this.defaults = data.data.defaults || {}; this.initializeContainers(); this.updateSubjectFromSprint(); } } catch (e) { console.error(e); } },
|
||||||
|
initializeContainers() { const groups = this.containerGroups; this.selectedGroups = groups.map(g => g.key); this.versions = Object.fromEntries(groups.map(g => [g.key, g.default_version || ''])); this.selectedContainers = groups.flatMap(g => (g.containers || []).map(c => c.name)); this.databases = []; this.refreshDatabases(); },
|
||||||
|
async loadData() { if (!this.sprint.trim()) { this.error = '请输入 Sprint'; return; } this.loading = true; this.error = ''; try { const data = await (await fetch(`/api/test-mail/data?sprint=${encodeURIComponent(this.sprint.trim())}`)).json(); if (!data.success) throw new Error(data.message || '加载失败'); this.issues = data.data.issues || []; this.defaults = data.data.defaults || this.defaults; this.sprintOptions = data.data.sprints || this.sprintOptions; if (!Object.keys(this.versions).length) this.initializeContainers(); this.jql = data.data.jql; this.subject = data.data.suggested_subject || this.buildSubject(this.resolveSprintPeriod() || (this.sprint.trim() ? `Sprint${this.sprint.trim()}` : '')); this.generateDraftSections(false); } catch (e) { this.error = e.message; } finally { this.loading = false; } },
|
||||||
|
handleSprintSelection() { this.updateSubjectFromSprint(); this.loadData(); },
|
||||||
|
rememberSprint(value) { try { const sprint = String(value || '').trim(); if (sprint) localStorage.setItem(LAST_SPRINT_STORAGE_KEY, sprint); else localStorage.removeItem(LAST_SPRINT_STORAGE_KEY); } catch (e) { console.error(e); } },
|
||||||
|
restoreSprint() { try { return localStorage.getItem(LAST_SPRINT_STORAGE_KEY) || ''; } catch (e) { console.error(e); return ''; } },
|
||||||
|
async refreshDatabases() { try { const data = await (await fetch('/api/test-mail/databases', { method: 'POST', headers: {'Content-Type':'application/json','X-CSRF-TOKEN':this.csrf()}, body: JSON.stringify({ selected_groups: this.selectedGroups, versions: this.versions }) })).json(); if (data.success) this.databases = data.data.databases || []; } catch (e) { console.error(e); } },
|
||||||
|
isGroupSelected(key) { return this.selectedGroups.includes(key); },
|
||||||
|
toggleGroup(key, checked) { const group = this.containerGroups.find(g => g.key === key); const names = (group?.containers || []).map(c => c.name); if (checked) { if (!this.selectedGroups.includes(key)) this.selectedGroups.push(key); this.selectedContainers = Array.from(new Set([...this.selectedContainers, ...names])); } else { this.selectedGroups = this.selectedGroups.filter(k => k !== key); this.selectedContainers = this.selectedContainers.filter(n => !names.includes(n)); } this.refreshDatabases(); },
|
||||||
|
handlePaste(event, section) { for (const item of (event.clipboardData?.items || [])) if (item.type.startsWith('image/')) { event.preventDefault(); const file = item.getAsFile(); const reader = new FileReader(); reader.onload = () => { const cid = `${section}-${Date.now()}-${this.images.length}@toolbox.local`; this.images.push({cid,section,name:file.name || `${section}-${this.images.length+1}.png`,dataUrl:reader.result}); const img=document.createElement('img'); img.src=reader.result; img.style.maxWidth='100%'; img.style.display='block'; img.style.margin='8px 0'; this.$refs.environmentPasteBox.appendChild(img); }; reader.readAsDataURL(file); } },
|
||||||
|
clearEnvironmentScreenshots() { this.images = this.images.filter(i => i.section !== 'environment'); this.$refs.environmentPasteBox.innerHTML = '<p class="text-gray-500">点击这里后直接粘贴「八、环境部署准备清单」截图</p>'; },
|
||||||
|
addNoteRow() { this.testNoteRows.push({_id:Date.now()+Math.random(),type:'其他依赖项',issue:'',system:'',content:'',owner:''}); }, removeNoteRow(i) { this.testNoteRows.splice(i,1); if (!this.testNoteRows.length) this.addNoteRow(); },
|
||||||
|
addRiskRow() { this.riskRows.push({_id:Date.now()+Math.random(),problem:'',impact:'',action:'',owner:''}); }, removeRiskRow(i) { this.riskRows.splice(i,1); if (!this.riskRows.length) this.addRiskRow(); },
|
||||||
|
async generateDraftSections(showErrors = true) { this.draftLoading = true; if (showErrors) this.error = ''; try { const res = await fetch('/api/test-mail/draft-sections', { method:'POST', headers:{'Content-Type':'application/json','X-CSRF-TOKEN':this.csrf()}, body:JSON.stringify({ issues:this.issues, tech_docs:this.techDocs, selected_containers:this.selectedContainers, databases:this.databases }) }); const data = await res.json(); if (!data.success) throw new Error(data.message || '生成草稿失败'); this.testNoteRows = (data.data.test_notes || []).map((row, index) => ({_id:Date.now()+index, type:row.type || '测试注意事项', issue:row.issue || '', system:row.system || '', content:row.content || '', owner:row.owner || ''})); this.riskRows = (data.data.risks || []).map((row, index) => ({_id:Date.now()+100+index, problem:row.problem || '', impact:row.impact || '', action:row.action || '', owner:row.owner || ''})); if (!this.testNoteRows.length) this.addNoteRow(); if (!this.riskRows.length) this.addRiskRow(); this.draftSource = data.data.source || 'rules'; } catch(e) { if (showErrors) this.error = e.message; else console.error(e); } finally { this.draftLoading = false; } },
|
||||||
|
updateSubjectFromSprint() { const sprint = this.sprint.trim(); this.subject = this.buildSubject(this.resolveSprintPeriod() || (sprint ? `Sprint${sprint}` : '')); },
|
||||||
|
buildSubject(period) { return `【提测】${period ? period : ''}需求提测(SP、PP、TP)`; },
|
||||||
|
resolveSprintPeriod() { const option = this.selectedSprintOption(); if (option?.period) return option.period; const candidates = [this.sprint, option?.name, option?.label, ...this.issues.map(i => i.sprint || '')]; for (const candidate of candidates) { const period = normalizeTestMailSprintPeriod(candidate); if (period) return period; } return ''; },
|
||||||
|
selectedSprintOption() { return this.sprintOptions.find(o => String(o.id || o.name) === String(this.sprint)); },
|
||||||
|
async openMailDraft() { if (this.draftOpening) return; const to = this.extractEmails(this.to); if (!to.length) { this.error = '请先填写收件人'; return; } this.draftOpening = true; this.error = ''; const text = this.plainText(); try { const res = await fetch('/api/test-mail/open-draft', { method:'POST', headers:{'Content-Type':'application/json','X-CSRF-TOKEN':this.csrf()}, body:JSON.stringify({subject:this.subject,from:this.from,to:this.to,cc:this.cc,html:this.mailHtml,text,images:this.images}) }); const data = await res.json().catch(() => ({})); if (!res.ok || !data.success) throw new Error(data.message || 'Thunderbird 打开失败'); this.error = data.message || '已向 Thunderbird 发送打开完整邮件草稿请求'; } catch (e) { await this.copyDraftBody(text); this.openMailtoFallback(text); } finally { setTimeout(() => { this.draftOpening = false; }, 1500); } },
|
||||||
|
async copyDraftBody(text) { try { await navigator.clipboard?.writeText(text); this.error = '无法直接写入正文,已复制正文到剪贴板;草稿打开后请粘贴。'; } catch (e) { this.error = '无法直接写入正文;请使用下载 .eml 获取完整邮件。'; } },
|
||||||
|
openMailtoFallback(text) { const to = this.extractEmails(this.to); const cc = this.extractEmails(this.cc); const body = text.length > 1200 ? '正文已复制到剪贴板,请在此处粘贴。' : text; const params = new URLSearchParams({ subject:this.subject, body }); if (cc.length) params.set('cc', cc.join(',')); window.location.href = `mailto:${to.join(',')}?${params.toString()}`; },
|
||||||
|
extractEmails(value) { return Array.from(new Set(String(value || '').match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi) || [])); },
|
||||||
|
async downloadEml() { this.downloading = true; this.error = ''; try { const res = await fetch('/api/test-mail/download', { method:'POST', headers:{'Content-Type':'application/json','X-CSRF-TOKEN':this.csrf()}, body:JSON.stringify({subject:this.subject,from:this.from,to:this.to,cc:this.cc,html:this.mailHtml,text:this.plainText(),images:this.images}) }); if (!res.ok) throw new Error(await res.text()); const blob=await res.blob(); const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url; a.download=`${this.subject.replace(/[\\/:*?"<>|]/g,'_')}.eml`; a.click(); URL.revokeObjectURL(url); } catch(e) { this.error='生成邮件失败: '+e.message; } finally { this.downloading=false; } },
|
||||||
|
section(t){return `<h1 style="font-size:14px;margin:16px 0 6px 0;font-weight:bold">${this.escape(t)}</h1>`;}, paragraph(t){return `<div style="margin:0 0 8px 0">${t || '无'}</div>`;}, multiline(t){return `<div style="margin:0 0 8px 0;white-space:pre-wrap">${this.escape(t || '无')}</div>`;}, smokeLinksHtml(){const lines=String(this.smokeUrl||'').split(/\r?\n/).map(line=>line.trim()).filter(Boolean); if(!lines.length)return ''; return `<div style="margin:0 0 8px 0">${lines.map(line=>/^https?:\/\//i.test(line)?`<div><a href="${this.escapeAttr(line)}">${this.escape(line)}</a></div>`:`<div>${this.escape(line)}</div>`).join('')}</div>`;}, screenshotHtml(s){return this.images.filter(i=>i.section===s).map(i=>`<div><img src="cid:${this.escapeAttr(i.cid)}" style="max-width:100%;height:auto" alt=""></div>`).join('') || '<div>无</div>';},
|
||||||
|
issueTableHtml(){return this.simpleTable(this.issueHeaders,this.issues.map(i=>({key:`<a href="${this.escapeAttr(i.url)}">${this.escape(i.key)}</a>`,summary:this.escape(i.summary),reporter:this.escape(i.reporter || ''),status:this.escape(i.status),developer:this.escape(i.developer || ''),assignee:this.escape(i.assignee || ''),sprint:this.escape(i.sprint || ''),estimated_test_at:this.escape(i.estimated_test_at || ''),estimated_release_at:this.escape(i.estimated_release_at || '')})),['key','summary','reporter','status','developer','assignee','sprint','estimated_test_at','estimated_release_at'],true);},
|
||||||
|
noteTableHtml(){const rows=this.testNoteRows.filter(r=>['issue','system','content','owner'].some(k=>String(r[k]??'').trim())); return this.simpleTable(this.noteHeaders,rows,['type','issue','system','content','owner']);}, riskTableHtml(){const rows=this.riskRows.filter(r=>['problem','impact','action','owner'].some(k=>String(r[k]??'').trim())); return this.simpleTable(this.riskHeaders,rows,['problem','impact','action','owner']);},
|
||||||
|
simpleTable(headers, rows, keys, trusted=false){if(!rows.length)return '<div>无</div>'; return `<table border="1" cellspacing="0" cellpadding="4" style="border-collapse:collapse;font-size:14px;margin:6px 0 14px 0"><thead><tr>${headers.map(h=>`<th style="background:#f2f2f2">${this.escape(h)}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${keys.map(k=>`<td>${trusted?(r[k]||''):this.escape(String(r[k]??'')).replace(/\n/g,'<br>')}</td>`).join('')}</tr>`).join('')}</tbody></table>`;},
|
||||||
|
plainText(){return this.mailHtml.replace(/<br\s*\/?>(\s*)/gi,'\n').replace(/<[^>]+>/g,'').replace(/ /g,' ').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}, escape(v){return String(v??'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));}, escapeAttr(v){return this.escape(v).replace(/'/g,''');}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.btn-primary { border-radius: 0.375rem; background: #2563eb; padding: 0.5rem 1rem; font-size: 0.875rem; font-weight: 500; color: white; }
|
||||||
|
.btn-primary:hover { background: #1d4ed8; }
|
||||||
|
.btn-primary:disabled { opacity: .5; }
|
||||||
|
.btn-secondary { border-radius: 0.375rem; background: #f3f4f6; padding: 0.5rem 1rem; font-size: 0.875rem; font-weight: 500; color: #374151; }
|
||||||
|
.btn-secondary:hover { background: #e5e7eb; }
|
||||||
|
.btn-success { border-radius: 0.375rem; background: #16a34a; padding: 0.5rem 1rem; font-size: 0.875rem; font-weight: 500; color: white; }
|
||||||
|
.btn-success:hover { background: #15803d; }
|
||||||
|
.btn-success:disabled { opacity: .5; }
|
||||||
|
.dense-card { border-radius: 0.75rem; border: 1px solid #e5e7eb; background: #fff; padding: 1rem; box-shadow: 0 1px 2px rgba(0,0,0,.04); }
|
||||||
|
.dense-title { margin-bottom: .75rem; font-size: .875rem; font-weight: 600; color: #111827; }
|
||||||
|
.compact-field { display: block; font-size: .75rem; font-weight: 500; color: #4b5563; }
|
||||||
|
.control { margin-top: .25rem; width: 100%; border-radius: .375rem; border: 1px solid #d1d5db; padding: .375rem .5rem; font-size: .875rem; color: #111827; }
|
||||||
|
.control:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 1px #3b82f6; }
|
||||||
|
.dense-table { width: 100%; border-collapse: collapse; background: #fff; font-size: .75rem; }
|
||||||
|
.dense-table th { position: sticky; top: 0; border-right: 1px solid #e5e7eb; border-bottom: 1px solid #e5e7eb; background: #f3f4f6; padding: .375rem .5rem; text-align: left; font-weight: 600; color: #374151; }
|
||||||
|
.dense-table td { border-right: 1px solid #f3f4f6; border-bottom: 1px solid #f3f4f6; padding: .375rem .5rem; vertical-align: top; color: #1f2937; }
|
||||||
|
.table-control { width: 100%; border-radius: .25rem; border: 1px solid #e5e7eb; padding: .25rem .375rem; font-size: .75rem; }
|
||||||
|
.table-control:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 1px #3b82f6; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,312 @@
|
|||||||
|
<template>
|
||||||
|
<div class="production-diagnosis h-full flex flex-col p-4 box-border gap-4 overflow-y-auto">
|
||||||
|
<!-- 输入区 -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 flex flex-col gap-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-sm lg:text-base font-semibold text-gray-900 flex items-center">
|
||||||
|
<svg class="w-4 h-4 text-blue-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
进产诊断
|
||||||
|
</h2>
|
||||||
|
<span class="text-xs text-gray-500">输入病例 / 业务单据 / 销售单据编号,查找无法进产的原因</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-[160px_1fr_auto] gap-3">
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">单据类型</label>
|
||||||
|
<select
|
||||||
|
v-model="type"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm bg-white"
|
||||||
|
>
|
||||||
|
<option v-for="opt in typeOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-xs font-medium text-gray-600 mb-1">单据编号</label>
|
||||||
|
<input
|
||||||
|
v-model.trim="code"
|
||||||
|
type="text"
|
||||||
|
:placeholder="currentPlaceholder"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm font-mono"
|
||||||
|
@keyup.enter="diagnose"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="clearAll"
|
||||||
|
class="px-3 py-2 text-xs text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="diagnose"
|
||||||
|
:disabled="loading || !code"
|
||||||
|
class="px-3 py-2 text-xs text-white bg-blue-600 rounded-lg hover:bg-blue-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
<span v-if="loading">诊断中...</span>
|
||||||
|
<span v-else>开始诊断</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-xs text-gray-500">
|
||||||
|
进产逻辑参考 agent-be:
|
||||||
|
<code class="font-mono">AgentCase\\ConfirmProduction::canProduction</code> /
|
||||||
|
<code class="font-mono">AgentBusinessDocument\\ConfirmProduction::canProduction</code> /
|
||||||
|
<code class="font-mono">AgentSaleDocument\\ConfirmPermit::canPermit</code>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 错误 -->
|
||||||
|
<div v-if="errorMessage" class="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">
|
||||||
|
{{ errorMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 结果 -->
|
||||||
|
<div v-if="result" class="flex flex-col gap-4">
|
||||||
|
<!-- 概览 -->
|
||||||
|
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
|
||||||
|
<div class="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border"
|
||||||
|
:class="overallBadgeClass"
|
||||||
|
>
|
||||||
|
{{ overallBadge }}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm font-semibold text-gray-900">
|
||||||
|
{{ result.type_label }} · {{ result.code }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span v-if="result.found && result.entity" class="text-xs text-gray-500">
|
||||||
|
状态: {{ result.entity.status_label }} ({{ result.entity.status }})
|
||||||
|
<span class="mx-2 text-gray-300">|</span>
|
||||||
|
归属代理: {{ result.entity.agent_code || '-' }}
|
||||||
|
<span class="mx-2 text-gray-300">|</span>
|
||||||
|
产品: {{ result.entity.product_code || '-' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!result.found" class="mt-3 text-sm text-gray-600">
|
||||||
|
{{ result.message }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="result.entity" class="mt-3 grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
||||||
|
<div v-for="field in entityFields" :key="field.key" class="bg-gray-50 rounded-lg p-2">
|
||||||
|
<div class="text-gray-500">{{ field.label }}</div>
|
||||||
|
<div class="font-mono text-gray-900 break-all">{{ field.value || '-' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 检查项列表 -->
|
||||||
|
<div v-if="result.found && result.checks" class="space-y-3">
|
||||||
|
<div
|
||||||
|
v-for="check in result.checks"
|
||||||
|
:key="check.key"
|
||||||
|
class="bg-white rounded-xl shadow-sm border p-4"
|
||||||
|
:class="check.pass ? 'border-emerald-200' : 'border-red-200'"
|
||||||
|
>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="flex items-start gap-3 min-w-0">
|
||||||
|
<span
|
||||||
|
class="mt-0.5 inline-flex items-center justify-center w-6 h-6 rounded-full flex-shrink-0 text-xs font-bold"
|
||||||
|
:class="check.pass ? 'bg-emerald-100 text-emerald-700' : 'bg-red-100 text-red-700'"
|
||||||
|
>
|
||||||
|
{{ check.pass ? '✓' : '✗' }}
|
||||||
|
</span>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-sm font-semibold text-gray-900">{{ check.label }}</div>
|
||||||
|
<div class="text-xs text-gray-600 mt-1">{{ check.detail }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-2 py-0.5 rounded text-[11px] font-medium flex-shrink-0"
|
||||||
|
:class="check.pass ? 'bg-emerald-50 text-emerald-700' : 'bg-red-50 text-red-700'"
|
||||||
|
>
|
||||||
|
{{ check.pass ? '通过' : '未通过' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 grid grid-cols-1 md:grid-cols-2 gap-2 text-xs">
|
||||||
|
<div class="bg-gray-50 rounded p-2">
|
||||||
|
<div class="text-gray-500">期望</div>
|
||||||
|
<div class="font-mono text-gray-900 break-all">{{ check.expected }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 rounded p-2">
|
||||||
|
<div class="text-gray-500">实际</div>
|
||||||
|
<div class="font-mono text-gray-900 break-all">{{ check.actual }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!check.pass && check.hint" class="mt-2 text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded p-2">
|
||||||
|
提示:{{ check.hint }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 账期链路展示 -->
|
||||||
|
<div v-if="check.key === 'credit' && check.chain && check.chain.length" class="mt-3">
|
||||||
|
<div class="text-xs text-gray-500 mb-1">
|
||||||
|
代理账期链路 (产品 {{ check.product_code || '-' }})
|
||||||
|
<span v-if="!check.crm_configured" class="ml-2 text-amber-700">
|
||||||
|
· CRM 未配置,一级账期视为未知
|
||||||
|
</span>
|
||||||
|
<span v-else-if="!check.first_agent_credit_resolved" class="ml-2 text-amber-700">
|
||||||
|
· CRM 调用失败
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
<table class="w-full text-xs">
|
||||||
|
<thead class="bg-gray-50 text-gray-600">
|
||||||
|
<tr>
|
||||||
|
<th class="px-2 py-1 text-left font-medium">层级</th>
|
||||||
|
<th class="px-2 py-1 text-left font-medium">代理编号</th>
|
||||||
|
<th class="px-2 py-1 text-left font-medium">代理名称</th>
|
||||||
|
<th class="px-2 py-1 text-left font-medium">是否有账期</th>
|
||||||
|
<th class="px-2 py-1 text-left font-medium">来源</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="(node, idx) in check.chain"
|
||||||
|
:key="idx"
|
||||||
|
:class="[
|
||||||
|
node.broken ? 'bg-gray-50 text-gray-400' : '',
|
||||||
|
idx === check.chain.length - 1 ? 'border-t border-gray-100' : 'border-t border-gray-100'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<td class="px-2 py-1 font-mono">
|
||||||
|
{{ node.is_root ? '一级' : (node.level != null ? node.level + '级' : '-') }}
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-1 font-mono">{{ node.agent_code }}</td>
|
||||||
|
<td class="px-2 py-1">{{ node.agent_name || '-' }}</td>
|
||||||
|
<td class="px-2 py-1">
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center px-1.5 py-0.5 rounded text-[11px]"
|
||||||
|
:class="node.has_credit ? 'bg-emerald-100 text-emerald-700' : 'bg-red-100 text-red-700'"
|
||||||
|
>
|
||||||
|
{{ node.has_credit ? '有' : '无' }}
|
||||||
|
</span>
|
||||||
|
<span v-if="node.broken" class="ml-2 text-gray-400">(链路已中断)</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-1 font-mono text-gray-500">{{ node.credit_source }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'ProductionDiagnosis',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
type: 'case',
|
||||||
|
code: '',
|
||||||
|
loading: false,
|
||||||
|
errorMessage: '',
|
||||||
|
result: null,
|
||||||
|
typeOptions: [
|
||||||
|
{ value: 'case', label: '病例', placeholder: '示例: C01008446046 / X0X60F' },
|
||||||
|
{ value: 'business_document', label: '业务单据', placeholder: '示例: B12345678 / LX5KP3' },
|
||||||
|
{ value: 'sale_document', label: '销售单据', placeholder: '示例: 销售单据 code' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
currentPlaceholder() {
|
||||||
|
const opt = this.typeOptions.find((o) => o.value === this.type);
|
||||||
|
return opt ? opt.placeholder : '';
|
||||||
|
},
|
||||||
|
overallBadge() {
|
||||||
|
if (!this.result) return '';
|
||||||
|
if (!this.result.found) return '未找到';
|
||||||
|
return this.result.can_production ? '可以进产' : '不能进产';
|
||||||
|
},
|
||||||
|
overallBadgeClass() {
|
||||||
|
if (!this.result) return '';
|
||||||
|
if (!this.result.found) return 'bg-gray-50 text-gray-600 border-gray-200';
|
||||||
|
return this.result.can_production
|
||||||
|
? 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
||||||
|
: 'bg-red-50 text-red-700 border-red-200';
|
||||||
|
},
|
||||||
|
entityFields() {
|
||||||
|
if (!this.result || !this.result.entity) return [];
|
||||||
|
const e = this.result.entity;
|
||||||
|
const base = [
|
||||||
|
{ key: 'code', label: '编号', value: e.code },
|
||||||
|
{ key: 'agent_code', label: '归属代理 (agent_code)', value: e.agent_code },
|
||||||
|
{ key: 'settlement_agent_code', label: '结算代理', value: e.settlement_agent_code },
|
||||||
|
{ key: 'product_code', label: '产品编号', value: e.product_code }
|
||||||
|
];
|
||||||
|
if (this.result.type === 'case') {
|
||||||
|
base.push(
|
||||||
|
{ key: 'hospital_code', label: '机构编号', value: e.hospital_code },
|
||||||
|
{ key: 'doctor_code', label: '医生编号', value: e.doctor_code },
|
||||||
|
{ key: 'patient_name', label: '患者姓名', value: e.patient_name },
|
||||||
|
{ key: 'is_need_pfp', label: 'is_need_pfp', value: e.is_need_pfp },
|
||||||
|
{ key: 'is_pfp', label: 'is_pfp', value: e.is_pfp }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
base.push({ key: 'hospital_code', label: '机构编号', value: e.hospital_code });
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
clearAll() {
|
||||||
|
this.code = '';
|
||||||
|
this.result = null;
|
||||||
|
this.errorMessage = '';
|
||||||
|
},
|
||||||
|
async diagnose() {
|
||||||
|
if (!this.code) {
|
||||||
|
this.errorMessage = '请输入单据编号';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.loading = true;
|
||||||
|
this.errorMessage = '';
|
||||||
|
this.result = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const csrfMeta = document.querySelector('meta[name="csrf-token"]');
|
||||||
|
const response = await fetch('/api/production-diagnosis/diagnose', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Accept: 'application/json',
|
||||||
|
'X-CSRF-TOKEN': csrfMeta ? csrfMeta.getAttribute('content') : ''
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ type: this.type, code: this.code })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || !data.success) {
|
||||||
|
this.errorMessage = data.message || '诊断失败,请稍后重试';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.result = data.data;
|
||||||
|
} catch (err) {
|
||||||
|
this.errorMessage = '网络请求失败: ' + err.message;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.production-diagnosis {
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,9 +3,11 @@
|
|||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use App\Http\Controllers\EnvController;
|
use App\Http\Controllers\EnvController;
|
||||||
use App\Http\Controllers\JiraController;
|
use App\Http\Controllers\JiraController;
|
||||||
|
use App\Http\Controllers\TestMailController;
|
||||||
use App\Http\Controllers\LogAnalysisController;
|
use App\Http\Controllers\LogAnalysisController;
|
||||||
use App\Http\Controllers\MessageSyncController;
|
use App\Http\Controllers\MessageSyncController;
|
||||||
use App\Http\Controllers\MessageDispatchController;
|
use App\Http\Controllers\MessageDispatchController;
|
||||||
|
use App\Http\Controllers\ProductionDiagnosisController;
|
||||||
use App\Http\Controllers\SqlGeneratorController;
|
use App\Http\Controllers\SqlGeneratorController;
|
||||||
use App\Http\Controllers\Admin\AdminMetaController;
|
use App\Http\Controllers\Admin\AdminMetaController;
|
||||||
use App\Http\Controllers\Admin\ConfigController;
|
use App\Http\Controllers\Admin\ConfigController;
|
||||||
@@ -36,6 +38,11 @@ Route::prefix('sql-generator')->group(function () {
|
|||||||
Route::post('/production-countries/check', [SqlGeneratorController::class, 'checkProductionCountries']);
|
Route::post('/production-countries/check', [SqlGeneratorController::class, 'checkProductionCountries']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 进产诊断 API 路由
|
||||||
|
Route::prefix('production-diagnosis')->group(function () {
|
||||||
|
Route::post('/diagnose', [ProductionDiagnosisController::class, 'diagnose']);
|
||||||
|
});
|
||||||
|
|
||||||
// JIRA API路由
|
// JIRA API路由
|
||||||
Route::prefix('jira')->group(function () {
|
Route::prefix('jira')->group(function () {
|
||||||
Route::get('/config', [JiraController::class, 'getConfig']);
|
Route::get('/config', [JiraController::class, 'getConfig']);
|
||||||
@@ -44,6 +51,17 @@ Route::prefix('jira')->group(function () {
|
|||||||
Route::get('/weekly-report/download', [JiraController::class, 'downloadWeeklyReport']);
|
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路由
|
// 消息同步API路由
|
||||||
Route::prefix('message-sync')->group(function () {
|
Route::prefix('message-sync')->group(function () {
|
||||||
Route::post('/query', [MessageSyncController::class, 'queryMessages']);
|
Route::post('/query', [MessageSyncController::class, 'queryMessages']);
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ Route::get('/', [AdminController::class, 'index'])->name('home');
|
|||||||
// 前端路由 - 所有页面都通过admin框架显示
|
// 前端路由 - 所有页面都通过admin框架显示
|
||||||
Route::get('/env', [AdminController::class, 'index'])->name('admin.env');
|
Route::get('/env', [AdminController::class, 'index'])->name('admin.env');
|
||||||
Route::get('/sql-generator', [AdminController::class, 'index'])->name('admin.sql-generator');
|
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('/weekly-report', [AdminController::class, 'index'])->name('admin.weekly-report');
|
||||||
Route::get('/worklog', [AdminController::class, 'index'])->name('admin.worklog');
|
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('/message-sync', [AdminController::class, 'index'])->name('admin.message-sync');
|
||||||
Route::get('/event-consumer-sync', [AdminController::class, 'index'])->name('admin.event-consumer-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');
|
Route::get('/message-dispatch', [AdminController::class, 'index'])->name('admin.message-dispatch');
|
||||||
|
|||||||
@@ -195,6 +195,57 @@ class JiraServiceTest extends TestCase
|
|||||||
$this->assertEquals('十月中需求', $result);
|
$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()
|
public function test_extract_bug_stage_from_labels()
|
||||||
{
|
{
|
||||||
$reflection = new \ReflectionClass($this->jiraService);
|
$reflection = new \ReflectionClass($this->jiraService);
|
||||||
|
|||||||
Reference in New Issue
Block a user