增加了手机操作端

This commit is contained in:
wushumin
2026-05-15 14:01:36 +08:00
parent 9aac78b8da
commit dd56e0861b
107 changed files with 23547 additions and 346 deletions

View File

@@ -5,6 +5,7 @@ namespace app\controller\admin;
use app\support\AppraisalEvidenceService;
use app\support\ContentService;
use app\support\EnterpriseWebhookService;
use app\support\FulfillmentFlowService;
use app\support\MessageDispatcher;
use app\support\MaterialTagService;
use app\support\PublicAssetUrlService;
@@ -19,9 +20,14 @@ class AppraisalTasksController
$taskStage = trim((string)$request->input('task_stage', ''));
$status = trim((string)$request->input('status', ''));
$serviceProvider = trim((string)$request->input('service_provider', ''));
$scope = trim((string)$request->input('scope', ''));
$paginationEnabled = $request->input('page', null) !== null || $request->input('page_size', null) !== null;
$page = max(1, (int)$request->input('page', 1));
$pageSize = max(1, min(100, (int)$request->input('page_size', 20)));
$query = $this->buildTaskBaseQuery()
->whereRaw($this->workbenchVisibleOrderStatusSql());
$this->applyTaskScopeFilter($query, $request, $scope);
if ($keyword !== '') {
$query->where(function ($builder) use ($keyword) {
@@ -49,7 +55,12 @@ class AppraisalTasksController
$matchedRows = $query->select()->toArray();
if (!$matchedRows) {
return api_success(['list' => []]);
return api_success($paginationEnabled ? [
'list' => [],
'total' => 0,
'page' => $page,
'page_size' => $pageSize,
] : ['list' => []]);
}
$orderIds = array_values(array_unique(array_map(fn (array $item) => (int)$item['order_id'], $matchedRows)));
@@ -58,12 +69,26 @@ class AppraisalTasksController
$allRows = $this->buildTaskBaseQuery()
->whereRaw($this->workbenchVisibleOrderStatusSql())
->whereIn('t.order_id', $orderIds)
->group('t.id')
->order('t.order_id', 'desc')
->order('t.id', 'desc')
->select()
->toArray();
$this->applyTaskScopeFilterRows($allRows, $request, $scope);
$list = $this->buildGroupedTaskList($allRows, $reportMap);
$total = count($list);
if ($paginationEnabled) {
$offset = ($page - 1) * $pageSize;
$list = array_slice($list, $offset, $pageSize);
return api_success([
'list' => $list,
'total' => $total,
'page' => $page,
'page_size' => $pageSize,
]);
}
return api_success(['list' => $list]);
}
@@ -81,7 +106,10 @@ class AppraisalTasksController
->leftJoin('order_products p', 'p.order_id = t.order_id')
->leftJoin('order_extras e', 'e.order_id = t.order_id')
->leftJoin('appraisal_task_results r', 'r.task_id = t.id')
->leftJoin('reports rp', 'rp.order_id = t.order_id AND rp.report_type = "appraisal"')
->leftJoin('report_contents rc', 'rc.report_id = rp.id')
->leftJoin('enterprise_customer_order_refs ecr', 'ecr.order_id = t.order_id')
->order('rp.id', 'desc')
->field([
't.id',
't.order_id',
@@ -123,6 +151,11 @@ class AppraisalTasksController
'r.attachments_json as result_attachments_json',
'r.external_remark',
'r.internal_remark',
'rp.zhongjian_report_no',
'rp.report_entry_admin_id',
'rp.report_entry_admin_name',
'rp.report_entered_at',
'rc.zhongjian_report_files_json',
])
->where('t.id', $id)
->find();
@@ -195,6 +228,7 @@ class AppraisalTasksController
$stageTaskRows = $this->buildTaskBaseQuery()
->where('t.order_id', (int)$task['order_id'])
->group('t.id')
->order('t.id', 'asc')
->select()
->toArray();
@@ -279,6 +313,13 @@ class AppraisalTasksController
'report_status_text' => $this->reportStatusText($report['report_status']),
] : null,
'material_tag' => $materialTag,
'zhongjian_report' => [
'report_no' => (string)($task['zhongjian_report_no'] ?? ''),
'report_entry_admin_id' => (int)($task['report_entry_admin_id'] ?? 0),
'report_entry_admin_name' => (string)($task['report_entry_admin_name'] ?? ''),
'report_entered_at' => (string)($task['report_entered_at'] ?? ''),
'files' => $this->evidenceService()->normalize($task['zhongjian_report_files_json'] ?? null, $request),
],
'product_info' => [
'product_name' => $task['product_name'] ?: '',
'category_id' => (int)($task['category_id'] ?? 0),
@@ -332,6 +373,9 @@ class AppraisalTasksController
if (!$task) {
return api_error('任务不存在', 404);
}
if (($task['service_provider'] ?? '') === 'zhongjian') {
return api_error('中检订单不使用平台验真吊牌', 422);
}
$operatorGuard = $this->guardTaskOperator($request, $task);
if ($operatorGuard['error']) {
@@ -354,6 +398,193 @@ class AppraisalTasksController
], '吊牌已绑定');
}
public function scanTransferTag(Request $request)
{
try {
return api_success((new FulfillmentFlowService())->scanTransferForAppraisal(
(string)$request->input('internal_tag_no', ''),
$request
));
} catch (\InvalidArgumentException $e) {
return api_error($e->getMessage(), 422);
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), $e->getCode() ?: 404);
} catch (\Throwable $e) {
return api_error('内部流转码识别失败', 500, ['detail' => $e->getMessage()]);
}
}
public function publishWithMaterialTag(Request $request)
{
$id = (int)$request->input('id', 0);
$qrInput = trim((string)$request->input('qr_input', ''));
if ($id <= 0 || $qrInput === '') {
return api_error('任务 ID 和验真吊牌不能为空', 422);
}
$task = Db::name('appraisal_tasks')->where('id', $id)->find();
if (!$task) {
return api_error('任务不存在', 404);
}
if (($task['service_provider'] ?? '') === 'zhongjian') {
return api_error('中检订单不使用平台验真吊牌', 422);
}
try {
$tag = (new MaterialTagService())->bindTagToReportByTask($id, $qrInput, $request);
$report = $this->findLatestAppraisalReport((int)$task['order_id']);
if (!$report) {
return api_error('请先提交鉴定结论生成报告草稿', 422);
}
$publish = $this->publishReportRecord($report, $request);
(new FulfillmentFlowService())->markReportPublished((int)$task['order_id'], $request);
} catch (\InvalidArgumentException $e) {
return api_error($e->getMessage(), 422);
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), $e->getCode() ?: 404);
} catch (\Throwable $e) {
return api_error('验真吊牌绑定或报告发布失败', 500, ['detail' => $e->getMessage()]);
}
return api_success([
'id' => $id,
'material_tag' => $tag,
'report' => $publish,
], '验真吊牌已绑定,报告已发布');
}
public function saveZhongjianReport(Request $request)
{
$id = (int)$request->input('id', 0);
$reportNo = trim((string)$request->input('zhongjian_report_no', ''));
$files = $this->evidenceService()->normalize($request->input('report_files', []), $request, true);
if ($id <= 0) {
return api_error('任务 ID 不能为空', 422);
}
if ($reportNo === '') {
return api_error('中检报告编号不能为空', 422);
}
if (!$files) {
return api_error('请至少上传 1 个中检报告文件', 422);
}
$task = Db::name('appraisal_tasks')->where('id', $id)->find();
if (!$task) {
return api_error('任务不存在', 404);
}
if (($task['service_provider'] ?? '') !== 'zhongjian') {
return api_error('非中检订单不能录入中检报告', 422);
}
$operatorGuard = $this->guardTaskOperator($request, $task);
if ($operatorGuard['error']) {
return $operatorGuard['error'];
}
$operatorId = (int)$request->header('x-admin-id', 0);
$operatorName = trim((string)$request->header('x-admin-name', ''));
$now = date('Y-m-d H:i:s');
Db::startTrans();
try {
if ($operatorGuard['task_update']) {
Db::name('appraisal_tasks')->where('id', $id)->update(array_merge($operatorGuard['task_update'], [
'updated_at' => $now,
]));
$task = array_merge($task, $operatorGuard['task_update']);
}
Db::name('appraisal_tasks')->where('id', $id)->update([
'status' => 'completed',
'started_at' => $task['started_at'] ?: $now,
'submitted_at' => $now,
'updated_at' => $now,
]);
Db::name('orders')->where('id', (int)$task['order_id'])->update([
'order_status' => 'generating_report',
'display_status' => '正在生成报告',
'updated_at' => $now,
]);
$resultPayload = [
'task_id' => $id,
'order_id' => (int)$task['order_id'],
'result_status' => 'zhongjian_report',
'result_text' => '以中检报告为准',
'result_desc' => '中检报告已回传并由平台录入。',
'condition_grade' => '',
'condition_desc' => '',
'valuation_min' => 0,
'valuation_max' => 0,
'valuation_desc' => '',
'attachments_json' => json_encode($files, JSON_UNESCAPED_UNICODE),
'external_remark' => '',
'internal_remark' => '中检报告编号:' . $reportNo,
'updated_at' => $now,
];
$resultId = Db::name('appraisal_task_results')->where('task_id', $id)->value('id');
if ($resultId) {
Db::name('appraisal_task_results')->where('id', (int)$resultId)->update($resultPayload);
} else {
$resultPayload['created_at'] = $now;
Db::name('appraisal_task_results')->insert($resultPayload);
}
$this->createOrUpdateReportDraft((int)$task['order_id'], $task, $resultPayload, $now);
$report = $this->findLatestAppraisalReport((int)$task['order_id']);
if (!$report) {
Db::rollback();
return api_error('中检报告草稿生成失败', 500);
}
Db::name('reports')->where('id', (int)$report['id'])->update([
'zhongjian_report_no' => $reportNo,
'report_entry_admin_id' => $operatorId,
'report_entry_admin_name' => $operatorName,
'report_entered_at' => $now,
'updated_at' => $now,
]);
$content = Db::name('report_contents')->where('report_id', (int)$report['id'])->find();
if ($content) {
Db::name('report_contents')->where('id', (int)$content['id'])->update([
'zhongjian_report_files_json' => json_encode($files, JSON_UNESCAPED_UNICODE),
'updated_at' => $now,
]);
}
Db::name('order_timelines')->insert([
'order_id' => (int)$task['order_id'],
'node_code' => 'zhongjian_report_entered',
'node_text' => '中检报告已录入',
'node_desc' => '报告录入人已录入中检报告编号并上传报告文件。',
'operator_type' => 'admin',
'operator_id' => $operatorId,
'occurred_at' => $now,
'created_at' => $now,
]);
Db::commit();
$freshReport = $this->findLatestAppraisalReport((int)$task['order_id']);
$publish = $this->publishReportRecord($freshReport, $request);
(new FulfillmentFlowService())->markReportPublished((int)$task['order_id'], $request);
return api_success([
'id' => $id,
'report' => $publish,
], '中检报告已录入并发布');
} catch (\Throwable $e) {
try {
Db::rollback();
} catch (\Throwable $rollbackError) {
// Transaction may already be committed before publishing.
}
return api_error('中检报告录入失败', 500, ['detail' => $e->getMessage()]);
}
}
public function assignableAdmins(Request $request)
{
$id = (int)$request->input('id', 0);
@@ -537,7 +768,7 @@ class AppraisalTasksController
'node_text' => '正在生成报告',
'node_desc' => '鉴定已完成,系统正在生成正式报告草稿',
'operator_type' => 'admin',
'operator_id' => 1,
'operator_id' => (int)$request->header('x-admin-id', 0),
'occurred_at' => $now,
'created_at' => $now,
]);
@@ -654,7 +885,7 @@ class AppraisalTasksController
'reason' => $reason,
'deadline' => $deadline !== '' ? $deadline : null,
'status' => 'pending',
'created_by' => 1,
'created_by' => (int)$request->header('x-admin-id', 0),
'submitted_at' => null,
'approved_at' => null,
'created_at' => $now,
@@ -704,7 +935,7 @@ class AppraisalTasksController
'node_text' => '待补资料',
'node_desc' => $reason,
'operator_type' => 'admin',
'operator_id' => 1,
'operator_id' => (int)$request->header('x-admin-id', 0),
'occurred_at' => $now,
'created_at' => $now,
]);
@@ -797,10 +1028,43 @@ class AppraisalTasksController
'p.category_name',
'p.brand_id',
'p.brand_name',
'r.result_text',
'r.result_text',
]);
}
private function applyTaskScopeFilter($query, Request $request, string $scope): void
{
if ($scope !== 'my') {
return;
}
$adminId = (int)$request->header('x-admin-id', 0);
if ($adminId <= 0) {
return;
}
$query->whereRaw('(t.assignee_id = :scope_admin_id OR t.assignee_id IS NULL OR t.assignee_id = 0)', [
'scope_admin_id' => $adminId,
]);
}
private function applyTaskScopeFilterRows(array &$rows, Request $request, string $scope): void
{
if ($scope !== 'my') {
return;
}
$adminId = (int)$request->header('x-admin-id', 0);
if ($adminId <= 0) {
return;
}
$rows = array_values(array_filter($rows, function (array $row) use ($adminId) {
$assigneeId = (int)($row['assignee_id'] ?? 0);
return $assigneeId <= 0 || $assigneeId === $adminId;
}));
}
private function normalizeTaskListRow(array $item, ?array $report = null): array
{
$effectiveStatus = $this->effectiveTaskStatus($item, $report);
@@ -1602,6 +1866,176 @@ class AppraisalTasksController
return $admin;
}
private function publishReportRecord(array $report, Request $request): array
{
if (!$report) {
throw new \RuntimeException('报告不存在', 404);
}
if (!in_array((string)$report['report_status'], ['draft', 'pending_publish', 'updated', 'published'], true)) {
throw new \InvalidArgumentException('当前报告状态不支持发布');
}
$operatorId = (int)$request->header('x-admin-id', 0);
$now = date('Y-m-d H:i:s');
$effectivePublishTime = $report['publish_time'] ?: $now;
$usesPlatformVerify = (string)($report['service_provider'] ?? '') !== 'zhongjian';
$verify = [];
Db::startTrans();
try {
if (($report['report_status'] ?? '') !== 'published') {
Db::name('reports')->where('id', (int)$report['id'])->update([
'report_status' => 'published',
'publish_time' => $effectivePublishTime,
'updated_at' => $now,
]);
$report['report_status'] = 'published';
$report['publish_time'] = $effectivePublishTime;
}
if ($usesPlatformVerify) {
$verify = $this->createOrUpdateVerifyRecord($report, $now);
}
if (($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0) {
Db::name('orders')->where('id', (int)$report['order_id'])->update([
'order_status' => 'report_published',
'display_status' => '报告已出具',
'updated_at' => $now,
]);
$order = Db::name('orders')->where('id', (int)$report['order_id'])->find();
$product = Db::name('order_products')->where('order_id', (int)$report['order_id'])->find();
$timelineExists = Db::name('order_timelines')
->where('order_id', (int)$report['order_id'])
->where('node_code', 'report_published')
->where('node_text', '报告已出具')
->find();
if (!$timelineExists) {
Db::name('order_timelines')->insert([
'order_id' => (int)$report['order_id'],
'node_code' => 'report_published',
'node_text' => '报告已出具',
'node_desc' => '正式报告已发布,用户可查看报告。',
'operator_type' => 'admin',
'operator_id' => $operatorId ?: null,
'occurred_at' => $now,
'created_at' => $now,
]);
}
(new MessageDispatcher())->sendInboxEvent('report_published', [
'user_id' => (int)($order['user_id'] ?? 0),
'biz_type' => 'report',
'biz_id' => (int)$report['id'],
'report_no' => (string)$report['report_no'],
'report_title' => (string)$report['report_title'],
'product_name' => $product['product_name'] ?? '',
'publish_time' => $effectivePublishTime,
'verify_url' => $usesPlatformVerify ? (string)($verify['verify_url'] ?? '') : '',
'fallback_title' => '报告已出具',
'fallback_content' => '您的正式报告已生成,可前往报告中心查看。',
]);
}
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
if (($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0) {
(new EnterpriseWebhookService())->recordOrderEvent((int)$report['order_id'], 'report_published', [
'report_id' => (int)$report['id'],
'report_no' => (string)$report['report_no'],
'report_title' => (string)$report['report_title'],
'publish_time' => $effectivePublishTime,
'verify_url' => $usesPlatformVerify ? (string)($verify['verify_url'] ?? '') : '',
'report_page_url' => $usesPlatformVerify ? (string)($verify['report_page_url'] ?? '') : '',
]);
}
return [
'id' => (int)$report['id'],
'report_status' => 'published',
'publish_time' => $effectivePublishTime,
'verify_url' => $usesPlatformVerify ? (string)($verify['verify_url'] ?? '') : '',
'report_page_url' => $usesPlatformVerify ? (string)($verify['report_page_url'] ?? '') : '',
];
}
private function createOrUpdateVerifyRecord(array $report, string $now): array
{
$reportNo = (string)$report['report_no'];
$verifyToken = 'verify_' . strtolower((string)preg_replace('/[^a-zA-Z0-9]/', '', $reportNo));
$verifyUrl = $this->buildPublicPageUrl('/pages/verify/result', ['report_no' => $reportNo]);
$reportPageUrl = $this->buildPublicPageUrl('/pages/report/detail', ['report_no' => $reportNo]);
$payload = [
'report_id' => (int)$report['id'],
'report_no' => $reportNo,
'verify_token' => $verifyToken,
'verify_qrcode_url' => $reportPageUrl,
'verify_url' => $verifyUrl,
'verify_status' => 'valid',
'updated_at' => $now,
];
$verify = Db::name('report_verifies')->where('report_id', (int)$report['id'])->find();
if ($verify) {
Db::name('report_verifies')->where('id', (int)$verify['id'])->update($payload);
} else {
$payload['last_verified_at'] = null;
$payload['verify_count'] = 0;
$payload['created_at'] = $now;
Db::name('report_verifies')->insert($payload);
}
$fresh = Db::name('report_verifies')->where('report_id', (int)$report['id'])->find() ?: $payload;
$fresh['report_page_url'] = $reportPageUrl;
return $fresh;
}
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;
}
return $baseUrl === '' ? $hashPath : $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();

View File

@@ -51,6 +51,30 @@ class MaterialsController
}
public function download(Request $request)
{
$file = $this->resolveDownloadFile($request);
if ($file instanceof \support\Response) {
return $file;
}
return redirect($file['url'], 302);
}
public function downloadLink(Request $request)
{
$file = $this->resolveDownloadFile($request);
if ($file instanceof \support\Response) {
return $file;
}
return api_success([
'filename' => $file['filename'],
'url' => $file['url'],
'size' => $file['size'],
], '下载链接已生成');
}
private function resolveDownloadFile(Request $request): array|\support\Response
{
$id = (int)$request->input('id', 0);
if ($id <= 0) {
@@ -65,12 +89,43 @@ class MaterialsController
return api_error('物料批次下载失败', 500, ['detail' => $e->getMessage()]);
}
$filename = rawurlencode($file['filename']);
return response($file['content'], 200, [
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition' => "attachment; filename=\"{$file['filename']}\"; filename*=UTF-8''{$filename}",
'Cache-Control' => 'no-store, no-cache, must-revalidate',
]);
return $file;
}
public function invalidateBatch(Request $request)
{
$id = (int)$request->input('id', 0);
if ($id <= 0) {
return api_error('物料批次 ID 不能为空', 422);
}
try {
return api_success($this->service()->invalidateBatch($id, $request), '物料批次已失效');
} catch (\InvalidArgumentException $e) {
return api_error($e->getMessage(), 422);
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), $e->getCode() ?: 500);
} catch (\Throwable $e) {
return api_error('物料批次失效失败', 500, ['detail' => $e->getMessage()]);
}
}
public function invalidateTag(Request $request)
{
$id = (int)$request->input('id', 0);
if ($id <= 0) {
return api_error('物料条码 ID 不能为空', 422);
}
try {
return api_success($this->service()->invalidateTag($id, $request), '物料条码已失效');
} catch (\InvalidArgumentException $e) {
return api_error($e->getMessage(), 422);
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), $e->getCode() ?: 500);
} catch (\Throwable $e) {
return api_error('物料条码失效失败', 500, ['detail' => $e->getMessage()]);
}
}
private function service(): MaterialTagService

