3
0
mirror of https://github.com/snipe/snipe-it.git synced 2026-08-18 11:15:42 +00:00
Files
snipe-it/app/Importer/LicenseImporter.php
2026-08-13 12:20:54 +01:00

305 lines
13 KiB
PHP

<?php
namespace App\Importer;
use App\Models\Asset;
use App\Models\License;
class LicenseImporter extends ItemImporter
{
public function __construct($filename)
{
parent::__construct($filename);
}
protected function handle($row)
{
// LicenseImporter deliberately does NOT call parent::handle(). The
// parent unconditionally assigns $this->item entries for every
// shared field, which conflates "column absent from CSV" with
// "column present but empty" and prevents empty CSV cells from
// clearing existing DB values on update. LicenseImporter builds
// $this->item exclusively via setItemFromCsvIfPresent so absent
// columns never enter the update payload (preserving DB values)
// and present-but-empty columns land as empty strings (clearing
// DB values). See sanitizeItemForStoring override below for the
// matching pass-through sanitize.
$this->item = [];
// Shared lookup fields. Present-and-empty clears the FK; absent
// preserves it; present-and-set resolves and stores the id.
foreach ([
['category_id', 'category', fn ($v) => $this->createOrFetchCategory($v)],
['company_id', 'company', fn ($v) => $this->createOrFetchCompany($v)],
['manufacturer_id', 'manufacturer', fn ($v) => $this->createOrFetchManufacturer($v)],
['supplier_id', 'supplier', fn ($v) => $this->createOrFetchSupplier($v)],
] as [$itemKey, $csvKey, $resolver]) {
if ($this->csvRowHas($row, $csvKey)) {
$value = $this->findCsvMatch($row, $csvKey);
$this->item[$itemKey] = ($value !== '') ? $resolver($value) : null;
}
}
// Shared straight assignments from ItemImporter's field list that
// apply to licenses.
$this->setItemFromCsvIfPresent($row, 'name', 'item_name');
$this->setItemFromCsvIfPresent($row, 'notes');
$this->setItemFromCsvIfPresent($row, 'purchase_cost');
if ($this->csvRowHas($row, 'purchase_date')) {
$raw = $this->findCsvMatch($row, 'purchase_date');
if ($raw !== '') {
$this->item['purchase_date'] = $raw;
$this->item['purchase_date'] = $this->parseOrNullDate('purchase_date');
} else {
$this->item['purchase_date'] = null;
}
}
// Serial and asset_tag both need special handling. serial IS a
// License field so it goes through the helper. asset_tag is captured
// separately for the seat-checkout logic in createLicenseIfNotExists.
$this->setItemFromCsvIfPresent($row, 'serial');
// checkout_class + checkout_target are internal signals for the
// seat-checkout logic below; neither is fillable on License, so
// sanitize's fillable filter drops them before the DB write.
$this->item['checkout_class'] = $this->findCsvMatch($row, 'checkout_class');
$this->item['checkout_target'] = $this->determineCheckout($row);
$this->item['created_by'] = $this->created_by;
$this->createLicenseIfNotExists($row);
}
/**
* Override the base sanitize to skip the reject-empty pass. LicenseImporter
* populates $this->item exclusively from CSV columns that were present in
* the row, so an empty value here is an explicit intent to clear the DB
* field on update. See handle() above for the matching item-population.
*
* @SuppressWarnings(PHPMD.UnusedFormalParameter)
*/
protected function sanitizeItemForStoring($model, $updating = false)
{
return collect($this->item)->only($model->getFillable())->toArray();
}
/**
* Create the license if it does not exist.
*
* @author Daniel Melzter
*
* @since 4.0
*
* @return License|mixed|null
* updated @author Jes Vinsmoke
*
* @since 6.1
*/
public function createLicenseIfNotExists(array $row)
{
// serial and name are the identity fields for the create-or-update
// match. Both are populated in handle() via setItemFromCsvIfPresent,
// so a CSV without those columns will produce empty keys here.
// Fallback to empty string so the where clauses do not blow up on
// undefined indexes; the resulting query will match nothing, and the
// validation error path takes over on the save attempt.
$serial = $this->item['serial'] ?? '';
$name = $this->item['name'] ?? '';
$editingLicense = false;
// When the CSV row supplies a serial, match on serial + name. When
// the row has no serial (either the column is absent from the CSV
// or the cell is empty), match on name plus "no serial", where
// "no serial" covers both NULL and empty-string DB values. Without
// this null-or-empty branch, users importing a seat-assignment CSV
// that omits the serial column would create a new License per row
// instead of matching to the one they just created above, because
// the fillable insert path stores absent columns as NULL while the
// match query previously compared against ''.
$query = License::where('name', $name);
if ($serial !== '') {
$query->where('serial', $serial);
} else {
$query->where(function ($q) {
$q->whereNull('serial')->orWhere('serial', '');
});
}
$license = $query->first();
// Asset tag is captured separately for the seat-checkout logic below;
// it is not itself a License column, so it does not go through the
// item array (asset_tag isn't in License's fillable).
$asset_tag = $this->csvRowHas($row, 'asset_tag') ? $this->findCsvMatch($row, 'asset_tag') : '';
if ($license) {
if (! $this->updating) {
// Duplicate license record. Don't recreate it, but still
// honor the row's seat-assignment intent so users can run
// a "assign these users to seats" CSV in create mode
// without every row silently dropping the checkout.
if ($serial !== '') {
$this->log('A matching License '.$name.' with serial '.$serial.' already exists, checking for seat assignment');
} else {
$this->log('A matching License '.$name.' with no serial number already exists, checking for seat assignment');
}
$this->recordSkipped();
$this->assignSeatIfCheckoutTargetSet($license, $asset_tag);
return;
}
$this->log('Updating License');
$editingLicense = true;
} else {
$this->log('No Matching License, Creating a new one');
$license = new License;
}
$this->setItemFromCsvIfPresent($row, 'license_email');
$this->setItemFromCsvIfPresent($row, 'license_name');
$this->setItemFromCsvIfPresent($row, 'maintained');
$this->setItemFromCsvIfPresent($row, 'purchase_order');
$this->setItemFromCsvIfPresent($row, 'order_number');
$this->setItemFromCsvIfPresent($row, 'reassignable');
$this->setItemFromCsvIfPresent($row, 'min_amt');
$this->setItemFromCsvIfPresent($row, 'seats');
// Dates need parseOrNullDate after the raw value is in $this->item.
// Empty value stays null (which clears the DB field on update).
foreach (['expiration_date', 'termination_date'] as $dateField) {
if ($this->csvRowHas($row, $dateField)) {
$raw = $this->findCsvMatch($row, $dateField);
if ($raw !== '') {
$this->item[$dateField] = $raw;
$this->item[$dateField] = $this->parseOrNullDate($dateField);
} else {
$this->item[$dateField] = null;
}
}
}
if ($editingLicense) {
$license->update($this->sanitizeItemForUpdating($license));
} else {
$license->fill($this->sanitizeItemForStoring($license));
$license->created_by = $this->created_by;
}
// This sets an attribute on the Loggable trait for the action log
$license->setImported(true);
// For new licenses we need to save, for existing ones update() already saved
$licenseWasSaved = $editingLicense || $license->save();
if ($licenseWasSaved) {
$this->log('License '.$name.' with serial number '.$serial.' was created or updated');
if ($editingLicense) {
$this->recordUpdated();
} else {
$this->recordCreated();
}
$this->assignSeatIfCheckoutTargetSet($license, $asset_tag);
return;
}
$this->recordErrored();
$this->logError($license, 'License "'.$name.'"');
}
/**
* Attempt a single-seat checkout on $license for the checkout_target
* captured on $this->item in handle(). Called from all three create-or-
* update paths (created, updated, skipped-because-duplicate) so a
* seat-assignment CSV works regardless of whether the license record
* itself was newly created, updated, or already existed.
*
* If $license has no free seats, no checkout_target was supplied, or
* a company mismatch trips the canCheckoutTo gate, this is a no-op
* with a log line.
*/
protected function assignSeatIfCheckoutTargetSet(License $license, string $asset_tag): void
{
if ($license->seats <= 0) {
return;
}
$checkout_target = $this->item['checkout_target'] ?? null;
$asset = $asset_tag !== '' ? Asset::where('asset_tag', $asset_tag)->first() : null;
if (! $checkout_target && ! $asset) {
return;
}
$targetLicense = $license->freeSeat();
if (is_null($targetLicense)) {
// No free seats left on the license. The row's license
// create/update already succeeded (or was correctly skipped as
// a duplicate), but the seat-assignment portion cannot proceed.
// Surface this as an errored row so the wizard tally reflects
// that the caller's intent was only partially fulfilled, and
// log a specific message the caller can act on. Without this,
// a seat-assignment CSV whose row count exceeds the license's
// available seats silently drops the trailing rows on the
// floor (reported in #19467 follow-up).
// The guard at the top of this method already returned when
// both $checkout_target and $asset were falsy, so the else
// branch here is reached only when $asset is truthy.
if ($checkout_target) {
$target_label = $checkout_target->name ?: ($checkout_target->username ?: (string) $checkout_target->id);
} else {
$target_label = $asset->present()->name();
}
$this->log(trans('admin/licenses/message.import.no_free_seats', [
'license' => $license->name,
'target' => $target_label,
]));
$this->addErrorToBag($license, 'seats', trans('admin/licenses/message.import.no_free_seats', [
'license' => $license->name,
'target' => $target_label,
]));
$this->recordErrored();
return;
}
if ($checkout_target) {
if (! $license->canCheckoutTo($checkout_target)) {
$this->log(trans('general.error_checkout_company_mismatch', [
'item' => trans('general.license').' "'.$license->name.'"',
'item_company' => $license->company?->name ?? trans('general.unassigned'),
'target' => ($checkout_target->name ?? $checkout_target->username ?? $checkout_target->id),
]));
return;
}
$targetLicense->assigned_to = $checkout_target->id;
$targetLicense->created_by = $this->created_by;
if ($asset) {
$targetLicense->asset_id = $asset->id;
}
$targetLicense->save();
return;
}
if (! $license->canCheckoutTo($asset)) {
$this->log(trans('general.error_checkout_company_mismatch', [
'item' => trans('general.license').' "'.$license->name.'"',
'item_company' => $license->company->name ?? trans('general.unassigned'),
'target' => trans('general.asset').' "'.$asset->display_name.'"',
]));
return;
}
$targetLicense->created_by = $this->created_by;
$targetLicense->asset_id = $asset->id;
$targetLicense->save();
}
}