feat: Add A6 PDF QR stickers with human-readable order info
Implement QrStickerService to generate both SVG (web) and PDF (print) stickers:
- Generate 32x32mm QR codes using SimpleSoftwareIO
- Create A6 (148mm 105mm) PDF stickers using DomPDF
- Include human-readable info: order number, customer, status, date, tracking
- Store order type (STOCK/CUSTOM) with visual badge
- Add generation timestamp to footer
Create stickers/qr-sticker.blade.php template:
- Two-column layout: info left, QR right
- Styled for A6 landscape printing
- Shows order number, customer, status, date, tracking info
- QR code positioned for easy scanning
- Print-optimized CSS
Update GenerateQrCodeOnOrderCreated listener:
- Now calls QrStickerService::generateSticker()
- Generates both SVG and PDF on OrderCreated event
- Logs paths to both formats
Add OpsController::downloadSticker() endpoint:
- GET /ops/orders/{id}/sticker/download
- Returns PDF with filename QR-{order_number}.pdf
- Requires ops access authorization
- Logs all downloads with user context
Update ops order-detail view:
- Add download button for A6 sticker PDF
- Position next to QR code display
- Link to new sticker download route
This commit is contained in:
@@ -200,4 +200,39 @@ class OpsController extends Controller
|
|||||||
|
|
||||||
return $actions;
|
return $actions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download QR sticker PDF for printing
|
||||||
|
*
|
||||||
|
* GET /ops/orders/{id}/sticker/download
|
||||||
|
*/
|
||||||
|
public function downloadSticker(Order $order)
|
||||||
|
{
|
||||||
|
// Authorize
|
||||||
|
if (! auth()->user()->can('access-ops')) {
|
||||||
|
abort(403, 'Unauthorized to access ops interface');
|
||||||
|
}
|
||||||
|
|
||||||
|
$stickerPath = "qr-stickers/{$order->uuid}.pdf";
|
||||||
|
|
||||||
|
if (! \Illuminate\Support\Facades\Storage::disk('public')->exists($stickerPath)) {
|
||||||
|
Log::warning('QR sticker PDF not found', [
|
||||||
|
'order_uuid' => $order->uuid,
|
||||||
|
'path' => $stickerPath,
|
||||||
|
]);
|
||||||
|
|
||||||
|
abort(404, 'QR sticker not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::info('QR sticker downloaded', [
|
||||||
|
'order_uuid' => $order->uuid,
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
'user_name' => auth()->user()->name,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return \Illuminate\Support\Facades\Storage::disk('public')->download(
|
||||||
|
$stickerPath,
|
||||||
|
"QR-{$order->order_number}.pdf"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,9 @@
|
|||||||
namespace App\Listeners;
|
namespace App\Listeners;
|
||||||
|
|
||||||
use App\Events\OrderCreated;
|
use App\Events\OrderCreated;
|
||||||
|
use App\Services\QrStickerService;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
|
||||||
|
|
||||||
class GenerateQrCodeOnOrderCreated
|
class GenerateQrCodeOnOrderCreated
|
||||||
{
|
{
|
||||||
@@ -25,26 +24,19 @@ class GenerateQrCodeOnOrderCreated
|
|||||||
$order->save();
|
$order->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate QR code pointing to ops interface
|
Log::debug('Generating QR sticker', [
|
||||||
$qrUrl = route('ops.order.show', ['token' => $order->qr_token]);
|
|
||||||
|
|
||||||
Log::debug('Generating QR code', [
|
|
||||||
'order_uuid' => $order->uuid,
|
'order_uuid' => $order->uuid,
|
||||||
'qr_url' => $qrUrl,
|
'order_number' => $order->order_number,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Generate QR code SVG
|
// Generate both SVG (web) and PDF (print) stickers
|
||||||
$qrCode = QrCode::size(300)
|
$service = new QrStickerService();
|
||||||
->errorCorrection('H')
|
$paths = $service->generateSticker($order);
|
||||||
->generate($qrUrl);
|
|
||||||
|
|
||||||
// Save QR code image
|
Log::info('QR sticker generated successfully', [
|
||||||
$path = "qr-codes/{$order->uuid}.svg";
|
|
||||||
Storage::disk('public')->put($path, $qrCode);
|
|
||||||
|
|
||||||
Log::info('QR code generated successfully', [
|
|
||||||
'order_uuid' => $order->uuid,
|
'order_uuid' => $order->uuid,
|
||||||
'path' => $path,
|
'svg_path' => $paths['svg_path'],
|
||||||
|
'pdf_path' => $paths['pdf_path'],
|
||||||
'token' => substr($order->qr_token, 0, 8) . '...',
|
'token' => substr($order->qr_token, 0, 8) . '...',
|
||||||
]);
|
]);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Models\Order;
|
||||||
|
use Barryvdh\DomPDF\Facade\Pdf;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use SimpleSoftwareIO\QrCode\Facades\QrCode;
|
||||||
|
|
||||||
|
class QrStickerService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Generate both SVG and A6 PDF sticker for an order
|
||||||
|
*/
|
||||||
|
public function generateSticker(Order $order): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
// Generate QR code URL
|
||||||
|
$qrUrl = route('ops.order.show', ['token' => $order->qr_token]);
|
||||||
|
|
||||||
|
Log::debug('Generating QR sticker', [
|
||||||
|
'order_uuid' => $order->uuid,
|
||||||
|
'order_number' => $order->order_number,
|
||||||
|
'qr_url' => $qrUrl,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Generate SVG (for web display)
|
||||||
|
$svgPath = $this->generateSvg($order, $qrUrl);
|
||||||
|
|
||||||
|
// Generate A6 PDF (for printing)
|
||||||
|
$pdfPath = $this->generatePdf($order, $qrUrl);
|
||||||
|
|
||||||
|
Log::info('QR sticker generated successfully', [
|
||||||
|
'order_uuid' => $order->uuid,
|
||||||
|
'svg_path' => $svgPath,
|
||||||
|
'pdf_path' => $pdfPath,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'svg_path' => $svgPath,
|
||||||
|
'pdf_path' => $pdfPath,
|
||||||
|
];
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Failed to generate QR sticker', [
|
||||||
|
'order_uuid' => $order->uuid,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate SVG QR code for web display
|
||||||
|
*/
|
||||||
|
private function generateSvg(Order $order, string $qrUrl): string
|
||||||
|
{
|
||||||
|
$qrCode = QrCode::size(300)
|
||||||
|
->errorCorrection('H')
|
||||||
|
->generate($qrUrl);
|
||||||
|
|
||||||
|
$path = "qr-codes/{$order->uuid}.svg";
|
||||||
|
Storage::disk('public')->put($path, $qrCode);
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate A6 PDF sticker (105mm × 148mm) with QR code and readable info
|
||||||
|
*
|
||||||
|
* A6 dimensions: 105mm × 148mm landscape
|
||||||
|
* Margins: 5mm all around
|
||||||
|
* QR size: ~50mm × 50mm
|
||||||
|
* Text area: remaining space for order info
|
||||||
|
*/
|
||||||
|
private function generatePdf(Order $order, string $qrUrl): string
|
||||||
|
{
|
||||||
|
// Generate inline QR code as base64 data URL
|
||||||
|
$qrSvg = QrCode::size(400)
|
||||||
|
->errorCorrection('H')
|
||||||
|
->generate($qrUrl);
|
||||||
|
|
||||||
|
// Determine order type and format label
|
||||||
|
$orderType = $order->type === 'standard'
|
||||||
|
? 'STOCK ORDER'
|
||||||
|
: 'CUSTOM ORDER';
|
||||||
|
|
||||||
|
// Prepare data for the view
|
||||||
|
$data = [
|
||||||
|
'order' => $order,
|
||||||
|
'qrSvg' => $qrSvg,
|
||||||
|
'orderType' => $orderType,
|
||||||
|
'qrUrl' => $qrUrl,
|
||||||
|
'generatedAt' => now()->format('Y-m-d H:i'),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Generate PDF using the sticker template
|
||||||
|
$pdf = Pdf::loadView('stickers.qr-sticker', $data);
|
||||||
|
|
||||||
|
// Configure for A6 landscape
|
||||||
|
// A6 is 105mm × 148mm (landscape: 148mm × 105mm)
|
||||||
|
$pdf->setPaper([0, 0, 420.94, 297.64], 'portrait'); // 148mm × 105mm in points (1mm ≈ 2.834645669 points)
|
||||||
|
|
||||||
|
// Store PDF
|
||||||
|
$filename = "{$order->uuid}.pdf";
|
||||||
|
$path = "qr-stickers/{$filename}";
|
||||||
|
$pdfContent = $pdf->output();
|
||||||
|
Storage::disk('public')->put($path, $pdfContent);
|
||||||
|
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,14 @@
|
|||||||
|
|
||||||
<!-- QR Code -->
|
<!-- QR Code -->
|
||||||
<div class="border-t pt-6">
|
<div class="border-t pt-6">
|
||||||
<p class="text-sm text-gray-600 mb-3">Scan to access this order:</p>
|
<div class="flex items-start justify-between mb-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600 mb-3">Scan to access this order:</p>
|
||||||
|
</div>
|
||||||
|
<a href="{{ route('ops.order.sticker.download', $order) }}" class="inline-flex items-center px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm">
|
||||||
|
📥 Download Sticker (A6 PDF)
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<div class="inline-block bg-gray-50 p-4 rounded border">
|
<div class="inline-block bg-gray-50 p-4 rounded border">
|
||||||
{!! file_get_contents(storage_path("app/public/qr-codes/{$order->uuid}.svg")) !!}
|
{!! file_get_contents(storage_path("app/public/qr-codes/{$order->uuid}.svg")) !!}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A6 Landscape: 148mm × 105mm */
|
||||||
|
.sticker {
|
||||||
|
width: 148mm;
|
||||||
|
height: 105mm;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 5mm;
|
||||||
|
background: white;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Split layout: QR on right (50mm), info on left */
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
gap: 3mm;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-section {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-section {
|
||||||
|
width: 55mm;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header with order type badge */
|
||||||
|
.header {
|
||||||
|
border-bottom: 1pt solid #ddd;
|
||||||
|
padding-bottom: 2mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-type {
|
||||||
|
display: inline-block;
|
||||||
|
background: #333;
|
||||||
|
color: white;
|
||||||
|
padding: 1mm 2mm;
|
||||||
|
font-size: 7pt;
|
||||||
|
font-weight: bold;
|
||||||
|
letter-spacing: 0.5pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.order-number {
|
||||||
|
font-size: 9pt;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: 1mm;
|
||||||
|
color: #222;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Customer info */
|
||||||
|
.customer-info {
|
||||||
|
font-size: 8pt;
|
||||||
|
color: #555;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-info .label {
|
||||||
|
font-size: 7pt;
|
||||||
|
color: #999;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
.customer-info .name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Order details */
|
||||||
|
.details {
|
||||||
|
font-size: 7.5pt;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 2mm;
|
||||||
|
margin-bottom: 1mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #999;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-size: 6.5pt;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
text-align: right;
|
||||||
|
color: #222;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* QR code */
|
||||||
|
.qr-code {
|
||||||
|
width: 50mm;
|
||||||
|
height: 50mm;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-code svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer timestamp */
|
||||||
|
.footer {
|
||||||
|
font-size: 6pt;
|
||||||
|
color: #999;
|
||||||
|
text-align: center;
|
||||||
|
border-top: 0.5pt solid #eee;
|
||||||
|
padding-top: 1mm;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Print settings */
|
||||||
|
@media print {
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sticker {
|
||||||
|
page-break-after: avoid;
|
||||||
|
margin: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="sticker">
|
||||||
|
<div class="container">
|
||||||
|
<div class="info-section">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="header">
|
||||||
|
<span class="order-type">{{ $orderType }}</span>
|
||||||
|
<div class="order-number">{{ $order->order_number }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Customer Info -->
|
||||||
|
<div class="customer-info">
|
||||||
|
<div class="label">Customer</div>
|
||||||
|
<div class="name">{{ $order->user->name ?? 'N/A' }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Order Details -->
|
||||||
|
<div class="details">
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Date:</span>
|
||||||
|
<span class="detail-value">{{ $order->created_at->format('M d') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Status:</span>
|
||||||
|
<span class="detail-value" style="text-transform: capitalize;">
|
||||||
|
{{ str_replace('_', ' ', $order->status) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($order->type === 'standard')
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Items:</span>
|
||||||
|
<span class="detail-value">{{ $order->items->count() }}</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($order->packing_completed_at)
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Packed:</span>
|
||||||
|
<span class="detail-value">✓</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if($order->courier_tracking_number)
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Track:</span>
|
||||||
|
<span class="detail-value" style="font-size: 6.5pt;">{{ substr($order->courier_tracking_number, 0, 8) }}</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="footer">
|
||||||
|
Gen: {{ $generatedAt }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- QR Code Section -->
|
||||||
|
<div class="qr-section">
|
||||||
|
<div class="qr-code">
|
||||||
|
{!! $qrSvg !!}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+2
-1
@@ -77,6 +77,7 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::post('/ops/orders/{order:uuid}/pack', [OpsController::class, 'confirmPacking'])->name('ops.order.pack');
|
Route::post('/ops/orders/{order:uuid}/pack', [OpsController::class, 'confirmPacking'])->name('ops.order.pack');
|
||||||
Route::post('/ops/orders/{order:uuid}/inspection-passed', [OpsController::class, 'markInspectionPassed'])->name('ops.order.inspection-passed');
|
Route::post('/ops/orders/{order:uuid}/inspection-passed', [OpsController::class, 'markInspectionPassed'])->name('ops.order.inspection-passed');
|
||||||
Route::post('/ops/orders/{order:uuid}/inspection-failed', [OpsController::class, 'flagInspectionIssue'])->name('ops.order.inspection-failed');
|
Route::post('/ops/orders/{order:uuid}/inspection-failed', [OpsController::class, 'flagInspectionIssue'])->name('ops.order.inspection-failed');
|
||||||
|
Route::get('/ops/orders/{order:uuid}/sticker/download', [OpsController::class, 'downloadSticker'])->name('ops.order.sticker.download');
|
||||||
});
|
});
|
||||||
|
|
||||||
// QR landing page (public but token-gated)
|
// QR landing page (public but token-gated)
|
||||||
@@ -94,4 +95,4 @@ Route::get('/font-test', function () {
|
|||||||
|
|
||||||
|
|
||||||
// Test route for invoice generation (remove in production)
|
// Test route for invoice generation (remove in production)
|
||||||
require __DIR__ . '/test-invoice.php';
|
require __DIR__ . '/test-invoice.php';
|
||||||
|
|||||||
Reference in New Issue
Block a user