View File

@@ -16,6 +16,9 @@ class OrdersController
$status = trim((string)$request->input('status', ''));
$serviceProvider = trim((string)$request->input('service_provider', ''));
$sourceChannel = $this->normalizeOrderSourceChannel((string)$request->input('source_channel', ''));
$paginationEnabled = $request->input('page', null) !== null || $request->input('page_size', null) !== null;
$page = max(1, (int)$request->input('page', 1));
$pageSize = max(1, min(100, (int)$request->input('page_size', 20)));
$query = Db::name('orders')
->alias('o')
@@ -51,11 +54,38 @@ class OrdersController
});
}
$specialStatusFilters = ['returning', 'completed_signed'];
$warehouseStatusFilters = [
'warehouse_active',
'warehouse_in_transit',
'warehouse_received',
'warehouse_pending_return',
];
$specialStatusFilters = array_merge(['returning', 'completed_signed'], $warehouseStatusFilters);
if ($status !== '' && !in_array($status, $specialStatusFilters, true)) {
$query->where('o.order_status', $status);
}
if (in_array($status, $warehouseStatusFilters, true)) {
$warehouseActiveStatuses = [
'pending_shipping',
'received',
'in_first_review',
'pending_supplement',
'in_final_review',
'generating_report',
'report_published',
];
if ($status === 'warehouse_in_transit') {
$query->where('o.order_status', 'pending_shipping');
} elseif ($status === 'warehouse_received') {
$query->whereIn('o.order_status', array_values(array_diff($warehouseActiveStatuses, ['pending_shipping', 'report_published'])));
} elseif ($status === 'warehouse_pending_return') {
$query->where('o.order_status', 'report_published');
} else {
$query->whereIn('o.order_status', $warehouseActiveStatuses);
}
}
if ($serviceProvider !== '') {
$query->where('o.service_provider', $serviceProvider);
}
@@ -66,28 +96,23 @@ class OrdersController
$rows = $query->select()->toArray();
$returnTrackingMap = [];
if ($rows) {
$returnRows = Db::name('order_logistics')
->whereIn('order_id', array_column($rows, 'id'))
->where('logistics_type', 'return_to_user')
->order('id', 'desc')
->select()
->toArray();
foreach ($returnRows as $row) {
$orderId = (int)($row['order_id'] ?? 0);
if ($orderId > 0 && !isset($returnTrackingMap[$orderId])) {
$returnTrackingMap[$orderId] = [
'tracking_no' => (string)($row['tracking_no'] ?? ''),
'tracking_status' => (string)($row['tracking_status'] ?? ''),
];
}
}
}
$orderIds = array_map('intval', array_column($rows, 'id'));
$sendTrackingMap = $this->latestLogisticsMap($orderIds, 'send_to_center');
$returnTrackingMap = $this->latestLogisticsMap($orderIds, 'return_to_user');
$list = array_map(function (array $item) use ($sendTrackingMap, $returnTrackingMap) {
$orderId = (int)$item['id'];
$sendTrackingNo = $sendTrackingMap[$orderId]['tracking_no'] ?? '';
$sendTrackingStatus = $sendTrackingMap[$orderId]['tracking_status'] ?? '';
$warehouseBucket = $this->warehouseOrderBucket(
(string)$item['order_status'],
$sendTrackingNo,
$sendTrackingStatus,
(string)($item['display_status'] ?? '')
);
$list = array_map(function (array $item) use ($returnTrackingMap) {
return [
'id' => (int)$item['id'],
'id' => $orderId,
'order_no' => $item['order_no'],
'appraisal_no' => $item['appraisal_no'],
'product_name' => $item['product_name'] ?: '待完善物品信息',
@@ -102,9 +127,11 @@ class OrdersController
'display_status' => $this->displayStatus(
(string)$item['order_status'],
(string)$item['display_status'],
$returnTrackingMap[(int)$item['id']]['tracking_no'] ?? '',
$returnTrackingMap[(int)$item['id']]['tracking_status'] ?? '',
$returnTrackingMap[$orderId]['tracking_no'] ?? '',
$returnTrackingMap[$orderId]['tracking_status'] ?? '',
),
'warehouse_bucket' => $warehouseBucket,
'warehouse_bucket_text' => $this->warehouseOrderBucketText($warehouseBucket),
'estimated_finish_time' => $item['estimated_finish_time'],
'pay_amount' => (float)$item['pay_amount'],
'created_at' => $item['created_at'],
@@ -123,6 +150,33 @@ class OrdersController
}));
}
if (in_array($status, $warehouseStatusFilters, true)) {
$list = array_values(array_filter($list, function (array $item) use ($status) {
if ($status === 'warehouse_active') {
return in_array($item['warehouse_bucket'], [
'warehouse_in_transit',
'warehouse_received',
'warehouse_pending_return',
], true);
}
return $item['warehouse_bucket'] === $status;
}));
}
$total = count($list);
if ($paginationEnabled) {
$offset = ($page - 1) * $pageSize;
$list = array_slice($list, $offset, $pageSize);
return api_success([
'list' => $list,
'total' => $total,
'page' => $page,
'page_size' => $pageSize,
]);
}
return api_success([
'list' => $list,
]);
@@ -355,9 +409,11 @@ class OrdersController
'items' => $supplementItems,
] : null,
'report_summary' => $report ? [
'id' => (int)$report['id'],
'report_no' => $report['report_no'],
'report_title' => $report['report_title'],
'report_status' => $report['report_status'],
'report_status_text' => $this->reportStatusText((string)$report['report_status']),
'publish_time' => $report['publish_time'],
] : null,
]);
@@ -469,7 +525,7 @@ class OrdersController
'node_text' => '仓库已改派',
'node_desc' => sprintf('订单收货仓库已改派至 %s', $snapshot['warehouse_name']),
'operator_type' => 'admin',
'operator_id' => 1,
'operator_id' => (int)$request->header('x-admin-id', 0) ?: null,
'occurred_at' => $now,
'created_at' => $now,
]);
@@ -581,7 +637,7 @@ class OrdersController
? '包裹已由鉴定中心签收,订单已进入鉴定流程'
: '大客户推送订单已确认到仓,订单已进入鉴定流程',
'operator_type' => 'admin',
'operator_id' => 1,
'operator_id' => (int)$request->header('x-admin-id', 0) ?: null,
'occurred_at' => $now,
'created_at' => $now,
]);
@@ -729,7 +785,7 @@ class OrdersController
'node_text' => $nodeText,
'node_desc' => $nodeDesc,
'operator_type' => 'admin',
'operator_id' => 1,
'operator_id' => (int)$request->header('x-admin-id', 0) ?: null,
'occurred_at' => $now,
'created_at' => $now,
]);
@@ -821,7 +877,7 @@ class OrdersController
'node_text' => '用户已签收',
'node_desc' => '回寄商品已由用户签收,本次订单已完成。',
'operator_type' => 'admin',
'operator_id' => 1,
'operator_id' => (int)$request->header('x-admin-id', 0) ?: null,
'occurred_at' => $now,
'created_at' => $now,
]);
@@ -870,6 +926,18 @@ class OrdersController
};
}
private function reportStatusText(string $status): string
{
return match ($status) {
'draft' => '草稿中',
'pending_publish' => '待发布',
'published' => '已发布',
'updated' => '已更新',
'invalid' => '已作废',
default => $status,
};
}
private function displayStatus(string $orderStatus, string $displayStatus, string $returnTrackingNo = '', string $returnTrackingStatus = ''): string
{
if ($orderStatus === 'report_published') {
@@ -888,6 +956,77 @@ class OrdersController
return $displayStatus;
}
private function latestLogisticsMap(array $orderIds, string $logisticsType): array
{
$orderIds = array_values(array_unique(array_filter(array_map('intval', $orderIds))));
if (!$orderIds) {
return [];
}
$rows = Db::name('order_logistics')
->whereIn('order_id', $orderIds)
->where('logistics_type', $logisticsType)
->order('id', 'desc')
->select()
->toArray();
$map = [];
foreach ($rows as $row) {
$orderId = (int)($row['order_id'] ?? 0);
if ($orderId > 0 && !isset($map[$orderId])) {
$map[$orderId] = [
'tracking_no' => (string)($row['tracking_no'] ?? ''),
'tracking_status' => (string)($row['tracking_status'] ?? ''),
];
}
}
return $map;
}
private function warehouseOrderBucket(
string $orderStatus,
string $sendTrackingNo = '',
string $sendTrackingStatus = '',
string $displayStatus = ''
): string
{
if ($orderStatus === 'pending_shipping') {
$hasSubmittedTracking = $sendTrackingNo !== '' && $sendTrackingStatus !== 'received';
$hasSubmittedDisplayStatus = in_array($displayStatus, ['已提交运单', '用户已提交运单'], true)
&& $sendTrackingStatus !== 'received';
if ($hasSubmittedTracking || $hasSubmittedDisplayStatus) {
return 'warehouse_in_transit';
}
}
if (in_array($orderStatus, [
'received',
'in_first_review',
'pending_supplement',
'in_final_review',
'generating_report',
], true)) {
return 'warehouse_received';
}
if ($orderStatus === 'report_published') {
return 'warehouse_pending_return';
}
return '';
}
private function warehouseOrderBucketText(string $bucket): string
{
return match ($bucket) {
'warehouse_in_transit' => '在途',
'warehouse_received' => '已入仓',
'warehouse_pending_return' => '待寄回',
default => '',
};
}
private function normalizeOrderSourceChannel(string $sourceChannel): string
{
$sourceChannel = trim($sourceChannel);

View File

@@ -16,6 +16,9 @@ class ReportsController
$keyword = trim((string)$request->input('keyword', ''));
$status = trim((string)$request->input('status', ''));
$serviceProvider = trim((string)$request->input('service_provider', ''));
$paginationEnabled = $request->input('page', null) !== null || $request->input('page_size', null) !== null;
$page = max(1, (int)$request->input('page', 1));
$pageSize = max(1, min(100, (int)$request->input('page_size', 20)));
$query = Db::name('reports')
->alias('r')
@@ -32,6 +35,9 @@ class ReportsController
'r.service_provider',
'r.institution_name',
'r.publish_time',
'r.zhongjian_report_no',
'r.report_entry_admin_name',
'r.report_entered_at',
'o.order_no',
'p.product_name',
'p.category_name',
@@ -68,6 +74,9 @@ class ReportsController
'service_provider_text' => $this->serviceProviderText($item['service_provider']),
'institution_name' => $item['institution_name'] ?: $this->defaultInstitutionName($item['service_provider']),
'publish_time' => $item['publish_time'],
'zhongjian_report_no' => (string)($item['zhongjian_report_no'] ?? ''),
'report_entry_admin_name' => (string)($item['report_entry_admin_name'] ?? ''),
'report_entered_at' => (string)($item['report_entered_at'] ?? ''),
'product_name' => $item['product_name'] ?: (string)($productSnapshot['product_name'] ?? ''),
'category_name' => $item['category_name'] ?: (string)($productSnapshot['category_name'] ?? ''),
'brand_name' => $item['brand_name'] ?: (string)($productSnapshot['brand_name'] ?? ''),
@@ -80,6 +89,19 @@ class ReportsController
$list[] = $mapped;
}
$total = count($list);
if ($paginationEnabled) {
$offset = ($page - 1) * $pageSize;
$list = array_slice($list, $offset, $pageSize);
return api_success([
'list' => $list,
'total' => $total,
'page' => $page,
'page_size' => $pageSize,
]);
}
return api_success(['list' => $list]);
}
@@ -100,22 +122,24 @@ class ReportsController
$resultSnapshot = $this->decodeJsonField($content['result_snapshot_json'] ?? null);
$appraisalSnapshot = $this->decodeJsonField($content['appraisal_snapshot_json'] ?? null);
$valuationSnapshot = $this->decodeJsonField($content['valuation_snapshot_json'] ?? null);
$zhongjianReportFiles = $this->evidenceService()->normalize($content['zhongjian_report_files_json'] ?? null, $request);
$appraisalSnapshot = $this->enrichAppraisalSnapshot($report, $appraisalSnapshot);
$evidenceAttachments = $this->evidenceService()->normalize($content['evidence_attachments_json'] ?? null, $request);
$verify = Db::name('report_verifies')->where('report_id', $id)->find() ?: [];
if (($report['report_status'] ?? '') === 'published') {
$usesPlatformVerify = (string)($report['service_provider'] ?? '') !== 'zhongjian';
$verify = $usesPlatformVerify ? (Db::name('report_verifies')->where('report_id', $id)->find() ?: []) : [];
if ($usesPlatformVerify && ($report['report_status'] ?? '') === 'published') {
$verify = $this->createOrUpdateVerifyRecord($report, date('Y-m-d H:i:s'));
}
$reportPageUrl = $this->buildPublicPageUrl('/pages/report/detail', ['report_no' => $report['report_no']]);
$verifyUrl = $this->buildPublicPageUrl('/pages/verify/result', ['report_no' => $report['report_no']]);
$reportPageUrl = $usesPlatformVerify ? $this->buildPublicPageUrl('/pages/report/detail', ['report_no' => $report['report_no']]) : '';
$verifyUrl = $usesPlatformVerify ? $this->buildPublicPageUrl('/pages/verify/result', ['report_no' => $report['report_no']]) : '';
if (!$verify) {
$verify = [];
}
$verify['report_page_url'] = $verify['report_page_url'] ?? $reportPageUrl;
$verify['verify_qrcode_url'] = $verify['verify_qrcode_url'] ?? $reportPageUrl;
$verify['verify_url'] = $verify['verify_url'] ?? $verifyUrl;
$verify['report_page_url'] = $usesPlatformVerify ? ($verify['report_page_url'] ?? $reportPageUrl) : '';
$verify['verify_qrcode_url'] = $usesPlatformVerify ? ($verify['verify_qrcode_url'] ?? $reportPageUrl) : '';
$verify['verify_url'] = $usesPlatformVerify ? ($verify['verify_url'] ?? $verifyUrl) : '';
$defaultRiskNotice = (new ContentService())->getReportRiskNotice((string)($report['report_type'] ?? 'appraisal'));
return api_success([
@@ -132,12 +156,17 @@ class ReportsController
'service_provider_text' => $this->serviceProviderText($report['service_provider']),
'institution_name' => $report['institution_name'] ?: $this->defaultInstitutionName($report['service_provider']),
'publish_time' => $report['publish_time'],
'zhongjian_report_no' => (string)($report['zhongjian_report_no'] ?? ''),
'report_entry_admin_id' => (int)($report['report_entry_admin_id'] ?? 0),
'report_entry_admin_name' => (string)($report['report_entry_admin_name'] ?? ''),
'report_entered_at' => (string)($report['report_entered_at'] ?? ''),
],
'product_info' => $productSnapshot,
'result_info' => $resultSnapshot,
'appraisal_info' => $appraisalSnapshot,
'valuation_info' => $valuationSnapshot,
'evidence_attachments' => $evidenceAttachments,
'zhongjian_report_files' => $zhongjianReportFiles,
'risk_notice_text' => ($content['risk_notice_text'] ?? '') !== '' ? $content['risk_notice_text'] : $defaultRiskNotice,
'verify_info' => [
'verify_status' => $verify['verify_status'] ?? (($report['report_status'] ?? '') === 'published' ? 'valid' : 'pending'),
@@ -304,8 +333,9 @@ class ReportsController
'verify_url' => '',
'report_page_url' => '',
];
$usesPlatformVerify = $serviceProvider !== 'zhongjian';
if ($reportStatus === 'published' && $reportRecord) {
if ($reportStatus === 'published' && $reportRecord && $usesPlatformVerify) {
$verifyInfo = $this->createOrUpdateVerifyRecord($reportRecord, $now);
} else {
Db::name('report_verifies')->where('report_id', $reportId)->delete();
@@ -351,6 +381,7 @@ class ReportsController
}
$effectivePublishTime = $report['publish_time'] ?: $now;
$usesPlatformVerify = (string)($report['service_provider'] ?? '') !== 'zhongjian';
if ($report['report_status'] !== 'published') {
Db::name('reports')->where('id', $id)->update([
'report_status' => 'published',
@@ -365,7 +396,12 @@ class ReportsController
$this->refreshAppraisalSnapshot((int)$report['id'], (int)$report['order_id'], $report['service_provider'], $now);
}
$verify = $this->createOrUpdateVerifyRecord($report, $now);
$verify = [];
if ($usesPlatformVerify) {
$verify = $this->createOrUpdateVerifyRecord($report, $now);
} else {
Db::name('report_verifies')->where('report_id', $id)->delete();
}
if (($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0) {
Db::name('orders')->where('id', $report['order_id'])->update([
@@ -388,9 +424,9 @@ class ReportsController
'order_id' => $report['order_id'],
'node_code' => 'report_published',
'node_text' => '报告已出具',
'node_desc' => '正式报告已发布,用户可查看报告并进行验真。',
'node_desc' => $usesPlatformVerify ? '正式报告已发布,用户可查看报告并进行验真。' : '中检报告已发布,用户可查看报告。',
'operator_type' => 'admin',
'operator_id' => 1,
'operator_id' => (int)$request->header('x-admin-id', 0) ?: null,
'occurred_at' => $now,
'created_at' => $now,
]);
@@ -404,9 +440,9 @@ class ReportsController
'report_title' => $report['report_title'],
'product_name' => $product['product_name'] ?? '',
'publish_time' => $report['publish_time'] ?: $now,
'verify_url' => $verify['verify_url'],
'verify_url' => $usesPlatformVerify ? (string)($verify['verify_url'] ?? '') : '',
'fallback_title' => '报告已出具',
'fallback_content' => '您的正式报告已生成,可前往报告中心查看并完成验真。',
'fallback_content' => $usesPlatformVerify ? '您的正式报告已生成,可前往报告中心查看并完成验真。' : '您的中检报告已生成,可前往报告中心查看。',
]);
}
@@ -418,8 +454,8 @@ class ReportsController
'report_no' => (string)$report['report_no'],
'report_title' => (string)$report['report_title'],
'publish_time' => $effectivePublishTime,
'verify_url' => (string)($verify['verify_url'] ?? ''),
'report_page_url' => (string)($verify['report_page_url'] ?? ''),
'verify_url' => $usesPlatformVerify ? (string)($verify['verify_url'] ?? '') : '',
'report_page_url' => $usesPlatformVerify ? (string)($verify['report_page_url'] ?? '') : '',
]);
}
@@ -427,8 +463,8 @@ class ReportsController
'id' => $id,
'report_status' => 'published',
'publish_time' => $effectivePublishTime,
'verify_url' => $verify['verify_url'],
'report_page_url' => $verify['report_page_url'],
'verify_url' => $usesPlatformVerify ? (string)($verify['verify_url'] ?? '') : '',
'report_page_url' => $usesPlatformVerify ? (string)($verify['report_page_url'] ?? '') : '',
], '报告已发布');
} catch (\Throwable $e) {
Db::rollback();

View File

@@ -0,0 +1,144 @@
<?php
namespace app\controller\admin;
use app\support\FulfillmentFlowService;
use support\Request;
class WarehouseWorkbenchController
{
public function inboundLookup(Request $request)
{
try {
return api_success($this->service()->lookupInboundByTrackingNo((string)$request->input('tracking_no', '')));
} catch (\InvalidArgumentException $e) {
return api_error($e->getMessage(), 422);
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), $e->getCode() ?: 404);
} catch (\Throwable $e) {
return api_error('入库匹配失败', 500, ['detail' => $e->getMessage()]);
}
}
public function inboundReceive(Request $request)
{
try {
return api_success($this->service()->receiveInbound(
(string)$request->input('tracking_no', ''),
(string)$request->input('internal_tag_no', ''),
$request
), '入库完成');
} catch (\InvalidArgumentException $e) {
return api_error($e->getMessage(), 422);
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), $e->getCode() ?: 404);
} catch (\Throwable $e) {
return api_error('入库失败', 500, ['detail' => $e->getMessage()]);
}
}
public function zhongjianLookup(Request $request)
{
try {
return api_success($this->service()->lookupZhongjianTransfer((string)$request->input('internal_tag_no', '')));
} catch (\InvalidArgumentException $e) {
return api_error($e->getMessage(), 422);
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), $e->getCode() ?: 404);
} catch (\Throwable $e) {
return api_error('中检流转查询失败', 500, ['detail' => $e->getMessage()]);
}
}
public function zhongjianOutbound(Request $request)
{
try {
return api_success($this->service()->zhongjianOutbound((string)$request->input('internal_tag_no', ''), $request), '送检出库完成');
} catch (\InvalidArgumentException $e) {
return api_error($e->getMessage(), 422);
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), $e->getCode() ?: 404);
} catch (\Throwable $e) {
return api_error('送检出库失败', 500, ['detail' => $e->getMessage()]);
}
}
public function zhongjianInbound(Request $request)
{
try {
return api_success($this->service()->zhongjianInbound((string)$request->input('internal_tag_no', ''), $request), '送检入库完成');
} catch (\InvalidArgumentException $e) {
return api_error($e->getMessage(), 422);
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), $e->getCode() ?: 404);
} catch (\Throwable $e) {
return api_error('送检入库失败', 500, ['detail' => $e->getMessage()]);
}
}
public function returnLookup(Request $request)
{
try {
return api_success($this->service()->lookupReturn((string)$request->input('internal_tag_no', ''), $request));
} catch (\InvalidArgumentException $e) {
return api_error($e->getMessage(), 422);
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), $e->getCode() ?: 404);
} catch (\Throwable $e) {
return api_error('寄回查询失败', 500, ['detail' => $e->getMessage()]);
}
}
public function verifyReturnMaterialTag(Request $request)
{
try {
return api_success($this->service()->verifyReturnMaterialTag(
(string)$request->input('internal_tag_no', ''),
(string)$request->input('qr_input', ''),
$request
), '验真吊牌已确认');
} catch (\InvalidArgumentException $e) {
return api_error($e->getMessage(), 422);
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), $e->getCode() ?: 404);
} catch (\Throwable $e) {
return api_error('验真吊牌确认失败', 500, ['detail' => $e->getMessage()]);
}
}
public function confirmZhongjianReturn(Request $request)
{
try {
return api_success($this->service()->confirmZhongjianReturn((string)$request->input('internal_tag_no', ''), $request), '中检报告已确认');
} catch (\InvalidArgumentException $e) {
return api_error($e->getMessage(), 422);
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), $e->getCode() ?: 404);
} catch (\Throwable $e) {
return api_error('中检报告确认失败', 500, ['detail' => $e->getMessage()]);
}
}
public function shipReturn(Request $request)
{
try {
return api_success($this->service()->shipReturn(
(string)$request->input('internal_tag_no', ''),
(string)$request->input('express_company', ''),
(string)$request->input('tracking_no', ''),
$request
), '回寄运单已登记');
} catch (\InvalidArgumentException $e) {
return api_error($e->getMessage(), 422);
} catch (\RuntimeException $e) {
return api_error($e->getMessage(), $e->getCode() ?: 404);
} catch (\Throwable $e) {
return api_error('回寄运单登记失败', 500, ['detail' => $e->getMessage()]);
}
}
private function service(): FulfillmentFlowService
{
return new FulfillmentFlowService();
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace app\controller\app;
use app\support\MaterialTagService;
use support\Request;
class MaterialTagRedirectController
{
public function redirect(Request $request)
{
$token = trim((string)($request->route?->param('token', '') ?? ''));
if ($token === '') {
return response('Material tag token is required', 400);
}
try {
$url = (new MaterialTagService())->buildMaterialTagDetailUrl($token);
} catch (\Throwable $e) {
return response($e->getMessage(), 500);
}
return redirect($url, 302);
}
}

View File

@@ -92,10 +92,12 @@ class ReportsController
$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 ?: []);
$isZhongjian = (string)($reportData['service_provider'] ?? '') === 'zhongjian';
$verify = $isZhongjian ? [] : (Db::name('report_verifies')->where('report_id', $reportData['id'])->find() ?: []);
$verify = $isZhongjian ? [] : $this->normalizeVerifyInfo($reportData, $verify);
$pdfUrl = $this->ensurePdfFile($request, $reportData, $content ?: [], $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),
@@ -115,18 +117,22 @@ class ReportsController
'service_provider' => $reportData['service_provider'],
'institution_name' => $reportData['institution_name'],
'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'] ?? ''),
],
'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,
'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'] ?? '',
'verify_status' => $isZhongjian ? '' : ($verify['verify_status'] ?? 'valid'),
'verify_url' => $isZhongjian ? '' : ($verify['verify_url'] ?? ''),
'verify_qrcode_url' => $isZhongjian ? '' : ($verify['verify_qrcode_url'] ?? ''),
],
'file_info' => [
'pdf_url' => $pdfUrl,
@@ -212,7 +218,9 @@ class ReportsController
'verify_info' => sprintf(
'%s / %s',
$verify['report_no'] ?? ($report['report_no'] ?? '-'),
($verify['verify_status'] ?? 'valid') === 'valid' ? '有效' : ($verify['verify_status'] ?? '-')
($report['service_provider'] ?? '') === 'zhongjian'
? '中检报告'
: (($verify['verify_status'] ?? 'valid') === 'valid' ? '有效' : ($verify['verify_status'] ?? '-'))
),
'risk_notice_text' => ($content['risk_notice_text'] ?? '') !== '' ? $content['risk_notice_text'] : ($defaultRiskNotice !== '' ? $defaultRiskNotice : '-'),
]);

