feat(pricing): 开发定价方案
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import { Pool } from 'pg';
|
||||
import type { Database } from '@atlas/database';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { afterAll, beforeAll, describe, it } from 'vitest';
|
||||
import { DATABASE } from '../src/database/database.module.js';
|
||||
import { createApplication } from '../src/main.js';
|
||||
|
||||
let app: NestExpressApplication;
|
||||
let db: Database;
|
||||
let migrator: Pool;
|
||||
let base = '',
|
||||
token = '',
|
||||
user = '',
|
||||
workspace = '',
|
||||
card = '',
|
||||
platform = '';
|
||||
const request = async (
|
||||
path: string,
|
||||
method = 'GET',
|
||||
body?: unknown,
|
||||
key?: string,
|
||||
) => {
|
||||
const response = await fetch(`${base}/api${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(key ? { 'Idempotency-Key': key } : {}),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
return { status: response.status, body: (await response.json()) as any };
|
||||
};
|
||||
|
||||
describe('系统物流费率审核', () => {
|
||||
beforeAll(async () => {
|
||||
Object.assign(process.env, {
|
||||
NODE_ENV: 'test',
|
||||
SWAGGER_ENABLED: 'false',
|
||||
AUTH_REGISTER_IP_LIMIT: '100',
|
||||
AUTH_REGISTER_EMAIL_LIMIT: '100',
|
||||
});
|
||||
app = (await createApplication()).app;
|
||||
db = app.get(DATABASE);
|
||||
migrator = new Pool({
|
||||
connectionString: process.env.MIGRATOR_DATABASE_URL,
|
||||
max: 1,
|
||||
});
|
||||
await app.listen(0, '127.0.0.1');
|
||||
base = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
|
||||
const registered = await request('/auth/register', 'POST', {
|
||||
email: `pricing-review-${randomUUID()}@example.test`,
|
||||
password: 'pricing-review-test-password-123',
|
||||
password_confirmation: 'pricing-review-test-password-123',
|
||||
});
|
||||
assert.equal(registered.status, 201, JSON.stringify(registered.body));
|
||||
token = registered.body.data.access_token;
|
||||
user = registered.body.data.user.id;
|
||||
workspace = registered.body.data.workspace.id;
|
||||
const scope = await db.query<{
|
||||
platform_id: string;
|
||||
provider_id: string;
|
||||
}>(
|
||||
`SELECT platform.id AS platform_id,provider.id AS provider_id FROM atlas.platforms platform CROSS JOIN atlas.logistics_providers provider WHERE upper(platform.code::text)='OZON' AND upper(provider.code::text)='CEL' LIMIT 1`,
|
||||
);
|
||||
platform = scope.rows[0].platform_id;
|
||||
const provider = scope.rows[0].provider_id;
|
||||
card = (
|
||||
await db.query<{ id: string }>(
|
||||
`INSERT INTO atlas.logistics_rate_cards(provider_id,platform_id,fulfillment,market_code,version,source_name,status,effective_from)
|
||||
VALUES($1,$2,'rfbs','RU',$3,'自动化审核测试,未来生效','draft',now()) RETURNING id`,
|
||||
[provider, platform, `qa-${randomUUID()}`],
|
||||
)
|
||||
).rows[0].id;
|
||||
const methods: string[] = [];
|
||||
for (const code of ['express', 'standard', 'economy']) {
|
||||
methods.push(
|
||||
(
|
||||
await db.query<{ id: string }>(
|
||||
`INSERT INTO atlas.logistics_transport_methods(rate_card_id,code,name) VALUES($1,$2::text,$2::text) RETURNING id`,
|
||||
[card, code],
|
||||
)
|
||||
).rows[0].id,
|
||||
);
|
||||
}
|
||||
for (const [index, code] of [
|
||||
'ExtraSmall',
|
||||
'Budget',
|
||||
'Small',
|
||||
'Big',
|
||||
'PremiumSmall',
|
||||
'PremiumBig',
|
||||
].entries()) {
|
||||
const type = (
|
||||
await db.query<{ id: string }>(
|
||||
`INSERT INTO atlas.logistics_product_types(rate_card_id,code,name,merchandise_currency_code,min_merchandise_value,max_merchandise_value,min_actual_weight_kg,max_actual_weight_kg,billing_basis)
|
||||
VALUES($1,$2::text,$2::text,'CNY',0,10000,0.001,30,'actual_only') RETURNING id`,
|
||||
[card, code],
|
||||
)
|
||||
).rows[0].id;
|
||||
for (const method of methods.slice(0, [3, 3, 3, 2, 3, 2][index]))
|
||||
await db.query(
|
||||
`INSERT INTO atlas.logistics_rate_rules(product_type_id,transport_method_id,fixed_fee_cny,per_billable_kg_fee_cny,status)
|
||||
VALUES($1,$2,3.12,10,'draft')`,
|
||||
[type, method],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (migrator) {
|
||||
if (card)
|
||||
await migrator.query(
|
||||
`DELETE FROM atlas.logistics_rate_card_reviews WHERE rate_card_id=$1`,
|
||||
[card],
|
||||
);
|
||||
if (user)
|
||||
await migrator.query(
|
||||
`DELETE FROM atlas.system_admin_grants WHERE user_id=$1`,
|
||||
[user],
|
||||
);
|
||||
}
|
||||
if (db) {
|
||||
if (card)
|
||||
await db.query(
|
||||
`DELETE FROM atlas.logistics_rate_cards WHERE id=$1`,
|
||||
[card],
|
||||
);
|
||||
if (user) {
|
||||
await db.query(
|
||||
`DELETE FROM atlas.workspace_memberships WHERE user_id=$1`,
|
||||
[user],
|
||||
);
|
||||
await db.query(`DELETE FROM atlas.workspaces WHERE id=$1`, [
|
||||
workspace,
|
||||
]);
|
||||
await db.query(`DELETE FROM atlas.users WHERE id=$1`, [user]);
|
||||
}
|
||||
}
|
||||
if (migrator) await migrator.end();
|
||||
if (app) await app.close();
|
||||
});
|
||||
|
||||
it('仅系统管理员可读取和启用;六类账单复算、重放与过期版本保护', async () => {
|
||||
assert.equal(
|
||||
(
|
||||
await request(
|
||||
`/system/pricing/rate-cards?platform_id=${platform}`,
|
||||
)
|
||||
).status,
|
||||
403,
|
||||
);
|
||||
await migrator.query(
|
||||
`INSERT INTO atlas.system_admin_grants(user_id) VALUES($1)`,
|
||||
[user],
|
||||
);
|
||||
const listed = await request(
|
||||
`/system/pricing/rate-cards?platform_id=${platform}`,
|
||||
);
|
||||
assert.equal(listed.status, 200, JSON.stringify(listed.body));
|
||||
const item = listed.body.data.find((row: any) => row.id === card);
|
||||
assert.equal(item.status, 'draft');
|
||||
assert.equal(item.types.length, 6);
|
||||
assert.equal(item.rules.length, 16);
|
||||
const input = {
|
||||
rules_hash: item.rules_hash,
|
||||
source_reference: 'CEL 真实账单 QA 批次-2026-09',
|
||||
review_note:
|
||||
'逐类型核对实际包装、货值、计费重量与物流账单费用;本测试版本仅在未来生效。',
|
||||
effective_from: new Date(Date.now() + 86400000).toISOString(),
|
||||
samples: item.types.map((type: any, index: number) => ({
|
||||
rule_id: item.rules.find(
|
||||
(rule: any) => rule.product_type_id === type.id,
|
||||
).id,
|
||||
invoice_reference: `QA-INVOICE-${index + 1}`,
|
||||
merchandise_value_cny: '100',
|
||||
shipping_weight_g: '1000',
|
||||
length_mm: '100',
|
||||
width_mm: '100',
|
||||
height_mm: '100',
|
||||
billed_logistics_cny: '13.12',
|
||||
})),
|
||||
};
|
||||
const key = randomUUID();
|
||||
const mismatch = structuredClone(input);
|
||||
mismatch.samples[0].billed_logistics_cny = '12.00';
|
||||
const rejected = await request(
|
||||
`/system/pricing/rate-cards/${card}/reviews`,
|
||||
'POST',
|
||||
mismatch,
|
||||
randomUUID(),
|
||||
);
|
||||
assert.equal(rejected.status, 422, JSON.stringify(rejected.body));
|
||||
const repeatedInvoice = structuredClone(input);
|
||||
repeatedInvoice.samples[1].invoice_reference =
|
||||
repeatedInvoice.samples[0].invoice_reference;
|
||||
assert.equal(
|
||||
(
|
||||
await request(
|
||||
`/system/pricing/rate-cards/${card}/reviews`,
|
||||
'POST',
|
||||
repeatedInvoice,
|
||||
randomUUID(),
|
||||
)
|
||||
).status,
|
||||
422,
|
||||
);
|
||||
assert.equal(
|
||||
(await request(`/system/pricing/rate-cards/${card}`)).body.data
|
||||
.status,
|
||||
'draft',
|
||||
);
|
||||
const [accepted, concurrentReplay] = await Promise.all([
|
||||
request(
|
||||
`/system/pricing/rate-cards/${card}/reviews`,
|
||||
'POST',
|
||||
input,
|
||||
key,
|
||||
),
|
||||
request(
|
||||
`/system/pricing/rate-cards/${card}/reviews`,
|
||||
'POST',
|
||||
input,
|
||||
key,
|
||||
),
|
||||
]);
|
||||
assert.equal(accepted.status, 201, JSON.stringify(accepted.body));
|
||||
assert.equal(
|
||||
concurrentReplay.status,
|
||||
201,
|
||||
JSON.stringify(concurrentReplay.body),
|
||||
);
|
||||
assert.equal(accepted.body.data.status, 'active');
|
||||
assert.equal(accepted.body.data.review.samples.length, 6);
|
||||
const replay = await request(
|
||||
`/system/pricing/rate-cards/${card}/reviews`,
|
||||
'POST',
|
||||
input,
|
||||
key,
|
||||
);
|
||||
assert.equal(replay.status, 201, JSON.stringify(replay.body));
|
||||
assert.equal(
|
||||
(
|
||||
await request(
|
||||
`/system/pricing/rate-cards/${card}/reviews`,
|
||||
'POST',
|
||||
mismatch,
|
||||
key,
|
||||
)
|
||||
).status,
|
||||
409,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request(
|
||||
`/system/pricing/rate-cards/${card}/reviews`,
|
||||
'POST',
|
||||
input,
|
||||
randomUUID(),
|
||||
)
|
||||
).status,
|
||||
409,
|
||||
);
|
||||
const activeRuleCount = await db.query<{ count: string }>(
|
||||
`SELECT count(*) FROM atlas.logistics_rate_rules r JOIN atlas.logistics_product_types t ON t.id=r.product_type_id WHERE t.rate_card_id=$1 AND r.status='active'`,
|
||||
[card],
|
||||
);
|
||||
assert.equal(Number(activeRuleCount.rows[0].count), 16);
|
||||
await assert.rejects(() =>
|
||||
db.query(
|
||||
`UPDATE atlas.logistics_rate_rules SET fixed_fee_cny=4 WHERE id=$1`,
|
||||
[item.rules[0].id],
|
||||
),
|
||||
);
|
||||
await assert.rejects(() =>
|
||||
db.query(
|
||||
`UPDATE atlas.logistics_rate_cards SET source_name='错误改写' WHERE id=$1`,
|
||||
[card],
|
||||
),
|
||||
);
|
||||
const draftCard = await db.query<{ id: string }>(
|
||||
`SELECT id FROM atlas.logistics_rate_cards WHERE version='cel-rfbs-review-2026-09' LIMIT 1`,
|
||||
);
|
||||
await assert.rejects(() =>
|
||||
db.query(
|
||||
`UPDATE atlas.logistics_product_types SET rate_card_id=$1 WHERE id=$2`,
|
||||
[draftCard.rows[0].id, item.types[0].id],
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'vitest';
|
||||
import { draftPricingEnabled } from '../src/config/app-config.module.js';
|
||||
|
||||
describe('草稿费率开发模式边界', () => {
|
||||
it('开发环境默认开放,可显式关闭;测试环境需显式开启', () => {
|
||||
assert.equal(draftPricingEnabled('development', 'auto'), true);
|
||||
assert.equal(draftPricingEnabled('development', 'false'), false);
|
||||
assert.equal(draftPricingEnabled('test', 'auto'), false);
|
||||
assert.equal(draftPricingEnabled('test', 'true'), true);
|
||||
});
|
||||
|
||||
it('生产环境即使误设开关也不能使用草稿费率', () => {
|
||||
assert.equal(draftPricingEnabled('production', 'true'), false);
|
||||
assert.equal(draftPricingEnabled('production', 'auto'), false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
calculatePricing,
|
||||
type LogisticsRule,
|
||||
type CommissionRule,
|
||||
} from '../src/pricing/pricing-engine.js';
|
||||
import { pricingPreviewRequestSchema, pricingInputKey } from '@atlas/contracts';
|
||||
const id = 'ad877f74-36d0-4ef9-a711-cd904b37912f';
|
||||
const input = () =>
|
||||
pricingPreviewRequestSchema.parse({
|
||||
store_id: id,
|
||||
platform_product_category_id: id,
|
||||
fulfillment: 'rfbs',
|
||||
purchase_cost_cny: '28',
|
||||
package: {
|
||||
shipping_weight_g: '380',
|
||||
length_mm: '220',
|
||||
width_mm: '160',
|
||||
height_mm: '90',
|
||||
status: 'confirmed',
|
||||
},
|
||||
pricing: {
|
||||
rate_card_id: id,
|
||||
transport_method_ids: [id],
|
||||
default_transport_method_id: id,
|
||||
domestic_shipping_and_label_fee_cny: '3',
|
||||
ad_rate: '0.03',
|
||||
last_mile_rate: '0.02',
|
||||
withdrawal_rate: '0.01',
|
||||
other_rate: '0.02',
|
||||
desired_markup_rate: '0.5',
|
||||
list_price_discount_rate: '0.2',
|
||||
},
|
||||
});
|
||||
const rule: LogisticsRule = {
|
||||
id,
|
||||
product_type_id: id,
|
||||
transport_method_id: id,
|
||||
transport_name: '标准',
|
||||
product_type_name: 'ExtraSmall',
|
||||
fixed_fee_cny: '3.12',
|
||||
per_billable_kg_fee_cny: '36.4',
|
||||
min_charge_cny: null,
|
||||
surcharge_rules: [],
|
||||
merchandise_currency_code: 'CNY',
|
||||
min_merchandise_value: null,
|
||||
max_merchandise_value: '135',
|
||||
min_value_inclusive: true,
|
||||
max_value_inclusive: true,
|
||||
min_actual_weight_kg: '0.001',
|
||||
min_actual_weight_inclusive: true,
|
||||
max_actual_weight_kg: '0.5',
|
||||
max_sum_dimensions_cm: '90',
|
||||
max_longest_side_cm: '60',
|
||||
max_second_longest_side_cm: null,
|
||||
billing_basis: 'actual_only',
|
||||
volume_divisor: null,
|
||||
volume_condition_sum_gt_cm: null,
|
||||
volume_round_scale: null,
|
||||
billing_round_scale: null,
|
||||
max_volume_weight_kg: null,
|
||||
volume_limit_round_scale: null,
|
||||
};
|
||||
const commission: CommissionRule = {
|
||||
id,
|
||||
currency_code: 'RUB',
|
||||
min_price: null,
|
||||
max_price: null,
|
||||
min_inclusive: true,
|
||||
max_inclusive: true,
|
||||
commission_rate: '0.12',
|
||||
version: 'test',
|
||||
};
|
||||
describe('十进制定价引擎', () => {
|
||||
it('目标加价与明细一致,CNY / RUB 都按最终币种舍入', () => {
|
||||
const c = calculatePricing(input(), 'CNY', '12.3', [rule], [commission])
|
||||
.options[0];
|
||||
expect(c.selling_price).toBe('77.44');
|
||||
expect(c.profit_cny).toBe('14.00');
|
||||
expect(c.list_price).toBe('96.80');
|
||||
expect(c.fees.logistics_cny).toBe('16.9520');
|
||||
const rub = calculatePricing(
|
||||
input(),
|
||||
'RUB',
|
||||
'12.3',
|
||||
[rule],
|
||||
[commission],
|
||||
).options[0];
|
||||
expect(rub.selling_price).toBe('952.52');
|
||||
expect(rub.can_confirm).toBe(true);
|
||||
});
|
||||
it('固定售价保持原价并识别亏损', () => {
|
||||
const i = input();
|
||||
i.pricing.mode = 'fixed_sale';
|
||||
i.pricing.selling_price = '10';
|
||||
const o = calculatePricing(i, 'CNY', '12.3', [rule], [commission])
|
||||
.options[0];
|
||||
expect(o.selling_price).toBe('10.00');
|
||||
expect(o.can_confirm).toBe(false);
|
||||
expect(Number(o.profit_cny)).toBeLessThan(0);
|
||||
});
|
||||
it('实际重、尺寸和开区间边界精确生效', () => {
|
||||
const i = input();
|
||||
i.package.shipping_weight_g = '500';
|
||||
expect(
|
||||
calculatePricing(i, 'CNY', '12.3', [rule], [commission]).options,
|
||||
).toHaveLength(1);
|
||||
i.package.shipping_weight_g = '500.001';
|
||||
expect(
|
||||
calculatePricing(i, 'CNY', '12.3', [rule], [commission]).options,
|
||||
).toHaveLength(0);
|
||||
i.package.shipping_weight_g = '380';
|
||||
expect(
|
||||
calculatePricing(
|
||||
i,
|
||||
'CNY',
|
||||
'12.3',
|
||||
[
|
||||
{
|
||||
...rule,
|
||||
min_actual_weight_kg: '0.380',
|
||||
min_actual_weight_inclusive: false,
|
||||
},
|
||||
],
|
||||
[commission],
|
||||
).options,
|
||||
).toHaveLength(0);
|
||||
});
|
||||
it('先舍入售价再检查货值及佣金档,不跨档蒙混', () => {
|
||||
const i = input();
|
||||
expect(
|
||||
calculatePricing(
|
||||
i,
|
||||
'CNY',
|
||||
'12.3',
|
||||
[{ ...rule, max_merchandise_value: '77.439' }],
|
||||
[commission],
|
||||
).options,
|
||||
).toHaveLength(0);
|
||||
expect(
|
||||
calculatePricing(
|
||||
i,
|
||||
'CNY',
|
||||
'12.3',
|
||||
[rule],
|
||||
[{ ...commission, max_price: '952.51' }],
|
||||
).options,
|
||||
).toHaveLength(0);
|
||||
});
|
||||
it('总费率达到 100% 拒绝,未知附加费不能漏算', () => {
|
||||
const i = input();
|
||||
i.pricing.ad_rate = '0.9';
|
||||
expect(
|
||||
calculatePricing(i, 'CNY', '12.3', [rule], [commission]).options,
|
||||
).toHaveLength(0);
|
||||
expect(
|
||||
calculatePricing(
|
||||
input(),
|
||||
'CNY',
|
||||
'12.3',
|
||||
[{ ...rule, surcharge_rules: [{ unknown: true }] }],
|
||||
[commission],
|
||||
).options,
|
||||
).toHaveLength(0);
|
||||
});
|
||||
it('体积重按指定位数进位并检查进位后的上限', () => {
|
||||
const i = input();
|
||||
i.package = {
|
||||
shipping_weight_g: '5000',
|
||||
length_mm: '500',
|
||||
width_mm: '400',
|
||||
height_mm: '301',
|
||||
status: 'confirmed',
|
||||
};
|
||||
const volume = {
|
||||
...rule,
|
||||
max_actual_weight_kg: '30',
|
||||
max_sum_dimensions_cm: '310',
|
||||
billing_basis: 'max_actual_volume',
|
||||
volume_divisor: '12000',
|
||||
volume_round_scale: 3,
|
||||
max_volume_weight_kg: '6',
|
||||
volume_limit_round_scale: 0,
|
||||
};
|
||||
expect(
|
||||
calculatePricing(
|
||||
i,
|
||||
'CNY',
|
||||
'12.3',
|
||||
[{ ...volume, max_merchandise_value: null }],
|
||||
[commission],
|
||||
).options[0].billable_weight_kg,
|
||||
).toBe('5.017');
|
||||
expect(
|
||||
calculatePricing(
|
||||
i,
|
||||
'CNY',
|
||||
'12.3',
|
||||
[{ ...volume, max_volume_weight_kg: '5' }],
|
||||
[commission],
|
||||
).options,
|
||||
).toHaveLength(0);
|
||||
});
|
||||
it('默认运输失效时不偷偷选择其他方式', () => {
|
||||
const i = input();
|
||||
i.pricing.default_transport_method_id =
|
||||
'8d6df67c-1fa3-45b9-9766-d4f5c813b903';
|
||||
expect(
|
||||
calculatePricing(i, 'CNY', '12.3', [rule], [commission])
|
||||
.recommended_option_id,
|
||||
).toBeNull();
|
||||
});
|
||||
it('格式化金额与 JSONB 字段重排不会使同一输入失效', () => {
|
||||
const original = input();
|
||||
const reordered = JSON.parse(
|
||||
JSON.stringify(original, (_key, value) =>
|
||||
value && typeof value === 'object' && !Array.isArray(value)
|
||||
? Object.fromEntries(Object.entries(value).reverse())
|
||||
: value,
|
||||
),
|
||||
);
|
||||
reordered.purchase_cost_cny = '28.00';
|
||||
reordered.package.shipping_weight_g = '380.000';
|
||||
reordered.pricing.ad_rate = '0.030000';
|
||||
expect(pricingInputKey(original)).toBe(pricingInputKey(reordered));
|
||||
reordered.package.length_mm = '221';
|
||||
expect(pricingInputKey(original)).not.toBe(pricingInputKey(reordered));
|
||||
});
|
||||
it('显示利润舍入为零时,真实微亏仍禁止确认', () => {
|
||||
const i = input();
|
||||
i.pricing.mode = 'fixed_sale';
|
||||
i.pricing.selling_price = '59.94';
|
||||
const option = calculatePricing(
|
||||
i,
|
||||
'CNY',
|
||||
'12.3',
|
||||
[{ ...rule, fixed_fee_cny: '3.121' }],
|
||||
[commission],
|
||||
).options[0];
|
||||
expect(option.profit_cny).toBe('-0.00');
|
||||
expect(option.can_confirm).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,653 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { AddressInfo } from 'node:net';
|
||||
import type { Database } from '@atlas/database';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import type {
|
||||
PricingPreviewRequest,
|
||||
ProductEditorSnapshot,
|
||||
SaveProductEditorDraftRequest,
|
||||
} from '@atlas/contracts';
|
||||
import { pricingPreviewRequestSchema } from '@atlas/contracts';
|
||||
import { beforeAll, afterAll, it, describe } from 'vitest';
|
||||
import { DATABASE } from '../src/database/database.module.js';
|
||||
import { createApplication } from '../src/main.js';
|
||||
let app: NestExpressApplication,
|
||||
db: Database,
|
||||
base = '',
|
||||
workspace = '',
|
||||
user = '',
|
||||
token = '',
|
||||
platform = '',
|
||||
store = '',
|
||||
category = '',
|
||||
product = '',
|
||||
profile = '',
|
||||
provider = '',
|
||||
card = '',
|
||||
transport = '',
|
||||
variant = '';
|
||||
async function request(
|
||||
path: string,
|
||||
method = 'GET',
|
||||
body?: unknown,
|
||||
extra: Record<string, string> = {},
|
||||
) {
|
||||
const r = await fetch(base + '/api' + path, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
...(method !== 'GET' ? { 'Idempotency-Key': randomUUID() } : {}),
|
||||
...extra,
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const data = (await r.json()) as any;
|
||||
return { status: r.status, ...data };
|
||||
}
|
||||
async function read(): Promise<ProductEditorSnapshot> {
|
||||
const r = await request(`/products/${product}/editor`, 'GET', undefined, {
|
||||
'X-Atlas-Platform-Id': platform,
|
||||
});
|
||||
assert.equal(r.status, 200, JSON.stringify(r));
|
||||
return r.data;
|
||||
}
|
||||
function draft(s: ProductEditorSnapshot): SaveProductEditorDraftRequest {
|
||||
return {
|
||||
ai_source_material: s.source_material,
|
||||
ai_source_overridden_fields: s.source_material_state.overridden_fields,
|
||||
common: {
|
||||
...s.product.common,
|
||||
default_physical_spec: s.product.default_physical_spec,
|
||||
default_package_spec: {
|
||||
shipping_weight_g: '380',
|
||||
length_mm: '220',
|
||||
width_mm: '160',
|
||||
height_mm: '90',
|
||||
status: 'confirmed',
|
||||
},
|
||||
},
|
||||
profile: {
|
||||
target_store_id: store,
|
||||
fulfillment: 'rfbs',
|
||||
title: s.profile.title,
|
||||
description: s.profile.description,
|
||||
tags: s.profile.tags,
|
||||
platform_product_category_id: category,
|
||||
category_source: 'manual',
|
||||
},
|
||||
variants: s.variants.map((v) => ({
|
||||
id: v.id,
|
||||
client_key: null,
|
||||
display_name: v.display_name,
|
||||
source_sku: v.source_sku,
|
||||
purchase_cost_cny: '28.00',
|
||||
specification_values: v.specification_values,
|
||||
title: v.title,
|
||||
tags: v.tags,
|
||||
description: v.description,
|
||||
physical_spec: v.physical_spec,
|
||||
package_spec: v.package_spec,
|
||||
offer_id: v.listing.offer_id,
|
||||
media_asset_ids: [],
|
||||
pricing_draft: v.listing.pricing_draft ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
function quote(
|
||||
variantId: string | null = variant,
|
||||
clientKey: string | null = null,
|
||||
): PricingPreviewRequest {
|
||||
return pricingPreviewRequestSchema.parse({
|
||||
store_id: store,
|
||||
platform_product_category_id: category,
|
||||
fulfillment: 'rfbs',
|
||||
binding: {
|
||||
profile_id: profile,
|
||||
variant_id: variantId,
|
||||
client_key: clientKey,
|
||||
},
|
||||
purchase_cost_cny: '28',
|
||||
package: {
|
||||
shipping_weight_g: '380',
|
||||
length_mm: '220',
|
||||
width_mm: '160',
|
||||
height_mm: '90',
|
||||
status: 'confirmed',
|
||||
},
|
||||
pricing: {
|
||||
rate_card_id: card,
|
||||
transport_method_ids: [transport],
|
||||
default_transport_method_id: transport,
|
||||
domestic_shipping_and_label_fee_cny: '3',
|
||||
ad_rate: '0.03',
|
||||
last_mile_rate: '0.02',
|
||||
withdrawal_rate: '0.01',
|
||||
other_rate: '0.02',
|
||||
desired_markup_rate: '0.5',
|
||||
list_price_discount_rate: '0.2',
|
||||
temporary_commission_rate: '0.12',
|
||||
fx: {
|
||||
mode: 'one_time_override',
|
||||
rate: '12.3',
|
||||
reason: '仅集成测试',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
async function save(
|
||||
s: ProductEditorSnapshot,
|
||||
d: SaveProductEditorDraftRequest,
|
||||
key = randomUUID(),
|
||||
) {
|
||||
return request(`/products/${product}/editor-draft`, 'PUT', d, {
|
||||
'X-Atlas-Platform-Id': platform,
|
||||
'If-Match': `"${s.product.revision}:${s.profile.revision}"`,
|
||||
'Idempotency-Key': key,
|
||||
});
|
||||
}
|
||||
describe('SKU 试算与原子确认 HTTP / PostgreSQL', () => {
|
||||
beforeAll(async () => {
|
||||
Object.assign(process.env, {
|
||||
NODE_ENV: 'test',
|
||||
PRICING_ALLOW_DRAFT_RATES: 'true',
|
||||
SWAGGER_ENABLED: 'false',
|
||||
AUTH_REGISTER_IP_LIMIT: '100',
|
||||
AUTH_REGISTER_EMAIL_LIMIT: '100',
|
||||
});
|
||||
app = (await createApplication()).app;
|
||||
db = app.get(DATABASE);
|
||||
await app.listen(0, '127.0.0.1');
|
||||
base = `http://127.0.0.1:${(app.getHttpServer().address() as AddressInfo).port}`;
|
||||
const reg = await request('/auth/register', 'POST', {
|
||||
email: `pricing-${randomUUID()}@example.test`,
|
||||
password: 'pricing-test-password-123',
|
||||
password_confirmation: 'pricing-test-password-123',
|
||||
});
|
||||
assert.equal(reg.status, 201, JSON.stringify(reg));
|
||||
workspace = reg.data.workspace.id;
|
||||
user = reg.data.user.id;
|
||||
token = reg.data.access_token;
|
||||
platform = (await request('/platforms')).data[0].id;
|
||||
await db.workspaceTransaction(workspace, async (tx) => {
|
||||
store = (
|
||||
await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.stores(workspace_id,platform_id,name,platform_client_id,currency_code,is_default,status,credential_status) VALUES($1,$2,'定价集成测试',$3,'CNY',true,'active','valid') RETURNING id`,
|
||||
[workspace, platform, randomUUID()],
|
||||
)
|
||||
).rows[0].id;
|
||||
category = (
|
||||
await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.platform_product_categories(platform_id,external_category_id,external_type_id,name,path) VALUES($1,$2,'test','测试类目','定价测试') RETURNING id`,
|
||||
[platform, randomUUID()],
|
||||
)
|
||||
).rows[0].id;
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.platform_catalog_resource_states(platform_id,scope,scope_key,category_id,last_success_at,is_complete) VALUES($1,'attributes','attributes:' || $2::uuid,$2,now(),true)`,
|
||||
[platform, category],
|
||||
);
|
||||
provider = (
|
||||
await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.logistics_providers(code,name) VALUES($1,'仅自动化测试') RETURNING id`,
|
||||
[randomUUID()],
|
||||
)
|
||||
).rows[0].id;
|
||||
card = (
|
||||
await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.logistics_rate_cards(provider_id,platform_id,fulfillment,version,source_name,status,effective_from,reviewed_at,reviewed_by_user_id) VALUES($1,$2,'rfbs','test-1','测试规则,不用于业务','active',now()-interval '1 day',now(),$3) RETURNING id`,
|
||||
[provider, platform, user],
|
||||
)
|
||||
).rows[0].id;
|
||||
transport = (
|
||||
await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.logistics_transport_methods(rate_card_id,code,name) VALUES($1,'standard','标准测试') RETURNING id`,
|
||||
[card],
|
||||
)
|
||||
).rows[0].id;
|
||||
const type = (
|
||||
await tx.query<{ id: string }>(
|
||||
`INSERT INTO atlas.logistics_product_types(rate_card_id,code,name,merchandise_currency_code,max_merchandise_value,max_actual_weight_kg,max_sum_dimensions_cm,max_longest_side_cm,billing_basis) VALUES($1,'ExtraSmall','小包测试','CNY',135,0.5,90,60,'actual_only') RETURNING id`,
|
||||
[card],
|
||||
)
|
||||
).rows[0].id;
|
||||
await tx.query(
|
||||
`INSERT INTO atlas.logistics_rate_rules(product_type_id,transport_method_id,fixed_fee_cny,per_billable_kg_fee_cny) VALUES($1,$2,3.12,36.4)`,
|
||||
[type, transport],
|
||||
);
|
||||
});
|
||||
const collection = await request('/collection-items', 'POST', {
|
||||
title: '定价集成测试商品',
|
||||
});
|
||||
assert.equal(collection.status, 201);
|
||||
product = collection.data.product_id;
|
||||
const context = await request(
|
||||
`/collection-items/${collection.data.collection_item.id}/editor-contexts`,
|
||||
'POST',
|
||||
undefined,
|
||||
{ 'X-Atlas-Platform-Id': platform },
|
||||
);
|
||||
assert.equal(context.status, 201, JSON.stringify(context));
|
||||
profile = context.data.profile_id;
|
||||
variant = (await read()).variants[0].id;
|
||||
});
|
||||
afterAll(async () => {
|
||||
if (db && workspace) {
|
||||
await db.workspaceTransaction(workspace, async (tx) => {
|
||||
// 仅清理本测试创建的租户,按外键依赖顺序删除。
|
||||
for (const table of [
|
||||
'idempotency_requests',
|
||||
'pricing_previews',
|
||||
'product_variant_listing_media',
|
||||
'product_variant_platform_listings',
|
||||
'pricing_quote_options',
|
||||
'pricing_quotes',
|
||||
'pricing_policy_transport_methods',
|
||||
'pricing_policies',
|
||||
'product_unmapped_attributes',
|
||||
'product_attributes',
|
||||
'product_variants',
|
||||
'product_platform_profiles',
|
||||
'products',
|
||||
'collection_items',
|
||||
'outbox_events',
|
||||
'operations',
|
||||
'stores',
|
||||
])
|
||||
await tx.query(
|
||||
`DELETE FROM atlas.${table} WHERE workspace_id=$1`,
|
||||
[workspace],
|
||||
);
|
||||
});
|
||||
if (card)
|
||||
await db.query(
|
||||
'DELETE FROM atlas.logistics_rate_cards WHERE id=$1',
|
||||
[card],
|
||||
);
|
||||
if (provider)
|
||||
await db.query(
|
||||
'DELETE FROM atlas.logistics_providers WHERE id=$1',
|
||||
[provider],
|
||||
);
|
||||
if (category) {
|
||||
await db.query(
|
||||
'DELETE FROM atlas.platform_catalog_resource_states WHERE category_id=$1',
|
||||
[category],
|
||||
);
|
||||
await db.query(
|
||||
'DELETE FROM atlas.platform_product_categories WHERE id=$1',
|
||||
[category],
|
||||
);
|
||||
}
|
||||
await db.query(
|
||||
'DELETE FROM atlas.workspace_memberships WHERE user_id=$1',
|
||||
[user],
|
||||
);
|
||||
await db.query('DELETE FROM atlas.workspaces WHERE id=$1', [
|
||||
workspace,
|
||||
]);
|
||||
await db.query('DELETE FROM atlas.users WHERE id=$1', [user]);
|
||||
}
|
||||
if (app) await app.close();
|
||||
});
|
||||
it('目录只返回审核规则,预览幂等且独立试算不写商品', async () => {
|
||||
const catalog = await request(`/pricing/catalog?store_id=${store}`);
|
||||
assert.equal(catalog.status, 200, JSON.stringify(catalog));
|
||||
assert.ok(catalog.data.rate_cards.some((c: any) => c.id === card));
|
||||
const categories = await request(
|
||||
`/pricing/categories?store_id=${store}&q=定价测试`,
|
||||
);
|
||||
assert.equal(categories.status, 200, JSON.stringify(categories));
|
||||
const input = quote(),
|
||||
key = randomUUID(),
|
||||
a = await request('/pricing/quote-previews', 'POST', input, {
|
||||
'Idempotency-Key': key,
|
||||
}),
|
||||
b = await request('/pricing/quote-previews', 'POST', input, {
|
||||
'Idempotency-Key': key,
|
||||
});
|
||||
assert.equal(a.status, 201, JSON.stringify(a));
|
||||
assert.deepEqual(b.data, a.data);
|
||||
assert.equal(a.data.options[0].selling_price, '77.44');
|
||||
assert.equal((await read()).variants[0].listing.selling_price, null);
|
||||
});
|
||||
it('原子确认、重放、重读与同输入再保存保持快照', async () => {
|
||||
const s = await read(),
|
||||
d = draft(s),
|
||||
input = quote(),
|
||||
p = await request('/pricing/quote-previews', 'POST', input);
|
||||
d.variants[0].pricing_draft = input.pricing;
|
||||
d.variants[0].pricing_selection = {
|
||||
preview_id: p.data.preview_id,
|
||||
option_id: p.data.recommended_option_id,
|
||||
input_hash: p.data.input_hash,
|
||||
};
|
||||
const key = randomUUID(),
|
||||
saved = await save(s, d, key);
|
||||
assert.equal(saved.status, 200, JSON.stringify(saved));
|
||||
assert.deepEqual((await save(s, d, key)).data, saved.data);
|
||||
const after = await read();
|
||||
assert.equal(after.variants[0].listing.pricing_valid, true);
|
||||
assert.equal(
|
||||
after.variants[0].listing.pricing_result?.profit_cny,
|
||||
'14.00',
|
||||
);
|
||||
assert.equal(Number(after.variants[0].listing.selling_price), 77.44);
|
||||
const again = await save(after, draft(after));
|
||||
assert.equal(again.status, 200, JSON.stringify(again));
|
||||
assert.equal((await read()).variants[0].listing.pricing_valid, true);
|
||||
});
|
||||
it('输入/有效期变化返回 SKU 错误,整笔商品保存回滚;无报价允许保存', async () => {
|
||||
const s = await read(),
|
||||
d = draft(s),
|
||||
input = quote(),
|
||||
p = await request('/pricing/quote-previews', 'POST', input);
|
||||
d.common.source_title = '不应被保存';
|
||||
d.variants[0].pricing_draft = input.pricing;
|
||||
d.variants[0].purchase_cost_cny = '29';
|
||||
d.variants[0].pricing_selection = {
|
||||
preview_id: p.data.preview_id,
|
||||
option_id: p.data.recommended_option_id,
|
||||
input_hash: p.data.input_hash,
|
||||
};
|
||||
const failed = await save(s, d);
|
||||
assert.equal(failed.status, 409, JSON.stringify(failed));
|
||||
assert.equal(failed.error.code, 'PRICING_PREVIEW_STALE');
|
||||
assert.equal((await read()).product.revision, s.product.revision);
|
||||
d.variants[0].purchase_cost_cny = '28';
|
||||
await db.workspaceTransaction(workspace, (tx) =>
|
||||
tx.query(
|
||||
`UPDATE atlas.pricing_previews SET expires_at=now()-interval '1 second' WHERE id=$1`,
|
||||
[p.data.preview_id],
|
||||
),
|
||||
);
|
||||
assert.equal((await save(s, d)).status, 409);
|
||||
d.variants[0].pricing_selection = null;
|
||||
d.variants[0].purchase_cost_cny = '29';
|
||||
assert.equal((await save(s, d)).status, 200);
|
||||
assert.equal((await read()).variants[0].listing.pricing_valid, false);
|
||||
});
|
||||
it('新 SKU client_key 与真实 ID 在同一事务映射', async () => {
|
||||
const s = await read(),
|
||||
d = draft(s),
|
||||
input = quote(null, 'new-pricing'),
|
||||
p = await request('/pricing/quote-previews', 'POST', input);
|
||||
d.variants.push({
|
||||
...d.variants[0],
|
||||
id: null,
|
||||
client_key: 'new-pricing',
|
||||
display_name: '新报价 SKU',
|
||||
pricing_draft: input.pricing,
|
||||
pricing_selection: {
|
||||
preview_id: p.data.preview_id,
|
||||
option_id: p.data.recommended_option_id,
|
||||
input_hash: p.data.input_hash,
|
||||
},
|
||||
});
|
||||
const saved = await save(s, d);
|
||||
assert.equal(saved.status, 200, JSON.stringify(saved));
|
||||
const after = await read(),
|
||||
newId = saved.data.created_variants[0].id;
|
||||
assert.equal(
|
||||
after.variants.find((v) => v.id === newId)?.listing.pricing_valid,
|
||||
true,
|
||||
);
|
||||
});
|
||||
it('其他租户不能读取预览或使用门店;报价复合外键拒绝跨 SKU 引用', async () => {
|
||||
const outsider = randomUUID();
|
||||
await db.workspaceTransaction(outsider, async (tx) => {
|
||||
assert.equal(
|
||||
(
|
||||
await tx.query(
|
||||
'SELECT id FROM atlas.pricing_previews WHERE store_id=$1',
|
||||
[store],
|
||||
)
|
||||
).rowCount,
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await tx.query(
|
||||
'SELECT id FROM atlas.pricing_quotes WHERE store_id=$1',
|
||||
[store],
|
||||
)
|
||||
).rowCount,
|
||||
0,
|
||||
);
|
||||
});
|
||||
const s = await read();
|
||||
await assert.rejects(() =>
|
||||
db.workspaceTransaction(workspace, async (tx) => {
|
||||
await tx.query(
|
||||
`UPDATE atlas.product_variant_platform_listings SET pricing_quote_id=(SELECT pricing_quote_id FROM atlas.product_variant_platform_listings WHERE product_variant_id=$1) WHERE product_variant_id=$2`,
|
||||
[s.variants[1].id, s.variants[0].id],
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
it('方案 CRUD、版本冲突、默认与归档不改变已确认报价', async () => {
|
||||
const body = {
|
||||
name: '测试默认方案',
|
||||
parameters: {
|
||||
...quote().pricing,
|
||||
fx: { mode: 'policy_fixed', rate: '12.3' },
|
||||
},
|
||||
allow_commission_override: true,
|
||||
};
|
||||
const c = await request(
|
||||
`/stores/${store}/pricing-policies`,
|
||||
'POST',
|
||||
body,
|
||||
);
|
||||
assert.equal(c.status, 201, JSON.stringify(c));
|
||||
const id = c.data.id;
|
||||
assert.equal(
|
||||
(await request(`/pricing-policies/${id}/set-default`, 'POST'))
|
||||
.status,
|
||||
200,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await request(`/pricing-policies/${id}`, 'PATCH', body, {
|
||||
'If-Match': '"1"',
|
||||
})
|
||||
).status,
|
||||
409,
|
||||
);
|
||||
const before = await read();
|
||||
assert.equal(
|
||||
(await request(`/pricing-policies/${id}`, 'DELETE')).status,
|
||||
200,
|
||||
);
|
||||
assert.deepEqual(
|
||||
(await read()).variants[1].listing.pricing_result,
|
||||
before.variants[1].listing.pricing_result,
|
||||
);
|
||||
});
|
||||
it('规则更新、独立预览和其他 SKU 预览不能被旧确认意图接受', async () => {
|
||||
const state = await read(),
|
||||
payload = draft(state),
|
||||
input = quote();
|
||||
const preview = await request('/pricing/quote-previews', 'POST', input);
|
||||
payload.variants[0].pricing_draft = input.pricing;
|
||||
payload.variants[0].pricing_selection = {
|
||||
preview_id: preview.data.preview_id,
|
||||
option_id: preview.data.recommended_option_id,
|
||||
input_hash: preview.data.input_hash,
|
||||
};
|
||||
await db.query(
|
||||
`UPDATE atlas.logistics_rate_cards SET version='test-2',updated_at=now() WHERE id=$1`,
|
||||
[card],
|
||||
);
|
||||
assert.equal(
|
||||
(await save(state, payload)).error.code,
|
||||
'PRICING_PREVIEW_STALE',
|
||||
);
|
||||
const standalone = await request('/pricing/quote-previews', 'POST', {
|
||||
...input,
|
||||
binding: null,
|
||||
});
|
||||
payload.variants[0].pricing_selection = {
|
||||
preview_id: standalone.data.preview_id,
|
||||
option_id: standalone.data.recommended_option_id,
|
||||
input_hash: standalone.data.input_hash,
|
||||
};
|
||||
assert.equal((await save(state, payload)).status, 409);
|
||||
const other = await request('/pricing/quote-previews', 'POST', {
|
||||
...input,
|
||||
binding: { ...input.binding, variant_id: state.variants[1].id },
|
||||
});
|
||||
payload.variants[0].pricing_selection = {
|
||||
preview_id: other.data.preview_id,
|
||||
option_id: other.data.recommended_option_id,
|
||||
input_hash: other.data.input_hash,
|
||||
};
|
||||
assert.equal((await save(state, payload)).status, 409);
|
||||
assert.equal((await read()).product.revision, state.product.revision);
|
||||
});
|
||||
it('确认快照不可更新;历史预览过期不使确认失效', async () => {
|
||||
const before = await read();
|
||||
await assert.rejects(() =>
|
||||
db.workspaceTransaction(workspace, (tx) =>
|
||||
tx.query(
|
||||
`UPDATE atlas.pricing_quotes SET purchase_cost_cny=0 WHERE workspace_id=$1 AND status='confirmed'`,
|
||||
[workspace],
|
||||
),
|
||||
),
|
||||
);
|
||||
await assert.rejects(() =>
|
||||
db.workspaceTransaction(workspace, (tx) =>
|
||||
tx.query(
|
||||
`UPDATE atlas.pricing_quote_options SET profit_rub=0 WHERE workspace_id=$1`,
|
||||
[workspace],
|
||||
),
|
||||
),
|
||||
);
|
||||
await db.workspaceTransaction(workspace, (tx) =>
|
||||
tx.query(
|
||||
`UPDATE atlas.pricing_previews SET expires_at=now()-interval '1 day' WHERE workspace_id=$1`,
|
||||
[workspace],
|
||||
),
|
||||
);
|
||||
assert.deepEqual(
|
||||
(await read()).variants[1].listing,
|
||||
before.variants[1].listing,
|
||||
);
|
||||
});
|
||||
it('没有当前报价的草稿保存成功,但发布校验给出定价问题', async () => {
|
||||
const state = await read(),
|
||||
payload = draft(state);
|
||||
payload.variants[0].purchase_cost_cny = '41.00';
|
||||
assert.equal((await save(state, payload)).status, 200);
|
||||
const validation = await request(
|
||||
`/product-platform-profiles/${profile}/validation`,
|
||||
'GET',
|
||||
undefined,
|
||||
{ 'X-Atlas-Platform-Id': platform },
|
||||
);
|
||||
assert.equal(validation.status, 200, JSON.stringify(validation));
|
||||
assert.ok(
|
||||
validation.data.variants[0].issues.some(
|
||||
(i: { code: string }) => i.code === 'PRICING_REQUIRED',
|
||||
),
|
||||
);
|
||||
});
|
||||
it('并发商品保存只有一个版本成功', async () => {
|
||||
const state = await read(),
|
||||
a = draft(state),
|
||||
b = draft(state);
|
||||
a.common.source_title = '并发 A';
|
||||
b.common.source_title = '并发 B';
|
||||
const results = await Promise.all([save(state, a), save(state, b)]);
|
||||
assert.deepEqual(results.map((r) => r.status).sort(), [200, 409]);
|
||||
});
|
||||
it('未审核费率、缺失正式佣金以及亏损候选不会变成发布报价', async () => {
|
||||
await db.query(
|
||||
`UPDATE atlas.logistics_rate_cards SET reviewed_at=NULL WHERE id=$1`,
|
||||
[card],
|
||||
);
|
||||
assert.equal(
|
||||
(await request('/pricing/quote-previews', 'POST', quote())).error
|
||||
.code,
|
||||
'PRICING_RULES_UNAVAILABLE',
|
||||
);
|
||||
await db.query(
|
||||
`UPDATE atlas.logistics_rate_cards SET reviewed_at=now() WHERE id=$1`,
|
||||
[card],
|
||||
);
|
||||
const input = quote();
|
||||
input.pricing.temporary_commission_rate = null;
|
||||
assert.equal(
|
||||
(await request('/pricing/quote-previews', 'POST', input)).error
|
||||
.code,
|
||||
'PRICING_COMMISSION_UNAVAILABLE',
|
||||
);
|
||||
input.pricing.temporary_commission_rate = '0.12';
|
||||
input.pricing.mode = 'fixed_sale';
|
||||
input.pricing.selling_price = '10';
|
||||
const p = await request('/pricing/quote-previews', 'POST', input);
|
||||
assert.equal(p.status, 201);
|
||||
assert.equal(p.data.options[0].can_confirm, false);
|
||||
const state = await read(),
|
||||
payload = draft(state);
|
||||
payload.variants[0].pricing_draft = input.pricing;
|
||||
payload.variants[0].pricing_selection = {
|
||||
preview_id: p.data.preview_id,
|
||||
option_id: p.data.options[0].id,
|
||||
input_hash: p.data.input_hash,
|
||||
};
|
||||
assert.equal((await save(state, payload)).status, 409);
|
||||
});
|
||||
it('开发草稿可试算并确认到商品编辑页,但不能通过首次上架校验', async () => {
|
||||
await db.query(
|
||||
`UPDATE atlas.logistics_rate_cards SET status='draft',reviewed_at=NULL,reviewed_by_user_id=NULL WHERE id=$1`,
|
||||
[card],
|
||||
);
|
||||
await db.query(
|
||||
`UPDATE atlas.logistics_rate_rules SET status='draft' WHERE product_type_id IN (SELECT id FROM atlas.logistics_product_types WHERE rate_card_id=$1)`,
|
||||
[card],
|
||||
);
|
||||
const catalog = await request(`/pricing/catalog?store_id=${store}`);
|
||||
assert.equal(catalog.status, 200, JSON.stringify(catalog));
|
||||
assert.equal(
|
||||
catalog.data.rate_cards.find((item: { id: string }) => item.id === card)
|
||||
?.development_only,
|
||||
true,
|
||||
);
|
||||
const input = quote();
|
||||
input.purchase_cost_cny = '41.00';
|
||||
const preview = await request('/pricing/quote-previews', 'POST', input);
|
||||
assert.equal(preview.status, 201, JSON.stringify(preview));
|
||||
assert.equal(preview.data.context.development_only, true);
|
||||
const state = await read();
|
||||
const payload = draft(state);
|
||||
payload.variants[0].purchase_cost_cny = '41.00';
|
||||
payload.variants[0].pricing_draft = input.pricing;
|
||||
payload.variants[0].pricing_selection = {
|
||||
preview_id: preview.data.preview_id,
|
||||
option_id: preview.data.recommended_option_id,
|
||||
input_hash: preview.data.input_hash,
|
||||
};
|
||||
const saved = await save(state, payload);
|
||||
assert.equal(saved.status, 200, JSON.stringify(saved));
|
||||
const validation = await request(
|
||||
`/product-platform-profiles/${profile}/validation`,
|
||||
'GET',
|
||||
undefined,
|
||||
{ 'X-Atlas-Platform-Id': platform },
|
||||
);
|
||||
assert.equal(validation.status, 200, JSON.stringify(validation));
|
||||
assert.ok(
|
||||
validation.data.variants[0].issues.some(
|
||||
(issue: { code: string }) =>
|
||||
issue.code === 'PRICING_RATE_REVIEW_REQUIRED',
|
||||
),
|
||||
);
|
||||
const rateCard = await db.query<{ status: string; reviewed_at: Date | null }>(
|
||||
'SELECT status,reviewed_at FROM atlas.logistics_rate_cards WHERE id=$1',
|
||||
[card],
|
||||
);
|
||||
assert.equal(rateCard.rows[0].status, 'draft');
|
||||
assert.equal(rateCard.rows[0].reviewed_at, null);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,7 @@ describe('SKU 图片处理结果的保存资格', () => {
|
||||
const service = new ProductEditorService(
|
||||
{} as ConstructorParameters<typeof ProductEditorService>[0],
|
||||
{} as ConstructorParameters<typeof ProductEditorService>[1],
|
||||
{ pricingAllowDraftRates: false } as ConstructorParameters<typeof ProductEditorService>[2],
|
||||
) as unknown as {
|
||||
persistListingMedia: (tx: ReturnType<typeof mediaTransaction>, value: typeof input) => Promise<void>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user