Files
autocrm/app/Services/ExcelInvoiceImportService.php
T
Vasyka 3911012c65 feat(i18n): mega-wrap 494 raw RO strings + 269 lang entries + 161 human RU/163 EN
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>
2026-07-15 05:53:22 +00:00

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);
}
}