feat(supply-chain): add fee ratio and operating profit

This commit is contained in:
WuYongJie 2026-08-19 10:01:21 +08:00
parent 8cfd3f8292
commit 6024abe7aa
9 changed files with 98 additions and 4 deletions

View File

@ -0,0 +1,4 @@
-- Add derived fee ratio and operating profit to the existing sales snapshot.
ALTER TABLE "SupplyChainProductArchiveSalesPerformance"
ADD COLUMN "feeRatio" DECIMAL(20,8),
ADD COLUMN "operatingProfit" DECIMAL(24,4);

View File

@ -580,7 +580,7 @@ model SupplyChainProductArchive {
@@index([firstSourceCode, createdAt])
}
// 销售宽表按条码汇总的每日快照;费比和营业利润额待完整费用字段开放后再接入。
// 销售宽表按条码汇总的每日快照。
model SupplyChainProductArchiveSalesPerformance {
archiveId String @id
archive SupplyChainProductArchive @relation(fields: [archiveId], references: [id], onDelete: Cascade)
@ -594,6 +594,8 @@ model SupplyChainProductArchiveSalesPerformance {
returnQty BigInt
grossProfit Decimal @db.Decimal(24, 4)
grossMargin Decimal? @db.Decimal(20, 8)
feeRatio Decimal? @db.Decimal(20, 8)
operatingProfit Decimal? @db.Decimal(24, 4)
syncedAt DateTime @default(now())
@@index([periodEnd])

View File

@ -9,6 +9,15 @@ export type ProductArchiveSalesPerformanceAggregate = {
returnQty: string
clearSalesCost: string
sendFreight: string
platformCommission: string
xyCost: string
afterSaleAmount: string
pushFeeTotal: string
marketingFeeTotal: string
advertisementFeeTotal: string
sharedWarehousingSum: string
otherFee: string
operatingFee: string
}
export interface ProductArchiveSalesPerformanceSourceRepository {
@ -112,7 +121,16 @@ class ProductArchiveSalesPerformanceSourceRepositoryClass
CONVERT(varchar(64), SUM(CAST(COALESCE(goods_count, 0) AS bigint))) AS shipQty,
CONVERT(varchar(64), SUM(CAST(COALESCE(refund_count, 0) AS bigint))) AS returnQty,
CONVERT(varchar(64), SUM(CAST(COALESCE(clear_sales_cost, 0) AS decimal(38, 4)))) AS clearSalesCost,
CONVERT(varchar(64), SUM(CAST(COALESCE(send_freight, 0) AS decimal(38, 4)))) AS sendFreight
CONVERT(varchar(64), SUM(CAST(COALESCE(send_freight, 0) AS decimal(38, 4)))) AS sendFreight,
CONVERT(varchar(64), SUM(CAST(COALESCE(platform_commission, 0) AS decimal(38, 4)))) AS platformCommission,
CONVERT(varchar(64), SUM(CAST(COALESCE(xy_cost, 0) AS decimal(38, 4)))) AS xyCost,
CONVERT(varchar(64), SUM(CAST(COALESCE(after_sale_amount, 0) AS decimal(38, 4)))) AS afterSaleAmount,
CONVERT(varchar(64), SUM(CAST(COALESCE(push_fee_total, 0) AS decimal(38, 4)))) AS pushFeeTotal,
CONVERT(varchar(64), SUM(CAST(COALESCE(marketing_fee_total, 0) AS decimal(38, 4)))) AS marketingFeeTotal,
CONVERT(varchar(64), SUM(CAST(COALESCE(advertisement_fee_total, 0) AS decimal(38, 4)))) AS advertisementFeeTotal,
CONVERT(varchar(64), SUM(CAST(COALESCE(shared_warehousing_sum, 0) AS decimal(38, 4)))) AS sharedWarehousingSum,
CONVERT(varchar(64), SUM(CAST(COALESCE(other_fee, 0) AS decimal(38, 4)))) AS otherFee,
CONVERT(varchar(64), SUM(CAST(COALESCE(operating_fee, 0) AS decimal(38, 4)))) AS operatingFee
FROM ${this.sourceObject()}
WHERE stat_date >= @periodStart
AND stat_date <= @periodEnd

View File

@ -71,6 +71,11 @@ class SupplyChainProductArchiveSalesPerformanceSyncRepositoryClass
snapshot.grossMargin === null
? null
: new Prisma.Decimal(snapshot.grossMargin),
feeRatio:
snapshot.feeRatio === null
? null
: new Prisma.Decimal(snapshot.feeRatio),
operatingProfit: new Prisma.Decimal(snapshot.operatingProfit),
syncedAt: snapshot.syncedAt,
})),
})

View File

@ -69,6 +69,8 @@ const productArchiveSelect = {
returnQty: true,
grossProfit: true,
grossMargin: true,
feeRatio: true,
operatingProfit: true,
},
},
createdAt: true,

View File

