feat(M01-B): 严格转换旧库订单金额
This commit is contained in:
@@ -15,7 +15,7 @@
|
|||||||
"db:migrate:up": "npm run build && node dist/db/migrate-cli.js up",
|
"db:migrate:up": "npm run build && node dist/db/migrate-cli.js up",
|
||||||
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
|
"db:migrate:verify": "npm run build && node dist/db/migrate-cli.js verify",
|
||||||
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
|
"db:migrate:down": "npm run build && node dist/db/migrate-cli.js down",
|
||||||
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-read-repository.test.mjs"
|
"test": "npm run build && node tests/backend-contract.test.mjs && node tests/migration-contract.test.mjs && node tests/mysql-pool-contract.test.mjs && node tests/migration-runner.test.mjs && node tests/legacy-money.test.mjs && node tests/legacy-read-repository.test.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
export const MYSQL_UNSIGNED_INT_MAX = 4_294_967_295;
|
||||||
|
|
||||||
|
export interface LegacyMoneyOptions {
|
||||||
|
nullable?: boolean;
|
||||||
|
maxCents?: number;
|
||||||
|
field?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeField(field: string | undefined): string {
|
||||||
|
return field ? ` for ${field}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function legacyDecimalToCents(
|
||||||
|
value: unknown,
|
||||||
|
options: LegacyMoneyOptions = {}
|
||||||
|
): number | null {
|
||||||
|
const field = describeField(options.field);
|
||||||
|
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
if (options.nullable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw new Error(`Legacy money value${field} cannot be null.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value !== 'string' && typeof value !== 'number') {
|
||||||
|
throw new Error(`Legacy money value${field} must be a decimal string or number.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number' && !Number.isFinite(value)) {
|
||||||
|
throw new Error(`Legacy money value${field} must be finite.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const decimal = String(value);
|
||||||
|
const match = /^(\d+)(?:\.(\d{1,2}))?$/.exec(decimal);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(
|
||||||
|
`Legacy money value${field} must be a non-negative decimal with at most two fractional digits.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const wholeCents = BigInt(match[1]) * 100n;
|
||||||
|
const fraction = (match[2] ?? '').padEnd(2, '0');
|
||||||
|
const cents = wholeCents + BigInt(fraction || '0');
|
||||||
|
const maxCents = options.maxCents ?? MYSQL_UNSIGNED_INT_MAX;
|
||||||
|
|
||||||
|
if (!Number.isSafeInteger(maxCents) || maxCents < 0) {
|
||||||
|
throw new Error('Legacy money maxCents must be a non-negative safe integer.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cents > BigInt(maxCents)) {
|
||||||
|
throw new Error(`Legacy money value${field} exceeds the target cents column limit.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Number(cents);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { MySqlPool } from './mysql.js';
|
import type { MySqlPool } from './mysql.js';
|
||||||
|
import { legacyDecimalToCents } from './legacy-money.js';
|
||||||
|
|
||||||
type LegacyEntity = 'stores' | 'rooms' | 'orders' | 'devices';
|
type LegacyEntity = 'stores' | 'rooms' | 'orders' | 'devices';
|
||||||
|
|
||||||
@@ -12,6 +13,11 @@ export interface LegacyTableMapping {
|
|||||||
codeColumn?: string;
|
codeColumn?: string;
|
||||||
startColumn?: string;
|
startColumn?: string;
|
||||||
endColumn?: string;
|
endColumn?: string;
|
||||||
|
totalAmountColumn?: string;
|
||||||
|
paidAmountColumn?: string;
|
||||||
|
renewalAmountColumn?: string;
|
||||||
|
groupAmountColumn?: string;
|
||||||
|
refundAmountColumn?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LegacyReadOptions {
|
export interface LegacyReadOptions {
|
||||||
@@ -29,6 +35,11 @@ export interface LegacyRecord {
|
|||||||
status: string | null;
|
status: string | null;
|
||||||
startAt: Date | string | null;
|
startAt: Date | string | null;
|
||||||
endAt: Date | string | null;
|
endAt: Date | string | null;
|
||||||
|
totalAmountCents: number | null;
|
||||||
|
paidAmountCents: number | null;
|
||||||
|
renewalAmountCents: number | null;
|
||||||
|
groupAmountCents: number | null;
|
||||||
|
refundAmountCents: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultLegacyMappings: Record<LegacyEntity, LegacyTableMapping> = {
|
export const defaultLegacyMappings: Record<LegacyEntity, LegacyTableMapping> = {
|
||||||
@@ -56,7 +67,12 @@ export const defaultLegacyMappings: Record<LegacyEntity, LegacyTableMapping> = {
|
|||||||
codeColumn: 'order_no',
|
codeColumn: 'order_no',
|
||||||
statusColumn: 'status',
|
statusColumn: 'status',
|
||||||
startColumn: 'start_time',
|
startColumn: 'start_time',
|
||||||
endColumn: 'end_time'
|
endColumn: 'end_time',
|
||||||
|
totalAmountColumn: 'price',
|
||||||
|
paidAmountColumn: 'pay_price',
|
||||||
|
renewalAmountColumn: 'renew_price',
|
||||||
|
groupAmountColumn: 'group_pay_price',
|
||||||
|
refundAmountColumn: 'refund_price'
|
||||||
},
|
},
|
||||||
devices: {
|
devices: {
|
||||||
table: 'member_device_info',
|
table: 'member_device_info',
|
||||||
@@ -89,6 +105,81 @@ function normalizeLimit(limit = 100): number {
|
|||||||
return limit;
|
return limit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LegacyQueryRow extends Omit<
|
||||||
|
LegacyRecord,
|
||||||
|
| 'totalAmountCents'
|
||||||
|
| 'paidAmountCents'
|
||||||
|
| 'renewalAmountCents'
|
||||||
|
| 'groupAmountCents'
|
||||||
|
| 'refundAmountCents'
|
||||||
|
> {
|
||||||
|
totalAmountDecimal: unknown;
|
||||||
|
paidAmountDecimal: unknown;
|
||||||
|
renewalAmountDecimal: unknown;
|
||||||
|
groupAmountDecimal: unknown;
|
||||||
|
refundAmountDecimal: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMoney(
|
||||||
|
value: unknown,
|
||||||
|
mapping: LegacyTableMapping,
|
||||||
|
column: string | undefined,
|
||||||
|
nullable: boolean
|
||||||
|
): number | null {
|
||||||
|
if (!column) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return legacyDecimalToCents(value, {
|
||||||
|
field: `${mapping.table}.${column}`,
|
||||||
|
nullable
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRecord(row: LegacyQueryRow, mapping: LegacyTableMapping): LegacyRecord {
|
||||||
|
const {
|
||||||
|
totalAmountDecimal,
|
||||||
|
paidAmountDecimal,
|
||||||
|
renewalAmountDecimal,
|
||||||
|
groupAmountDecimal,
|
||||||
|
refundAmountDecimal,
|
||||||
|
...record
|
||||||
|
} = row;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...record,
|
||||||
|
totalAmountCents: normalizeMoney(
|
||||||
|
totalAmountDecimal,
|
||||||
|
mapping,
|
||||||
|
mapping.totalAmountColumn,
|
||||||
|
false
|
||||||
|
),
|
||||||
|
paidAmountCents: normalizeMoney(
|
||||||
|
paidAmountDecimal,
|
||||||
|
mapping,
|
||||||
|
mapping.paidAmountColumn,
|
||||||
|
true
|
||||||
|
),
|
||||||
|
renewalAmountCents: normalizeMoney(
|
||||||
|
renewalAmountDecimal,
|
||||||
|
mapping,
|
||||||
|
mapping.renewalAmountColumn,
|
||||||
|
true
|
||||||
|
),
|
||||||
|
groupAmountCents: normalizeMoney(
|
||||||
|
groupAmountDecimal,
|
||||||
|
mapping,
|
||||||
|
mapping.groupAmountColumn,
|
||||||
|
true
|
||||||
|
),
|
||||||
|
refundAmountCents: normalizeMoney(
|
||||||
|
refundAmountDecimal,
|
||||||
|
mapping,
|
||||||
|
mapping.refundAmountColumn,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export class LegacyReadRepository {
|
export class LegacyReadRepository {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly pool: Pick<MySqlPool, 'query'>,
|
private readonly pool: Pick<MySqlPool, 'query'>,
|
||||||
@@ -132,7 +223,12 @@ export class LegacyReadRepository {
|
|||||||
`${selectedColumn(mapping.codeColumn, 'code')},`,
|
`${selectedColumn(mapping.codeColumn, 'code')},`,
|
||||||
`${selectedColumn(mapping.statusColumn, 'status')},`,
|
`${selectedColumn(mapping.statusColumn, 'status')},`,
|
||||||
`${selectedColumn(mapping.startColumn, 'startAt')},`,
|
`${selectedColumn(mapping.startColumn, 'startAt')},`,
|
||||||
selectedColumn(mapping.endColumn, 'endAt'),
|
`${selectedColumn(mapping.endColumn, 'endAt')},`,
|
||||||
|
`${selectedColumn(mapping.totalAmountColumn, 'totalAmountDecimal')},`,
|
||||||
|
`${selectedColumn(mapping.paidAmountColumn, 'paidAmountDecimal')},`,
|
||||||
|
`${selectedColumn(mapping.renewalAmountColumn, 'renewalAmountDecimal')},`,
|
||||||
|
`${selectedColumn(mapping.groupAmountColumn, 'groupAmountDecimal')},`,
|
||||||
|
selectedColumn(mapping.refundAmountColumn, 'refundAmountDecimal'),
|
||||||
`FROM ${quoteIdentifier(mapping.table)}`,
|
`FROM ${quoteIdentifier(mapping.table)}`,
|
||||||
`WHERE ${clauses.join(' AND ')}`,
|
`WHERE ${clauses.join(' AND ')}`,
|
||||||
`ORDER BY ${quoteIdentifier(mapping.idColumn)} ASC`,
|
`ORDER BY ${quoteIdentifier(mapping.idColumn)} ASC`,
|
||||||
@@ -140,6 +236,6 @@ export class LegacyReadRepository {
|
|||||||
].join(' ');
|
].join(' ');
|
||||||
|
|
||||||
const [rows] = await this.pool.query(sql, parameters);
|
const [rows] = await this.pool.query(sql, parameters);
|
||||||
return rows as LegacyRecord[];
|
return (rows as LegacyQueryRow[]).map((row) => normalizeRecord(row, mapping));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
legacyDecimalToCents,
|
||||||
|
MYSQL_UNSIGNED_INT_MAX
|
||||||
|
} from '../dist/db/legacy-money.js';
|
||||||
|
|
||||||
|
assert.equal(legacyDecimalToCents('0'), 0);
|
||||||
|
assert.equal(legacyDecimalToCents('0.01'), 1);
|
||||||
|
assert.equal(legacyDecimalToCents('12.3'), 1230);
|
||||||
|
assert.equal(legacyDecimalToCents('12.34'), 1234);
|
||||||
|
assert.equal(legacyDecimalToCents(12.34), 1234);
|
||||||
|
assert.equal(legacyDecimalToCents('42949672.95'), MYSQL_UNSIGNED_INT_MAX);
|
||||||
|
assert.equal(legacyDecimalToCents(null, { nullable: true }), null);
|
||||||
|
|
||||||
|
for (const invalidValue of [
|
||||||
|
'',
|
||||||
|
' 1.00',
|
||||||
|
'1.00 ',
|
||||||
|
'-0.01',
|
||||||
|
'+1.00',
|
||||||
|
'1.001',
|
||||||
|
'1e2',
|
||||||
|
'NaN',
|
||||||
|
Number.NaN,
|
||||||
|
Number.POSITIVE_INFINITY,
|
||||||
|
{},
|
||||||
|
null
|
||||||
|
]) {
|
||||||
|
assert.throws(
|
||||||
|
() => legacyDecimalToCents(invalidValue),
|
||||||
|
/Legacy money/
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => legacyDecimalToCents('42949672.96', { field: 'member_order_info.price' }),
|
||||||
|
/member_order_info\.price.*exceeds/
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => legacyDecimalToCents('1.00', { maxCents: Number.MAX_SAFE_INTEGER + 1 }),
|
||||||
|
/maxCents/
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('PASS: legacy DECIMAL money conversion is exact and bounded.');
|
||||||
@@ -5,6 +5,7 @@ const calls = [];
|
|||||||
const fakePool = {
|
const fakePool = {
|
||||||
async query(sql, parameters) {
|
async query(sql, parameters) {
|
||||||
calls.push({ sql, parameters });
|
calls.push({ sql, parameters });
|
||||||
|
const isOrderQuery = /FROM `member_order_info`/.test(sql);
|
||||||
return [[{
|
return [[{
|
||||||
legacyId: '7',
|
legacyId: '7',
|
||||||
tenantId: '2',
|
tenantId: '2',
|
||||||
@@ -13,7 +14,12 @@ const fakePool = {
|
|||||||
code: 'A01',
|
code: 'A01',
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
startAt: null,
|
startAt: null,
|
||||||
endAt: null
|
endAt: null,
|
||||||
|
totalAmountDecimal: isOrderQuery ? '25.80' : null,
|
||||||
|
paidAmountDecimal: isOrderQuery ? '20.00' : null,
|
||||||
|
renewalAmountDecimal: isOrderQuery ? '5.8' : null,
|
||||||
|
groupAmountDecimal: isOrderQuery ? '0.00' : null,
|
||||||
|
refundAmountDecimal: null
|
||||||
}], []];
|
}], []];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -22,6 +28,11 @@ const repository = new LegacyReadRepository(fakePool);
|
|||||||
const rooms = await repository.listRooms({ tenantId: 2, parentId: 3, limit: 25 });
|
const rooms = await repository.listRooms({ tenantId: 2, parentId: 3, limit: 25 });
|
||||||
|
|
||||||
assert.equal(rooms.length, 1);
|
assert.equal(rooms.length, 1);
|
||||||
|
assert.equal(rooms[0].totalAmountCents, null);
|
||||||
|
assert.equal(rooms[0].paidAmountCents, null);
|
||||||
|
assert.equal(rooms[0].renewalAmountCents, null);
|
||||||
|
assert.equal(rooms[0].groupAmountCents, null);
|
||||||
|
assert.equal(rooms[0].refundAmountCents, null);
|
||||||
assert.equal(calls.length, 1);
|
assert.equal(calls.length, 1);
|
||||||
assert.match(calls[0].sql, /FROM `member_room_info`/);
|
assert.match(calls[0].sql, /FROM `member_room_info`/);
|
||||||
assert.match(calls[0].sql, /`tenant_id` = \?/);
|
assert.match(calls[0].sql, /`tenant_id` = \?/);
|
||||||
@@ -30,6 +41,16 @@ assert.match(calls[0].sql, /ORDER BY `id` ASC LIMIT \?/);
|
|||||||
assert.deepEqual(calls[0].parameters, [2, 3, 25]);
|
assert.deepEqual(calls[0].parameters, [2, 3, 25]);
|
||||||
assert.doesNotMatch(calls[0].sql, /\b(?:INSERT|UPDATE|DELETE|REPLACE)\b/i);
|
assert.doesNotMatch(calls[0].sql, /\b(?:INSERT|UPDATE|DELETE|REPLACE)\b/i);
|
||||||
|
|
||||||
|
const orders = await repository.listOrders({ tenantId: 2, parentId: 3, limit: 10 });
|
||||||
|
assert.equal(orders[0].totalAmountCents, 2580);
|
||||||
|
assert.equal(orders[0].paidAmountCents, 2000);
|
||||||
|
assert.equal(orders[0].renewalAmountCents, 580);
|
||||||
|
assert.equal(orders[0].groupAmountCents, 0);
|
||||||
|
assert.equal(orders[0].refundAmountCents, null);
|
||||||
|
assert.match(calls[1].sql, /`price` AS `totalAmountDecimal`/);
|
||||||
|
assert.match(calls[1].sql, /`pay_price` AS `paidAmountDecimal`/);
|
||||||
|
assert.match(calls[1].sql, /`refund_price` AS `refundAmountDecimal`/);
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
() => repository.listStores({ tenantId: 2, limit: 501 }),
|
() => repository.listStores({ tenantId: 2, limit: 501 }),
|
||||||
/between 1 and 500/
|
/between 1 and 500/
|
||||||
@@ -63,4 +84,54 @@ await assert.rejects(
|
|||||||
/Unsafe legacy SQL identifier/
|
/Unsafe legacy SQL identifier/
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const invalidMoneyRepository = new LegacyReadRepository({
|
||||||
|
async query() {
|
||||||
|
return [[{
|
||||||
|
legacyId: '8',
|
||||||
|
tenantId: '2',
|
||||||
|
parentId: '3',
|
||||||
|
name: null,
|
||||||
|
code: 'ORDER-8',
|
||||||
|
status: 'PAID',
|
||||||
|
startAt: null,
|
||||||
|
endAt: null,
|
||||||
|
totalAmountDecimal: '1.001',
|
||||||
|
paidAmountDecimal: '1.00',
|
||||||
|
renewalAmountDecimal: '0.00',
|
||||||
|
groupAmountDecimal: '0.00',
|
||||||
|
refundAmountDecimal: '0.00'
|
||||||
|
}], []];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => invalidMoneyRepository.listOrders({ tenantId: 2 }),
|
||||||
|
/member_order_info\.price/
|
||||||
|
);
|
||||||
|
|
||||||
|
const missingRequiredMoneyRepository = new LegacyReadRepository({
|
||||||
|
async query() {
|
||||||
|
return [[{
|
||||||
|
legacyId: '9',
|
||||||
|
tenantId: '2',
|
||||||
|
parentId: '3',
|
||||||
|
name: null,
|
||||||
|
code: 'ORDER-9',
|
||||||
|
status: 'PAID',
|
||||||
|
startAt: null,
|
||||||
|
endAt: null,
|
||||||
|
totalAmountDecimal: null,
|
||||||
|
paidAmountDecimal: null,
|
||||||
|
renewalAmountDecimal: null,
|
||||||
|
groupAmountDecimal: null,
|
||||||
|
refundAmountDecimal: null
|
||||||
|
}], []];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => missingRequiredMoneyRepository.listOrders({ tenantId: 2 }),
|
||||||
|
/member_order_info\.price.*cannot be null/
|
||||||
|
);
|
||||||
|
|
||||||
console.log('PASS: legacy read-only repository contracts are present.');
|
console.log('PASS: legacy read-only repository contracts are present.');
|
||||||
|
|||||||
Reference in New Issue
Block a user