View File

@@ -26,8 +26,8 @@ class AdminAuthMiddleware implements MiddlewareInterface
return api_error('未登录或登录已过期', 401);
}
$permissionCode = $this->permissionCode($path);
if ($permissionCode !== '' && !$authService->hasPermission($adminInfo, $permissionCode)) {
$permissionCodes = $this->permissionCodes($path, (string)$request->method());
if ($permissionCodes && !$this->hasAnyPermission($authService, $adminInfo, $permissionCodes)) {
return api_error('无权访问该后台功能', 403);
}
@@ -37,33 +37,47 @@ class AdminAuthMiddleware implements MiddlewareInterface
return $handler($request);
}
private function permissionCode(string $path): string
private function hasAnyPermission(AdminAuthService $authService, array $adminInfo, array $permissionCodes): bool
{
foreach ($permissionCodes as $permissionCode) {
if ($authService->hasPermission($adminInfo, $permissionCode)) {
return true;
}
}
return false;
}
private function permissionCodes(string $path, string $method): array
{
return match (true) {
str_starts_with($path, '/api/admin/dashboard') => 'dashboard.view',
str_starts_with($path, '/api/admin/dashboard') => ['dashboard.view'],
str_starts_with($path, '/api/admin/orders') && strtoupper($method) === 'GET' => ['orders.manage', 'warehouse_workbench.manage'],
str_starts_with($path, '/api/admin/order/') && strtoupper($method) === 'GET' => ['orders.manage', 'warehouse_workbench.manage'],
str_starts_with($path, '/api/admin/orders'),
str_starts_with($path, '/api/admin/order/') => 'orders.manage',
str_starts_with($path, '/api/admin/order/') => ['orders.manage'],
str_starts_with($path, '/api/admin/appraisal-tasks'),
str_starts_with($path, '/api/admin/appraisal-task/') => 'appraisal_tasks.manage',
str_starts_with($path, '/api/admin/catalog/') => 'catalog.manage',
str_starts_with($path, '/api/admin/appraisal-task/') => ['appraisal_tasks.manage'],
str_starts_with($path, '/api/admin/catalog/') => ['catalog.manage'],
str_starts_with($path, '/api/admin/reports'),
str_starts_with($path, '/api/admin/report/') => 'reports.manage',
str_starts_with($path, '/api/admin/messages') => 'messages.manage',
str_starts_with($path, '/api/admin/report/') => ['reports.manage'],
str_starts_with($path, '/api/admin/messages') => ['messages.manage'],
str_starts_with($path, '/api/admin/tickets'),
str_starts_with($path, '/api/admin/ticket/') => 'tickets.manage',
str_starts_with($path, '/api/admin/ticket/') => ['tickets.manage'],
str_starts_with($path, '/api/admin/users'),
str_starts_with($path, '/api/admin/user/') => 'users.manage',
str_starts_with($path, '/api/admin/user/') => ['users.manage'],
str_starts_with($path, '/api/admin/customers'),
str_starts_with($path, '/api/admin/customer/') => 'customers.manage',
str_starts_with($path, '/api/admin/customer/') => ['customers.manage'],
str_starts_with($path, '/api/admin/warehouse-workbench/') => ['warehouse_workbench.manage'],
str_starts_with($path, '/api/admin/warehouses'),
str_starts_with($path, '/api/admin/warehouse/') => 'warehouses.manage',
str_starts_with($path, '/api/admin/material/') => 'materials.manage',
str_starts_with($path, '/api/admin/access/') => 'access.manage',
str_starts_with($path, '/api/admin/content/') => 'system.manage',
str_starts_with($path, '/api/admin/system-configs') => 'system.manage',
str_starts_with($path, '/api/admin/warehouse/') => ['warehouses.manage'],
str_starts_with($path, '/api/admin/material/') => ['materials.manage'],
str_starts_with($path, '/api/admin/access/') => ['access.manage'],
str_starts_with($path, '/api/admin/content/') => ['system.manage'],
str_starts_with($path, '/api/admin/system-configs') => ['system.manage'],
str_starts_with($path, '/api/admin/auth/me'),
str_starts_with($path, '/api/admin/auth/logout') => '',
default => '',
str_starts_with($path, '/api/admin/auth/logout') => [],
default => [],
};
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace app\queue\redis;
use app\support\MaterialBatchPackageService;
use support\Log;
use Webman\RedisQueue\Consumer;
class MaterialBatchPackageConsumer implements Consumer
{
public string $queue = MaterialBatchPackageService::QUEUE_NAME;
public string $connection = 'default';
public function consume($data): void
{
$batchId = (int)($data['batch_id'] ?? 0);
if ($batchId <= 0) {
return;
}
try {
(new MaterialBatchPackageService())->generateForBatchId($batchId);
} catch (\Throwable $e) {
Log::error('material batch package generation failed', [
'batch_id' => $batchId,
'message' => $e->getMessage(),
]);
throw $e;
}
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace app\queue\redis;
use app\support\MaterialTagQrCodeService;
use app\support\MaterialBatchPackageService;
use support\Log;
use Webman\RedisQueue\Consumer;
class MaterialTagQrCodeConsumer implements Consumer
{
public string $queue = MaterialTagQrCodeService::QUEUE_NAME;
public string $connection = 'default';
public function consume($data): void
{
$tagIds = array_values(array_filter(array_map('intval', (array)($data['tag_ids'] ?? []))));
if (!$tagIds) {
return;
}
$service = new MaterialTagQrCodeService();
$errors = [];
foreach ($tagIds as $tagId) {
try {
$service->generateForTagId($tagId);
} catch (\Throwable $e) {
$errors[] = sprintf('#%d %s', $tagId, $e->getMessage());
Log::error('material tag QR image generation failed', [
'tag_id' => $tagId,
'batch_id' => (int)($data['batch_id'] ?? 0),
'message' => $e->getMessage(),
]);
}
}
if ($errors) {
throw new \RuntimeException('物料二维码图片生成失败:' . implode('; ', array_slice($errors, 0, 3)));
}
$batchId = (int)($data['batch_id'] ?? 0);
if ($batchId > 0) {
try {
(new MaterialBatchPackageService())->enqueueIfReady($batchId);
} catch (\Throwable $e) {
Log::error('material batch package job enqueue failed after QR generation', [
'batch_id' => $batchId,
'message' => $e->getMessage(),
]);
throw $e;
}
}
}
}

View File

@@ -26,6 +26,7 @@ class AdminAccessService
['name' => '管理工单', 'code' => 'tickets.manage', 'module' => 'tickets', 'action' => 'manage'],
['name' => '管理用户', 'code' => 'users.manage', 'module' => 'users', 'action' => 'manage'],
['name' => '管理客户', 'code' => 'customers.manage', 'module' => 'customers', 'action' => 'manage'],
['name' => '仓管作业', 'code' => 'warehouse_workbench.manage', 'module' => 'warehouse_workbench', 'action' => 'manage'],
['name' => '管理仓库', 'code' => 'warehouses.manage', 'module' => 'warehouses', 'action' => 'manage'],
['name' => '管理物料', 'code' => 'materials.manage', 'module' => 'materials', 'action' => 'manage'],
['name' => '管理权限', 'code' => 'access.manage', 'module' => 'access', 'action' => 'manage'],
@@ -45,6 +46,7 @@ class AdminAccessService
'tickets' => '客服与售后',
'users' => '用户管理',
'customers' => '客户管理',
'warehouse_workbench' => '仓管作业台',
'warehouses' => '仓库中心',
'materials' => '物料管理',
'access' => '权限中心',
@@ -149,6 +151,12 @@ class AdminAccessService
'dashboard.view',
'materials.manage',
]);
$this->ensureRoleWithPermissions('warehouse_operator', '仓管', [
'dashboard.view',
'warehouse_workbench.manage',
'warehouses.manage',
]);
}
private function ensureRoleWithPermissions(string $code, string $name, array $permissionCodes): int

View File

