Files
anxinyan/server-api/app/support/MaterialBatchPackageService.php
2026-05-15 14:01:36 +08:00

324 lines
13 KiB
PHP

<?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>';
}
}