2026-10-07 19:35:18 +03:30
/**
* Reconciliation (GO-LIVE-PLAN §7): what legacy has vs what the target now
* holds, with the tolerance of DATA-MIGRATION-MAP §4 instead of "zero
* difference", which double → Decimal rounding makes impossible.
*/
import { Prisma } from '@prisma/client' ;
import type { Ctx } from '../lib/context' ;
import { toNumber } from '../lib/normalize' ;
2026-10-08 10:40:21 +03:30
import { agentSalesWhere , customerSalesWhere } from '../../../src/modules/sales-agent/agent-orders' ;
2026-10-07 19:35:18 +03:30
type Row = Record < string , unknown >;
const TOLERANCE = new Prisma . Decimal ( '0.0001' );
export async function reconcile ( ctx : Ctx ) {
const S = 'reconciliation' ;
/* counts: legacy rows in scope vs ids mapped */
const scope : [ string , string , string ][] = [
[ 'cities' , 'city' , `SELECT COUNT(*) n FROM cities WHERE deleted_at IS NULL` ],
[ 'branches' , 'branch' , `SELECT COUNT(*) n FROM branches WHERE deleted_at IS NULL` ],
[ 'workshops' , 'workshop' , `SELECT COUNT(*) n FROM workshops WHERE deleted_at IS NULL` ],
[ 'admin users' , 'admin' , `SELECT COUNT(*) n FROM users WHERE deleted_at IS NULL` ],
[ 'sales experts' , 'expert' , `SELECT COUNT(*) n FROM customers WHERE deleted_at IS NULL AND type = 1` ],
[ 'live products' , 'product' , `SELECT COUNT(*) n FROM product WHERE deleted_at IS NULL` ],
[ 'live variants' , 'variant' , `SELECT COUNT(*) n FROM products_productvarient WHERE deleted_at IS NULL` ],
[ 'live orders' , 'order' , `SELECT COUNT(*) n FROM orders WHERE deleted_at IS NULL` ],
[ 'custom orders' , 'preorder' , `SELECT COUNT(*) n FROM custom_orders WHERE deleted_at IS NULL` ],
];
const rows : string [] = [ '| | legacy | mapped |' , '|---|---:|---:|' ];
for ( const [ label , entity , sql ] of scope ) {
const [{ n }] = await ctx . q < { n : number } > ( sql );
const [{ m }] = await ctx . db . $queryRawUnsafe < { m : bigint }[] > (
`SELECT COUNT(*) AS m FROM legacy_import.id_map WHERE entity = $1` , entity ,
);
rows . push ( `| ${ label } | ${ String ( n ) } | ${ String ( m ) } |` );
}
ctx . report . note ( S , `Counts (mapped may exceed legacy live rows where deleted rows were kept for order history): \ n \ n ${ rows . join ( '\n' ) } \ n` );
/* balances: every migrated customer, legacy stored vs target cached */
const legacy = await ctx . q < Row >( 'SELECT id, remain, rial_remain FROM customers WHERE deleted_at IS NULL' );
const want = new Map < string , { gold : number ; rial : number }>();
for ( const r of legacy ) {
const id = ctx . map . get ( 'customer' , r . id );
if ( ! id ) continue ;
const w = want . get ( id ) ?? { gold : 0 , rial : 0 };
w . gold += toNumber ( r . remain );
w . rial += toNumber ( r . rial_remain );
want . set ( id , w );
}
const targets = await ctx . db . customer . findMany ({
where : { id : { in : [... want . keys ()] } },
select : { id : true , goldDebtGrams : true , fiatDebtRial : true },
});
// Matched customers may carry target-native entries too, so only the
// customers the import created are held to legacy's number.
const created = new Set (
( await ctx . db . $queryRawUnsafe < { target_id : string }[] > (
`SELECT DISTINCT target_id FROM legacy_import.id_map WHERE entity = 'customer' AND how = 'created'` ,
)). map (( r ) => r . target_id ),
);
let goldOk = 0 , goldBad = 0 , rialOk = 0 , rialBad = 0 ;
let legacyGold = new Prisma . Decimal ( 0 ), targetGold = new Prisma . Decimal ( 0 );
let legacyRial = new Prisma . Decimal ( 0 ), targetRial = new Prisma . Decimal ( 0 );
for ( const t of targets ) {
if ( ! created . has ( t . id )) continue ;
const w = want . get ( t . id ) ! ;
const g = new Prisma . Decimal ( w . gold . toFixed ( 4 ));
const r = new Prisma . Decimal ( w . rial . toFixed ( 2 ));
legacyGold = legacyGold . add ( g ); targetGold = targetGold . add ( t . goldDebtGrams );
legacyRial = legacyRial . add ( r ); targetRial = targetRial . add ( t . fiatDebtRial );
if ( g . sub ( t . goldDebtGrams ). abs (). lte ( TOLERANCE )) goldOk ++ ;
else { goldBad ++ ; ctx . report . note ( S , `⚠ gold balance differs for customer ${ t . id } : legacy ${ g . toString () } / target ${ t . goldDebtGrams . toString () } ` ); }
if ( r . sub ( t . fiatDebtRial ). abs (). lt ( 1 )) rialOk ++ ;
else { rialBad ++ ; ctx . report . note ( S , `⚠ rial balance differs for customer ${ t . id } : legacy ${ r . toString () } / target ${ t . fiatDebtRial . toString () } ` ); }
}
ctx . report . count ( S , 'created customers — gold balance matches legacy (±0.0001 g)' , goldOk );
ctx . report . count ( S , 'created customers — gold balance DIFFERS' , goldBad );
ctx . report . count ( S , 'created customers — rial balance matches legacy (±1 rial)' , rialOk );
ctx . report . count ( S , 'created customers — rial balance DIFFERS' , rialBad );
ctx . report . note ( S , `Total gold debt (created customers): legacy ${ legacyGold . toFixed ( 4 ) } g · target ${ targetGold . toFixed ( 4 ) } g` );
ctx . report . note ( S , `Total rial debt (created customers): legacy ${ legacyRial . toFixed ( 2 ) } · target ${ targetRial . toFixed ( 2 ) } ` );
ctx . report . note ( S , `Matched (pre-existing) customers are not held to legacy's number: they may carry target-native ledger entries. ${ targets . length - created . size } of them; see the "balances" section for whose cached balance changed.` );
/* protected columns: the import must never have written them */
const [{ n : imageIds }] = await ctx . db . $queryRawUnsafe < { n : bigint }[] > (
`SELECT COUNT(*) AS n FROM "ProductVariant" v
JOIN legacy_import.id_map m ON m.entity = 'variant' AND m.target_id = v.id AND m.how = 'created'
WHERE v."imageId" IS NOT NULL` ,
);
ctx . report . note ( S , `Imported variants with an imageId: ${ String ( imageIds ) } (must be 0 — PROTECTED.md §B).` );
}
2026-10-08 10:40:21 +03:30
/**
* «میزان فروش» for every migrated customer and expert, against legacy's own
* formula (CustomerController::updateGramSell, soft-deleted orders excluded):
* SUM(orders.input) WHERE customer_id = X OR reagent_id = X
* computed live from legacy's rows — and, separately, legacy's STORED
* `customers.grams_sell`, which the legacy screens print. The stored value is
* a cache refreshed only on some order events, so where it disagrees with the
* formula the legacy screen itself is out of date; each such row is listed.
*
* Legacy rows the import merged into one target row (duplicate mobiles) are
* compared as one: their legacy figures are summed.
*/
export async function reconcileSales ( ctx : Ctx ) {
const S = 'reconciliation — sales («میزان فروش»)' ;
const rows = await ctx . q < Row >(
`SELECT c.id, c.grams_sell AS kept,
(SELECT COALESCE(SUM(o.input), 0) FROM orders o
WHERE o.deleted_at IS NULL AND (o.customer_id = c.id OR o.reagent_id = c.id)) AS live
FROM customers c WHERE c.deleted_at IS NULL` ,
);
const groups = new Map < string , { where : Prisma.OrderWhereInput ; live : number ; ids : string [] }>();
let stale = 0 ;
for ( const r of rows ) {
const agentId = ctx . map . get ( 'expert' , r . id );
const customerId = ctx . map . get ( 'customer' , r . id );
if ( ! agentId && ! customerId ) continue ;
const key = agentId ? `expert: ${ agentId } ` : `customer: ${ customerId ! } ` ;
const g = groups . get ( key ) ?? {
where : agentId ? agentSalesWhere ( agentId ) : customerSalesWhere ( customerId ! ),
live : 0 ,
ids : [],
};
g . live += toNumber ( r . live );
g . ids . push ( String ( r . id ));
groups . set ( key , g );
const live = new Prisma . Decimal ( toNumber ( r . live ). toFixed ( 4 ));
const kept = new Prisma . Decimal ( toNumber ( r . kept ). toFixed ( 4 ));
if ( kept . sub ( live ). abs (). gt ( TOLERANCE )) {
stale ++ ;
ctx . report . note ( S , `legacy's stored figure is out of date for # ${ String ( r . id ) } : legacy screen shows ${ kept . toString () } , legacy's own formula gives ${ live . toString () } ` );
}
}
let ok = 0 , bad = 0 ;
for ( const [ key , g ] of groups ) {
const agg = await ctx . db . order . aggregate ({ where : g.where , _sum : { totalGoldWeightGrams : true } });
const target = agg . _sum . totalGoldWeightGrams ?? new Prisma . Decimal ( 0 );
const live = new Prisma . Decimal ( g . live . toFixed ( 4 ));
if ( live . sub ( target ). abs (). lte ( TOLERANCE )) ok ++ ;
else { bad ++ ; ctx . report . note ( S , `⚠ ${ key . split ( ':' )[ 0 ] } legacy # ${ g . ids . join ( ' + #' ) } : legacy formula ${ live . toString () } / target ${ target . toString () } ` ); }
}
ctx . report . count ( S , 'matches legacy formula (±0.0001 g)' , ok );
ctx . report . count ( S , 'DIFFERS from legacy formula' , bad );
ctx . report . count ( S , "legacy's stored screen value is stale (legacy disagrees with itself)" , stale );
}