This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
toAdminMarketValuationDto,
|
||||
toAdminMarketValuationSummaryDto,
|
||||
toAdminPricingObservationDto,
|
||||
} from "@pokemon-inventory/contracts/pricing";
|
||||
import {
|
||||
@@ -91,6 +92,14 @@ export async function registerAdminPricingRoutes(app, options) {
|
||||
return reply.code(201).send({ data: override });
|
||||
});
|
||||
|
||||
app.get("/valuations", async (request) => ({
|
||||
data: (
|
||||
await pricingService.listValuations({
|
||||
sellerId: request.admin.sellerId,
|
||||
})
|
||||
).map(toAdminMarketValuationSummaryDto),
|
||||
}));
|
||||
|
||||
app.post("/inventory/:id/pricing/refresh", async (request, reply) => {
|
||||
await authorizeUnsafeRequest?.(
|
||||
request.cookies[sessionCookieName],
|
||||
|
||||
@@ -203,6 +203,10 @@ export function createPricingService({
|
||||
return pricingRefreshJobRepository?.listForSeller(sellerId) ?? [];
|
||||
},
|
||||
|
||||
async listValuations({ sellerId }) {
|
||||
return pricingRepository.listLatestValuationsForSeller(sellerId);
|
||||
},
|
||||
|
||||
async retryJob({ sellerId, jobId }) {
|
||||
const job = await pricingRefreshJobRepository?.retryForSeller(
|
||||
jobId,
|
||||
|
||||
@@ -182,3 +182,38 @@ test("lists jobs and retries a failed job with CSRF protection", async (testCont
|
||||
assert.equal(retryResponse.statusCode, 202);
|
||||
assert.equal(csrfChecks, 1);
|
||||
});
|
||||
|
||||
test("lists seller-scoped latest market valuations", async (testContext) => {
|
||||
const app = buildApp({
|
||||
authenticateAdmin: async () => ({ sellerId: "seller-1" }),
|
||||
pricingService: {
|
||||
async listValuations({ sellerId }) {
|
||||
assert.equal(sellerId, "seller-1");
|
||||
return [
|
||||
{
|
||||
id: "valuation-1",
|
||||
inventoryItemId: "item-1",
|
||||
valueMinor: 2500,
|
||||
currency: "USD",
|
||||
strategy: "MEDIAN",
|
||||
confidence: "MEDIUM",
|
||||
sourceCount: 2,
|
||||
calculatedAt: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
},
|
||||
},
|
||||
});
|
||||
testContext.after(() => app.close());
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/admin/valuations",
|
||||
});
|
||||
|
||||
assert.equal(response.statusCode, 200);
|
||||
const [valuation] = response.json().data;
|
||||
assert.equal(valuation.inventoryItemId, "item-1");
|
||||
assert.equal(valuation.valueMinor, 2500);
|
||||
assert.equal(valuation.stale, false);
|
||||
});
|
||||
|
||||
@@ -12,13 +12,10 @@ import ReviewListStep from "@/components/molecules/ReviewListStep.vue";
|
||||
import {
|
||||
createAdminInventory,
|
||||
createAdminProduct,
|
||||
archiveAdminInventory,
|
||||
listAdminCategories,
|
||||
listAdminConditions,
|
||||
listAdminInventory,
|
||||
listAdminProducts,
|
||||
refreshAdminInventoryPricing,
|
||||
updateAdminInventoryTags,
|
||||
enqueueAdminInventoryExport,
|
||||
getAdminInventoryExportJob,
|
||||
} from "@/services/admin-catalog-service.js";
|
||||
@@ -34,10 +31,6 @@ import {
|
||||
getAdminPricingJob,
|
||||
overrideAdminValuation,
|
||||
} from "@/services/admin-pricing-service.js";
|
||||
import {
|
||||
listAdminPricingJobs,
|
||||
retryAdminPricingJob,
|
||||
} from "@/services/admin-jobs-service.js";
|
||||
import {
|
||||
createAdminProviderMapping,
|
||||
listAdminProviderMappings,
|
||||
@@ -57,8 +50,6 @@ const activeStepId = ref(WIZARD_STEP_IDS[0]);
|
||||
const activeStepIndex = computed(() => WIZARD_STEP_IDS.indexOf(activeStepId.value));
|
||||
const products = ref([]);
|
||||
const inventory = ref([]);
|
||||
const selectedInventoryIds = ref([]);
|
||||
const inventoryTags = ref("");
|
||||
const exportStatus = ref("");
|
||||
const exportJobId = ref("");
|
||||
const listings = ref([]);
|
||||
@@ -118,7 +109,6 @@ const mappingType = ref("PRODUCT");
|
||||
const mappingExternalIdentifier = ref("");
|
||||
const mappingSearchQuery = ref("");
|
||||
const providerMappings = ref([]);
|
||||
const pricingJobs = ref([]);
|
||||
|
||||
function csrfToken() {
|
||||
return (
|
||||
@@ -139,7 +129,6 @@ async function load() {
|
||||
conditions.value,
|
||||
listings.value,
|
||||
dashboard.value,
|
||||
pricingJobs.value,
|
||||
] = await Promise.all([
|
||||
listAdminProducts(),
|
||||
listAdminInventory(),
|
||||
@@ -147,7 +136,6 @@ async function load() {
|
||||
listAdminConditions(),
|
||||
listAdminListings(),
|
||||
getAdminDashboard(),
|
||||
listAdminPricingJobs(),
|
||||
]);
|
||||
} catch (requestError) {
|
||||
error.value = requestError;
|
||||
@@ -219,61 +207,6 @@ async function createInventory() {
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveSelectedInventory() {
|
||||
if (!selectedInventoryIds.value.length) return;
|
||||
isSaving.value = true;
|
||||
try {
|
||||
const archivedItems = await archiveAdminInventory(
|
||||
selectedInventoryIds.value,
|
||||
csrfToken(),
|
||||
);
|
||||
const archivedById = new Map(archivedItems.map((item) => [item.id, item]));
|
||||
inventory.value = inventory.value.map((item) => archivedById.get(item.id) ?? item);
|
||||
selectedInventoryIds.value = [];
|
||||
} catch (requestError) {
|
||||
error.value = requestError;
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSelectedPricing() {
|
||||
if (!selectedInventoryIds.value.length) return;
|
||||
isSaving.value = true;
|
||||
try {
|
||||
const jobs = await refreshAdminInventoryPricing(
|
||||
selectedInventoryIds.value,
|
||||
pricingProvider.value,
|
||||
csrfToken(),
|
||||
);
|
||||
pricingJobs.value = [...jobs, ...pricingJobs.value];
|
||||
selectedInventoryIds.value = [];
|
||||
} catch (requestError) {
|
||||
error.value = requestError;
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSelectedTags(action) {
|
||||
const tags = inventoryTags.value.split(",").map((tag) => tag.trim()).filter(Boolean);
|
||||
if (!selectedInventoryIds.value.length || !tags.length) return;
|
||||
isSaving.value = true;
|
||||
try {
|
||||
await updateAdminInventoryTags(
|
||||
selectedInventoryIds.value,
|
||||
action === "add" ? tags : [],
|
||||
action === "remove" ? tags : [],
|
||||
csrfToken(),
|
||||
);
|
||||
inventoryTags.value = "";
|
||||
selectedInventoryIds.value = [];
|
||||
} catch (requestError) {
|
||||
error.value = requestError;
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function enqueueInventoryExport() {
|
||||
isSaving.value = true;
|
||||
@@ -387,18 +320,6 @@ async function refreshPricing() {
|
||||
}
|
||||
}
|
||||
|
||||
async function retryPricingJob(jobId) {
|
||||
isSaving.value = true;
|
||||
try {
|
||||
await retryAdminPricingJob(jobId, csrfToken());
|
||||
pricingJobs.value = await listAdminPricingJobs();
|
||||
} catch (requestError) {
|
||||
error.value = requestError;
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function overridePricing() {
|
||||
isSaving.value = true;
|
||||
try {
|
||||
@@ -538,25 +459,25 @@ onMounted(() => {
|
||||
<div>
|
||||
<dt>Missing images</dt>
|
||||
<dd>
|
||||
<a href="#inventory-title">{{ dashboard.counts.missingImages }}</a>
|
||||
<a href="/admin/inventory">{{ dashboard.counts.missingImages }}</a>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Missing pricing</dt>
|
||||
<dd>
|
||||
<a href="#pricing-title">{{ dashboard.counts.missingPricing }}</a>
|
||||
<a href="/admin/inventory">{{ dashboard.counts.missingPricing }}</a>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Stale pricing</dt>
|
||||
<dd>
|
||||
<a href="#pricing-title">{{ dashboard.counts.stalePricing }}</a>
|
||||
<a href="/admin/inventory">{{ dashboard.counts.stalePricing }}</a>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Provider failures</dt>
|
||||
<dd>
|
||||
<a href="#pricing-title">{{
|
||||
<a href="/admin/inventory">{{
|
||||
dashboard.counts.failedPricingJobs
|
||||
}}</a>
|
||||
</dd>
|
||||
@@ -717,103 +638,8 @@ onMounted(() => {
|
||||
aria-labelledby="inventory-title"
|
||||
>
|
||||
<h2 id="inventory-title">Physical inventory</h2>
|
||||
<div class="admin-list__actions">
|
||||
<button
|
||||
class="button button--secondary"
|
||||
type="button"
|
||||
:disabled="isSaving || !selectedInventoryIds.length"
|
||||
@click="archiveSelectedInventory"
|
||||
>
|
||||
Archive selected
|
||||
</button>
|
||||
<button
|
||||
class="button button--secondary"
|
||||
type="button"
|
||||
:disabled="isSaving || !selectedInventoryIds.length"
|
||||
@click="refreshSelectedPricing"
|
||||
>
|
||||
Refresh pricing
|
||||
</button>
|
||||
<input
|
||||
v-model="inventoryTags"
|
||||
class="fixture-input"
|
||||
type="text"
|
||||
placeholder="Tags, comma separated"
|
||||
aria-label="Tags to add or remove"
|
||||
/>
|
||||
<button
|
||||
class="button button--secondary"
|
||||
type="button"
|
||||
:disabled="isSaving || !selectedInventoryIds.length || !inventoryTags.trim()"
|
||||
@click="updateSelectedTags('add')"
|
||||
>
|
||||
Add tags
|
||||
</button>
|
||||
<button
|
||||
class="button button--secondary"
|
||||
type="button"
|
||||
:disabled="isSaving || !selectedInventoryIds.length || !inventoryTags.trim()"
|
||||
@click="updateSelectedTags('remove')"
|
||||
>
|
||||
Remove tags
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="!inventory.length && !isLoading" class="state state--empty">
|
||||
No physical copies yet.
|
||||
</p>
|
||||
<ul v-else>
|
||||
<li
|
||||
v-for="item in inventory"
|
||||
:key="item.id"
|
||||
:data-product-id="item.productId"
|
||||
>
|
||||
<label v-if="item.status === 'ACTIVE'">
|
||||
<input
|
||||
v-model="selectedInventoryIds"
|
||||
type="checkbox"
|
||||
:value="item.id"
|
||||
:aria-label="`Select inventory item ${item.id}`"
|
||||
/>
|
||||
<span>{{ item.id }}</span>
|
||||
</label>
|
||||
<span v-else>{{ item.id }}</span>
|
||||
· {{ item.status }}
|
||||
</li>
|
||||
</ul>
|
||||
<ul v-if="hasValuations" aria-label="Market valuations">
|
||||
<li
|
||||
v-for="(valuation, inventoryItemId) in valuations"
|
||||
:key="inventoryItemId"
|
||||
>
|
||||
{{ inventoryItemId }} · Market value ${{
|
||||
(valuation.valueMinor / 100).toFixed(2)
|
||||
}}
|
||||
· {{ valuation.confidence }} confidence ·
|
||||
{{ valuation.sourceCount }} source{{
|
||||
valuation.sourceCount === 1 ? "" : "s"
|
||||
}}<span v-if="valuation.stale"> · STALE</span>
|
||||
<span
|
||||
v-if="valuation.observations?.some(({ isOutlier }) => isOutlier)"
|
||||
>
|
||||
· OUTLIER FLAGGED</span
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
<ul v-if="pricingJobs.length" aria-label="Pricing jobs">
|
||||
<li v-for="job in pricingJobs" :key="job.id">
|
||||
{{ job.id }} · {{ job.providerId }} · {{ job.status
|
||||
}}<span v-if="job.error"> · {{ job.error }}</span>
|
||||
<button
|
||||
v-if="job.status === 'FAILED'"
|
||||
class="button button--secondary"
|
||||
type="button"
|
||||
:disabled="isSaving"
|
||||
@click="retryPricingJob(job.id)"
|
||||
>
|
||||
Retry job
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<p>{{ inventory.length }} physical cop{{ inventory.length === 1 ? "y" : "ies" }} recorded.</p>
|
||||
<a class="button button--secondary" href="/admin/inventory">Manage inventory</a>
|
||||
</section>
|
||||
<section class="admin-list" aria-labelledby="listings-title">
|
||||
<h2 id="listings-title">Listings</h2>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import AdminInventoryPage from './AdminInventoryPage.vue';
|
||||
|
||||
export default { title: 'Pages/AdminInventoryPage', component: AdminInventoryPage };
|
||||
|
||||
export const Inventory = {};
|
||||
@@ -0,0 +1,149 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
|
||||
import {
|
||||
archiveAdminInventory,
|
||||
listAdminInventory,
|
||||
refreshAdminInventoryPricing,
|
||||
updateAdminInventoryTags,
|
||||
} from '@/services/admin-catalog-service.js';
|
||||
import { listAdminPricingJobs, retryAdminPricingJob } from '@/services/admin-jobs-service.js';
|
||||
import { listAdminValuations } from '@/services/admin-pricing-service.js';
|
||||
|
||||
const inventory = ref([]);
|
||||
const valuations = ref([]);
|
||||
const pricingJobs = ref([]);
|
||||
const selectedInventoryIds = ref([]);
|
||||
const inventoryTags = ref('');
|
||||
const isLoading = ref(true);
|
||||
const isSaving = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
function csrfToken() {
|
||||
return document.cookie.split('; ').find((value) => value.startsWith('pokemon_inventory_csrf='))?.split('=')[1] ?? '';
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
[inventory.value, valuations.value, pricingJobs.value] = await Promise.all([
|
||||
listAdminInventory(),
|
||||
listAdminValuations(),
|
||||
listAdminPricingJobs(),
|
||||
]);
|
||||
} catch (requestError) {
|
||||
error.value = requestError.message;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveSelectedInventory() {
|
||||
if (!selectedInventoryIds.value.length) return;
|
||||
isSaving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const archivedItems = await archiveAdminInventory(selectedInventoryIds.value, csrfToken());
|
||||
const archivedById = new Map(archivedItems.map((item) => [item.id, item]));
|
||||
inventory.value = inventory.value.map((item) => archivedById.get(item.id) ?? item);
|
||||
selectedInventoryIds.value = [];
|
||||
} catch (requestError) {
|
||||
error.value = requestError.message;
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSelectedPricing() {
|
||||
if (!selectedInventoryIds.value.length) return;
|
||||
isSaving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const jobs = await refreshAdminInventoryPricing(selectedInventoryIds.value, 'fixture-provider', csrfToken());
|
||||
pricingJobs.value = [...jobs, ...pricingJobs.value];
|
||||
selectedInventoryIds.value = [];
|
||||
} catch (requestError) {
|
||||
error.value = requestError.message;
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSelectedTags(action) {
|
||||
const tags = inventoryTags.value.split(',').map((tag) => tag.trim()).filter(Boolean);
|
||||
if (!selectedInventoryIds.value.length || !tags.length) return;
|
||||
isSaving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await updateAdminInventoryTags(
|
||||
selectedInventoryIds.value,
|
||||
action === 'add' ? tags : [],
|
||||
action === 'remove' ? tags : [],
|
||||
csrfToken(),
|
||||
);
|
||||
inventoryTags.value = '';
|
||||
selectedInventoryIds.value = [];
|
||||
} catch (requestError) {
|
||||
error.value = requestError.message;
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function retryPricingJob(jobId) {
|
||||
isSaving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await retryAdminPricingJob(jobId, csrfToken());
|
||||
pricingJobs.value = await listAdminPricingJobs();
|
||||
} catch (requestError) {
|
||||
error.value = requestError.message;
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="admin-page">
|
||||
<p class="catalog-kicker">Inventory management</p>
|
||||
<h1>Physical inventory</h1>
|
||||
<p v-if="isLoading" class="state state--loading" role="status">Loading inventory...</p>
|
||||
<p v-if="error" class="state state--error" role="alert">{{ error }}</p>
|
||||
<section v-if="!isLoading" class="admin-list" aria-labelledby="inventory-title">
|
||||
<h2 id="inventory-title">All physical copies</h2>
|
||||
<div class="admin-list__actions">
|
||||
<button class="button button--secondary" type="button" :disabled="isSaving || !selectedInventoryIds.length" @click="archiveSelectedInventory">Archive selected</button>
|
||||
<button class="button button--secondary" type="button" :disabled="isSaving || !selectedInventoryIds.length" @click="refreshSelectedPricing">Refresh pricing</button>
|
||||
<input v-model="inventoryTags" class="fixture-input" type="text" placeholder="Tags, comma separated" aria-label="Tags to add or remove" />
|
||||
<button class="button button--secondary" type="button" :disabled="isSaving || !selectedInventoryIds.length || !inventoryTags.trim()" @click="updateSelectedTags('add')">Add tags</button>
|
||||
<button class="button button--secondary" type="button" :disabled="isSaving || !selectedInventoryIds.length || !inventoryTags.trim()" @click="updateSelectedTags('remove')">Remove tags</button>
|
||||
</div>
|
||||
<p v-if="!inventory.length" class="state state--empty">No physical copies yet.</p>
|
||||
<ul v-else>
|
||||
<li v-for="item in inventory" :key="item.id" :data-product-id="item.productId">
|
||||
<label v-if="item.status === 'ACTIVE'">
|
||||
<input v-model="selectedInventoryIds" type="checkbox" :value="item.id" :aria-label="`Select inventory item ${item.id}`" />
|
||||
<span>{{ item.id }}</span>
|
||||
</label>
|
||||
<span v-else>{{ item.id }}</span>
|
||||
· {{ item.status }}
|
||||
</li>
|
||||
</ul>
|
||||
<ul v-if="valuations.length" aria-label="Market valuations">
|
||||
<li v-for="valuation in valuations" :key="valuation.id">
|
||||
{{ valuation.inventoryItemId }} · Market value ${{ (valuation.valueMinor / 100).toFixed(2) }}
|
||||
· {{ valuation.confidence }} confidence ·
|
||||
{{ valuation.sourceCount }} source{{ valuation.sourceCount === 1 ? "" : "s" }}<span v-if="valuation.stale"> · STALE</span>
|
||||
</li>
|
||||
</ul>
|
||||
<ul v-if="pricingJobs.length" aria-label="Pricing jobs">
|
||||
<li v-for="job in pricingJobs" :key="job.id">
|
||||
{{ job.id }} · {{ job.providerId }} · {{ job.status }}<span v-if="job.error"> · {{ job.error }}</span>
|
||||
<button v-if="job.status === 'FAILED'" class="button button--secondary" type="button" :disabled="isSaving" @click="retryPricingJob(job.id)">Retry job</button>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
@@ -19,6 +19,7 @@ async function logout() {
|
||||
<h1>Inventory</h1>
|
||||
<nav aria-label="Admin navigation">
|
||||
<a href="/admin">Overview</a>
|
||||
<RouterLink :to="{ name: 'admin-inventory' }">Inventory</RouterLink>
|
||||
<RouterLink :to="{ name: 'admin-listings' }">Listings</RouterLink>
|
||||
<RouterLink :to="{ name: 'admin-sales' }">Sales</RouterLink>
|
||||
<RouterLink :to="{ name: 'admin-acquisitions' }">Acquisitions</RouterLink>
|
||||
|
||||
@@ -13,6 +13,7 @@ import AdminSavedViewsPage from '@/pages/AdminSavedViewsPage.vue';
|
||||
import AdminListingsPage from '@/pages/AdminListingsPage.vue';
|
||||
import AdminSalesPage from '@/pages/AdminSalesPage.vue';
|
||||
import AdminAcquisitionsPage from '@/pages/AdminAcquisitionsPage.vue';
|
||||
import AdminInventoryPage from '@/pages/AdminInventoryPage.vue';
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -27,6 +28,7 @@ const router = createRouter({
|
||||
children: [
|
||||
{ path: '', name: 'admin-home', component: AdminHomePage },
|
||||
{ path: 'inventory/:id/images', name: 'admin-image-manager', component: AdminImagePage },
|
||||
{ path: 'inventory', name: 'admin-inventory', component: AdminInventoryPage },
|
||||
{ path: 'listings', name: 'admin-listings', component: AdminListingsPage },
|
||||
{ path: 'sales', name: 'admin-sales', component: AdminSalesPage },
|
||||
{ path: 'acquisitions', name: 'admin-acquisitions', component: AdminAcquisitionsPage },
|
||||
|
||||
@@ -58,3 +58,7 @@ export function enqueueAdminPricingRefresh(inventoryItemId, input, csrfToken) {
|
||||
export function getAdminPricingJob(jobId) {
|
||||
return request(`/admin/jobs/${encodeURIComponent(jobId)}`);
|
||||
}
|
||||
|
||||
export function listAdminValuations() {
|
||||
return request("/admin/valuations");
|
||||
}
|
||||
|
||||
@@ -171,4 +171,19 @@ test('creates an admin saved view through the CSRF-protected API', async () => {
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('lists seller-scoped latest market valuations', async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url) => {
|
||||
assert.equal(url, '/api/v1/admin/valuations');
|
||||
return { ok: true, async json() { return { data: [{ id: 'valuation-1', inventoryItemId: 'item-1', valueMinor: 2500 }] }; } };
|
||||
};
|
||||
|
||||
try {
|
||||
const { listAdminValuations } = await import('../src/services/admin-pricing-service.js');
|
||||
assert.deepEqual(await listAdminValuations(), [{ id: 'valuation-1', inventoryItemId: 'item-1', valueMinor: 2500 }]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -119,7 +119,7 @@ docker context use orbstack
|
||||
| HTTP | Product and inventory creation, validation, authentication/recovery, CSRF, rate limiting, and session management |
|
||||
| Integration | PostgreSQL catalog/session/audit/password recovery persistence and Redis-shared login throttling |
|
||||
|
||||
Verified locally: `npm test` passes 163 tests with no failures (22 domain, 78 application, and 63 HTTP); worker tests, web tests, web build, Storybook build, Storybook interaction/accessibility checks, integration tests, and authenticated E2E workflows also pass when their required local services and credentials are available.
|
||||
Verified locally: `npm test` passes 164 tests with no failures (22 domain, 78 application, and 64 HTTP); worker tests, web tests, web build, Storybook build, Storybook interaction/accessibility checks, integration tests, and authenticated E2E workflows also pass when their required local services and credentials are available.
|
||||
|
||||
## Next Milestone
|
||||
|
||||
@@ -182,6 +182,8 @@ Remaining roadmap areas are approved external provider credentials/adapters, bro
|
||||
- Listing management (publish/mark pending) is now a dedicated `/admin/listings` screen with its own navigation entry; the guided workflow overview links to it and only tracks listing creation and a lightweight count for wizard completion.
|
||||
- Sale recording/voiding and acquisition recording are now dedicated `/admin/sales` and `/admin/acquisitions` screens with their own navigation entries; the guided workflow overview links to both instead of embedding their forms and history inline.
|
||||
- The guided wizard now manages focus and announces step changes: selecting a stage (via the stepper or Back/Next) moves keyboard/assistive-technology focus to that stage's labeled region and updates a live-region status announcement, with Storybook interaction coverage.
|
||||
- Seller-scoped latest market valuations are now readable through `GET /api/v1/admin/valuations`.
|
||||
- Physical inventory management (bulk archive, bulk pricing refresh, bulk tag updates, market valuations, and pricing job retry) is now a dedicated `/admin/inventory` screen with its own navigation entry; the guided workflow overview links to it and only tracks a lightweight inventory count for wizard completion.
|
||||
|
||||
## Local Commands
|
||||
|
||||
|
||||
@@ -36,3 +36,19 @@ export function toAdminMarketValuationDto({ valuation, observations }) {
|
||||
observations: observations.map(toAdminPricingObservationDto),
|
||||
};
|
||||
}
|
||||
|
||||
export function toAdminMarketValuationSummaryDto(valuation) {
|
||||
return {
|
||||
id: valuation.id,
|
||||
inventoryItemId: valuation.inventoryItemId,
|
||||
valueMinor: valuation.valueMinor,
|
||||
currency: valuation.currency,
|
||||
strategy: valuation.strategy,
|
||||
confidence: valuation.confidence,
|
||||
sourceCount: valuation.sourceCount,
|
||||
calculatedAt: toIsoString(valuation.calculatedAt),
|
||||
stale:
|
||||
Date.now() - new Date(valuation.calculatedAt).getTime() >
|
||||
7 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user