@ -11,6 +11,15 @@ describe('calculateProductArchiveSalesPerformance', () => {
returnQty: '1',
clearSalesCost: '60',
sendFreight: '15',
platformCommission: '2',
xyCost: '3',
afterSaleAmount: '4',
pushFeeTotal: '5',
marketingFeeTotal: '6',
advertisementFeeTotal: '7',
sharedWarehousingSum: '8',
otherFee: '1',
operatingFee: '2',
})).toEqual({
barcode: 'SKU-001',
shipAmount: '70.0000',
@ -20,6 +29,8 @@ describe('calculateProductArchiveSalesPerformance', () => {
returnQty: 1n,
grossProfit: '25.0000',
grossMargin: '0.25000000',
feeRatio: '1.13000000',
operatingProfit: '-13.0000',
})
})
@ -33,9 +44,20 @@ describe('calculateProductArchiveSalesPerformance', () => {
returnQty: '0',
clearSalesCost: '10',
sendFreight: '2',
platformCommission: '0',
xyCost: '0',
afterSaleAmount: '0',
pushFeeTotal: '0',
marketingFeeTotal: '0',
advertisementFeeTotal: '0',
sharedWarehousingSum: '0',
otherFee: '0',
operatingFee: '0',
})
expect(result.grossProfit).toBe('-12.0000')
expect(result.grossMargin).toBeNull()
expect(result.feeRatio).toBeNull()
expect(result.operatingProfit).toBe('-12.0000')
})
})

View File

@ -27,6 +27,15 @@ describe('SupplyChainProductArchiveSalesPerformanceSyncService', () => {
returnQty: '1',
clearSalesCost: '60',
sendFreight: '15',
platformCommission: '2',
xyCost: '3',
afterSaleAmount: '4',
pushFeeTotal: '5',
marketingFeeTotal: '6',
advertisementFeeTotal: '7',
sharedWarehousingSum: '8',
otherFee: '1',
operatingFee: '2',
},
{
barcode: 'NOT-IN-ARCHIVE',
@ -37,6 +46,15 @@ describe('SupplyChainProductArchiveSalesPerformanceSyncService', () => {
returnQty: '0',
clearSalesCost: '10',
sendFreight: '2',
platformCommission: '0',
xyCost: '0',
afterSaleAmount: '0',
pushFeeTotal: '0',
marketingFeeTotal: '0',
advertisementFeeTotal: '0',
sharedWarehousingSum: '0',
otherFee: '0',
operatingFee: '0',
},
]),
close: jest.fn().mockResolvedValue(undefined),
@ -76,6 +94,8 @@ describe('SupplyChainProductArchiveSalesPerformanceSyncService', () => {
barcode: 'SKU-001',
grossProfit: '25.0000',
grossMargin: '0.25000000',
feeRatio: '1.13000000',
operatingProfit: '-13.0000',
periodStart: new Date('2026-01-01T00:00:00Z'),
periodEnd: new Date('2026-08-15T00:00:00Z'),
})

View File

@ -10,6 +10,8 @@ export type ProductArchiveSalesPerformanceValues = {
returnQty: bigint
grossProfit: string
grossMargin: string | null
feeRatio: string | null
operatingProfit: string
}
export function normalizeSalesPerformanceBarcode(value: string) {
@ -43,10 +45,27 @@ export function calculateProductArchiveSalesPerformance(
const realPerformance = decimal(aggregate.realPerformance, 'realPerformance')
const clearSalesCost = decimal(aggregate.clearSalesCost, 'clearSalesCost')
const sendFreight = decimal(aggregate.sendFreight, 'sendFreight')
const totalExpense = [
sendFreight,
clearSalesCost,
decimal(aggregate.platformCommission, 'platformCommission'),
decimal(aggregate.xyCost, 'xyCost'),
decimal(aggregate.afterSaleAmount, 'afterSaleAmount'),
decimal(aggregate.pushFeeTotal, 'pushFeeTotal'),
decimal(aggregate.marketingFeeTotal, 'marketingFeeTotal'),
decimal(aggregate.advertisementFeeTotal, 'advertisementFeeTotal'),
decimal(aggregate.sharedWarehousingSum, 'sharedWarehousingSum'),
decimal(aggregate.otherFee, 'otherFee'),
decimal(aggregate.operatingFee, 'operatingFee'),
].reduce((total, value) => total.plus(value), new Prisma.Decimal(0))
const grossProfit = realPerformance.minus(clearSalesCost).minus(sendFreight)
const grossMargin = realPerformance.isZero()
? null
: grossProfit.dividedBy(realPerformance).toFixed(8)
const feeRatio = realPerformance.isZero()
? null
: totalExpense.dividedBy(realPerformance).toFixed(8)
const operatingProfit = realPerformance.minus(totalExpense)
return {
barcode,
@ -57,5 +76,7 @@ export function calculateProductArchiveSalesPerformance(
returnQty: integer(aggregate.returnQty, 'returnQty'),
grossProfit: grossProfit.toFixed(4),
grossMargin,
feeRatio,
operatingProfit: operatingProfit.toFixed(4),
}
}

View File

@ -100,8 +100,8 @@ export function mapProductArchiveRow({
shipQty: record.salesPerformance?.shipQty.toString() ?? '',
grossProfit: decimalText(record.salesPerformance?.grossProfit ?? null),
grossMargin: percentageText(record.salesPerformance?.grossMargin ?? null),
feeRatio: '',
operatingProfit: '',
feeRatio: percentageText(record.salesPerformance?.feeRatio ?? null),
operatingProfit: decimalText(record.salesPerformance?.operatingProfit ?? null),
returnQty: record.salesPerformance?.returnQty.toString() ?? '',
} satisfies SupplyChainProductArchiveRow
}