324 lines
13 KiB
PHP
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>';
|
|
}
|
|
}
|