chore: initial commit of existing codebase
This commit is contained in:
commit
11e94889b2
292 files changed
+38586
No files matched your search
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Item;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
class ItemImportService
|
||||
{
|
||||
/** Section-header text → category value */
|
||||
private array $sectionMap = [
|
||||
'material inventory for sale' => 'finished_good',
|
||||
'chemical materials' => 'raw_material',
|
||||
'natural pigments' => 'raw_material',
|
||||
'raw materials' => 'raw_material',
|
||||
'forkoll bags' => 'raw_material',
|
||||
'others' => 'raw_material',
|
||||
];
|
||||
|
||||
/** Rows whose column-A value should be skipped entirely */
|
||||
private array $skipWords = ['sub total', 'subtotal', 'total', 'si. no', 'si no', 'sl. no', 'sl no', 's.no'];
|
||||
|
||||
/** Col-B values that indicate a header row, not data */
|
||||
private array $headerWords = ['description', 'item name', 'item_name', 'material'];
|
||||
|
||||
public function import(string $filePath): array
|
||||
{
|
||||
$spreadsheet = IOFactory::load($filePath);
|
||||
$format = $this->detectFormat($spreadsheet);
|
||||
|
||||
$rows = $format === 'forkoll'
|
||||
? $this->extractFromForkoll($spreadsheet)
|
||||
: $this->extractFromTemplate($spreadsheet);
|
||||
|
||||
$imported = 0;
|
||||
$skipped = 0;
|
||||
$codeSeq = (Item::max('id') ?? 0) + 1;
|
||||
|
||||
foreach ($rows as $data) {
|
||||
$name = trim($data['item_name'] ?? '');
|
||||
if (empty($name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$exists = Item::whereRaw('LOWER(item_name) = ?', [strtolower($name)])->exists();
|
||||
|
||||
if ($exists) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$active = !str_contains(strtolower($name), 'not in use');
|
||||
$itemCode = $data['item_code'] ?? ('ITEM-' . str_pad($codeSeq, 5, '0', STR_PAD_LEFT));
|
||||
$codeSeq++;
|
||||
|
||||
Item::create([
|
||||
'item_code' => $itemCode,
|
||||
'item_name' => $name,
|
||||
'category' => $data['category'] ?? 'finished_good',
|
||||
'unit_of_measure' => $data['unit_of_measure'] ?? 'EA',
|
||||
'cost_price' => $data['cost_price'] ?? 0,
|
||||
'minimum_stock_level' => $data['minimum_stock_level'] ?? 0,
|
||||
'description' => $data['description'] ?? null,
|
||||
'is_active' => $data['is_active'] ?? $active,
|
||||
]);
|
||||
|
||||
$imported++;
|
||||
}
|
||||
|
||||
return ['imported' => $imported, 'skipped' => $skipped, 'format' => $format];
|
||||
}
|
||||
|
||||
private function detectFormat(\PhpOffice\PhpSpreadsheet\Spreadsheet $spreadsheet): string
|
||||
{
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
// Forkoll sheets have "MATERIAL INVENTORY FOR SALE" somewhere in col A around row 3
|
||||
for ($r = 1; $r <= 5; $r++) {
|
||||
$val = strtolower(trim((string) $sheet->getCell('A' . $r)->getValue()));
|
||||
if (str_contains($val, 'material inventory for sale') || str_contains($val, 'forkoll')) {
|
||||
return 'forkoll';
|
||||
}
|
||||
}
|
||||
return 'template';
|
||||
}
|
||||
|
||||
private function extractFromForkoll(\PhpOffice\PhpSpreadsheet\Spreadsheet $spreadsheet): array
|
||||
{
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$maxRow = $sheet->getHighestRow();
|
||||
$items = [];
|
||||
$category = 'finished_good';
|
||||
|
||||
for ($r = 1; $r <= $maxRow; $r++) {
|
||||
$colA = trim((string) $sheet->getCell('A' . $r)->getValue());
|
||||
$colB = trim((string) $sheet->getCell('B' . $r)->getValue());
|
||||
$colC = trim((string) $sheet->getCell('C' . $r)->getValue());
|
||||
$colD = trim((string) $sheet->getCell('D' . $r)->getValue());
|
||||
|
||||
// Detect section header (text in col A, empty col B)
|
||||
if ($colA !== '' && $colB === '') {
|
||||
$lower = strtolower($colA);
|
||||
|
||||
// Skip SUB TOTAL rows
|
||||
foreach ($this->skipWords as $skip) {
|
||||
if (str_contains($lower, $skip)) {
|
||||
continue 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Update current category from section map
|
||||
foreach ($this->sectionMap as $keyword => $cat) {
|
||||
if (str_contains($lower, $keyword)) {
|
||||
$category = $cat;
|
||||
break;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip rows with no description or a formula
|
||||
if ($colB === '' || str_starts_with($colB, '=')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip header rows (col B says "DESCRIPTION", "Item Name", etc.)
|
||||
if (in_array(strtolower($colB), $this->headerWords)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$price = is_numeric($colD) ? (float) $colD : 0.0;
|
||||
|
||||
$items[] = [
|
||||
'item_name' => $colB,
|
||||
'unit_of_measure' => $colC ?: 'EA',
|
||||
'category' => $category,
|
||||
'cost_price' => $price,
|
||||
'is_active' => !str_contains(strtolower($colB), 'not in use'),
|
||||
];
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
private function extractFromTemplate(\PhpOffice\PhpSpreadsheet\Spreadsheet $spreadsheet): array
|
||||
{
|
||||
$rows = $spreadsheet->getActiveSheet()->toArray(null, true, true, false);
|
||||
$headers = array_map(
|
||||
fn($h) => strtolower(trim(str_replace('*', '', (string) $h))),
|
||||
$rows[0] ?? []
|
||||
);
|
||||
|
||||
$fields = ['item_name', 'unit_of_measure', 'category', 'cost_price', 'minimum_stock_level', 'description', 'is_active'];
|
||||
$map = [];
|
||||
|
||||
foreach ($fields as $field) {
|
||||
$idx = array_search($field, $headers);
|
||||
if ($idx !== false) {
|
||||
$map[$field] = $idx;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($map['item_name'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$items = [];
|
||||
foreach (array_slice($rows, 1) as $row) {
|
||||
$name = trim((string) ($row[$map['item_name']] ?? ''));
|
||||
if (empty($name) || str_starts_with($name, '*')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entry = ['item_name' => $name];
|
||||
foreach (array_diff($fields, ['item_name']) as $field) {
|
||||
if (isset($map[$field])) {
|
||||
$val = trim((string) ($row[$map[$field]] ?? ''));
|
||||
$entry[$field] = $val !== '' ? $val : null;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($entry['cost_price'])) {
|
||||
$entry['cost_price'] = is_numeric($entry['cost_price']) ? (float) $entry['cost_price'] : 0;
|
||||
}
|
||||
if (isset($entry['minimum_stock_level'])) {
|
||||
$entry['minimum_stock_level'] = is_numeric($entry['minimum_stock_level']) ? (float) $entry['minimum_stock_level'] : 0;
|
||||
}
|
||||
if (isset($entry['is_active'])) {
|
||||
$entry['is_active'] = in_array(strtolower($entry['is_active'] ?? ''), ['yes', 'true', '1', 'active', 'y']);
|
||||
}
|
||||
|
||||
$items[] = $entry;
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\PurchaseRequest;
|
||||
|
||||
class PurchaseStageService
|
||||
{
|
||||
const STAGES = [
|
||||
'draft', 'gm_approval', 'rfq', 'quoting',
|
||||
'comparison', 'lpo', 'receiving', 'payment', 'complete',
|
||||
];
|
||||
|
||||
public function advance(PurchaseRequest $request): void
|
||||
{
|
||||
$current = array_search($request->stage, self::STAGES);
|
||||
if ($current === false || $current === count(self::STAGES) - 1) {
|
||||
return;
|
||||
}
|
||||
$request->update(['stage' => self::STAGES[$current + 1]]);
|
||||
}
|
||||
|
||||
public function setStage(PurchaseRequest $request, string $stage): void
|
||||
{
|
||||
abort_unless(in_array($stage, self::STAGES), 422, 'Invalid stage');
|
||||
$request->update(['stage' => $stage]);
|
||||
}
|
||||
|
||||
public function stageIndex(string $stage): int
|
||||
{
|
||||
$idx = array_search($stage, self::STAGES);
|
||||
return $idx === false ? 0 : $idx;
|
||||
}
|
||||
|
||||
public function stageLabel(string $stage): string
|
||||
{
|
||||
return match ($stage) {
|
||||
'draft' => 'Purchase Request',
|
||||
'gm_approval' => 'GM Signature',
|
||||
'rfq' => 'Select Suppliers',
|
||||
'quoting' => 'Awaiting Quotes',
|
||||
'comparison' => 'Quote Comparison',
|
||||
'lpo' => 'LPO Issued',
|
||||
'receiving' => 'Receiving Materials',
|
||||
'payment' => 'Payment',
|
||||
'complete' => 'Complete',
|
||||
default => ucfirst($stage),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Mail\RfqInvitationMail;
|
||||
use App\Models\PurchaseRequest;
|
||||
use App\Models\RfqInvitation;
|
||||
use App\Models\Supplier;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
class RfqInvitationService
|
||||
{
|
||||
public function select(PurchaseRequest $purchaseRequest, Supplier $supplier, string $channel, array $itemIds = []): RfqInvitation
|
||||
{
|
||||
return RfqInvitation::create([
|
||||
'purchase_request_id' => $purchaseRequest->id,
|
||||
'supplier_id' => $supplier->id,
|
||||
'token' => bin2hex(random_bytes(32)),
|
||||
'channel' => $channel,
|
||||
'expires_at' => now()->addDays(14),
|
||||
'status' => 'pending',
|
||||
'item_ids' => empty($itemIds) ? null : $itemIds,
|
||||
]);
|
||||
}
|
||||
|
||||
public function sendInvitation(RfqInvitation $invitation): void
|
||||
{
|
||||
$invitation->update(['status' => 'sent', 'sent_at' => now()]);
|
||||
|
||||
$supplier = $invitation->supplier;
|
||||
if (in_array($invitation->channel, ['email', 'both']) && $supplier->email) {
|
||||
try {
|
||||
Mail::to($supplier->email)->send(new RfqInvitationMail($invitation));
|
||||
} catch (\Exception $e) {
|
||||
// Mail failure should not block flow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function invite(PurchaseRequest $purchaseRequest, Supplier $supplier, string $channel): RfqInvitation
|
||||
{
|
||||
$invitation = $this->select($purchaseRequest, $supplier, $channel);
|
||||
$this->sendInvitation($invitation);
|
||||
return $invitation;
|
||||
}
|
||||
|
||||
public function whatsappLink(RfqInvitation $invitation): string
|
||||
{
|
||||
$url = route('rfq.show', $invitation->token);
|
||||
$text = "Hello {$invitation->supplier->name},\n\n"
|
||||
. "You are invited to submit a quote for purchase request {$invitation->purchaseRequest->request_number}.\n\n"
|
||||
. "Please click the link below to submit your quote:\n{$url}\n\n"
|
||||
. "This link expires in 7 days and can only be used once.";
|
||||
$phone = preg_replace('/\D/', '', $invitation->supplier->phone ?? '');
|
||||
return 'https://wa.me/' . $phone . '?text=' . rawurlencode($text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Supplier;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
|
||||
class SupplierImportService
|
||||
{
|
||||
private array $mrfStopWords = ['comments if any', 'lowest price', 'avg price', 'recommendation'];
|
||||
|
||||
public function import(string $filePath): array
|
||||
{
|
||||
$spreadsheet = IOFactory::load($filePath);
|
||||
$format = $this->detectFormat($spreadsheet);
|
||||
|
||||
$rows = $format === 'mrf'
|
||||
? $this->extractFromMrf($spreadsheet)
|
||||
: $this->extractFromTemplate($spreadsheet);
|
||||
|
||||
$imported = 0;
|
||||
$updated = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($rows as $data) {
|
||||
$name = trim($data['name'] ?? '');
|
||||
if (empty($name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$existing = Supplier::whereRaw('LOWER(name) = ?', [strtolower($name)])->first();
|
||||
|
||||
if ($existing) {
|
||||
// Update the existing record with any new/missing fields
|
||||
$existing->update($this->buildAttributes($data, true));
|
||||
$updated++;
|
||||
} else {
|
||||
Supplier::create($this->buildAttributes($data, false));
|
||||
$imported++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'imported' => $imported,
|
||||
'updated' => $updated,
|
||||
'skipped' => $skipped,
|
||||
'format' => $format,
|
||||
];
|
||||
}
|
||||
|
||||
private function buildAttributes(array $data, bool $isUpdate): array
|
||||
{
|
||||
$attrs = [
|
||||
'contact_person' => $this->str($data['contact_person'] ?? ''),
|
||||
'email' => $this->str($data['email'] ?? ''),
|
||||
'secondary_email' => $this->str($data['secondary_email'] ?? ''),
|
||||
'phone' => $this->normalizePhone($data['phone'] ?? ''),
|
||||
'phone2' => $this->normalizePhone($data['phone2'] ?? ''),
|
||||
'whatsapp' => $this->normalizePhone($data['whatsapp'] ?? ''),
|
||||
'address' => $this->str($data['address'] ?? ''),
|
||||
'website' => $this->str($data['website'] ?? ''),
|
||||
'tax_number' => $this->str($data['tax_number'] ?? ''),
|
||||
'credit_terms' => $this->str($data['credit_terms'] ?? ''),
|
||||
'credit_days' => $this->parseCreditDays($data['credit_days'] ?? ''),
|
||||
'remarks' => $this->str($data['remarks'] ?? ''),
|
||||
'is_active' => $this->parseBoolean($data['is_active'] ?? 'yes'),
|
||||
];
|
||||
|
||||
if (!$isUpdate) {
|
||||
$attrs['name'] = trim($data['name']);
|
||||
$attrs['supplier_code'] = $this->str($data['supplier_code'] ?? '');
|
||||
$attrs['category'] = $this->str($data['category'] ?? '');
|
||||
} else {
|
||||
// On update: only fill category/code if not already set
|
||||
if (!empty($data['supplier_code'])) {
|
||||
$attrs['supplier_code'] = $this->str($data['supplier_code']);
|
||||
}
|
||||
if (!empty($data['category'])) {
|
||||
$attrs['category'] = $this->str($data['category']);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove null-equivalent values so we don't overwrite real data with blanks
|
||||
return array_filter($attrs, fn($v) => $v !== null && $v !== '');
|
||||
}
|
||||
|
||||
// ── Format detection ────────────────────────────────────────────────────
|
||||
|
||||
private function detectFormat(\PhpOffice\PhpSpreadsheet\Spreadsheet $spreadsheet): string
|
||||
{
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$a4 = strtolower(trim((string) $sheet->getCell('A4')->getValue()));
|
||||
return $a4 === 's.no' ? 'mrf' : 'template';
|
||||
}
|
||||
|
||||
// ── MRF extraction ──────────────────────────────────────────────────────
|
||||
|
||||
private function extractFromMrf(\PhpOffice\PhpSpreadsheet\Spreadsheet $spreadsheet): array
|
||||
{
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$suppliers = [];
|
||||
|
||||
for ($col = 7; $col <= 50; $col++) {
|
||||
$coord = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($col) . '4';
|
||||
$value = trim((string) $sheet->getCell($coord)->getValue());
|
||||
|
||||
if (empty($value) || in_array(strtolower($value), $this->mrfStopWords)) {
|
||||
break;
|
||||
}
|
||||
|
||||
$suppliers[] = ['name' => $value];
|
||||
}
|
||||
|
||||
return $suppliers;
|
||||
}
|
||||
|
||||
// ── Template / Unified format extraction ────────────────────────────────
|
||||
|
||||
private function extractFromTemplate(\PhpOffice\PhpSpreadsheet\Spreadsheet $spreadsheet): array
|
||||
{
|
||||
$rows = $spreadsheet->getActiveSheet()->toArray(null, true, true, false);
|
||||
$headers = array_map(
|
||||
fn($h) => strtolower(trim(str_replace(['*', '_'], [' ', ' '], (string) $h))),
|
||||
$rows[0] ?? []
|
||||
);
|
||||
|
||||
// Map normalised header text → internal field name
|
||||
$aliases = [
|
||||
'supplier id' => 'supplier_code',
|
||||
'name' => 'name',
|
||||
'company name' => 'name',
|
||||
'category' => 'category',
|
||||
'contact person' => 'contact_person',
|
||||
'primary email' => 'email',
|
||||
'email' => 'email',
|
||||
'secondary email' => 'secondary_email',
|
||||
'phone 1' => 'phone',
|
||||
'phone' => 'phone',
|
||||
'phone 2' => 'phone2',
|
||||
'whatsapp number' => 'whatsapp',
|
||||
'whatsapp' => 'whatsapp',
|
||||
'address' => 'address',
|
||||
'website' => 'website',
|
||||
'credit (y/n)' => 'credit_terms',
|
||||
'credit days' => 'credit_days',
|
||||
'tax number' => 'tax_number',
|
||||
'is active' => 'is_active',
|
||||
'remarks / key details' => 'remarks',
|
||||
'remarks' => 'remarks',
|
||||
];
|
||||
|
||||
$map = [];
|
||||
foreach ($headers as $idx => $header) {
|
||||
if (isset($aliases[$header]) && !isset($map[$aliases[$header]])) {
|
||||
$map[$aliases[$header]] = $idx;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($map['name'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$suppliers = [];
|
||||
foreach (array_slice($rows, 1) as $row) {
|
||||
$name = trim((string) ($row[$map['name']] ?? ''));
|
||||
|
||||
if (empty($name) || str_starts_with($name, '*')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$this->looksLikeSupplierName($name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$entry = ['name' => $name];
|
||||
foreach (array_keys($aliases) as $alias) {
|
||||
$field = $aliases[$alias];
|
||||
if ($field === 'name') continue;
|
||||
if (isset($map[$field]) && !isset($entry[$field])) {
|
||||
$entry[$field] = trim((string) ($row[$map[$field]] ?? ''));
|
||||
}
|
||||
}
|
||||
|
||||
$suppliers[] = $entry;
|
||||
}
|
||||
|
||||
return $suppliers;
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Reject rows that are specification text, not company names.
|
||||
* Patterns: starts with digit, measurement strings, known junk phrases.
|
||||
*/
|
||||
private function looksLikeSupplierName(string $name): bool
|
||||
{
|
||||
// Starts with a digit → quantity/spec row
|
||||
if (preg_match('/^\d/', $name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$lower = strtolower($name);
|
||||
|
||||
$junkPhrases = [
|
||||
'gdcd minimum', 'approx. quantity', 'approx quantity',
|
||||
'per 200 m', 'per 30 m', 'coverage mandatory',
|
||||
'hydrant within', 'smoke extraction', 'mandatory for',
|
||||
'required for buildings', '–', '—',
|
||||
];
|
||||
|
||||
foreach ($junkPhrases as $phrase) {
|
||||
if (str_contains($lower, $phrase)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise phone numbers to +COUNTRYCODELOCAL (digits only after +).
|
||||
* Examples: "+973 3318 8311" → "+97333188311"
|
||||
* "39209304" (8-digit Bahrain local) → "+97339209304"
|
||||
*/
|
||||
public function normalizePhone(string $raw): string
|
||||
{
|
||||
$raw = trim($raw);
|
||||
if ($raw === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Strip parenthetical extensions: (ext 118), (ext 201, 202)
|
||||
$val = preg_replace('/\(\s*ext[^)]*\)/i', '', $raw);
|
||||
|
||||
// Strip trailing extensions: " Ext. 7438", "x 26", "- 26" (1-4 digits at end after dash/space)
|
||||
$val = preg_replace('/\s+(?:ext\.?|x)\s*[\d,\s]+$/i', '', $val);
|
||||
$val = preg_replace('/\s*[-–]\s*\d{1,4}\s*$/', '', $val);
|
||||
|
||||
// Extract only digits
|
||||
$digits = preg_replace('/[^\d]/', '', $val);
|
||||
|
||||
if ($digits === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 8-digit Bahrain local number → prepend 973
|
||||
if (strlen($digits) === 8) {
|
||||
return '+973' . $digits;
|
||||
}
|
||||
|
||||
// Already has country code as prefix
|
||||
return '+' . $digits;
|
||||
}
|
||||
|
||||
private function parseCreditDays(string $val): ?int
|
||||
{
|
||||
$digits = preg_replace('/[^\d]/', '', $val);
|
||||
return $digits !== '' ? (int) $digits : null;
|
||||
}
|
||||
|
||||
private function parseBoolean(string $value): bool
|
||||
{
|
||||
return in_array(strtolower(trim($value)), ['yes', 'true', '1', 'active', 'y']);
|
||||
}
|
||||
|
||||
private function str(string $val): ?string
|
||||
{
|
||||
$v = trim($val);
|
||||
return $v !== '' ? $v : null;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user