This commit is contained in:
@@ -37,4 +37,15 @@ export async function registerAdminExportRoutes(app, options) {
|
|||||||
.header('content-disposition', 'attachment; filename="inventory.csv"')
|
.header('content-disposition', 'attachment; filename="inventory.csv"')
|
||||||
.send(data);
|
.send(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/exports/inventory.xls', async (request, reply) => {
|
||||||
|
const data = await adminExportService.inventoryExcel({
|
||||||
|
sellerId: request.admin.sellerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply
|
||||||
|
.type('application/vnd.ms-excel; charset=utf-8')
|
||||||
|
.header('content-disposition', 'attachment; filename="inventory.xls"')
|
||||||
|
.send(data);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,9 +19,34 @@ export function createAdminExportService({ inventoryItemService }) {
|
|||||||
...rows.map((row) => columns.map((column) => escapeCsvValue(row[column])).join(',')),
|
...rows.map((row) => columns.map((column) => escapeCsvValue(row[column])).join(',')),
|
||||||
].join('\r\n');
|
].join('\r\n');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async inventoryExcel({ sellerId }) {
|
||||||
|
const rows = await inventoryRows(sellerId);
|
||||||
|
const columns = ['id', 'productId', 'conditionId', 'status', 'notes', 'createdAt', 'updatedAt'];
|
||||||
|
const header = columns.map((column) => excelCell(column, true)).join('');
|
||||||
|
const body = rows.map((row) => (
|
||||||
|
`<Row>${columns.map((column) => excelCell(row[column])).join('')}</Row>`
|
||||||
|
)).join('');
|
||||||
|
|
||||||
|
return `<?xml version="1.0"?>\n<?mso-application progid="Excel.Sheet"?>\n<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet" xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet"><Styles><Style ss:ID="Header"><Font ss:Bold="1" /></Style></Styles><Worksheet ss:Name="Inventory"><Table><Row>${header}</Row>${body}</Table></Worksheet></Workbook>`;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function excelCell(value, header = false) {
|
||||||
|
const style = header ? ' ss:StyleID="Header"' : '';
|
||||||
|
return `<Cell${style}><Data ss:Type="String">${escapeXml(value)}</Data></Cell>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXml(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
function escapeCsvValue(value) {
|
function escapeCsvValue(value) {
|
||||||
const text = value == null ? '' : String(value);
|
const text = value == null ? '' : String(value);
|
||||||
return `"${text.replaceAll('"', '""')}"`;
|
return `"${text.replaceAll('"', '""')}"`;
|
||||||
|
|||||||
@@ -52,3 +52,18 @@ test('exports inventory as escaped CSV rows', async () => {
|
|||||||
'"item-1","product-1","condition-1","ACTIVE","Front, ""clean""\nback","",""',
|
'"item-1","product-1","condition-1","ACTIVE","Front, ""clean""\nback","",""',
|
||||||
].join('\r\n'));
|
].join('\r\n'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('exports inventory as an escaped Excel XML workbook', async () => {
|
||||||
|
const service = createAdminExportService({
|
||||||
|
inventoryItemService: {
|
||||||
|
async listBySellerId() {
|
||||||
|
return [{ id: 'item-1', productId: 'product-1', conditionId: 'condition-1', status: 'ACTIVE', notes: 'A <note>' }];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const workbook = await service.inventoryExcel({ sellerId: 'seller-1' });
|
||||||
|
assert.match(workbook, /Excel\.Sheet/);
|
||||||
|
assert.match(workbook, /A <note>/);
|
||||||
|
assert.match(workbook, /<Cell ss:StyleID="Header"><Data ss:Type="String">id<\/Data><\/Cell>/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -80,3 +80,23 @@ test('downloads seller-scoped inventory CSV for an authenticated admin', async (
|
|||||||
assert.equal(response.headers['content-disposition'], 'attachment; filename="inventory.csv"');
|
assert.equal(response.headers['content-disposition'], 'attachment; filename="inventory.csv"');
|
||||||
assert.equal(response.body, 'id,status\r\n"item-1","ACTIVE"');
|
assert.equal(response.body, 'id,status\r\n"item-1","ACTIVE"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('downloads seller-scoped inventory Excel XML for an authenticated admin', async (testContext) => {
|
||||||
|
const app = buildApp({
|
||||||
|
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
||||||
|
adminExportService: {
|
||||||
|
async inventoryExcel({ sellerId }) {
|
||||||
|
assert.equal(sellerId, 'seller-1');
|
||||||
|
return '<?xml version="1.0"?><Workbook />';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
testContext.after(() => app.close());
|
||||||
|
|
||||||
|
const response = await app.inject({ method: 'GET', url: '/api/v1/admin/exports/inventory.xls' });
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 200);
|
||||||
|
assert.equal(response.headers['content-type'], 'application/vnd.ms-excel; charset=utf-8');
|
||||||
|
assert.equal(response.headers['content-disposition'], 'attachment; filename="inventory.xls"');
|
||||||
|
assert.equal(response.body, '<?xml version="1.0"?><Workbook />');
|
||||||
|
});
|
||||||
|
|||||||
@@ -47,6 +47,17 @@ export const InventoryCsvExport = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const InventoryExcelExport = {
|
||||||
|
parameters: {
|
||||||
|
docs: {
|
||||||
|
description: {
|
||||||
|
story:
|
||||||
|
"The admin workspace exposes a seller-scoped Excel-compatible inventory download.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const BulkTagSelection = {
|
export const BulkTagSelection = {
|
||||||
parameters: {
|
parameters: {
|
||||||
docs: {
|
docs: {
|
||||||
|
|||||||
@@ -508,6 +508,13 @@ onMounted(load);
|
|||||||
>
|
>
|
||||||
Download inventory CSV
|
Download inventory CSV
|
||||||
</a>
|
</a>
|
||||||
|
<a
|
||||||
|
class="button button--secondary"
|
||||||
|
href="/api/v1/admin/exports/inventory.xls"
|
||||||
|
download="inventory.xls"
|
||||||
|
>
|
||||||
|
Download inventory Excel
|
||||||
|
</a>
|
||||||
</p>
|
</p>
|
||||||
<p v-if="isLoading" class="state state--loading" role="status">
|
<p v-if="isLoading" class="state state--loading" role="status">
|
||||||
Loading inventory...
|
Loading inventory...
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ POST /api/v1/admin/bulk
|
|||||||
GET /api/v1/admin/dashboard
|
GET /api/v1/admin/dashboard
|
||||||
GET /api/v1/admin/exports/inventory.json
|
GET /api/v1/admin/exports/inventory.json
|
||||||
GET /api/v1/admin/exports/inventory.csv
|
GET /api/v1/admin/exports/inventory.csv
|
||||||
|
GET /api/v1/admin/exports/inventory.xls
|
||||||
|
|
||||||
GET /api/v1/admin/saved-views
|
GET /api/v1/admin/saved-views
|
||||||
POST /api/v1/admin/saved-views
|
POST /api/v1/admin/saved-views
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ Remaining roadmap areas are bulk status/tag/pricing actions, richer exports/sett
|
|||||||
- Bulk archive, asynchronous bulk pricing refresh, and Product-level bulk tag updates are implemented through the seller-scoped API boundary; bulk status changes remain.
|
- Bulk archive, asynchronous bulk pricing refresh, and Product-level bulk tag updates are implemented through the seller-scoped API boundary; bulk status changes remain.
|
||||||
- Admin inventory JSON export is implemented at `GET /api/v1/admin/exports/inventory.json` and serializes seller-scoped admin DTOs as a downloadable attachment.
|
- Admin inventory JSON export is implemented at `GET /api/v1/admin/exports/inventory.json` and serializes seller-scoped admin DTOs as a downloadable attachment.
|
||||||
- Admin inventory CSV export is implemented at `GET /api/v1/admin/exports/inventory.csv` with escaped spreadsheet-safe fields and seller-scoped admin DTOs.
|
- Admin inventory CSV export is implemented at `GET /api/v1/admin/exports/inventory.csv` with escaped spreadsheet-safe fields and seller-scoped admin DTOs.
|
||||||
|
- Admin inventory Excel-compatible export is implemented at `GET /api/v1/admin/exports/inventory.xls` using the XML workbook format and seller-scoped admin DTOs.
|
||||||
- PostgreSQL restore verification and image snapshot restore scripts are implemented and covered by shell tests as part of the deployment drill workflow.
|
- PostgreSQL restore verification and image snapshot restore scripts are implemented and covered by shell tests as part of the deployment drill workflow.
|
||||||
- `npm audit --omit=dev` currently reports zero production vulnerabilities. Re-run the audit after dependency changes and review development-dependency findings separately before production releases.
|
- `npm audit --omit=dev` currently reports zero production vulnerabilities. Re-run the audit after dependency changes and review development-dependency findings separately before production releases.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user