Bulk Dell warranty sync + add-warranty from asset pages

- POST /warranty/sync/dell: auto-detect Dell hardware by service tag (asset
  serial) and create warranties for the ones Dell recognizes, in batches of 100.
  Only looks up assets that lack a dated warranty, so re-runs are cheap and
  non-Dell serials are filtered out by Dell. DellProvider.bulk_lookup batches
  tags over the cached token.
- "Sync Dell" button on the Warranties page with a toast summary.
- WarrantyPanel "Add one / Add-manage" links deep-link to the warranties add
  modal pre-linked to that asset (?addfor=); WarrantiesList opens it prefilled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-09 17:08:42 -04:00
parent d6142b10b4
commit 640b8de1b2
5 changed files with 161 additions and 4 deletions

View File

@@ -970,6 +970,9 @@ export const warrantyApi = {
refresh(id) {
return api.post(`/warranty/${id}/refresh`)
},
syncDell() {
return api.post('/warranty/sync/dell')
},
report() {
return api.get('/warranty/report')
}

View File

@@ -14,12 +14,12 @@
<span v-if="w.servicetag" class="mono">Tag {{ w.servicetag }}</span>
</div>
</div>
<router-link to="/warranties" class="warranty-manage">Manage warranties</router-link>
<router-link :to="`/warranties?addfor=${assetid}`" class="warranty-manage">Add / manage</router-link>
</div>
<div v-else class="warranty-empty">
<span class="muted">No warranty on record.</span>
<router-link to="/warranties" class="warranty-manage">Add one</router-link>
<router-link :to="`/warranties?addfor=${assetid}`" class="warranty-manage">Add one</router-link>
</div>
</div>
</template>

View File

@@ -2,7 +2,12 @@
<div>
<div class="page-header">
<h2>Warranties</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Warranty</button>
<div class="header-actions">
<button class="btn btn-secondary" :disabled="syncing" @click="syncDell">
{{ syncing ? 'Syncing Dell...' : 'Sync Dell' }}
</button>
<button class="btn btn-primary" @click="openModal()">+ Add Warranty</button>
</div>
</div>
@@ -137,14 +142,17 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { colorStyle } from '@/utils/colorStyle'
import { warrantyApi, assetsApi } from '../../api'
import { useToast } from '../../composables/toast'
const toast = useToast()
const route = useRoute()
const items = ref([])
const loading = ref(true)
const syncing = ref(false)
const statusFilter = ref('')
const showModal = ref(false)
const editing = ref(null)
@@ -171,7 +179,21 @@ function assetLink(a) {
return base + a.assetid
}
onMounted(loadData)
onMounted(async () => {
await loadData()
// Deep-link from an asset detail page: open the add modal pre-linked to it.
const addfor = route.query.addfor
if (addfor) {
openModal()
try {
const response = await assetsApi.get(addfor)
const asset = response.data.data
if (asset) selectedAssets.value = [{ assetid: asset.assetid, assetnumber: asset.assetnumber }]
} catch (err) {
selectedAssets.value = [{ assetid: Number(addfor), assetnumber: `Asset ${addfor}` }]
}
}
})
async function loadData() {
loading.value = true
@@ -269,6 +291,21 @@ async function deleteWarranty(w) {
}
}
async function syncDell() {
syncing.value = true
toast.info('Looking up Dell service tags... this can take a moment.')
try {
const response = await warrantyApi.syncDell()
const summary = response.data?.data || {}
loadData()
toast.success(`Dell sync: ${summary.created || 0} added, ${summary.updated || 0} updated (${summary.matched || 0} tags matched).`)
} catch (err) {
toast.error(apiError(err, 'Dell sync failed'))
} finally {
syncing.value = false
}
}
async function refresh(w) {
try {
const response = await warrantyApi.refresh(w.warrantyid)

View File

@@ -210,6 +210,95 @@ def refresh_warranty(warrantyid):
return success_response(_warranty_payload(warranty), message='Warranty refreshed')
# =============================================================================
# Bulk Dell sync - auto-detect Dell hardware by service tag and pull coverage
# =============================================================================
@warranty_bp.route('/sync/dell', methods=['POST'])
@jwt_required()
@require_permission('warranty.edit')
def sync_dell():
"""Look up every asset serial (= Dell service tag) that has no warranty yet
and create warranties for the ones Dell recognizes.
Only assets that lack a dated warranty are looked up, so re-running is cheap
and non-Dell serials are filtered out by Dell (they return no coverage).
"""
provider = get_provider('dell')
# Assets already covered by a dated warranty - skip these.
covered = set()
for link in WarrantyAsset.query.all():
w = Warranty.query.get(link.warrantyid)
if w and w.isactive and w.enddate:
covered.add(link.assetid)
# Candidate assets: active, have a serial, not already covered.
candidates = (Asset.query
.filter(Asset.isactive.is_(True),
Asset.serialnumber.isnot(None),
Asset.serialnumber != '')
.all())
by_tag = {}
for asset in candidates:
if asset.assetid in covered:
continue
by_tag.setdefault(asset.serialnumber.strip().upper(), []).append(asset.assetid)
if not by_tag:
return success_response({
'candidates': 0, 'matched': 0, 'created': 0, 'updated': 0,
}, message='No PCs need a warranty lookup.')
try:
results = provider.bulk_lookup(list(by_tag.keys()))
except (ProviderNotConfigured, WarrantyLookupError) as exc:
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), http_code=400)
created = updated = matched = 0
now = datetime.utcnow()
for tag, assetids in by_tag.items():
found = results.get(tag)
if not found:
continue
matched += 1
for assetid in assetids:
# Reuse an existing Dell warranty for this asset if there is one.
existing = None
for link in WarrantyAsset.query.filter_by(assetid=assetid).all():
candidate = Warranty.query.get(link.warrantyid)
if candidate and candidate.provider == 'dell':
existing = candidate
break
if existing:
existing.servicelevel = found.get('servicelevel')
existing.startdate = _parse_date(found.get('startdate'))
existing.enddate = _parse_date(found.get('enddate'))
existing.servicetag = tag
existing.lastcheckeddate = now
updated += 1
else:
warranty = Warranty(
vendor='Dell', provider='dell', servicetag=tag,
servicelevel=found.get('servicelevel'),
startdate=_parse_date(found.get('startdate')),
enddate=_parse_date(found.get('enddate')),
lastcheckeddate=now,
)
warranty.links.append(WarrantyAsset(assetid=assetid))
db.session.add(warranty)
created += 1
db.session.commit()
return success_response({
'candidates': sum(len(v) for v in by_tag.values()),
'tags': len(by_tag),
'matched': matched,
'created': created,
'updated': updated,
}, message=f'Dell sync: {created} added, {updated} updated, {matched} tags matched.')
# =============================================================================
# Report buckets (for the Reports hub)
# =============================================================================

View File

@@ -203,6 +203,34 @@ class DellProvider(WarrantyProvider):
token = self._get_token(config)
return self._map(self._fetch(config, token, servicetag))
def bulk_lookup(self, servicetags):
"""Look up many tags at once. Dell takes up to 100 tags per call.
Returns {SERVICETAG_UPPER: {servicelevel, startdate, enddate}} for tags
Dell recognizes; unknown/non-Dell tags are simply absent.
"""
config = self._config()
if not (config['enabled'] and config['clientid'] and config['clientsecret']):
raise ProviderNotConfigured(
'Dell warranty lookup is not configured. Set warranty_dell_enabled, '
'clientid and clientsecret in Settings > Integrations.'
)
tags = [t.strip() for t in servicetags if t and t.strip()]
if not tags:
return {}
token = self._get_token(config)
results = {}
for start in range(0, len(tags), 100):
chunk = tags[start:start + 100]
payload = self._fetch(config, token, ','.join(chunk))
assets = payload if isinstance(payload, list) else [payload]
for asset in assets:
tag = (asset.get('serviceTag') or '').upper()
mapped = self._map([asset])
if tag and mapped:
results[tag] = mapped
return results
class ApiProvider(WarrantyProvider):
"""Generic config-shaped stub for vendors not yet wired (Lenovo, HP)."""