add stickers logic

This commit is contained in:
root
2026-03-10 16:40:11 -04:00
parent 3bf4c687da
commit 2e9a391280
17 changed files with 1047 additions and 390 deletions
@@ -0,0 +1,80 @@
<?php
namespace App\Http\Controllers\Api\Reports;
use App\Http\Controllers\Api\BaseApiController;
use App\Http\Requests\Reports\Stickers\StickerFormDataRequest;
use App\Http\Requests\Reports\Stickers\StickerPrintRequest;
use App\Http\Requests\Reports\Stickers\StickerStudentsByClassRequest;
use App\Http\Resources\Reports\Stickers\StickerClassSectionResource;
use App\Http\Resources\Reports\Stickers\StickerPresetResource;
use App\Http\Resources\Reports\Stickers\StickerStudentResource;
use App\Services\Reports\Stickers\StickerPresetService;
use App\Services\Reports\Stickers\StickerPrintService;
use App\Services\Reports\Stickers\StickerQueryService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Symfony\Component\HttpFoundation\Response;
class StickersController extends BaseApiController
{
public function __construct(
private StickerQueryService $queryService,
private StickerPresetService $presetService,
private StickerPrintService $printService
) {
parent::__construct();
}
public function formData(StickerFormDataRequest $request): JsonResponse
{
$payload = $request->validated();
$schoolYear = $this->queryService->resolveSchoolYear($payload['school_year'] ?? null);
$classSectionId = (int) ($payload['class_section_id'] ?? 0);
$classes = $this->queryService->listClassesWithStudents($schoolYear);
$students = $classSectionId > 0
? $this->queryService->listStudentsByClass($classSectionId, $schoolYear)
: $this->queryService->listStudentsForYear($schoolYear);
$presets = $this->presetService->presets();
return $this->success([
'classes' => StickerClassSectionResource::collection($classes),
'students' => StickerStudentResource::collection($students),
'presets' => StickerPresetResource::collection($presets),
]);
}
public function studentsByClass(StickerStudentsByClassRequest $request): JsonResponse
{
$payload = $request->validated();
$schoolYear = $this->queryService->resolveSchoolYear($payload['school_year'] ?? null);
$classSectionId = (int) ($payload['class_section_id'] ?? 0);
$students = $this->queryService->listStudentsByClass($classSectionId, $schoolYear);
return $this->success([
'students' => StickerStudentResource::collection($students),
]);
}
public function print(StickerPrintRequest $request)
{
try {
$result = $this->printService->generate($request->validated());
} catch (\Throwable $e) {
Log::error('Sticker print failed: ' . $e->getMessage());
return $this->error('Failed to generate stickers.', Response::HTTP_INTERNAL_SERVER_ERROR);
}
if (!($result['ok'] ?? false)) {
return $this->error($result['message'] ?? 'Unable to generate stickers.', Response::HTTP_UNPROCESSABLE_ENTITY);
}
return response($result['pdf'], 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="' . ($result['filename'] ?? 'Student_Stickers.pdf') . '"',
]);
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Reports\Stickers;
use App\Http\Requests\ApiFormRequest;
class StickerFormDataRequest extends ApiFormRequest
{
protected function prepareForValidation(): void
{
parent::prepareForValidation();
if (!$this->has('class_section_id') && $this->has('class_id')) {
$this->merge(['class_section_id' => $this->input('class_id')]);
}
}
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'school_year' => ['nullable', 'string', 'max:20'],
'class_section_id' => ['nullable', 'integer', 'min:1'],
];
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Http\Requests\Reports\Stickers;
use App\Http\Requests\ApiFormRequest;
use Illuminate\Validation\Validator;
class StickerPrintRequest extends ApiFormRequest
{
protected function prepareForValidation(): void
{
parent::prepareForValidation();
if (!$this->has('class_section_id') && $this->has('class_id')) {
$this->merge(['class_section_id' => $this->input('class_id')]);
}
}
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'school_year' => ['nullable', 'string', 'max:20'],
'student_id' => ['nullable', 'integer', 'min:1'],
'class_section_id' => ['nullable', 'integer', 'min:1'],
'print_all' => ['nullable', 'boolean'],
'sticker_size' => ['nullable', 'string', 'max:20'],
'sticker_width' => ['nullable', 'numeric', 'min:1'],
'sticker_height' => ['nullable', 'numeric', 'min:1'],
'stickers_per_page' => ['nullable', 'integer', 'min:1'],
'page_size' => ['nullable', 'string', 'max:20'],
'orientation' => ['nullable', 'string', 'max:2'],
'margin_left' => ['nullable', 'numeric', 'min:0'],
'margin_top' => ['nullable', 'numeric', 'min:0'],
'margin_right' => ['nullable', 'numeric', 'min:0'],
'margin_bottom' => ['nullable', 'numeric', 'min:0'],
'gap_x' => ['nullable', 'numeric', 'min:0'],
'gap_y' => ['nullable', 'numeric', 'min:0'],
'copies' => ['nullable', 'array'],
'copies.*' => ['integer', 'min:0'],
'single_copies' => ['nullable', 'integer', 'min:0'],
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator) {
$hasStudent = $this->filled('student_id');
$hasClass = $this->filled('class_section_id');
$printAll = (bool) $this->input('print_all', false);
if (!$hasStudent && !$hasClass && !$printAll) {
$validator->errors()->add('selection', 'Please select a student or class, or set print_all.');
}
});
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Requests\Reports\Stickers;
use App\Http\Requests\ApiFormRequest;
class StickerStudentsByClassRequest extends ApiFormRequest
{
protected function prepareForValidation(): void
{
parent::prepareForValidation();
if (!$this->has('class_section_id') && $this->has('class_id')) {
$this->merge(['class_section_id' => $this->input('class_id')]);
}
}
public function authorize(): bool
{
return auth()->check();
}
public function rules(): array
{
return [
'class_section_id' => ['required', 'integer', 'min:1'],
'school_year' => ['nullable', 'string', 'max:20'],
];
}
}
@@ -0,0 +1,16 @@
<?php
namespace App\Http\Resources\Reports\Stickers;
use Illuminate\Http\Resources\Json\JsonResource;
class StickerClassSectionResource extends JsonResource
{
public function toArray($request): array
{
return [
'class_section_id' => (int) ($this['class_section_id'] ?? 0),
'class_section_name' => (string) ($this['class_section_name'] ?? ''),
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Resources\Reports\Stickers;
use Illuminate\Http\Resources\Json\JsonResource;
class StickerPresetResource extends JsonResource
{
public function toArray($request): array
{
return [
'value' => (string) ($this['value'] ?? ''),
'label' => (string) ($this['label'] ?? ''),
'title' => (string) ($this['title'] ?? ''),
'text' => (string) ($this['text'] ?? ''),
'per_page' => $this['per_page'] ?? null,
];
}
}
@@ -0,0 +1,19 @@
<?php
namespace App\Http\Resources\Reports\Stickers;
use Illuminate\Http\Resources\Json\JsonResource;
class StickerStudentResource extends JsonResource
{
public function toArray($request): array
{
return [
'id' => (int) ($this['id'] ?? 0),
'firstname' => (string) ($this['firstname'] ?? ''),
'lastname' => (string) ($this['lastname'] ?? ''),
'registration_grade' => $this['registration_grade'] ?? null,
'gender' => $this['gender'] ?? null,
];
}
}
@@ -0,0 +1,18 @@
<?php
namespace App\Services\Reports\Stickers;
use App\Models\Configuration;
class StickerContextService
{
public function schoolYear(?string $override = null): string
{
$override = trim((string) $override);
if ($override !== '') {
return $override;
}
return trim((string) (Configuration::getConfig('school_year') ?? ''));
}
}
@@ -0,0 +1,23 @@
<?php
namespace App\Services\Reports\Stickers;
class StickerPresetService
{
public function presets(): array
{
$rows = [
['value' => '100x24.5', 'label' => 'Xsmall - 100x24.5 mm', 'ppg' => 20],
['value' => '60x30', 'label' => 'Small - 60x30 mm', 'ppg' => 24],
['value' => '102x34', 'label' => 'Medium - 102x34 mm', 'ppg' => 14],
['value' => '90x45', 'label' => 'Large - 90x45 mm', 'ppg' => 10],
];
return array_map(static function (array $row): array {
$row['title'] = $row['title'] ?? ($row['label'] ?? ($row['value'] . ' mm'));
$row['text'] = $row['text'] ?? $row['title'];
$row['per_page'] = $row['per_page'] ?? ($row['ppg'] ?? null);
return $row;
}, $rows);
}
}
@@ -0,0 +1,255 @@
<?php
namespace App\Services\Reports\Stickers;
use Illuminate\Support\Facades\Log;
class StickerPrintService
{
public function __construct(private StickerQueryService $queryService)
{
}
public function generate(array $payload): array
{
$schoolYear = $this->queryService->resolveSchoolYear($payload['school_year'] ?? null);
if ($schoolYear === '') {
Log::error('Sticker print requested without school year.');
}
$studentId = (int) ($payload['student_id'] ?? 0);
$classSectionId = (int) ($payload['class_section_id'] ?? $payload['class_id'] ?? 0);
$printAll = (bool) ($payload['print_all'] ?? false);
if ($studentId > 0) {
$student = $this->queryService->findStudent($studentId);
if (!$student) {
return ['ok' => false, 'message' => 'Student not found.'];
}
$students = [$student];
} elseif ($classSectionId > 0) {
$students = $this->queryService->listStudentsByClass($classSectionId, $schoolYear);
if (empty($students)) {
return ['ok' => false, 'message' => 'No students found in this class.'];
}
} elseif ($printAll) {
$students = $this->queryService->listStudentsForPrintAll($schoolYear);
if (empty($students)) {
return ['ok' => false, 'message' => 'No students found for this school year.'];
}
} else {
return ['ok' => false, 'message' => 'Please select a student or class.'];
}
$printList = $this->expandCopies($students, $payload, $studentId > 0);
if (empty($printList)) {
return ['ok' => false, 'message' => 'Nothing to print. All quantities are zero.'];
}
$layout = $this->buildLayout($payload);
$pdf = new \FPDF($layout['orientation'], 'mm', $layout['page_size']);
$pdf->SetMargins($layout['margin_left'], $layout['margin_top'], $layout['margin_right']);
$pdf->SetAutoPageBreak(true, $layout['margin_bottom']);
$pdf->AddPage();
$pdf->SetFont('Arial', '', 12);
$pageW = (float) $pdf->GetPageWidth();
$pageH = (float) $pdf->GetPageHeight();
$usableW = max(0.0, $pageW - ($layout['margin_left'] + $layout['margin_right']));
$usableH = max(0.0, $pageH - ($layout['margin_top'] + $layout['margin_bottom']));
$cols = (int) floor(($usableW + max(0.0, $layout['gap_x'])) / (max(0.1, $layout['sticker_width']) + max(0.0, $layout['gap_x'])));
$rows = (int) floor(($usableH + max(0.0, $layout['gap_y'])) / (max(0.1, $layout['sticker_height']) + max(0.0, $layout['gap_y'])));
$cols = max(1, $cols);
$rows = max(1, $rows);
$capacity = $cols * $rows;
if ($layout['stickers_per_page'] > 0 && $layout['stickers_per_page'] < $capacity) {
$rows = (int) ceil($layout['stickers_per_page'] / $cols);
$maxRowsFit = (int) floor(($usableH + max(0.0, $layout['gap_y'])) / (max(0.1, $layout['sticker_height']) + max(0.0, $layout['gap_y'])));
$rows = max(1, min($rows, max(1, $maxRowsFit)));
$capacity = $cols * $rows;
}
$xStart = $layout['margin_left'];
$yStart = $layout['margin_top'];
$x = $xStart;
$y = $yStart;
$ptToMm = static function (float $pt): float {
return $pt * 0.352778;
};
$i = 0;
$perPage = $capacity;
foreach ($printList as $student) {
if ($i > 0 && $i % $perPage === 0) {
$pdf->AddPage();
$x = $xStart;
$y = $yStart;
}
$pdf->SetFillColor(255, 255, 255);
$pdf->Rect($x, $y, $layout['sticker_width'], $layout['sticker_height'], 'F');
$fontFamily = 'Arial';
$fontStyle = 'B';
$maxPt = 16;
$minPt = 8;
$hPad = 2;
$fullName = trim(($student['firstname'] ?? '') . ' ' . ($student['lastname'] ?? '')) ?: 'Student';
$bestPt = $maxPt;
$textW = 0.0;
while ($bestPt >= $minPt) {
$pdf->SetFont($fontFamily, $fontStyle, $bestPt);
$textW = (float) $pdf->GetStringWidth($fullName);
if ($textW <= ($layout['sticker_width'] - 2 * $hPad)) {
break;
}
$bestPt -= 0.5;
}
if ($bestPt < $minPt) {
$bestPt = $minPt;
$pdf->SetFont($fontFamily, $fontStyle, $bestPt);
$textW = (float) $pdf->GetStringWidth($fullName);
}
$textH = $ptToMm($bestPt);
$cellX = $x + (($layout['sticker_width'] - $textW) / 2);
$cellY = $y + (($layout['sticker_height'] - $textH) / 2);
$pdf->SetXY($cellX, $cellY);
$pdf->Cell($textW, $textH, $fullName, 0, 0, 'L');
$i++;
$posInRow = ($i - 1) % $cols;
if ($posInRow === ($cols - 1)) {
$x = $xStart;
$y += $layout['sticker_height'] + $layout['gap_y'];
} else {
$x += $layout['sticker_width'] + $layout['gap_x'];
}
}
return [
'ok' => true,
'pdf' => $pdf->Output('S'),
'filename' => 'Student_Stickers.pdf',
];
}
private function expandCopies(array $students, array $payload, bool $singleStudentMode): array
{
$copiesPosted = $payload['copies'] ?? null;
$singleCopies = (int) ($payload['single_copies'] ?? 1);
if ($singleCopies < 0) {
$singleCopies = 0;
}
$printList = [];
if ($singleStudentMode) {
$qty = max(0, $singleCopies);
for ($k = 0; $k < $qty; $k++) {
$printList[] = $students[0];
}
return $printList;
}
$hasCopies = is_array($copiesPosted);
$defaultQty = $hasCopies ? 0 : 1;
foreach ($students as $student) {
$id = (int) ($student['id'] ?? 0);
$qty = $defaultQty;
if ($hasCopies && array_key_exists((string) $id, $copiesPosted)) {
$qty = (int) $copiesPosted[(string) $id];
}
$qty = max(0, $qty);
for ($k = 0; $k < $qty; $k++) {
$printList[] = $student;
}
}
return $printList;
}
private function buildLayout(array $payload): array
{
$sizeStrReq = trim((string) ($payload['sticker_size'] ?? ''));
$stickerWidth = (float) ($payload['sticker_width'] ?? 0);
$stickerHeight = (float) ($payload['sticker_height'] ?? 0);
if (($stickerWidth <= 0 || $stickerHeight <= 0) && $sizeStrReq !== '') {
if (preg_match('/^\\s*(\\d+(?:\\.\\d+)?)\\s*[xX\\x{00D7}]\\s*(\\d+(?:\\.\\d+)?)\\s*$/u', $sizeStrReq, $m)) {
$stickerWidth = (float) $m[1];
$stickerHeight = (float) $m[2];
}
}
if ($stickerWidth <= 0) {
$stickerWidth = 102.0;
}
if ($stickerHeight <= 0) {
$stickerHeight = 34.0;
}
$stickersPerPage = (int) ($payload['stickers_per_page'] ?? 0);
$pageSize = (string) ($payload['page_size'] ?? 'A4');
$orientation = (string) ($payload['orientation'] ?? 'P');
$marginLeftProvided = array_key_exists('margin_left', $payload);
$marginTopProvided = array_key_exists('margin_top', $payload);
$marginRightProvided = array_key_exists('margin_right', $payload);
$marginBottomProvided = array_key_exists('margin_bottom', $payload);
$gapXProvided = array_key_exists('gap_x', $payload);
$gapYProvided = array_key_exists('gap_y', $payload);
$marginLeft = (float) ($payload['margin_left'] ?? 6);
$marginTop = (float) ($payload['margin_top'] ?? 6);
$marginRight = (float) ($payload['margin_right'] ?? $marginLeft);
$marginBottom = (float) ($payload['margin_bottom'] ?? 10);
$gapX = (float) ($payload['gap_x'] ?? 0);
$gapY = (float) ($payload['gap_y'] ?? 0);
$normalizedSize = strtolower(str_replace(["\x{00D7}", ' '], ['x', ''], $sizeStrReq));
$isXSmallPreset = $normalizedSize === '100x24.5'
|| (abs($stickerWidth - 100.0) < 0.6 && abs($stickerHeight - 24.5) < 0.6);
if ($isXSmallPreset) {
if (!$marginLeftProvided) {
$marginLeft = 4.0;
if (!$marginRightProvided) {
$marginRight = 4.0;
}
}
if (!$marginRightProvided && $marginRight < 4.0) {
$marginRight = 4.0;
}
if (!$gapXProvided) {
$gapX = 0.0;
}
if (!$gapYProvided) {
$gapY = 0.0;
}
}
return [
'sticker_width' => $stickerWidth,
'sticker_height' => $stickerHeight,
'stickers_per_page' => $stickersPerPage,
'page_size' => $pageSize,
'orientation' => $orientation,
'margin_left' => $marginLeft,
'margin_top' => $marginTop,
'margin_right' => $marginRight,
'margin_bottom' => $marginBottom,
'gap_x' => $gapX,
'gap_y' => $gapY,
];
}
}
@@ -0,0 +1,114 @@
<?php
namespace App\Services\Reports\Stickers;
use Illuminate\Support\Facades\DB;
class StickerQueryService
{
public function __construct(private StickerContextService $context)
{
}
public function resolveSchoolYear(?string $schoolYear): string
{
return $this->context->schoolYear($schoolYear);
}
public function listClassesWithStudents(string $schoolYear): array
{
return DB::table('classSection')
->select('classSection.class_section_id', 'classSection.class_section_name')
->join('student_class', 'student_class.class_section_id', '=', 'classSection.class_section_id')
->where('student_class.school_year', $schoolYear)
->groupBy('classSection.class_section_id', 'classSection.class_section_name')
->orderBy('classSection.class_section_name', 'ASC')
->get()
->map(fn ($row) => (array) $row)
->all();
}
public function listStudentsByClass(int $classSectionId, string $schoolYear): array
{
return DB::table('student_class as sc')
->select('s.id', 's.firstname', 's.lastname', 's.registration_grade', 's.gender')
->join('students as s', 's.id', '=', 'sc.student_id')
->where('sc.class_section_id', $classSectionId)
->where('sc.school_year', $schoolYear)
->orderBy('s.lastname', 'asc')
->get()
->map(fn ($row) => (array) $row)
->all();
}
public function listStudentsForYear(string $schoolYear, int $limit = 500): array
{
return DB::table('students')
->select('students.id', 'students.firstname', 'students.lastname')
->join('student_class', 'student_class.student_id', '=', 'students.id')
->where('student_class.school_year', $schoolYear)
->groupBy('students.id', 'students.firstname', 'students.lastname')
->orderBy('students.firstname', 'ASC')
->orderBy('students.lastname', 'ASC')
->limit($limit)
->get()
->map(fn ($row) => (array) $row)
->all();
}
public function listStudentsForPrintAll(string $schoolYear, int $limit = 5000): array
{
$rows = DB::table('students')
->select(
'students.id',
'students.firstname',
'students.lastname',
'sc.class_section_id',
'cs.class_section_name',
'c.id as class_id'
)
->join('student_class as sc', 'sc.student_id', '=', 'students.id')
->join('classSection as cs', 'cs.class_section_id', '=', 'sc.class_section_id')
->join('classes as c', 'c.id', '=', 'cs.class_id')
->where('sc.school_year', $schoolYear)
->groupBy(
'students.id',
'students.firstname',
'students.lastname',
'sc.class_section_id',
'cs.class_section_name',
'c.id'
)
->orderBy('c.id', 'ASC')
->orderBy('cs.class_section_name', 'ASC')
->orderBy('students.firstname', 'ASC')
->orderBy('students.lastname', 'ASC')
->limit($limit)
->get()
->map(fn ($row) => (array) $row)
->all();
return $this->filterExcludedSections($rows);
}
public function findStudent(int $studentId): ?array
{
$row = DB::table('students')
->select('id', 'firstname', 'lastname')
->where('id', $studentId)
->first();
return $row ? (array) $row : null;
}
private function filterExcludedSections(array $students): array
{
return array_values(array_filter($students, static function (array $student): bool {
$sectionName = trim((string) ($student['class_section_name'] ?? ''));
if ($sectionName === '') {
return false;
}
return !preg_match('/^(youth|kg)(?:\b|-)/i', $sectionName);
}));
}
}
-390
View File
@@ -1,390 +0,0 @@
<?php
namespace App\Controllers\View;
class StickersController extends PrintablesBaseController
{
public function stickerForm()
{
$data['students'] = $this->studentModel->findAll();
$data['classes'] = $this->classSectionModel->findAll();
$data['stickers'] = $this->stickerPresets();
return view('printables_reports/sticker_form', $data);
}
public function getByClass($classId)
{
$builder = $this->db->table('student_class sc')
->select('s.id, s.firstname, s.lastname, s.registration_grade, s.gender')
->join('students s', 's.id = sc.student_id')
->where('sc.class_section_id', $classId)
->where('sc.school_year', $this->schoolYear)
->orderBy('s.lastname', 'asc');
$students = $builder->get()->getResultArray();
return $this->response->setJSON($students);
}
/** Preset sticker sizes for the view (robust keys) */
private function stickerPresets(): array
{
$rows = [
['value' => '100x24.5', 'label' => 'Xsmall - 100x24.5 mm', 'ppg' => 20],
['value' => '60x30', 'label' => 'Small - 60x30 mm', 'ppg' => 24],
['value' => '102x34', 'label' => 'Medium - 102x34 mm', 'ppg' => 14],
['value' => '90x45', 'label' => 'Large - 90x45 mm', 'ppg' => 10],
];
// Add aliases so different views will not break (title/text/per_page)
return array_map(static function ($r) {
$r['title'] = $r['title'] ?? ($r['label'] ?? ($r['value'] . ' mm'));
$r['text'] = $r['text'] ?? $r['title'];
$r['per_page'] = $r['per_page'] ?? ($r['ppg'] ?? null);
return $r;
}, $rows);
}
/**
* Generate name stickers as a PDF.
*
* Can be used for different sticker sizes/sheets by passing parameters
* via function args (from routes) or via GET/POST form fields.
*
* Accepted request params (POST takes precedence over GET):
* - class_id : int
* - student_id : int
* - print_all : 0|1
* - sticker_size : string like "102x34" (accepts upper/lower case X or the unicode multiply sign)
* - sticker_width : number (mm)
* - sticker_height : number (mm)
* - stickers_per_page : int (optional cap; grid still derived from size/margins)
* - page_size : "A4" (default) | "Letter" | any FPDF size token
* - orientation : "P" (default) | "L"
* - margin_left : number (mm)
* - margin_top : number (mm)
* - margin_right : number (mm; defaults to margin_left)
* - margin_bottom : number (mm; default 10)
* - gap_x : number (mm; horizontal gap between stickers)
* - gap_y : number (mm; vertical gap between stickers)
* - copies[<student_id>] : int per-student copies in batch mode
* - single_copies : int copies for single student mode
*/
public function generateStickers(
?int $studentId = null,
?float $stickerWidthArg = null,
?float $stickerHeightArg = null,
?int $stickersPerPageArg = null,
?string $pageSizeArg = null,
?string $orientationArg = null,
?float $marginLeftArg = null,
?float $marginTopArg = null,
?float $marginRightArg = null,
?float $marginBottomArg = null,
?float $gapXArg = null,
?float $gapYArg = null
) {
$request = service('request');
$method = strtolower($request->getMethod());
$classId = $request->getPost('class_id') ?: $request->getGet('class_id');
$studentId = $studentId ?? ($request->getPost('student_id') ?: $request->getGet('student_id'));
// Safety cast
$classId = $classId !== null ? (int) $classId : null;
$studentId = $studentId !== null ? (int) $studentId : null;
// Sanity log for school year
if (empty($this->schoolYear)) {
log_message('error', 'generateStickers(): $this->schoolYear is empty. Load it from ConfigurationModel.');
} else {
log_message('debug', 'generateStickers(): schoolYear=' . $this->schoolYear);
}
// Load classes that actually have students in this school year
$classes = $this->classSectionModel
->select('classSection.class_section_id, classSection.class_section_name')
->join('student_class', 'student_class.class_section_id = classSection.class_section_id', 'inner')
->where('student_class.school_year', $this->schoolYear)
->groupBy('classSection.class_section_id, classSection.class_section_name')
->orderBy('classSection.class_section_name', 'ASC')
->findAll();
// ---------- GET: render form ----------
if ($method === 'get') {
$students = [];
if (!empty($classId)) {
$students = $this->fetchStudentsByClass((int) $classId, $this->schoolYear);
} else {
$students = $this->studentModel
->select('students.id, students.firstname, students.lastname')
->join('student_class', 'student_class.student_id = students.id', 'inner')
->where('student_class.school_year', $this->schoolYear)
->groupBy('students.id, students.firstname, students.lastname')
->orderBy('students.firstname', 'ASC')
->orderBy('students.lastname', 'ASC')
->findAll(500);
}
// Presets (now with robust keys expected by the view)
$presetRows = [
['value' => '60x30', 'label' => 'Small - 60x30 mm', 'ppg' => 24],
['value' => '102x34', 'label' => 'Medium - 102x34 mm', 'ppg' => 14],
['value' => '90x45', 'label' => 'Large - 90x45 mm', 'ppg' => 10],
];
// Normalize to include 'title', 'text', and 'per_page' so views using any of those will not error.
$stickers = array_map(static function (array $r): array {
$r['title'] = $r['title'] ?? ($r['label'] ?? ($r['value'] . ' mm'));
$r['text'] = $r['text'] ?? $r['title'];
$r['per_page'] = $r['per_page'] ?? ($r['ppg'] ?? null);
return $r;
}, $presetRows);
return view('printables_reports/sticker_form', [
'classes' => $classes,
'students' => $students,
'stickers' => $stickers,
]);
}
// ---------- POST: build student list ----------
$printAll = (int) ($request->getPost('print_all') ?? $request->getGet('print_all') ?? 0) === 1;
$students = [];
if (!empty($studentId)) {
$student = $this->studentModel->find((int) $studentId);
if (!$student) {
return 'Student not found.';
}
$students = [$student];
} elseif (!empty($classId)) {
$students = $this->fetchStudentsByClass((int) $classId, $this->schoolYear);
if (empty($students)) {
return 'No students found in this class.';
}
} elseif ($printAll) {
$students = $this->studentModel
->select('students.id, students.firstname, students.lastname, sc.class_section_id, cs.class_section_name, c.id AS class_id')
->join('student_class sc', 'sc.student_id = students.id', 'inner')
->join('classSection cs', 'cs.class_section_id = sc.class_section_id', 'inner')
->join('classes c', 'c.id = cs.class_id', 'inner')
->where('sc.school_year', $this->schoolYear)
->groupBy('students.id, students.firstname, students.lastname, sc.class_section_id, cs.class_section_name, c.id')
->orderBy('c.id', 'ASC')
->orderBy('cs.class_section_name', 'ASC')
->orderBy('students.firstname', 'ASC')
->orderBy('students.lastname', 'ASC')
->findAll(5000);
// Exclude Youth and KG
$students = array_values(array_filter($students, static function ($s) {
$g = trim((string) ($s['class_section_name'] ?? ''));
return $g !== '' && !preg_match('/^(youth|kg)(?:\b|-)/i', $g);
}));
if (empty($students)) {
return 'No students found for this school year.';
}
} else {
return 'Please select a student or class.';
}
// ---- Expand list according to per-student copy counts ----
$copiesPosted = $request->getPost('copies'); // array|null
$singleCopies = (int) ($request->getPost('single_copies') ?? $request->getGet('single_copies') ?? 1);
if ($singleCopies < 0) $singleCopies = 0;
$printList = [];
if (!empty($studentId)) {
$qty = max(0, $singleCopies);
for ($k = 0; $k < $qty; $k++) {
$printList[] = $students[0];
}
} else {
$hasCopies = is_array($copiesPosted);
$defaultQty = $hasCopies ? 0 : 1; // if copies[] present, only print IDs provided
foreach ($students as $stu) {
$id = (int) ($stu['id'] ?? 0);
$qty = $defaultQty;
if ($hasCopies && array_key_exists((string)$id, $copiesPosted)) {
$qty = (int) $copiesPosted[(string)$id];
}
$qty = max(0, $qty);
for ($k = 0; $k < $qty; $k++) {
$printList[] = $stu;
}
}
}
if (empty($printList)) {
return 'Nothing to print. All quantities are zero.';
}
// ---------- Layout parameters (request/args/defaults) ----------
$sizeStrReq = trim((string) ($request->getPost('sticker_size') ?? $request->getGet('sticker_size') ?? ''));
$stickerWidth = $stickerWidthArg ?? (float) ($request->getPost('sticker_width') ?? $request->getGet('sticker_width') ?? 0);
$stickerHeight = $stickerHeightArg ?? (float) ($request->getPost('sticker_height') ?? $request->getGet('sticker_height') ?? 0);
// parse size string if provided (supports decimals; accepts x/X/unicode multiply)
if (($stickerWidth <= 0 || $stickerHeight <= 0) && $sizeStrReq !== '') {
if (preg_match('/^\\s*(\\d+(?:\\.\\d+)?)\\s*[xX\\x{00D7}]\\s*(\\d+(?:\\.\\d+)?)\\s*$/u', $sizeStrReq, $m)) {
$stickerWidth = (float) $m[1];
$stickerHeight = (float) $m[2];
}
}
// fallbacks to controller properties or sane defaults (mm)
if ($stickerWidth <= 0) {
$stickerWidth = isset($this->stickerWidth) ? (float)$this->stickerWidth : 102.0;
}
if ($stickerHeight <= 0) {
$stickerHeight = isset($this->stickerHeight) ? (float)$this->stickerHeight : 34.0;
}
$stickersPerPageReq = $stickersPerPageArg ?? (int) ($request->getPost('stickers_per_page') ?? $request->getGet('stickers_per_page') ?? 0);
$pageSize = $pageSizeArg ?? (string) ($request->getPost('page_size') ?? $request->getGet('page_size') ?? 'A4');
$orientation = $orientationArg ?? (string) ($request->getPost('orientation') ?? $request->getGet('orientation') ?? 'P');
$marginLeftProvided = $marginLeftArg !== null || $request->getPost('margin_left') !== null || $request->getGet('margin_left') !== null;
$marginTopProvided = $marginTopArg !== null || $request->getPost('margin_top') !== null || $request->getGet('margin_top') !== null;
$marginRightProvided = $marginRightArg !== null || $request->getPost('margin_right') !== null || $request->getGet('margin_right') !== null;
$marginBottomProvided = $marginBottomArg !== null || $request->getPost('margin_bottom') !== null || $request->getGet('margin_bottom') !== null;
$gapXProvided = $gapXArg !== null || $request->getPost('gap_x') !== null || $request->getGet('gap_x') !== null;
$gapYProvided = $gapYArg !== null || $request->getPost('gap_y') !== null || $request->getGet('gap_y') !== null;
$marginLeft = $marginLeftArg ?? (float) ($request->getPost('margin_left') ?? $request->getGet('margin_left') ?? ($this->marginL ?? 6));
$marginTop = $marginTopArg ?? (float) ($request->getPost('margin_top') ?? $request->getGet('margin_top') ?? ($this->marginT ?? 6));
$marginRight = $marginRightArg ?? (float) ($request->getPost('margin_right') ?? $request->getGet('margin_right') ?? $marginLeft);
$marginBottom = $marginBottomArg ?? (float) ($request->getPost('margin_bottom') ?? $request->getGet('margin_bottom') ?? 10);
$gapX = $gapXArg ?? (float) ($request->getPost('gap_x') ?? $request->getGet('gap_x') ?? ($this->gapX ?? 0));
$gapY = $gapYArg ?? (float) ($request->getPost('gap_y') ?? $request->getGet('gap_y') ?? ($this->gapY ?? 0));
// If using the 100x24.5 preset (small labels) and nothing custom was provided,
// default to tight margins and zero gaps to fit 20 per page (2 columns * 10+ rows).
$normalizedSize = strtolower(str_replace(['×', ' '], ['x', ''], $sizeStrReq));
$isXSmallPreset = $normalizedSize === '100x24.5' || (abs($stickerWidth - 100.0) < 0.6 && abs($stickerHeight - 24.5) < 0.6);
if ($isXSmallPreset) {
if (!$marginLeftProvided) {
$marginLeft = 4.0;
if (!$marginRightProvided) {
$marginRight = 4.0;
}
}
if (!$marginRightProvided && $marginRight < 4.0) {
$marginRight = 4.0;
}
if (!$gapXProvided) {
$gapX = 0.0;
}
if (!$gapYProvided) {
$gapY = 0.0;
}
}
// ---------- FPDF setup ----------
$pdf = new \FPDF($orientation, 'mm', $pageSize);
$pdf->SetMargins($marginLeft, $marginTop, $marginRight);
$pdf->SetAutoPageBreak(true, $marginBottom);
$pdf->AddPage();
$pdf->SetFont('Arial', '', 12);
// Printable area
$pageW = (float) $pdf->GetPageWidth();
$pageH = (float) $pdf->GetPageHeight();
$usableW = max(0.0, $pageW - ($marginLeft + $marginRight));
$usableH = max(0.0, $pageH - ($marginTop + $marginBottom));
// Compute columns & rows from physical dimensions and gaps.
$cols = (int) floor(($usableW + max(0.0, $gapX)) / (max(0.1, $stickerWidth) + max(0.0, $gapX)));
$rows = (int) floor(($usableH + max(0.0, $gapY)) / (max(0.1, $stickerHeight) + max(0.0, $gapY)));
$cols = max(1, $cols);
$rows = max(1, $rows);
// Optional cap: stickers_per_page
$capacity = $cols * $rows;
if ($stickersPerPageReq > 0 && $stickersPerPageReq < $capacity) {
$rows = (int) ceil($stickersPerPageReq / $cols);
$maxRowsFit = (int) floor(($usableH + max(0.0, $gapY)) / (max(0.1, $stickerHeight) + max(0.0, $gapY)));
$rows = max(1, min($rows, max(1, $maxRowsFit)));
$capacity = $cols * $rows;
}
// Starting positions
$xStart = $marginLeft;
$yStart = $marginTop;
$x = $xStart;
$y = $yStart;
$pt2mm = static function (float $pt): float {
return $pt * 0.352778;
};
// Draw loop
$i = 0;
$perPage = $capacity;
foreach ($printList as $stu) {
if ($i > 0 && $i % $perPage === 0) {
$pdf->AddPage();
$x = $xStart;
$y = $yStart;
}
// Background sticker rectangle (optional; fill white)
$pdf->SetFillColor(255, 255, 255);
$pdf->Rect($x, $y, $stickerWidth, $stickerHeight, 'F');
// Centered name with dynamic font size
$fontFamily = 'Arial';
$fontStyle = 'B';
$maxPt = 16;
$minPt = 8;
$hPad = 2;
$fullName = trim(($stu['firstname'] ?? '') . ' ' . ($stu['lastname'] ?? '')) ?: 'Student';
$bestPt = $maxPt;
$textW = 0.0;
while ($bestPt >= $minPt) {
$pdf->SetFont($fontFamily, $fontStyle, $bestPt);
$textW = (float) $pdf->GetStringWidth($fullName);
if ($textW <= ($stickerWidth - 2 * $hPad)) break;
$bestPt -= 0.5;
}
if ($bestPt < $minPt) {
$bestPt = $minPt;
$pdf->SetFont($fontFamily, $fontStyle, $bestPt);
$textW = (float) $pdf->GetStringWidth($fullName);
}
$textH = $pt2mm($bestPt);
$cellX = $x + (($stickerWidth - $textW) / 2);
$cellY = $y + (($stickerHeight - $textH) / 2);
$pdf->SetXY($cellX, $cellY);
$pdf->Cell($textW, $textH, $fullName, 0, 0, 'L');
// Advance cell position within the grid
$i++;
$posInRow = ($i - 1) % $cols;
if ($posInRow === ($cols - 1)) {
$x = $xStart;
$y += $stickerHeight + $gapY;
} else {
$x += $stickerWidth + $gapX;
}
}
// Output
$pdfContent = $pdf->Output('S');
return $this->response
->setHeader('Content-Type', 'application/pdf')
->setHeader('Content-Disposition', 'inline; filename="Student_Stickers.pdf"')
->setHeader('Cache-Control', 'private, max-age=0, must-revalidate')
->setHeader('Pragma', 'public')
->setHeader('Content-Length', (string) strlen($pdfContent))
->setBody($pdfContent);
}
}
+6
View File
@@ -39,6 +39,7 @@ use App\Http\Controllers\Api\Settings\EventController;
use App\Http\Controllers\Api\Family\FamilyAdminController; use App\Http\Controllers\Api\Family\FamilyAdminController;
use App\Http\Controllers\Api\Family\FamilyController; use App\Http\Controllers\Api\Family\FamilyController;
use App\Http\Controllers\Api\Reports\FilesController; use App\Http\Controllers\Api\Reports\FilesController;
use App\Http\Controllers\Api\Reports\StickersController;
use App\Http\Controllers\Api\Scores\FinalController; use App\Http\Controllers\Api\Scores\FinalController;
use App\Http\Controllers\Api\Finance\FinancialController; use App\Http\Controllers\Api\Finance\FinancialController;
use App\Http\Controllers\Api\System\FlagController; use App\Http\Controllers\Api\System\FlagController;
@@ -397,6 +398,11 @@ Route::prefix('v1')->group(function () {
Route::get('logs', [SlipPrinterController::class, 'logs']); Route::get('logs', [SlipPrinterController::class, 'logs']);
Route::post('reprint', [SlipPrinterController::class, 'reprint']); Route::post('reprint', [SlipPrinterController::class, 'reprint']);
}); });
Route::prefix('reports/stickers')->group(function () {
Route::get('form-data', [StickersController::class, 'formData']);
Route::get('students', [StickersController::class, 'studentsByClass']);
Route::post('print', [StickersController::class, 'print']);
});
Route::prefix('subjects/curriculum')->group(function () { Route::prefix('subjects/curriculum')->group(function () {
Route::get('/', [SubjectCurriculumApiController::class, 'index']); Route::get('/', [SubjectCurriculumApiController::class, 'index']);
@@ -0,0 +1,156 @@
<?php
namespace Tests\Feature\Api\V1\Reports;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class StickersControllerTest extends TestCase
{
use RefreshDatabase;
public function test_form_data_returns_classes_students_and_presets(): void
{
$this->seedConfig();
$user = $this->seedUser();
$this->seedClassAndStudent();
Sanctum::actingAs($user);
$response = $this->getJson('/api/v1/reports/stickers/form-data?school_year=2025-2026');
$response->assertOk();
$response->assertJsonPath('status', true);
$this->assertNotEmpty($response->json('data.classes'));
$this->assertNotEmpty($response->json('data.students'));
$this->assertNotEmpty($response->json('data.presets'));
$this->assertArrayHasKey('value', $response->json('data.presets.0'));
}
public function test_students_by_class_returns_students(): void
{
$this->seedConfig();
$user = $this->seedUser();
$this->seedClassAndStudent();
Sanctum::actingAs($user);
$response = $this->getJson('/api/v1/reports/stickers/students?class_section_id=200&school_year=2025-2026');
$response->assertOk();
$response->assertJsonPath('status', true);
$this->assertNotEmpty($response->json('data.students'));
}
public function test_print_returns_pdf(): void
{
$this->seedConfig();
$user = $this->seedUser();
$this->seedClassAndStudent();
Sanctum::actingAs($user);
$response = $this->postJson('/api/v1/reports/stickers/print', [
'student_id' => 100,
'school_year' => '2025-2026',
'sticker_size' => '102x34',
]);
$response->assertOk();
$this->assertSame('application/pdf', $response->headers->get('content-type'));
}
public function test_print_validation_rejects_missing_selection(): void
{
$this->seedConfig();
$user = $this->seedUser();
Sanctum::actingAs($user);
$response = $this->postJson('/api/v1/reports/stickers/print', [
'school_year' => '2025-2026',
]);
$response->assertStatus(422);
$response->assertJsonPath('message', 'Validation failed.');
}
public function test_requires_authentication(): void
{
$response = $this->getJson('/api/v1/reports/stickers/form-data');
$response->assertStatus(401);
}
private function seedConfig(): void
{
DB::table('configuration')->insert([
['config_key' => 'school_year', 'config_value' => '2025-2026'],
['config_key' => 'semester', 'config_value' => 'Fall'],
]);
}
private function seedUser(): User
{
$userId = DB::table('users')->insertGetId([
'firstname' => 'Admin',
'lastname' => 'User',
'cellphone' => '9999999999',
'email' => 'stickers@example.com',
'address_street' => '123 Street',
'city' => 'City',
'state' => 'ST',
'zip' => '12345',
'accept_school_policy' => 1,
'password' => bcrypt('password'),
'user_type' => 'primary',
'semester' => 'Fall',
'school_year' => '2025-2026',
'status' => 'Active',
]);
return User::query()->findOrFail($userId);
}
private function seedClassAndStudent(): void
{
DB::table('classes')->insert([
'id' => 1,
'class_name' => 'Grade 1',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('classSection')->insert([
'class_section_id' => 200,
'class_section_name' => 'Grade 1',
'class_id' => 1,
'semester' => 'Fall',
'school_year' => '2025-2026',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('students')->insert([
'id' => 100,
'school_id' => 1,
'firstname' => 'Student',
'lastname' => 'One',
'parent_id' => 10,
'semester' => 'Fall',
'school_year' => '2025-2026',
'is_active' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('student_class')->insert([
'student_id' => 100,
'class_section_id' => 200,
'school_year' => '2025-2026',
'semester' => 'Fall',
'is_event_only' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
@@ -0,0 +1,20 @@
<?php
namespace Tests\Unit\Services\Reports\Stickers;
use App\Services\Reports\Stickers\StickerPresetService;
use Tests\TestCase;
class StickerPresetServiceTest extends TestCase
{
public function test_presets_include_alias_fields(): void
{
$service = new StickerPresetService();
$presets = $service->presets();
$this->assertNotEmpty($presets);
$this->assertArrayHasKey('title', $presets[0]);
$this->assertArrayHasKey('text', $presets[0]);
$this->assertArrayHasKey('per_page', $presets[0]);
}
}
@@ -0,0 +1,83 @@
<?php
namespace Tests\Unit\Services\Reports\Stickers;
use App\Services\Reports\Stickers\StickerContextService;
use App\Services\Reports\Stickers\StickerPrintService;
use App\Services\Reports\Stickers\StickerQueryService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class StickerPrintServiceTest extends TestCase
{
use RefreshDatabase;
public function test_generate_returns_pdf_content(): void
{
$this->seedData();
$service = new StickerPrintService(new StickerQueryService(new StickerContextService()));
$result = $service->generate([
'student_id' => 100,
'school_year' => '2025-2026',
'sticker_size' => '102x34',
]);
$this->assertTrue($result['ok']);
$this->assertNotEmpty($result['pdf']);
}
public function test_generate_returns_error_when_no_students(): void
{
$service = new StickerPrintService(new StickerQueryService(new StickerContextService()));
$result = $service->generate([
'class_section_id' => 999,
'school_year' => '2025-2026',
]);
$this->assertFalse($result['ok']);
$this->assertSame('No students found in this class.', $result['message']);
}
private function seedData(): void
{
DB::table('classes')->insert([
['id' => 1, 'class_name' => 'Grade 1', 'created_at' => now(), 'updated_at' => now()],
]);
DB::table('classSection')->insert([
'class_section_id' => 200,
'class_section_name' => 'Grade 1',
'class_id' => 1,
'semester' => 'Fall',
'school_year' => '2025-2026',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('students')->insert([
'id' => 100,
'school_id' => 1,
'firstname' => 'Student',
'lastname' => 'One',
'parent_id' => 10,
'semester' => 'Fall',
'school_year' => '2025-2026',
'is_active' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('student_class')->insert([
'student_id' => 100,
'class_section_id' => 200,
'school_year' => '2025-2026',
'semester' => 'Fall',
'is_event_only' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
@@ -0,0 +1,117 @@
<?php
namespace Tests\Unit\Services\Reports\Stickers;
use App\Services\Reports\Stickers\StickerContextService;
use App\Services\Reports\Stickers\StickerQueryService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
class StickerQueryServiceTest extends TestCase
{
use RefreshDatabase;
public function test_list_students_by_class_returns_rows(): void
{
$this->seedData();
$service = new StickerQueryService(new StickerContextService());
$students = $service->listStudentsByClass(200, '2025-2026');
$this->assertCount(1, $students);
$this->assertSame(100, $students[0]['id']);
}
public function test_list_students_for_print_all_excludes_youth_and_kg(): void
{
$this->seedData();
$this->seedYouthClass();
$service = new StickerQueryService(new StickerContextService());
$students = $service->listStudentsForPrintAll('2025-2026');
$this->assertCount(1, $students);
$this->assertSame('Grade 1', $students[0]['class_section_name']);
}
private function seedData(): void
{
DB::table('classes')->insert([
['id' => 1, 'class_name' => 'Grade 1', 'created_at' => now(), 'updated_at' => now()],
]);
DB::table('classSection')->insert([
'class_section_id' => 200,
'class_section_name' => 'Grade 1',
'class_id' => 1,
'semester' => 'Fall',
'school_year' => '2025-2026',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('students')->insert([
'id' => 100,
'school_id' => 1,
'firstname' => 'Student',
'lastname' => 'One',
'parent_id' => 10,
'semester' => 'Fall',
'school_year' => '2025-2026',
'is_active' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('student_class')->insert([
'student_id' => 100,
'class_section_id' => 200,
'school_year' => '2025-2026',
'semester' => 'Fall',
'is_event_only' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
}
private function seedYouthClass(): void
{
DB::table('classes')->insert([
['id' => 2, 'class_name' => 'Youth', 'created_at' => now(), 'updated_at' => now()],
]);
DB::table('classSection')->insert([
'class_section_id' => 201,
'class_section_name' => 'Youth',
'class_id' => 2,
'semester' => 'Fall',
'school_year' => '2025-2026',
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('students')->insert([
'id' => 101,
'school_id' => 1,
'firstname' => 'Youth',
'lastname' => 'Student',
'parent_id' => 11,
'semester' => 'Fall',
'school_year' => '2025-2026',
'is_active' => 1,
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('student_class')->insert([
'student_id' => 101,
'class_section_id' => 201,
'school_year' => '2025-2026',
'semester' => 'Fall',
'is_event_only' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
}
}