#feature: add test mail generator

This commit is contained in:
2026-05-22 10:10:16 +08:00
parent 3c628eb391
commit 787a69c207
15 changed files with 2367 additions and 4 deletions
+104
View File
@@ -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);
}
}
}
+460
View File
@@ -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
View File
@@ -4,6 +4,7 @@ namespace App\Providers;
use App\Clients\AgentClient;
use App\Clients\AiClient;
use App\Clients\CrmClient;
use App\Clients\MonoClient;
use App\Clients\SlsClient;
use App\Services\AiService;
@@ -14,6 +15,7 @@ use App\Services\EnvService;
use App\Services\GitMonitorService;
use App\Services\JiraService;
use App\Services\LogAnalysisService;
use App\Services\ProductionDiagnosisService;
use App\Services\SlsService;
use Illuminate\Support\ServiceProvider;
@@ -28,6 +30,7 @@ class AppServiceProvider extends ServiceProvider
$this->app->singleton(AgentClient::class);
$this->app->singleton(MonoClient::class);
$this->app->singleton(SlsClient::class);
$this->app->singleton(CrmClient::class);
$this->app->singleton(AiClient::class, fn ($app) => new AiClient($app->make(ConfigService::class)));
// 注册 Services
@@ -40,6 +43,7 @@ class AppServiceProvider extends ServiceProvider
$this->app->singleton(AiService::class);
$this->app->singleton(CodeContextService::class);
$this->app->singleton(LogAnalysisService::class);
$this->app->singleton(ProductionDiagnosisService::class);
}
/**
+471 -4
View File
@@ -2,9 +2,11 @@
namespace App\Services;
use App\Models\Project;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use JiraRestApi\Configuration\ArrayConfiguration;
use JiraRestApi\Field\FieldService;
use JiraRestApi\Issue\IssueService;
use JiraRestApi\JiraException;
use JiraRestApi\Project\ProjectService;
@@ -15,6 +17,8 @@ class JiraService
private ProjectService $projectService;
private FieldService $fieldService;
private array $config;
public function __construct()
@@ -39,6 +43,7 @@ class JiraService
$this->issueService = new IssueService($clientConfig);
$this->projectService = new ProjectService($clientConfig);
$this->fieldService = new FieldService($clientConfig);
}
/**
@@ -167,6 +172,452 @@ class JiraService
return collect();
}
/**
* 获取 Jira Sprint 下拉选项(从最近 Story Sprint 字段中提取)
*/
public function getTestMailSprintOptions(): Collection
{
$jql = 'project in (WP, AM, TP) AND issuetype = Story AND Sprint is not EMPTY ORDER BY updated DESC';
try {
$result = $this->issueService->search($jql, 0, 80, ['summary', 'customfield_10004']);
$options = collect();
foreach (($result->issues ?? []) as $issue) {
foreach ($this->extractSprintOptionsFromIssue($issue) as $option) {
$key = $option['id'] ?: $option['name'];
if (! $options->has($key)) {
$options->put($key, $option);
}
}
}
return $options->values()->sortByDesc(function ($option) {
return (int) preg_replace('/\D+/', '', (string) ($option['id'] ?: $option['name']));
})->values();
} catch (JiraException) {
return collect();
}
}
/**
* 获取提测邮件需要的 Sprint 需求列表
*/
public function getSprintTestIssues(string $sprint): Collection
{
$sprint = trim($sprint);
if ($sprint === '') {
throw new \InvalidArgumentException('Sprint 不能为空');
}
$jql = sprintf(
'project in (WP, AM, TP) AND issuetype = Story AND status in (开发中, 测试中, 需求调研中, 需求已调研, 需求已评审, 已提测, 待上线, 需求已排期, 待提测, 产品验收) AND Sprint = %s ORDER BY priority DESC, cf[10004] ASC, key ASC',
$sprint
);
$dynamicFields = $this->resolveTestMailDynamicFieldIds();
$fields = array_values(array_unique(array_filter(array_merge([
'summary',
'status',
'project',
'issuetype',
'priority',
'assignee',
'reporter',
'fixVersions',
'customfield_10004',
'customfield_11000',
'customfield_14305',
], array_values($dynamicFields)))));
try {
$issues = collect();
$startAt = 0;
$maxResults = 100;
do {
$result = $this->issueService->search($jql, $startAt, $maxResults, $fields);
$pageIssues = $result->issues ?? [];
foreach ($pageIssues as $issue) {
$issues->push($this->formatSprintTestIssue($issue, $dynamicFields));
}
$startAt += $maxResults;
$total = $result->total ?? $issues->count();
} while ($issues->count() < $total && ! empty($pageIssues));
return $issues;
} catch (JiraException $e) {
throw new \RuntimeException('获取 Sprint 需求失败: '.$e->getMessage());
}
}
/**
* 获取提测邮件默认模板数据,来源于 ~/Documents/提测邮件.xlsx 的当前结构
*/
public function getTestMailTemplateDefaults(): array
{
$containerGroups = [
'agent' => [
'label' => 'agent',
'system' => 'PP',
'project_slug' => 'agent-be',
'db_project' => '/home/tradewind/Projects/agent-db-version',
'current_version' => $this->resolveTestMailCurrentVersion('agent-be', '2.69.0.0'),
'containers' => [
['name' => 'agent-be-aplct', 'location' => '中国&美国'],
['name' => 'agent-be-web', 'location' => '中国&美国'],
['name' => 'agent-fe-web', 'location' => '中国&美国'],
],
],
'portal' => [
'label' => 'portal',
'system' => 'SP',
'project_slug' => 'portal-be',
'db_project' => '/home/tradewind/Projects/portal-db-version',
'current_version' => $this->resolveTestMailCurrentVersion('portal-be', '2.64.0.0'),
'containers' => [
['name' => 'portal-be-aplct', 'location' => '法兰克福&中国'],
['name' => 'portal-be-web', 'location' => '法兰克福'],
['name' => 'portal-fe-web', 'location' => '法兰克福'],
],
],
'portal-ticket' => [
'label' => 'portal-ticket',
'system' => 'TP',
'project_slug' => 'ticket-be',
'db_project' => '/home/tradewind/Projects/ticket-db-version',
'current_version' => $this->resolveTestMailCurrentVersion('ticket-be', '1.42.0.0'),
'containers' => [
['name' => 'portal-ticket-be-aplct', 'location' => '法兰克福&中国'],
['name' => 'portal-ticket-be-web', 'location' => '法兰克福&中国'],
['name' => 'portal-ticket-fe-web', 'location' => '法兰克福&中国'],
],
],
];
foreach ($containerGroups as &$group) {
$group['default_version'] = $this->nextTestMailVersion($group['current_version']);
}
unset($group);
return [
'container_groups' => [
...$containerGroups,
],
'scripts_template' => [
['issue' => '', 'description' => '', 'container' => '', 'script' => '', 'owner' => ''],
],
'configs_template' => [
['issue' => '', 'system' => '', 'key' => '', 'description' => '', 'owner' => ''],
],
'risks_template' => [
['problem' => '', 'impact' => '', 'action' => ''],
],
];
}
private function resolveTestMailCurrentVersion(string $projectSlug, string $fallback): string
{
try {
$version = Project::query()
->where('slug', $projectSlug)
->value('git_current_version');
} catch (\Throwable) {
$version = null;
}
return trim((string) ($version ?: $fallback));
}
private function nextTestMailVersion(string $version): string
{
$parts = explode('.', trim($version));
if (count($parts) < 2 || ! ctype_digit($parts[1])) {
return $version;
}
$parts[1] = (string) ((int) $parts[1] + 1);
return implode('.', $parts);
}
public function buildTestMailDatabases(array $selectedGroups, array $versions): array
{
$defaults = $this->getTestMailTemplateDefaults();
$rows = [];
foreach ($selectedGroups as $groupKey) {
if (! isset($defaults['container_groups'][$groupKey])) {
continue;
}
$group = $defaults['container_groups'][$groupKey];
$version = trim((string) ($versions[$groupKey] ?? $group['default_version'] ?? ''));
$branch = $version !== '' ? 'release/'.$version : '';
$exists = $branch !== '' && $this->gitBranchExists($group['db_project'], $branch);
$rows[] = [
'system' => $group['system'],
'has_database' => $exists ? '有' : '无',
'branch' => $exists ? basename($group['db_project']).' '.$branch : '',
'group' => $groupKey,
'version' => $version,
];
}
return $rows;
}
private function gitBranchExists(string $repo, string $branch): bool
{
if (! is_dir($repo)) {
return false;
}
$command = sprintf('cd %s && git show-ref --verify --quiet %s', escapeshellarg($repo), escapeshellarg('refs/heads/'.$branch));
exec($command, $output, $code);
if ($code === 0) {
return true;
}
$command = sprintf('cd %s && git ls-remote --exit-code --heads origin %s >/dev/null 2>&1', escapeshellarg($repo), escapeshellarg($branch));
exec($command, $output, $code);
return $code === 0;
}
private function formatSprintTestIssue($issue, array $dynamicFields = []): array
{
$fixVersions = [];
if (isset($issue->fields->fixVersions) && is_array($issue->fields->fixVersions)) {
foreach ($issue->fields->fixVersions as $version) {
if (isset($version->name)) {
$fixVersions[] = $version->name;
}
}
}
$estimatedTestAtField = $dynamicFields['estimated_test_at'] ?? null;
$estimatedReleaseAtField = $dynamicFields['estimated_release_at'] ?? null;
$developerOwnerField = $dynamicFields['developer_owner'] ?? null;
return [
'key' => $issue->key,
'url' => $this->config['host'].'/browse/'.$issue->key,
'summary' => $issue->fields->summary ?? '',
'project_key' => $issue->fields->project->key ?? '',
'project_name' => $issue->fields->project->name ?? '',
'status' => $issue->fields->status->name ?? '',
'priority' => $issue->fields->priority->name ?? '',
'reporter' => $this->extractReporter($issue),
'assignee' => $this->extractAssignee($issue),
'developer' => $this->extractDeveloper($issue, $developerOwnerField, true),
'requirement_type' => $this->extractRequirementType($issue),
'sprint' => $this->extractSprintInfo($issue),
'fix_versions' => $fixVersions,
'estimated_test_at' => $estimatedTestAtField ? $this->extractCustomFieldText($issue, $estimatedTestAtField) : '',
'estimated_release_at' => $estimatedReleaseAtField ? $this->extractCustomFieldText($issue, $estimatedReleaseAtField) : '',
];
}
private function resolveTestMailDynamicFieldIds(): array
{
$fields = [
'estimated_test_at' => null,
'estimated_release_at' => null,
'developer_owner' => null,
];
try {
foreach ($this->fieldService->getAllFields() as $field) {
$name = $field->name ?? '';
$id = $field->id ?? '';
if ($id === '') {
continue;
}
if ($fields['estimated_test_at'] === null && $this->isEstimatedTestAtFieldName($name)) {
$fields['estimated_test_at'] = $id;
}
if ($fields['estimated_release_at'] === null && preg_match('/预计.*发布|预计.*上线|发布.*时间|上线.*时间|计划.*发布|计划.*上线/u', $name)) {
$fields['estimated_release_at'] = $id;
}
if ($fields['developer_owner'] === null && $this->isDeveloperOwnerFieldName($name)) {
$fields['developer_owner'] = $id;
}
}
} catch (\Exception) {
// 字段发现失败时保持为空,页面允许后续手工校验。
}
return array_filter($fields);
}
private function isEstimatedTestAtFieldName(string $name): bool
{
$normalized = mb_strtolower(preg_replace('/\s+/u', '', $name));
if (preg_match('/实际|完成/u', $normalized)) {
return false;
}
return preg_match('/预计.*提测|计划.*提测/u', $normalized) === 1;
}
private function isDeveloperOwnerFieldName(string $name): bool
{
$normalized = mb_strtolower(preg_replace('/\s+/u', '', $name));
return preg_match('/研发owner|开发owner|rdowner|研发负责人|开发负责人/u', $normalized) === 1;
}
public function resolveTestMailSprintPeriod(string $sprint, array|Collection $issues = []): ?string
{
$candidates = [$sprint];
foreach ($issues instanceof Collection ? $issues->all() : $issues as $issue) {
if (is_array($issue)) {
$candidates[] = $issue['sprint'] ?? '';
} elseif (is_object($issue)) {
$candidates[] = $issue->sprint ?? '';
}
}
foreach ($candidates as $candidate) {
$period = $this->normalizeTestMailSprintPeriod((string) $candidate);
if ($period !== null) {
return $period;
}
}
return null;
}
private function normalizeTestMailSprintPeriod(string $value): ?string
{
$value = trim($value);
if ($value === '') {
return null;
}
if (preg_match('/[A-Z]+(\d{4})(中|底)迭代/iu', $value, $matches)) {
return sprintf('Sprint%s月%s', $matches[1], $matches[2]);
}
if (preg_match('/(?:Sprint\s*)?(\d{4})\s*月\s*(中|底)/iu', $value, $matches)) {
return sprintf('Sprint%s月%s', $matches[1], $matches[2]);
}
if (preg_match('/(?:20)?(\d{2})\s*年\s*0?([1-9]|1[0-2])\s*月\s*(中|底)/u', $value, $matches)) {
return sprintf('Sprint%s%02d月%s', $matches[1], (int) $matches[2], $matches[3]);
}
return null;
}
private function extractSprintOptionsFromIssue($issue): array
{
$value = $issue->fields->customFields['customfield_10004'] ?? null;
$items = is_array($value) ? $value : ($value ? [$value] : []);
$options = [];
foreach ($items as $item) {
if (is_string($item)) {
$name = null;
$id = null;
if (preg_match('/name=([^,\]]+)/', $item, $m)) {
$name = $m[1];
}
if (preg_match('/(?:id|sequence)=([0-9]+)/', $item, $m)) {
$id = $m[1];
}
if ($name || $id) {
$options[] = [
'id' => $id ?: $name,
'name' => $name ?: $id,
'label' => ($id ? $id.' - ' : '').($name ?: $id),
'period' => $name ? $this->normalizeTestMailSprintPeriod($name) : null,
];
}
} elseif (is_object($item)) {
$id = isset($item->id) ? (string) $item->id : (isset($item->sequence) ? (string) $item->sequence : '');
$name = $item->name ?? $id;
if ($name || $id) {
$options[] = [
'id' => $id ?: $name,
'name' => $name ?: $id,
'label' => ($id ? $id.' - ' : '').($name ?: $id),
'period' => $this->normalizeTestMailSprintPeriod((string) $name),
];
}
}
}
return $options;
}
private function extractReporter($issue): ?string
{
if (isset($issue->fields->reporter)) {
return $this->extractUserValue($issue->fields->reporter);
}
return null;
}
private function extractCustomFieldText($issue, string $fieldKey): string
{
if (! isset($issue->fields->customFields[$fieldKey])) {
return '';
}
$value = $issue->fields->customFields[$fieldKey];
if ($value === null) {
return '';
}
if (is_scalar($value)) {
return (string) $value;
}
if (is_object($value)) {
if (isset($value->value)) {
return (string) $value->value;
}
if (isset($value->name)) {
return (string) $value->name;
}
if (isset($value->displayName)) {
return (string) $value->displayName;
}
}
if (is_array($value)) {
return collect($value)->map(function ($item) {
if (is_scalar($item)) {
return (string) $item;
}
if (is_object($item)) {
return $item->value ?? $item->name ?? $item->displayName ?? '';
}
if (is_array($item)) {
return $item['value'] ?? $item['name'] ?? $item['displayName'] ?? '';
}
return '';
})->filter()->implode(', ');
}
return '';
}
private function extractUserValue($value): ?string
{
if (is_object($value)) {
return $value->displayName ?? $value->name ?? $value->key ?? null;
}
if (is_array($value)) {
return $value['displayName'] ?? $value['name'] ?? $value['key'] ?? null;
}
if (is_string($value) && $value !== '') {
return $value;
}
return null;
}
/**
* 生成 Markdown 格式的周报
*/
@@ -683,10 +1134,22 @@ class JiraService
/**
* 提取开发人
*/
private function extractDeveloper($issue): ?string
private function extractDeveloper($issue, ?string $developerOwnerField = null, bool $includePeopleFallback = false): ?string
{
if ($developerOwnerField) {
$developer = $this->extractUserFieldName($issue, $developerOwnerField);
if ($developer) {
return $developer;
}
}
// 从customfield_11000获取开发人(User 类型字段返回对象,兼容字符串/数组)
return $this->extractUserFieldName($issue, 'customfield_11000');
$developer = $this->extractUserFieldName($issue, 'customfield_11000');
if ($developer || ! $includePeopleFallback) {
return $developer;
}
return $this->extractAssignee($issue) ?: $this->extractReporter($issue);
}
/**
@@ -710,12 +1173,16 @@ class JiraService
$value = $issue->fields->customFields[$fieldKey];
if (is_object($value)) {
return $value->name ?? $value->key ?? null;
return $value->displayName ?? $value->name ?? $value->key ?? null;
}
if (is_array($value)) {
if (array_is_list($value)) {
return collect($value)->map(fn ($item) => $this->extractUserValue($item))->filter()->implode(', ') ?: null;
}
// JIRA 用户字段以关联数组形式返回时
return $value['name'] ?? $value['key'] ?? null;
return $value['displayName'] ?? $value['name'] ?? $value['key'] ?? null;
}
if (is_string($value) && $value !== '') {
+564
View File
@@ -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 是否启用了对应产品账期';
}
}