3911012c65
Broad sweep across the whole codebase:
- Blade views (39 files, 315 wraps): tag-text and title/placeholder/alt
attributes wrapped with {{ __() }}. Excludes scripts, styles, @php,
@verbatim, {{ }}, {!! !!}, comments to avoid touching interpolations.
- PHP (54 files, 179 wraps): array 'key' => 'RO value' patterns and
list items with diacritics wrapped with __(). Reverted __() inside
const arrays (PHP disallows non-constant expressions).
- Added 269 new keys to lang/{ru,en}.json (identity fallback for
unknowns → 161 human RU + 163 EN translations added for the most
common enums, stages, roles, statuses, payment methods, vehicle
categories, warehouse, portal, form actions.
Missing translations fall back to RO so the UI never breaks. All 306
tests pass; view cache compiles cleanly.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
232 lines
9.0 KiB
PHP
232 lines
9.0 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Tenant\Part;
|
|
use App\Models\Tenant\Purchase;
|
|
use App\Models\Tenant\PurchaseItem;
|
|
use App\Models\Tenant\Supplier;
|
|
use App\Models\Tenant\SupplierInvoiceMapping;
|
|
use Illuminate\Support\Facades\DB;
|
|
use PhpOffice\PhpSpreadsheet\IOFactory;
|
|
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
|
|
|
/**
|
|
* Parses XLSX / CSV files coming from suppliers and turns them into
|
|
* Purchase drafts. Mapping (column letters → field) is stored per supplier
|
|
* so the second import becomes instant.
|
|
*
|
|
* Three steps:
|
|
* 1. headersPreview($path) — first 5 rows + detected column letters
|
|
* 2. preview($path, $mapping) — parse all rows + classify each as
|
|
* found (article matches part) / new
|
|
* (article exists in file but not in DB) /
|
|
* no_article (line has data but no article col)
|
|
* 3. import($supplier, $rows) — create Purchase + PurchaseItems; auto-creates
|
|
* Parts for "new" rows when create_new = true
|
|
*/
|
|
class ExcelInvoiceImportService
|
|
{
|
|
/** Read first $maxRows of a spreadsheet for the wizard preview. */
|
|
public function headersPreview(string $absPath, int $maxRows = 5): array
|
|
{
|
|
$sheet = $this->loadSheet($absPath);
|
|
$highestColumn = $sheet->getHighestColumn();
|
|
$highestColIndex = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::columnIndexFromString($highestColumn);
|
|
$cols = [];
|
|
for ($i = 1; $i <= min($highestColIndex, 20); $i++) {
|
|
$cols[] = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($i);
|
|
}
|
|
$rows = [];
|
|
for ($r = 1; $r <= $maxRows; $r++) {
|
|
$row = [];
|
|
foreach ($cols as $col) {
|
|
$row[$col] = (string) ($sheet->getCell("$col$r")->getValue() ?? '');
|
|
}
|
|
$rows[] = $row;
|
|
}
|
|
return ['columns' => $cols, 'rows' => $rows];
|
|
}
|
|
|
|
/**
|
|
* Parse the file according to $mapping and classify each line.
|
|
*
|
|
* Mapping keys:
|
|
* article_col, name_col, qty_col, price_col, brand_col (optional),
|
|
* header_row (1-based row number to skip), sheet_name (optional).
|
|
*/
|
|
public function preview(string $absPath, array $mapping): array
|
|
{
|
|
$sheet = $this->loadSheet($absPath, $mapping['sheet_name'] ?? null);
|
|
$headerRow = (int) ($mapping['header_row'] ?? 1);
|
|
$highestRow = $sheet->getHighestRow();
|
|
|
|
$rows = [];
|
|
$articlesInFile = [];
|
|
|
|
for ($r = $headerRow + 1; $r <= $highestRow; $r++) {
|
|
$article = $this->cellString($sheet, $mapping['article_col'] ?? null, $r);
|
|
$name = $this->cellString($sheet, $mapping['name_col'] ?? null, $r);
|
|
$brand = $this->cellString($sheet, $mapping['brand_col'] ?? null, $r);
|
|
$qty = $this->cellDecimal($sheet, $mapping['qty_col'] ?? null, $r);
|
|
$price = $this->cellDecimal($sheet, $mapping['price_col'] ?? null, $r);
|
|
|
|
// Skip totally empty lines
|
|
if ($article === '' && $name === '' && $qty <= 0) continue;
|
|
|
|
$status = 'no_article';
|
|
$partId = null;
|
|
if ($article !== '') {
|
|
$articlesInFile[] = $article;
|
|
}
|
|
|
|
$rows[] = [
|
|
'row' => $r,
|
|
'article' => $article,
|
|
'name' => $name,
|
|
'brand' => $brand,
|
|
'qty' => $qty,
|
|
'price' => $price,
|
|
'status' => $status,
|
|
'part_id' => $partId,
|
|
];
|
|
}
|
|
|
|
// Single batch query for all articles → status
|
|
if (! empty($articlesInFile)) {
|
|
$existing = Part::whereIn('article', array_unique($articlesInFile))->get()->keyBy('article');
|
|
foreach ($rows as &$row) {
|
|
if ($row['article'] === '') continue;
|
|
if ($existing->has($row['article'])) {
|
|
$row['status'] = 'found';
|
|
$row['part_id'] = $existing[$row['article']]->id;
|
|
} else {
|
|
$row['status'] = 'new';
|
|
}
|
|
}
|
|
unset($row);
|
|
}
|
|
|
|
return [
|
|
'rows' => $rows,
|
|
'summary' => [
|
|
'total' => count($rows),
|
|
'found' => count(array_filter($rows, fn ($r) => $r['status'] === 'found')),
|
|
'new' => count(array_filter($rows, fn ($r) => $r['status'] === 'new')),
|
|
'no_article' => count(array_filter($rows, fn ($r) => $r['status'] === 'no_article')),
|
|
],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Create a draft Purchase + items from the rows. Rows with status='new'
|
|
* automatically get a Part created if $createNew is true.
|
|
*/
|
|
public function import(Supplier $supplier, array $rows, bool $createNew = true): Purchase
|
|
{
|
|
return DB::transaction(function () use ($supplier, $rows, $createNew) {
|
|
$purchase = Purchase::create([
|
|
'supplier_id' => $supplier->id,
|
|
'number' => $this->generatePurchaseNumber($supplier->company_id),
|
|
'order_date' => today(),
|
|
'status' => 'ordered',
|
|
'total' => 0,
|
|
'notes' => __('Auto-import Excel/CSV'),
|
|
]);
|
|
$total = 0;
|
|
foreach ($rows as $row) {
|
|
$partId = $row['part_id'] ?? null;
|
|
if (! $partId && $row['status'] === 'new' && $createNew && $row['article'] !== '') {
|
|
$part = Part::create([
|
|
'name' => $row['name'] ?: $row['article'],
|
|
'article' => $row['article'],
|
|
'brand' => $row['brand'] ?? null,
|
|
'buy_price' => $row['price'],
|
|
'preferred_supplier_id' => $supplier->id,
|
|
]);
|
|
$partId = $part->id;
|
|
}
|
|
PurchaseItem::create([
|
|
'purchase_id' => $purchase->id,
|
|
'part_id' => $partId,
|
|
'name' => $row['name'] ?: $row['article'],
|
|
'article' => $row['article'],
|
|
'qty' => $row['qty'] ?: 1,
|
|
'qty_received' => 0,
|
|
'buy_price' => $row['price'],
|
|
'total' => round(($row['qty'] ?: 1) * $row['price'], 2),
|
|
]);
|
|
$total += ($row['qty'] ?: 1) * $row['price'];
|
|
}
|
|
$purchase->update(['total' => round($total, 2)]);
|
|
return $purchase;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Save mapping config for a supplier so subsequent imports skip the wizard.
|
|
*/
|
|
public function rememberMapping(Supplier $supplier, array $mapping, ?string $sampleFileName = null): SupplierInvoiceMapping
|
|
{
|
|
return SupplierInvoiceMapping::updateOrCreate(
|
|
['supplier_id' => $supplier->id],
|
|
[
|
|
'mapping_config' => $mapping,
|
|
'sample_file_name' => $sampleFileName,
|
|
'last_used_at' => now(),
|
|
]
|
|
);
|
|
}
|
|
|
|
public function rememberedMappingFor(Supplier $supplier): ?array
|
|
{
|
|
$m = SupplierInvoiceMapping::where('supplier_id', $supplier->id)->first();
|
|
return $m?->mapping_config;
|
|
}
|
|
|
|
private function loadSheet(string $absPath, ?string $sheetName = null)
|
|
{
|
|
$reader = IOFactory::createReaderForFile($absPath);
|
|
$reader->setReadDataOnly(true);
|
|
$spreadsheet = $reader->load($absPath);
|
|
return $sheetName ? $spreadsheet->getSheetByName($sheetName) ?? $spreadsheet->getActiveSheet() : $spreadsheet->getActiveSheet();
|
|
}
|
|
|
|
private function cellString($sheet, ?string $col, int $row): string
|
|
{
|
|
if (! $col) return '';
|
|
$value = $sheet->getCell("$col$row")->getValue();
|
|
return trim((string) ($value ?? ''));
|
|
}
|
|
|
|
private function cellDecimal($sheet, ?string $col, int $row): float
|
|
{
|
|
if (! $col) return 0.0;
|
|
$value = $sheet->getCell("$col$row")->getValue();
|
|
if ($value === null || $value === '') return 0.0;
|
|
// Normalize "1 234,56" / "1,234.56" → 1234.56
|
|
// PCRE doesn't support \u{XXXX} — use \x{00A0} (non-breaking space) instead
|
|
$clean = preg_replace('/[\s\x{00A0}]/u', '', (string) $value);
|
|
$clean = str_replace(',', '.', $clean);
|
|
// remove duplicate dots if any
|
|
$parts = explode('.', $clean);
|
|
if (count($parts) > 2) {
|
|
$clean = implode('', array_slice($parts, 0, -1)) . '.' . end($parts);
|
|
}
|
|
return (float) $clean;
|
|
}
|
|
|
|
private function generatePurchaseNumber(int $companyId): string
|
|
{
|
|
$year = date('Y');
|
|
$last = Purchase::where('company_id', $companyId)
|
|
->where('number', 'like', "P-$year-%")
|
|
->orderByDesc('id')->first();
|
|
$next = 1;
|
|
if ($last && preg_match('/P-\d{4}-(\d+)$/', $last->number, $m)) {
|
|
$next = (int) $m[1] + 1;
|
|
}
|
|
return sprintf('P-%s-%04d', $year, $next);
|
|
}
|
|
}
|