增加了手机操作端
This commit is contained in:
323
server-api/app/support/MaterialBatchPackageService.php
Normal file
323
server-api/app/support/MaterialBatchPackageService.php
Normal 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>';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user