Merge remote-tracking branch 'origin/ranjith-dev' into ranjith-dev
Some checks failed
Scan for leaked secrets using Kingfisher / kingfisher-secrets-scan (push) Successful in 16s
Scan for leaked secrets using Kingfisher / kingfisher-secrets-scan (pull_request) Successful in 1m6s
Gemini PR Review / Gemini PR Review (pull_request) Successful in 1m14s
Laravel Pint / pint (pull_request) Failing after 2m50s
Laravel Larastan / larastan (pull_request) Failing after 4m56s
Some checks failed
Scan for leaked secrets using Kingfisher / kingfisher-secrets-scan (push) Successful in 16s
Scan for leaked secrets using Kingfisher / kingfisher-secrets-scan (pull_request) Successful in 1m6s
Gemini PR Review / Gemini PR Review (pull_request) Successful in 1m14s
Laravel Pint / pint (pull_request) Failing after 2m50s
Laravel Larastan / larastan (pull_request) Failing after 4m56s
This commit is contained in:
44
app/Filament/Exports/EmployeeMasterExporter.php
Normal file
44
app/Filament/Exports/EmployeeMasterExporter.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Exports;
|
||||
|
||||
use App\Models\EmployeeMaster;
|
||||
use Filament\Actions\Exports\ExportColumn;
|
||||
use Filament\Actions\Exports\Exporter;
|
||||
use Filament\Actions\Exports\Models\Export;
|
||||
|
||||
class EmployeeMasterExporter extends Exporter
|
||||
{
|
||||
protected static ?string $model = EmployeeMaster::class;
|
||||
|
||||
public static function getColumns(): array
|
||||
{
|
||||
return [
|
||||
ExportColumn::make('id')
|
||||
->label('ID'),
|
||||
ExportColumn::make('plant.name'),
|
||||
ExportColumn::make('name'),
|
||||
ExportColumn::make('code'),
|
||||
ExportColumn::make('department'),
|
||||
ExportColumn::make('designation'),
|
||||
ExportColumn::make('email'),
|
||||
ExportColumn::make('mobile_number'),
|
||||
ExportColumn::make('created_at'),
|
||||
ExportColumn::make('updated_at'),
|
||||
ExportColumn::make('created_by'),
|
||||
ExportColumn::make('updated_by'),
|
||||
ExportColumn::make('deleted_at'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getCompletedNotificationBody(Export $export): string
|
||||
{
|
||||
$body = 'Your employee master export has completed and ' . number_format($export->successful_rows) . ' ' . str('row')->plural($export->successful_rows) . ' exported.';
|
||||
|
||||
if ($failedRowsCount = $export->getFailedRowsCount()) {
|
||||
$body .= ' ' . number_format($failedRowsCount) . ' ' . str('row')->plural($failedRowsCount) . ' failed to export.';
|
||||
}
|
||||
|
||||
return $body;
|
||||
}
|
||||
}
|
||||
53
app/Filament/Imports/EmployeeMasterImporter.php
Normal file
53
app/Filament/Imports/EmployeeMasterImporter.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Imports;
|
||||
|
||||
use App\Models\EmployeeMaster;
|
||||
use Filament\Actions\Imports\ImportColumn;
|
||||
use Filament\Actions\Imports\Importer;
|
||||
use Filament\Actions\Imports\Models\Import;
|
||||
|
||||
class EmployeeMasterImporter extends Importer
|
||||
{
|
||||
protected static ?string $model = EmployeeMaster::class;
|
||||
|
||||
public static function getColumns(): array
|
||||
{
|
||||
return [
|
||||
ImportColumn::make('plant')
|
||||
->requiredMapping()
|
||||
->relationship()
|
||||
->rules(['required']),
|
||||
ImportColumn::make('name'),
|
||||
ImportColumn::make('code'),
|
||||
ImportColumn::make('department'),
|
||||
ImportColumn::make('designation'),
|
||||
ImportColumn::make('email')
|
||||
->rules(['email']),
|
||||
ImportColumn::make('mobile_number'),
|
||||
ImportColumn::make('created_by'),
|
||||
ImportColumn::make('updated_by'),
|
||||
];
|
||||
}
|
||||
|
||||
public function resolveRecord(): ?EmployeeMaster
|
||||
{
|
||||
// return EmployeeMaster::firstOrNew([
|
||||
// // Update existing records, matching them by `$this->data['column_name']`
|
||||
// 'email' => $this->data['email'],
|
||||
// ]);
|
||||
|
||||
return new EmployeeMaster();
|
||||
}
|
||||
|
||||
public static function getCompletedNotificationBody(Import $import): string
|
||||
{
|
||||
$body = 'Your employee master import has completed and ' . number_format($import->successful_rows) . ' ' . str('row')->plural($import->successful_rows) . ' imported.';
|
||||
|
||||
if ($failedRowsCount = $import->getFailedRowsCount()) {
|
||||
$body .= ' ' . number_format($failedRowsCount) . ' ' . str('row')->plural($failedRowsCount) . ' failed to import.';
|
||||
}
|
||||
|
||||
return $body;
|
||||
}
|
||||
}
|
||||
217
app/Filament/Resources/EmployeeMasterResource.php
Normal file
217
app/Filament/Resources/EmployeeMasterResource.php
Normal file
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Exports\EmployeeMasterExporter;
|
||||
use App\Filament\Imports\EmployeeMasterImporter;
|
||||
use App\Filament\Resources\EmployeeMasterResource\Pages;
|
||||
use App\Filament\Resources\EmployeeMasterResource\RelationManagers;
|
||||
use App\Models\EmployeeMaster;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||
use Filament\Tables\Actions\ExportAction;
|
||||
use Filament\Tables\Actions\ImportAction;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class EmployeeMasterResource extends Resource
|
||||
{
|
||||
protected static ?string $model = EmployeeMaster::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
|
||||
|
||||
public static function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Forms\Components\Select::make('plant_id')
|
||||
->label('Plant')
|
||||
->relationship('plant', 'name')
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('name')
|
||||
->label('Name')
|
||||
->required()
|
||||
->reactive()
|
||||
->extraInputAttributes([
|
||||
'oninput' => 'this.value = this.value.replace(/[^a-zA-Z\s]/g, "")',
|
||||
]),
|
||||
Forms\Components\TextInput::make('code')
|
||||
->label('ID')
|
||||
->extraInputAttributes([
|
||||
'oninput' => 'this.value = this.value.replace(/[^a-zA-Z0-9]/g, "")',])
|
||||
->required()
|
||||
->unique(
|
||||
table: 'employee_masters',
|
||||
column: 'code',
|
||||
ignoreRecord: true
|
||||
)
|
||||
->validationMessages([
|
||||
'unique' => 'Duplicate employee code already exists.',
|
||||
]),
|
||||
Forms\Components\TextInput::make('department')
|
||||
->label('Department')
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('designation')
|
||||
->label('Designation')
|
||||
->extraInputAttributes([
|
||||
'oninput' => 'this.value = this.value.replace(/[^a-zA-Z\s]/g, "")',])
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('email')
|
||||
->label('Email')
|
||||
->email()
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('mobile_number')
|
||||
->label('Mobile Number')
|
||||
->length(10)
|
||||
->reactive()
|
||||
->extraInputAttributes([
|
||||
'oninput' => 'this.value = this.value.replace(/[^0-9]/g, "").slice(0, 10)', // blocks non-numbers + limits to 10 chars
|
||||
'maxlength' => 10,
|
||||
])
|
||||
->required()
|
||||
->unique(
|
||||
table: 'employee_masters',
|
||||
column: 'mobile_number',
|
||||
ignoreRecord: true
|
||||
)
|
||||
->validationMessages([
|
||||
'unique' => 'Duplicate mobile number already exists.',
|
||||
]),
|
||||
Forms\Components\Hidden::make('created_by')
|
||||
->label('Created by')
|
||||
->default(Filament::auth()->user()?->name ?? ''),
|
||||
Forms\Components\Hidden::make('updated_by')
|
||||
->label('Updated by')
|
||||
->default(Filament::auth()->user()?->name ?? ''),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('No.')
|
||||
->label('No.')
|
||||
->getStateUsing(function ($record, $livewire, $column, $rowLoop) {
|
||||
$paginator = $livewire->getTableRecords();
|
||||
$perPage = method_exists($paginator, 'perPage') ? $paginator->perPage() : 10;
|
||||
$currentPage = method_exists($paginator, 'currentPage') ? $paginator->currentPage() : 1;
|
||||
return ($currentPage - 1) * $perPage + $rowLoop->iteration;
|
||||
})
|
||||
->alignCenter(),
|
||||
Tables\Columns\TextColumn::make('plant.name')
|
||||
->numeric()
|
||||
->sortable()
|
||||
->alignCenter(),
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->label('Name')
|
||||
->sortable()
|
||||
->alignCenter()
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('code')
|
||||
->label('Employee ID')
|
||||
->sortable()
|
||||
->alignCenter()
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('department')
|
||||
->label('Department')
|
||||
->sortable()
|
||||
->alignCenter()
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('designation')
|
||||
->label('Designation')
|
||||
->sortable()
|
||||
->alignCenter()
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('email')
|
||||
->label('Email')
|
||||
->sortable()
|
||||
->alignCenter()
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('mobile_number')
|
||||
->label('Mobile Number')
|
||||
->sortable()
|
||||
->alignCenter()
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->alignCenter(),
|
||||
Tables\Columns\TextColumn::make('updated_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->alignCenter(),
|
||||
Tables\Columns\TextColumn::make('created_by')
|
||||
->label('Created by')
|
||||
->sortable()
|
||||
->alignCenter(),
|
||||
Tables\Columns\TextColumn::make('updated_by')
|
||||
->label('Updated by')
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->alignCenter(),
|
||||
Tables\Columns\TextColumn::make('deleted_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->alignCenter(),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\TrashedFilter::make(),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\ViewAction::make(),
|
||||
Tables\Actions\EditAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DeleteBulkAction::make(),
|
||||
Tables\Actions\ForceDeleteBulkAction::make(),
|
||||
Tables\Actions\RestoreBulkAction::make(),
|
||||
]),
|
||||
])
|
||||
->headerActions([
|
||||
ImportAction::make()
|
||||
->importer(EmployeeMasterImporter::class)
|
||||
->visible(function() {
|
||||
return Filament::auth()->user()->can('view import employee master');
|
||||
}),
|
||||
ExportAction::make()
|
||||
->exporter(EmployeeMasterExporter::class)
|
||||
->visible(function() {
|
||||
return Filament::auth()->user()->can('view export employee master');
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListEmployeeMasters::route('/'),
|
||||
'create' => Pages\CreateEmployeeMaster::route('/create'),
|
||||
'view' => Pages\ViewEmployeeMaster::route('/{record}'),
|
||||
'edit' => Pages\EditEmployeeMaster::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getEloquentQuery()
|
||||
->withoutGlobalScopes([
|
||||
SoftDeletingScope::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\EmployeeMasterResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EmployeeMasterResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateEmployeeMaster extends CreateRecord
|
||||
{
|
||||
protected static string $resource = EmployeeMasterResource::class;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\EmployeeMasterResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EmployeeMasterResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditEmployeeMaster extends EditRecord
|
||||
{
|
||||
protected static string $resource = EmployeeMasterResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\ViewAction::make(),
|
||||
Actions\DeleteAction::make(),
|
||||
Actions\ForceDeleteAction::make(),
|
||||
Actions\RestoreAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\EmployeeMasterResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EmployeeMasterResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListEmployeeMasters extends ListRecords
|
||||
{
|
||||
protected static string $resource = EmployeeMasterResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\EmployeeMasterResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EmployeeMasterResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewEmployeeMaster extends ViewRecord
|
||||
{
|
||||
protected static string $resource = EmployeeMasterResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\EditAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,11 @@ use App\Filament\Resources\StickerValidationResource;
|
||||
use App\Models\Item;
|
||||
use App\Models\ItemCharacteristic;
|
||||
use App\Models\ProductionQuantity;
|
||||
use App\Models\StickerDetail;
|
||||
use App\Models\StickerMappingMaster;
|
||||
use App\Models\StickerStructureDetail;
|
||||
use App\Models\StickerValidation;
|
||||
use App\Services\StickerPdfService;
|
||||
use Filament\Actions;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Notifications\Notification;
|
||||
@@ -92,8 +95,7 @@ class CreateStickerValidation extends CreateRecord
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (preg_match('/^([a-zA-Z0-9]{6,})\|([1-9][a-zA-Z0-9]{8,})\|?$/', $this->serNo, $matches)) {
|
||||
else {
|
||||
$itemCode = $matches[1];
|
||||
$serialNumber = $matches[2];
|
||||
|
||||
@@ -195,58 +197,6 @@ class CreateStickerValidation extends CreateRecord
|
||||
return;
|
||||
}
|
||||
|
||||
$urls = [];
|
||||
|
||||
foreach ($stickers as $sticker) {
|
||||
$urls[] = route('stickers1.pdf', [
|
||||
'stickerId' => $sticker['sticker_id'],
|
||||
'plant_id' => $this->plantId,
|
||||
'item_characteristic_id' => $sticker['item_characteristic'],
|
||||
'serial_number' => $serialNumber,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->dispatch('open-stickers-sequence', urls: $urls);
|
||||
|
||||
// $pdfPath = storage_path('app/private/uploads/StickerTemplateOcr/multi.pdf');
|
||||
|
||||
// if (! file_exists($pdfPath)) {
|
||||
// Notification::make()
|
||||
// ->danger()
|
||||
// ->title('Pdf Not Found')
|
||||
// ->body("pdf file not exist.")
|
||||
// ->send();
|
||||
// return;
|
||||
// }
|
||||
|
||||
// if (! $printerName) {
|
||||
// Notification::make()
|
||||
// ->danger()
|
||||
// ->title('Printer Not Found')
|
||||
// ->body("No CUPS printer configured for IP: $iotsPrintIp")
|
||||
// ->send();
|
||||
// return;
|
||||
// }
|
||||
|
||||
// putenv('CUPS_SERVER=printer.iotsignin.com');
|
||||
|
||||
// $cmd = "lp -d " . escapeshellarg($printerName)
|
||||
// . " -o fit-to-page "
|
||||
// . escapeshellarg($pdfPath);
|
||||
|
||||
// exec($cmd, $out, $status);
|
||||
|
||||
// if ($status != 0) {
|
||||
// Notification::make()
|
||||
// ->danger()
|
||||
// ->title('Print Failed')
|
||||
// ->body('CUPS print command failed.')
|
||||
// ->send();
|
||||
// return;
|
||||
// }
|
||||
|
||||
//dd($iotsPrintIp, $matchedSticker);
|
||||
|
||||
StickerValidation::create([
|
||||
'plant_id' => $this->plantId,
|
||||
'machine_id' => $this->workCenter,
|
||||
@@ -275,24 +225,192 @@ class CreateStickerValidation extends CreateRecord
|
||||
|
||||
$this->dispatch('refreshEmptySticker', $plantId, $this->ref_number);
|
||||
|
||||
}
|
||||
|
||||
// foreach ($stickers as $sticker) {
|
||||
|
||||
// // $printerName = $this->getCupsPrinterNameByIp($sticker['print_ip']);
|
||||
|
||||
// \Log::info("Looking up printer for IP: " . $sticker['print_ip']);
|
||||
// $printerName = $this->getCupsPrinterNameByIp($sticker['print_ip']);
|
||||
// \Log::info("Found printer: " . ($printerName ?? 'NULL'));
|
||||
|
||||
// if (! $printerName) {
|
||||
// Notification::make()
|
||||
// ->danger()
|
||||
// ->title('Printer Not Found')
|
||||
// ->body("No CUPS printer configured for IP: {$sticker['print_ip']}")
|
||||
// ->send();
|
||||
// return;
|
||||
// }
|
||||
|
||||
// $structure = StickerStructureDetail::findOrFail($sticker['sticker_id']);
|
||||
// $itemCharacteristic = ItemCharacteristic::where('plant_id', $this->plantId)
|
||||
// ->where('id', $sticker['item_characteristic'])
|
||||
// ->firstOrFail();
|
||||
|
||||
// $dynamicElements = StickerDetail::where(
|
||||
// 'sticker_structure_detail_id',
|
||||
// $structure->id
|
||||
// )->where('element_type', 'Dynamic')->get();
|
||||
|
||||
|
||||
|
||||
// /** STEP 3: Stream PDF to CUPS (STDIN) */
|
||||
// $process = proc_open(
|
||||
// 'lp -d ' . escapeshellarg($printerName) . ' -o fit-to-page -',
|
||||
// [
|
||||
// ['pipe', 'r'], // STDIN
|
||||
// ['pipe', 'w'], // STDOUT
|
||||
// ['pipe', 'w'], // STDERR
|
||||
// ],
|
||||
// $pipes
|
||||
// );
|
||||
|
||||
|
||||
// if (! is_resource($process)) {
|
||||
// Notification::make()
|
||||
// ->danger()
|
||||
// ->title('Print Failed')
|
||||
// ->body('Unable to start CUPS print process.')
|
||||
// ->send();
|
||||
// return;
|
||||
// // continue;
|
||||
// }
|
||||
|
||||
// $pdfContent = (new StickerPdfService())->generatePdf1(
|
||||
// $structure->sticker_id,
|
||||
// $dynamicElements,
|
||||
// $itemCharacteristic,
|
||||
// $serialNumber,
|
||||
// $serNo
|
||||
// );
|
||||
|
||||
// fwrite($pipes[0], $pdfContent);
|
||||
// fclose($pipes[0]);
|
||||
|
||||
// $stderr = stream_get_contents($pipes[2]);
|
||||
// fclose($pipes[1]);
|
||||
// fclose($pipes[2]);
|
||||
|
||||
// $status = proc_close($process);
|
||||
|
||||
// if ($status != 0) {
|
||||
// Notification::make()
|
||||
// ->danger()
|
||||
// ->title('Print Failed')
|
||||
// ->body("CUPS error: {$stderr}")
|
||||
// ->send();
|
||||
// return;
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
foreach ($stickers as $sticker)
|
||||
{
|
||||
|
||||
\Log::info("Looking up printer for IP: " . $sticker['print_ip']);
|
||||
|
||||
$printerName = $this->getCupsPrinterNameByIp($sticker['print_ip']);
|
||||
|
||||
\Log::info("Found printer: " . ($printerName ?? 'NULL'));
|
||||
|
||||
if (! $printerName) {
|
||||
Notification::make()
|
||||
->danger()
|
||||
->title('Printer Not Found')
|
||||
->body("No CUPS printer configured for IP: {$sticker['print_ip']}")
|
||||
->send();
|
||||
return;
|
||||
}
|
||||
|
||||
$structure = StickerStructureDetail::findOrFail($sticker['sticker_id']);
|
||||
|
||||
$itemCharacteristic = ItemCharacteristic::where('plant_id', $this->plantId)
|
||||
->where('id', $sticker['item_characteristic'])
|
||||
->firstOrFail();
|
||||
|
||||
$dynamicElements = StickerDetail::where(
|
||||
'sticker_structure_detail_id',
|
||||
$structure->id
|
||||
)->where('element_type', 'Dynamic')->get();
|
||||
|
||||
$pdfContent = (new StickerPdfService())->generatePdf1(
|
||||
$structure->sticker_id,
|
||||
$dynamicElements,
|
||||
$itemCharacteristic,
|
||||
$serialNumber,
|
||||
$serNo
|
||||
);
|
||||
|
||||
$tempPdfPath = storage_path('app/temp_sticker_' . uniqid() . '.pdf');
|
||||
file_put_contents($tempPdfPath, $pdfContent);
|
||||
|
||||
exec(
|
||||
"lp -d " . escapeshellarg($printerName) . " " . escapeshellarg($tempPdfPath),
|
||||
$output,
|
||||
$status
|
||||
);
|
||||
|
||||
\Log::info("LP Output:", $output);
|
||||
\Log::info("LP Status: " . $status);
|
||||
|
||||
if ($status != 0) {
|
||||
Notification::make()
|
||||
->danger()
|
||||
->title('Print Failed')
|
||||
->body("CUPS error while printing.")
|
||||
->send();
|
||||
|
||||
if (file_exists($tempPdfPath)) {
|
||||
unlink($tempPdfPath);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (file_exists($tempPdfPath)) {
|
||||
unlink($tempPdfPath);
|
||||
}
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->success()
|
||||
->title('Sticker Printed')
|
||||
->body("Sticker for Serial Number: $serialNumber printed successfully!")
|
||||
->seconds(3)
|
||||
->send();
|
||||
|
||||
// [$itemCode, $serialNumber] = explode('|', $serNo);
|
||||
|
||||
// $this->dispatch('open-sticker-pdf', [
|
||||
// 'url' => url("/sticker/pdf/{$itemCode}/{$serialNumber}/$this->plantId/$this->ref_number")
|
||||
// ]);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private function getPrinterNameByIp(string $ip): ?string
|
||||
protected function getCupsPrinterNameByIp(string $ip): ?string
|
||||
{
|
||||
exec('lpstat -v', $output);
|
||||
|
||||
foreach ($output as $line) {
|
||||
// Example:
|
||||
// device for TSC_WC_01: socket://192.168.1.50:9100
|
||||
if (str_contains($line, $ip)) {
|
||||
preg_match('/device for (.+?):/', $line, $matches);
|
||||
return $matches[1] ?? null;
|
||||
// exec('lpstat -v 2>&1', $output, $status);
|
||||
|
||||
exec('lpstat -h cups:631 -v 2>&1', $output, $status);
|
||||
|
||||
if ($status != 0 || empty($output)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($output as $line){
|
||||
$parts = explode(':', $line, 2);
|
||||
if (count($parts) < 2) continue;
|
||||
|
||||
$printerName = trim(str_replace('device for', '', $parts[0]));
|
||||
$deviceUri = trim($parts[1]);
|
||||
|
||||
if (str_contains($deviceUri, $ip)) {
|
||||
return $printerName;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -52,7 +52,6 @@ class UserResource extends Resource
|
||||
// ->email()
|
||||
->unique(ignoreRecord: true)
|
||||
->required()
|
||||
->readOnly()
|
||||
// ->rule(function (callable $get) {
|
||||
// return Rule::unique('users', 'email')
|
||||
// ->ignore($get('id')); // Ignore current record during updates
|
||||
|
||||
276
app/Filament/Resources/VisitorEntryResource.php
Normal file
276
app/Filament/Resources/VisitorEntryResource.php
Normal file
@@ -0,0 +1,276 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\VisitorEntryResource\Pages;
|
||||
use App\Filament\Resources\VisitorEntryResource\RelationManagers;
|
||||
use App\Models\VisitorEntry;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Forms;
|
||||
use Filament\Forms\Form;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||
|
||||
class VisitorEntryResource extends Resource
|
||||
{
|
||||
protected static ?string $model = VisitorEntry::class;
|
||||
|
||||
protected static ?string $navigationIcon = 'heroicon-o-rectangle-stack';
|
||||
|
||||
public static function form(Form $form): Form
|
||||
{
|
||||
return $form
|
||||
->schema([
|
||||
Forms\Components\TextInput::make('mobile_number')
|
||||
->label('Mobile Number')
|
||||
->length(10)
|
||||
->reactive()
|
||||
->extraInputAttributes([
|
||||
'oninput' => 'this.value = this.value.replace(/[^0-9]/g, "").slice(0, 10)', // blocks non-numbers + limits to 10 chars
|
||||
'maxlength' => 10,
|
||||
])
|
||||
->required()
|
||||
->extraAttributes([
|
||||
'id' => 'mobile_number_input',
|
||||
'x-data' => '{ value: "" }',
|
||||
'x-model' => 'value',
|
||||
'wire:keydown.enter.prevent' => 'processMobile(value)',
|
||||
]),
|
||||
Forms\Components\TextInput::make('name')
|
||||
->label('Name')
|
||||
->required()
|
||||
->reactive()
|
||||
->extraInputAttributes([
|
||||
'oninput' => 'this.value = this.value.replace(/[^a-zA-Z\s]/g, "")',
|
||||
]),
|
||||
Forms\Components\Select::make('type')
|
||||
->label('Type')
|
||||
->reactive()
|
||||
->options([
|
||||
'Student' => 'Student',
|
||||
'Consultant' => 'Consultant',
|
||||
'Vendor' => 'Vendor',
|
||||
'Other' => 'Other',
|
||||
])
|
||||
->required()
|
||||
->dehydrateStateUsing(function ($state, callable $get) {
|
||||
return $state == 'Other'
|
||||
? $get('other_type')
|
||||
: $state;
|
||||
}),
|
||||
Forms\Components\TextInput::make('other_type')
|
||||
->label('Specify Type')
|
||||
->reactive()
|
||||
->visible(fn (callable $get) => $get('type') == 'Other')
|
||||
->required(fn (callable $get) => $get('type') == 'Other')
|
||||
->dehydrated(false),
|
||||
Forms\Components\TextInput::make('company')
|
||||
->label('Company')
|
||||
->required(),
|
||||
Forms\Components\Select::make('department')
|
||||
->label('Employee Department')
|
||||
->options(
|
||||
\App\Models\EmployeeMaster::distinct()
|
||||
->pluck('department', 'department')
|
||||
)
|
||||
->required()
|
||||
->reactive()
|
||||
->afterStateUpdated(function (callable $set) {
|
||||
$set('employee_master_id', null);
|
||||
$set('code', null);
|
||||
}),
|
||||
// Forms\Components\Select::make('employee_master_id')
|
||||
// ->label('Recipient Employee')
|
||||
// ->required()
|
||||
// ->options(function (callable $get) {
|
||||
// $department = $get('department');
|
||||
|
||||
// if (!$department) {
|
||||
// return [];
|
||||
// }
|
||||
|
||||
// return \App\Models\EmployeeMaster::where('department', $department)
|
||||
// ->pluck('name', 'id');
|
||||
// })
|
||||
// ->reactive()
|
||||
// ->afterStateUpdated(function (callable $set, callable $get, ?string $state) {
|
||||
// $department = $get('department');
|
||||
|
||||
// $employee = \App\Models\EmployeeMaster::where('id', $state)
|
||||
// ->where('department', $department)
|
||||
// ->first();
|
||||
|
||||
// $set('code', $employee ? $employee->code : '');
|
||||
// }),
|
||||
|
||||
Forms\Components\Select::make('employee_master_id')
|
||||
->label('Recipient Employee')
|
||||
->required()
|
||||
->options(function (callable $get) {
|
||||
$department = $get('department');
|
||||
// Always load ALL employees, filter by department if set
|
||||
if ($department) {
|
||||
return \App\Models\EmployeeMaster::where('department', $department)
|
||||
->pluck('name', 'id');
|
||||
}
|
||||
// Fallback: load all so fill() can always match the ID
|
||||
return \App\Models\EmployeeMaster::pluck('name', 'id');
|
||||
})
|
||||
->reactive()
|
||||
->afterStateUpdated(function (callable $set, ?string $state) {
|
||||
$employee = \App\Models\EmployeeMaster::find($state);
|
||||
$set('code', $employee?->code ?? '');
|
||||
}),
|
||||
|
||||
Forms\Components\TextInput::make('code')
|
||||
->label('Employee Code')
|
||||
->readOnly(),
|
||||
Forms\Components\Textarea::make('purpose_of_visit')
|
||||
->label('Purpose of Visit')
|
||||
->required(),
|
||||
Forms\Components\TextInput::make('number_of_person')
|
||||
->numeric()
|
||||
->default(1)
|
||||
->required(),
|
||||
Forms\Components\DateTimePicker::make('in_time')
|
||||
->label('In Time'),
|
||||
Forms\Components\DateTimePicker::make('out_time')
|
||||
->label('Out Time'),
|
||||
Forms\Components\View::make('components.webcam-field')
|
||||
->columnSpanFull(),
|
||||
Forms\Components\Hidden::make('photo'),
|
||||
Forms\Components\Hidden::make('created_by')
|
||||
->label('created_by')
|
||||
->default(Filament::auth()->user()?->name),
|
||||
Forms\Components\Hidden::make('updated_by')
|
||||
->label('updated_by')
|
||||
->default(Filament::auth()->user()?->name),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('No.')
|
||||
->label('NO')
|
||||
->alignCenter()
|
||||
->getStateUsing(function ($record, $livewire, $column, $rowLoop) {
|
||||
$paginator = $livewire->getTableRecords();
|
||||
$perPage = method_exists($paginator, 'perPage') ? $paginator->perPage() : 10;
|
||||
$currentPage = method_exists($paginator, 'currentPage') ? $paginator->currentPage() : 1;
|
||||
|
||||
return ($currentPage - 1) * $perPage + $rowLoop->iteration;
|
||||
}),
|
||||
Tables\Columns\ImageColumn::make('photo')
|
||||
->label('Photo')
|
||||
->disk('public')
|
||||
->height(50)
|
||||
->width(50)
|
||||
// ->defaultImageUrl('https://ui-avatars.com/api/?name=Visitor&background=555&color=fff')
|
||||
->defaultImageUrl(asset('images/profile.png'))
|
||||
->alignCenter()
|
||||
->extraImgAttributes(['style' => 'border-radius: 6px; object-fit: cover;']),
|
||||
Tables\Columns\TextColumn::make('type')
|
||||
->label('Visitor Type')
|
||||
->alignCenter()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('name')
|
||||
->label('Visitor Name')
|
||||
->sortable()
|
||||
->alignCenter()
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('mobile_number')
|
||||
->label('Visitor Mobile Number')
|
||||
->alignCenter()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('employeeMaster.name')
|
||||
->label('Recipient Name')
|
||||
->alignCenter()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('employeeMaster.code')
|
||||
->label('Receipient ID')
|
||||
->alignCenter()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('employeeMaster.department')
|
||||
->label('Receipient Department')
|
||||
->alignCenter()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('number_of_person')
|
||||
->label('Number of Person')
|
||||
->numeric()
|
||||
->alignCenter()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('in_time')
|
||||
->label('In Time')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->alignCenter(),
|
||||
Tables\Columns\TextColumn::make('out_time')
|
||||
->label('Out Time')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->alignCenter(),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->label('Created At')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->alignCenter(),
|
||||
Tables\Columns\TextColumn::make('updated_at')
|
||||
->label('Updated At')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->alignCenter(),
|
||||
Tables\Columns\TextColumn::make('deleted_at')
|
||||
->label('Deleted At')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true)
|
||||
->alignCenter(),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\TrashedFilter::make(),
|
||||
])
|
||||
->actions([
|
||||
Tables\Actions\ViewAction::make(),
|
||||
Tables\Actions\EditAction::make(),
|
||||
])
|
||||
->bulkActions([
|
||||
Tables\Actions\BulkActionGroup::make([
|
||||
Tables\Actions\DeleteBulkAction::make(),
|
||||
Tables\Actions\ForceDeleteBulkAction::make(),
|
||||
Tables\Actions\RestoreBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListVisitorEntries::route('/'),
|
||||
'create' => Pages\CreateVisitorEntry::route('/create'),
|
||||
'view' => Pages\ViewVisitorEntry::route('/{record}'),
|
||||
'edit' => Pages\EditVisitorEntry::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getEloquentQuery()
|
||||
->withoutGlobalScopes([
|
||||
SoftDeletingScope::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\VisitorEntryResource\Pages;
|
||||
|
||||
use App\Filament\Resources\VisitorEntryResource;
|
||||
use App\Models\EmployeeMaster;
|
||||
use App\Models\VisitorEntry;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
use Livewire\Attributes\On;
|
||||
use Storage;
|
||||
|
||||
class CreateVisitorEntry extends CreateRecord
|
||||
{
|
||||
protected static string $resource = VisitorEntryResource::class;
|
||||
|
||||
public $capturedPhoto;
|
||||
|
||||
// #[On('photo-captured')]
|
||||
// public function handlePhotoCapture(string $photo): void
|
||||
// {
|
||||
// // Puts the Base64 photo into the form's data array
|
||||
// $this->data['photo'] = $photo;
|
||||
// }
|
||||
|
||||
public function processMobile($mobile)
|
||||
{
|
||||
$visitor = VisitorEntry::where('mobile_number', $mobile)->latest()->first();
|
||||
|
||||
if ($visitor) {
|
||||
|
||||
$employee = EmployeeMaster::where('id', $visitor->employee_master_id)->first();
|
||||
|
||||
$this->form->fill([
|
||||
'mobile_number' => $mobile ?? '',
|
||||
'name' => $visitor->name ?? '',
|
||||
'company' => $visitor->company ?? '',
|
||||
'type' => $visitor->type ?? '',
|
||||
'department' => $employee->department ?? '',
|
||||
'employee_master_id' => $visitor->employee_master_id->name ?? '',
|
||||
'code' => $employee->code ?? '',
|
||||
]);
|
||||
}
|
||||
else {
|
||||
|
||||
$this->form->fill([
|
||||
'mobile_number' => $mobile ?? '',
|
||||
'name' => $visitor->name ?? '',
|
||||
'company' => $visitor->company ?? '',
|
||||
'type' => $visitor->type ?? '',
|
||||
'department' => $employee->department ?? '',
|
||||
'employee_master_id' => $visitor->employee_master_id->name ?? '',
|
||||
'code' => $employee->code ?? '',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// protected function mutateFormDataBeforeCreate(array $data): array
|
||||
// {
|
||||
// if (
|
||||
// !empty($data['photo']) &&
|
||||
// str_starts_with($data['photo'], 'data:image')
|
||||
// ) {
|
||||
// // Step A: Strip the "data:image/jpeg;base64," prefix
|
||||
// $imageData = explode(',', $data['photo'])[1];
|
||||
|
||||
// // Step B: Generate a unique filename
|
||||
// $filename = 'visitor_' . time() . '_' . uniqid() . '.jpg';
|
||||
|
||||
// // Step C: Decode Base64 and save as a real .jpg file
|
||||
// $path = 'visitor-photos/' . $filename;
|
||||
// Storage::disk('public')->put($path, base64_decode($imageData));
|
||||
|
||||
// // Step D: Replace the Base64 string with just the file path
|
||||
// $data['photo'] = $path;
|
||||
// }
|
||||
|
||||
// return $data;
|
||||
// }
|
||||
|
||||
#[On('photo-captured')]
|
||||
public function handlePhotoCapture(string $photo): void
|
||||
{
|
||||
$this->data['photo'] = $photo;
|
||||
\Log::info('WEBCAM: photo-captured event received, length: ' . strlen($photo));
|
||||
}
|
||||
|
||||
// protected function mutateFormDataBeforeCreate(array $data): array
|
||||
// {
|
||||
// \Log::info('WEBCAM: mutateFormDataBeforeCreate called, photo value: ' . substr($data['photo'] ?? 'NULL', 0, 50));
|
||||
|
||||
// if (
|
||||
// !empty($data['photo']) &&
|
||||
// str_starts_with($data['photo'], 'data:image')
|
||||
// ) {
|
||||
// $imageData = explode(',', $data['photo'])[1];
|
||||
// $filename = 'visitor_' . time() . '_' . uniqid() . '.jpg';
|
||||
// $path = 'visitor-photos/' . $filename;
|
||||
// Storage::disk('public')->put($path, base64_decode($imageData));
|
||||
// $data['photo'] = $path;
|
||||
|
||||
// \Log::info('WEBCAM: photo saved to ' . $path);
|
||||
// }
|
||||
|
||||
// return $data;
|
||||
// }
|
||||
|
||||
protected function mutateFormDataBeforeCreate(array $data): array
|
||||
{
|
||||
if (
|
||||
!empty($data['photo']) &&
|
||||
str_starts_with($data['photo'], 'data:image')
|
||||
) {
|
||||
try {
|
||||
$imageData = explode(',', $data['photo'])[1];
|
||||
|
||||
$filename = 'visitor_' . time() . '_' . uniqid() . '.jpg';
|
||||
|
||||
$path = 'visitor-photos/' . $filename;
|
||||
|
||||
$decoded = base64_decode($imageData);
|
||||
|
||||
$saved = Storage::disk('public')->put($path, $decoded);
|
||||
|
||||
\Log::info('PHOTO UPLOAD (PUBLIC):', [
|
||||
'filename' => $filename,
|
||||
'path' => $path,
|
||||
'size_bytes' => strlen($decoded),
|
||||
'saved' => $saved ? 'SUCCESS' : 'FAILED',
|
||||
]);
|
||||
|
||||
$data['photo'] = $path;
|
||||
|
||||
} catch (\Exception $e) {
|
||||
\Log::error('PHOTO UPLOAD ERROR: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
|
||||
public function setPhoto(string $photo): void
|
||||
{
|
||||
$this->capturedPhoto = $photo;
|
||||
|
||||
// Change this ↓ to dispatch to parent explicitly
|
||||
$this->dispatch('photo-captured', photo: $photo)->to(\App\Filament\Resources\VisitorEntryResource\Pages\CreateVisitorEntry::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\VisitorEntryResource\Pages;
|
||||
|
||||
use App\Filament\Resources\VisitorEntryResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
use Livewire\Attributes\On;
|
||||
use Storage;
|
||||
|
||||
class EditVisitorEntry extends EditRecord
|
||||
{
|
||||
protected static string $resource = VisitorEntryResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\ViewAction::make(),
|
||||
Actions\DeleteAction::make(),
|
||||
Actions\ForceDeleteAction::make(),
|
||||
Actions\RestoreAction::make(),
|
||||
];
|
||||
}
|
||||
|
||||
#[On('photo-captured')]
|
||||
public function handlePhotoCapture(string $photo): void
|
||||
{
|
||||
$this->data['photo'] = $photo;
|
||||
}
|
||||
|
||||
protected function mutateFormDataBeforeSave(array $data): array
|
||||
{
|
||||
if (
|
||||
!empty($data['photo']) &&
|
||||
str_starts_with($data['photo'], 'data:image')
|
||||
) {
|
||||
// Delete the old photo file if one exists
|
||||
$oldPhoto = $this->record->photo;
|
||||
if ($oldPhoto && Storage::disk('public')->exists($oldPhoto)) {
|
||||
Storage::disk('public')->delete($oldPhoto);
|
||||
}
|
||||
|
||||
// Save the new photo
|
||||
$imageData = explode(',', $data['photo'])[1];
|
||||
$filename = 'visitor_' . time() . '_' . uniqid() . '.jpg';
|
||||
$path = 'visitor-photos/' . $filename;
|
||||
Storage::disk('public')->put($path, base64_decode($imageData));
|
||||
|
||||
$data['photo'] = $path;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\VisitorEntryResource\Pages;
|
||||
|
||||
use App\Filament\Resources\VisitorEntryResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListVisitorEntries extends ListRecords
|
||||
{
|
||||
protected static string $resource = VisitorEntryResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\VisitorEntryResource\Pages;
|
||||
|
||||
use App\Filament\Resources\VisitorEntryResource;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewVisitorEntry extends ViewRecord
|
||||
{
|
||||
protected static string $resource = VisitorEntryResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Actions\EditAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
482
app/Livewire/ChatBot.php
Normal file
482
app/Livewire/ChatBot.php
Normal file
@@ -0,0 +1,482 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Services\ChatbotService;
|
||||
use App\Services\GeminiChatbotService;
|
||||
use Livewire\Component;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ChatBot extends Component
|
||||
{
|
||||
// ── Panel state ───────────────────────────────────────────────────────────
|
||||
public bool $isOpen = false;
|
||||
|
||||
/**
|
||||
* 'select' → mode-picker screen shown first
|
||||
* 'basic' → structured query UI (production or invoice report)
|
||||
* 'advanced' → free-text natural-language UI (Gemini-powered)
|
||||
*/
|
||||
public string $mode = 'select';
|
||||
|
||||
// ── Basic mode — shared ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Which report the user picked inside basic mode.
|
||||
* '' → report-type picker shown
|
||||
* 'production' → production form
|
||||
* 'invoice' → invoice type-lookup form
|
||||
* 'invoice_status' → invoice scan-status form
|
||||
*/
|
||||
public string $reportType = '';
|
||||
|
||||
public array $plants = [];
|
||||
|
||||
// ── Basic mode — Production report ───────────────────────────────────────
|
||||
public string $result = '';
|
||||
public bool $hasResult = false;
|
||||
|
||||
public ?int $selectedPlantId = null;
|
||||
public ?int $selectedLineId = null;
|
||||
public string $dateFrom = '';
|
||||
public string $dateTo = '';
|
||||
public array $lines = [];
|
||||
|
||||
// ── Basic mode — Invoice report (type lookup) ─────────────────────────────
|
||||
public ?int $invoicePlantId = null;
|
||||
public string $invoiceItemCode = '';
|
||||
public string $invoiceResult = '';
|
||||
public bool $hasInvoiceResult = false;
|
||||
|
||||
// ── Basic mode — Invoice status (scan status) ─────────────────────────────
|
||||
public string $invoiceNumber = '';
|
||||
public string $invoiceStatusResult = ''; // kept for simple error strings
|
||||
public bool $hasInvoiceStatusResult = false;
|
||||
|
||||
/**
|
||||
* Structured result from ChatbotService::getInvoiceData().
|
||||
* Shape: type, message, invoice_number, total, scanned, not_scanned, unscanned_serials[]
|
||||
*/
|
||||
public array $invoiceStatusData = [];
|
||||
|
||||
/** Controls whether all unscanned serials are shown (vs the first 10). */
|
||||
public bool $showAllUnscanned = false;
|
||||
|
||||
// ── Advanced mode ─────────────────────────────────────────────────────────
|
||||
public string $advancedQuestion = '';
|
||||
public string $advancedResult = '';
|
||||
public bool $hasAdvancedResult = false;
|
||||
public bool $isAdvancedLoading = false;
|
||||
|
||||
/**
|
||||
* Conversation history shown in advanced mode.
|
||||
* Each entry: ['role' => 'user'|'assistant', 'content' => '…']
|
||||
*
|
||||
* The full history is passed to GeminiChatbotService on every turn so
|
||||
* Gemini can resolve follow-up messages (e.g. answering a clarification
|
||||
* question) in context.
|
||||
*/
|
||||
public array $chatHistory = [];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->plants = DB::table('plants')
|
||||
->whereNull('deleted_at')
|
||||
->orderBy('name')
|
||||
->get(['id', 'name'])
|
||||
->toArray();
|
||||
|
||||
$this->dateFrom = now()->startOfMonth()->format('Y-m-d');
|
||||
$this->dateTo = now()->format('Y-m-d');
|
||||
}
|
||||
|
||||
// ── Mode switching ────────────────────────────────────────────────────────
|
||||
|
||||
public function setMode(string $mode): void
|
||||
{
|
||||
$this->mode = $mode;
|
||||
}
|
||||
|
||||
public function setReportType(string $type): void
|
||||
{
|
||||
$this->reportType = $type;
|
||||
|
||||
// Clear previous results when switching report type
|
||||
$this->result = '';
|
||||
$this->hasResult = false;
|
||||
$this->invoiceResult = '';
|
||||
$this->hasInvoiceResult = false;
|
||||
$this->invoiceStatusResult = '';
|
||||
$this->hasInvoiceStatusResult = false;
|
||||
$this->invoiceStatusData = [];
|
||||
$this->showAllUnscanned = false;
|
||||
}
|
||||
|
||||
// ── Basic mode — Production helpers ──────────────────────────────────────
|
||||
|
||||
public function updatedSelectedPlantId(): void
|
||||
{
|
||||
$this->selectedLineId = null;
|
||||
$this->lines = [];
|
||||
$this->result = '';
|
||||
$this->hasResult = false;
|
||||
|
||||
if ($this->selectedPlantId) {
|
||||
$this->lines = DB::table('lines')
|
||||
->whereNull('deleted_at')
|
||||
->where('plant_id', $this->selectedPlantId)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name'])
|
||||
->toArray();
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedSelectedLineId(): void
|
||||
{
|
||||
$this->result = '';
|
||||
$this->hasResult = false;
|
||||
}
|
||||
|
||||
public function fetchProduction(): void
|
||||
{
|
||||
if (! $this->selectedPlantId) {
|
||||
$this->result = 'Please select a plant.';
|
||||
$this->hasResult = true;
|
||||
return;
|
||||
}
|
||||
|
||||
$query = DB::table('production_quantities')
|
||||
->whereNull('deleted_at')
|
||||
->where('plant_id', $this->selectedPlantId)
|
||||
->whereDate('created_at', '>=', $this->dateFrom)
|
||||
->whereDate('created_at', '<=', $this->dateTo);
|
||||
|
||||
if ($this->selectedLineId) {
|
||||
$query->where('line_id', $this->selectedLineId);
|
||||
}
|
||||
|
||||
$count = $query->count();
|
||||
|
||||
$plantName = collect($this->plants)
|
||||
->firstWhere('id', $this->selectedPlantId)?->name ?? 'Unknown Plant';
|
||||
|
||||
$lineName = $this->selectedLineId
|
||||
? (collect($this->lines)->firstWhere('id', $this->selectedLineId)?->name ?? 'Unknown Line')
|
||||
: 'All Lines';
|
||||
|
||||
$from = \Carbon\Carbon::parse($this->dateFrom)->format('d M Y');
|
||||
$to = \Carbon\Carbon::parse($this->dateTo)->format('d M Y');
|
||||
|
||||
$this->result = "Production count for {$plantName} / {$lineName} from {$from} to {$to}: {$count} records.";
|
||||
$this->hasResult = true;
|
||||
}
|
||||
|
||||
// ── Basic mode — Invoice report (type lookup) ─────────────────────────────
|
||||
|
||||
public function updatedInvoicePlantId(): void
|
||||
{
|
||||
$this->invoiceResult = '';
|
||||
$this->hasInvoiceResult = false;
|
||||
}
|
||||
|
||||
public function updatedInvoiceItemCode(): void
|
||||
{
|
||||
$this->invoiceResult = '';
|
||||
$this->hasInvoiceResult = false;
|
||||
}
|
||||
|
||||
public function fetchInvoiceReport(): void
|
||||
{
|
||||
if (! $this->invoicePlantId) {
|
||||
$this->invoiceResult = 'Please select a plant.';
|
||||
$this->hasInvoiceResult = true;
|
||||
return;
|
||||
}
|
||||
|
||||
$itemCode = trim($this->invoiceItemCode);
|
||||
|
||||
if ($itemCode === '') {
|
||||
$this->invoiceResult = 'Please enter an item code.';
|
||||
$this->hasInvoiceResult = true;
|
||||
return;
|
||||
}
|
||||
|
||||
$plantName = collect($this->plants)
|
||||
->firstWhere('id', $this->invoicePlantId)?->name ?? 'Unknown Plant';
|
||||
|
||||
try {
|
||||
$rows = DB::select("
|
||||
WITH plant_item AS (
|
||||
SELECT ? AS user_plant,
|
||||
? AS user_item_code
|
||||
),
|
||||
t1 AS (
|
||||
SELECT
|
||||
plants.id AS plant_id,
|
||||
plants.name AS plant_name,
|
||||
ARRAY_AGG(items.code) AS item_codes
|
||||
FROM plants
|
||||
LEFT JOIN items ON plants.id = items.plant_id
|
||||
GROUP BY plants.id, plants.name
|
||||
),
|
||||
t2 AS (
|
||||
SELECT
|
||||
t1.plant_id,
|
||||
t1.plant_name,
|
||||
CASE
|
||||
WHEN plant_item.user_item_code = ANY(t1.item_codes) THEN 1
|
||||
ELSE 0
|
||||
END AS exists_flag
|
||||
FROM t1
|
||||
CROSS JOIN plant_item
|
||||
WHERE t1.plant_name = plant_item.user_plant
|
||||
),
|
||||
t3 AS (
|
||||
SELECT t2.plant_id, t2.plant_name, t2.exists_flag,
|
||||
plant_item.user_item_code
|
||||
FROM t2
|
||||
LEFT JOIN plant_item ON plant_item.user_plant = t2.plant_name
|
||||
),
|
||||
t4 AS (
|
||||
SELECT items.id AS item_id,
|
||||
t3.plant_id, t3.plant_name, t3.exists_flag, t3.user_item_code
|
||||
FROM t3
|
||||
LEFT JOIN items
|
||||
ON t3.plant_id = items.plant_id
|
||||
AND t3.user_item_code = items.code
|
||||
)
|
||||
SELECT
|
||||
t4.item_id,
|
||||
t4.plant_id,
|
||||
t4.plant_name,
|
||||
t4.exists_flag,
|
||||
t4.user_item_code,
|
||||
COALESCE(sticker_masters.material_type, 0) AS material_type,
|
||||
CASE
|
||||
WHEN sticker_masters.item_id IS NULL
|
||||
THEN 'no match found'
|
||||
WHEN COALESCE(sticker_masters.material_type, 0) = 0
|
||||
THEN 'serial invoice'
|
||||
ELSE 'material invoice'
|
||||
END AS invoice_description
|
||||
FROM t4
|
||||
LEFT JOIN sticker_masters
|
||||
ON sticker_masters.plant_id = t4.plant_id
|
||||
AND sticker_masters.item_id = t4.item_id
|
||||
", [$plantName, $itemCode]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('ChatBot: invoice report query failed', [
|
||||
'plant' => $plantName,
|
||||
'item_code' => $itemCode,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$this->invoiceResult = "Sorry, I couldn't fetch data. Please try again or contact support.";
|
||||
$this->hasInvoiceResult = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty($rows)) {
|
||||
$this->invoiceResult = "No data found for plant \"{$plantName}\". Please verify the plant selection.";
|
||||
$this->hasInvoiceResult = true;
|
||||
return;
|
||||
}
|
||||
|
||||
$row = $rows[0];
|
||||
|
||||
if ((int) $row->exists_flag === 0) {
|
||||
$this->invoiceResult = 'Provided item code does not exist in the item table.';
|
||||
} else {
|
||||
switch ($row->invoice_description) {
|
||||
case 'no match found':
|
||||
$this->invoiceResult = "Item not found in sticker master for the plant {$row->plant_name}.";
|
||||
break;
|
||||
case 'serial invoice':
|
||||
$this->invoiceResult = 'It is a serial invoice item.';
|
||||
break;
|
||||
case 'material invoice':
|
||||
$this->invoiceResult = 'It is a material invoice item.';
|
||||
break;
|
||||
default:
|
||||
$this->invoiceResult = 'Unexpected result. Please contact support.';
|
||||
}
|
||||
}
|
||||
|
||||
$this->hasInvoiceResult = true;
|
||||
}
|
||||
|
||||
// ── Basic mode — Invoice status (scan status) ─────────────────────────────
|
||||
|
||||
public function updatedInvoiceNumber(): void
|
||||
{
|
||||
$this->invoiceStatusResult = '';
|
||||
$this->hasInvoiceStatusResult = false;
|
||||
$this->invoiceStatusData = [];
|
||||
$this->showAllUnscanned = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up how many serials within an invoice have been scanned / not scanned.
|
||||
* Stores structured data in $invoiceStatusData so the blade can render
|
||||
* a "show more" serial-number list without dumping 70+ serials in one blob.
|
||||
*/
|
||||
public function fetchInvoiceStatus(): void
|
||||
{
|
||||
$invoiceNumber = trim(preg_replace('/\s+/', '', $this->invoiceNumber));
|
||||
|
||||
if (empty($invoiceNumber)) {
|
||||
$this->invoiceStatusResult = 'Please enter a valid invoice number.';
|
||||
$this->invoiceStatusData = [];
|
||||
$this->showAllUnscanned = false;
|
||||
$this->hasInvoiceStatusResult = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
/** @var \App\Services\ChatbotService $service */
|
||||
$service = app(\App\Services\ChatbotService::class);
|
||||
$data = $service->getInvoiceData($invoiceNumber);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('ChatBot: invoice status fetch failed', [
|
||||
'invoice' => $invoiceNumber,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
$data = [
|
||||
'type' => 'error',
|
||||
'message' => "Sorry, I couldn't fetch data for invoice {$invoiceNumber}. "
|
||||
. 'Please try again or contact support.',
|
||||
'invoice_number' => $invoiceNumber,
|
||||
'total' => 0,
|
||||
'scanned' => 0,
|
||||
'not_scanned' => 0,
|
||||
'unscanned_serials' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$this->invoiceStatusData = $data;
|
||||
$this->invoiceStatusResult = $data['message']; // fallback plain-text copy
|
||||
$this->showAllUnscanned = false;
|
||||
$this->hasInvoiceStatusResult = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles the "show all / show less" state for unscanned serial numbers
|
||||
* in the Basic → Invoice Status result card.
|
||||
*/
|
||||
public function toggleShowAllUnscanned(): void
|
||||
{
|
||||
$this->showAllUnscanned = ! $this->showAllUnscanned;
|
||||
}
|
||||
|
||||
// ── Advanced mode (Gemini-powered) ────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Handles a free-text user message in advanced mode.
|
||||
*
|
||||
* Steps:
|
||||
* 1. Appends the user message to chatHistory immediately (UI feedback).
|
||||
* 2. Calls GeminiChatbotService with the full prior history for context.
|
||||
* 3. Gemini classifies the intent, extracts params, and either:
|
||||
* a) runs the appropriate DB query and returns the result, or
|
||||
* b) returns a clarification question if intent is ambiguous.
|
||||
* 4. Appends the assistant reply to chatHistory.
|
||||
*/
|
||||
public function askAdvanced(): void
|
||||
{
|
||||
$question = trim($this->advancedQuestion);
|
||||
|
||||
if (empty($question)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the user's message in the chat bubble immediately
|
||||
$this->chatHistory[] = [
|
||||
'role' => 'user',
|
||||
'content' => $question,
|
||||
];
|
||||
|
||||
$this->advancedQuestion = '';
|
||||
$this->isAdvancedLoading = true;
|
||||
|
||||
try {
|
||||
/** @var GeminiChatbotService $gemini */
|
||||
$gemini = app(GeminiChatbotService::class);
|
||||
|
||||
// Pass history *without* the turn we just appended — the new user
|
||||
// message is passed separately so Gemini sees it as the latest turn.
|
||||
$priorHistory = array_slice($this->chatHistory, 0, -1);
|
||||
$answer = $gemini->processMessage($priorHistory, $question);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('ChatBot: advanced ask failed', [
|
||||
'error' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
$answer = 'Sorry, something went wrong. Please try again.';
|
||||
}
|
||||
|
||||
$this->chatHistory[] = [
|
||||
'role' => 'assistant',
|
||||
'content' => $answer,
|
||||
];
|
||||
|
||||
$this->isAdvancedLoading = false;
|
||||
}
|
||||
|
||||
public function clearAdvancedChat(): void
|
||||
{
|
||||
$this->chatHistory = [];
|
||||
$this->advancedQuestion = '';
|
||||
$this->isAdvancedLoading = false;
|
||||
}
|
||||
|
||||
// ── Panel controls ────────────────────────────────────────────────────────
|
||||
|
||||
public function toggleChat(): void
|
||||
{
|
||||
$this->isOpen = ! $this->isOpen;
|
||||
}
|
||||
|
||||
public function resetForm(): void
|
||||
{
|
||||
// Basic mode — shared
|
||||
$this->reportType = '';
|
||||
|
||||
// Basic mode — production
|
||||
$this->selectedPlantId = null;
|
||||
$this->selectedLineId = null;
|
||||
$this->lines = [];
|
||||
$this->result = '';
|
||||
$this->hasResult = false;
|
||||
$this->dateFrom = now()->startOfMonth()->format('Y-m-d');
|
||||
$this->dateTo = now()->format('Y-m-d');
|
||||
|
||||
// Basic mode — invoice type lookup
|
||||
$this->invoicePlantId = null;
|
||||
$this->invoiceItemCode = '';
|
||||
$this->invoiceResult = '';
|
||||
$this->hasInvoiceResult = false;
|
||||
|
||||
// Basic mode — invoice scan status
|
||||
$this->invoiceNumber = '';
|
||||
$this->invoiceStatusResult = '';
|
||||
$this->hasInvoiceStatusResult = false;
|
||||
$this->invoiceStatusData = [];
|
||||
$this->showAllUnscanned = false;
|
||||
|
||||
// Advanced mode
|
||||
$this->clearAdvancedChat();
|
||||
|
||||
// Go back to mode selector
|
||||
$this->mode = 'select';
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.chat-bot');
|
||||
}
|
||||
}
|
||||
32
app/Livewire/Webcam.php
Normal file
32
app/Livewire/Webcam.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire;
|
||||
|
||||
use Livewire\Component;
|
||||
|
||||
class Webcam extends Component
|
||||
{
|
||||
|
||||
public string $capturedPhoto = '';
|
||||
|
||||
// Called from JavaScript when a photo is taken
|
||||
public function setPhoto(string $photo): void
|
||||
{
|
||||
$this->capturedPhoto = $photo;
|
||||
|
||||
// Fires a browser event that the Filament form will listen to
|
||||
$this->dispatch('photo-captured', photo: $photo);
|
||||
}
|
||||
|
||||
// Called from JavaScript when user clicks "Retake"
|
||||
public function clearPhoto(): void
|
||||
{
|
||||
$this->capturedPhoto = '';
|
||||
|
||||
$this->dispatch('photo-captured', photo: '');
|
||||
}
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.webcam');
|
||||
}
|
||||
}
|
||||
32
app/Models/EmployeeMaster.php
Normal file
32
app/Models/EmployeeMaster.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class EmployeeMaster extends Model
|
||||
{
|
||||
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'plant_id',
|
||||
'name',
|
||||
'code',
|
||||
'department',
|
||||
'designation',
|
||||
'email',
|
||||
'mobile_number',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'created_by',
|
||||
'updated_by',
|
||||
];
|
||||
|
||||
public function plant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Plant::class);
|
||||
}
|
||||
}
|
||||
30
app/Models/VisitorEntry.php
Normal file
30
app/Models/VisitorEntry.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class VisitorEntry extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $fillable = [
|
||||
'mobile_number',
|
||||
'name',
|
||||
'company',
|
||||
'purpose_of_visit',
|
||||
'type',
|
||||
'in_time',
|
||||
'out_time',
|
||||
'photo',
|
||||
'employee_master_id',
|
||||
'number_of_person'
|
||||
];
|
||||
|
||||
public function employeeMaster(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EmployeeMaster::class);
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,9 @@ use App\Filament\Auth\CustomLogin as AuthCustomLogin;
|
||||
use App\Filament\Pages\CustomLogin;
|
||||
use Filament\View\PanelsRenderHook;
|
||||
use Filament\Support\Facades\FilamentView;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Filament\Pages\Auth\PasswordReset\RequestPasswordReset;
|
||||
use Filament\Pages\Auth\PasswordReset\ResetPassword;
|
||||
|
||||
|
||||
class AdminPanelProvider extends PanelProvider
|
||||
@@ -41,6 +44,10 @@ class AdminPanelProvider extends PanelProvider
|
||||
->id('admin')
|
||||
->path('admin')
|
||||
->login()
|
||||
->passwordReset(
|
||||
RequestPasswordReset::class,
|
||||
ResetPassword::class,
|
||||
)
|
||||
//->maxContentWidth(MaxWidth::Small)
|
||||
//->simplePageMaxContentWidth(MaxWidth::Medium)
|
||||
|
||||
@@ -160,5 +167,10 @@ class AdminPanelProvider extends PanelProvider
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
FilamentView::registerRenderHook(
|
||||
PanelsRenderHook::BODY_END,
|
||||
fn (): string => Blade::render("@livewire('chat-bot')"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
403
app/Services/ChatbotService.php
Normal file
403
app/Services/ChatbotService.php
Normal file
@@ -0,0 +1,403 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* ChatbotService (Advanced Mode)
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* Parses STRUCTURED user inputs via regex — no LLM involved.
|
||||
*
|
||||
* HOW TO ADD A NEW COMMAND:
|
||||
* 1. Add an entry to $handlers with a regex pattern and a handler method name.
|
||||
* 2. Write the private handler method (receives the captured group as $value).
|
||||
* 3. Add a usage example to unknownCommand() so users know about it.
|
||||
*
|
||||
* Pattern convention: /keyword\s*=\s*(.+)/i
|
||||
* - The first capture group is the raw value after "=".
|
||||
* - The handler receives it already trimmed.
|
||||
*/
|
||||
class ChatbotService
|
||||
{
|
||||
/**
|
||||
* Registry of structured-command handlers.
|
||||
*
|
||||
* Each entry:
|
||||
* 'pattern' — PCRE regex; capture group 1 is the extracted value.
|
||||
* 'handler' — name of the private method that processes the command.
|
||||
*
|
||||
* For two-value commands (e.g. invoice report), group 1 and group 2 are
|
||||
* both passed to the handler; single-value handlers simply ignore $value2.
|
||||
*/
|
||||
private array $handlers = [
|
||||
[
|
||||
'pattern' => '/inv(?:oice)?(?:\s*(?:number|num|no\.?))?\s*(?:=|is| |equal\s+to)\s*([^\s,\.]+)/i',
|
||||
'handler' => 'handleInvoice',
|
||||
],
|
||||
|
||||
// ── Invoice report: item type lookup ──────────────────────────────────
|
||||
// Accepts patterns like:
|
||||
// item = 674071 plant = Vahinie Unit 2
|
||||
// item code = 674071 plant = Vahinie Unit 2
|
||||
// check item 674071 for plant Vahinie Unit 2
|
||||
[
|
||||
'pattern' => '/item(?:\s*code)?\s*(?:=|is|:)?\s*([^\s,]+)\s+(?:for\s+)?plant\s*(?:=|is|:)?\s*(.+)/i',
|
||||
'handler' => 'handleInvoiceReport',
|
||||
],
|
||||
|
||||
// ── Add more commands here ────────────────────────────────────────────
|
||||
// Example:
|
||||
// [
|
||||
// 'pattern' => '/^\s*serial\s*=\s*(.+)/i',
|
||||
// 'handler' => 'handleSerial',
|
||||
// ],
|
||||
];
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Public entry point
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Dispatch the user's input to the matching handler.
|
||||
*/
|
||||
public function ask(string $input): string
|
||||
{
|
||||
$input = trim($input);
|
||||
|
||||
foreach ($this->handlers as $entry) {
|
||||
if (preg_match($entry['pattern'], $input, $matches)) {
|
||||
$value = trim($matches[1] ?? '');
|
||||
$value2 = trim($matches[2] ?? '');
|
||||
return $this->{$entry['handler']}($value, $value2);
|
||||
}
|
||||
}
|
||||
|
||||
return $this->unknownCommand($input);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Handler: invoice = <invoice_number>
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Looks up scan status for an invoice number in invoice_validations.
|
||||
* Returns a plain-English string (used by the Advanced / free-text path).
|
||||
* Structured callers should use getInvoiceData() directly.
|
||||
*/
|
||||
private function handleInvoice(string $invoiceNumber, string $_unused = ''): string
|
||||
{
|
||||
$data = $this->getInvoiceData($invoiceNumber);
|
||||
|
||||
// For the plain-text path (advanced mode / ChatbotService::ask()),
|
||||
// reassemble a human-readable sentence from the structured data.
|
||||
if (in_array($data['type'], ['invalid', 'error', 'not_found'], true)) {
|
||||
return $data['message'];
|
||||
}
|
||||
|
||||
if ($data['type'] === 'all_scanned') {
|
||||
$n = $data['total'];
|
||||
$itemWord = $n === 1 ? 'serial number' : 'serial numbers';
|
||||
return "For invoice number {$data['invoice_number']}, all {$n} {$itemWord} "
|
||||
. ($n === 1 ? 'has' : 'have') . ' been scanned. ✅';
|
||||
}
|
||||
|
||||
// partial or none_scanned
|
||||
$total = $data['total'];
|
||||
$scanned = $data['scanned'];
|
||||
$notScan = $data['not_scanned'];
|
||||
$inv = $data['invoice_number'];
|
||||
$itemWord = $total === 1 ? 'serial number' : 'serial numbers';
|
||||
|
||||
if ($scanned === 0) {
|
||||
$msg = "For invoice number {$inv}, there "
|
||||
. ($total === 1 ? 'is' : 'are') . " {$total} {$itemWord} "
|
||||
. 'and none have been scanned.';
|
||||
} else {
|
||||
$msg = "For invoice number {$inv}, there "
|
||||
. ($total === 1 ? 'is' : 'are') . " {$total} {$itemWord} in total. "
|
||||
. "Out of which {$scanned} "
|
||||
. ($scanned === 1 ? 'has' : 'have') . ' been scanned and '
|
||||
. "{$notScan} "
|
||||
. ($notScan === 1 ? 'has' : 'have') . ' not been scanned.';
|
||||
}
|
||||
|
||||
if (! empty($data['unscanned_serials'])) {
|
||||
$msg .= ' Unscanned serial numbers are: '
|
||||
. implode(', ', $data['unscanned_serials']) . '.';
|
||||
}
|
||||
|
||||
return $msg;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Public structured accessor — used by ChatBot (Basic mode)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns structured scan-status data for an invoice number.
|
||||
*
|
||||
* Return shape:
|
||||
* [
|
||||
* 'type' => 'all_scanned' | 'partial' | 'none_scanned'
|
||||
* | 'not_found' | 'error' | 'invalid',
|
||||
* 'message' => string, // one-line human summary (no serial list)
|
||||
* 'invoice_number' => string,
|
||||
* 'total' => int,
|
||||
* 'scanned' => int,
|
||||
* 'not_scanned' => int,
|
||||
* 'unscanned_serials' => string[], // full list — may be large
|
||||
* ]
|
||||
*/
|
||||
public function getInvoiceData(string $invoiceNumber): array
|
||||
{
|
||||
$invoiceNumber = preg_replace('/\s+/', '', $invoiceNumber);
|
||||
|
||||
if (empty($invoiceNumber)) {
|
||||
return [
|
||||
'type' => 'invalid',
|
||||
'message' => 'Please provide a valid invoice number. Example: invoice = 3RA0013333',
|
||||
'invoice_number' => '',
|
||||
'total' => 0,
|
||||
'scanned' => 0,
|
||||
'not_scanned' => 0,
|
||||
'unscanned_serials' => [],
|
||||
];
|
||||
}
|
||||
|
||||
try {
|
||||
$rows = DB::select("
|
||||
SELECT
|
||||
COALESCE(scanned_status, 'not scanned') AS status,
|
||||
COUNT(*) AS total_count,
|
||||
STRING_AGG(
|
||||
CASE
|
||||
WHEN scanned_status IS NULL THEN serial_number::text
|
||||
END,
|
||||
', '
|
||||
) AS serial_numbers_not_scanned
|
||||
FROM invoice_validations
|
||||
WHERE invoice_number = ?
|
||||
GROUP BY scanned_status
|
||||
", [$invoiceNumber]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('ChatbotService: invoice query failed', [
|
||||
'invoice' => $invoiceNumber,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [
|
||||
'type' => 'error',
|
||||
'message' => "Sorry, I couldn't fetch data for invoice {$invoiceNumber}. "
|
||||
. 'Please try again or contact support if this keeps happening.',
|
||||
'invoice_number' => $invoiceNumber,
|
||||
'total' => 0,
|
||||
'scanned' => 0,
|
||||
'not_scanned' => 0,
|
||||
'unscanned_serials' => [],
|
||||
];
|
||||
}
|
||||
|
||||
if (empty($rows)) {
|
||||
return [
|
||||
'type' => 'not_found',
|
||||
'message' => "No records found for invoice number {$invoiceNumber}. "
|
||||
. 'Please double-check the invoice number and try again.',
|
||||
'invoice_number' => $invoiceNumber,
|
||||
'total' => 0,
|
||||
'scanned' => 0,
|
||||
'not_scanned' => 0,
|
||||
'unscanned_serials' => [],
|
||||
];
|
||||
}
|
||||
|
||||
// ── Aggregate rows ────────────────────────────────────────────────────
|
||||
$totalScanned = 0;
|
||||
$totalNotScanned = 0;
|
||||
$unscannedSerials = [];
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if ($row->status === 'not scanned') {
|
||||
$totalNotScanned = (int) $row->total_count;
|
||||
if (! empty($row->serial_numbers_not_scanned)) {
|
||||
$unscannedSerials = array_values(
|
||||
array_filter(
|
||||
array_map('trim', explode(',', $row->serial_numbers_not_scanned))
|
||||
)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
$totalScanned += (int) $row->total_count;
|
||||
}
|
||||
}
|
||||
|
||||
$grandTotal = $totalScanned + $totalNotScanned;
|
||||
|
||||
// ── All scanned ───────────────────────────────────────────────────────
|
||||
if ($totalNotScanned === 0) {
|
||||
$n = $grandTotal;
|
||||
$itemWord = $n === 1 ? 'serial number' : 'serial numbers';
|
||||
return [
|
||||
'type' => 'all_scanned',
|
||||
'message' => "All {$n} {$itemWord} scanned for invoice {$invoiceNumber}. ✅",
|
||||
'invoice_number' => $invoiceNumber,
|
||||
'total' => $grandTotal,
|
||||
'scanned' => $totalScanned,
|
||||
'not_scanned' => 0,
|
||||
'unscanned_serials' => [],
|
||||
];
|
||||
}
|
||||
|
||||
// ── None / partial scanned ────────────────────────────────────────────
|
||||
$type = $totalScanned === 0 ? 'none_scanned' : 'partial';
|
||||
$itemWord = $grandTotal === 1 ? 'serial number' : 'serial numbers';
|
||||
|
||||
if ($totalScanned === 0) {
|
||||
$summary = "Invoice {$invoiceNumber} — {$grandTotal} {$itemWord}, none scanned yet.";
|
||||
} else {
|
||||
$summary = "Invoice {$invoiceNumber} — {$grandTotal} {$itemWord} total: "
|
||||
. "{$totalScanned} scanned, {$totalNotScanned} not scanned.";
|
||||
}
|
||||
|
||||
return [
|
||||
'type' => $type,
|
||||
'message' => $summary,
|
||||
'invoice_number' => $invoiceNumber,
|
||||
'total' => $grandTotal,
|
||||
'scanned' => $totalScanned,
|
||||
'not_scanned' => $totalNotScanned,
|
||||
'unscanned_serials' => $unscannedSerials,
|
||||
];
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Handler: item = <item_code> plant = <plant_name>
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Determines whether an item is a serial invoice or material invoice
|
||||
* for a given plant, using the sticker_masters table.
|
||||
*
|
||||
* @param string $itemCode Extracted item code (capture group 1)
|
||||
* @param string $plantName Extracted plant name (capture group 2)
|
||||
*/
|
||||
private function handleInvoiceReport(string $itemCode, string $plantName): string
|
||||
{
|
||||
$itemCode = trim($itemCode);
|
||||
$plantName = trim($plantName);
|
||||
|
||||
if (empty($itemCode)) {
|
||||
return 'Please provide an item code. Example: item = 674071 plant = Vahinie Unit 2';
|
||||
}
|
||||
|
||||
if (empty($plantName)) {
|
||||
return 'Please provide a plant name. Example: item = 674071 plant = Vahinie Unit 2';
|
||||
}
|
||||
|
||||
try {
|
||||
$rows = DB::select("
|
||||
WITH plant_item AS (
|
||||
SELECT ? AS user_plant,
|
||||
? AS user_item_code
|
||||
),
|
||||
t1 AS (
|
||||
SELECT
|
||||
plants.id AS plant_id,
|
||||
plants.name AS plant_name,
|
||||
ARRAY_AGG(items.code) AS item_codes
|
||||
FROM plants
|
||||
LEFT JOIN items ON plants.id = items.plant_id
|
||||
GROUP BY plants.id, plants.name
|
||||
),
|
||||
t2 AS (
|
||||
SELECT
|
||||
t1.plant_id,
|
||||
t1.plant_name,
|
||||
CASE
|
||||
WHEN plant_item.user_item_code = ANY(t1.item_codes) THEN 1
|
||||
ELSE 0
|
||||
END AS exists_flag
|
||||
FROM t1
|
||||
CROSS JOIN plant_item
|
||||
WHERE t1.plant_name = plant_item.user_plant
|
||||
),
|
||||
t3 AS (
|
||||
SELECT t2.plant_id, t2.plant_name, t2.exists_flag,
|
||||
plant_item.user_item_code
|
||||
FROM t2
|
||||
LEFT JOIN plant_item ON plant_item.user_plant = t2.plant_name
|
||||
),
|
||||
t4 AS (
|
||||
SELECT items.id AS item_id,
|
||||
t3.plant_id, t3.plant_name, t3.exists_flag, t3.user_item_code
|
||||
FROM t3
|
||||
LEFT JOIN items
|
||||
ON t3.plant_id = items.plant_id
|
||||
AND t3.user_item_code = items.code
|
||||
)
|
||||
SELECT
|
||||
t4.item_id,
|
||||
t4.plant_id,
|
||||
t4.plant_name,
|
||||
t4.exists_flag,
|
||||
t4.user_item_code,
|
||||
COALESCE(sticker_masters.material_type, 0) AS material_type,
|
||||
CASE
|
||||
WHEN sticker_masters.item_id IS NULL
|
||||
THEN 'no match found'
|
||||
WHEN COALESCE(sticker_masters.material_type, 0) = 0
|
||||
THEN 'serial invoice'
|
||||
ELSE 'material invoice'
|
||||
END AS invoice_description
|
||||
FROM t4
|
||||
LEFT JOIN sticker_masters
|
||||
ON sticker_masters.plant_id = t4.plant_id
|
||||
AND sticker_masters.item_id = t4.item_id
|
||||
", [$plantName, $itemCode]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('ChatbotService: invoice report query failed', [
|
||||
'plant' => $plantName,
|
||||
'item_code' => $itemCode,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return "Sorry, I couldn't fetch data for item {$itemCode} in plant {$plantName}. "
|
||||
. 'Please try again or contact support.';
|
||||
}
|
||||
|
||||
if (empty($rows)) {
|
||||
return "No data found for plant \"{$plantName}\". Please check the plant name and try again.";
|
||||
}
|
||||
|
||||
$row = $rows[0];
|
||||
|
||||
if ((int) $row->exists_flag === 0) {
|
||||
return 'Provided item code does not exist in the item table.';
|
||||
}
|
||||
|
||||
return match ($row->invoice_description) {
|
||||
'no match found' => "Item not found in sticker master for the plant {$row->plant_name}.",
|
||||
'serial invoice' => 'It is a serial invoice item.',
|
||||
'material invoice' => 'It is a material invoice item.',
|
||||
default => 'Unexpected result. Please contact support.',
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Fallback for unrecognised input
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
private function unknownCommand(string $input): string
|
||||
{
|
||||
return "I didn't recognise that command. Please include a supported keyword with a value after '='.\n\n"
|
||||
. "• Invoice scan status:\n"
|
||||
. " invoice = 3RA0013333\n"
|
||||
. " what is the status of invoice = 3RA0013333\n\n"
|
||||
. "• Invoice type lookup:\n"
|
||||
. " item = 674071 plant = Vahinie Unit 2\n"
|
||||
. " item code = 674071 plant = Vahinie Unit 2\n\n"
|
||||
. 'Any sentence containing keyword = value will work.';
|
||||
}
|
||||
}
|
||||
819
app/Services/GeminiChatbotService.php
Normal file
819
app/Services/GeminiChatbotService.php
Normal file
@@ -0,0 +1,819 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* GeminiChatbotService
|
||||
* ─────────────────────────────────────────────────────────────────────────────
|
||||
* Powers the "Advanced" chatbot mode with plain-English understanding.
|
||||
*
|
||||
* FLOW:
|
||||
* 1. Takes the full chat history + new user message.
|
||||
* 2. Sends them to Gemini with a structured system prompt.
|
||||
* 3. Gemini returns JSON: { task, params, missing, clarification }.
|
||||
* 4. If task is a known, complete query → runs the matching DB handler.
|
||||
* 5. If task is "unknown" → sends the message to Gemini again as a free-form
|
||||
* conversational assistant so it can answer or ask the user what they need.
|
||||
* 6. If required params are missing → Gemini's clarification question is returned.
|
||||
*
|
||||
* SUPPORTED TASKS:
|
||||
* - invoice_status → scan status of an invoice number
|
||||
* - invoice_report → serial/material invoice type for an item + plant
|
||||
* - production_report → production count for a plant / line / date range
|
||||
* - unknown → handled via a free-form Gemini conversation turn
|
||||
*
|
||||
* CONFIGURATION (.env):
|
||||
* GEMINI_API_KEY=your_key_here
|
||||
* GEMINI_MODEL=gemini-2.5-flash-preview ← set whichever model your key supports
|
||||
*
|
||||
* HOW TO ADD A NEW TASK:
|
||||
* 1. Describe it in buildSystemPrompt() under TASKS.
|
||||
* 2. Add a private handle*() method below.
|
||||
* 3. Add a match arm in processMessage().
|
||||
*/
|
||||
class GeminiChatbotService
|
||||
{
|
||||
private string $apiKey;
|
||||
|
||||
/**
|
||||
* Full REST endpoint — model name is part of the URL path, e.g.:
|
||||
* https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview:generateContent
|
||||
*
|
||||
* Built in __construct() from config('services.gemini.model').
|
||||
* Change GEMINI_MODEL in .env to switch models without editing code.
|
||||
*/
|
||||
private string $apiUrl;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->apiKey = config('services.gemini.api_key', '');
|
||||
|
||||
$model = config('services.gemini.model', 'gemini-3-flash-preview');
|
||||
|
||||
$this->apiUrl = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent";
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Public entry point
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Process a user message in context of the prior conversation.
|
||||
*
|
||||
* @param array $chatHistory Previous turns: [['role'=>'user'|'assistant','content'=>'…'], …]
|
||||
* @param string $userInput The new message just typed.
|
||||
* @return string Plain-text reply to show in the chat bubble.
|
||||
*/
|
||||
public function processMessage(array $chatHistory, string $userInput): string
|
||||
{
|
||||
if (empty($this->apiKey)) {
|
||||
return '⚠️ AI features are not configured. Please set GEMINI_API_KEY in your .env file.';
|
||||
}
|
||||
|
||||
// ── Step 1: Classify the intent ───────────────────────────────────────
|
||||
try {
|
||||
$classification = $this->classifyWithGemini($chatHistory, $userInput);
|
||||
} catch (\RuntimeException $e) {
|
||||
// Surface the specific error directly in the chat bubble
|
||||
return $e->getMessage();
|
||||
}
|
||||
|
||||
$task = $classification['task'] ?? 'unknown';
|
||||
$params = $classification['params'] ?? [];
|
||||
$missing = $classification['missing'] ?? [];
|
||||
$clarification = $classification['clarification'] ?? null;
|
||||
|
||||
// ── Step 2: Missing required params → ask user for them ───────────────
|
||||
if (! empty($missing) && ! empty($clarification)) {
|
||||
return $clarification;
|
||||
}
|
||||
|
||||
// ── Step 3: Dispatch to the appropriate handler ───────────────────────
|
||||
return match ($task) {
|
||||
'invoice_status' => $this->handleInvoiceStatus($params),
|
||||
'invoice_report' => $this->handleInvoiceReport($params),
|
||||
'production_report' => $this->handleProductionReport($params),
|
||||
|
||||
// ── Unknown intent: hand off to Gemini as a free-form assistant ──
|
||||
'unknown' => $this->handleUnknown($chatHistory, $userInput, $clarification),
|
||||
|
||||
default => $this->handleUnknown($chatHistory, $userInput, null),
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Gemini API calls
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Phase 1 — Classify the user's intent and extract structured params.
|
||||
*
|
||||
* @return array|null Parsed JSON array, or null on failure.
|
||||
*/
|
||||
/**
|
||||
* @throws \RuntimeException with a user-facing message describing exactly what failed.
|
||||
*/
|
||||
private function classifyWithGemini(array $history, string $userInput): array
|
||||
{
|
||||
$requestBody = [
|
||||
'system_instruction' => [
|
||||
'parts' => [['text' => $this->buildSystemPrompt()]],
|
||||
],
|
||||
'contents' => $this->buildGeminiContents($history, $userInput),
|
||||
'generationConfig' => [
|
||||
'temperature' => 0.1,
|
||||
'responseMimeType' => 'application/json',
|
||||
],
|
||||
];
|
||||
|
||||
// ── HTTP call ─────────────────────────────────────────────────────────
|
||||
try {
|
||||
$response = Http::withHeaders(['Content-Type' => 'application/json'])
|
||||
->timeout(15)
|
||||
->post($this->apiUrl . '?key=' . $this->apiKey, $requestBody);
|
||||
} catch (\Exception $e) {
|
||||
Log::error('GeminiChatbotService: HTTP exception', ['error' => $e->getMessage()]);
|
||||
throw new \RuntimeException(
|
||||
'⚠️ Could not reach the Gemini API. Check your network or firewall. ('
|
||||
. $e->getMessage() . ')'
|
||||
);
|
||||
}
|
||||
|
||||
// ── HTTP-level error ──────────────────────────────────────────────────
|
||||
if (! $response->successful()) {
|
||||
$status = $response->status();
|
||||
$body = $response->body();
|
||||
|
||||
Log::error('GeminiChatbotService: API HTTP error', [
|
||||
'status' => $status,
|
||||
'body' => $body,
|
||||
]);
|
||||
|
||||
// Parse Google's error message when available
|
||||
$googleMsg = $response->json('error.message') ?? $body;
|
||||
|
||||
throw new \RuntimeException(
|
||||
"⚠️ Gemini API returned HTTP {$status}: {$googleMsg}"
|
||||
);
|
||||
}
|
||||
|
||||
// ── Extract text from response ────────────────────────────────────────
|
||||
$text = $response->json('candidates.0.content.parts.0.text');
|
||||
|
||||
if (empty($text)) {
|
||||
// Check for prompt-blocking
|
||||
$blockReason = $response->json('promptFeedback.blockReason');
|
||||
$finishReason = $response->json('candidates.0.finishReason');
|
||||
|
||||
Log::error('GeminiChatbotService: empty response text', [
|
||||
'blockReason' => $blockReason,
|
||||
'finishReason' => $finishReason,
|
||||
'full' => $response->json(),
|
||||
]);
|
||||
|
||||
$hint = $blockReason
|
||||
? "prompt was blocked (reason: {$blockReason})"
|
||||
: ($finishReason ? "finish reason: {$finishReason}" : 'no text returned');
|
||||
|
||||
throw new \RuntimeException("⚠️ Gemini returned no content — {$hint}");
|
||||
}
|
||||
|
||||
// ── JSON decode ───────────────────────────────────────────────────────
|
||||
$clean = preg_replace('/^```json\s*/i', '', trim($text));
|
||||
$clean = preg_replace('/\s*```$/i', '', $clean);
|
||||
$parsed = json_decode($clean, true);
|
||||
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
Log::error('GeminiChatbotService: JSON decode failed', ['raw' => $text]);
|
||||
throw new \RuntimeException(
|
||||
'⚠️ Gemini returned a non-JSON response: ' . mb_substr($text, 0, 200)
|
||||
);
|
||||
}
|
||||
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 2 (unknown task only) — Ask Gemini to respond conversationally.
|
||||
*
|
||||
* Sends the full chat history + new user message to Gemini as a friendly
|
||||
* factory-operations assistant (no JSON constraint). Gemini can ask for
|
||||
* clarification, answer general questions, or guide the user to one of the
|
||||
* supported tasks.
|
||||
*
|
||||
* @param string|null $hintFromClassification Optional clarification from the
|
||||
* classification step — prepended as assistant context if present.
|
||||
* @return string Plain-text reply from Gemini.
|
||||
*/
|
||||
private function callGeminiConversational(
|
||||
array $history,
|
||||
string $userInput,
|
||||
?string $hintFromClassification = null
|
||||
): ?string {
|
||||
// If the classifier already produced a good clarification question, use it
|
||||
// directly and skip the second API call to save latency + quota.
|
||||
if (! empty($hintFromClassification)) {
|
||||
return $hintFromClassification;
|
||||
}
|
||||
|
||||
$requestBody = [
|
||||
'system_instruction' => [
|
||||
'parts' => [['text' => $this->buildConversationalSystemPrompt()]],
|
||||
],
|
||||
'contents' => $this->buildGeminiContents($history, $userInput),
|
||||
'generationConfig' => [
|
||||
'temperature' => 0.7, // more natural conversational tone
|
||||
'maxOutputTokens' => 400,
|
||||
],
|
||||
];
|
||||
|
||||
try {
|
||||
$response = Http::withHeaders(['Content-Type' => 'application/json'])
|
||||
->timeout(20)
|
||||
->post($this->apiUrl . '?key=' . $this->apiKey, $requestBody);
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('GeminiChatbotService: API error (conversational)', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
return null;
|
||||
}
|
||||
|
||||
return $response->json('candidates.0.content.parts.0.text');
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('GeminiChatbotService: exception (conversational)', ['error' => $e->getMessage()]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Prompt builders
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* System prompt for Phase 1 (classification) — forces JSON output.
|
||||
*/
|
||||
private function buildSystemPrompt(): string
|
||||
{
|
||||
$today = now()->format('Y-m-d');
|
||||
$startOfMonth = now()->startOfMonth()->format('Y-m-d');
|
||||
|
||||
return <<<PROMPT
|
||||
You are a factory operations assistant that classifies plain-English user queries into structured tasks.
|
||||
|
||||
TASKS:
|
||||
1. "invoice_status"
|
||||
The user wants to check how many serial numbers in an invoice have been scanned / not scanned.
|
||||
Required params: invoice_number
|
||||
Examples:
|
||||
- "check invoice 3RA0013333"
|
||||
- "is invoice 3RA0013333 fully scanned?"
|
||||
- "what's the scan status of 3RA0013333"
|
||||
- "show me unscanned serials for invoice ABC123"
|
||||
|
||||
2. "invoice_report"
|
||||
The user wants to know whether an item is a serial invoice or material invoice for a given plant.
|
||||
Required params: item_code, plant_name
|
||||
Examples:
|
||||
- "check item 674071 for plant Vahinie Unit 2"
|
||||
- "is item 500100 a serial or material invoice in Chennai plant?"
|
||||
- "what type is item code 200300 at Vahinie?"
|
||||
|
||||
3. "production_report"
|
||||
The user wants the production count for a plant and optional line over a date range.
|
||||
Required params: plant_name
|
||||
Optional params: line_name, date_from, date_to
|
||||
Default dates: date_from = {$startOfMonth}, date_to = {$today}
|
||||
Interpret relative dates: "this month", "last week", "yesterday", "today", etc.
|
||||
Examples:
|
||||
- "show production for Chennai plant this month"
|
||||
- "how many units were produced in line 1 of Vahinie Unit 2 last week?"
|
||||
- "production report for all plants from 2024-01-01 to 2024-01-31"
|
||||
|
||||
4. "unknown"
|
||||
The query cannot be clearly matched to any of the above tasks.
|
||||
Use this when the user is asking something general, greeting, asking what the bot can do,
|
||||
asking a follow-up question that doesn't map to a task, or if you need more context.
|
||||
In this case, set "clarification" to a friendly, helpful response — it may be a question,
|
||||
a helpful explanation, or guidance toward the supported tasks.
|
||||
|
||||
CONVERSATION CONTEXT:
|
||||
Consider the full conversation history. If the previous assistant message asked a clarifying question
|
||||
(e.g. "Do you mean Invoice Status or Invoice Report?") and the user is now answering that question,
|
||||
classify accordingly based on the combined context.
|
||||
|
||||
OUTPUT FORMAT (return ONLY this JSON, no markdown fences, no extra text):
|
||||
{
|
||||
"task": "invoice_status | invoice_report | production_report | unknown",
|
||||
"params": {
|
||||
"invoice_number": "...",
|
||||
"item_code": "...",
|
||||
"plant_name": "...",
|
||||
"line_name": "...",
|
||||
"date_from": "YYYY-MM-DD",
|
||||
"date_to": "YYYY-MM-DD"
|
||||
},
|
||||
"missing": ["list of required params that were not found in the user input"],
|
||||
"clarification": "Friendly response for unknown tasks or missing-param prompts. Set to null when task is clear and params are complete."
|
||||
}
|
||||
|
||||
RULES:
|
||||
- Only include params relevant to the detected task.
|
||||
- If a required param is missing, add it to "missing" and set a helpful "clarification" asking only for that missing value.
|
||||
- If task is "unknown", always set "missing" to [] and put your full helpful response in "clarification".
|
||||
- When task is clear and all required params are present, set "missing" to [] and "clarification" to null.
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* System prompt for Phase 2 (conversational fallback) — plain-text output.
|
||||
*
|
||||
* Used only when the classifier returns "unknown" AND produced no clarification.
|
||||
*/
|
||||
private function buildConversationalSystemPrompt(): string
|
||||
{
|
||||
return <<<PROMPT
|
||||
You are a helpful factory operations assistant integrated into an internal management panel.
|
||||
|
||||
You can perform these tasks when the user gives you enough information:
|
||||
• Invoice Status — check how many serial numbers in an invoice have been scanned and list any unscanned ones. Requires: invoice number.
|
||||
• Invoice Report — find out whether an item is a serial invoice or material invoice for a given plant. Requires: item code and plant name.
|
||||
• Production Report — get the production count for a plant, optionally filtered by line and date range. Requires: plant name.
|
||||
|
||||
When the user's request does not match any of the above:
|
||||
- Answer general questions helpfully and concisely.
|
||||
- If you need more information to perform a task, ask for only the missing detail.
|
||||
- Guide the user toward one of the supported tasks when relevant.
|
||||
- Keep replies short and conversational (2-4 sentences max).
|
||||
- Do NOT mention JSON, APIs, or technical internals.
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Livewire chat history + new user message into Gemini's contents array.
|
||||
* Gemini uses "model" for the assistant role (not "assistant").
|
||||
*/
|
||||
private function buildGeminiContents(array $history, string $userInput): array
|
||||
{
|
||||
$contents = [];
|
||||
|
||||
// Include the last 8 turns at most to stay within token limits
|
||||
foreach (array_slice($history, -8) as $msg) {
|
||||
$contents[] = [
|
||||
'role' => $msg['role'] === 'user' ? 'user' : 'model',
|
||||
'parts' => [['text' => $msg['content']]],
|
||||
];
|
||||
}
|
||||
|
||||
$contents[] = [
|
||||
'role' => 'user',
|
||||
'parts' => [['text' => $userInput]],
|
||||
];
|
||||
|
||||
return $contents;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Task handlers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Invoice Status — delegate to ChatbotService (regex-based, same as basic mode).
|
||||
*/
|
||||
private function handleInvoiceStatus(array $params): string
|
||||
{
|
||||
$invoiceNumber = trim(preg_replace('/\s+/', '', $params['invoice_number'] ?? ''));
|
||||
|
||||
if (empty($invoiceNumber)) {
|
||||
return 'I need the invoice number to check the scan status. What is the invoice number?';
|
||||
}
|
||||
|
||||
/** @var ChatbotService $svc */
|
||||
$svc = app(ChatbotService::class);
|
||||
|
||||
return $svc->ask("invoice = {$invoiceNumber}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoice Report — resolves plant name with fuzzy matching, then runs the
|
||||
* same CTE query as ChatBot::fetchInvoiceReport() directly.
|
||||
*
|
||||
* We bypass ChatbotService::ask() here so that resolvePlant()'s multi-strategy
|
||||
* fuzzy logic is applied rather than the simpler LIKE inside ChatbotService.
|
||||
*/
|
||||
private function handleInvoiceReport(array $params): string
|
||||
{
|
||||
$itemCode = trim($params['item_code'] ?? '');
|
||||
$plantName = trim($params['plant_name'] ?? '');
|
||||
|
||||
if (empty($itemCode)) {
|
||||
return 'I need the item code to look up the invoice type. What is the item code?';
|
||||
}
|
||||
|
||||
if (empty($plantName)) {
|
||||
return 'I need the plant name to look up the invoice type. Which plant are you asking about?';
|
||||
}
|
||||
|
||||
// ── Fuzzy-resolve the plant name ──────────────────────────────────────
|
||||
$plant = $this->resolvePlant($plantName);
|
||||
|
||||
if ($plant === null) {
|
||||
return "I couldn't find a plant matching \"{$plantName}\". "
|
||||
. 'Please check the plant name and try again.';
|
||||
}
|
||||
|
||||
// ── Run the same CTE as ChatBot::fetchInvoiceReport() ─────────────────
|
||||
try {
|
||||
$rows = DB::select("
|
||||
WITH plant_item AS (
|
||||
SELECT ? AS user_plant,
|
||||
? AS user_item_code
|
||||
),
|
||||
t1 AS (
|
||||
SELECT
|
||||
plants.id AS plant_id,
|
||||
plants.name AS plant_name,
|
||||
ARRAY_AGG(items.code) AS item_codes
|
||||
FROM plants
|
||||
LEFT JOIN items ON plants.id = items.plant_id
|
||||
GROUP BY plants.id, plants.name
|
||||
),
|
||||
t2 AS (
|
||||
SELECT
|
||||
t1.plant_id,
|
||||
t1.plant_name,
|
||||
CASE
|
||||
WHEN plant_item.user_item_code = ANY(t1.item_codes) THEN 1
|
||||
ELSE 0
|
||||
END AS exists_flag
|
||||
FROM t1
|
||||
CROSS JOIN plant_item
|
||||
WHERE t1.plant_name = plant_item.user_plant
|
||||
),
|
||||
t3 AS (
|
||||
SELECT t2.plant_id, t2.plant_name, t2.exists_flag,
|
||||
plant_item.user_item_code
|
||||
FROM t2
|
||||
LEFT JOIN plant_item ON plant_item.user_plant = t2.plant_name
|
||||
),
|
||||
t4 AS (
|
||||
SELECT items.id AS item_id,
|
||||
t3.plant_id, t3.plant_name, t3.exists_flag, t3.user_item_code
|
||||
FROM t3
|
||||
LEFT JOIN items
|
||||
ON t3.plant_id = items.plant_id
|
||||
AND t3.user_item_code = items.code
|
||||
)
|
||||
SELECT
|
||||
t4.item_id,
|
||||
t4.plant_id,
|
||||
t4.plant_name,
|
||||
t4.exists_flag,
|
||||
t4.user_item_code,
|
||||
COALESCE(sticker_masters.material_type, 0) AS material_type,
|
||||
CASE
|
||||
WHEN sticker_masters.item_id IS NULL
|
||||
THEN 'no match found'
|
||||
WHEN COALESCE(sticker_masters.material_type, 0) = 0
|
||||
THEN 'serial invoice'
|
||||
ELSE 'material invoice'
|
||||
END AS invoice_description
|
||||
FROM t4
|
||||
LEFT JOIN sticker_masters
|
||||
ON sticker_masters.plant_id = t4.plant_id
|
||||
AND sticker_masters.item_id = t4.item_id
|
||||
", [$plant->name, $itemCode]);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error('GeminiChatbotService: invoice report query failed', [
|
||||
'plant' => $plant->name,
|
||||
'item_code' => $itemCode,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
return "Sorry, I couldn't fetch data. Please try again or contact support.";
|
||||
}
|
||||
|
||||
if (empty($rows)) {
|
||||
return "No data found for plant \"{$plant->name}\". Please verify the plant name.";
|
||||
}
|
||||
|
||||
$row = $rows[0];
|
||||
|
||||
if ((int) $row->exists_flag === 0) {
|
||||
return 'The provided item code does not exist in the item table.';
|
||||
}
|
||||
|
||||
return match ($row->invoice_description) {
|
||||
'serial invoice' => 'It is a serial invoice item.',
|
||||
'material invoice' => 'It is a material invoice item.',
|
||||
'no match found' => "Item not found in sticker master for plant {$plant->name}.",
|
||||
default => 'Unexpected result. Please contact support.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Production Report — resolves plant/line names to IDs and runs the count query.
|
||||
* Mirrors ChatBot::fetchProduction() but works with plain names instead of IDs,
|
||||
* using resolvePlant() for robust fuzzy matching.
|
||||
*/
|
||||
private function handleProductionReport(array $params): string
|
||||
{
|
||||
$plantName = trim($params['plant_name'] ?? '');
|
||||
$lineName = trim($params['line_name'] ?? '');
|
||||
$dateFrom = $params['date_from'] ?? now()->startOfMonth()->format('Y-m-d');
|
||||
$dateTo = $params['date_to'] ?? now()->format('Y-m-d');
|
||||
|
||||
if (empty($plantName)) {
|
||||
return 'I need a plant name to fetch the production report. Which plant are you asking about?';
|
||||
}
|
||||
|
||||
// ── Fuzzy-resolve the plant name ──────────────────────────────────────
|
||||
$plant = $this->resolvePlant($plantName);
|
||||
|
||||
if ($plant === null) {
|
||||
return "I couldn't find a plant matching \"{$plantName}\". "
|
||||
. 'Please check the plant name and try again.';
|
||||
}
|
||||
|
||||
// ── Base query ────────────────────────────────────────────────────────
|
||||
$query = DB::table('production_quantities')
|
||||
->whereNull('deleted_at')
|
||||
->where('plant_id', $plant->id)
|
||||
->whereDate('created_at', '>=', $dateFrom)
|
||||
->whereDate('created_at', '<=', $dateTo);
|
||||
|
||||
$lineLabel = 'All Lines';
|
||||
|
||||
// ── Optionally filter by line (fuzzy LIKE match) ──────────────────────
|
||||
if (! empty($lineName)) {
|
||||
$line = $this->resolveLine($lineName, $plant->id);
|
||||
|
||||
if ($line === null) {
|
||||
return "I couldn't find a line matching \"{$lineName}\" "
|
||||
. "in plant \"{$plant->name}\". Please check the line name.";
|
||||
}
|
||||
|
||||
$query->where('line_id', $line->id);
|
||||
$lineLabel = $line->name;
|
||||
}
|
||||
|
||||
try {
|
||||
$count = $query->count();
|
||||
} catch (\Exception $e) {
|
||||
Log::error('GeminiChatbotService: production query failed', [
|
||||
'plant' => $plant->name,
|
||||
'line' => $lineLabel,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return "Sorry, I couldn't fetch production data for {$plant->name}. "
|
||||
. 'Please try again or contact support.';
|
||||
}
|
||||
|
||||
$from = \Carbon\Carbon::parse($dateFrom)->format('d M Y');
|
||||
$to = \Carbon\Carbon::parse($dateTo)->format('d M Y');
|
||||
|
||||
return "📊 Production count for {$plant->name} / {$lineLabel} "
|
||||
. "from {$from} to {$to}: {$count} records.";
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Fuzzy name resolvers
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve a user-supplied plant name to the best matching DB row.
|
||||
*
|
||||
* Strategy cascade (stops at first hit):
|
||||
* 1. Exact case-insensitive match → "ransar industries-i" == "Ransar Industries-I"
|
||||
* 2. Normalised LIKE match → strips hyphens/spaces, swaps I↔1
|
||||
* 3. Every significant word present (LIKE) → "ransar unit 2" matches "Ransar Industries Unit 2"
|
||||
* 4. Best token-overlap score → picks the DB row sharing the most words
|
||||
*
|
||||
* @return object|null stdClass with {id, name} or null if no match.
|
||||
*/
|
||||
private function resolvePlant(string $userInput): ?object
|
||||
{
|
||||
$allPlants = DB::table('plants')
|
||||
->whereNull('deleted_at')
|
||||
->get(['id', 'name']);
|
||||
|
||||
$norm = $this->normaliseForMatching($userInput);
|
||||
|
||||
// ── Strategy 1: exact normalised match ────────────────────────────────
|
||||
foreach ($allPlants as $plant) {
|
||||
if ($this->normaliseForMatching($plant->name) === $norm) {
|
||||
return $plant;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Strategy 2: normalised LIKE (user input contained in plant name or vice-versa) ──
|
||||
foreach ($allPlants as $plant) {
|
||||
$dbNorm = $this->normaliseForMatching($plant->name);
|
||||
if (str_contains($dbNorm, $norm) || str_contains($norm, $dbNorm)) {
|
||||
return $plant;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Strategy 3: all significant user words appear in the plant name ───
|
||||
$userTokens = $this->significantTokens($norm);
|
||||
|
||||
if (count($userTokens) >= 1) {
|
||||
foreach ($allPlants as $plant) {
|
||||
$dbNorm = $this->normaliseForMatching($plant->name);
|
||||
$allFound = true;
|
||||
foreach ($userTokens as $token) {
|
||||
if (! str_contains($dbNorm, $token)) {
|
||||
$allFound = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($allFound) {
|
||||
return $plant;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Strategy 4: best token-overlap score ─────────────────────────────
|
||||
$bestPlant = null;
|
||||
$bestScore = 0;
|
||||
|
||||
foreach ($allPlants as $plant) {
|
||||
$dbTokens = $this->significantTokens($this->normaliseForMatching($plant->name));
|
||||
$shared = count(array_intersect($userTokens, $dbTokens));
|
||||
|
||||
// Require at least half the user tokens to match to avoid false positives
|
||||
$threshold = max(1, (int) ceil(count($userTokens) / 2));
|
||||
|
||||
if ($shared >= $threshold && $shared > $bestScore) {
|
||||
$bestScore = $shared;
|
||||
$bestPlant = $plant;
|
||||
}
|
||||
}
|
||||
|
||||
return $bestPlant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a user-supplied line name within a specific plant.
|
||||
* Uses the same normalisation + token strategies as resolvePlant().
|
||||
*
|
||||
* @return object|null stdClass with {id, name} or null if no match.
|
||||
*/
|
||||
private function resolveLine(string $userInput, int $plantId): ?object
|
||||
{
|
||||
$allLines = DB::table('lines')
|
||||
->whereNull('deleted_at')
|
||||
->where('plant_id', $plantId)
|
||||
->get(['id', 'name']);
|
||||
|
||||
$norm = $this->normaliseForMatching($userInput);
|
||||
|
||||
// Strategy 1: exact normalised
|
||||
foreach ($allLines as $line) {
|
||||
if ($this->normaliseForMatching($line->name) === $norm) {
|
||||
return $line;
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: normalised LIKE
|
||||
foreach ($allLines as $line) {
|
||||
$dbNorm = $this->normaliseForMatching($line->name);
|
||||
if (str_contains($dbNorm, $norm) || str_contains($norm, $dbNorm)) {
|
||||
return $line;
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: all user tokens found in line name
|
||||
$userTokens = $this->significantTokens($norm);
|
||||
|
||||
foreach ($allLines as $line) {
|
||||
$dbNorm = $this->normaliseForMatching($line->name);
|
||||
$allFound = true;
|
||||
foreach ($userTokens as $token) {
|
||||
if (! str_contains($dbNorm, $token)) {
|
||||
$allFound = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($allFound) {
|
||||
return $line;
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 4: best token-overlap
|
||||
$bestLine = null;
|
||||
$bestScore = 0;
|
||||
|
||||
foreach ($allLines as $line) {
|
||||
$dbTokens = $this->significantTokens($this->normaliseForMatching($line->name));
|
||||
$shared = count(array_intersect($userTokens, $dbTokens));
|
||||
$threshold = max(1, (int) ceil(count($userTokens) / 2));
|
||||
|
||||
if ($shared >= $threshold && $shared > $bestScore) {
|
||||
$bestScore = $shared;
|
||||
$bestLine = $line;
|
||||
}
|
||||
}
|
||||
|
||||
return $bestLine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a plant/line name for fuzzy comparison:
|
||||
* - lowercase
|
||||
* - replace Roman numeral suffixes I/II/III/IV → 1/2/3/4 (and vice-versa digits → numerals as a canonical form)
|
||||
* - collapse hyphens, underscores, extra spaces into a single space
|
||||
* - strip leading/trailing whitespace
|
||||
*
|
||||
* Both the user input AND the DB value are passed through this before comparing,
|
||||
* so the comparison is always apples-to-apples.
|
||||
*/
|
||||
private function normaliseForMatching(string $value): string
|
||||
{
|
||||
$v = strtolower($value);
|
||||
|
||||
// 1. Punctuation/separators → space
|
||||
$v = str_replace(['-', '_', '.', ','], ' ', $v);
|
||||
|
||||
// 2. Split any letter→digit or digit→letter boundary with a space.
|
||||
// e.g. "industries1" → "industries 1", "unit2" → "unit 2", "2unit" → "2 unit"
|
||||
// This must happen BEFORE Roman numeral conversion so isolated digits are
|
||||
// already separated from words.
|
||||
$v = preg_replace('/([a-z])(\d)/', '$1 $2', $v);
|
||||
$v = preg_replace('/(\d)([a-z])/', '$1 $2', $v);
|
||||
|
||||
// 3. Convert standalone Roman numerals to digits.
|
||||
// Applied AFTER splitting so "industries" is never touched —
|
||||
// the \b boundary ensures only whole tokens are matched.
|
||||
// Order matters: longer patterns first (iii before ii before i).
|
||||
$romanMap = [
|
||||
'/\bviii\b/' => '8',
|
||||
'/\bvii\b/' => '7',
|
||||
'/\bvi\b/' => '6',
|
||||
'/\biv\b/' => '4',
|
||||
'/\biii\b/' => '3',
|
||||
'/\bii\b/' => '2',
|
||||
'/\bv\b/' => '5',
|
||||
'/\bi\b/' => '1', // last — single i only after all others consumed
|
||||
];
|
||||
foreach ($romanMap as $pattern => $digit) {
|
||||
$v = preg_replace($pattern, $digit, $v);
|
||||
}
|
||||
|
||||
// 4. Collapse multiple spaces
|
||||
$v = preg_replace('/\s+/', ' ', $v);
|
||||
|
||||
return trim($v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a normalised string into significant tokens (drops noise words).
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
private function significantTokens(string $normalised): array
|
||||
{
|
||||
$stopWords = ['and', 'the', 'of', 'for', 'at', 'in', 'a'];
|
||||
$tokens = explode(' ', $normalised);
|
||||
|
||||
return array_values(array_filter($tokens, function (string $t) use ($stopWords) {
|
||||
return strlen($t) >= 2 && ! in_array($t, $stopWords, true);
|
||||
}));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Unknown task — let Gemini respond conversationally.
|
||||
*
|
||||
* If the classification step already produced a useful clarification string
|
||||
* (e.g. "Could you clarify — are you asking about scan status or invoice type?"),
|
||||
* we return that directly without a second API call.
|
||||
* Otherwise we hit Gemini again with a conversational system prompt.
|
||||
*/
|
||||
private function handleUnknown(
|
||||
array $chatHistory,
|
||||
string $userInput,
|
||||
?string $clarificationFromClassifier
|
||||
): string {
|
||||
$reply = $this->callGeminiConversational(
|
||||
$chatHistory,
|
||||
$userInput,
|
||||
$clarificationFromClassifier
|
||||
);
|
||||
|
||||
return $reply
|
||||
?? "I'm not sure I understood that. I can help you with:\n\n"
|
||||
. "• Invoice Status — scan progress of an invoice\n"
|
||||
. "• Invoice Report — serial vs material type for an item\n"
|
||||
. "• Production Report — unit count for a plant / line\n\n"
|
||||
. "What would you like to check?";
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,16 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Item;
|
||||
use App\Models\ItemCharacteristic;
|
||||
use App\Models\ProductionQuantity;
|
||||
use App\Models\StickerDetail;
|
||||
use App\Models\StickerMappingMaster;
|
||||
use App\Models\StickerStructureDetail;
|
||||
use App\Models\StickerValidation;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use TCPDF;
|
||||
|
||||
@@ -447,11 +452,10 @@ class StickerPdfService
|
||||
}
|
||||
|
||||
// return $pdf->Output('sticker.pdf', 'S');
|
||||
$pdfContent = $pdf->Output('sticker.pdf', 'S');
|
||||
$pdfContent = $pdf->Output('', 'S'); // 'S' returns string
|
||||
|
||||
return (new Response($pdfContent, 200))
|
||||
->header('Content-Type', 'application/pdf')
|
||||
->header('Content-Disposition', 'inline; filename="sticker.pdf"');
|
||||
// Encode as base64
|
||||
return base64_encode($pdfContent);
|
||||
|
||||
}
|
||||
|
||||
@@ -788,10 +792,11 @@ class StickerPdfService
|
||||
// ->header('Content-Disposition', 'inline; filename="sticker.pdf"');
|
||||
}
|
||||
|
||||
public function generatePdf1(string $stickerId, Collection $dynamicElements, ?ItemCharacteristic $itemCharacteristic, ?string $serialNumber)
|
||||
public function generatePdf1(string $stickerId, Collection $dynamicElements, ?ItemCharacteristic $itemCharacteristic, ?string $serialNumber, $serNo)
|
||||
{
|
||||
|
||||
$dynamicValueMap = [];
|
||||
$itemCode = $itemCharacteristic?->item?->code ?? '';
|
||||
|
||||
foreach ($dynamicElements as $element) {
|
||||
|
||||
@@ -827,42 +832,29 @@ class StickerPdfService
|
||||
|
||||
$pdf = new TCPDF('P', 'mm', [$width, $height], true, 'UTF-8', false);
|
||||
|
||||
// $pdf->SetMargins(
|
||||
// (float) $structure->sticker_lmargin,
|
||||
// (float) $structure->sticker_tmargin,
|
||||
// (float) $structure->sticker_rmargin,
|
||||
// );
|
||||
|
||||
// //$pdf->SetAutoPageBreak(false, (float) $structure->sticker_bmargin);
|
||||
// $pdf->SetAutoPageBreak(false, (float) $structure->sticker_bmargin);
|
||||
|
||||
$pdf->setPrintHeader(false);
|
||||
$pdf->setPrintFooter(false);
|
||||
// $pdf->setCellPaddings(0, 0, 0, 0);
|
||||
// $pdf->setCellMargins(5, 5, 5, 5);
|
||||
|
||||
// Set margins
|
||||
// $pdf->SetMargins(5, 5, 5); // left, top, right
|
||||
|
||||
$pdf->SetMargins(
|
||||
(float) $structure->sticker_lmargin,
|
||||
(float) $structure->sticker_tmargin,
|
||||
(float) $structure->sticker_rmargin,
|
||||
(float) $structure->sticker_bmargin,
|
||||
);
|
||||
$pdf->SetAutoPageBreak(false, 0);
|
||||
|
||||
$pdf->AddPage();
|
||||
|
||||
if (!empty($serialNumber)) {
|
||||
$pdf->SetFont('helvetica', 'B', 10);
|
||||
$pdf->SetTextColor(0, 0, 0);
|
||||
// if (!empty($serialNumber)) {
|
||||
// $pdf->SetFont('helvetica', 'B', 10);
|
||||
// $pdf->SetTextColor(0, 0, 0);
|
||||
|
||||
// HARD-CODED POSITION (mm)
|
||||
$x = 40; // change as needed
|
||||
$y = 60; // change as needed
|
||||
// // HARD-CODED POSITION (mm)
|
||||
// $x = 40; // change as needed
|
||||
// $y = 60; // change as needed
|
||||
|
||||
$pdf->Text($x, $y, (string) $serialNumber);
|
||||
}
|
||||
// $pdf->Text($x, $y, (string) $serialNumber);
|
||||
// }
|
||||
|
||||
$pdf->SetFont('helvetica', 'B', 10);
|
||||
|
||||
@@ -900,15 +892,32 @@ class StickerPdfService
|
||||
break;
|
||||
|
||||
case 'QR':
|
||||
$pdf->write2DBarcode(
|
||||
if (
|
||||
($row->element_type) == 'Dynamic'
|
||||
) {
|
||||
$qrContent = $serNo ?? '';
|
||||
$pdf->write2DBarcode(
|
||||
$qrContent,
|
||||
'QRCODE,H',
|
||||
(float) ($row->qr_x_value ?? 0),
|
||||
(float) ($row->qr_y_value ?? 0),
|
||||
(float) ($row->qr_size ?? 10),
|
||||
(float) ($row->qr_size ?? 10)
|
||||
);
|
||||
break;
|
||||
}
|
||||
else{
|
||||
$pdf->write2DBarcode(
|
||||
$row->qr_value ?? '',
|
||||
'QRCODE,H',
|
||||
(float) ($row->qr_x_value ?? 0),
|
||||
(float) ($row->qr_y_value ?? 0),
|
||||
(float) ($row->qr_size ?? 10),
|
||||
(float) ($row->qr_size ?? 10)
|
||||
);
|
||||
break;
|
||||
'QRCODE,H',
|
||||
(float) ($row->qr_x_value ?? 0),
|
||||
(float) ($row->qr_y_value ?? 0),
|
||||
(float) ($row->qr_size ?? 10),
|
||||
(float) ($row->qr_size ?? 10)
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
case 'Image':
|
||||
|
||||
@@ -1009,22 +1018,123 @@ class StickerPdfService
|
||||
}
|
||||
}
|
||||
|
||||
// return $pdf->Output('sticker.pdf', 'S');
|
||||
$pdfContent = $pdf->Output('sticker1.pdf', 'S');
|
||||
$filename = "sticker_{$stickerId}_" . time() . ".pdf";
|
||||
// $pdfContent = $pdf->Output('', 'S'); // 'S' returns string
|
||||
|
||||
// return (new Response($pdfContent, 200))
|
||||
// ->header('Content-Type', 'application/pdf');
|
||||
// ->header('Content-Disposition', 'inline; filename="sticker.pdf"');
|
||||
return response($pdfContent, 200)
|
||||
->header('Content-Type', 'application/pdf')
|
||||
->header('Content-Disposition', 'inline; filename="'.$filename.'"')
|
||||
->header('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
|
||||
->header('Pragma', 'no-cache')
|
||||
->header('Expires', '0');
|
||||
// // Encode as base64
|
||||
// return base64_encode($pdfContent);
|
||||
|
||||
|
||||
// $pdfContent = $pdf->Output('', 'S');
|
||||
|
||||
// return response($pdfContent)
|
||||
// ->header('Content-Type', 'application/pdf')
|
||||
// ->header('Content-Disposition', 'inline; filename="sticker.pdf"');
|
||||
$pdfContent = $pdf->Output('', 'S'); // 'S' returns the PDF as a string
|
||||
|
||||
// Return the PDF as a response
|
||||
try {
|
||||
$pdfContent = $pdf->Output('', 'S'); // 'S' returns the PDF as a string
|
||||
return response($pdfContent)
|
||||
->header('Content-Type', 'application/pdf')
|
||||
->header('Content-Disposition', 'inline; filename="sticker.pdf"');
|
||||
} catch (\Exception $e) {
|
||||
Log::error('PDF generation failed: '.$e->getMessage());
|
||||
abort(500, 'Failed to generate PDF');
|
||||
}
|
||||
}
|
||||
|
||||
public function generatePdfBySerial($item, $serNo, $plantId, $refNumber)
|
||||
{
|
||||
$recFound = ProductionQuantity::where('plant_id', $plantId)
|
||||
->where('production_order', $refNumber)
|
||||
->where('serial_number', $serNo)
|
||||
->first();
|
||||
|
||||
if (!$recFound) {
|
||||
abort(404, 'Serial not found');
|
||||
}
|
||||
|
||||
$duplicate = StickerValidation::where('plant_id', $plantId)
|
||||
->where('production_order', $refNumber)
|
||||
->where('serial_number', $serNo)
|
||||
->first();
|
||||
|
||||
$itemC = Item::where('code', $item)
|
||||
->where('plant_id', $plantId)
|
||||
->first();
|
||||
|
||||
if (!$itemC) {
|
||||
abort(404, 'Item not found');
|
||||
}
|
||||
|
||||
$item = ItemCharacteristic::where('item_id', $itemC->id)
|
||||
->where('plant_id', $plantId)
|
||||
->first();
|
||||
|
||||
if (!$item) {
|
||||
abort(404, 'Item characteristic not found');
|
||||
}
|
||||
|
||||
$mapping = StickerMappingMaster::where('plant_id', $plantId)
|
||||
->where('item_characteristic_id', $item->id)
|
||||
->first();
|
||||
|
||||
if (!$mapping) {
|
||||
abort(404, 'Sticker mapping not found');
|
||||
}
|
||||
|
||||
$structure = StickerStructureDetail::findOrFail($mapping->sticker_structure1_id);
|
||||
|
||||
$dynamicElements = StickerDetail::where(
|
||||
'sticker_structure_detail_id',
|
||||
$structure->id
|
||||
)->where('element_type', 'Dynamic')->get();
|
||||
|
||||
return $this->generatePdf1(
|
||||
$structure->sticker_id,
|
||||
$dynamicElements,
|
||||
$item,
|
||||
$serNo,
|
||||
$serNo
|
||||
);
|
||||
|
||||
// return response($pdf)
|
||||
// ->header('Content-Type', 'application/pdf')
|
||||
// ->header('Content-Disposition', 'inline; filename="sticker.pdf"');
|
||||
}
|
||||
|
||||
// public function printStickersToUSB(array $stickers, int $plantId, ?string $serialNumber)
|
||||
// {
|
||||
// $printerPort = 'USB001';
|
||||
|
||||
// foreach ($stickers as $sticker) {
|
||||
|
||||
// $dynamicElements = StickerDetail::where('sticker_structure_detail_id', $sticker['sticker_id'])
|
||||
// ->get();
|
||||
|
||||
// $itemCharacteristic = ItemCharacteristic::find(
|
||||
// $sticker['item_characteristic']
|
||||
// );
|
||||
|
||||
// $pdfContent = $this->generatePdf1(
|
||||
// $sticker['sticker_id'],
|
||||
// $dynamicElements,
|
||||
// $itemCharacteristic,
|
||||
// $serialNumber
|
||||
// );
|
||||
|
||||
// $handle = fopen("{$printerPort}:", "wb");
|
||||
|
||||
// if (! $handle) {
|
||||
// throw new \Exception("Cannot open printer port {$printerPort}");
|
||||
// }
|
||||
|
||||
// fwrite($handle, $pdfContent);
|
||||
// fclose($handle);
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
// private function hexToRgb($hex)
|
||||
// {
|
||||
// $hex = ltrim($hex, '#');
|
||||
@@ -1034,6 +1144,7 @@ class StickerPdfService
|
||||
// hexdec(substr($hex, 4, 2)),
|
||||
// ];
|
||||
// }
|
||||
|
||||
private function hexToRgb($hex)
|
||||
{
|
||||
if (! is_string($hex) || ($hex = trim($hex)) === '') {
|
||||
|
||||
Reference in New Issue
Block a user