#feature: some update
This commit is contained in:
@@ -92,10 +92,12 @@ class JenkinsClient
|
||||
];
|
||||
}
|
||||
|
||||
$path = $this->getJobPath($jobName).(empty($parameters) ? '/build' : '/buildWithParameters');
|
||||
$path = $this->getJobPath($jobName).'/build?delay=0sec';
|
||||
$url = $this->host.$path;
|
||||
|
||||
try {
|
||||
$jobInfo = $this->getJobInfo($jobName);
|
||||
$nextBuildNumber = isset($jobInfo['nextBuildNumber']) ? (int) $jobInfo['nextBuildNumber'] : null;
|
||||
$request = $this->http();
|
||||
$crumb = $this->getCrumb();
|
||||
if ($crumb) {
|
||||
@@ -104,12 +106,20 @@ class JenkinsClient
|
||||
|
||||
$response = empty($parameters)
|
||||
? $request->post($url)
|
||||
: $request->asForm()->post($url, $parameters);
|
||||
: $request->asForm()->post($url, [
|
||||
'json' => json_encode([
|
||||
'parameter' => $this->buildFormParameters($parameters),
|
||||
'statusCode' => '303',
|
||||
'redirectTo' => '.',
|
||||
], JSON_UNESCAPED_UNICODE),
|
||||
'Submit' => 'Build',
|
||||
]);
|
||||
|
||||
if ($response->successful() || $response->status() === 201) {
|
||||
return [
|
||||
'success' => true,
|
||||
'queue_url' => $response->header('Location'),
|
||||
'build_number' => $nextBuildNumber,
|
||||
'status' => $response->status(),
|
||||
];
|
||||
}
|
||||
@@ -158,6 +168,143 @@ class JenkinsClient
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function getBuildStatus(string $jobName, ?string $queueUrl = null, ?int $buildNumber = null): array
|
||||
{
|
||||
if (! $this->isConfigured()) {
|
||||
Log::warning('Jenkins client is not configured');
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'status' => 'UNKNOWN',
|
||||
'message' => 'Jenkins not configured',
|
||||
];
|
||||
}
|
||||
|
||||
$queueItem = null;
|
||||
if ($queueUrl && ! $buildNumber) {
|
||||
$queueItem = $this->getQueueItem($queueUrl);
|
||||
if (! $queueItem) {
|
||||
return [
|
||||
'success' => false,
|
||||
'status' => 'UNKNOWN',
|
||||
'queue_url' => $queueUrl,
|
||||
'message' => '无法获取 Jenkins 队列状态',
|
||||
];
|
||||
}
|
||||
|
||||
if ($queueItem['cancelled'] ?? false) {
|
||||
return [
|
||||
'success' => true,
|
||||
'status' => 'ABORTED',
|
||||
'result' => 'ABORTED',
|
||||
'completed' => true,
|
||||
'queue_url' => $queueUrl,
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($queueItem['executable']['number'])) {
|
||||
return [
|
||||
'success' => true,
|
||||
'status' => 'PENDING',
|
||||
'building' => true,
|
||||
'completed' => false,
|
||||
'queue_url' => $queueUrl,
|
||||
'message' => $queueItem['why'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
$buildNumber = (int) $queueItem['executable']['number'];
|
||||
}
|
||||
|
||||
if (! $buildNumber) {
|
||||
return [
|
||||
'success' => false,
|
||||
'status' => 'UNKNOWN',
|
||||
'queue_url' => $queueUrl,
|
||||
'message' => '缺少 Jenkins 构建号',
|
||||
];
|
||||
}
|
||||
|
||||
$buildInfo = $this->getBuildInfo($jobName, $buildNumber);
|
||||
if (! $buildInfo) {
|
||||
return [
|
||||
'success' => true,
|
||||
'status' => 'PENDING',
|
||||
'building' => true,
|
||||
'completed' => false,
|
||||
'build_number' => $buildNumber,
|
||||
'queue_url' => $queueUrl,
|
||||
'message' => $this->findQueuedItem($jobName) ? '等待 Jenkins 开始构建' : '等待 Jenkins 创建构建',
|
||||
];
|
||||
}
|
||||
|
||||
$building = (bool) ($buildInfo['building'] ?? false);
|
||||
$result = $buildInfo['result'] ?? null;
|
||||
|
||||
return [
|
||||
'success' => true,
|
||||
'status' => $building ? 'BUILDING' : ($result ?? 'UNKNOWN'),
|
||||
'result' => $result,
|
||||
'building' => $building,
|
||||
'completed' => ! $building && ! empty($result),
|
||||
'build_number' => $buildNumber,
|
||||
'build_url' => $buildInfo['url'] ?? ($queueItem['executable']['url'] ?? null),
|
||||
'queue_url' => $queueUrl,
|
||||
];
|
||||
}
|
||||
|
||||
public function cancelBuild(string $jobName, ?string $queueUrl = null, ?int $buildNumber = null): array
|
||||
{
|
||||
if (! $this->isConfigured()) {
|
||||
Log::warning('Jenkins client is not configured');
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Jenkins not configured',
|
||||
];
|
||||
}
|
||||
|
||||
if ($queueUrl && ! $buildNumber) {
|
||||
$queueItem = $this->getQueueItem($queueUrl);
|
||||
if (! empty($queueItem['executable']['number'])) {
|
||||
$buildNumber = (int) $queueItem['executable']['number'];
|
||||
} else {
|
||||
$queueId = $this->extractQueueId($queueUrl);
|
||||
if (! $queueId) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => '无法识别 Jenkins 队列 ID',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
...$this->post('/queue/cancelItem?id='.rawurlencode($queueId), 'Jenkins queue cancel'),
|
||||
'cancelled_queue' => true,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$queueItem = $this->findQueuedItem($jobName);
|
||||
if (! empty($queueItem['id'])) {
|
||||
return [
|
||||
...$this->post('/queue/cancelItem?id='.rawurlencode((string) $queueItem['id']), 'Jenkins queue cancel'),
|
||||
'cancelled_queue' => true,
|
||||
];
|
||||
}
|
||||
|
||||
if (! $buildNumber) {
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => '缺少 Jenkins 构建号',
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
...$this->post($this->getJobPath($jobName)."/{$buildNumber}/stop", 'Jenkins build stop'),
|
||||
'stopping_build' => true,
|
||||
];
|
||||
}
|
||||
|
||||
private function request(string $path): ?array
|
||||
{
|
||||
if (! $this->isConfigured()) {
|
||||
@@ -191,6 +338,60 @@ class JenkinsClient
|
||||
}
|
||||
}
|
||||
|
||||
private function post(string $path, string $operation): array
|
||||
{
|
||||
$url = $this->host.$path;
|
||||
|
||||
try {
|
||||
$request = $this->http();
|
||||
$crumb = $this->getCrumb();
|
||||
if ($crumb) {
|
||||
$request = $request->withHeaders([$crumb['field'] => $crumb['crumb']]);
|
||||
}
|
||||
|
||||
$response = $request->post($url);
|
||||
if ($response->successful() || in_array($response->status(), [201, 302], true)) {
|
||||
return [
|
||||
'success' => true,
|
||||
'status' => $response->status(),
|
||||
];
|
||||
}
|
||||
|
||||
Log::warning($operation.' failed', [
|
||||
'url' => $url,
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => 'Jenkins 返回状态码 '.$response->status(),
|
||||
'status' => $response->status(),
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
Log::error($operation.' error', [
|
||||
'url' => $url,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'success' => false,
|
||||
'message' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function buildFormParameters(array $parameters): array
|
||||
{
|
||||
return collect($parameters)
|
||||
->map(fn ($value, $name) => [
|
||||
'name' => $name,
|
||||
'value' => $value,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function requestBody(string $path, bool $allowMethodNotAllowed = false): ?string
|
||||
{
|
||||
if (! $this->isConfigured()) {
|
||||
@@ -278,6 +479,55 @@ class JenkinsClient
|
||||
return $parameters;
|
||||
}
|
||||
|
||||
private function getQueueItem(string $queueUrl): ?array
|
||||
{
|
||||
$path = $this->normalizeJenkinsPath($queueUrl);
|
||||
$path = rtrim($path, '/').'/api/json';
|
||||
|
||||
return $this->request($path);
|
||||
}
|
||||
|
||||
private function findQueuedItem(string $jobName): ?array
|
||||
{
|
||||
$queue = $this->request('/queue/api/json');
|
||||
if (empty($queue['items']) || ! is_array($queue['items'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalizedJobName = trim($jobName, '/');
|
||||
$lastSegment = basename(str_replace('\\', '/', $normalizedJobName));
|
||||
|
||||
foreach ($queue['items'] as $item) {
|
||||
$task = $item['task'] ?? [];
|
||||
$taskName = $task['fullName'] ?? $task['name'] ?? '';
|
||||
|
||||
if ($taskName === $normalizedJobName || $taskName === $lastSegment) {
|
||||
return $item;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeJenkinsPath(string $pathOrUrl): string
|
||||
{
|
||||
$path = parse_url($pathOrUrl, PHP_URL_PATH) ?: $pathOrUrl;
|
||||
$query = parse_url($pathOrUrl, PHP_URL_QUERY);
|
||||
|
||||
return $query ? "{$path}?{$query}" : $path;
|
||||
}
|
||||
|
||||
private function extractQueueId(string $queueUrl): ?string
|
||||
{
|
||||
if (preg_match('#/queue/item/(\d+)#', $queueUrl, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
parse_str(parse_url($queueUrl, PHP_URL_QUERY) ?: '', $query);
|
||||
|
||||
return isset($query['id']) ? (string) $query['id'] : null;
|
||||
}
|
||||
|
||||
private function http(): PendingRequest
|
||||
{
|
||||
return Http::timeout($this->timeout)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\ErpRequestReportService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ErpRequestReportCommand extends Command
|
||||
{
|
||||
protected $signature = 'erp-request-report:send
|
||||
{--date= : 统计单日(Y-m-d;与 --from/--to 互斥,默认昨天)}
|
||||
{--from= : 开始时间(Y-m-d 或 Y-m-d H:i:s,含)}
|
||||
{--to= : 结束时间(Y-m-d 或 Y-m-d H:i:s;仅日期时含整天)}';
|
||||
|
||||
protected $description = '汇总指定时间段 ERP OpenAPI 请求并发送钉钉日报(默认昨天)';
|
||||
|
||||
public function handle(ErpRequestReportService $service): int
|
||||
{
|
||||
try {
|
||||
$result = $service->sendReport(
|
||||
$this->option('date'),
|
||||
$this->option('from'),
|
||||
$this->option('to')
|
||||
);
|
||||
|
||||
Log::channel('erp-request-report')->info('ERP 请求日报已发送', $result);
|
||||
$this->info("ERP 请求日报已发送:{$result['date']},{$result['company_count']} 家公司,{$result['request_count']} 次请求。");
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (\Throwable $e) {
|
||||
Log::channel('erp-request-report')->error('ERP 请求日报发送失败', [
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
$this->error($e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* CRM 病例标记位 ea_case_cstm.label_bit
|
||||
*
|
||||
* 对应 service 项目的 Eainc\Enum\Cases\LabelBitEnum。
|
||||
*
|
||||
* 数据流:
|
||||
* 1. CRM(service)通过 /case/update/bit 写入 ea_case_cstm.label_bit,
|
||||
* 并投递 case_basic_info_change 事件(携带 labelBit);
|
||||
* 2. agent-be 的 CaseEventHandleService::fillCase 消费事件后写入
|
||||
* cases.is_need_pfp = label_bit & DebtEnum::needMoney(),
|
||||
* 其中 needMoney() 是 configs 表 stuck_payment_reason 里所有 key 的按位或。
|
||||
*
|
||||
* 也就是说 is_need_pfp 并不是布尔值,而是「被 stuck_payment_reason 过滤后的卡款原因位图」。
|
||||
*/
|
||||
final class CaseLabelBit
|
||||
{
|
||||
/** @var int bit0 允许 APP 授信放行(非卡款原因) */
|
||||
public const ALLOW_PROCESS_BY_HONEST = 1;
|
||||
|
||||
/** @var int bit1 新病例订单卡生产 */
|
||||
public const APPLIANCE_NEED_MONEY = 2;
|
||||
|
||||
/** @var int bit2 产品变更(升档)卡生产 */
|
||||
public const UPGRADE_NEED_MONEY = 4;
|
||||
|
||||
/** @var int bit3 病例延期产品卡生产 */
|
||||
public const EXTENSION_NEED_MONEY = 8;
|
||||
|
||||
/** @var array<int,string> CRM 侧原始位含义 */
|
||||
private const CRM_LABELS = [
|
||||
self::ALLOW_PROCESS_BY_HONEST => '允许APP授信放行',
|
||||
self::APPLIANCE_NEED_MONEY => '新病例订单卡生产',
|
||||
self::UPGRADE_NEED_MONEY => '产品变更卡生产',
|
||||
self::EXTENSION_NEED_MONEY => '病例延期卡生产',
|
||||
];
|
||||
|
||||
/** @var array<int,string> 面向用户的原因解释 */
|
||||
private const DESCRIPTIONS = [
|
||||
self::ALLOW_PROCESS_BY_HONEST => '允许 APP 走授信放行的开关,不属于卡款原因,不会计入 is_need_pfp',
|
||||
self::APPLIANCE_NEED_MONEY => '新病例订单款项未结清,CRM 把病例卡在生产前,需要代理在代理端确认进产',
|
||||
self::UPGRADE_NEED_MONEY => '病例做了产品变更(升档),差价款项未结清,需要代理确认进产后才会放行',
|
||||
self::EXTENSION_NEED_MONEY => '病例服务年限延期的费用未结清,需要先结清费用才能继续生产',
|
||||
];
|
||||
|
||||
public static function crmLabel(int $bit): string
|
||||
{
|
||||
return self::CRM_LABELS[$bit] ?? ('未知标记位 '.$bit);
|
||||
}
|
||||
|
||||
public static function description(int $bit): string
|
||||
{
|
||||
return self::DESCRIPTIONS[$bit] ?? '未在 CRM 枚举中定义的标记位,需确认 CRM 是否新增了卡款原因';
|
||||
}
|
||||
|
||||
/**
|
||||
* 拆出位图中所有置位的 bit
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
public static function split(int $value): array
|
||||
{
|
||||
$bits = [];
|
||||
for ($bit = 1; $bit > 0 && $bit <= $value; $bit <<= 1) {
|
||||
if (($value & $bit) === $bit) {
|
||||
$bits[] = $bit;
|
||||
}
|
||||
}
|
||||
|
||||
return $bits;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把位图翻译成可读文本,如「新病例订单卡生产 / 产品变更卡生产」
|
||||
*/
|
||||
public static function toText(int $value): string
|
||||
{
|
||||
if ($value <= 0) {
|
||||
return '无标记';
|
||||
}
|
||||
|
||||
return implode(' / ', array_map(
|
||||
static fn (int $bit): string => self::crmLabel($bit),
|
||||
self::split($value)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Config;
|
||||
use App\Services\ErpRequestReportService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
@@ -14,6 +15,7 @@ class ConfigController extends Controller
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$configs = Config::query()
|
||||
->where('key', '!=', ErpRequestReportService::DINGTALK_TOKEN_CONFIG_KEY)
|
||||
->orderBy('key')
|
||||
->get();
|
||||
|
||||
@@ -28,7 +30,7 @@ class ConfigController extends Controller
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$data = $request->validate([
|
||||
'key' => ['required', 'string', 'max:255', 'unique:configs,key'],
|
||||
'key' => ['required', 'string', 'max:255', 'unique:configs,key', 'not_in:'.ErpRequestReportService::DINGTALK_TOKEN_CONFIG_KEY],
|
||||
'value' => ['nullable', 'string'],
|
||||
'description' => ['nullable', 'string', 'max:255'],
|
||||
]);
|
||||
@@ -49,12 +51,15 @@ class ConfigController extends Controller
|
||||
|
||||
public function update(Request $request, Config $config): JsonResponse
|
||||
{
|
||||
$this->ensureNotProtected($config);
|
||||
|
||||
$data = $request->validate([
|
||||
'key' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('configs', 'key')->ignore($config->id),
|
||||
'not_in:'.ErpRequestReportService::DINGTALK_TOKEN_CONFIG_KEY,
|
||||
],
|
||||
'value' => ['nullable', 'string'],
|
||||
'description' => ['nullable', 'string', 'max:255'],
|
||||
@@ -76,6 +81,8 @@ class ConfigController extends Controller
|
||||
|
||||
public function destroy(Config $config): JsonResponse
|
||||
{
|
||||
$this->ensureNotProtected($config);
|
||||
|
||||
$config->delete();
|
||||
|
||||
return response()->json([
|
||||
@@ -103,4 +110,13 @@ class ConfigController extends Controller
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private function ensureNotProtected(Config $config): void
|
||||
{
|
||||
if ($config->key === ErpRequestReportService::DINGTALK_TOKEN_CONFIG_KEY) {
|
||||
throw ValidationException::withMessages([
|
||||
'key' => '该配置只能通过 ERP 请求日报设置修改',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\ConfigService;
|
||||
use App\Services\ErpRequestReportService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ErpRequestReportConfigController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ConfigService $configService) {}
|
||||
|
||||
public function show(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'dingtalk_token_configured' => filled($this->configService->get(ErpRequestReportService::DINGTALK_TOKEN_CONFIG_KEY)),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'dingtalk_token' => ['required', 'string', 'max:2048'],
|
||||
]);
|
||||
|
||||
$token = trim($validated['dingtalk_token']);
|
||||
if ($token === '') {
|
||||
throw ValidationException::withMessages([
|
||||
'dingtalk_token' => 'Token 不能为空',
|
||||
]);
|
||||
}
|
||||
|
||||
$this->configService->set(
|
||||
ErpRequestReportService::DINGTALK_TOKEN_CONFIG_KEY,
|
||||
$token,
|
||||
'ERP 请求日报钉钉机器人 Token'
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'ERP 请求日报钉钉机器人 Token 已保存',
|
||||
'data' => [
|
||||
'dingtalk_token_configured' => true,
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,7 @@ class JenkinsBuildController extends Controller
|
||||
'success' => (bool) ($result['success'] ?? false),
|
||||
'message' => $result['message'] ?? null,
|
||||
'queue_url' => $result['queue_url'] ?? null,
|
||||
'build_number' => $result['build_number'] ?? null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -94,6 +95,83 @@ class JenkinsBuildController extends Controller
|
||||
], $successCount > 0 ? 200 : 422);
|
||||
}
|
||||
|
||||
public function statuses(Request $request): JsonResponse
|
||||
{
|
||||
if (! $this->jenkinsClient->isConfigured()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Jenkins 未配置,请先配置 Jenkins 连接信息',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$projectSlugs = Project::getJenkinsNotifyEnabled()->pluck('slug')->all();
|
||||
$data = $request->validate([
|
||||
'builds' => ['required', 'array', 'min:1'],
|
||||
'builds.*.id' => ['required', 'string'],
|
||||
'builds.*.project_slug' => ['required', 'string', Rule::in($projectSlugs)],
|
||||
'builds.*.queue_url' => ['nullable', 'string'],
|
||||
'builds.*.build_number' => ['nullable', 'integer'],
|
||||
]);
|
||||
|
||||
$projects = Project::getJenkinsNotifyEnabled()->keyBy('slug');
|
||||
$results = [];
|
||||
|
||||
foreach ($data['builds'] as $build) {
|
||||
/** @var Project $project */
|
||||
$project = $projects[$build['project_slug']];
|
||||
|
||||
$results[] = [
|
||||
'id' => $build['id'],
|
||||
'project_slug' => $project->slug,
|
||||
'job_name' => $project->jenkins_job_name,
|
||||
...$this->jenkinsClient->getBuildStatus(
|
||||
$project->jenkins_job_name,
|
||||
$build['queue_url'] ?? null,
|
||||
isset($build['build_number']) ? (int) $build['build_number'] : null
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'results' => $results,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function cancel(Request $request): JsonResponse
|
||||
{
|
||||
if (! $this->jenkinsClient->isConfigured()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Jenkins 未配置,请先配置 Jenkins 连接信息',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$projectSlugs = Project::getJenkinsNotifyEnabled()->pluck('slug')->all();
|
||||
$data = $request->validate([
|
||||
'project_slug' => ['required', 'string', Rule::in($projectSlugs)],
|
||||
'queue_url' => ['nullable', 'string'],
|
||||
'build_number' => ['nullable', 'integer'],
|
||||
]);
|
||||
|
||||
$project = Project::getJenkinsNotifyEnabled()->firstWhere('slug', $data['project_slug']);
|
||||
$result = $this->jenkinsClient->cancelBuild(
|
||||
$project->jenkins_job_name,
|
||||
$data['queue_url'] ?? null,
|
||||
isset($data['build_number']) ? (int) $data['build_number'] : null
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'success' => (bool) ($result['success'] ?? false),
|
||||
'message' => ($result['success'] ?? false) ? '已发送取消请求' : ($result['message'] ?? '取消失败'),
|
||||
'data' => [
|
||||
'result' => $result,
|
||||
],
|
||||
], ($result['success'] ?? false) ? 200 : 422);
|
||||
}
|
||||
|
||||
private function normalizeParameters(array $parameters): array
|
||||
{
|
||||
return collect($parameters)
|
||||
|
||||
@@ -5,13 +5,12 @@ namespace App\Http\Controllers;
|
||||
use App\Services\ProductionDiagnosisService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ProductionDiagnosisController extends Controller
|
||||
{
|
||||
public function __construct(private readonly ProductionDiagnosisService $service)
|
||||
{
|
||||
}
|
||||
public function __construct(private readonly ProductionDiagnosisService $service) {}
|
||||
|
||||
/**
|
||||
* 单条进产诊断
|
||||
@@ -37,9 +36,15 @@ class ProductionDiagnosisController extends Controller
|
||||
'errors' => $e->errors(),
|
||||
], 422);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Production diagnosis failed.', [
|
||||
'type' => $request->input('type'),
|
||||
'code' => $request->input('code'),
|
||||
'exception' => $e,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => '诊断失败: '.$e->getMessage(),
|
||||
'message' => '诊断服务暂不可用,请稍后重试',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\View\View;
|
||||
|
||||
class ProductionDiagnosisPageController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): View
|
||||
{
|
||||
if (strtolower($request->getHost()) === config('toolbox.admin_host')) {
|
||||
return view('admin.index');
|
||||
}
|
||||
|
||||
return view('production-diagnosis.index');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class HostAccessMiddleware
|
||||
{
|
||||
/**
|
||||
* @param Closure(Request): Response $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$host = strtolower(trim($request->getHost(), '[]'));
|
||||
$adminHost = strtolower((string) config('toolbox.admin_host', 'toolbox.local'));
|
||||
|
||||
if ($host === $adminHost) {
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
if (filter_var($host, FILTER_VALIDATE_IP) === false || ! $this->isPublicDiagnosisRequest($request)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
|
||||
private function isPublicDiagnosisRequest(Request $request): bool
|
||||
{
|
||||
return ($request->isMethod('GET') && $request->is('production-diagnosis'))
|
||||
|| ($request->isMethod('POST') && $request->is('api/production-diagnosis/diagnose'));
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use App\Services\CodeContextService;
|
||||
use App\Services\ConfigService;
|
||||
use App\Services\DingTalkService;
|
||||
use App\Services\EnvService;
|
||||
use App\Services\ErpRequestReportService;
|
||||
use App\Services\GitMonitorService;
|
||||
use App\Services\JiraService;
|
||||
use App\Services\LogAnalysisService;
|
||||
@@ -38,6 +39,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
$this->app->singleton(JiraService::class);
|
||||
$this->app->singleton(DingTalkService::class);
|
||||
$this->app->singleton(EnvService::class);
|
||||
$this->app->singleton(ErpRequestReportService::class);
|
||||
$this->app->singleton(GitMonitorService::class);
|
||||
$this->app->singleton(SlsService::class);
|
||||
$this->app->singleton(AiService::class);
|
||||
|
||||
@@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Log;
|
||||
class DingTalkService
|
||||
{
|
||||
private ?string $webhook;
|
||||
|
||||
private ?string $secret;
|
||||
|
||||
public function __construct()
|
||||
@@ -25,9 +26,32 @@ class DingTalkService
|
||||
'atMobiles' => $atMobiles,
|
||||
'atAll' => $atAll,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->sendTextToWebhook($this->webhook, $message, $atMobiles, $atAll, $this->secret);
|
||||
}
|
||||
|
||||
public function sendTextToToken(string $token, string $message, array $atMobiles = [], bool $atAll = false): bool
|
||||
{
|
||||
$token = trim($token);
|
||||
if ($token === '') {
|
||||
Log::warning('DingTalk robot token is not configured, skip sending alert.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->sendTextToWebhook(
|
||||
'https://oapi.dingtalk.com/robot/send?access_token='.urlencode($token),
|
||||
$message,
|
||||
$atMobiles,
|
||||
$atAll
|
||||
);
|
||||
}
|
||||
|
||||
private function sendTextToWebhook(string $webhook, string $message, array $atMobiles, bool $atAll, ?string $secret = null): bool
|
||||
{
|
||||
$payload = [
|
||||
'msgtype' => 'text',
|
||||
'text' => [
|
||||
@@ -39,22 +63,33 @@ class DingTalkService
|
||||
],
|
||||
];
|
||||
|
||||
$url = $this->webhook;
|
||||
if (!empty($this->secret)) {
|
||||
$url = $webhook;
|
||||
if (! empty($secret)) {
|
||||
$timestamp = (int) round(microtime(true) * 1000);
|
||||
$stringToSign = $timestamp . "\n" . $this->secret;
|
||||
$sign = base64_encode(hash_hmac('sha256', $stringToSign, $this->secret, true));
|
||||
$stringToSign = $timestamp."\n".$secret;
|
||||
$sign = base64_encode(hash_hmac('sha256', $stringToSign, $secret, true));
|
||||
$encodedSign = urlencode($sign);
|
||||
$separator = str_contains($url, '?') ? '&' : '?';
|
||||
$url .= "{$separator}timestamp={$timestamp}&sign={$encodedSign}";
|
||||
}
|
||||
|
||||
try {
|
||||
Http::timeout(10)->asJson()->post($url, $payload);
|
||||
$response = Http::timeout(10)->asJson()->post($url, $payload);
|
||||
|
||||
if ($response->successful() && (int) $response->json('errcode', -1) === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Log::error('DingTalk alert was rejected', [
|
||||
'status' => $response->status(),
|
||||
'errcode' => $response->json('errcode'),
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to send DingTalk alert', [
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\DatabaseManager;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
|
||||
class ErpRequestReportService
|
||||
{
|
||||
public const DINGTALK_TOKEN_CONFIG_KEY = 'erp_request_report.dingtalk_token';
|
||||
|
||||
private const REPORT_TIMEZONE = 'Asia/Shanghai';
|
||||
|
||||
// Keep a margin below DingTalk's text-message limit for transport overhead.
|
||||
private const MAX_MESSAGE_BYTES = 18_000;
|
||||
|
||||
public function __construct(
|
||||
private DatabaseManager $database,
|
||||
private DingTalkService $dingTalkService,
|
||||
private ConfigService $configService
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param string|null $date 单日(Y-m-d),与 from/to 互斥
|
||||
* @param string|null $from 开始时间(Y-m-d 或 Y-m-d H:i:s),含
|
||||
* @param string|null $to 结束时间(Y-m-d 或 Y-m-d H:i:s);仅日期时含整天,含时分秒时含该时刻
|
||||
*/
|
||||
public function sendReport(?string $date = null, ?string $from = null, ?string $to = null): array
|
||||
{
|
||||
$token = trim((string) $this->configService->get(self::DINGTALK_TOKEN_CONFIG_KEY));
|
||||
|
||||
if ($token === '') {
|
||||
throw new RuntimeException('未配置 ERP 请求日报的钉钉机器人 Token');
|
||||
}
|
||||
|
||||
[$start, $end] = $this->resolvePeriod($date, $from, $to);
|
||||
$periodLabel = $this->formatPeriodLabel($start, $end);
|
||||
|
||||
$records = $this->database->connection('agentslave')
|
||||
->table('request_records')
|
||||
->selectRaw("agents.name as agent_name, agents.code as agent_code, SUBSTRING_INDEX(request_records.request_uri, '?', 1) as request_uri, COUNT(*) as request_count")
|
||||
->leftJoin('agents', 'agents.id', '=', 'request_records.user_id')
|
||||
->where('request_records.created', '>=', $start->toDateTimeString())
|
||||
->where('request_records.created', '<', $end->toDateTimeString())
|
||||
->where('request_records.request_uri', 'like', '/openapi/erp/%')
|
||||
->groupByRaw("agents.id, agents.name, agents.code, SUBSTRING_INDEX(request_records.request_uri, '?', 1)")
|
||||
->orderBy('agents.name')
|
||||
->orderBy('agents.code')
|
||||
->orderByRaw("SUBSTRING_INDEX(request_records.request_uri, '?', 1)")
|
||||
->get();
|
||||
|
||||
$messages = $this->formatMessages($periodLabel, $records);
|
||||
$messageCount = count($messages);
|
||||
|
||||
foreach ($messages as $index => $message) {
|
||||
if (! $this->dingTalkService->sendTextToToken($token, $message)) {
|
||||
throw new RuntimeException(sprintf('ERP 请求日报第 %d/%d 条发送到钉钉失败', $index + 1, $messageCount));
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'date' => $periodLabel,
|
||||
'from' => $start->toDateTimeString(),
|
||||
'to' => $end->subSecond()->toDateTimeString(),
|
||||
'company_count' => $records->groupBy(fn ($record) => $record->agent_name."\0".$record->agent_code)->count(),
|
||||
'request_count' => $records->sum('request_count'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0: CarbonImmutable, 1: CarbonImmutable} half-open interval [start, end)
|
||||
*/
|
||||
private function resolvePeriod(?string $date, ?string $from, ?string $to): array
|
||||
{
|
||||
$date = $this->normalizeOption($date);
|
||||
$from = $this->normalizeOption($from);
|
||||
$to = $this->normalizeOption($to);
|
||||
|
||||
if ($date !== null && ($from !== null || $to !== null)) {
|
||||
throw new InvalidArgumentException('--date 不能与 --from/--to 同时使用');
|
||||
}
|
||||
|
||||
if ($date !== null) {
|
||||
if (! $this->isDateOnly($date)) {
|
||||
throw new InvalidArgumentException('--date 仅支持 Y-m-d 格式');
|
||||
}
|
||||
|
||||
$start = CarbonImmutable::parse($date, self::REPORT_TIMEZONE)->startOfDay();
|
||||
|
||||
return [$start, $start->addDay()];
|
||||
}
|
||||
|
||||
if ($from === null && $to === null) {
|
||||
$start = CarbonImmutable::now(self::REPORT_TIMEZONE)->subDay()->startOfDay();
|
||||
|
||||
return [$start, $start->addDay()];
|
||||
}
|
||||
|
||||
if ($from === null || $to === null) {
|
||||
throw new InvalidArgumentException('--from 与 --to 需要同时指定');
|
||||
}
|
||||
|
||||
$start = $this->parseBound($from, isStart: true);
|
||||
$end = $this->parseBound($to, isStart: false);
|
||||
|
||||
if ($end->lessThanOrEqualTo($start)) {
|
||||
throw new InvalidArgumentException('结束时间必须晚于开始时间');
|
||||
}
|
||||
|
||||
return [$start, $end];
|
||||
}
|
||||
|
||||
private function parseBound(string $value, bool $isStart): CarbonImmutable
|
||||
{
|
||||
$parsed = CarbonImmutable::parse($value, self::REPORT_TIMEZONE);
|
||||
|
||||
if ($this->isDateOnly($value)) {
|
||||
return $isStart ? $parsed->startOfDay() : $parsed->startOfDay()->addDay();
|
||||
}
|
||||
|
||||
return $isStart ? $parsed : $parsed->addSecond();
|
||||
}
|
||||
|
||||
private function isDateOnly(string $value): bool
|
||||
{
|
||||
return (bool) preg_match('/^\d{4}-\d{2}-\d{2}$/', $value);
|
||||
}
|
||||
|
||||
private function normalizeOption(?string $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = trim($value);
|
||||
|
||||
return $value === '' ? null : $value;
|
||||
}
|
||||
|
||||
private function formatPeriodLabel(CarbonImmutable $start, CarbonImmutable $end): string
|
||||
{
|
||||
$inclusiveEnd = $end->subSecond();
|
||||
$isFullDayStart = $start->format('H:i:s') === '00:00:00';
|
||||
$isFullDayEnd = $inclusiveEnd->format('H:i:s') === '23:59:59';
|
||||
$spansSingleDay = $start->toDateString() === $inclusiveEnd->toDateString();
|
||||
|
||||
if ($isFullDayStart && $isFullDayEnd && $spansSingleDay) {
|
||||
return $start->toDateString();
|
||||
}
|
||||
|
||||
if ($isFullDayStart && $isFullDayEnd) {
|
||||
return $start->toDateString().' ~ '.$inclusiveEnd->toDateString();
|
||||
}
|
||||
|
||||
return $start->format('Y-m-d H:i:s').' ~ '.$inclusiveEnd->format('Y-m-d H:i:s');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function formatMessages(string $periodLabel, Collection $records): array
|
||||
{
|
||||
$title = "{$periodLabel} ERP OpenAPI 请求统计";
|
||||
|
||||
if ($records->isEmpty()) {
|
||||
return ["{$title}\n无请求记录"];
|
||||
}
|
||||
|
||||
$messages = [$title];
|
||||
|
||||
foreach ($records->groupBy(fn ($record) => $record->agent_name."\0".$record->agent_code) as $companyRecords) {
|
||||
$first = $companyRecords->first();
|
||||
$company = trim(($first->agent_name ?: '未知机构').' '.($first->agent_code ?: ''));
|
||||
|
||||
$this->appendCompanyRecords($messages, $title, $company, $companyRecords);
|
||||
}
|
||||
|
||||
if (count($messages) === 1) {
|
||||
return $messages;
|
||||
}
|
||||
|
||||
return array_map(
|
||||
fn (string $message, int $index) => "{$message}(第 ".($index + 1).'/'.count($messages).' 条)',
|
||||
$messages,
|
||||
array_keys($messages)
|
||||
);
|
||||
}
|
||||
|
||||
private function appendCompanyRecords(array &$messages, string $title, string $company, Collection $records): void
|
||||
{
|
||||
foreach ($records->values() as $index => $record) {
|
||||
$line = $this->formatRequestLine($record);
|
||||
$messageIndex = array_key_last($messages);
|
||||
$isFirstRecord = $index === 0;
|
||||
$addition = $isFirstRecord
|
||||
? "\n\n{$company}\n{$line}"
|
||||
: "\n{$line}";
|
||||
|
||||
if (strlen($messages[$messageIndex].$addition) <= self::MAX_MESSAGE_BYTES) {
|
||||
$messages[$messageIndex] .= $addition;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$messages[] = "{$title}\n\n{$company}\n{$line}";
|
||||
}
|
||||
}
|
||||
|
||||
private function formatRequestLine(object $record): string
|
||||
{
|
||||
$uri = explode('?', (string) $record->request_uri, 2)[0];
|
||||
$uri = preg_replace('/[\\x00-\\x1F\\x7F]/u', '', $uri) ?? '';
|
||||
|
||||
return Str::limit($uri, 1_000, '…')." {$record->request_count}次";
|
||||
}
|
||||
}
|
||||
@@ -331,6 +331,16 @@ class JiraService
|
||||
['name' => 'portal-ticket-fe-web', 'location' => '法兰克福&中国'],
|
||||
],
|
||||
],
|
||||
'mono' => [
|
||||
'label' => 'mono',
|
||||
'system' => 'SP',
|
||||
'current_version' => $this->resolveTestMailCurrentVersion('portal-mono-be', '1.11.0.0'),
|
||||
'database_enabled' => false,
|
||||
'containers' => [
|
||||
['name' => 'portal-mono-be-aplct', 'location' => '中国'],
|
||||
['name' => 'portal-mono-be-web', 'location' => '中国'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($containerGroups as &$group) {
|
||||
@@ -368,10 +378,15 @@ class JiraService
|
||||
}
|
||||
|
||||
private function nextTestMailVersion(string $version): string
|
||||
{
|
||||
return $this->nextMinorVersion($version) ?? $version;
|
||||
}
|
||||
|
||||
private function nextMinorVersion(string $version): ?string
|
||||
{
|
||||
$parts = explode('.', trim($version));
|
||||
if (count($parts) < 2 || ! ctype_digit($parts[1])) {
|
||||
return $version;
|
||||
return null;
|
||||
}
|
||||
|
||||
$parts[1] = (string) ((int) $parts[1] + 1);
|
||||
@@ -379,6 +394,20 @@ class JiraService
|
||||
return implode('.', $parts);
|
||||
}
|
||||
|
||||
private function buildFallbackReleaseVersion(string $currentVersion): ?array
|
||||
{
|
||||
$version = $this->nextMinorVersion($currentVersion);
|
||||
if ($version === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'version' => $version,
|
||||
'description' => null,
|
||||
'release_date' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function buildTestMailDatabases(array $selectedGroups, array $versions): array
|
||||
{
|
||||
$defaults = $this->getTestMailTemplateDefaults();
|
||||
@@ -389,6 +418,9 @@ class JiraService
|
||||
continue;
|
||||
}
|
||||
$group = $defaults['container_groups'][$groupKey];
|
||||
if (($group['database_enabled'] ?? true) === false) {
|
||||
continue;
|
||||
}
|
||||
$version = trim((string) ($versions[$groupKey] ?? $group['default_version'] ?? ''));
|
||||
$branch = $version !== '' ? 'release/'.$version : '';
|
||||
$exists = $branch !== '' && $this->gitBranchExists($group['db_project'], $branch);
|
||||
@@ -1498,7 +1530,7 @@ class JiraService
|
||||
->first();
|
||||
|
||||
if (! $candidate) {
|
||||
return null;
|
||||
return $this->buildFallbackReleaseVersion($currentVersion);
|
||||
}
|
||||
|
||||
return [
|
||||
@@ -1517,7 +1549,7 @@ class JiraService
|
||||
->first();
|
||||
|
||||
if (! $candidate) {
|
||||
return null;
|
||||
return $this->buildFallbackReleaseVersion($currentVersion);
|
||||
}
|
||||
|
||||
return [
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
namespace App\Services;
|
||||
|
||||
use App\Clients\CrmClient;
|
||||
use App\Enums\CaseLabelBit;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* 进产诊断服务
|
||||
@@ -18,15 +20,31 @@ use Illuminate\Support\Facades\DB;
|
||||
class ProductionDiagnosisService
|
||||
{
|
||||
public const TYPE_CASE = 'case';
|
||||
|
||||
public const TYPE_BUSINESS = 'business_document';
|
||||
|
||||
public const TYPE_SALE = 'sale_document';
|
||||
|
||||
/** @var string agent-be configs 表中的卡款原因配置键 */
|
||||
private const STUCK_PAYMENT_REASON_KEY = 'stuck_payment_reason';
|
||||
|
||||
/**
|
||||
* 读不到 configs 表时的兜底,与当前生产配置保持一致
|
||||
*
|
||||
* @var array<int,string>
|
||||
*/
|
||||
private const DEFAULT_STUCK_PAYMENT_REASONS = [
|
||||
CaseLabelBit::APPLIANCE_NEED_MONEY => '新病例进产',
|
||||
CaseLabelBit::UPGRADE_NEED_MONEY => '转产品',
|
||||
];
|
||||
|
||||
/** @var string agent-be 数据库连接名 */
|
||||
private string $connection = 'agentslave';
|
||||
|
||||
public function __construct(private readonly CrmClient $crm)
|
||||
{
|
||||
}
|
||||
/** @var string CRM 数据库连接名,用于回溯 label_bit 原始值 */
|
||||
private string $crmConnection = 'crmslave';
|
||||
|
||||
public function __construct(private readonly CrmClient $crm) {}
|
||||
|
||||
/**
|
||||
* 执行单次诊断
|
||||
@@ -36,7 +54,7 @@ class ProductionDiagnosisService
|
||||
$code = trim($code);
|
||||
$entity = $this->findEntity($type, $code);
|
||||
|
||||
if (!$entity) {
|
||||
if (! $entity) {
|
||||
return [
|
||||
'type' => $type,
|
||||
'type_label' => $this->typeLabel($type),
|
||||
@@ -49,11 +67,13 @@ class ProductionDiagnosisService
|
||||
$operatorCode = (string) $entity->agent_code;
|
||||
$operatorAgent = $this->findAgent($operatorCode);
|
||||
$checks = [];
|
||||
$pfpContext = null;
|
||||
|
||||
$checks['status'] = $this->checkStatus($type, $entity);
|
||||
|
||||
if ($type === self::TYPE_CASE) {
|
||||
$checks['need_pfp'] = $this->checkNeedPfp($entity);
|
||||
$pfpContext = $this->buildPfpContext($entity, $operatorCode);
|
||||
$checks['need_pfp'] = $this->checkNeedPfp($pfpContext);
|
||||
}
|
||||
|
||||
$checks['owner_agent'] = $this->checkOwnerAgent($type, $entity, $operatorCode);
|
||||
@@ -66,7 +86,7 @@ class ProductionDiagnosisService
|
||||
'type_label' => $this->typeLabel($type),
|
||||
'code' => $code,
|
||||
'found' => true,
|
||||
'entity' => $this->normalizeEntity($type, $entity),
|
||||
'entity' => $this->normalizeEntity($type, $entity, $pfpContext),
|
||||
'operating_agent_code' => $operatorCode,
|
||||
'operating_agent' => $operatorAgent ? [
|
||||
'code' => (string) $operatorAgent->code,
|
||||
@@ -144,29 +164,301 @@ class ProductionDiagnosisService
|
||||
}
|
||||
|
||||
/**
|
||||
* 病例放行检查 - case.is_need_pfp > 0
|
||||
* 进产原因检查 - case.is_need_pfp 位图
|
||||
*
|
||||
* is_need_pfp 来源于 CRM 的 ea_case_cstm.label_bit,经 stuck_payment_reason
|
||||
* 配置过滤后写入代理库;只有存在卡款原因的病例才会走代理端进产流程。
|
||||
*
|
||||
* @param array<string,mixed> $ctx buildPfpContext 的返回值
|
||||
*/
|
||||
private function checkNeedPfp(object $caseEntity): array
|
||||
private function checkNeedPfp(array $ctx): array
|
||||
{
|
||||
$isNeedPfp = (int) ($caseEntity->is_need_pfp ?? 0);
|
||||
$isPfp = (int) ($caseEntity->is_pfp ?? 0);
|
||||
$pass = $isNeedPfp > 0;
|
||||
$pass = $ctx['is_need_pfp'] > 0;
|
||||
$reasonText = $ctx['reason_text'];
|
||||
|
||||
$detail = $pass
|
||||
? '病例 is_need_pfp = '.$isNeedPfp.',命中放行节点'
|
||||
: '病例 is_need_pfp = 0,不需要放行(非欠款病例,不会触发进产流程)';
|
||||
$actual = $pass
|
||||
? sprintf('进产原因:%s(is_need_pfp = %d)', $reasonText, $ctx['is_need_pfp'])
|
||||
: sprintf('无进产原因(is_need_pfp = 0);放行状态:%s', $ctx['is_pfp_text']);
|
||||
|
||||
return [
|
||||
'key' => 'need_pfp',
|
||||
'label' => '放行节点检查 (is_need_pfp)',
|
||||
'label' => '进产原因检查',
|
||||
'pass' => $pass,
|
||||
'expected' => 'is_need_pfp > 0',
|
||||
'actual' => 'is_need_pfp = '.$isNeedPfp.', is_pfp = '.$isPfp,
|
||||
'detail' => $detail,
|
||||
'hint' => $pass ? null : '检查 stuck_payment_reason 配置以及病例账期推算逻辑',
|
||||
'expected' => '病例存在卡生产原因('.$this->configOptionText($ctx).')',
|
||||
'actual' => $actual,
|
||||
'detail' => $this->pfpDetail($ctx, $pass),
|
||||
'hint' => $pass ? null : $this->pfpHint($ctx),
|
||||
] + $ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* 汇总进产原因所需的全部上下文:代理库位图、配置项、CRM 原始 label_bit
|
||||
*
|
||||
* @return array<string,mixed>
|
||||
*/
|
||||
private function buildPfpContext(object $caseEntity, string $operatorCode): array
|
||||
{
|
||||
$isNeedPfp = (int) ($caseEntity->is_need_pfp ?? 0);
|
||||
$isPfp = (int) ($caseEntity->is_pfp ?? 0);
|
||||
|
||||
$config = $this->stuckPaymentReasonConfig($operatorCode);
|
||||
$reasons = $this->describeReasons($isNeedPfp, $config['reasons']);
|
||||
|
||||
$crmLabelBit = $this->crmLabelBit((string) $caseEntity->case_code);
|
||||
$crmAvailable = $crmLabelBit !== null;
|
||||
$expectedNeedPfp = $crmAvailable ? ($crmLabelBit & $config['mask']) : null;
|
||||
|
||||
// CRM 上有卡款标记,但对应的 bit 没有配进 stuck_payment_reason,代理端会直接忽略
|
||||
$ignoredBits = $crmAvailable
|
||||
? array_values(array_filter(
|
||||
CaseLabelBit::split($crmLabelBit & ~$config['mask']),
|
||||
static fn (int $bit): bool => $bit !== CaseLabelBit::ALLOW_PROCESS_BY_HONEST
|
||||
))
|
||||
: [];
|
||||
|
||||
return [
|
||||
'is_need_pfp' => $isNeedPfp,
|
||||
'is_pfp' => $isPfp,
|
||||
'is_pfp_text' => $isPfp > 0 ? '已放行' : '未放行',
|
||||
'reasons' => $reasons,
|
||||
'reason_text' => $reasons === [] ? '无' : implode(' / ', array_column($reasons, 'label')),
|
||||
'config_mask' => $config['mask'],
|
||||
'config_source' => $config['source'],
|
||||
'config_source_text' => $this->configSourceText($config['source']),
|
||||
'config_options' => array_map(
|
||||
static fn (int $bit, string $label): array => ['bit' => $bit, 'label' => $label],
|
||||
array_keys($config['reasons']),
|
||||
array_values($config['reasons'])
|
||||
),
|
||||
'crm_available' => $crmAvailable,
|
||||
'crm_label_bit' => $crmLabelBit,
|
||||
'crm_label_bit_text' => $crmAvailable ? CaseLabelBit::toText($crmLabelBit) : '未知(CRM 库不可读)',
|
||||
'crm_ignored_reasons' => array_map(
|
||||
static fn (int $bit): array => [
|
||||
'bit' => $bit,
|
||||
'label' => CaseLabelBit::crmLabel($bit),
|
||||
'description' => CaseLabelBit::description($bit),
|
||||
],
|
||||
$ignoredBits
|
||||
),
|
||||
'expected_is_need_pfp' => $expectedNeedPfp,
|
||||
'sync_mismatch' => $expectedNeedPfp !== null && $expectedNeedPfp !== $isNeedPfp,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 把位图翻译成用户可读的进产原因
|
||||
*
|
||||
* @param array<int,string> $configReasons
|
||||
* @return array<int,array<string,mixed>>
|
||||
*/
|
||||
private function describeReasons(int $bitmap, array $configReasons): array
|
||||
{
|
||||
return array_map(
|
||||
static fn (int $bit): array => [
|
||||
'bit' => $bit,
|
||||
'label' => $configReasons[$bit] ?? CaseLabelBit::crmLabel($bit),
|
||||
'crm_label' => CaseLabelBit::crmLabel($bit),
|
||||
'description' => CaseLabelBit::description($bit),
|
||||
],
|
||||
CaseLabelBit::split($bitmap)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 agent-be configs 表中的 stuck_payment_reason
|
||||
*
|
||||
* 复现 ConfigService::getOne 的取值顺序:代理自身配置 → 全局配置 → 兜底默认值。
|
||||
*
|
||||
* @return array{reasons: array<int,string>, mask: int, source: string}
|
||||
*/
|
||||
private function stuckPaymentReasonConfig(string $operatorCode): array
|
||||
{
|
||||
try {
|
||||
$rows = DB::connection($this->connection)
|
||||
->table('configs')
|
||||
->where('key', self::STUCK_PAYMENT_REASON_KEY)
|
||||
->whereIn('agent_code', array_values(array_unique([$operatorCode, ''])))
|
||||
->get();
|
||||
|
||||
$row = $rows->firstWhere('agent_code', $operatorCode) ?: $rows->firstWhere('agent_code', '');
|
||||
$reasons = $this->parseStuckPaymentReasons($row->val ?? null);
|
||||
|
||||
if ($reasons !== []) {
|
||||
return [
|
||||
'reasons' => $reasons,
|
||||
'mask' => $this->maskOf($reasons),
|
||||
'source' => ((string) ($row->agent_code ?? '')) === '' ? 'global' : 'agent',
|
||||
];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('读取 stuck_payment_reason 配置失败,使用默认卡款原因。', ['exception' => $e]);
|
||||
}
|
||||
|
||||
return [
|
||||
'reasons' => self::DEFAULT_STUCK_PAYMENT_REASONS,
|
||||
'mask' => $this->maskOf(self::DEFAULT_STUCK_PAYMENT_REASONS),
|
||||
'source' => 'default',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* configs.val 形如 [{"key":2,"lable":"新病例进产"},{"key":4,"lable":"转产品"}]
|
||||
* 线上配置的 label 字段存在 lable 拼写,两种都兼容
|
||||
*
|
||||
* @return array<int,string>
|
||||
*/
|
||||
private function parseStuckPaymentReasons(mixed $val): array
|
||||
{
|
||||
if (is_string($val)) {
|
||||
$val = json_decode($val, true);
|
||||
}
|
||||
|
||||
if (! is_array($val)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$reasons = [];
|
||||
foreach ($val as $item) {
|
||||
$bit = (int) (is_array($item) ? ($item['key'] ?? 0) : 0);
|
||||
if ($bit <= 0) {
|
||||
continue;
|
||||
}
|
||||
$label = (string) ($item['lable'] ?? $item['label'] ?? '');
|
||||
$reasons[$bit] = $label !== '' ? $label : CaseLabelBit::crmLabel($bit);
|
||||
}
|
||||
|
||||
return $reasons;
|
||||
}
|
||||
|
||||
/**
|
||||
* 复现 DebtEnum::needMoney - 所有配置项 key 的按位或
|
||||
*
|
||||
* @param array<int,string> $reasons
|
||||
*/
|
||||
private function maskOf(array $reasons): int
|
||||
{
|
||||
$mask = 0;
|
||||
foreach (array_keys($reasons) as $bit) {
|
||||
$mask |= $bit;
|
||||
}
|
||||
|
||||
return $mask;
|
||||
}
|
||||
|
||||
/**
|
||||
* 直查 CRM 库的 ea_case_cstm.label_bit,用于判断代理库是否同步到位
|
||||
*/
|
||||
private function crmLabelBit(string $caseCode): ?int
|
||||
{
|
||||
if ($caseCode === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$value = DB::connection($this->crmConnection)
|
||||
->table('ea_case as c')
|
||||
->join('ea_case_cstm as cc', 'cc.id_c', '=', 'c.id')
|
||||
->where('c.name', $caseCode)
|
||||
->where('c.deleted', 0)
|
||||
->value('cc.label_bit');
|
||||
|
||||
return $value === null ? null : (int) $value;
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('读取 CRM label_bit 失败,跳过同步比对。', ['case_code' => $caseCode, 'exception' => $e]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $ctx
|
||||
*/
|
||||
private function pfpDetail(array $ctx, bool $pass): string
|
||||
{
|
||||
if ($pass) {
|
||||
$detail = sprintf('病例因「%s」被卡在生产前,需要代理确认进产后才会放行。', $ctx['reason_text']);
|
||||
|
||||
if ($ctx['is_pfp'] > 0) {
|
||||
$detail .= '该病例已放行(is_pfp = 1)。';
|
||||
}
|
||||
|
||||
if ($ctx['sync_mismatch']) {
|
||||
$detail .= sprintf(
|
||||
'注意:CRM 当前 label_bit = %d,按配置应为 is_need_pfp = %d,与代理库不一致。',
|
||||
$ctx['crm_label_bit'],
|
||||
$ctx['expected_is_need_pfp']
|
||||
);
|
||||
}
|
||||
|
||||
return $detail;
|
||||
}
|
||||
|
||||
if ($ctx['sync_mismatch'] && $ctx['expected_is_need_pfp'] > 0) {
|
||||
return sprintf(
|
||||
'CRM 已标记「%s」,按配置应写入 is_need_pfp = %d,但代理库仍为 0,疑似 case_basic_info_change 事件未消费或延迟。',
|
||||
CaseLabelBit::toText($ctx['expected_is_need_pfp']),
|
||||
$ctx['expected_is_need_pfp']
|
||||
);
|
||||
}
|
||||
|
||||
if ($ctx['crm_ignored_reasons'] !== []) {
|
||||
return sprintf(
|
||||
'CRM 标记了「%s」,但该原因未纳入 stuck_payment_reason 配置(当前仅 %s),代理端不会产生进产原因。',
|
||||
implode(' / ', array_column($ctx['crm_ignored_reasons'], 'label')),
|
||||
$this->configOptionText($ctx)
|
||||
);
|
||||
}
|
||||
|
||||
if ($ctx['crm_available'] && $ctx['crm_label_bit'] === 0) {
|
||||
return '病例在 CRM 侧没有任何卡生产标记,属于正常病例,不需要也无法走代理进产流程。';
|
||||
}
|
||||
|
||||
return '病例没有卡生产原因(is_need_pfp = 0),不需要代理放行,进产流程不会对该病例生效。';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $ctx
|
||||
*/
|
||||
private function pfpHint(array $ctx): string
|
||||
{
|
||||
if ($ctx['sync_mismatch'] && $ctx['expected_is_need_pfp'] > 0) {
|
||||
return '检查 agent-be 是否正常消费 CRM 的病例变更事件,必要时重新推送该病例的 case_basic_info_change 消息';
|
||||
}
|
||||
|
||||
if ($ctx['crm_ignored_reasons'] !== []) {
|
||||
return '若该原因也需要代理放行,需在 agent-be configs 表的 stuck_payment_reason 中补充对应 key';
|
||||
}
|
||||
|
||||
if (! $ctx['crm_available']) {
|
||||
return '未能读取 CRM 的 ea_case_cstm.label_bit,可检查 crmslave 数据库配置后重新诊断';
|
||||
}
|
||||
|
||||
return '确认该病例是否确实需要卡款放行;正常病例由 CRM 直接进产,无需代理操作';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $ctx
|
||||
*/
|
||||
private function configOptionText(array $ctx): string
|
||||
{
|
||||
$options = array_map(
|
||||
static fn (array $option): string => sprintf('%s(%d)', $option['label'], $option['bit']),
|
||||
$ctx['config_options']
|
||||
);
|
||||
|
||||
return $options === [] ? '未配置任何卡款原因' : implode('、', $options);
|
||||
}
|
||||
|
||||
private function configSourceText(string $source): string
|
||||
{
|
||||
return match ($source) {
|
||||
'agent' => '代理级 configs 配置',
|
||||
'global' => '全局 configs 配置',
|
||||
default => '内置默认配置(未读到 configs 表)',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 归属代理检查 - 必须满足:
|
||||
* 1) entity.agent_code === operatorCode
|
||||
@@ -190,7 +482,7 @@ class ProductionDiagnosisService
|
||||
$detail = match (true) {
|
||||
$entityAgentCode === '' => '归属代理 agent_code 为空,无法定位操作代理',
|
||||
$entityAgentCode !== $operatorCode => '当前操作代理 '.$operatorCode.' 与单据归属代理 '.$entityAgentCode.' 不一致',
|
||||
!$exists => '结算代理表 '.$settlementTable.' 中未找到 code='.$entityCode.', agent_code='.$operatorCode.' 的有效记录',
|
||||
! $exists => '结算代理表 '.$settlementTable.' 中未找到 code='.$entityCode.', agent_code='.$operatorCode.' 的有效记录',
|
||||
default => '操作代理为归属代理,且在结算代理表中存在有效记录',
|
||||
};
|
||||
|
||||
@@ -219,7 +511,7 @@ class ProductionDiagnosisService
|
||||
{
|
||||
$productCode = (string) ($entity->product_code ?? '');
|
||||
|
||||
if (!$operatorAgent) {
|
||||
if (! $operatorAgent) {
|
||||
return [
|
||||
'key' => 'credit',
|
||||
'label' => '账期检查',
|
||||
@@ -249,7 +541,7 @@ class ProductionDiagnosisService
|
||||
->where('deleted', 0)
|
||||
->first();
|
||||
|
||||
if (!$relation) {
|
||||
if (! $relation) {
|
||||
return [
|
||||
'key' => 'credit',
|
||||
'label' => '账期检查',
|
||||
@@ -303,9 +595,9 @@ class ProductionDiagnosisService
|
||||
$pass = $lastCreditAgentCode !== '' && $lastCreditAgentCode === $operatorCode;
|
||||
|
||||
$detail = match (true) {
|
||||
!$crmConfigured => 'CRM 接口未配置(CRM_SERVICE_BASE_URI),一级代理账期视为「未知」,链路计算可能与生产不一致',
|
||||
! $crmConfigured => 'CRM 接口未配置(CRM_SERVICE_BASE_URI),一级代理账期视为「未知」,链路计算可能与生产不一致',
|
||||
$firstAgentCreditMap === null => '调用 CRM 一级代理详情失败,无法判断一级代理账期',
|
||||
!$firstAgentHasCredit => '一级代理 '.$rootAgentCode.' 在产品 '.$productCode.' 上无账期,AgentCredit 返回空,账期判断必然失败',
|
||||
! $firstAgentHasCredit => '一级代理 '.$rootAgentCode.' 在产品 '.$productCode.' 上无账期,AgentCredit 返回空,账期判断必然失败',
|
||||
$lastCreditAgentCode === '' => '账期链路计算结果为空',
|
||||
$pass => '最后一级有账期的代理 = 单据归属代理('.$operatorCode.')',
|
||||
default => '最后一级有账期的代理为 '.$lastCreditAgentCode.',与单据归属代理 '.$operatorCode.' 不一致',
|
||||
@@ -348,7 +640,7 @@ class ProductionDiagnosisService
|
||||
'credit_source' => 'crm:getAgentByCode',
|
||||
];
|
||||
|
||||
if (!$firstAgentHasCredit) {
|
||||
if (! $firstAgentHasCredit) {
|
||||
return [
|
||||
'last_credit_agent_code' => '',
|
||||
'chain' => $chainView,
|
||||
@@ -377,8 +669,9 @@ class ProductionDiagnosisService
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$hasCredit) {
|
||||
if (! $hasCredit) {
|
||||
$broken = true;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -520,7 +813,10 @@ class ProductionDiagnosisService
|
||||
return $type === self::TYPE_CASE ? (string) $entity->case_code : (string) $entity->code;
|
||||
}
|
||||
|
||||
private function normalizeEntity(string $type, object $entity): array
|
||||
/**
|
||||
* @param array<string,mixed>|null $pfpContext
|
||||
*/
|
||||
private function normalizeEntity(string $type, object $entity, ?array $pfpContext = null): array
|
||||
{
|
||||
$base = [
|
||||
'status' => (int) $entity->status,
|
||||
@@ -538,6 +834,9 @@ class ProductionDiagnosisService
|
||||
'patient_name' => (string) ($entity->patient_name ?? ''),
|
||||
'is_need_pfp' => (int) ($entity->is_need_pfp ?? 0),
|
||||
'is_pfp' => (int) ($entity->is_pfp ?? 0),
|
||||
'debt_reason_text' => $pfpContext['reason_text'] ?? '无',
|
||||
'is_pfp_text' => $pfpContext['is_pfp_text'] ?? ((int) ($entity->is_pfp ?? 0) > 0 ? '已放行' : '未放行'),
|
||||
'crm_label_bit_text' => $pfpContext['crm_label_bit_text'] ?? '未知',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -549,13 +848,13 @@ class ProductionDiagnosisService
|
||||
|
||||
private function creditHint(bool $crmConfigured, ?array $firstAgentCreditMap, bool $firstAgentHasCredit): string
|
||||
{
|
||||
if (!$crmConfigured) {
|
||||
if (! $crmConfigured) {
|
||||
return '配置 .env 中的 CRM_SERVICE_BASE_URI 后可获得准确的一级代理账期判断';
|
||||
}
|
||||
if ($firstAgentCreditMap === null) {
|
||||
return 'CRM 接口调用失败,可查看 laravel.log';
|
||||
}
|
||||
if (!$firstAgentHasCredit) {
|
||||
if (! $firstAgentHasCredit) {
|
||||
return '需在 CRM 「集团详情」productList 中确认该产品的 agentAccountingPeriod > 0';
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ class ScheduledTaskService
|
||||
try {
|
||||
self::$configServiceInstance ??= app(ConfigService::class);
|
||||
$enabled = self::$configServiceInstance->get(self::CONFIG_KEY, []);
|
||||
|
||||
return $enabled[$name] ?? false;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
@@ -70,7 +71,7 @@ class ScheduledTaskService
|
||||
}
|
||||
}
|
||||
|
||||
if (!$exists) {
|
||||
if (! $exists) {
|
||||
throw new \InvalidArgumentException("未知任务: {$name}");
|
||||
}
|
||||
|
||||
@@ -111,8 +112,10 @@ class ScheduledTaskService
|
||||
if (str_contains($command, 'artisan')) {
|
||||
$command = preg_replace('/^.*artisan\s+/', '', $command);
|
||||
}
|
||||
|
||||
return trim(str_replace("'", '', $command));
|
||||
}
|
||||
|
||||
return 'closure';
|
||||
}
|
||||
|
||||
@@ -132,9 +135,11 @@ class ScheduledTaskService
|
||||
'0 0 * * *' => '每天凌晨 0:00',
|
||||
'0 2 * * *' => '每天凌晨 2:00',
|
||||
'0 3 * * *' => '每天凌晨 3:00',
|
||||
'0 8 * * *' => '每天早上 08:00',
|
||||
'0 0 * * 0' => '每周日凌晨',
|
||||
'0 0 1 * *' => '每月 1 日凌晨',
|
||||
];
|
||||
|
||||
return $map[$expression] ?? $expression;
|
||||
}
|
||||
|
||||
@@ -149,9 +154,11 @@ class ScheduledTaskService
|
||||
'daily-log-analysis' => 'SLS 日志分析 - 每日分析过去 24 小时日志',
|
||||
'frequent-log-analysis' => 'SLS 日志分析 - 定期分析过去 6 小时日志',
|
||||
'jenkins-monitor' => 'Jenkins 发布监控 - 检查新构建并发送通知',
|
||||
'erp-request-report' => 'ERP 请求日报 - 汇总前一天 OpenAPI 请求并发送钉钉',
|
||||
'scheduled-task-refresh' => '定时任务管理 - 刷新定时任务列表',
|
||||
'logs-cleanup' => '日志清理 - 自动删除 7 天前的定时任务日志',
|
||||
];
|
||||
|
||||
return $descriptions[$name] ?? $name;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user