Files
anxinyan/server-api/app/controller/app/ReportsController.php
2026-06-04 12:08:16 +08:00

746 lines
29 KiB
PHP

<?php
namespace app\controller\app;
use app\model\Report;
use app\support\AppraisalEvidenceService;
use app\support\AppAuthService;
use app\support\ContentService;
use app\support\FileStorageService;
use app\support\ReportPdfGenerator;
use support\Request;
use support\think\Db;
class ReportsController
{
public function index(Request $request)
{
$userId = app_user_id($request);
$rows = Db::name('orders')
->alias('o')
->leftJoin('reports r', 'r.order_id = o.id AND r.report_status = "published"')
->leftJoin('order_products p', 'p.order_id = o.id')
->field([
'o.id AS order_id',
'o.order_status',
'o.display_status',
'o.service_provider',
'p.product_name',
'p.product_cover',
'r.id AS report_id',
'r.report_no',
'r.institution_name',
'r.publish_time',
])
->where('o.user_id', $userId)
->whereIn('o.order_status', ['in_first_review', 'in_final_review', 'generating_report', 'report_published', 'completed'])
->whereRaw('r.id IS NOT NULL')
->order('o.id', 'desc')
->select()
->toArray();
$list = array_map(function (array $item) {
$published = !empty($item['report_id']);
return [
'report_id' => $published ? (int)$item['report_id'] : null,
'order_id' => (int)$item['order_id'],
'report_no' => $item['report_no'] ?: '',
'product_name' => $item['product_name'] ?: '',
'product_cover' => $item['product_cover'] ?: '',
'service_provider' => $item['service_provider'],
'status' => $published ? '已出报告' : '待出报告',
'result_text' => $published ? '正品' : '待出报告',
'institution_name' => $this->displayInstitutionName((string)$item['service_provider']),
'publish_time' => $item['publish_time'],
];
}, $rows);
return api_success(['list' => $list]);
}
public function detail(Request $request)
{
$id = (int)$request->input('id', 0);
$reportNo = trim((string)$request->input('report_no', ''));
if (!$id && $reportNo === '') {
return api_error('报告标识不能为空', 422);
}
$report = null;
if ($reportNo !== '') {
$report = Report::where('report_status', 'published')
->where('report_no', $reportNo)
->find();
} elseif ($id > 0) {
$userInfo = app_user($request) ?: (new AppAuthService())->current($request);
if (!$userInfo) {
return api_error('未登录或登录已过期', 401);
}
$report = Db::name('reports')
->alias('r')
->join('orders o', 'o.id = r.order_id')
->where('r.id', $id)
->where('r.report_status', 'published')
->where('o.user_id', (int)$userInfo['id'])
->field('r.*')
->find();
}
if (!$report) {
return api_error('报告不存在', 404);
}
$reportData = is_array($report) ? $report : $report->toArray();
$content = Db::name('report_contents')->where('report_id', $reportData['id'])->find();
$verify = Db::name('report_verifies')->where('report_id', $reportData['id'])->find() ?: [];
$verify = $this->normalizeVerifyInfo($reportData, $verify);
$evidenceAttachments = $this->evidenceService()->normalize($content['evidence_attachments_json'] ?? null, $request);
$zhongjianReportFiles = $this->evidenceService()->normalize($content['zhongjian_report_files_json'] ?? null, $request);
$defaultRiskNotice = (new ContentService())->getReportRiskNotice((string)($reportData['report_type'] ?? 'appraisal'));
$payload = [
'product_snapshot' => $this->decodeJsonField($content['product_snapshot_json'] ?? null),
'result_snapshot' => $this->decodeJsonField($content['result_snapshot_json'] ?? null),
'appraisal_snapshot' => $this->decodeJsonField($content['appraisal_snapshot_json'] ?? null),
'valuation_snapshot' => $this->decodeJsonField($content['valuation_snapshot_json'] ?? null),
'risk_notice_text' => ($content['risk_notice_text'] ?? '') !== '' ? $content['risk_notice_text'] : $defaultRiskNotice,
];
$productDisplay = $this->buildProductDisplay(
$reportData,
$payload['product_snapshot'],
$payload['result_snapshot'],
$payload['valuation_snapshot'],
$payload['appraisal_snapshot']
);
$reportMedia = [
'images' => $this->filterAssetsByType($evidenceAttachments, 'image'),
];
$traceInfoVisible = (int)($reportData['trace_info_visible'] ?? 0) === 1;
$traceInfo = $traceInfoVisible
? $this->buildTraceInfo(
(int)($reportData['order_id'] ?? 0),
$payload['appraisal_snapshot'],
$evidenceAttachments,
$request
)
: ['visible' => false, 'nodes' => []];
$traceInfo['visible'] = $traceInfoVisible;
$pdfProductDisplay = $productDisplay;
$pdfProductDisplay['items'] = array_values(array_filter(
$productDisplay['items'] ?? [],
fn (array $item) => ($item['label'] ?? '') !== '服务类型'
));
$pdfUrl = $this->ensurePdfFile($request, $reportData, $content ?: [], $verify ?: [], $pdfProductDisplay, $reportMedia);
return api_success([
'report_header' => [
'report_id' => (int)$reportData['id'],
'report_no' => $reportData['report_no'],
'report_type' => $reportData['report_type'] ?? 'appraisal',
'report_title' => $reportData['report_title'],
'report_status' => $reportData['report_status'],
'service_provider' => $reportData['service_provider'],
'service_provider_text' => $this->serviceProviderText((string)$reportData['service_provider']),
'institution_name' => $this->displayInstitutionName((string)$reportData['service_provider']),
'publish_time' => $reportData['publish_time'],
'zhongjian_report_no' => (string)($reportData['zhongjian_report_no'] ?? ''),
'report_entry_admin_name' => (string)($reportData['report_entry_admin_name'] ?? ''),
'report_entered_at' => (string)($reportData['report_entered_at'] ?? ''),
'trace_info_visible' => $traceInfoVisible,
],
'result_info' => $payload['result_snapshot'],
'product_info' => $payload['product_snapshot'],
'appraisal_info' => $payload['appraisal_snapshot'],
'valuation_info' => $payload['valuation_snapshot'],
'evidence_attachments' => $evidenceAttachments,
'zhongjian_report_files' => $zhongjianReportFiles,
'report_media' => $reportMedia,
'product_display' => $productDisplay,
'trace_info' => $traceInfo,
'risk_notice_text' => $payload['risk_notice_text'],
'verify_info' => [
'report_no' => $reportData['report_no'],
'verify_status' => $verify['verify_status'] ?? 'valid',
'verify_url' => $verify['verify_url'] ?? '',
'verify_qrcode_url' => $verify['verify_qrcode_url'] ?? '',
],
'file_info' => [
'pdf_url' => $pdfUrl,
],
]);
}
public function antiCounterfeitVerify(Request $request)
{
$reportNo = trim((string)$request->input('report_no', ''));
$verifyCode = trim((string)$request->input('verify_code', ''));
if ($reportNo === '' || $verifyCode === '') {
return api_error('报告编号和防伪查询码不能为空', 422);
}
if (!preg_match('/^\d{6}$/', $verifyCode)) {
return api_error('防伪查询码应为 6 位数字', 422);
}
$report = Db::name('reports')
->where('report_no', $reportNo)
->where('report_status', 'published')
->find();
if (!$report) {
return api_success([
'verify_passed' => false,
'verify_message' => '未查询到有效报告,请核对报告编号后重试。',
'verify_count' => 0,
]);
}
$tag = Db::name('material_tag_codes')
->where('report_id', (int)$report['id'])
->where('bind_status', 'bound')
->find();
if (!$tag) {
return api_success([
'verify_passed' => false,
'verify_message' => '当前报告尚未绑定防伪吊牌,暂不能完成防伪查询。',
'verify_count' => 0,
]);
}
$batch = Db::name('material_batches')->where('id', (int)$tag['batch_id'])->find();
$active = ($tag['status'] ?? 'active') !== 'invalid'
&& (!$batch || ($batch['status'] ?? 'active') !== 'invalid');
$passed = $active
&& hash_equals((string)$tag['verify_code'], $verifyCode)
&& (string)($tag['report_no'] ?: $report['report_no']) === $reportNo;
$now = date('Y-m-d H:i:s');
if ($passed) {
Db::name('material_tag_codes')->where('id', (int)$tag['id'])->update([
'verify_count' => (int)$tag['verify_count'] + 1,
'last_verified_at' => $now,
'updated_at' => $now,
]);
}
$this->insertMaterialTagVerifyLog($tag, $reportNo, $verifyCode, $passed, $request, $now);
if (!$active) {
return api_success([
'verify_passed' => false,
'verify_message' => '该防伪吊牌已失效,不能进行防伪查询。',
'verify_count' => (int)$tag['verify_count'],
]);
}
return api_success([
'verify_passed' => $passed,
'verify_message' => $passed
? '防伪查询通过,该报告编号与吊牌防伪查询码匹配。'
: '防伪查询码与当前报告不匹配,请核对吊牌后重试。',
'verify_count' => (int)$tag['verify_count'] + ($passed ? 1 : 0),
]);
}
private function decodeJsonField(mixed $value): array
{
if (is_array($value)) {
return $value;
}
if (is_string($value) && $value !== '') {
return json_decode($value, true) ?: [];
}
return [];
}
private function normalizeVerifyInfo(array $report, array $verify): array
{
$reportNo = (string)($report['report_no'] ?? '');
$verifyPageUrl = $this->buildPublicPageUrl('/pages/verify/result', ['report_no' => $reportNo]);
$verify['report_no'] = $verify['report_no'] ?? $reportNo;
$verify['verify_status'] = $verify['verify_status'] ?? 'valid';
$rawVerifyUrl = trim((string)($verify['verify_url'] ?? ''));
if ($rawVerifyUrl === '' || str_starts_with($rawVerifyUrl, '/api/app/verify')) {
$verify['verify_url'] = $verifyPageUrl;
}
$rawQrValue = trim((string)($verify['verify_qrcode_url'] ?? ''));
if ($rawQrValue === '' || str_starts_with($rawQrValue, '/api/app/verify') || str_contains($rawQrValue, '/pages/report/detail')) {
$verify['verify_qrcode_url'] = $verify['verify_url'];
}
return $verify;
}
private function ensurePdfFile(Request $request, array $report, array $content, array $verify, array $productDisplay, array $reportMedia): string
{
$existingFile = Db::name('report_files')
->where('report_id', (int)$report['id'])
->where('file_type', 'pdf')
->find();
$publishTime = (string)($report['publish_time'] ?: date('Y-m-d H:i:s'));
$relativeDir = 'uploads/reports/' . date('Ymd', strtotime($publishTime));
$filename = $report['report_no'] . '-v3.pdf';
$relativePath = $relativeDir . '/' . $filename;
if ($existingFile && !empty($existingFile['file_url'])) {
$relativeUrl = ltrim((string)$existingFile['file_url'], '/');
if ($relativeUrl === $relativePath && !$this->shouldRebuildPdf($existingFile, $report, $content) && $this->storage()->exists($relativeUrl)) {
return $this->storage()->publicUrl($request, $relativeUrl);
}
}
$productInfo = $this->decodeJsonField($content['product_snapshot_json'] ?? null);
$resultInfo = $this->decodeJsonField($content['result_snapshot_json'] ?? null);
$defaultRiskNotice = (new ContentService())->getReportRiskNotice((string)($report['report_type'] ?? 'appraisal'));
$generator = new ReportPdfGenerator();
$pdfBinary = $generator->generate([
'report_title' => $report['report_title'] ?? '鉴定报告',
'service_provider_text' => $this->serviceProviderText((string)($report['service_provider'] ?? 'anxinyan')),
'institution_name' => $this->displayInstitutionName((string)($report['service_provider'] ?? 'anxinyan')),
'report_no' => $report['report_no'] ?? '',
'publish_time' => $publishTime,
'result_text' => $resultInfo['result_text'] ?? '-',
'result_desc' => $resultInfo['result_desc'] ?? '-',
'product_name' => $productInfo['product_name'] ?? '-',
'product_items' => $productDisplay['items'] ?? [],
'hero_image' => $this->firstPdfHeroImage($reportMedia['images'] ?? []),
'hero_image_labels' => $this->assetNameList($reportMedia['images'] ?? []),
'verify_info' => sprintf(
'%s / %s',
$verify['report_no'] ?? ($report['report_no'] ?? '-'),
(($verify['verify_status'] ?? 'valid') === 'valid' ? '有效' : ($verify['verify_status'] ?? '-'))
),
'risk_notice_text' => ($content['risk_notice_text'] ?? '') !== '' ? $content['risk_notice_text'] : ($defaultRiskNotice !== '' ? $defaultRiskNotice : '-'),
]);
$this->storage()->putContentsWithMimeType($relativePath, $pdfBinary, 'application/pdf');
$now = date('Y-m-d H:i:s');
$filePayload = [
'report_id' => (int)$report['id'],
'file_type' => 'pdf',
'file_url' => '/' . $relativePath,
'file_status' => 'ready',
'updated_at' => $now,
];
if ($existingFile) {
Db::name('report_files')->where('id', $existingFile['id'])->update($filePayload);
} else {
$filePayload['created_at'] = $now;
Db::name('report_files')->insert($filePayload);
}
return $this->storage()->publicUrl($request, $relativePath);
}
private function buildProductDisplay(array $report, array $productInfo, array $resultInfo, array $valuationInfo = [], array $appraisalInfo = []): array
{
$items = [];
$this->appendDisplayItem(
$items,
'检测结论',
$this->textValue($resultInfo['result_text'] ?? '') ?: '-',
$this->textValue($resultInfo['result_desc'] ?? ''),
true
);
foreach ([
'品类' => $productInfo['category_name'] ?? '',
'品牌' => $productInfo['brand_name'] ?? '',
'颜色' => $productInfo['color'] ?? '',
'规格/尺寸' => $productInfo['size_spec'] ?? '',
'序列号/编码' => $productInfo['serial_no'] ?? '',
] as $label => $value) {
$this->appendDisplayItem($items, $label, $value);
}
foreach (($resultInfo['key_points'] ?? []) as $point) {
if (!is_array($point)) {
continue;
}
$label = $this->textValue($point['point_name'] ?? '');
if ($label === '') {
continue;
}
if ($this->displayItemIndex($items, $label) !== null) {
continue;
}
$this->appendDisplayItem(
$items,
$label,
$this->textValue($point['point_value'] ?? '') ?: '-',
'',
true
);
}
$this->appendDisplayItem(
$items,
'服务类型',
$this->serviceProviderText((string)($report['service_provider'] ?? 'anxinyan'))
);
$appraiserName = $this->textValue($appraisalInfo['appraiser_name'] ?? '')
?: $this->textValue($appraisalInfo['reviewer_name'] ?? '')
?: $this->textValue($report['report_entry_admin_name'] ?? '');
$this->appendDisplayItem($items, '鉴定师', $appraiserName);
$conditionGrade = $this->textValue($valuationInfo['condition_grade'] ?? '');
$conditionDesc = $this->textValue($valuationInfo['condition_desc'] ?? '');
if ($conditionGrade !== '' || $conditionDesc !== '') {
$this->appendDisplayItem($items, '成色评级', $conditionGrade ?: '-', $conditionDesc, true);
}
$valuationRange = $this->formatValuationRange($valuationInfo['valuation_min'] ?? 0, $valuationInfo['valuation_max'] ?? 0);
$valuationDesc = $this->textValue($valuationInfo['valuation_desc'] ?? '');
if ($valuationRange !== '' || $valuationDesc !== '') {
$this->appendDisplayItem($items, '估值区间', $valuationRange ?: '-', $valuationDesc, true);
}
$externalRemark = $this->textValue($resultInfo['external_remark'] ?? '');
if ($externalRemark !== '') {
$this->appendDisplayItem($items, '备注', $externalRemark);
}
return [
'product_name' => $this->textValue($productInfo['product_name'] ?? '') ?: '-',
'institution_name' => $this->displayInstitutionName((string)($report['service_provider'] ?? 'anxinyan')),
'items' => $items,
];
}
private function displayItemIndex(array $items, string $label): ?int
{
foreach ($items as $index => $item) {
if (($item['label'] ?? '') === $label) {
return $index;
}
}
return null;
}
private function appendDisplayItem(array &$items, string $label, mixed $value, mixed $remark = '', bool $keepEmpty = false): void
{
$valueText = $this->textValue($value);
$remarkText = $this->textValue($remark);
if (!$keepEmpty && $valueText === '' && $remarkText === '') {
return;
}
$items[] = [
'label' => $label,
'value' => $valueText !== '' ? $valueText : '-',
'remark' => $remarkText,
];
}
private function formatValuationRange(mixed $min, mixed $max): string
{
$minValue = (float)($min ?? 0);
$maxValue = (float)($max ?? 0);
if ($minValue <= 0 && $maxValue <= 0) {
return '';
}
if ($minValue > 0 && $maxValue > 0) {
return '¥' . $this->formatMoney($minValue) . ' - ¥' . $this->formatMoney($maxValue);
}
if ($minValue > 0) {
return '¥' . $this->formatMoney($minValue) . ' 起';
}
return '¥' . $this->formatMoney($maxValue) . ' 内';
}
private function formatMoney(float $value): string
{
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
}
private function buildTraceInfo(int $orderId, array $appraisalInfo, array $evidenceAttachments, Request $request): array
{
$logs = $orderId > 0
? Db::name('order_transfer_flow_logs')
->where('order_id', $orderId)
->whereIn('action_code', ['inbound_received', 'return_shipped'])
->order('id', 'asc')
->select()
->toArray()
: [];
$inboundLog = $this->findFlowLog($logs, 'inbound_received');
$returnLog = $this->findFlowLog($logs, 'return_shipped');
$inboundPayload = $this->decodeJsonObject($inboundLog['payload_json'] ?? null);
$returnPayload = $this->decodeJsonObject($returnLog['payload_json'] ?? null);
$appraisalFinishedAt = $this->appraisalFinishedAt($orderId, $appraisalInfo);
$appraisalVideoAssets = $this->filterAssetsByType($evidenceAttachments, 'video');
$nodes = [
[
'code' => 'inbound',
'title' => '入仓',
'occurred_at' => (string)($inboundLog['created_at'] ?? ''),
'status' => $inboundLog ? 'completed' : 'pending',
'assets' => $this->evidenceService()->normalize($inboundPayload['inbound_attachments'] ?? [], $request),
],
[
'code' => 'appraisal',
'title' => '鉴定',
'occurred_at' => $appraisalFinishedAt,
'status' => ($appraisalFinishedAt !== '' || $appraisalVideoAssets) ? 'completed' : 'pending',
'assets' => $appraisalVideoAssets,
],
[
'code' => 'return',
'title' => '寄回',
'occurred_at' => (string)($returnLog['created_at'] ?? ''),
'status' => $returnLog ? 'completed' : 'pending',
'assets' => $this->evidenceService()->normalize($returnPayload['packing_attachments'] ?? [], $request),
],
];
return ['nodes' => $nodes];
}
private function findFlowLog(array $logs, string $actionCode): ?array
{
$matched = null;
foreach ($logs as $log) {
if (($log['action_code'] ?? '') === $actionCode) {
$matched = $log;
}
}
return $matched;
}
private function appraisalFinishedAt(int $orderId, array $appraisalInfo): string
{
if ($orderId > 0) {
$submittedAt = Db::name('appraisal_tasks')
->where('order_id', $orderId)
->whereRaw('submitted_at IS NOT NULL')
->order('submitted_at', 'desc')
->value('submitted_at');
if ($submittedAt) {
return (string)$submittedAt;
}
}
return $this->textValue($appraisalInfo['appraisal_time'] ?? '');
}
private function filterAssetsByType(array $assets, string $fileType): array
{
return array_values(array_filter($assets, fn (array $asset) => (string)($asset['file_type'] ?? '') === $fileType));
}
private function shouldRebuildPdf(array $existingFile, array $report, array $content): bool
{
$fileUpdatedAt = strtotime((string)($existingFile['updated_at'] ?? '')) ?: 0;
if ($fileUpdatedAt <= 0) {
return true;
}
foreach ([$report['updated_at'] ?? '', $content['updated_at'] ?? ''] as $timestamp) {
if ((strtotime((string)$timestamp) ?: 0) > $fileUpdatedAt) {
return true;
}
}
return false;
}
private function firstPdfHeroImage(array $images): ?array
{
foreach ($images as $image) {
$binary = $this->readAssetBinary((string)($image['file_url'] ?? ''));
if ($binary === '' || strlen($binary) > 5 * 1024 * 1024) {
continue;
}
$info = @getimagesizefromstring($binary);
if (!$info) {
continue;
}
if ((int)($info[2] ?? 0) !== IMAGETYPE_JPEG) {
$converted = $this->convertImageBinaryToJpeg($binary);
if ($converted === '') {
continue;
}
$binary = $converted;
}
return [
'data' => $binary,
'width' => (int)$info[0],
'height' => (int)$info[1],
];
}
return null;
}
private function convertImageBinaryToJpeg(string $binary): string
{
if (!function_exists('imagecreatefromstring')) {
return '';
}
$image = @imagecreatefromstring($binary);
if (!$image) {
return '';
}
$width = imagesx($image);
$height = imagesy($image);
$canvas = imagecreatetruecolor($width, $height);
if (!$canvas) {
imagedestroy($image);
return '';
}
$background = imagecolorallocate($canvas, 255, 255, 255);
imagefill($canvas, 0, 0, $background);
imagecopy($canvas, $image, 0, 0, 0, 0, $width, $height);
ob_start();
imagejpeg($canvas, null, 86);
$jpeg = (string)ob_get_clean();
imagedestroy($canvas);
imagedestroy($image);
return $jpeg;
}
private function readAssetBinary(string $fileUrl): string
{
$fileUrl = trim($fileUrl);
if ($fileUrl === '') {
return '';
}
$relativePath = $this->storage()->storagePath($fileUrl);
$localPath = public_path() . '/' . $relativePath;
if (is_file($localPath)) {
return (string)file_get_contents($localPath);
}
if (preg_match('/^https?:\/\//i', $fileUrl)) {
$context = stream_context_create([
'http' => ['timeout' => 3],
'ssl' => ['verify_peer' => false, 'verify_peer_name' => false],
]);
return (string)(@file_get_contents($fileUrl, false, $context) ?: '');
}
return '';
}
private function assetNameList(array $assets): array
{
return array_values(array_filter(array_map(function (array $asset) {
return $this->textValue($asset['name'] ?? '') ?: $this->textValue($asset['file_id'] ?? '');
}, $assets)));
}
private function insertMaterialTagVerifyLog(array $tag, string $reportNo, string $verifyCode, bool $passed, Request $request, string $now): void
{
Db::name('material_tag_scan_logs')->insert([
'tag_code_id' => (int)$tag['id'],
'batch_id' => (int)$tag['batch_id'],
'report_id' => (int)($tag['report_id'] ?? 0) ?: null,
'report_no' => $reportNo,
'verify_type' => 'report_page_code',
'verify_code_input' => mb_substr($verifyCode, 0, 16),
'verify_passed' => $passed ? 1 : 0,
'ip' => (string)$request->getRealIp(),
'user_agent' => mb_substr((string)$request->header('user-agent', ''), 0, 500),
'scanned_at' => $now,
'created_at' => $now,
]);
}
private function decodeJsonObject(mixed $value): array
{
if (is_array($value)) {
return $value;
}
if (is_string($value) && $value !== '') {
$decoded = json_decode($value, true);
return is_array($decoded) ? $decoded : [];
}
return [];
}
private function textValue(mixed $value): string
{
return trim((string)($value ?? ''));
}
private function serviceProviderText(string $serviceProvider): string
{
return $serviceProvider === 'zhongjian' ? '中检鉴定' : '实物鉴定';
}
private function displayInstitutionName(string $serviceProvider): string
{
return $serviceProvider === 'zhongjian' ? '中检鉴定中心' : '安心检验';
}
private function buildPublicPageUrl(string $pagePath, array $query = []): string
{
$baseUrl = $this->normalizeH5BaseUrl($this->getSystemConfigValue('h5', 'page_base_url'));
$page = ltrim($pagePath, '/');
$queryString = http_build_query($query);
$hashPath = '/#/' . $page;
if ($queryString !== '') {
$hashPath .= '?' . $queryString;
}
if ($baseUrl === '') {
return $hashPath;
}
return $baseUrl . $hashPath;
}
private function normalizeH5BaseUrl(string $value): string
{
$baseUrl = trim($value);
if ($baseUrl === '') {
return '';
}
$hashPos = strpos($baseUrl, '#');
if ($hashPos !== false) {
$baseUrl = substr($baseUrl, 0, $hashPos);
}
if (!preg_match('/^https?:\/\//i', $baseUrl)) {
$baseUrl = 'https://' . ltrim($baseUrl, '/');
}
return rtrim($baseUrl, '/');
}
private function getSystemConfigValue(string $groupCode, string $configKey): string
{
$row = Db::name('system_configs')
->where('config_group', $groupCode)
->where('config_key', $configKey)
->find();
return trim((string)($row['config_value'] ?? ''));
}
private function evidenceService(): AppraisalEvidenceService
{
return new AppraisalEvidenceService();
}
private function storage(): FileStorageService
{
return new FileStorageService();
}
}