Import the wilm agent store, WINC adoption work and batches CLI, with four fixes

This commit is contained in:
2026-09-03 08:16:38 -04:00
parent 4b21aa3887
commit b0bd73f62d
51 changed files with 5107 additions and 90 deletions

View File

@@ -0,0 +1,187 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
// The WINC source is the one import path that is NOT a canonical-CSV adapter:
// it previews a machine-adoption plan, gates a commit behind an explicit
// business-unit acknowledgment, and exposes an undo list. These specs pin that
// gate and the preview/commit/undo wiring, mocking the settings API so no
// network or backend is involved.
vi.mock('@/api', () => ({
settingsApi: {
get: vi.fn().mockRejectedValue({ response: { status: 404 } }),
listImportTemplates: vi.fn().mockResolvedValue({ data: { data: { tables: [] } } }),
wincPreview: vi.fn(),
wincCommit: vi.fn(),
wincBatches: vi.fn().mockResolvedValue({ data: { data: { batches: [] } } }),
wincUndo: vi.fn().mockResolvedValue({ data: { data: { reverted: 3, conflicts: [] } } }),
},
computersApi: { types: { listAll: vi.fn().mockResolvedValue([]) } },
}))
vi.mock('@/utils/apiError', () => ({ apiError: (e) => String(e) }))
vi.mock('../../utils/apiError', () => ({ apiError: (e) => String(e) }))
import { settingsApi } from '@/api'
import ImportSiteAssets from './ImportSiteAssets.vue'
function previewReport(overrides = {}) {
return {
data: {
data: {
committed: false,
blocking: false,
rowcount: 1,
summary: { in_scope: 1, skipped_no_existing_machine: 0, ambiguous_match: 0 },
needscreate: { businessunit: { 'Large Parts': 1 } },
unresolved: {},
...overrides,
},
},
}
}
async function render() {
const wrapper = mount(ImportSiteAssets, {
global: {
stubs: { 'router-link': { template: '<a><slot /></a>' } },
},
})
await flushPromises()
return wrapper
}
function selectWinc(wrapper) {
// The WINC card is the fourth source-format button.
const card = wrapper.findAll('.source-card').find(c => c.text().includes('WINC'))
return card.trigger('click')
}
async function pickFile(wrapper) {
const input = wrapper.find('.winc-panel input[type="file"]')
const file = new File(['assetnum\nW0083A\n'], 'winc.csv', { type: 'text/csv' })
Object.defineProperty(input.element, 'files', { value: [file], configurable: true })
await input.trigger('change')
return file
}
describe('WINC source selection', () => {
beforeEach(() => {
settingsApi.wincPreview.mockReset()
settingsApi.wincCommit.mockReset()
})
it('offers WINC as a fourth source card', async () => {
const wrapper = await render()
const labels = wrapper.findAll('.source-card').map(c => c.text())
expect(labels.some(t => t.includes('WINC'))).toBe(true)
})
it('shows the WINC panel instead of the generic controls when selected', async () => {
const wrapper = await render()
await selectWinc(wrapper)
expect(wrapper.find('.winc-panel').exists()).toBe(true)
})
})
describe('WINC preview and commit gating', () => {
beforeEach(() => {
settingsApi.wincPreview.mockReset()
settingsApi.wincCommit.mockReset()
settingsApi.wincBatches.mockClear()
settingsApi.wincBatches.mockResolvedValue({ data: { data: { batches: [] } } })
})
it('previews the uploaded export and lists needs-create business units', async () => {
settingsApi.wincPreview.mockResolvedValue(previewReport())
const wrapper = await render()
await selectWinc(wrapper)
await pickFile(wrapper)
await wrapper.find('.winc-panel .btn-secondary').trigger('click')
await flushPromises()
expect(settingsApi.wincPreview).toHaveBeenCalledOnce()
expect(wrapper.find('.winc-needscreate').text()).toContain('Large Parts')
})
it('blocks commit until the business-unit creation is acknowledged', async () => {
settingsApi.wincPreview.mockResolvedValue(previewReport())
const wrapper = await render()
await selectWinc(wrapper)
await pickFile(wrapper)
await wrapper.find('.winc-panel .btn-secondary').trigger('click')
await flushPromises()
const commitBtn = wrapper.find('.winc-panel .btn-primary')
expect(commitBtn.attributes('disabled')).toBeDefined()
await wrapper.find('.winc-ack input[type="checkbox"]').setValue(true)
expect(commitBtn.attributes('disabled')).toBeUndefined()
})
it('commits with the businessunit acknowledgment and refreshes the batch list', async () => {
settingsApi.wincPreview.mockResolvedValue(previewReport())
settingsApi.wincCommit.mockResolvedValue({
data: { data: { committed: true, rowcount: 1, batchid: 'b-1', summary: { in_scope: 1, skipped_no_existing_machine: 0, ambiguous_match: 0 }, needscreate: {}, unresolved: {} } },
})
const wrapper = await render()
await selectWinc(wrapper)
await pickFile(wrapper)
await wrapper.find('.winc-panel .btn-secondary').trigger('click')
await flushPromises()
await wrapper.find('.winc-ack input[type="checkbox"]').setValue(true)
await wrapper.find('.winc-panel .btn-primary').trigger('click')
await flushPromises()
const [file, acknowledge] = settingsApi.wincCommit.mock.calls[0]
expect(file.name).toBe('winc.csv')
expect(acknowledge).toEqual(['businessunit'])
// Batches reloaded once on mount and once after commit.
expect(settingsApi.wincBatches).toHaveBeenCalledTimes(2)
})
it('does not require acknowledgment when there is nothing to create', async () => {
settingsApi.wincPreview.mockResolvedValue(previewReport({ needscreate: {} }))
const wrapper = await render()
await selectWinc(wrapper)
await pickFile(wrapper)
await wrapper.find('.winc-panel .btn-secondary').trigger('click')
await flushPromises()
expect(wrapper.find('.winc-ack').exists()).toBe(false)
expect(wrapper.find('.winc-panel .btn-primary').attributes('disabled')).toBeUndefined()
})
it('blocks commit when the plan is blocking', async () => {
settingsApi.wincPreview.mockResolvedValue(previewReport({
blocking: true, needscreate: {}, unresolved: { location: { 'WM-XYZ': 2 } },
}))
const wrapper = await render()
await selectWinc(wrapper)
await pickFile(wrapper)
await wrapper.find('.winc-panel .btn-secondary').trigger('click')
await flushPromises()
expect(wrapper.find('.winc-panel .btn-primary').attributes('disabled')).toBeDefined()
expect(wrapper.find('.problems').text()).toContain('WM-XYZ')
})
})
describe('WINC undo', () => {
it('undoes an applied batch and reloads the list', async () => {
settingsApi.wincBatches.mockResolvedValue({
data: { data: { batches: [
{ batchid: 'b-1', kind: 'winc-machine-adoption', rowcount: 5, createdat: '2026-01-01T00:00:00Z', createdby: 'admin', status: 'applied', undoneat: null },
] } },
})
const wrapper = await render()
await selectWinc(wrapper)
await flushPromises()
await wrapper.find('.winc-batches .btn-secondary').trigger('click')
await flushPromises()
expect(settingsApi.wincUndo).toHaveBeenCalledWith('b-1')
})
})