@@ -81,6 +81,11 @@ class FileStorageService
}
public function putContents(string $relativePath, string $content): void
{
$this->putContentsWithMimeType($relativePath, $content);
}
public function putContentsWithMimeType(string $relativePath, string $content, string $mimeType = ''): void
{
$relativePath = $this->storagePath($relativePath);
@@ -94,10 +99,14 @@ class FileStorageService
file_put_contents($tmpFile, $content);
try {
$options = $mimeType !== '' ? [
OssClient::OSS_CONTENT_TYPE => $mimeType,
] : null;
$this->ossClient()->uploadFile(
$this->configService()->bucket(),
$this->configService()->objectKey($relativePath),
$tmpFile
$tmpFile,
$options
);
} finally {
if (file_exists($tmpFile)) {
@@ -118,7 +127,7 @@ class FileStorageService
try {
$key = $this->configService()->objectKey($relativePath);
$this->qiniuUploadFile($tmpFile, $key);
$this->qiniuUploadFile($tmpFile, $key, $mimeType !== '' ? $mimeType : 'application/octet-stream');
} finally {
if (file_exists($tmpFile)) {
@unlink($tmpFile);
@@ -273,18 +282,18 @@ class FileStorageService
);
}
private function qiniuUploadFile(string $filePath, string $key): void
private function qiniuUploadFile(string $filePath, string $key, string $mimeType = 'application/octet-stream'): void
{
$token = $this->qiniuAuth()->uploadToken($this->configService()->qiniuBucket(), $key);
try {
[$ret, $err] = $this->qiniuUploadManager()->putFile($token, $key, $filePath);
[$ret, $err] = $this->qiniuUploadManager()->putFile($token, $key, $filePath, null, $mimeType);
} catch (\Throwable $e) {
$err = $e;
}
if ($err !== null && $this->shouldRetryQiniuWithoutHttps($err)) {
try {
[$ret, $err] = $this->qiniuUploadManager(false)->putFile($token, $key, $filePath);
[$ret, $err] = $this->qiniuUploadManager(false)->putFile($token, $key, $filePath, null, $mimeType);
} catch (\Throwable $e) {
$err = $e;
}

View File

@@ -0,0 +1,876 @@
<?php
namespace app\support;
use support\Request;
use support\think\Db;
class FulfillmentFlowService
{
public function lookupInboundByTrackingNo(string $trackingNo): array
{
$trackingNo = trim($trackingNo);
if ($trackingNo === '') {
throw new \InvalidArgumentException('请先扫描寄入运单号');
}
$rows = Db::name('order_logistics')
->where('logistics_type', 'send_to_center')
->where('tracking_no', $trackingNo)
->select()
->toArray();
if (!$rows) {
throw new \RuntimeException('未匹配到订单,请核对寄入运单号', 404);
}
if (count($rows) > 1) {
throw new \RuntimeException('该运单号匹配到多笔订单,请人工核查后处理', 409);
}
return $this->formatOrderContext((int)$rows[0]['order_id']);
}
public function receiveInbound(string $trackingNo, string $tagNo, Request $request): array
{
$operator = $this->operator($request);
$context = $this->lookupInboundByTrackingNo($trackingNo);
$order = $context['order_info'];
$orderId = (int)$order['id'];
if (!in_array((string)$order['order_status'], ['pending_shipping', 'received'], true)) {
throw new \InvalidArgumentException('当前订单状态不支持入库绑定');
}
$tagNo = $this->normalizeTagNo($tagNo);
if ($tagNo === '') {
throw new \InvalidArgumentException('请扫描或输入内部流转挂牌编号');
}
$now = date('Y-m-d H:i:s');
Db::startTrans();
try {
$tag = $this->ensureTransferTag($tagNo, $operator, $now);
$activeFlow = $this->findActiveFlowByTagId((int)$tag['id']);
if ($activeFlow && (int)$activeFlow['order_id'] !== $orderId) {
Db::rollback();
throw new \InvalidArgumentException('该内部流转挂牌已绑定其他未结束订单');
}
$flow = Db::name('order_transfer_flows')
->where('order_id', $orderId)
->where('flow_status', '<>', 'ended')
->order('id', 'desc')
->find();
if ($flow && (int)$flow['internal_tag_id'] !== (int)$tag['id']) {
Db::rollback();
throw new \InvalidArgumentException('当前订单已绑定其他内部流转挂牌');
}
if (!$flow) {
$flowId = (int)Db::name('order_transfer_flows')->insertGetId([
'order_id' => $orderId,
'internal_tag_id' => (int)$tag['id'],
'internal_tag_no' => $tagNo,
'service_provider' => (string)$order['service_provider'],
'flow_status' => 'active',
'current_stage' => 'warehouse_received',
'current_location' => 'warehouse_pending_inspection',
'inbound_by' => $operator['id'],
'inbound_by_name' => $operator['name'],
'inbound_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
$flow = Db::name('order_transfer_flows')->where('id', $flowId)->find();
} else {
Db::name('order_transfer_flows')->where('id', (int)$flow['id'])->update([
'current_stage' => 'warehouse_received',
'current_location' => 'warehouse_pending_inspection',
'inbound_by' => $flow['inbound_by'] ?: $operator['id'],
'inbound_by_name' => $flow['inbound_by_name'] ?: $operator['name'],
'inbound_at' => $flow['inbound_at'] ?: $now,
'updated_at' => $now,
]);
$flow = Db::name('order_transfer_flows')->where('id', (int)$flow['id'])->find();
}
Db::name('internal_transfer_tags')->where('id', (int)$tag['id'])->update([
'bind_status' => 'bound',
'current_order_id' => $orderId,
'current_flow_id' => (int)$flow['id'],
'current_stage' => 'warehouse_received',
'current_location' => 'warehouse_pending_inspection',
'bound_by' => $operator['id'],
'bound_by_name' => $operator['name'],
'bound_at' => $now,
'released_by' => null,
'released_by_name' => '',
'released_at' => null,
'updated_at' => $now,
]);
$logistics = Db::name('order_logistics')
->where('order_id', $orderId)
->where('logistics_type', 'send_to_center')
->where('tracking_no', trim($trackingNo))
->order('id', 'desc')
->find();
if ($logistics) {
Db::name('order_logistics')->where('id', (int)$logistics['id'])->update([
'tracking_status' => 'received',
'latest_desc' => '仓库已扫描寄入包裹并完成入库。',
'latest_time' => $now,
'updated_at' => $now,
]);
Db::name('order_logistics_nodes')->insert([
'logistics_id' => (int)$logistics['id'],
'node_time' => $now,
'node_desc' => '仓库已扫描寄入包裹并完成入库。',
'node_location' => '仓库',
'created_at' => $now,
]);
}
Db::name('orders')->where('id', $orderId)->update([
'order_status' => 'received',
'display_status' => '已入仓待检',
'updated_at' => $now,
]);
$this->insertTimeline($orderId, 'inbound_received', '已入仓待检', '仓管扫描寄入运单并完成物品入库。', $operator, $now);
$this->insertFlowLog($flow, 'inbound_received', '入库完成', '', '', 'warehouse_received', 'warehouse_pending_inspection', $operator, '扫描寄入运单号入库', $now);
$this->insertFlowLog($flow, 'internal_tag_bound', '绑定内部流转挂牌', 'warehouse_received', 'warehouse_pending_inspection', 'warehouse_received', 'warehouse_pending_inspection', $operator, $tagNo, $now);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
return $this->formatOrderContext($orderId);
}
public function scanTransferForAppraisal(string $tagNo, Request $request): array
{
$operator = $this->operator($request);
$flow = $this->findActiveFlowByTagNo($tagNo);
if (!$flow) {
throw new \RuntimeException('未找到可用的内部流转挂牌', 404);
}
$order = Db::name('orders')->where('id', (int)$flow['order_id'])->find();
if (!$order) {
throw new \RuntimeException('订单不存在', 404);
}
$serviceProvider = (string)($order['service_provider'] ?? '');
$stage = (string)($flow['current_stage'] ?? '');
if ($serviceProvider === 'zhongjian' && !in_array($stage, ['zhongjian_returned', 'appraising'], true)) {
throw new \InvalidArgumentException('中检订单需完成送检入库后才能录入报告');
}
if ($serviceProvider !== 'zhongjian' && !in_array($stage, ['warehouse_received', 'appraising'], true)) {
throw new \InvalidArgumentException('当前流转状态不支持进入鉴定作业');
}
$task = Db::name('appraisal_tasks')
->where('order_id', (int)$flow['order_id'])
->where('task_stage', 'first_review')
->order('id', 'asc')
->find();
if (!$task) {
throw new \RuntimeException('鉴定任务不存在', 404);
}
$now = date('Y-m-d H:i:s');
Db::startTrans();
try {
$taskUpdate = [
'status' => 'processing',
'started_at' => $task['started_at'] ?: $now,
'updated_at' => $now,
];
if (empty($task['assignee_id']) || empty($task['assignee_name']) || $task['assignee_name'] === '未分配') {
$taskUpdate['assignee_id'] = $operator['id'];
$taskUpdate['assignee_name'] = $operator['name'];
}
Db::name('appraisal_tasks')->where('id', (int)$task['id'])->update($taskUpdate);
Db::name('orders')->where('id', (int)$flow['order_id'])->update([
'order_status' => 'in_first_review',
'display_status' => $serviceProvider === 'zhongjian' ? '中检报告录入中' : '鉴定中',
'updated_at' => $now,
]);
$this->updateFlowStage($flow, 'appraising', $serviceProvider === 'zhongjian' ? 'zhongjian_report_entry' : 'appraiser_workbench', [
'appraisal_started_by' => $flow['appraisal_started_by'] ?: $operator['id'],
'appraisal_started_by_name' => $flow['appraisal_started_by_name'] ?: $operator['name'],
'appraisal_started_at' => $flow['appraisal_started_at'] ?: $now,
], $now);
$this->insertTimeline((int)$flow['order_id'], 'appraisal_started', $serviceProvider === 'zhongjian' ? '中检报告录入中' : '鉴定中', $serviceProvider === 'zhongjian' ? '报告录入人已扫描内部流转码进入中检报告录入。' : '鉴定师已扫描内部流转码进入鉴定作业。', $operator, $now);
$this->insertFlowLog($flow, 'appraisal_started', $serviceProvider === 'zhongjian' ? '中检报告录入开始' : '鉴定开始', (string)$flow['current_stage'], (string)$flow['current_location'], 'appraising', $serviceProvider === 'zhongjian' ? 'zhongjian_report_entry' : 'appraiser_workbench', $operator, '', $now);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
return [
'task_id' => (int)$task['id'],
'order_id' => (int)$flow['order_id'],
'service_provider' => $serviceProvider,
'service_provider_text' => $serviceProvider === 'zhongjian' ? '中检鉴定' : '实物鉴定',
];
}
public function lookupZhongjianTransfer(string $tagNo): array
{
$flow = $this->findActiveFlowByTagNo($tagNo);
if (!$flow) {
throw new \RuntimeException('未找到可用的内部流转挂牌', 404);
}
if (($flow['service_provider'] ?? '') !== 'zhongjian') {
throw new \InvalidArgumentException('非中检订单不能进行送检出入库');
}
$stage = (string)$flow['current_stage'];
$nextAction = match ($stage) {
'warehouse_received' => 'outbound',
'sent_to_zhongjian' => 'inbound',
default => '',
};
return array_merge($this->formatOrderContext((int)$flow['order_id']), [
'next_action' => $nextAction,
'next_action_text' => $nextAction === 'outbound' ? '送检出库' : ($nextAction === 'inbound' ? '送检入库' : '暂无可执行送检动作'),
]);
}
public function zhongjianOutbound(string $tagNo, Request $request): array
{
return $this->moveZhongjian($tagNo, 'warehouse_received', 'sent_to_zhongjian', 'zhongjian_institution', 'zhongjian_outbound', '送检出库', '仓管扫描内部流转码,物品已送出至中检机构。', $request);
}
public function zhongjianInbound(string $tagNo, Request $request): array
{
return $this->moveZhongjian($tagNo, 'sent_to_zhongjian', 'zhongjian_returned', 'warehouse_pending_report_entry', 'zhongjian_inbound', '送检入库', '仓管扫描内部流转码,中检物品已回收入库。', $request);
}
public function lookupReturn(string $tagNo, Request $request): array
{
$flow = $this->findActiveFlowByTagNo($tagNo);
if (!$flow) {
throw new \RuntimeException('未找到可用的内部流转挂牌', 404);
}
$context = $this->formatOrderContext((int)$flow['order_id'], $request);
$report = $context['report_info'] ?? null;
if (!$report || ($report['report_status'] ?? '') !== 'published') {
throw new \InvalidArgumentException('订单报告未发布,不能进入寄回流程');
}
return $context + [
'return_confirmation' => [
'confirmed' => (string)($flow['current_stage'] ?? '') === 'return_confirmed',
'confirmed_by_name' => (string)($flow['return_confirmed_by_name'] ?? ''),
'confirmed_at' => (string)($flow['return_confirmed_at'] ?? ''),
],
];
}
public function verifyReturnMaterialTag(string $tagNo, string $qrInput, Request $request): array
{
$operator = $this->operator($request);
$flow = $this->findActiveFlowByTagNo($tagNo);
if (!$flow) {
throw new \RuntimeException('未找到可用的内部流转挂牌', 404);
}
if (($flow['service_provider'] ?? '') === 'zhongjian') {
throw new \InvalidArgumentException('中检订单不使用平台验真吊牌');
}
$report = $this->latestReport((int)$flow['order_id']);
if (!$report || ($report['report_status'] ?? '') !== 'published') {
throw new \InvalidArgumentException('订单报告未发布,不能确认寄回');
}
$tag = (new MaterialTagService())->findTagByInput($qrInput);
if (!$tag || (int)($tag['report_id'] ?? 0) !== (int)$report['id']) {
throw new \InvalidArgumentException('验真吊牌与当前订单报告不匹配');
}
return $this->markReturnConfirmed($flow, $operator, 'return_tag_verified', '验真吊牌确认', '仓管已扫描验真吊牌并确认报告信息。');
}
public function confirmZhongjianReturn(string $tagNo, Request $request): array
{
$operator = $this->operator($request);
$flow = $this->findActiveFlowByTagNo($tagNo);
if (!$flow) {
throw new \RuntimeException('未找到可用的内部流转挂牌', 404);
}
if (($flow['service_provider'] ?? '') !== 'zhongjian') {
throw new \InvalidArgumentException('非中检订单需扫描平台验真吊牌确认');
}
$report = $this->latestReport((int)$flow['order_id']);
$content = $report ? Db::name('report_contents')->where('report_id', (int)$report['id'])->find() : null;
$files = $this->decodeJsonArray($content['zhongjian_report_files_json'] ?? null);
if (!$report || ($report['report_status'] ?? '') !== 'published' || trim((string)($report['zhongjian_report_no'] ?? '')) === '' || !$files) {
throw new \InvalidArgumentException('中检报告未完整录入,不能确认寄回');
}
return $this->markReturnConfirmed($flow, $operator, 'return_confirmed', '中检报告已确认', '仓管已查看中检报告编号和报告文件。');
}
public function shipReturn(string $tagNo, string $expressCompany, string $trackingNo, Request $request): array
{
$operator = $this->operator($request);
$flow = $this->findActiveFlowByTagNo($tagNo);
if (!$flow) {
throw new \RuntimeException('未找到可用的内部流转挂牌', 404);
}
if ((string)($flow['current_stage'] ?? '') !== 'return_confirmed') {
throw new \InvalidArgumentException('请先完成报告确认,再登记回寄运单');
}
$expressCompany = trim($expressCompany);
$trackingNo = trim($trackingNo);
if ($expressCompany === '' || $trackingNo === '') {
throw new \InvalidArgumentException('请填写回寄快递公司和运单号');
}
$orderId = (int)$flow['order_id'];
$order = Db::name('orders')->where('id', $orderId)->find();
if (!$order) {
throw new \RuntimeException('订单不存在', 404);
}
$returnAddress = $this->returnAddressForOrder($order);
if (!$returnAddress) {
throw new \InvalidArgumentException('当前订单尚未确认寄回地址,且用户账户下没有可用地址');
}
$now = date('Y-m-d H:i:s');
$latestDesc = sprintf('平台已通过 %s 回寄商品,运单号 %s。', $expressCompany, $trackingNo);
Db::startTrans();
try {
$this->ensureReturnAddressSnapshot($orderId, $returnAddress, $now);
$existing = Db::name('order_logistics')
->where('order_id', $orderId)
->where('logistics_type', 'return_to_user')
->order('id', 'desc')
->find();
if ($existing) {
Db::name('order_logistics')->where('id', (int)$existing['id'])->update([
'express_company' => $expressCompany,
'tracking_no' => $trackingNo,
'tracking_status' => 'in_transit',
'latest_desc' => $latestDesc,
'latest_time' => $now,
'updated_at' => $now,
]);
$logisticsId = (int)$existing['id'];
$nodeText = '已更新回寄运单';
$nodeDesc = sprintf('平台更新回寄运单:%s %s', $expressCompany, $trackingNo);
} else {
$logisticsId = (int)Db::name('order_logistics')->insertGetId([
'order_id' => $orderId,
'logistics_type' => 'return_to_user',
'express_company' => $expressCompany,
'tracking_no' => $trackingNo,
'tracking_status' => 'in_transit',
'latest_desc' => $latestDesc,
'latest_time' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
$nodeText = '已寄回用户';
$nodeDesc = sprintf('平台已通过 %s 回寄商品,运单号 %s', $expressCompany, $trackingNo);
}
Db::name('order_logistics_nodes')->insert([
'logistics_id' => $logisticsId,
'node_time' => $now,
'node_desc' => $latestDesc,
'node_location' => $returnAddress['city'] ?? '用户地址',
'created_at' => $now,
]);
Db::name('orders')->where('id', $orderId)->update([
'order_status' => 'completed',
'display_status' => '物品已寄回',
'updated_at' => $now,
]);
Db::name('order_transfer_flows')->where('id', (int)$flow['id'])->update([
'flow_status' => 'ended',
'current_stage' => 'return_shipped',
'current_location' => 'ended',
'return_shipped_by' => $operator['id'],
'return_shipped_by_name' => $operator['name'],
'return_shipped_at' => $now,
'ended_at' => $now,
'updated_at' => $now,
]);
Db::name('internal_transfer_tags')->where('id', (int)$flow['internal_tag_id'])->update([
'bind_status' => 'released',
'current_order_id' => null,
'current_flow_id' => null,
'current_stage' => 'idle',
'current_location' => 'warehouse',
'released_by' => $operator['id'],
'released_by_name' => $operator['name'],
'released_at' => $now,
'updated_at' => $now,
]);
$this->insertTimeline($orderId, 'return_shipped', $nodeText, $nodeDesc, $operator, $now);
$this->insertFlowLog($flow, 'return_shipped', '物品寄回', 'return_confirmed', (string)$flow['current_location'], 'return_shipped', 'ended', $operator, $trackingNo, $now);
(new MessageDispatcher())->sendInboxEvent('return_shipped', [
'user_id' => (int)($order['user_id'] ?? 0),
'biz_type' => 'return_shipped',
'biz_id' => $orderId,
'express_company' => $expressCompany,
'tracking_no' => $trackingNo,
'fallback_title' => '鉴定物品已寄回',
'fallback_content' => sprintf('平台已通过%s回寄鉴定物品运单号 %s可前往订单详情查看物流进度。', $expressCompany, $trackingNo),
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
(new EnterpriseWebhookService())->recordOrderEvent($orderId, 'return_shipped', [
'express_company' => $expressCompany,
'tracking_no' => $trackingNo,
'shipped_at' => $now,
]);
return $this->formatOrderContext($orderId);
}
public function markReportPublished(int $orderId, Request $request): void
{
$flow = Db::name('order_transfer_flows')
->where('order_id', $orderId)
->where('flow_status', '<>', 'ended')
->order('id', 'desc')
->find();
if (!$flow) {
return;
}
$operator = $this->operator($request);
$now = date('Y-m-d H:i:s');
$this->updateFlowStage($flow, 'report_published', 'warehouse_return_pending', [
'report_published_by' => $operator['id'],
'report_published_by_name' => $operator['name'],
'report_published_at' => $now,
], $now);
$this->insertFlowLog($flow, 'report_published', '报告已发布', (string)$flow['current_stage'], (string)$flow['current_location'], 'report_published', 'warehouse_return_pending', $operator, '', $now);
}
public function formatOrderContext(int $orderId, ?Request $request = null): array
{
$order = Db::name('orders')->where('id', $orderId)->find();
if (!$order) {
throw new \RuntimeException('订单不存在', 404);
}
$product = Db::name('order_products')->where('order_id', $orderId)->find() ?: [];
$sendLogistics = Db::name('order_logistics')->where('order_id', $orderId)->where('logistics_type', 'send_to_center')->order('id', 'desc')->find();
$returnLogistics = Db::name('order_logistics')->where('order_id', $orderId)->where('logistics_type', 'return_to_user')->order('id', 'desc')->find();
$flow = Db::name('order_transfer_flows')->where('order_id', $orderId)->order('id', 'desc')->find();
$report = $this->latestReport($orderId);
$content = $report ? Db::name('report_contents')->where('report_id', (int)$report['id'])->find() : null;
$returnAddress = $this->returnAddressForOrder($order);
$flowLogs = $flow ? Db::name('order_transfer_flow_logs')
->where('flow_id', (int)$flow['id'])
->order('id', 'asc')
->select()
->toArray() : [];
return [
'order_info' => [
'id' => (int)$order['id'],
'order_no' => (string)$order['order_no'],
'appraisal_no' => (string)$order['appraisal_no'],
'service_provider' => (string)$order['service_provider'],
'service_provider_text' => $this->serviceProviderText((string)$order['service_provider']),
'source_channel' => (string)($order['source_channel'] ?? ''),
'source_channel_text' => $this->sourceChannelText((string)($order['source_channel'] ?? '')),
'source_customer_id' => (string)($order['source_customer_id'] ?? ''),
'order_status' => (string)$order['order_status'],
'display_status' => (string)$order['display_status'],
],
'product_info' => [
'product_name' => (string)($product['product_name'] ?? ''),
'category_name' => (string)($product['category_name'] ?? ''),
'brand_name' => (string)($product['brand_name'] ?? ''),
'color' => (string)($product['color'] ?? ''),
'size_spec' => (string)($product['size_spec'] ?? ''),
'serial_no' => (string)($product['serial_no'] ?? ''),
],
'logistics_info' => $sendLogistics ? [
'express_company' => (string)$sendLogistics['express_company'],
'tracking_no' => (string)$sendLogistics['tracking_no'],
'tracking_status' => (string)$sendLogistics['tracking_status'],
] : null,
'return_address' => $returnAddress ? [
'consignee' => (string)($returnAddress['consignee'] ?? ''),
'mobile' => (string)($returnAddress['mobile'] ?? ''),
'full_address' => trim(sprintf('%s%s%s%s', $returnAddress['province'] ?? '', $returnAddress['city'] ?? '', $returnAddress['district'] ?? '', $returnAddress['detail_address'] ?? '')),
] : null,
'return_logistics' => $returnLogistics ? [
'express_company' => (string)$returnLogistics['express_company'],
'tracking_no' => (string)$returnLogistics['tracking_no'],
'tracking_status' => (string)$returnLogistics['tracking_status'],
] : null,
'transfer_flow' => $flow ? $this->formatFlow($flow) : null,
'report_info' => $report ? [
'id' => (int)$report['id'],
'report_no' => (string)$report['report_no'],
'report_title' => (string)$report['report_title'],
'report_status' => (string)$report['report_status'],
'publish_time' => (string)($report['publish_time'] ?? ''),
'zhongjian_report_no' => (string)($report['zhongjian_report_no'] ?? ''),
'report_entry_admin_name' => (string)($report['report_entry_admin_name'] ?? ''),
'report_entered_at' => (string)($report['report_entered_at'] ?? ''),
'zhongjian_report_files' => $this->normalizeAssetList($this->decodeJsonArray($content['zhongjian_report_files_json'] ?? null), $request),
] : null,
'flow_logs' => array_map(fn (array $log) => [
'id' => (int)$log['id'],
'action_code' => (string)$log['action_code'],
'action_text' => (string)$log['action_text'],
'before_stage' => $this->stageText((string)($log['before_stage'] ?? '')),
'before_location' => $this->locationText((string)($log['before_location'] ?? '')),
'after_stage' => $this->stageText((string)($log['after_stage'] ?? '')),
'after_location' => $this->locationText((string)($log['after_location'] ?? '')),
'operator_name' => (string)($log['operator_name'] ?? ''),
'remark' => (string)($log['remark'] ?? ''),
'created_at' => (string)($log['created_at'] ?? ''),
], $flowLogs),
];
}
private function moveZhongjian(string $tagNo, string $expectedStage, string $nextStage, string $nextLocation, string $actionCode, string $actionText, string $desc, Request $request): array
{
$operator = $this->operator($request);
$flow = $this->findActiveFlowByTagNo($tagNo);
if (!$flow) {
throw new \RuntimeException('未找到可用的内部流转挂牌', 404);
}
if (($flow['service_provider'] ?? '') !== 'zhongjian') {
throw new \InvalidArgumentException('非中检订单不能进行送检出入库');
}
if ((string)$flow['current_stage'] !== $expectedStage) {
throw new \InvalidArgumentException('当前流转状态不支持该送检动作');
}
$now = date('Y-m-d H:i:s');
$auditFields = $actionCode === 'zhongjian_outbound'
? ['zhongjian_outbound_by' => $operator['id'], 'zhongjian_outbound_by_name' => $operator['name'], 'zhongjian_outbound_at' => $now]
: ['zhongjian_inbound_by' => $operator['id'], 'zhongjian_inbound_by_name' => $operator['name'], 'zhongjian_inbound_at' => $now];
Db::startTrans();
try {
$this->updateFlowStage($flow, $nextStage, $nextLocation, $auditFields, $now);
Db::name('orders')->where('id', (int)$flow['order_id'])->update([
'display_status' => $actionCode === 'zhongjian_outbound' ? '中检送检中' : '中检待录入报告',
'updated_at' => $now,
]);
$this->insertTimeline((int)$flow['order_id'], $actionCode, $actionText, $desc, $operator, $now);
$this->insertFlowLog($flow, $actionCode, $actionText, (string)$flow['current_stage'], (string)$flow['current_location'], $nextStage, $nextLocation, $operator, '', $now);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
return $this->lookupZhongjianTransfer($tagNo);
}
private function markReturnConfirmed(array $flow, array $operator, string $actionCode, string $actionText, string $timelineDesc): array
{
$now = date('Y-m-d H:i:s');
Db::startTrans();
try {
$this->updateFlowStage($flow, 'return_confirmed', 'warehouse_return_pending', [
'return_confirmed_by' => $operator['id'],
'return_confirmed_by_name' => $operator['name'],
'return_confirmed_at' => $now,
], $now);
$this->insertTimeline((int)$flow['order_id'], $actionCode, $actionText, $timelineDesc, $operator, $now);
$this->insertFlowLog($flow, $actionCode, $actionText, (string)$flow['current_stage'], (string)$flow['current_location'], 'return_confirmed', 'warehouse_return_pending', $operator, '', $now);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
return $this->formatOrderContext((int)$flow['order_id']);
}
private function updateFlowStage(array $flow, string $stage, string $location, array $extra, string $now): void
{
Db::name('order_transfer_flows')->where('id', (int)$flow['id'])->update(array_merge([
'current_stage' => $stage,
'current_location' => $location,
'updated_at' => $now,
], $extra));
Db::name('internal_transfer_tags')->where('id', (int)$flow['internal_tag_id'])->update([
'current_stage' => $stage,
'current_location' => $location,
'updated_at' => $now,
]);
}
private function insertTimeline(int $orderId, string $code, string $text, string $desc, array $operator, string $now): void
{
Db::name('order_timelines')->insert([
'order_id' => $orderId,
'node_code' => $code,
'node_text' => $text,
'node_desc' => $desc,
'operator_type' => 'admin',
'operator_id' => $operator['id'],
'occurred_at' => $now,
'created_at' => $now,
]);
}
private function insertFlowLog(array $flow, string $code, string $text, string $beforeStage, string $beforeLocation, string $afterStage, string $afterLocation, array $operator, string $remark, string $now): void
{
Db::name('order_transfer_flow_logs')->insert([
'flow_id' => (int)$flow['id'],
'order_id' => (int)$flow['order_id'],
'internal_tag_id' => (int)$flow['internal_tag_id'],
'internal_tag_no' => (string)$flow['internal_tag_no'],
'action_code' => $code,
'action_text' => $text,
'before_stage' => $beforeStage,
'before_location' => $beforeLocation,
'after_stage' => $afterStage,
'after_location' => $afterLocation,
'operator_id' => $operator['id'],
'operator_name' => $operator['name'],
'remark' => mb_substr($remark, 0, 500),
'payload_json' => null,
'created_at' => $now,
]);
}
private function ensureTransferTag(string $tagNo, array $operator, string $now): array
{
$tag = Db::name('internal_transfer_tags')->where('tag_no', $tagNo)->find();
if ($tag) {
if (($tag['status'] ?? 'active') === 'invalid') {
throw new \InvalidArgumentException('该内部流转挂牌已失效');
}
return $tag;
}
$id = (int)Db::name('internal_transfer_tags')->insertGetId([
'tag_no' => $tagNo,
'status' => 'active',
'bind_status' => 'free',
'current_stage' => 'idle',
'current_location' => 'warehouse',
'created_by' => $operator['id'],
'created_by_name' => $operator['name'],
'created_at' => $now,
'updated_at' => $now,
]);
return Db::name('internal_transfer_tags')->where('id', $id)->find();
}
private function findActiveFlowByTagNo(string $tagNo): ?array
{
$tagNo = $this->normalizeTagNo($tagNo);
if ($tagNo === '') {
throw new \InvalidArgumentException('请扫描内部流转挂牌编号');
}
return Db::name('order_transfer_flows')
->where('internal_tag_no', $tagNo)
->where('flow_status', '<>', 'ended')
->order('id', 'desc')
->find() ?: null;
}
private function findActiveFlowByTagId(int $tagId): ?array
{
return Db::name('order_transfer_flows')
->where('internal_tag_id', $tagId)
->where('flow_status', '<>', 'ended')
->order('id', 'desc')
->find() ?: null;
}
private function latestReport(int $orderId): ?array
{
return Db::name('reports')
->where('order_id', $orderId)
->where('report_type', 'appraisal')
->order('id', 'desc')
->find() ?: null;
}
private function returnAddressForOrder(array $order): ?array
{
$orderId = (int)($order['id'] ?? 0);
$address = Db::name('order_return_addresses')->where('order_id', $orderId)->find();
if ($address) {
return $address;
}
return Db::name('user_addresses')
->where('user_id', (int)($order['user_id'] ?? 0))
->where('is_default', 1)
->order('id', 'desc')
->find()
?: Db::name('user_addresses')
->where('user_id', (int)($order['user_id'] ?? 0))
->order('id', 'desc')
->find()
?: null;
}
private function ensureReturnAddressSnapshot(int $orderId, array $address, string $now): void
{
if (Db::name('order_return_addresses')->where('order_id', $orderId)->find()) {
return;
}
Db::name('order_return_addresses')->insert([
'order_id' => $orderId,
'user_address_id' => $address['user_address_id'] ?? ($address['id'] ?? null),
'consignee' => $address['consignee'] ?? '',
'mobile' => $address['mobile'] ?? '',
'province' => $address['province'] ?? '',
'city' => $address['city'] ?? '',
'district' => $address['district'] ?? '',
'detail_address' => $address['detail_address'] ?? '',
'created_at' => $now,
'updated_at' => $now,
]);
}
private function operator(Request $request): array
{
$id = (int)$request->header('x-admin-id', 0);
$name = trim((string)$request->header('x-admin-name', ''));
if ($id <= 0 || $name === '') {
throw new \RuntimeException('当前登录管理员信息异常', 401);
}
return ['id' => $id, 'name' => $name];
}
private function normalizeTagNo(string $tagNo): string
{
return mb_substr(trim($tagNo), 0, 80);
}
private function formatFlow(array $flow): array
{
return [
'id' => (int)$flow['id'],
'internal_tag_no' => (string)$flow['internal_tag_no'],
'flow_status' => (string)$flow['flow_status'],
'current_stage' => (string)$flow['current_stage'],
'current_stage_text' => $this->stageText((string)$flow['current_stage']),
'current_location' => (string)$flow['current_location'],
'current_location_text' => $this->locationText((string)$flow['current_location']),
'inbound_by_name' => (string)($flow['inbound_by_name'] ?? ''),
'inbound_at' => (string)($flow['inbound_at'] ?? ''),
'zhongjian_outbound_by_name' => (string)($flow['zhongjian_outbound_by_name'] ?? ''),
'zhongjian_outbound_at' => (string)($flow['zhongjian_outbound_at'] ?? ''),
'zhongjian_inbound_by_name' => (string)($flow['zhongjian_inbound_by_name'] ?? ''),
'zhongjian_inbound_at' => (string)($flow['zhongjian_inbound_at'] ?? ''),
'appraisal_started_by_name' => (string)($flow['appraisal_started_by_name'] ?? ''),
'appraisal_started_at' => (string)($flow['appraisal_started_at'] ?? ''),
'report_published_by_name' => (string)($flow['report_published_by_name'] ?? ''),
'report_published_at' => (string)($flow['report_published_at'] ?? ''),
'return_confirmed_by_name' => (string)($flow['return_confirmed_by_name'] ?? ''),
'return_confirmed_at' => (string)($flow['return_confirmed_at'] ?? ''),
'return_shipped_by_name' => (string)($flow['return_shipped_by_name'] ?? ''),
'return_shipped_at' => (string)($flow['return_shipped_at'] ?? ''),
];
}
private function stageText(string $stage): string
{
return match ($stage) {
'warehouse_received' => '已入仓待检',
'sent_to_zhongjian' => '中检送检出库',
'zhongjian_returned' => '中检送检入库',
'appraising' => '鉴定/报告录入中',
'report_published' => '报告已发布待寄回',
'return_confirmed' => '寄回确认完成',
'return_shipped' => '已寄回',
default => $stage,
};
}
private function locationText(string $location): string
{
return match ($location) {
'warehouse_pending_inspection' => '仓库待检区',
'zhongjian_institution' => '中检机构',
'warehouse_pending_report_entry' => '仓库中检回收区',
'appraiser_workbench' => '鉴定师作业区',
'zhongjian_report_entry' => '中检报告录入区',
'warehouse_return_pending' => '仓库待寄回区',
'ended' => '流转结束',
default => $location,
};
}
private function serviceProviderText(string $serviceProvider): string
{
return $serviceProvider === 'zhongjian' ? '中检鉴定' : '实物鉴定';
}
private function sourceChannelText(string $sourceChannel): string
{
return match ($sourceChannel) {
'mini_program' => '小程序',
'h5' => 'H5',
'enterprise_push' => '大客户推送订单',
default => $sourceChannel ?: '未知渠道',
};
}
private function decodeJsonArray(mixed $value): array
{
if (is_array($value)) {
return array_values($value);
}
if (is_string($value) && $value !== '') {
$decoded = json_decode($value, true);
return is_array($decoded) ? array_values($decoded) : [];
}
return [];
}
private function normalizeAssetList(array $files, ?Request $request): array
{
if (!$request) {
return $files;
}
return (new AppraisalEvidenceService())->normalize($files, $request);
}
}

View File

@@ -0,0 +1,323 @@
<?php
namespace app\support;
use support\think\Db;
use Webman\RedisQueue\Redis as RedisQueueClient;
class MaterialBatchPackageService
{
public const QUEUE_NAME = 'material-batch-package';
public const RETAIN_BATCHES = 3;
public function enqueueIfReady(int $batchId): bool
{
$batch = $this->batch($batchId);
if (!$batch || ($batch['status'] ?? 'active') === 'invalid' || ($batch['package_status'] ?? '') === 'purged') {
return false;
}
$packageStatus = (string)($batch['package_status'] ?? 'pending');
if ($packageStatus === 'generated' && trim((string)($batch['package_path'] ?? '')) !== '') {
return false;
}
if (in_array($packageStatus, ['pending', 'generating'], true) && trim((string)($batch['package_requested_at'] ?? '')) !== '') {
return false;
}
if (!$this->allQrImagesGenerated($batchId, (int)$batch['total_count'])) {
return false;
}
$now = date('Y-m-d H:i:s');
Db::name('material_batches')->where('id', $batchId)->update([
'package_status' => 'pending',
'package_error' => '',
'package_requested_at' => $now,
'updated_at' => $now,
]);
try {
$sent = (bool)RedisQueueClient::send(self::QUEUE_NAME, ['batch_id' => $batchId]);
if (!$sent) {
throw new \RuntimeException('Redis 队列写入返回失败');
}
return true;
} catch (\Throwable $e) {
Db::name('material_batches')->where('id', $batchId)->update([
'package_status' => 'failed',
'package_error' => mb_substr('压缩包生成任务投递失败:' . $e->getMessage(), 0, 500),
'updated_at' => date('Y-m-d H:i:s'),
]);
throw $e;
}
}
public function generateForBatchId(int $batchId): ?array
{
$batch = $this->batch($batchId);
if (!$batch) {
return null;
}
if (($batch['package_status'] ?? '') === 'purged') {
return $batch;
}
if (($batch['status'] ?? 'active') === 'invalid') {
throw new \RuntimeException('物料批次已失效,不能生成压缩包');
}
$now = date('Y-m-d H:i:s');
Db::name('material_batches')->where('id', $batchId)->update([
'package_status' => 'generating',
'package_error' => '',
'updated_at' => $now,
]);
try {
$codes = Db::name('material_tag_codes')
->where('batch_id', $batchId)
->where('status', 'active')
->order('id', 'asc')
->select()
->toArray();
if (count($codes) !== (int)$batch['total_count']) {
throw new \RuntimeException('该批次存在已失效条码,不能生成完整生产压缩包');
}
$qrService = new MaterialTagQrCodeService();
foreach ($codes as $index => $row) {
if ((string)($row['qr_image_status'] ?? '') !== 'generated'
|| trim((string)($row['qr_image_path'] ?? '')) === ''
|| !$qrService->isCurrentMaterialTagCard((string)$row['qr_image_path'])
) {
$codes[$index] = $qrService->generateForTag($row);
}
}
foreach ($codes as $row) {
if ((string)($row['qr_image_status'] ?? '') !== 'generated' || trim((string)($row['qr_image_path'] ?? '')) === '') {
throw new \RuntimeException('吊牌图片尚未全部生成');
}
if (!is_file($this->resource()->publicPath((string)$row['qr_image_path']))) {
throw new \RuntimeException('吊牌图片本地文件不存在:' . (string)$row['verify_code']);
}
}
$relativePath = $this->resource()->packageRelativePath((string)$batch['batch_no']);
$target = $this->resource()->publicPath($relativePath);
$this->resource()->ensureParentDirectory($target);
$this->buildZipFile($target, $batch, $codes);
$publicUrl = $this->resource()->publicUrl($relativePath);
$freshNow = date('Y-m-d H:i:s');
Db::name('material_batches')->where('id', $batchId)->update([
'package_status' => 'generated',
'package_path' => $relativePath,
'package_url' => $publicUrl,
'package_error' => '',
'package_generated_at' => $freshNow,
'package_purged_at' => null,
'updated_at' => $freshNow,
]);
$this->purgeOldBatchResources();
return Db::name('material_batches')->where('id', $batchId)->find() ?: $batch;
} catch (\Throwable $e) {
Db::name('material_batches')->where('id', $batchId)->update([
'package_status' => 'failed',
'package_error' => mb_substr($e->getMessage(), 0, 500),
'updated_at' => date('Y-m-d H:i:s'),
]);
throw $e;
}
}
public function purgeOldBatchResources(): void
{
$keepIds = Db::name('material_batches')
->order('created_at', 'desc')
->order('id', 'desc')
->limit(self::RETAIN_BATCHES)
->column('id');
$keepIds = array_values(array_filter(array_map('intval', $keepIds)));
$query = Db::name('material_batches')->where('package_status', '<>', 'purged');
if ($keepIds) {
$query->whereNotIn('id', $keepIds);
}
$oldBatches = $query->select()->toArray();
if (!$oldBatches) {
return;
}
$now = date('Y-m-d H:i:s');
foreach ($oldBatches as $batch) {
$batchId = (int)$batch['id'];
$this->resource()->deleteBatchResources((string)$batch['batch_no']);
Db::name('material_batches')->where('id', $batchId)->update([
'package_status' => 'purged',
'package_path' => '',
'package_url' => '',
'package_error' => MaterialLocalResourceService::RETENTION_MESSAGE,
'package_purged_at' => $now,
'updated_at' => $now,
]);
Db::name('material_tag_codes')->where('batch_id', $batchId)->update([
'qr_image_status' => 'purged',
'qr_image_url' => '',
'qr_image_path' => '',
'qr_image_error' => MaterialLocalResourceService::RETENTION_MESSAGE,
'updated_at' => $now,
]);
}
}
private function allQrImagesGenerated(int $batchId, int $totalCount): bool
{
if ($totalCount <= 0) {
return false;
}
$count = (int)Db::name('material_tag_codes')
->where('batch_id', $batchId)
->where('status', 'active')
->where('qr_image_status', 'generated')
->where('qr_image_url', '<>', '')
->count();
return $count === $totalCount;
}
private function buildZipFile(string $target, array $batch, array $codes): void
{
if (!class_exists(\ZipArchive::class)) {
throw new \RuntimeException('当前 PHP 环境缺少 ZipArchive 扩展,无法生成压缩包');
}
$zip = new \ZipArchive();
if ($zip->open($target, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
throw new \RuntimeException('压缩包创建失败');
}
$safeBatchNo = $this->resource()->safeName((string)$batch['batch_no'], 'batch');
$zip->addFromString(sprintf('material-batch-%s.xlsx', $safeBatchNo), $this->buildXlsxBinary($codes));
$usedNames = [];
foreach ($codes as $row) {
$filename = $this->uniqueQrFilename((string)$row['verify_code'], $usedNames);
$zip->addFile($this->resource()->publicPath((string)$row['qr_image_path']), 'tag-cards/' . $filename);
}
if (!$zip->close()) {
@unlink($target);
throw new \RuntimeException('压缩包写入失败');
}
}
private function uniqueQrFilename(string $verifyCode, array &$usedNames): string
{
$base = $this->resource()->safeName($verifyCode, 'code');
$candidate = $base . '.png';
$index = 2;
while (isset($usedNames[$candidate])) {
$candidate = sprintf('%s-%d.png', $base, $index++);
}
$usedNames[$candidate] = true;
return $candidate;
}
private function batch(int $batchId): ?array
{
return $batchId > 0 ? (Db::name('material_batches')->where('id', $batchId)->find() ?: null) : null;
}
private function resource(): MaterialLocalResourceService
{
return new MaterialLocalResourceService();
}
private function buildXlsxBinary(array $rows): string
{
$tmpFile = tempnam(sys_get_temp_dir(), 'mat_xlsx_');
if ($tmpFile === false) {
throw new \RuntimeException('临时文件创建失败');
}
$zip = new \ZipArchive();
if ($zip->open($tmpFile, \ZipArchive::OVERWRITE) !== true) {
@unlink($tmpFile);
throw new \RuntimeException('Excel 文件创建失败');
}
$zip->addFromString('[Content_Types].xml', $this->xlsxContentTypesXml());
$zip->addFromString('_rels/.rels', $this->xlsxRelsXml());
$zip->addFromString('xl/workbook.xml', $this->xlsxWorkbookXml());
$zip->addFromString('xl/_rels/workbook.xml.rels', $this->xlsxWorkbookRelsXml());
$zip->addFromString('xl/worksheets/sheet1.xml', $this->xlsxSheetXml($rows));
$zip->close();
$content = file_get_contents($tmpFile);
@unlink($tmpFile);
if ($content === false) {
throw new \RuntimeException('Excel 文件读取失败');
}
return $content;
}
private function xlsxContentTypesXml(): string
{
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
. '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
. '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
. '<Default Extension="xml" ContentType="application/xml"/>'
. '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
. '<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
. '</Types>';
}
private function xlsxRelsXml(): string
{
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
. '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
. '</Relationships>';
}
private function xlsxWorkbookXml(): string
{
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
. '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
. '<sheets><sheet name="物料二维码" sheetId="1" r:id="rId1"/></sheets>'
. '</workbook>';
}
private function xlsxWorkbookRelsXml(): string
{
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
. '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>'
. '</Relationships>';
}
private function xlsxSheetXml(array $rows): string
{
$sheetRows = [
['吊牌图片链接', '验真编码'],
...array_map(fn (array $row) => [(string)$row['qr_image_url'], (string)$row['verify_code']], $rows),
];
$xmlRows = [];
foreach ($sheetRows as $rowIndex => $row) {
$excelRow = $rowIndex + 1;
$xmlRows[] = sprintf(
'<row r="%d"><c r="A%d" t="inlineStr"><is><t>%s</t></is></c><c r="B%d" t="inlineStr"><is><t>%s</t></is></c></row>',
$excelRow,
$excelRow,
htmlspecialchars($row[0], ENT_XML1 | ENT_COMPAT, 'UTF-8'),
$excelRow,
htmlspecialchars($row[1], ENT_XML1 | ENT_COMPAT, 'UTF-8')
);
}
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
. '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
. '<cols><col min="1" max="1" width="72" customWidth="1"/><col min="2" max="2" width="16" customWidth="1"/></cols>'
. '<sheetData>' . implode('', $xmlRows) . '</sheetData>'
. '</worksheet>';
}
}

View File

@@ -0,0 +1,162 @@
<?php
namespace app\support;
use support\think\Db;
class MaterialLocalResourceService
{
public const RETENTION_MESSAGE = '系统仅保留3个批次图片';
public function qrRelativePath(string $batchNo, string $verifyCode): string
{
return sprintf(
'uploads/material-qrcodes/%s/%s.png',
$this->safeName($batchNo, 'batch'),
$this->safeName($verifyCode, 'code')
);
}
public function qrBatchDirectory(string $batchNo): string
{
return 'uploads/material-qrcodes/' . $this->safeName($batchNo, 'batch');
}
public function packageRelativePath(string $batchNo): string
{
$safeBatchNo = $this->safeName($batchNo, 'batch');
return sprintf(
'uploads/material-packages/%s/material-batch-%s.zip',
$safeBatchNo,
$safeBatchNo
);
}
public function packageBatchDirectory(string $batchNo): string
{
return 'uploads/material-packages/' . $this->safeName($batchNo, 'batch');
}
public function materialTagTemplatePath(): string
{
return dirname(__DIR__, 2) . '/resources/material-tag-template.jpg';
}
public function publicPath(string $relativePath): string
{
return public_path() . '/' . ltrim($relativePath, '/');
}
public function publicUrl(string $relativePath): string
{
$baseUrl = $this->localBaseUrl();
if ($baseUrl === '') {
throw new \RuntimeException('本地文件公开访问域名未配置,无法生成二维码下载链接');
}
return rtrim($baseUrl, '/') . '/' . ltrim($relativePath, '/');
}
public function ensureParentDirectory(string $filePath): void
{
$dir = dirname($filePath);
if (!is_dir($dir) && !mkdir($dir, 0775, true) && !is_dir($dir)) {
throw new \RuntimeException('本地文件目录创建失败');
}
}
public function deleteBatchResources(string $batchNo): void
{
$this->deleteDirectory($this->publicPath($this->qrBatchDirectory($batchNo)));
$this->deleteDirectory($this->publicPath($this->packageBatchDirectory($batchNo)));
}
public function deleteDirectory(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$items = scandir($dir);
if ($items === false) {
return;
}
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $dir . DIRECTORY_SEPARATOR . $item;
if (is_dir($path)) {
$this->deleteDirectory($path);
continue;
}
if (is_file($path)) {
@unlink($path);
}
}
@rmdir($dir);
}
public function safeName(string $value, string $fallback): string
{
$safe = preg_replace('/[^a-zA-Z0-9_-]/', '-', trim($value));
$safe = trim((string)$safe, '-_');
return $safe !== '' ? $safe : $fallback;
}
private function localBaseUrl(): string
{
foreach (['MATERIAL_LOCAL_BASE_URL', 'APP_PUBLIC_BASE_URL', 'PUBLIC_FILE_BASE_URL'] as $key) {
$value = trim((string)($_ENV[$key] ?? ''));
if ($value !== '') {
return $this->normalizeBaseUrl($value);
}
}
$notifyUrl = Db::name('system_configs')
->where('config_group', 'payment')
->where('config_key', 'notify_url')
->value('config_value');
if (is_string($notifyUrl) && trim($notifyUrl) !== '') {
return $this->extractOrigin($notifyUrl);
}
if (!in_array(strtolower((string)($_ENV['APP_ENV'] ?? '')), ['production', 'prod'], true)) {
return 'http://127.0.0.1:8787';
}
return '';
}
private function normalizeBaseUrl(string $baseUrl): string
{
$baseUrl = trim($baseUrl);
if ($baseUrl === '') {
return '';
}
if (!preg_match('/^https?:\/\//i', $baseUrl)) {
$baseUrl = 'https://' . ltrim($baseUrl, '/');
}
return rtrim($baseUrl, '/');
}
private function extractOrigin(string $url): string
{
$parts = parse_url(trim($url));
$scheme = (string)($parts['scheme'] ?? '');
$host = (string)($parts['host'] ?? '');
$port = (string)($parts['port'] ?? '');
if ($host === '') {
return $this->normalizeBaseUrl($url);
}
$origin = ($scheme !== '' ? $scheme : 'https') . '://' . $host;
if ($port !== '' && !(($scheme === 'http' && $port === '80') || ($scheme === 'https' && $port === '443'))) {
$origin .= ':' . $port;
}
return rtrim($origin, '/');
}
}

View File

@@ -0,0 +1,388 @@
<?php
namespace app\support;
use Endroid\QrCode\Color\Color;
use Endroid\QrCode\Encoding\Encoding;
use Endroid\QrCode\ErrorCorrectionLevel;
use Endroid\QrCode\QrCode;
use Endroid\QrCode\RoundBlockSizeMode;
use Endroid\QrCode\Writer\PngWriter;
use support\think\Db;
class MaterialTagQrCodeService
{
public const IMAGE_SIZE_PX = 945;
public const QUEUE_NAME = 'material-tag-qrcode';
private const QUIET_ZONE_MARGIN_PX = 40;
private const CARD_QR_TOTAL_SIZE_PX = 520;
private const CARD_QR_MARGIN_PX = 27;
private const CARD_QR_BOX = [
'x' => 565,
'y' => 1085,
'w' => 605,
'h' => 605,
];
private const CARD_CODE_BOX = [
'x' => 460,
'y' => 1935,
'w' => 788,
'h' => 186,
];
private const CARD_CODE_COLOR = [0, 0, 0];
private const CARD_CODE_FONT_SIZE_MAX = 110;
private const CARD_CODE_FONT_SIZE_MIN = 88;
public function generateForTagId(int $tagId): ?array
{
if ($tagId <= 0) {
return null;
}
$tag = Db::name('material_tag_codes')->where('id', $tagId)->find();
if (!$tag) {
return null;
}
return $this->generateForTag($tag);
}
public function generateForTag(array $tag): array
{
if (
($tag['qr_image_status'] ?? '') === 'generated'
&& trim((string)($tag['qr_image_url'] ?? '')) !== ''
&& $this->isCurrentMaterialTagCard((string)($tag['qr_image_path'] ?? ''))
) {
return $tag;
}
if (($tag['qr_image_status'] ?? '') === 'purged' || ($tag['status'] ?? 'active') === 'invalid') {
return $tag;
}
$tagId = (int)$tag['id'];
$batchId = (int)$tag['batch_id'];
$batch = Db::name('material_batches')->where('id', $batchId)->find();
if (!$batch) {
throw new \RuntimeException('物料批次不存在');
}
if (($batch['status'] ?? 'active') === 'invalid' || ($batch['package_status'] ?? '') === 'purged') {
return $tag;
}
$now = date('Y-m-d H:i:s');
Db::name('material_tag_codes')->where('id', $tagId)->update([
'qr_image_status' => 'generating',
'qr_image_error' => '',
'updated_at' => $now,
]);
try {
$resource = new MaterialLocalResourceService();
$relativePath = $resource->qrRelativePath((string)$batch['batch_no'], (string)$tag['verify_code']);
$png = $this->buildMaterialTagCard((string)$tag['qr_url'], (string)$tag['verify_code']);
$target = $resource->publicPath($relativePath);
$resource->ensureParentDirectory($target);
if (file_put_contents($target, $png) === false) {
throw new \RuntimeException('吊牌图片写入本地文件失败');
}
$publicUrl = $resource->publicUrl($relativePath);
$freshNow = date('Y-m-d H:i:s');
Db::name('material_tag_codes')->where('id', $tagId)->update([
'qr_image_url' => $publicUrl,
'qr_image_path' => $relativePath,
'qr_image_status' => 'generated',
'qr_image_error' => '',
'qr_image_generated_at' => $freshNow,
'updated_at' => $freshNow,
]);
$tag['qr_image_url'] = $publicUrl;
$tag['qr_image_path'] = $relativePath;
$tag['qr_image_status'] = 'generated';
$tag['qr_image_error'] = '';
$tag['qr_image_generated_at'] = $freshNow;
return $tag;
} catch (\Throwable $e) {
Db::name('material_tag_codes')->where('id', $tagId)->update([
'qr_image_status' => 'failed',
'qr_image_error' => mb_substr($e->getMessage(), 0, 500),
'updated_at' => date('Y-m-d H:i:s'),
]);
throw $e;
}
}
private function buildPng(string $content): string
{
return $this->buildQrPng($content, self::IMAGE_SIZE_PX, self::QUIET_ZONE_MARGIN_PX);
}
private function buildQrPng(
string $content,
int $totalSizePx,
int $marginPx,
?ErrorCorrectionLevel $errorCorrectionLevel = null
): string
{
if ($content === '') {
throw new \RuntimeException('二维码内容为空');
}
$innerSize = $totalSizePx - ($marginPx * 2);
if ($innerSize < 1) {
throw new \RuntimeException('二维码尺寸配置错误');
}
$qrCode = QrCode::create($content)
->setEncoding(new Encoding('UTF-8'))
->setErrorCorrectionLevel($errorCorrectionLevel ?? ErrorCorrectionLevel::High)
->setSize($innerSize)
->setMargin($marginPx)
->setRoundBlockSizeMode(RoundBlockSizeMode::None)
->setForegroundColor(new Color(0, 0, 0))
->setBackgroundColor(new Color(255, 255, 255));
return (new PngWriter())->write($qrCode)->getString();
}
private function buildMaterialTagCard(string $content, string $verifyCode): string
{
$templatePath = (new MaterialLocalResourceService())->materialTagTemplatePath();
if (!is_file($templatePath)) {
throw new \RuntimeException('吊牌模板图片不存在');
}
$template = imagecreatefromjpeg($templatePath);
if (!$template) {
throw new \RuntimeException('吊牌模板图片读取失败');
}
$qrImage = null;
$png = '';
try {
$qrPng = $this->buildQrPng(
$content,
self::CARD_QR_TOTAL_SIZE_PX,
self::CARD_QR_MARGIN_PX,
ErrorCorrectionLevel::Medium
);
$qrImage = imagecreatefromstring($qrPng);
if (!$qrImage) {
throw new \RuntimeException('二维码图片解码失败');
}
imagealphablending($template, true);
imagesavealpha($template, true);
$qrBox = self::CARD_QR_BOX;
imagecopyresampled(
$template,
$qrImage,
$qrBox['x'],
$qrBox['y'],
0,
0,
$qrBox['w'],
$qrBox['h'],
imagesx($qrImage),
imagesy($qrImage)
);
$this->drawCenteredVerifyCode($template, $verifyCode, self::CARD_CODE_BOX);
ob_start();
imagepng($template);
$png = ob_get_clean();
} finally {
if ($qrImage instanceof \GdImage) {
imagedestroy($qrImage);
}
if ($template instanceof \GdImage) {
imagedestroy($template);
}
}
if (!is_string($png) || $png === '') {
throw new \RuntimeException('吊牌图片生成失败');
}
return $png;
}
private function drawCenteredVerifyCode($canvas, string $verifyCode, array $box): void
{
$fontPath = $this->resolveFontPath();
if ($fontPath === '') {
throw new \RuntimeException('验真编码字体文件未找到');
}
$fontSize = self::CARD_CODE_FONT_SIZE_MAX;
$paddingX = 28;
$paddingY = 16;
$text = trim($verifyCode);
if ($text === '') {
throw new \RuntimeException('验真编码为空');
}
$bbox = null;
for (; $fontSize >= self::CARD_CODE_FONT_SIZE_MIN; $fontSize--) {
$bbox = imagettfbbox($fontSize, 0, $fontPath, $text);
if ($bbox === false) {
continue;
}
$textWidth = $this->bboxWidth($bbox);
$textHeight = $this->bboxHeight($bbox);
if ($textWidth <= ($box['w'] - ($paddingX * 2)) && $textHeight <= ($box['h'] - ($paddingY * 2))) {
break;
}
}
if ($bbox === false || $bbox === null) {
throw new \RuntimeException('验真编码字体绘制失败');
}
$left = min($bbox[0], $bbox[2], $bbox[4], $bbox[6]);
$right = max($bbox[0], $bbox[2], $bbox[4], $bbox[6]);
$top = min($bbox[1], $bbox[3], $bbox[5], $bbox[7]);
$bottom = max($bbox[1], $bbox[3], $bbox[5], $bbox[7]);
$textWidth = $right - $left;
$textHeight = $bottom - $top;
$centerX = $box['x'] + ($box['w'] / 2);
$centerY = $box['y'] + ($box['h'] / 2);
$x = (int)round($centerX - ($textWidth / 2) - $left);
$y = (int)round($centerY + ($textHeight / 2) - $bottom);
$color = imagecolorallocate($canvas, self::CARD_CODE_COLOR[0], self::CARD_CODE_COLOR[1], self::CARD_CODE_COLOR[2]);
if ($color === false) {
throw new \RuntimeException('验真编码颜色分配失败');
}
if (imagettftext($canvas, $fontSize, 0, $x, $y, $color, $fontPath, $text) === false) {
throw new \RuntimeException('验真编码绘制失败');
}
}
private function resolveFontPath(): string
{
$candidates = [
'/System/Library/Fonts/Supplemental/Arial.ttf',
'/System/Library/Fonts/Helvetica.ttc',
dirname(__DIR__, 2) . '/resources/fonts/DejaVuSans-Bold.ttf',
'/usr/share/fonts/dejavu/DejaVuSans-Bold.ttf',
'/usr/share/fonts/dejavu/DejaVuSans.ttf',
'/usr/share/fonts/noto/NotoSansCJK-Regular.ttc',
'/System/Library/Fonts/Hiragino Sans GB.ttc',
'/System/Library/Fonts/STHeiti Medium.ttc',
'/System/Library/Fonts/Supplemental/Arial Unicode.ttf',
];
foreach ($candidates as $candidate) {
if (is_file($candidate)) {
return $candidate;
}
}
return '';
}
public function isCurrentMaterialTagCard(string $relativePath): bool
{
$path = trim($relativePath);
if ($path === '') {
return false;
}
$publicPath = (new MaterialLocalResourceService())->publicPath($path);
if (!is_file($publicPath)) {
return false;
}
$imageSize = @getimagesize($publicPath);
if (!is_array($imageSize) || !isset($imageSize[0], $imageSize[1])) {
return false;
}
$templateSize = @getimagesize((new MaterialLocalResourceService())->materialTagTemplatePath());
if (!is_array($templateSize) || !isset($templateSize[0], $templateSize[1])) {
return false;
}
if ((int)$imageSize[0] !== (int)$templateSize[0] || (int)$imageSize[1] !== (int)$templateSize[1]) {
return false;
}
if (!$this->isGeneratedAfterTemplateAndServiceUpdate($publicPath)) {
return false;
}
return $this->hasQrContentAtCurrentPosition($publicPath);
}
private function bboxWidth(array $bbox): int
{
return (int)(max($bbox[0], $bbox[2], $bbox[4], $bbox[6]) - min($bbox[0], $bbox[2], $bbox[4], $bbox[6]));
}
private function bboxHeight(array $bbox): int
{
return (int)(max($bbox[1], $bbox[3], $bbox[5], $bbox[7]) - min($bbox[1], $bbox[3], $bbox[5], $bbox[7]));
}
private function isGeneratedAfterTemplateAndServiceUpdate(string $imagePath): bool
{
$imageMtime = @filemtime($imagePath);
$templateMtime = @filemtime((new MaterialLocalResourceService())->materialTagTemplatePath());
$serviceMtime = @filemtime(__FILE__);
if ($imageMtime === false || $templateMtime === false || $serviceMtime === false) {
return false;
}
return $imageMtime >= max($templateMtime, $serviceMtime);
}
private function hasQrContentAtCurrentPosition(string $imagePath): bool
{
$image = @imagecreatefromstring((string)@file_get_contents($imagePath));
if (!$image instanceof \GdImage) {
return false;
}
try {
$probe = [
'x' => self::CARD_QR_BOX['x'] + self::CARD_QR_MARGIN_PX + 24,
'y' => self::CARD_QR_BOX['y'] + self::CARD_QR_BOX['h'] - 140,
'w' => self::CARD_QR_BOX['w'] - ((self::CARD_QR_MARGIN_PX + 24) * 2),
'h' => 100,
];
if ($probe['w'] <= 0 || $probe['h'] <= 0) {
return false;
}
$darkPixels = 0;
$sampledPixels = 0;
for ($y = $probe['y']; $y < ($probe['y'] + $probe['h']); $y += 4) {
for ($x = $probe['x']; $x < ($probe['x'] + $probe['w']); $x += 4) {
$rgb = imagecolorat($image, $x, $y);
$r = ($rgb >> 16) & 0xFF;
$g = ($rgb >> 8) & 0xFF;
$b = $rgb & 0xFF;
if (($r + $g + $b) < 180) {
$darkPixels++;
}
$sampledPixels++;
}
}
return $sampledPixels > 0 && ($darkPixels / $sampledPixels) > 0.08;
} finally {
imagedestroy($image);
}
}
public function pngForContent(string $content): string
{
return $this->buildPng($content);
}
}

View File

@@ -2,13 +2,17 @@
namespace app\support;
use support\Log;
use support\Request;
use support\think\Db;
use Webman\RedisQueue\Redis as RedisQueueClient;
class MaterialTagService
{
private const VERIFY_CODE_CHARS = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
private const MAX_BATCH_COUNT = 10000;
private const QR_IMAGE_JOB_CHUNK_SIZE = 100;
private const MATERIAL_TAG_TOKEN_ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ';
private const MATERIAL_TAG_TOKEN_LENGTH = 7;
public function createBatch(int $count, string $remark, int $adminId, string $adminName): array
{
@@ -29,6 +33,11 @@ class MaterialTagService
$batchId = (int)Db::name('material_batches')->insertGetId([
'batch_no' => $batchNo,
'total_count' => $count,
'status' => 'active',
'package_status' => 'pending',
'package_path' => '',
'package_url' => '',
'package_error' => '',
'remark' => mb_substr($remark, 0, 500),
'download_count' => 0,
'created_by' => $adminId,
@@ -39,14 +48,22 @@ class MaterialTagService
$rows = [];
$pendingTokens = [];
$pendingVerifyCodes = [];
for ($i = 0; $i < $count; $i++) {
$token = $this->generateUniqueToken($pendingTokens);
$pendingTokens[$token] = true;
$verifyCode = $this->generateVerifyCode($pendingVerifyCodes);
$pendingVerifyCodes[$verifyCode] = true;
$rows[] = [
'batch_id' => $batchId,
'qr_token' => $token,
'qr_url' => $this->buildMaterialTagUrl($token, $h5BaseUrl),
'verify_code' => $this->generateVerifyCode(),
'qr_image_url' => '',
'qr_image_path' => '',
'qr_image_status' => 'pending',
'qr_image_error' => '',
'verify_code' => $verifyCode,
'status' => 'active',
'bind_status' => 'unbound',
'scan_count' => 0,
'verify_count' => 0,
@@ -65,6 +82,15 @@ class MaterialTagService
throw $e;
}
try {
$this->enqueueQrImageJobs($batchId);
} catch (\Throwable $e) {
Log::error('material tag QR image jobs enqueue failed after batch created', [
'batch_id' => $batchId,
'message' => $e->getMessage(),
]);
}
return [
'id' => $batchId,
'batch_no' => $batchNo,
@@ -131,6 +157,7 @@ class MaterialTagService
->where('bind_status', 'bound')
->group('batch_id')
->column('COUNT(*) AS c', 'batch_id');
$qrImageStats = $this->loadQrImageStats($batchIds);
$matchedByBatch = [];
if ($matchedCodeRows) {
@@ -141,13 +168,35 @@ class MaterialTagService
}
}
return array_map(function (array $row) use ($boundCounts, $matchedByBatch) {
return array_map(function (array $row) use ($boundCounts, $matchedByBatch, $qrImageStats) {
$id = (int)$row['id'];
$imageStat = $qrImageStats[$id] ?? $this->emptyQrImageStats();
if (
($row['status'] ?? 'active') !== 'invalid'
&& !in_array((string)($row['package_status'] ?? 'pending'), ['generated', 'generating', 'purged'], true)
&& $imageStat['generated'] === (int)$row['total_count']
) {
$this->enqueuePackageIfReady($id);
}
return [
'id' => $id,
'batch_no' => (string)$row['batch_no'],
'total_count' => (int)$row['total_count'],
'status' => (string)($row['status'] ?? 'active'),
'status_text' => $this->materialStatusText((string)($row['status'] ?? 'active')),
'invalidated_at' => (string)($row['invalidated_at'] ?? ''),
'invalidated_by_name' => (string)($row['invalidated_by_name'] ?? ''),
'invalid_reason' => (string)($row['invalid_reason'] ?? ''),
'package_status' => (string)($row['package_status'] ?? 'pending'),
'package_status_text' => $this->packageStatusText((string)($row['package_status'] ?? 'pending')),
'package_url' => (string)($row['package_url'] ?? ''),
'package_error' => (string)($row['package_error'] ?? ''),
'package_generated_at' => (string)($row['package_generated_at'] ?? ''),
'package_purged_at' => (string)($row['package_purged_at'] ?? ''),
'bound_count' => (int)($boundCounts[$id] ?? 0),
'qr_image_generated_count' => $imageStat['generated'],
'qr_image_failed_count' => $imageStat['failed'],
'qr_image_pending_count' => $imageStat['pending'],
'download_count' => (int)$row['download_count'],
'remark' => (string)($row['remark'] ?? ''),
'created_by_name' => (string)($row['created_by_name'] ?? ''),
@@ -164,7 +213,6 @@ class MaterialTagService
if (!$batch) {
throw new \RuntimeException('物料批次不存在', 404);
}
$query = Db::name('material_tag_codes')->where('batch_id', $batchId)->order('id', 'asc');
$keyword = trim($keyword);
if ($keyword !== '') {
@@ -179,13 +227,32 @@ class MaterialTagService
}
$codes = $query->select()->toArray();
if (($batch['status'] ?? 'active') !== 'invalid' && ($batch['package_status'] ?? '') !== 'purged') {
$this->enqueueMissingQrImageJobs($batchId, $codes);
$this->enqueuePackageIfReady($batchId);
}
$reportMap = $this->loadReportMap(array_values(array_filter(array_map(fn (array $item) => (int)($item['report_id'] ?? 0), $codes))));
$qrImageStats = $this->loadQrImageStats([$batchId])[$batchId] ?? $this->emptyQrImageStats();
return [
'batch' => [
'id' => (int)$batch['id'],
'batch_no' => (string)$batch['batch_no'],
'total_count' => (int)$batch['total_count'],
'status' => (string)($batch['status'] ?? 'active'),
'status_text' => $this->materialStatusText((string)($batch['status'] ?? 'active')),
'invalidated_at' => (string)($batch['invalidated_at'] ?? ''),
'invalidated_by_name' => (string)($batch['invalidated_by_name'] ?? ''),
'invalid_reason' => (string)($batch['invalid_reason'] ?? ''),
'package_status' => (string)($batch['package_status'] ?? 'pending'),
'package_status_text' => $this->packageStatusText((string)($batch['package_status'] ?? 'pending')),
'package_url' => (string)($batch['package_url'] ?? ''),
'package_error' => (string)($batch['package_error'] ?? ''),
'package_generated_at' => (string)($batch['package_generated_at'] ?? ''),
'package_purged_at' => (string)($batch['package_purged_at'] ?? ''),
'qr_image_generated_count' => $qrImageStats['generated'],
'qr_image_failed_count' => $qrImageStats['failed'],
'qr_image_pending_count' => $qrImageStats['pending'],
'download_count' => (int)$batch['download_count'],
'remark' => (string)($batch['remark'] ?? ''),
'created_by_name' => (string)($batch['created_by_name'] ?? ''),
@@ -202,16 +269,33 @@ class MaterialTagService
if (!$batch) {
throw new \RuntimeException('物料批次不存在', 404);
}
if (($batch['status'] ?? 'active') === 'invalid') {
throw new \RuntimeException('该物料批次已失效,不能下载压缩包', 409);
}
if (($batch['package_status'] ?? 'pending') === 'purged') {
throw new \RuntimeException(MaterialLocalResourceService::RETENTION_MESSAGE, 410);
}
if (($batch['package_status'] ?? 'pending') !== 'generated' || trim((string)($batch['package_path'] ?? '')) === '') {
throw new \RuntimeException('文件生成中,请稍后再下载', 409);
}
$codes = Db::name('material_tag_codes')
->where('batch_id', $batchId)
->order('id', 'asc')
->field(['qr_url', 'verify_code'])
->select()
->toArray();
$resource = new MaterialLocalResourceService();
$filePath = $resource->publicPath((string)$batch['package_path']);
if (!is_file($filePath)) {
Db::name('material_batches')->where('id', $batchId)->update([
'package_status' => 'failed',
'package_error' => '压缩包本地文件不存在',
'updated_at' => date('Y-m-d H:i:s'),
]);
throw new \RuntimeException('压缩包本地文件不存在,请重新生成批次', 409);
}
$filename = sprintf('material-batch-%s.xlsx', preg_replace('/[^a-zA-Z0-9_-]/', '-', (string)$batch['batch_no']));
$binary = $this->buildXlsxBinary($codes);
$packageUrl = trim((string)($batch['package_url'] ?? ''));
if ($packageUrl === '') {
$packageUrl = $resource->publicUrl((string)$batch['package_path']);
}
$filename = sprintf('material-batch-%s.zip', $resource->safeName((string)$batch['batch_no'], 'batch'));
$now = date('Y-m-d H:i:s');
$adminId = (int)$request->header('x-admin-id', 0);
$adminName = trim((string)$request->header('x-admin-name', ''));
@@ -221,6 +305,7 @@ class MaterialTagService
Db::name('material_batches')->where('id', $batchId)->update([
'download_count' => (int)$batch['download_count'] + 1,
'last_downloaded_at' => $now,
'package_url' => $packageUrl,
'updated_at' => $now,
]);
Db::name('material_batch_download_logs')->insert([
@@ -240,7 +325,9 @@ class MaterialTagService
return [
'filename' => $filename,
'content' => $binary,
'path' => $filePath,
'url' => $packageUrl,
'size' => filesize($filePath) ?: 0,
];
}
@@ -250,6 +337,13 @@ class MaterialTagService
if (!$tag) {
throw new \InvalidArgumentException('吊牌二维码不存在');
}
if (($tag['status'] ?? 'active') === 'invalid') {
throw new \InvalidArgumentException('该吊牌二维码已失效,不能绑定报告');
}
$batch = Db::name('material_batches')->where('id', (int)$tag['batch_id'])->find();
if ($batch && ($batch['status'] ?? 'active') === 'invalid') {
throw new \InvalidArgumentException('该吊牌所属批次已失效,不能绑定报告');
}
if (($tag['bind_status'] ?? '') === 'bound' || (int)($tag['report_id'] ?? 0) > 0) {
throw new \InvalidArgumentException('该吊牌已绑定报告,不能重复绑定');
}
@@ -258,6 +352,9 @@ class MaterialTagService
if (!$task) {
throw new \RuntimeException('任务不存在', 404);
}
if (($task['service_provider'] ?? '') === 'zhongjian') {
throw new \InvalidArgumentException('中检订单不使用平台验真吊牌');
}
$report = Db::name('reports')
->where('order_id', (int)$task['order_id'])
->where('report_type', 'appraisal')
@@ -296,6 +393,76 @@ class MaterialTagService
]);
}
public function invalidateBatch(int $batchId, Request $request): array
{
$batch = Db::name('material_batches')->where('id', $batchId)->find();
if (!$batch) {
throw new \RuntimeException('物料批次不存在', 404);
}
if (($batch['status'] ?? 'active') === 'invalid') {
throw new \InvalidArgumentException('该物料批次已失效');
}
$now = date('Y-m-d H:i:s');
$payload = $this->invalidatePayload($request, $now);
Db::startTrans();
try {
Db::name('material_batches')->where('id', $batchId)->update($payload + [
'status' => 'invalid',
'updated_at' => $now,
]);
Db::name('material_tag_codes')
->where('batch_id', $batchId)
->where('status', '<>', 'invalid')
->update($payload + [
'status' => 'invalid',
'updated_at' => $now,
]);
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
return [
'id' => $batchId,
'status' => 'invalid',
'status_text' => '已失效',
'invalidated_at' => $now,
];
}
public function invalidateTag(int $tagId, Request $request): array
{
$tag = Db::name('material_tag_codes')->where('id', $tagId)->find();
if (!$tag) {
throw new \RuntimeException('物料条码不存在', 404);
}
if (($tag['status'] ?? 'active') === 'invalid') {
throw new \InvalidArgumentException('该物料条码已失效');
}
$batch = Db::name('material_batches')->where('id', (int)$tag['batch_id'])->find();
if ($batch && ($batch['status'] ?? 'active') === 'invalid') {
throw new \InvalidArgumentException('所属批次已失效,无需单独失效');
}
$now = date('Y-m-d H:i:s');
Db::name('material_tag_codes')->where('id', $tagId)->update($this->invalidatePayload($request, $now) + [
'status' => 'invalid',
'updated_at' => $now,
]);
return [
'id' => $tagId,
'batch_id' => (int)$tag['batch_id'],
'status' => 'invalid',
'status_text' => '已失效',
'invalidated_at' => $now,
];
}
public function findBoundTagForReport(int $reportId): ?array
{
if ($reportId <= 0) {
@@ -326,6 +493,23 @@ class MaterialTagService
$tag['scan_count'] = (int)$tag['scan_count'] + 1;
$tag['last_scanned_at'] = $now;
$batch = Db::name('material_batches')->where('id', (int)$tag['batch_id'])->find();
if (($tag['status'] ?? 'active') === 'invalid' || ($batch && ($batch['status'] ?? 'active') === 'invalid')) {
return [
'tag_status' => 'invalid',
'status_text' => '吊牌已失效',
'message' => '该吊牌二维码已被后台失效处理,不能用于查看报告或验真。',
'qr_token' => (string)$tag['qr_token'],
'qr_url' => (string)$tag['qr_url'],
'scan_count' => (int)$tag['scan_count'],
'verify_count' => (int)$tag['verify_count'],
'report_summary' => null,
'product_summary' => [],
'result_summary' => [],
'verify_passed' => false,
];
}
$report = (int)($tag['report_id'] ?? 0) > 0
? Db::name('reports')->where('id', (int)$tag['report_id'])->find()
: null;
@@ -396,6 +580,17 @@ class MaterialTagService
throw new \RuntimeException('吊牌不存在', 404);
}
$batch = Db::name('material_batches')->where('id', (int)$tag['batch_id'])->find();
if (($tag['status'] ?? 'active') === 'invalid' || ($batch && ($batch['status'] ?? 'active') === 'invalid')) {
$now = date('Y-m-d H:i:s');
$this->insertScanLog($tag, 'verify_code', false, $request, $now, $verifyCode, $reportNo);
return [
'verify_passed' => false,
'verify_message' => '该吊牌二维码已失效,不能进行验真。',
'verify_count' => (int)$tag['verify_count'],
];
}
$report = (int)($tag['report_id'] ?? 0) > 0
? Db::name('reports')->where('id', (int)$tag['report_id'])->find()
: null;
@@ -446,6 +641,15 @@ class MaterialTagService
}
}
$path = trim((string)($parts['path'] ?? ''), '/');
if ($path !== '') {
$segments = explode('/', $path);
$lastSegment = trim((string)end($segments));
if (preg_match('/^[a-zA-Z0-9_-]{7,80}$/', $lastSegment)) {
return $lastSegment;
}
}
$fragment = (string)($parts['fragment'] ?? '');
if ($fragment !== '') {
$questionPos = strpos($fragment, '?');
@@ -461,10 +665,10 @@ class MaterialTagService
return trim((string)rawurldecode($matches[1]));
}
return preg_match('/^[a-zA-Z0-9_-]{16,80}$/', $value) ? $value : '';
return preg_match('/^[a-zA-Z0-9_-]{7,80}$/', $value) ? $value : '';
}
private function findTagByInput(string $input): ?array
public function findTagByInput(string $input): ?array
{
$value = trim($input);
if ($value === '') {
@@ -488,7 +692,18 @@ class MaterialTagService
'batch_id' => (int)$row['batch_id'],
'qr_token' => (string)$row['qr_token'],
'qr_url' => (string)$row['qr_url'],
'qr_image_url' => (string)($row['qr_image_url'] ?? ''),
'qr_image_path' => (string)($row['qr_image_path'] ?? ''),
'qr_image_status' => (string)($row['qr_image_status'] ?? 'pending'),
'qr_image_status_text' => $this->qrImageStatusText((string)($row['qr_image_status'] ?? 'pending')),
'qr_image_error' => (string)($row['qr_image_error'] ?? ''),
'qr_image_generated_at' => (string)($row['qr_image_generated_at'] ?? ''),
'verify_code' => (string)$row['verify_code'],
'status' => (string)($row['status'] ?? 'active'),
'status_text' => $this->materialStatusText((string)($row['status'] ?? 'active')),
'invalidated_at' => (string)($row['invalidated_at'] ?? ''),
'invalidated_by_name' => (string)($row['invalidated_by_name'] ?? ''),
'invalid_reason' => (string)($row['invalid_reason'] ?? ''),
'bind_status' => (string)$row['bind_status'],
'bind_status_text' => ($row['bind_status'] ?? '') === 'bound' ? '已绑定' : '未绑定',
'report_id' => (int)($row['report_id'] ?? 0),
@@ -516,9 +731,203 @@ class MaterialTagService
return $map;
}
private function invalidatePayload(Request $request, string $now): array
{
return [
'invalidated_at' => $now,
'invalidated_by' => (int)$request->header('x-admin-id', 0) ?: null,
'invalidated_by_name' => trim((string)$request->header('x-admin-name', '')),
'invalid_reason' => mb_substr(trim((string)$request->input('reason', '')), 0, 500),
];
}
private function materialStatusText(string $status): string
{
return $status === 'invalid' ? '已失效' : '有效';
}
private function packageStatusText(string $status): string
{
return match ($status) {
'generated' => '可下载',
'generating', 'pending' => '文件生成中',
'failed' => '生成失败',
'purged' => '已清理',
default => '文件生成中',
};
}
private function loadQrImageStats(array $batchIds): array
{
$batchIds = array_values(array_unique(array_filter(array_map('intval', $batchIds))));
if (!$batchIds) {
return [];
}
$rows = Db::name('material_tag_codes')
->whereIn('batch_id', $batchIds)
->fieldRaw(
"batch_id,"
. " SUM(CASE WHEN qr_image_status = 'generated' AND qr_image_url <> '' THEN 1 ELSE 0 END) AS generated_count,"
. " SUM(CASE WHEN qr_image_status = 'failed' THEN 1 ELSE 0 END) AS failed_count,"
. " SUM(CASE WHEN qr_image_status NOT IN ('generated', 'failed', 'purged') OR (qr_image_status = 'generated' AND qr_image_url = '') THEN 1 ELSE 0 END) AS pending_count"
)
->group('batch_id')
->select()
->toArray();
$stats = [];
foreach ($rows as $row) {
$stats[(int)$row['batch_id']] = [
'generated' => (int)($row['generated_count'] ?? 0),
'failed' => (int)($row['failed_count'] ?? 0),
'pending' => (int)($row['pending_count'] ?? 0),
];
}
return $stats;
}
private function emptyQrImageStats(): array
{
return [
'generated' => 0,
'failed' => 0,
'pending' => 0,
];
}
private function enqueueMissingQrImageJobs(int $batchId, array $codes): void
{
$tagIds = [];
foreach ($codes as $row) {
$status = (string)($row['qr_image_status'] ?? '');
if (trim((string)($row['qr_image_url'] ?? '')) === '' && !in_array($status, ['generating', 'purged'], true)) {
$tagIds[] = (int)$row['id'];
}
}
if ($tagIds) {
$this->enqueueQrImageJobs($batchId, $tagIds);
}
}
private function enqueueQrImageJobs(int $batchId, ?array $tagIds = null): void
{
if ($batchId <= 0) {
return;
}
$batch = Db::name('material_batches')->where('id', $batchId)->find();
if (!$batch || ($batch['status'] ?? 'active') === 'invalid' || ($batch['package_status'] ?? '') === 'purged') {
return;
}
if ($tagIds === null) {
$tagIds = Db::name('material_tag_codes')
->where('batch_id', $batchId)
->where('status', 'active')
->where('qr_image_status', '<>', 'purged')
->column('id');
}
$tagIds = array_values(array_unique(array_filter(array_map('intval', $tagIds))));
if (!$tagIds) {
return;
}
Db::name('material_batches')->where('id', $batchId)->update([
'package_status' => 'pending',
'package_error' => '',
'package_requested_at' => null,
'updated_at' => date('Y-m-d H:i:s'),
]);
Db::name('material_tag_codes')
->whereIn('id', $tagIds)
->where('status', 'active')
->where(function ($builder) {
$builder->where('qr_image_status', '<>', 'generated')->whereOr('qr_image_url', '');
})
->where('qr_image_status', '<>', 'purged')
->update([
'qr_image_status' => 'pending',
'updated_at' => date('Y-m-d H:i:s'),
]);
foreach (array_chunk($tagIds, self::QR_IMAGE_JOB_CHUNK_SIZE) as $chunk) {
try {
$sent = RedisQueueClient::send(MaterialTagQrCodeService::QUEUE_NAME, [
'batch_id' => $batchId,
'tag_ids' => array_values($chunk),
]);
if (!$sent) {
throw new \RuntimeException('Redis 队列写入返回失败');
}
} catch (\Throwable $e) {
Db::name('material_tag_codes')
->whereIn('id', array_values($chunk))
->where('qr_image_status', '<>', 'generated')
->where('qr_image_status', '<>', 'purged')
->update([
'qr_image_status' => 'failed',
'qr_image_error' => mb_substr('二维码生成任务投递失败:' . $e->getMessage(), 0, 500),
'updated_at' => date('Y-m-d H:i:s'),
]);
Log::error('material tag QR image job enqueue failed', [
'batch_id' => $batchId,
'tag_ids' => array_values($chunk),
'message' => $e->getMessage(),
]);
}
}
}
private function enqueuePackageIfReady(int $batchId): void
{
try {
(new MaterialBatchPackageService())->enqueueIfReady($batchId);
} catch (\Throwable $e) {
Log::error('material batch package job enqueue failed', [
'batch_id' => $batchId,
'message' => $e->getMessage(),
]);
}
}
private function qrImageStatusText(string $status): string
{
return match ($status) {
'generated' => '已生成',
'generating' => '生成中',
'failed' => '生成失败',
'purged' => MaterialLocalResourceService::RETENTION_MESSAGE,
default => '待生成',
};
}
private function buildMaterialTagUrl(string $token, string $baseUrl): string
{
return $baseUrl . '/#/pages/material-tag/detail?token=' . rawurlencode($token);
$shortBaseUrl = $this->resolveMaterialTagShortBaseUrl();
if ($shortBaseUrl !== '') {
return $this->formatShortQrBaseUrl($shortBaseUrl) . '/T/' . rawurlencode($token);
}
return $this->buildMaterialTagH5Url($token, $baseUrl);
}
public function buildMaterialTagDetailUrl(string $token): string
{
$baseUrl = $this->normalizeH5BaseUrl($this->getSystemConfigValue('h5', 'page_base_url'));
if ($baseUrl === '') {
throw new \RuntimeException('H5 页面根地址未配置');
}
return $this->buildMaterialTagH5Url($token, $baseUrl);
}
private function buildMaterialTagH5Url(string $token, string $baseUrl): string
{
return rtrim($baseUrl, '/') . '/#/pages/material-tag/detail?token=' . rawurlencode($token);
}
private function generateUniqueBatchNo(): string
@@ -535,7 +944,7 @@ class MaterialTagService
private function generateUniqueToken(array $pendingTokens): string
{
for ($i = 0; $i < 30; $i++) {
$candidate = 'mt_' . bin2hex(random_bytes(16));
$candidate = $this->generateMaterialTagToken();
if (!isset($pendingTokens[$candidate]) && !Db::name('material_tag_codes')->where('qr_token', $candidate)->find()) {
return $candidate;
}
@@ -543,14 +952,27 @@ class MaterialTagService
throw new \RuntimeException('二维码 token 生成失败,请重试');
}
private function generateVerifyCode(): string
private function generateMaterialTagToken(): string
{
$code = '';
$max = strlen(self::VERIFY_CODE_CHARS) - 1;
for ($i = 0; $i < 6; $i++) {
$code .= self::VERIFY_CODE_CHARS[random_int(0, $max)];
$alphabet = self::MATERIAL_TAG_TOKEN_ALPHABET;
$maxIndex = strlen($alphabet) - 1;
$token = '';
for ($i = 0; $i < self::MATERIAL_TAG_TOKEN_LENGTH; $i++) {
$token .= $alphabet[random_int(0, $maxIndex)];
}
return $code;
return $token;
}
private function generateVerifyCode(array $pendingVerifyCodes): string
{
for ($i = 0; $i < 1000; $i++) {
$code = str_pad((string)random_int(0, 999999), 6, '0', STR_PAD_LEFT);
if (!isset($pendingVerifyCodes[$code])) {
return $code;
}
}
throw new \RuntimeException('验真编码生成失败,请减少单批数量后重试');
}
private function getSystemConfigValue(string $groupCode, string $configKey): string
@@ -580,6 +1002,39 @@ class MaterialTagService
return rtrim($baseUrl, '/');
}
private function resolveMaterialTagShortBaseUrl(): string
{
foreach (['MATERIAL_TAG_SHORT_BASE_URL', 'MATERIAL_LOCAL_BASE_URL', 'APP_PUBLIC_BASE_URL', 'PUBLIC_FILE_BASE_URL'] as $key) {
$baseUrl = $this->normalizeH5BaseUrl((string)($_ENV[$key] ?? ''));
if ($baseUrl !== '') {
return $baseUrl;
}
}
return '';
}
private function formatShortQrBaseUrl(string $baseUrl): string
{
$parts = parse_url($baseUrl);
$scheme = strtoupper((string)($parts['scheme'] ?? 'https'));
$host = strtoupper((string)($parts['host'] ?? ''));
if ($host === '') {
return rtrim($baseUrl, '/');
}
$url = $scheme . '://' . $host;
if (isset($parts['port'])) {
$url .= ':' . (int)$parts['port'];
}
$path = trim((string)($parts['path'] ?? ''), '/');
if ($path !== '') {
$url .= '/' . $path;
}
return rtrim($url, '/');
}
private function decodeJsonField(mixed $value): array
{
if (is_array($value)) {
@@ -608,97 +1063,4 @@ class MaterialTagService
]);
}
private function buildXlsxBinary(array $rows): string
{
if (!class_exists(\ZipArchive::class)) {
throw new \RuntimeException('当前 PHP 环境缺少 ZipArchive 扩展,无法生成 Excel');
}
$tmpFile = tempnam(sys_get_temp_dir(), 'mat_xlsx_');
if ($tmpFile === false) {
throw new \RuntimeException('临时文件创建失败');
}
$zip = new \ZipArchive();
if ($zip->open($tmpFile, \ZipArchive::OVERWRITE) !== true) {
@unlink($tmpFile);
throw new \RuntimeException('Excel 文件创建失败');
}
$zip->addFromString('[Content_Types].xml', $this->xlsxContentTypesXml());
$zip->addFromString('_rels/.rels', $this->xlsxRelsXml());
$zip->addFromString('xl/workbook.xml', $this->xlsxWorkbookXml());
$zip->addFromString('xl/_rels/workbook.xml.rels', $this->xlsxWorkbookRelsXml());
$zip->addFromString('xl/worksheets/sheet1.xml', $this->xlsxSheetXml($rows));
$zip->close();
$content = file_get_contents($tmpFile);
@unlink($tmpFile);
if ($content === false) {
throw new \RuntimeException('Excel 文件读取失败');
}
return $content;
}
private function xlsxContentTypesXml(): string
{
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
. '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
. '<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
. '<Default Extension="xml" ContentType="application/xml"/>'
. '<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
. '<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
. '</Types>';
}
private function xlsxRelsXml(): string
{
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
. '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
. '</Relationships>';
}
private function xlsxWorkbookXml(): string
{
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
. '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
. '<sheets><sheet name="物料二维码" sheetId="1" r:id="rId1"/></sheets>'
. '</workbook>';
}
private function xlsxWorkbookRelsXml(): string
{
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
. '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
. '<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>'
. '</Relationships>';
}
private function xlsxSheetXml(array $rows): string
{
$sheetRows = [
['二维码链接', '验真编码'],
...array_map(fn (array $row) => [(string)$row['qr_url'], (string)$row['verify_code']], $rows),
];
$xmlRows = [];
foreach ($sheetRows as $rowIndex => $row) {
$excelRow = $rowIndex + 1;
$xmlRows[] = sprintf(
'<row r="%d"><c r="A%d" t="inlineStr"><is><t>%s</t></is></c><c r="B%d" t="inlineStr"><is><t>%s</t></is></c></row>',
$excelRow,
$excelRow,
htmlspecialchars($row[0], ENT_XML1 | ENT_COMPAT, 'UTF-8'),
$excelRow,
htmlspecialchars($row[1], ENT_XML1 | ENT_COMPAT, 'UTF-8')
);
}
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
. '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
. '<cols><col min="1" max="1" width="72" customWidth="1"/><col min="2" max="2" width="16" customWidth="1"/></cols>'
. '<sheetData>' . implode('', $xmlRows) . '</sheetData>'
. '</worksheet>';
}
}