tango-app-api-payment-subscription 3.5.14 → 3.5.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -5,8 +5,10 @@ import * as dailyPriceService from '../services/dailyPrice.service.js';
|
|
|
5
5
|
import * as storeService from '../services/store.service.js';
|
|
6
6
|
import * as assignedStoreService from '../services/assignedStore.service.js';
|
|
7
7
|
import * as basePriceService from '../services/basePrice.service.js';
|
|
8
|
+
import * as cameraService from '../services/camera.service.js';
|
|
9
|
+
import * as planogramService from '../services/planogram.service.js';
|
|
8
10
|
import dayjs from 'dayjs';
|
|
9
|
-
import { logger, checkFileExist, signedUrl, download, insertOpenSearchData } from 'tango-app-api-middleware';
|
|
11
|
+
import { logger, checkFileExist, signedUrl, download, insertOpenSearchData, getOpenSearchData } from 'tango-app-api-middleware';
|
|
10
12
|
import * as XLSX from 'xlsx';
|
|
11
13
|
import ExcelJS from 'exceljs';
|
|
12
14
|
import { bulkUpdateBillingGroupRowSchema } from '../dtos/validation.dtos.js';
|
|
@@ -1694,6 +1696,11 @@ export async function getUsdInrRate() {
|
|
|
1694
1696
|
export async function billingSummary( req, res ) {
|
|
1695
1697
|
try {
|
|
1696
1698
|
const now = dayjs();
|
|
1699
|
+
const nowDate = now.toDate();
|
|
1700
|
+
// Today's USD->INR rate. Hoisted here (cached) so both the aging buckets and
|
|
1701
|
+
// the revenue columns convert dollar invoices consistently.
|
|
1702
|
+
const usdRate = await getUsdInrRate();
|
|
1703
|
+
const usdRateForAging = usdRate;
|
|
1697
1704
|
const months = [];
|
|
1698
1705
|
for ( let i = 4; i >= 0; i-- ) {
|
|
1699
1706
|
const m = now.subtract( i, 'month' );
|
|
@@ -1753,6 +1760,54 @@ export async function billingSummary( req, res ) {
|
|
|
1753
1760
|
} );
|
|
1754
1761
|
const clientById = new Map( clients.map( ( c ) => [ String( c.clientId ), c ] ) );
|
|
1755
1762
|
|
|
1763
|
+
// AR aging — outstanding balance per client, bucketed by days past the
|
|
1764
|
+
// invoice DUE DATE. Scope: APPROVED invoices that are not fully paid
|
|
1765
|
+
// (paymentStatus unpaid/partial). Amount per invoice = balance due
|
|
1766
|
+
// (totalAmount - paidAmount). Dollar invoices are converted to INR at
|
|
1767
|
+
// today's rate so the buckets are comparable across currencies (matching the
|
|
1768
|
+
// revenue columns). This intentionally spans ALL time, not the 5-month
|
|
1769
|
+
// window, because overdue balances can be older than the revenue window.
|
|
1770
|
+
const agingRows = await invoiceService.aggregate( [
|
|
1771
|
+
{ $match: {
|
|
1772
|
+
status: 'approved',
|
|
1773
|
+
paymentStatus: { $in: [ 'unpaid', 'partial', 'due' ] },
|
|
1774
|
+
} },
|
|
1775
|
+
{ $addFields: {
|
|
1776
|
+
dueDateD: { $cond: [
|
|
1777
|
+
{ $eq: [ { $type: '$dueDate' }, 'date' ] },
|
|
1778
|
+
'$dueDate',
|
|
1779
|
+
{ $convert: { input: '$dueDate', to: 'date', onError: null, onNull: null } },
|
|
1780
|
+
] },
|
|
1781
|
+
balance: { $subtract: [
|
|
1782
|
+
{ $ifNull: [ '$totalAmount', 0 ] },
|
|
1783
|
+
{ $ifNull: [ '$paidAmount', 0 ] },
|
|
1784
|
+
] },
|
|
1785
|
+
isDollar: { $eq: [ '$currency', 'dollar' ] },
|
|
1786
|
+
} },
|
|
1787
|
+
// Only invoices with a positive balance still owed.
|
|
1788
|
+
{ $match: { balance: { $gt: 0 } } },
|
|
1789
|
+
{ $addFields: {
|
|
1790
|
+
// Days past due. Missing/invalid dueDate -> treat as 0 days overdue so
|
|
1791
|
+
// the amount still shows in the youngest bucket rather than vanishing.
|
|
1792
|
+
daysPastDue: { $cond: [
|
|
1793
|
+
{ $eq: [ '$dueDateD', null ] },
|
|
1794
|
+
0,
|
|
1795
|
+
{ $dateDiff: { startDate: '$dueDateD', endDate: nowDate, unit: 'day' } },
|
|
1796
|
+
] },
|
|
1797
|
+
} },
|
|
1798
|
+
{ $group: {
|
|
1799
|
+
_id: '$clientId',
|
|
1800
|
+
// Bucket the BALANCE (INR-normalized) by age. < 30, 30-60, > 60.
|
|
1801
|
+
b0to30: { $sum: { $cond: [ { $lt: [ '$daysPastDue', 30 ] },
|
|
1802
|
+
{ $cond: [ '$isDollar', { $multiply: [ '$balance', usdRateForAging ] }, '$balance' ] }, 0 ] } },
|
|
1803
|
+
b30to60: { $sum: { $cond: [ { $and: [ { $gte: [ '$daysPastDue', 30 ] }, { $lte: [ '$daysPastDue', 60 ] } ] },
|
|
1804
|
+
{ $cond: [ '$isDollar', { $multiply: [ '$balance', usdRateForAging ] }, '$balance' ] }, 0 ] } },
|
|
1805
|
+
bOver60: { $sum: { $cond: [ { $gt: [ '$daysPastDue', 60 ] },
|
|
1806
|
+
{ $cond: [ '$isDollar', { $multiply: [ '$balance', usdRateForAging ] }, '$balance' ] }, 0 ] } },
|
|
1807
|
+
} },
|
|
1808
|
+
] );
|
|
1809
|
+
const agingByClient = new Map( agingRows.map( ( a ) => [ String( a._id ), a ] ) );
|
|
1810
|
+
|
|
1756
1811
|
// Registered Entity comes from the billings collection (each billing group's
|
|
1757
1812
|
// registeredCompanyName). A client can have multiple groups/names, so
|
|
1758
1813
|
// collect the distinct list per client — the UI shows the first and reveals
|
|
@@ -1767,7 +1822,7 @@ export async function billingSummary( req, res ) {
|
|
|
1767
1822
|
|
|
1768
1823
|
// Per-client CSM (userAssignedStore, tangoUserType 'csm'); display the
|
|
1769
1824
|
// email's local part since the collection carries no display name.
|
|
1770
|
-
|
|
1825
|
+
// usdRate already fetched at the top of the function.
|
|
1771
1826
|
|
|
1772
1827
|
// Current month's store count comes from dailyPricing — counted the same
|
|
1773
1828
|
// way as Brands & Billing's "Billing Stores": distinct ACTIVE stores that
|
|
@@ -1984,11 +2039,21 @@ export async function billingSummary( req, res ) {
|
|
|
1984
2039
|
// a client has no billing-group registered name.
|
|
1985
2040
|
const regNames = regNamesByClient.get( r.clientId ) || [];
|
|
1986
2041
|
const registeredEntity = regNames[0] || r.registeredEntity || '';
|
|
2042
|
+
// AR aging buckets (INR-normalized). Rounded; outstanding = sum of buckets.
|
|
2043
|
+
const ag = agingByClient.get( r.clientId );
|
|
2044
|
+
const aging0to30 = ag ? Math.round( ag.b0to30 || 0 ) : 0;
|
|
2045
|
+
const aging30to60 = ag ? Math.round( ag.b30to60 || 0 ) : 0;
|
|
2046
|
+
const agingOver60 = ag ? Math.round( ag.bOver60 || 0 ) : 0;
|
|
2047
|
+
const outstanding = aging0to30 + aging30to60 + agingOver60;
|
|
1987
2048
|
return {
|
|
1988
2049
|
clientId: r.clientId,
|
|
1989
2050
|
clientName: r.clientName || registeredEntity || r.clientId,
|
|
1990
2051
|
registeredEntity,
|
|
1991
2052
|
registeredEntities: regNames.length ? regNames : ( r.registeredEntity ? [ r.registeredEntity ] : [] ),
|
|
2053
|
+
outstanding,
|
|
2054
|
+
aging0to30,
|
|
2055
|
+
aging30to60,
|
|
2056
|
+
agingOver60,
|
|
1992
2057
|
status: r.status,
|
|
1993
2058
|
products: r.products,
|
|
1994
2059
|
csm: r.csm,
|
|
@@ -2048,3 +2113,127 @@ export async function billingSummary( req, res ) {
|
|
|
2048
2113
|
return res.sendError( error, 500 );
|
|
2049
2114
|
}
|
|
2050
2115
|
}
|
|
2116
|
+
|
|
2117
|
+
// ---------------------------------------------------------------------------
|
|
2118
|
+
// Additional Products table (Billing Breakdown). These are extra, non-standard
|
|
2119
|
+
// products billed per store for a SPECIFIC client only. Currently scoped to
|
|
2120
|
+
// clientId '11'; for any other client this returns an empty list so the table
|
|
2121
|
+
// simply doesn't render.
|
|
2122
|
+
//
|
|
2123
|
+
// Both products and their unit prices are hardcoded here by design (they're not
|
|
2124
|
+
// in the basepricing catalog). Quantity = distinct store count from each
|
|
2125
|
+
// product's own source collection:
|
|
2126
|
+
// - Eyetest -> cameras collection (eye-test streams with a QR code)
|
|
2127
|
+
// - Planogram -> planograms collection (distinct storeName)
|
|
2128
|
+
// Total = Quantity * unit price (INR).
|
|
2129
|
+
// ---------------------------------------------------------------------------
|
|
2130
|
+
const ADDITIONAL_PRODUCTS_CLIENT_ID = '11';
|
|
2131
|
+
// Agreed INR unit prices for client 11.
|
|
2132
|
+
const ADDITIONAL_PRODUCT_PRICES = {
|
|
2133
|
+
eyetest: 450,
|
|
2134
|
+
planogram: 770,
|
|
2135
|
+
aiManager: 500,
|
|
2136
|
+
};
|
|
2137
|
+
// OpenSearch index holding extra billed products (VMS, Run AI, etc.). Each
|
|
2138
|
+
// record is a daily snapshot carrying its own quantity + price per product.
|
|
2139
|
+
const BILLING_DETAILS_INDEX = ( () => {
|
|
2140
|
+
try {
|
|
2141
|
+
return JSON.parse( process.env.OPENSEARCH || '{}' ).billingDetails || 'billing_details';
|
|
2142
|
+
} catch ( e ) {
|
|
2143
|
+
return 'billing_details';
|
|
2144
|
+
}
|
|
2145
|
+
} )();
|
|
2146
|
+
|
|
2147
|
+
// Compute the additional-products list for a client. Returns [] for any client
|
|
2148
|
+
// that has none (currently only clientId 11). Shared by the Billing Breakdown
|
|
2149
|
+
// table (additionalProducts controller) AND invoice generation, so both stay in
|
|
2150
|
+
// sync. Each entry: { productName, quantity, price, total }.
|
|
2151
|
+
export async function getAdditionalProducts( clientId ) {
|
|
2152
|
+
clientId = String( clientId ?? '' );
|
|
2153
|
+
if ( clientId !== ADDITIONAL_PRODUCTS_CLIENT_ID ) {
|
|
2154
|
+
return [];
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
// Eyetest: distinct stores in the cameras collection that have an eye-test
|
|
2158
|
+
// stream with a QR code.
|
|
2159
|
+
const eyetestRows = await cameraService.aggregate( [
|
|
2160
|
+
{ $match: { $and: [
|
|
2161
|
+
{ clientId: clientId },
|
|
2162
|
+
{ isEyeTestStream: true },
|
|
2163
|
+
{ qrCode: { $exists: true } },
|
|
2164
|
+
] } },
|
|
2165
|
+
{ $group: { _id: '$storeId' } },
|
|
2166
|
+
{ $count: 'stores' },
|
|
2167
|
+
] );
|
|
2168
|
+
const eyetestQty = eyetestRows?.[0]?.stores || 0;
|
|
2169
|
+
|
|
2170
|
+
// Planogram: distinct storeName in the planograms collection.
|
|
2171
|
+
const planogramRows = await planogramService.aggregate( [
|
|
2172
|
+
{ $match: { clientId: clientId } },
|
|
2173
|
+
{ $group: { _id: null, storecount: { $addToSet: '$storeName' } } },
|
|
2174
|
+
{ $project: { count: { $size: '$storecount' } } },
|
|
2175
|
+
] );
|
|
2176
|
+
const planogramQty = planogramRows?.[0]?.count || 0;
|
|
2177
|
+
|
|
2178
|
+
// AI Manager: same count as tangoTraffic running stores — distinct stores in
|
|
2179
|
+
// the latest daily-pricing doc whose tangoTraffic product ran more than one
|
|
2180
|
+
// working day (a single-day appearance is transient and not billed).
|
|
2181
|
+
const aiManagerRows = await dailyPriceService.aggregate( [
|
|
2182
|
+
{ $match: { clientId: clientId } },
|
|
2183
|
+
{ $sort: { dateISO: -1 } },
|
|
2184
|
+
{ $limit: 1 },
|
|
2185
|
+
{ $unwind: '$stores' },
|
|
2186
|
+
{ $unwind: '$stores.products' },
|
|
2187
|
+
{ $match: { 'stores.products.productName': 'tangoTraffic', 'stores.products.workingdays': { $gt: 1 } } },
|
|
2188
|
+
{ $group: { _id: '$stores.storeId' } },
|
|
2189
|
+
{ $count: 'stores' },
|
|
2190
|
+
] );
|
|
2191
|
+
const aiManagerQty = aiManagerRows?.[0]?.stores || 0;
|
|
2192
|
+
|
|
2193
|
+
const build = ( productName, quantity, price ) => ( {
|
|
2194
|
+
productName,
|
|
2195
|
+
quantity,
|
|
2196
|
+
price,
|
|
2197
|
+
total: Math.round( quantity * price * 100 ) / 100,
|
|
2198
|
+
} );
|
|
2199
|
+
|
|
2200
|
+
const products = [
|
|
2201
|
+
build( 'Eyetest', eyetestQty, ADDITIONAL_PRODUCT_PRICES.eyetest ),
|
|
2202
|
+
build( 'Planogram', planogramQty, ADDITIONAL_PRODUCT_PRICES.planogram ),
|
|
2203
|
+
build( 'AI Manager', aiManagerQty, ADDITIONAL_PRODUCT_PRICES.aiManager ),
|
|
2204
|
+
];
|
|
2205
|
+
|
|
2206
|
+
// Extra products from the billing_details OpenSearch index — the LATEST daily
|
|
2207
|
+
// snapshot for this client. quantity/price are stored as strings, so coerce
|
|
2208
|
+
// them. If OpenSearch is unreachable we just skip these rows rather than
|
|
2209
|
+
// failing the whole list.
|
|
2210
|
+
try {
|
|
2211
|
+
const osRes = await getOpenSearchData( BILLING_DETAILS_INDEX, {
|
|
2212
|
+
size: 1,
|
|
2213
|
+
query: { term: { 'client_id': clientId } },
|
|
2214
|
+
sort: [ { date_string: { order: 'desc' } } ],
|
|
2215
|
+
} );
|
|
2216
|
+
const hits = osRes?.body?.hits?.hits || osRes?.hits?.hits || [];
|
|
2217
|
+
const osProducts = hits[0]?._source?.products || [];
|
|
2218
|
+
for ( const p of osProducts ) {
|
|
2219
|
+
const quantity = Number( p.quantity ) || 0;
|
|
2220
|
+
const price = Number( p.price ) || 0;
|
|
2221
|
+
products.push( build( p.productName, quantity, price ) );
|
|
2222
|
+
}
|
|
2223
|
+
} catch ( osError ) {
|
|
2224
|
+
logger.error( { error: osError, function: 'getAdditionalProducts:openSearch' } );
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
return products;
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
export async function additionalProducts( req, res ) {
|
|
2231
|
+
try {
|
|
2232
|
+
const clientId = String( req.query?.clientId ?? req.body?.clientId ?? '' );
|
|
2233
|
+
const products = await getAdditionalProducts( clientId );
|
|
2234
|
+
return res.sendSuccess( { currency: 'inr', products } );
|
|
2235
|
+
} catch ( error ) {
|
|
2236
|
+
logger.error( { error: error, function: 'additionalProducts' } );
|
|
2237
|
+
return res.sendError( error, 500 );
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
@@ -18,7 +18,7 @@ import { invoiceStatusEnum } from '../dtos/validation.dtos.js';
|
|
|
18
18
|
import { findOneApplicationDefault } from '../services/applicationDefault.service.js';
|
|
19
19
|
import * as assignedStoreService from '../services/assignedStore.service.js';
|
|
20
20
|
import * as bankTransactionService from '../services/bankTransaction.service.js';
|
|
21
|
-
import { getUsdInrRate } from './brandsBilling.controller.js';
|
|
21
|
+
import { getUsdInrRate, getAdditionalProducts } from './brandsBilling.controller.js';
|
|
22
22
|
|
|
23
23
|
// Pulls CSM + Finance head emails (stored under applicationDefault
|
|
24
24
|
// type=invoice, subType=heads) AND the per-client CSMs assigned via
|
|
@@ -241,6 +241,26 @@ export async function createInvoice( req, res ) {
|
|
|
241
241
|
products = await stepPrice( group, getClient );
|
|
242
242
|
}
|
|
243
243
|
|
|
244
|
+
// Additional products (e.g. Lenskart / clientId 11: Eyetest, Planogram,
|
|
245
|
+
// AI Manager + the OpenSearch billing_details rows). Each is a single
|
|
246
|
+
// full-month line item (quantity x price), appended on top of the normal
|
|
247
|
+
// store-based product lines. Added BEFORE the multi-month expansion below
|
|
248
|
+
// so they repeat per month and are included in the taxable subtotal, just
|
|
249
|
+
// like the standard products. Returns [] for any client without extras.
|
|
250
|
+
const extraProducts = await getAdditionalProducts( group.clientId );
|
|
251
|
+
for ( const ep of extraProducts ) {
|
|
252
|
+
products.push( {
|
|
253
|
+
productName: ep.productName,
|
|
254
|
+
period: 'fullmonth',
|
|
255
|
+
storeCount: ep.quantity,
|
|
256
|
+
amount: ep.total,
|
|
257
|
+
price: ep.price,
|
|
258
|
+
description: ep.productName,
|
|
259
|
+
HsnNumber: '998314',
|
|
260
|
+
month: baseDate.format( 'MMM YYYY' ),
|
|
261
|
+
} );
|
|
262
|
+
}
|
|
263
|
+
|
|
244
264
|
// Billing horizon in months. Advance and normal cycle are independent —
|
|
245
265
|
// only one drives the span per generation:
|
|
246
266
|
// advanceInvoice ON -> advancePeriod (advance future billing)
|
|
@@ -454,12 +474,56 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
|
|
|
454
474
|
// converting here would make the annexure unit price disagree with the actual
|
|
455
475
|
// billed amount (e.g. a $45 negotiated price wrongly shown as $0.48).
|
|
456
476
|
|
|
457
|
-
const annexClient = await clientService.findOne( { clientId: invoiceInfo.clientId }, { 'planDetails.product': 1 } );
|
|
477
|
+
const annexClient = await clientService.findOne( { clientId: invoiceInfo.clientId }, { 'planDetails.product': 1, 'priceType': 1 } );
|
|
458
478
|
const billingTypeMap = {};
|
|
459
479
|
( annexClient?.planDetails?.product || [] ).forEach( ( p ) => {
|
|
460
480
|
billingTypeMap[p.productName] = p.billingType || 'perStore';
|
|
461
481
|
} );
|
|
462
482
|
|
|
483
|
+
// Pricing for this client. For STEP clients the price depends on the store's
|
|
484
|
+
// position within the product (tier ranges), so we can't join a single price
|
|
485
|
+
// per store in the aggregation — that join is what caused every store to
|
|
486
|
+
// appear once per tier (e.g. the same store listed at $45 and again at $40).
|
|
487
|
+
// Instead we pull the tiers here and assign each store a single tier price
|
|
488
|
+
// below, in JS, by its 1-based index within the product.
|
|
489
|
+
const pricingDoc = await basepricingService.findOne( { clientId: invoiceInfo.clientId }, { standard: 1, step: 1 } );
|
|
490
|
+
const isStep = annexClient?.priceType === 'step';
|
|
491
|
+
const tiersByProduct = {};
|
|
492
|
+
( ( isStep ? pricingDoc?.step : pricingDoc?.standard ) || [] ).forEach( ( p ) => {
|
|
493
|
+
if ( !tiersByProduct[p.productName] ) {
|
|
494
|
+
tiersByProduct[p.productName] = [];
|
|
495
|
+
}
|
|
496
|
+
tiersByProduct[p.productName].push( p );
|
|
497
|
+
} );
|
|
498
|
+
// Step tiers must be ordered by their range start so index lookups are correct.
|
|
499
|
+
Object.keys( tiersByProduct ).forEach( ( name ) => {
|
|
500
|
+
tiersByProduct[name].sort( ( a, b ) => {
|
|
501
|
+
const aStart = parseInt( String( a.storeRange || '0' ).split( '-' )[0], 10 ) || 0;
|
|
502
|
+
const bStart = parseInt( String( b.storeRange || '0' ).split( '-' )[0], 10 ) || 0;
|
|
503
|
+
return aStart - bStart;
|
|
504
|
+
} );
|
|
505
|
+
} );
|
|
506
|
+
|
|
507
|
+
// Resolve the per-store price. Standard: first (only) tier's price. Step: the
|
|
508
|
+
// tier whose range contains this store's 1-based position within the product.
|
|
509
|
+
const priceForStore = ( productName, positionInProduct ) => {
|
|
510
|
+
const tiers = tiersByProduct[productName] || [];
|
|
511
|
+
if ( !tiers.length ) {
|
|
512
|
+
return 0;
|
|
513
|
+
}
|
|
514
|
+
if ( !isStep ) {
|
|
515
|
+
return Number( tiers[0].negotiatePrice ) || 0;
|
|
516
|
+
}
|
|
517
|
+
for ( const tier of tiers ) {
|
|
518
|
+
const [ min, max ] = String( tier.storeRange || '' ).split( '-' ).map( Number );
|
|
519
|
+
if ( positionInProduct >= min && positionInProduct <= max ) {
|
|
520
|
+
return Number( tier.negotiatePrice ) || 0;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
// Beyond the last defined tier: fall back to the last tier's price.
|
|
524
|
+
return Number( tiers[tiers.length - 1].negotiatePrice ) || 0;
|
|
525
|
+
};
|
|
526
|
+
|
|
463
527
|
const rows = await dailyPricingService.aggregate( [
|
|
464
528
|
{ $match: { clientId: invoiceInfo.clientId, dateISO: { $lte: billingMonthEnd } } },
|
|
465
529
|
{ $sort: { dateISO: -1 } },
|
|
@@ -478,25 +542,12 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
|
|
|
478
542
|
zoneCameraCount: { $ifNull: [ '$stores.zoneCameraCount', 0 ] },
|
|
479
543
|
} },
|
|
480
544
|
{ $match: { workingdays: { $gt: 0 } } },
|
|
481
|
-
|
|
482
|
-
{ $
|
|
483
|
-
from: 'basepricings',
|
|
484
|
-
let: { clientId: invoiceInfo.clientId },
|
|
485
|
-
pipeline: [
|
|
486
|
-
{ $match: { $expr: { $eq: [ '$clientId', '$$clientId' ] } } },
|
|
487
|
-
{ $project: { standard: 1 } },
|
|
488
|
-
],
|
|
489
|
-
as: 'basepricing',
|
|
490
|
-
} },
|
|
491
|
-
{ $unwind: { path: '$basepricing', preserveNullAndEmptyArrays: true } },
|
|
492
|
-
{ $project: {
|
|
493
|
-
productName: 1, workingdays: 1, storeName: 1, storeId: 1, edgefirstFileDate: 1,
|
|
494
|
-
zoneCount: 1, trafficCameraCount: 1, zoneCameraCount: 1,
|
|
495
|
-
standard: { $filter: { input: '$basepricing.standard', as: 'standard', cond: { $eq: [ '$$standard.productName', '$productName' ] } } },
|
|
496
|
-
} },
|
|
497
|
-
{ $unwind: { path: '$standard', preserveNullAndEmptyArrays: true } },
|
|
545
|
+
// Deterministic ordering so step-tier assignment is stable run to run.
|
|
546
|
+
{ $sort: { productName: 1, storeId: 1 } },
|
|
498
547
|
] );
|
|
499
548
|
|
|
549
|
+
// Track each store's 1-based position within its product to pick the step tier.
|
|
550
|
+
const productPosition = {};
|
|
500
551
|
const data = rows.map( ( s ) => {
|
|
501
552
|
const billingType = billingTypeMap[s.productName] || 'perStore';
|
|
502
553
|
// Same units rule as invoice generation: perZone / perCamera multiply by
|
|
@@ -513,9 +564,11 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
|
|
|
513
564
|
units = s.trafficCameraCount;
|
|
514
565
|
}
|
|
515
566
|
}
|
|
567
|
+
productPosition[s.productName] = ( productPosition[s.productName] || 0 ) + 1;
|
|
516
568
|
// negotiatePrice is already in the invoice currency — use it verbatim,
|
|
517
|
-
// matching invoice generation.
|
|
518
|
-
|
|
569
|
+
// matching invoice generation. For step clients the tier is chosen by the
|
|
570
|
+
// store's position within the product.
|
|
571
|
+
const price = priceForStore( s.productName, productPosition[s.productName] );
|
|
519
572
|
const runningCost = s.workingdays >= monthDays ?
|
|
520
573
|
Math.round( price * units * 100 ) / 100 :
|
|
521
574
|
Math.round( ( price / monthDays ) * s.workingdays * units * 100 ) / 100;
|
|
@@ -1693,7 +1746,13 @@ async function stepPrice( group, getClient ) {
|
|
|
1693
1746
|
|
|
1694
1747
|
let stepPriceRecord = await basepricingService.findOne( { clientId: group.clientId } );
|
|
1695
1748
|
let data = products;
|
|
1696
|
-
|
|
1749
|
+
// Tiers must be ordered by range start for the cumulative step assignment in
|
|
1750
|
+
// processArray to walk them low-to-high.
|
|
1751
|
+
let pricing = ( stepPriceRecord.step || [] ).slice().sort( ( a, b ) => {
|
|
1752
|
+
const aStart = parseInt( String( a.storeRange || '0' ).split( '-' )[0], 10 ) || 0;
|
|
1753
|
+
const bStart = parseInt( String( b.storeRange || '0' ).split( '-' )[0], 10 ) || 0;
|
|
1754
|
+
return aStart - bStart;
|
|
1755
|
+
} );
|
|
1697
1756
|
|
|
1698
1757
|
const applyPricing = ( data, pricing ) => {
|
|
1699
1758
|
let totalcount = 0;
|
|
@@ -1722,26 +1781,54 @@ async function stepPrice( group, getClient ) {
|
|
|
1722
1781
|
function processArray( array1, array2 ) {
|
|
1723
1782
|
let updatedArray = [];
|
|
1724
1783
|
|
|
1784
|
+
// Step tiers apply CUMULATIVELY across every working-days bucket of a
|
|
1785
|
+
// product, not per bucket. Track how many stores of each product have
|
|
1786
|
+
// already been priced so each bucket continues where the previous one left
|
|
1787
|
+
// off. (Previously the tier counter reset for every bucket, so e.g. the 9
|
|
1788
|
+
// prorated stores — really stores 75-83, in the $40 tier — were each priced
|
|
1789
|
+
// from tier 1 at $45, producing extra/wrong-priced line items.)
|
|
1790
|
+
const assignedByProduct = {};
|
|
1791
|
+
|
|
1725
1792
|
for ( let item of array1 ) {
|
|
1726
1793
|
let remainingStores = item.storeCount;
|
|
1794
|
+
let assigned = assignedByProduct[item.productName] || 0;
|
|
1727
1795
|
|
|
1728
1796
|
for ( let range of array2 ) {
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
let applicableStores = Math.min( remainingStores, max - min + 1 );
|
|
1733
|
-
updatedArray.push( {
|
|
1734
|
-
productName: item.productName,
|
|
1735
|
-
workingdays: item.workingdays,
|
|
1736
|
-
storeCount: applicableStores,
|
|
1737
|
-
// Each tier is charged at its own range's negotiated price. (Was a
|
|
1738
|
-
// hardcoded 1100 for every tier after the first — a placeholder
|
|
1739
|
-
// that ignored the actual step price of the next range.)
|
|
1740
|
-
price: range.negotiatePrice,
|
|
1741
|
-
} );
|
|
1742
|
-
remainingStores -= applicableStores;
|
|
1797
|
+
const [ min, max ] = range.storeRange.split( '-' ).map( Number );
|
|
1798
|
+
if ( remainingStores <= 0 ) {
|
|
1799
|
+
break;
|
|
1743
1800
|
}
|
|
1801
|
+
// How many of this tier's slots are still free given everything already
|
|
1802
|
+
// assigned to this product across previous buckets.
|
|
1803
|
+
const tierCapacityLeft = max - Math.max( min - 1, assigned );
|
|
1804
|
+
if ( tierCapacityLeft <= 0 ) {
|
|
1805
|
+
continue;
|
|
1806
|
+
}
|
|
1807
|
+
const applicableStores = Math.min( remainingStores, tierCapacityLeft );
|
|
1808
|
+
updatedArray.push( {
|
|
1809
|
+
productName: item.productName,
|
|
1810
|
+
workingdays: item.workingdays,
|
|
1811
|
+
storeCount: applicableStores,
|
|
1812
|
+
// Each tier is charged at its own range's negotiated price.
|
|
1813
|
+
price: range.negotiatePrice,
|
|
1814
|
+
} );
|
|
1815
|
+
remainingStores -= applicableStores;
|
|
1816
|
+
assigned += applicableStores;
|
|
1744
1817
|
}
|
|
1818
|
+
|
|
1819
|
+
// Any stores beyond the last defined tier are charged at the last tier.
|
|
1820
|
+
if ( remainingStores > 0 && array2.length ) {
|
|
1821
|
+
updatedArray.push( {
|
|
1822
|
+
productName: item.productName,
|
|
1823
|
+
workingdays: item.workingdays,
|
|
1824
|
+
storeCount: remainingStores,
|
|
1825
|
+
price: array2[array2.length - 1].negotiatePrice,
|
|
1826
|
+
} );
|
|
1827
|
+
assigned += remainingStores;
|
|
1828
|
+
remainingStores = 0;
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
assignedByProduct[item.productName] = assigned;
|
|
1745
1832
|
}
|
|
1746
1833
|
return updatedArray;
|
|
1747
1834
|
}
|
|
@@ -1750,9 +1837,16 @@ async function stepPrice( group, getClient ) {
|
|
|
1750
1837
|
console.log( '++++++++++++', resultarray );
|
|
1751
1838
|
const result = applyPricing( resultarray, pricing );
|
|
1752
1839
|
console.log( '***********', result );
|
|
1840
|
+
// Group by product + period + PRICE TIER so each step tier is its own line
|
|
1841
|
+
// item, and all stores within a tier+period collapse into a single line:
|
|
1842
|
+
// - full-month stores of a tier -> one line (sum cost, sum count)
|
|
1843
|
+
// - prorated stores of a tier -> one averaged line (sum cost, sum count)
|
|
1844
|
+
// Keying on price (not storeCount) is what merges the leftover prorated stores
|
|
1845
|
+
// — which span several working-days buckets — into one $40 line instead of one
|
|
1846
|
+
// line per working-days value.
|
|
1753
1847
|
const groupedData = result.reduce( ( acc, item ) => {
|
|
1754
1848
|
const { productName, period, runningCost } = item;
|
|
1755
|
-
const key = `${productName}_${period}_${item.
|
|
1849
|
+
const key = `${productName}_${period}_${item.price}`;
|
|
1756
1850
|
if ( !acc[key] ) {
|
|
1757
1851
|
acc[key] = {
|
|
1758
1852
|
productName,
|
|
@@ -1761,14 +1855,8 @@ async function stepPrice( group, getClient ) {
|
|
|
1761
1855
|
count: 0,
|
|
1762
1856
|
};
|
|
1763
1857
|
}
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
acc[key].count += item.storeCount;
|
|
1767
|
-
} else {
|
|
1768
|
-
acc[key].totalRunningCost = parseFloat( runningCost );
|
|
1769
|
-
acc[key].count = item.storeCount;
|
|
1770
|
-
}
|
|
1771
|
-
|
|
1858
|
+
acc[key].totalRunningCost += parseFloat( runningCost );
|
|
1859
|
+
acc[key].count += item.storeCount;
|
|
1772
1860
|
return acc;
|
|
1773
1861
|
}, {} );
|
|
1774
1862
|
console.log( groupedData );
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
|
|
2
2
|
import express from 'express';
|
|
3
|
-
import { brandsBillingList, brandInvoiceList, latestDailyPricing, brandBillingGroups, updateDailyPricingWorkingDays, updateDailyPricingStoreField, getClientBillingInfo, bulkDownloadBillingGroups, bulkUpdateBillingGroups, billingSummary } from '../controllers/brandsBilling.controller.js';
|
|
3
|
+
import { brandsBillingList, brandInvoiceList, latestDailyPricing, brandBillingGroups, updateDailyPricingWorkingDays, updateDailyPricingStoreField, getClientBillingInfo, bulkDownloadBillingGroups, bulkUpdateBillingGroups, billingSummary, additionalProducts } from '../controllers/brandsBilling.controller.js';
|
|
4
4
|
import { isAllowedSessionHandler, accessVerification } from 'tango-app-api-middleware';
|
|
5
5
|
|
|
6
6
|
export const brandsBillingRouter = express.Router();
|
|
@@ -16,3 +16,4 @@ brandsBillingRouter.get( '/bulk-download-billing-groups', isAllowedSessionHandle
|
|
|
16
16
|
brandsBillingRouter.post( '/bulk-update-billing-groups', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [ 'isEdit' ] } ] } ), bulkUpdateBillingGroups );
|
|
17
17
|
brandsBillingRouter.post( '/billingSummary', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), billingSummary );
|
|
18
18
|
brandsBillingRouter.get( '/billingSummary', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), billingSummary );
|
|
19
|
+
brandsBillingRouter.get( '/additionalProducts', isAllowedSessionHandler, accessVerification( { userType: [ 'tango' ], access: [ { featureName: 'TangoAdmin', name: 'invoiceApproval', permissions: [] } ] } ), additionalProducts );
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import model from 'tango-api-schema';
|
|
2
|
+
|
|
3
|
+
export const aggregate = ( query = {} ) => {
|
|
4
|
+
return model.planogramModel.aggregate( query );
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export const find = ( query = {}, record = {} ) => {
|
|
8
|
+
return model.planogramModel.find( query, record );
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export const findOne = ( query = {}, record = {} ) => {
|
|
12
|
+
return model.planogramModel.findOne( query, record );
|
|
13
|
+
};
|