View File

@@ -112,6 +112,15 @@
:class="['source-card', { active: importSource === s.key }]"
@click="selectSource(s.key)"
>
<span class="source-card-head">
<svg class="source-card-icon" viewBox="0 0 24 24" aria-hidden="true">
<path :d="SOURCE_ICONS[s.icon]" fill="currentColor" />
</svg>
<span class="source-card-tag">{{ s.tag }}</span>
<svg v-if="importSource === s.key" class="source-card-check" viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 16.2 4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4z" fill="currentColor" />
</svg>
</span>
<span class="source-card-title">{{ s.label }}</span>
<span class="source-card-desc">{{ s.description }}</span>
</button>
@@ -204,7 +213,166 @@
</p>
</div>
<div class="import-controls">
<div v-if="importSource === 'winc'" class="winc-panel">
<p class="muted">
A WINC (Maximo-style) asset export adopts existing machines: it never
creates an asset, only updates a machine that already exists, matched
on its workstation number. It sets the machine's name, location, and
business unit. Preview first - it writes nothing and reports what would
change, which business units it would create (you must confirm), and
any values it cannot resolve (which block the import). Every commit is
journalled, so it can be undone from the list below.
</p>
<div class="import-controls">
<input
ref="wincFileInput"
type="file"
accept=".csv"
@change="onWincFilePicked"
/>
<div class="actions">
<button
class="btn btn-secondary"
:disabled="!wincFile || wincPreviewing || wincCommitting"
@click="wincPreview"
>
{{ wincPreviewing ? 'Checking...' : 'Preview (dry run)' }}
</button>
<button
class="btn btn-primary"
:disabled="!wincCanCommit"
@click="wincCommit"
>
{{ wincCommitting ? 'Importing...' : 'Commit Import' }}
</button>
<button
v-if="wincFile"
class="btn btn-secondary btn-sm"
:disabled="wincPreviewing || wincCommitting"
@click="clearWincSelection"
>
Clear
</button>
</div>
</div>
<p v-if="wincFile" class="muted">Selected: {{ wincFile.name }}</p>
<p v-if="wincError" class="error">{{ wincError }}</p>
<div v-if="wincReport" class="report">
<p
:class="['report-banner', wincReport.committed ? 'ok' : (wincReport.blocking ? 'warn' : 'info')]"
>
<template v-if="wincReport.committed">
Imported: {{ wincReport.rowcount }} machine(s) updated. Undo it
below if needed.
</template>
<template v-else-if="wincReport.blocking">
{{ wincReport.rowcount }} row(s) in scope, but unresolved values
block the import - nothing was written. Resolve them and preview
again.
</template>
<template v-else>
Looks good: {{ wincReport.rowcount }} machine(s) would be updated.
Review below, then Commit Import.
</template>
</p>
<table class="report-table">
<tbody>
<tr>
<td>In scope (existing machines matched)</td>
<td>{{ wincReport.summary.in_scope }}</td>
</tr>
<tr>
<td>Skipped (no existing machine)</td>
<td>{{ wincReport.summary.skipped_no_existing_machine }}</td>
</tr>
<tr v-if="wincReport.summary.ambiguous_match">
<td>Ambiguous (skipped, not guessed)</td>
<td>{{ wincReport.summary.ambiguous_match }}</td>
</tr>
</tbody>
</table>
<div v-if="wincNeedsBusinessUnit" class="winc-needscreate">
<h4>New business units to be created</h4>
<p class="muted">
These business units are referenced by the export but do not exist
yet. Confirm they are correct - you can edit them later in
Settings. Nothing is created until you acknowledge and commit.
</p>
<ul>
<li v-for="(count, name) in wincReport.needscreate.businessunit" :key="name">
<strong>{{ name }}</strong> ({{ count }} machine(s))
</li>
</ul>
<label class="winc-ack" v-if="!wincReport.committed">
<input type="checkbox" v-model="wincAckBusinessUnit" />
I confirm these business units should be created.
</label>
</div>
<div v-if="wincReport.unresolved && Object.keys(wincReport.unresolved).length" class="problems">
<h4>Unresolved values (blocking)</h4>
<p class="muted">
These export values have no mapping yet, so the import will not
touch the affected rows. An administrator must add an alias for
each before this import can run.
</p>
<ul>
<li v-for="(items, group) in wincReport.unresolved" :key="group">
<strong>{{ group }}:</strong>
<span v-for="(count, value) in items" :key="value">
"{{ value }}" ({{ count }}){{ ' ' }}
</span>
</li>
</ul>
</div>
</div>
<div v-if="wincBatches.length" class="winc-batches">
<h4>Import history</h4>
<p class="muted">
Each committed WINC import can be undone. Undo reverses only cells
this import set that have not been changed since, and removes only
business units this import created.
</p>
<table class="report-table">
<thead>
<tr>
<th>When</th>
<th>By</th>
<th>Rows</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="b in wincBatches" :key="b.batchid">
<td>{{ b.createdat }}</td>
<td>{{ b.createdby || '-' }}</td>
<td>{{ b.rowcount }}</td>
<td>{{ b.status }}</td>
<td>
<button
v-if="b.status === 'applied'"
class="btn btn-secondary btn-sm"
:disabled="wincUndoingId === b.batchid"
@click="undoWincBatch(b.batchid)"
>
{{ wincUndoingId === b.batchid ? 'Undoing...' : 'Undo' }}
</button>
<span v-else class="muted">undone</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="importSource !== 'winc'" class="import-controls">
<input
ref="fileInput"
type="file"
@@ -448,11 +616,48 @@ const report = ref(null)
// Which format the selected files are in. 'native' means the uploads are
// already canonical per-table CSVs; 'azure' and 'thor' route through a
// server-side adapter that transforms the foreign export first.
// Each source carries a short tag (the kind of thing it writes) and an icon key
// so the picker reads at a glance. Icons are inline SVG paths (24x24 viewBox),
// kept ASCII-only per the house style - no emoji or icon-font glyphs.
const IMPORT_SOURCES = [
{ key: 'native', label: 'ShopDB native', description: 'The canonical per-table CSVs below (assets, computers, vendors, ...).' },
{ key: 'azure', label: 'Azure / Intune device group', description: 'A device-group member export from Entra. Creates PCs by hostname and stores the deviceId.' },
{ key: 'thor', label: 'Thor PC inventory', description: 'A Thor inventory export. Maps hostname, vendor, model, OS, user, and location.' }
{
key: 'native',
label: 'ShopDB native',
tag: 'All tables',
icon: 'table',
description: 'The canonical per-table CSVs below (assets, computers, vendors, ...).'
},
{
key: 'azure',
label: 'Azure / Intune device group',
tag: 'Computers',
icon: 'cloud',
description: 'A device-group member export from Entra. Creates PCs by hostname and stores the deviceId.'
},
{
key: 'thor',
label: 'Thor PC inventory',
tag: 'Computers',
icon: 'desktop',
description: 'A Thor inventory export. Maps hostname, vendor, model, OS, user, and location.'
},
{
key: 'winc',
label: 'Maximo Import',
tag: 'Machines',
icon: 'wrench',
description: 'A Maximo asset export (validated against the WINC / West Manufacturing site). Updates existing machines only; reversible, with an approval step.'
}
]
// Inline 24x24 SVG path data keyed by IMPORT_SOURCES[].icon. Single-color paths
// that inherit currentColor, so they tint with the active/inactive card state.
const SOURCE_ICONS = {
table: 'M3 5h18v14H3V5zm2 2v3h5V7H5zm7 0v3h7V7h-7zM5 12v5h5v-5H5zm7 0v5h7v-5h-7z',
cloud: 'M6.5 19a4.5 4.5 0 0 1-.5-8.97A6 6 0 0 1 17.66 9.1 4.5 4.5 0 0 1 17.5 19h-11z',
desktop: 'M3 4h18v12H3V4zm2 2v8h14V6H5zm4 12h6v2H9v-2z',
wrench: 'M21.7 6.3a5 5 0 0 1-6.4 6.4l-6.9 6.9a2 2 0 0 1-2.9-2.9l6.9-6.9a5 5 0 0 1 6.4-6.4l-2.8 2.8 1.4 3.5 3.5 1.4 2.8-2.8z'
}
const importSource = ref('native')
// Optional computer type stamped onto every computer an Azure import creates or
// updates, so a SHP group becomes Shopfloor PCs and a BUS group Inspection PCs.
@@ -463,6 +668,34 @@ const addingType = ref(false)
const computerTypes = ref([])
const azureDeviceCategory = ref('')
// WINC (Maximo-style) machine adoption is its own reversible path, not a
// canonical-CSV adapter: one uploaded export, an approval report, an explicit
// acknowledgment before any business unit is created, and an undo list.
const wincFile = ref(null)
const wincFileInput = ref(null)
const wincReport = ref(null)
const wincError = ref('')
const wincPreviewing = ref(false)
const wincCommitting = ref(false)
const wincAckBusinessUnit = ref(false)
const wincBatches = ref([])
const wincUndoingId = ref('')
// A WINC commit is allowed only after a clean, non-blocking preview of the
// current file, with every needs-create group acknowledged.
const wincNeedsBusinessUnit = computed(() =>
!!(wincReport.value && wincReport.value.needscreate
&& wincReport.value.needscreate.businessunit))
const wincCanCommit = computed(() => {
const wincResult = wincReport.value
if (!wincResult || wincCommitting.value || wincPreviewing.value) return false
if (wincResult.committed || wincResult.blocking) return false
if (wincResult.rowcount === 0) return false
if (wincNeedsBusinessUnit.value && !wincAckBusinessUnit.value) return false
return true
})
// Commit is allowed only after a clean preview of the CURRENT selection: a
// report with no problems and no request-level error that has not already been
// committed.
@@ -651,6 +884,82 @@ async function commit() {
}
}
function onWincFilePicked(event) {
wincFile.value = (event.target.files || [])[0] || null
// A new file invalidates the previous report and its acknowledgment.
wincReport.value = null
wincError.value = ''
wincAckBusinessUnit.value = false
}
function clearWincSelection() {
wincFile.value = null
wincReport.value = null
wincError.value = ''
wincAckBusinessUnit.value = false
if (wincFileInput.value) wincFileInput.value.value = ''
}
async function wincPreview() {
if (!wincFile.value) return
wincPreviewing.value = true
wincError.value = ''
wincAckBusinessUnit.value = false
try {
const response = await settingsApi.wincPreview(wincFile.value)
wincReport.value = response.data?.data || response.data || null
} catch (err) {
wincError.value = apiError(err, 'WINC preview failed.')
wincReport.value = null
} finally {
wincPreviewing.value = false
}
}
async function wincCommit() {
if (!wincCanCommit.value) return
wincCommitting.value = true
wincError.value = ''
try {
const acknowledge = wincNeedsBusinessUnit.value ? ['businessunit'] : []
// The hash from the preview the admin actually looked at. Without it the
// server refuses: a commit has to be able to prove it is applying the plan
// that was approved, not whatever the file says now.
const planhash = wincReport.value?.planhash || ''
const response = await settingsApi.wincCommit(wincFile.value, acknowledge,
planhash)
wincReport.value = response.data?.data || response.data || null
await loadWincBatches()
} catch (err) {
wincError.value = apiError(err, 'WINC import failed.')
} finally {
wincCommitting.value = false
}
}
async function loadWincBatches() {
try {
const response = await settingsApi.wincBatches()
const data = response.data?.data || response.data || {}
wincBatches.value = data.batches || []
} catch {
wincBatches.value = []
}
}
async function undoWincBatch(batchid) {
wincUndoingId.value = batchid
wincError.value = ''
try {
await settingsApi.wincUndo(batchid)
await loadWincBatches()
} catch (err) {
wincError.value = apiError(err, 'WINC undo failed.')
} finally {
wincUndoingId.value = ''
}
}
async function addComputerType() {
const name = customComputerType.value.trim()
if (!name) return
@@ -683,6 +992,7 @@ onMounted(async () => {
await loadLocation()
await loadTemplates()
await loadComputerTypes()
await loadWincBatches()
})
</script>
@@ -769,27 +1079,63 @@ onMounted(async () => {
.source-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 10px;
margin: 4px 0;
gap: 12px;
margin: 6px 0;
}
.source-card {
display: flex;
flex-direction: column;
gap: 4px;
gap: 6px;
text-align: left;
padding: 12px 14px;
padding: 14px;
border: 1px solid var(--border);
border-radius: 8px;
border-radius: 10px;
background: var(--bg);
color: var(--text);
cursor: pointer;
position: relative;
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease, background 0.15s ease;
}
.source-card:hover {
border-color: var(--primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
transform: translateY(-1px);
}
.source-card.active {
border-color: var(--primary);
box-shadow: 0 0 0 1px var(--primary) inset;
box-shadow: 0 0 0 1px var(--primary) inset, 0 2px 10px rgba(0, 0, 0, 0.22);
background: color-mix(in srgb, var(--primary) 8%, var(--bg));
}
.source-card-head {
display: flex;
align-items: center;
gap: 8px;
}
.source-card-icon {
width: 22px;
height: 22px;
color: var(--text-light);
flex: none;
}
.source-card.active .source-card-icon {
color: var(--primary);
}
.source-card-tag {
font-size: 0.68rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 2px 8px;
border-radius: 999px;
background: color-mix(in srgb, var(--primary) 14%, transparent);
color: var(--primary);
}
.source-card-check {
width: 18px;
height: 18px;
color: var(--primary);
margin-left: auto;
flex: none;
}
.source-card-title {
font-weight: 600;
@@ -963,4 +1309,33 @@ onMounted(async () => {
margin: 4px 0;
font-size: 0.9rem;
}
.winc-panel {
margin-top: 12px;
}
.winc-needscreate {
margin-top: 12px;
border: 1px solid var(--warning, #d0a44a);
border-radius: 8px;
padding: 12px 14px;
}
.winc-needscreate h4 {
margin: 0 0 6px;
}
.winc-needscreate ul {
margin: 6px 0;
padding-left: 20px;
}
.winc-ack {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
font-size: 0.9rem;
}
.winc-batches {
margin-top: 16px;
}
.winc-batches h4 {
margin: 0 0 6px;
}
</style>