This commit is contained in:
@@ -139,7 +139,7 @@ async function startServer() {
|
|||||||
saleService: createSaleService({ saleRepository }),
|
saleService: createSaleService({ saleRepository }),
|
||||||
acquisitionService: createAcquisitionService({ acquisitionRepository }),
|
acquisitionService: createAcquisitionService({ acquisitionRepository }),
|
||||||
}),
|
}),
|
||||||
pricingService: createPricingService({ pricingRepository, pricingRefreshJobRepository }),
|
pricingService: createPricingService({ pricingRepository, pricingRefreshJobRepository, providerMappingRepository }),
|
||||||
providerMappingService: createProviderMappingService({ providerMappingRepository }),
|
providerMappingService: createProviderMappingService({ providerMappingRepository }),
|
||||||
publicListingService: createPublicListingService({ listingRepository, publicImageService }),
|
publicListingService: createPublicListingService({ listingRepository, publicImageService }),
|
||||||
productService: createProductService({ productRepository }),
|
productService: createProductService({ productRepository }),
|
||||||
|
|||||||
@@ -23,6 +23,19 @@ export function createProviderMappingRepository(db) {
|
|||||||
.then((rows) => rows.map(({ mapping }) => mapping));
|
.then((rows) => rows.map(({ mapping }) => mapping));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async findActiveForSeller(productId, sellerId, providerId) {
|
||||||
|
const conditions = [
|
||||||
|
eq(pricingProviderMappings.productId, productId),
|
||||||
|
eq(products.sellerId, sellerId),
|
||||||
|
eq(pricingProviderMappings.status, 'ACTIVE'),
|
||||||
|
];
|
||||||
|
if (providerId) conditions.push(eq(pricingProviderMappings.providerId, providerId));
|
||||||
|
const [row] = await db.select({ mapping: pricingProviderMappings }).from(pricingProviderMappings)
|
||||||
|
.innerJoin(products, eq(products.id, pricingProviderMappings.productId))
|
||||||
|
.where(and(...conditions));
|
||||||
|
return row?.mapping ?? null;
|
||||||
|
},
|
||||||
|
|
||||||
async disableForSeller(id, sellerId) {
|
async disableForSeller(id, sellerId) {
|
||||||
const [mapping] = await db.select().from(pricingProviderMappings)
|
const [mapping] = await db.select().from(pricingProviderMappings)
|
||||||
.innerJoin(products, eq(products.id, pricingProviderMappings.productId))
|
.innerJoin(products, eq(products.id, pricingProviderMappings.productId))
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createPricingObservation } from '../domain/pricing-observation.js';
|
import { createPricingObservation } from '../domain/pricing-observation.js';
|
||||||
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
import { createApplicationError } from '../../../shared/errors/application-error.js';
|
||||||
|
|
||||||
export function createPricingService({ pricingRepository, pricingProviderRegistry, pricingRefreshJobRepository }) {
|
export function createPricingService({ pricingRepository, pricingProviderRegistry, pricingRefreshJobRepository, providerMappingRepository }) {
|
||||||
return {
|
return {
|
||||||
async addObservation({ sellerId, ...input }) {
|
async addObservation({ sellerId, ...input }) {
|
||||||
const observation = createPricingObservation(input);
|
const observation = createPricingObservation(input);
|
||||||
@@ -55,7 +55,15 @@ export function createPricingService({ pricingRepository, pricingProviderRegistr
|
|||||||
|
|
||||||
async enqueueRefresh({ sellerId, inventoryItemId, providerId, productId, conditionId }) {
|
async enqueueRefresh({ sellerId, inventoryItemId, providerId, productId, conditionId }) {
|
||||||
if (!pricingRefreshJobRepository) throw createApplicationError('PRICING_QUEUE_UNAVAILABLE', 'Pricing refresh queue is unavailable.', 503);
|
if (!pricingRefreshJobRepository) throw createApplicationError('PRICING_QUEUE_UNAVAILABLE', 'Pricing refresh queue is unavailable.', 503);
|
||||||
const job = await pricingRefreshJobRepository.create({ sellerId, inventoryItemId, providerId, productId, conditionId });
|
const mapping = await providerMappingRepository?.findActiveForSeller(productId, sellerId, providerId);
|
||||||
|
if (!mapping) throw createApplicationError('PRICING_PROVIDER_MAPPING_NOT_FOUND', 'No active pricing provider mapping exists for this product.', 409);
|
||||||
|
const job = await pricingRefreshJobRepository.create({
|
||||||
|
sellerId,
|
||||||
|
inventoryItemId,
|
||||||
|
providerId: mapping.providerId,
|
||||||
|
productId,
|
||||||
|
conditionId,
|
||||||
|
});
|
||||||
return job;
|
return job;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -55,9 +55,11 @@ test('enqueues a seller-scoped pricing refresh and reads its status', async () =
|
|||||||
async create(input) { return { id: 'job-1', status: 'PENDING', ...input }; },
|
async create(input) { return { id: 'job-1', status: 'PENDING', ...input }; },
|
||||||
async findByIdForSeller(id, sellerId) { return { id, sellerId, status: 'COMPLETED' }; },
|
async findByIdForSeller(id, sellerId) { return { id, sellerId, status: 'COMPLETED' }; },
|
||||||
},
|
},
|
||||||
|
providerMappingRepository: { async findActiveForSeller() { return { providerId: 'mapped-provider' }; } },
|
||||||
});
|
});
|
||||||
|
|
||||||
const job = await service.enqueueRefresh({ sellerId: 'seller-1', inventoryItemId: 'item-1', providerId: 'provider-1', productId: 'product-1', conditionId: 'condition-1' });
|
const job = await service.enqueueRefresh({ sellerId: 'seller-1', inventoryItemId: 'item-1', providerId: 'provider-1', productId: 'product-1', conditionId: 'condition-1' });
|
||||||
assert.equal(job.status, 'PENDING');
|
assert.equal(job.status, 'PENDING');
|
||||||
|
assert.equal(job.providerId, 'mapped-provider');
|
||||||
assert.equal((await service.getJob({ sellerId: 'seller-1', jobId: job.id })).status, 'COMPLETED');
|
assert.equal((await service.getJob({ sellerId: 'seller-1', jobId: job.id })).status, 'COMPLETED');
|
||||||
});
|
});
|
||||||
@@ -70,4 +70,30 @@ test('enqueues a pricing refresh and exposes seller-scoped job status', async (t
|
|||||||
const jobResponse = await app.inject({ method: 'GET', url: '/api/v1/admin/jobs/job-1' });
|
const jobResponse = await app.inject({ method: 'GET', url: '/api/v1/admin/jobs/job-1' });
|
||||||
assert.equal(jobResponse.statusCode, 200);
|
assert.equal(jobResponse.statusCode, 200);
|
||||||
assert.equal(jobResponse.json().data.status, 'COMPLETED');
|
assert.equal(jobResponse.json().data.status, 'COMPLETED');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns a conflict when refresh has no active product mapping', async (testContext) => {
|
||||||
|
const app = buildApp({
|
||||||
|
authenticateAdmin: async () => ({ sellerId: 'seller-1' }),
|
||||||
|
authorizeUnsafeRequest: async () => {},
|
||||||
|
pricingService: {
|
||||||
|
async enqueueRefresh() {
|
||||||
|
const error = new Error('No active pricing provider mapping exists for this product.');
|
||||||
|
error.code = 'PRICING_PROVIDER_MAPPING_NOT_FOUND';
|
||||||
|
error.statusCode = 409;
|
||||||
|
throw error;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
testContext.after(() => app.close());
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/v1/admin/inventory/item-1/pricing/refresh',
|
||||||
|
headers: { 'x-csrf-token': 'csrf' },
|
||||||
|
payload: { productId: 'product-1', conditionId: 'condition-1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(response.statusCode, 409);
|
||||||
|
assert.equal(response.json().error.code, 'PRICING_PROVIDER_MAPPING_NOT_FOUND');
|
||||||
});
|
});
|
||||||
+2
-1
@@ -130,7 +130,8 @@ condition label, and public image URLs.
|
|||||||
Pricing refresh returns `202 Accepted` with a durable job identifier and
|
Pricing refresh returns `202 Accepted` with a durable job identifier and
|
||||||
`PENDING` status. Poll the job endpoint for `PROCESSING`, `COMPLETED`, or
|
`PENDING` status. Poll the job endpoint for `PROCESSING`, `COMPLETED`, or
|
||||||
`FAILED`; job status reads are seller-scoped and expose only safe status/error
|
`FAILED`; job status reads are seller-scoped and expose only safe status/error
|
||||||
metadata.
|
metadata. Refresh enqueue resolves the provider from an active Product-level
|
||||||
|
mapping and returns `409` when no mapping exists.
|
||||||
|
|
||||||
## Provider matching
|
## Provider matching
|
||||||
|
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ Work through one bounded vertical slice at a time. The linked documentation rema
|
|||||||
- The pricing refresh worker now claims durable PostgreSQL jobs with row locking, persists completed/failed outcomes, and routes provider observations through the pricing service; configured external providers remain deployment work.
|
- The pricing refresh worker now claims durable PostgreSQL jobs with row locking, persists completed/failed outcomes, and routes provider observations through the pricing service; configured external providers remain deployment work.
|
||||||
- Pricing refresh requests now enqueue seller-scoped jobs and expose a safe `GET /api/v1/admin/jobs/:id` polling contract instead of doing provider work inline.
|
- Pricing refresh requests now enqueue seller-scoped jobs and expose a safe `GET /api/v1/admin/jobs/:id` polling contract instead of doing provider work inline.
|
||||||
- Product-level provider mappings now persist seller-scoped `PRODUCT`/`SEARCH` relationships and support active listing and controlled disabling.
|
- Product-level provider mappings now persist seller-scoped `PRODUCT`/`SEARCH` relationships and support active listing and controlled disabling.
|
||||||
|
- Pricing refresh enqueue now resolves its provider from an active Product mapping and rejects unmapped Products explicitly.
|
||||||
- `npm install` currently reports dependency audit findings. Review and remediate them before production deployment; do not use `npm audit fix --force` without reviewing its changes.
|
- `npm install` currently reports dependency audit findings. Review and remediate them before production deployment; do not use `npm audit fix --force` without reviewing its changes.
|
||||||
|
|
||||||
## Local Commands
|
## Local Commands
|
||||||
|
|||||||
Reference in New Issue
Block a user