tango-app-api-payment-subscription 3.5.13 → 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
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// One-shot script: creates billing groups by store country for specific
|
|
2
|
+
// clients. For each target client it reads every store, groups storeIds by
|
|
3
|
+
// `storeProfile.country`, and creates one billing group per distinct country
|
|
4
|
+
// holding all the storeIds in that country.
|
|
5
|
+
//
|
|
6
|
+
// Scope: ONLY clientIds 387 and 193 (override with --clients=a,b).
|
|
7
|
+
//
|
|
8
|
+
// Idempotency / existing groups: if a billing group with the same
|
|
9
|
+
// (clientId, groupName=<country>) already exists, its `stores` array is
|
|
10
|
+
// OVERWRITTEN to match the current country membership (not duplicated).
|
|
11
|
+
//
|
|
12
|
+
// One store, one group: a store placed in a country group is pulled out of the
|
|
13
|
+
// primary "Default Group" so it lives in exactly one billing group (mirrors
|
|
14
|
+
// createBillingGroup).
|
|
15
|
+
//
|
|
16
|
+
// Stores with a missing/empty country are NOT grouped — they remain in the
|
|
17
|
+
// primary "Default Group". Any legacy fallback group named UNKNOWN_GROUP_NAME
|
|
18
|
+
// (from an earlier version of this script) is deleted; its stores already live
|
|
19
|
+
// in the Default Group, so nothing is lost.
|
|
20
|
+
//
|
|
21
|
+
// Connection: reuses the app's own getConnection() (config/database) so it
|
|
22
|
+
// connects exactly like the server does — reading mongo_username /
|
|
23
|
+
// mongo_password from .env plus host/name/authSource from config/env/env.js.
|
|
24
|
+
// Override with MONGO_URI if you want to point it elsewhere.
|
|
25
|
+
//
|
|
26
|
+
// Run modes:
|
|
27
|
+
// DRY RUN (default) — prints what it WOULD create/update, writes nothing:
|
|
28
|
+
// node scripts/create-billing-groups-by-country.js
|
|
29
|
+
// APPLY — actually writes:
|
|
30
|
+
// node scripts/create-billing-groups-by-country.js --apply
|
|
31
|
+
//
|
|
32
|
+
// Other flags:
|
|
33
|
+
// --clients=387,193 override the target client list
|
|
34
|
+
|
|
35
|
+
import mongoose from 'mongoose';
|
|
36
|
+
import 'dotenv/config';
|
|
37
|
+
import { getConnection } from '../config/database/database.js';
|
|
38
|
+
|
|
39
|
+
const DEFAULT_CLIENTS = [ '387', '193' ];
|
|
40
|
+
|
|
41
|
+
// Fallback billing group for stores that have no storeProfile.country.
|
|
42
|
+
const UNKNOWN_GROUP_NAME = 'Unknown';
|
|
43
|
+
|
|
44
|
+
function parseArgs( argv ) {
|
|
45
|
+
const apply = argv.includes( '--apply' ) || process.env.APPLY === 'true';
|
|
46
|
+
let clients = DEFAULT_CLIENTS;
|
|
47
|
+
const clientsArg = argv.find( ( a ) => a.startsWith( '--clients=' ) );
|
|
48
|
+
if ( clientsArg ) {
|
|
49
|
+
clients = clientsArg.slice( '--clients='.length ).split( ',' ).map( ( c ) => c.trim() ).filter( Boolean );
|
|
50
|
+
}
|
|
51
|
+
return { apply, clients };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function run() {
|
|
55
|
+
const { apply, clients } = parseArgs( process.argv.slice( 2 ) );
|
|
56
|
+
|
|
57
|
+
console.log( `Target clients: ${clients.join( ', ' )}` );
|
|
58
|
+
console.log( `Mode: ${apply ? 'APPLY (writing to DB)' : 'DRY RUN (no writes)'}` );
|
|
59
|
+
console.log( '' );
|
|
60
|
+
|
|
61
|
+
// Prefer an explicit MONGO_URI override; otherwise build the connection the
|
|
62
|
+
// same way the app does via getConnection().
|
|
63
|
+
if ( process.env.MONGO_URI ) {
|
|
64
|
+
await mongoose.connect( process.env.MONGO_URI );
|
|
65
|
+
} else {
|
|
66
|
+
const { uri, options } = getConnection();
|
|
67
|
+
await mongoose.connect( uri, options );
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// strict:false so we don't pin to a specific tango-api-schema version.
|
|
71
|
+
const Store = mongoose.model(
|
|
72
|
+
'_cbgStore',
|
|
73
|
+
new mongoose.Schema( {}, { strict: false } ),
|
|
74
|
+
'stores',
|
|
75
|
+
);
|
|
76
|
+
const Billing = mongoose.model(
|
|
77
|
+
'_cbgBilling',
|
|
78
|
+
new mongoose.Schema( {}, { strict: false } ),
|
|
79
|
+
'billings',
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
let totalGroupsCreated = 0;
|
|
83
|
+
let totalGroupsUpdated = 0;
|
|
84
|
+
let totalPulledFromPrimary = 0;
|
|
85
|
+
let totalUnknownGroupsDeleted = 0;
|
|
86
|
+
const noCountryStores = [];
|
|
87
|
+
|
|
88
|
+
for ( const clientId of clients ) {
|
|
89
|
+
const stores = await Store.find(
|
|
90
|
+
{ clientId: clientId },
|
|
91
|
+
{ 'storeId': 1, 'storeName': 1, 'storeProfile.country': 1 },
|
|
92
|
+
).lean();
|
|
93
|
+
|
|
94
|
+
console.log( `=== Client ${clientId}: ${stores.length} store(s) ===` );
|
|
95
|
+
|
|
96
|
+
if ( !stores.length ) {
|
|
97
|
+
console.log( ' No stores found. Skipping.\n' );
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Group storeIds by country. Stores with no country are NOT grouped — they
|
|
102
|
+
// remain in the primary "Default Group".
|
|
103
|
+
const byCountry = new Map();
|
|
104
|
+
for ( const s of stores ) {
|
|
105
|
+
const country = ( s.storeProfile?.country || '' ).trim();
|
|
106
|
+
if ( !country ) {
|
|
107
|
+
noCountryStores.push( { clientId, storeId: s.storeId, storeName: s.storeName } );
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if ( !byCountry.has( country ) ) {
|
|
111
|
+
byCountry.set( country, [] );
|
|
112
|
+
}
|
|
113
|
+
byCountry.get( country ).push( s.storeId );
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Tidy up the legacy fallback group: country-less stores belong in the
|
|
117
|
+
// primary group, so any previously-created "Unknown" group is removed.
|
|
118
|
+
// (Its stores are also in the primary group, so nothing is lost.)
|
|
119
|
+
const unknownGroup = await Billing.findOne( { clientId: clientId, groupName: UNKNOWN_GROUP_NAME } );
|
|
120
|
+
if ( unknownGroup ) {
|
|
121
|
+
console.log( ` [DELETE] legacy "${UNKNOWN_GROUP_NAME}" group (_id ${unknownGroup._id}) — country-less stores stay in Default Group` );
|
|
122
|
+
if ( apply ) {
|
|
123
|
+
await Billing.deleteOne( { _id: unknownGroup._id } );
|
|
124
|
+
}
|
|
125
|
+
totalUnknownGroupsDeleted++;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if ( !byCountry.size ) {
|
|
129
|
+
console.log( ' No stores with a country value. Nothing to group.\n' );
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
for ( const [ country, storeIds ] of byCountry ) {
|
|
134
|
+
const existing = await Billing.findOne( { clientId: clientId, groupName: country } );
|
|
135
|
+
|
|
136
|
+
if ( existing ) {
|
|
137
|
+
console.log( ` [UPDATE] group "${country}" exists (_id ${existing._id}) → set ${storeIds.length} store(s)` );
|
|
138
|
+
if ( apply ) {
|
|
139
|
+
await Billing.updateOne(
|
|
140
|
+
{ _id: existing._id },
|
|
141
|
+
{ $set: { stores: storeIds } },
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
totalGroupsUpdated++;
|
|
145
|
+
} else {
|
|
146
|
+
console.log( ` [CREATE] group "${country}" with ${storeIds.length} store(s)` );
|
|
147
|
+
if ( apply ) {
|
|
148
|
+
await Billing.create( {
|
|
149
|
+
clientId: clientId,
|
|
150
|
+
groupName: country,
|
|
151
|
+
groupTag: 'store',
|
|
152
|
+
country: country,
|
|
153
|
+
stores: storeIds,
|
|
154
|
+
} );
|
|
155
|
+
}
|
|
156
|
+
totalGroupsCreated++;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// A store lives in exactly one group: once it's in a country group, pull
|
|
160
|
+
// it out of the primary "Default Group" (mirrors createBillingGroup).
|
|
161
|
+
if ( apply ) {
|
|
162
|
+
const pullRes = await Billing.updateOne(
|
|
163
|
+
{ clientId: clientId, isPrimary: true },
|
|
164
|
+
{ $pull: { stores: { $in: storeIds } } },
|
|
165
|
+
);
|
|
166
|
+
if ( pullRes.modifiedCount ) {
|
|
167
|
+
totalPulledFromPrimary += storeIds.length;
|
|
168
|
+
}
|
|
169
|
+
} else {
|
|
170
|
+
totalPulledFromPrimary += storeIds.length;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
console.log( ` → pulled country-matched stores out of primary "Default Group"` );
|
|
174
|
+
console.log( '' );
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
console.log( '--- Summary ---' );
|
|
178
|
+
console.log( `Groups created: ${totalGroupsCreated}` );
|
|
179
|
+
console.log( `Groups updated: ${totalGroupsUpdated}` );
|
|
180
|
+
console.log( `Legacy "${UNKNOWN_GROUP_NAME}" groups deleted: ${totalUnknownGroupsDeleted}` );
|
|
181
|
+
console.log( `Store memberships pulled from primary group: ${totalPulledFromPrimary}` );
|
|
182
|
+
if ( noCountryStores.length ) {
|
|
183
|
+
console.log( `Stores with no country (left in "Default Group"): ${noCountryStores.length}` );
|
|
184
|
+
noCountryStores.slice( 0, 50 ).forEach( ( s ) =>
|
|
185
|
+
console.log( ` - client ${s.clientId} | ${s.storeId} | ${s.storeName || ''}` ),
|
|
186
|
+
);
|
|
187
|
+
if ( noCountryStores.length > 50 ) {
|
|
188
|
+
console.log( ` ...and ${noCountryStores.length - 50} more` );
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if ( !apply ) {
|
|
192
|
+
console.log( '\nDRY RUN complete. No data was written. Re-run with --apply to write.' );
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
await mongoose.disconnect();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
run().catch( ( err ) => {
|
|
199
|
+
console.error( err );
|
|
200
|
+
process.exit( 1 );
|
|
201
|
+
} );
|
|
@@ -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';
|
|
@@ -586,16 +588,34 @@ export async function brandInvoiceList( req, res ) {
|
|
|
586
588
|
};
|
|
587
589
|
|
|
588
590
|
if ( req.body.export ) {
|
|
591
|
+
// Mirror the on-screen table exactly: same columns (excl. GST and incl.
|
|
592
|
+
// GST as separate amounts), same currency symbol, and the same status
|
|
593
|
+
// text the table renders via getInvoiceStatus().
|
|
594
|
+
const currencySymbols = {
|
|
595
|
+
inr: '₹', dollar: '$', singaporedollar: 'S$', euro: '€', aed: 'AED',
|
|
596
|
+
};
|
|
597
|
+
const statusFor = ( inv ) => {
|
|
598
|
+
if ( inv.status === 'pendingCsm' ) return 'Pending CSM';
|
|
599
|
+
if ( inv.status === 'pendingFinance' ) return 'Pending Finance';
|
|
600
|
+
if ( inv.status === 'pendingApproval' ) return 'Pending Approval';
|
|
601
|
+
if ( inv.status === 'pending' ) return 'Pending Approval';
|
|
602
|
+
if ( inv.paymentStatus === 'partial' ) return 'Partial';
|
|
603
|
+
if ( inv.paymentStatus === 'unpaid' ) return 'Pending Payment';
|
|
604
|
+
if ( inv.paymentStatus === 'paid' ) return 'Paid';
|
|
605
|
+
return inv.status;
|
|
606
|
+
};
|
|
589
607
|
const exportdata = [];
|
|
590
608
|
allInvoices.forEach( ( element ) => {
|
|
609
|
+
const symbol = currencySymbols[element.currency] || '$';
|
|
591
610
|
exportdata.push( {
|
|
592
611
|
'Invoice #': element.invoice,
|
|
593
612
|
'Billing Group': element.groupName,
|
|
594
613
|
'Period': dayjs( element.billingDate ).format( 'MMM YYYY' ),
|
|
595
614
|
'Generated': dayjs( element.billingDate ).format( 'DD MMM YYYY' ),
|
|
596
615
|
'No of Stores': element.stores,
|
|
597
|
-
'Amount': element.
|
|
598
|
-
'
|
|
616
|
+
'Amount (excl. GST)': `${symbol}${Number( element.amount || 0 ).toFixed( 2 )}`,
|
|
617
|
+
'Amount (incl. GST)': `${symbol}${Number( element.totalAmount || 0 ).toFixed( 2 )}`,
|
|
618
|
+
'Status': statusFor( element ),
|
|
599
619
|
} );
|
|
600
620
|
} );
|
|
601
621
|
await download( exportdata, res );
|
|
@@ -705,18 +725,27 @@ export async function latestDailyPricing( req, res ) {
|
|
|
705
725
|
logger.error( { error: bpErr, function: 'latestDailyPricing.basePrice', clientId: req.body.clientId } );
|
|
706
726
|
}
|
|
707
727
|
// Billing type per product (perStore / perZone / perCamera) from the client
|
|
708
|
-
// plan — drives whether the amount multiplies by camera/zone count.
|
|
728
|
+
// plan — drives whether the amount multiplies by camera/zone count. The
|
|
729
|
+
// same client doc supplies the invoice-amount currency below.
|
|
709
730
|
const billingTypeByProduct = {};
|
|
731
|
+
// Invoice Amount currency comes from the client's paymentInvoice.currencyType
|
|
732
|
+
// (normalised to 'dollar' / 'inr' like the rest of this file). Falls back to
|
|
733
|
+
// the base-pricing currency only when the client has no currencyType set.
|
|
734
|
+
let invoiceCurrency = bpCurrency;
|
|
710
735
|
try {
|
|
711
736
|
const planClient = await clientService.findOne(
|
|
712
737
|
{ clientId: req.body.clientId },
|
|
713
|
-
{ 'planDetails.product.productName': 1, 'planDetails.product.billingType': 1 },
|
|
738
|
+
{ 'planDetails.product.productName': 1, 'planDetails.product.billingType': 1, 'paymentInvoice.currencyType': 1 },
|
|
714
739
|
);
|
|
715
740
|
for ( const p of ( planClient?.planDetails?.product || [] ) ) {
|
|
716
741
|
if ( p.productName ) {
|
|
717
742
|
billingTypeByProduct[p.productName] = p.billingType || 'perStore';
|
|
718
743
|
}
|
|
719
744
|
}
|
|
745
|
+
const clientCurrency = planClient?.paymentInvoice?.currencyType;
|
|
746
|
+
if ( clientCurrency ) {
|
|
747
|
+
invoiceCurrency = clientCurrency === 'dollar' ? 'dollar' : 'inr';
|
|
748
|
+
}
|
|
720
749
|
} catch ( btErr ) {
|
|
721
750
|
logger.error( { error: btErr, function: 'latestDailyPricing.billingType', clientId: req.body.clientId } );
|
|
722
751
|
}
|
|
@@ -828,7 +857,7 @@ export async function latestDailyPricing( req, res ) {
|
|
|
828
857
|
...( store._doc || store ),
|
|
829
858
|
invoiceAmount: Math.round( invoiceAmount * 100 ) / 100,
|
|
830
859
|
invoiceBreakdown,
|
|
831
|
-
invoiceCurrency:
|
|
860
|
+
invoiceCurrency: invoiceCurrency,
|
|
832
861
|
};
|
|
833
862
|
} );
|
|
834
863
|
|
|
@@ -1667,6 +1696,11 @@ export async function getUsdInrRate() {
|
|
|
1667
1696
|
export async function billingSummary( req, res ) {
|
|
1668
1697
|
try {
|
|
1669
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;
|
|
1670
1704
|
const months = [];
|
|
1671
1705
|
for ( let i = 4; i >= 0; i-- ) {
|
|
1672
1706
|
const m = now.subtract( i, 'month' );
|
|
@@ -1726,6 +1760,54 @@ export async function billingSummary( req, res ) {
|
|
|
1726
1760
|
} );
|
|
1727
1761
|
const clientById = new Map( clients.map( ( c ) => [ String( c.clientId ), c ] ) );
|
|
1728
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
|
+
|
|
1729
1811
|
// Registered Entity comes from the billings collection (each billing group's
|
|
1730
1812
|
// registeredCompanyName). A client can have multiple groups/names, so
|
|
1731
1813
|
// collect the distinct list per client — the UI shows the first and reveals
|
|
@@ -1740,7 +1822,7 @@ export async function billingSummary( req, res ) {
|
|
|
1740
1822
|
|
|
1741
1823
|
// Per-client CSM (userAssignedStore, tangoUserType 'csm'); display the
|
|
1742
1824
|
// email's local part since the collection carries no display name.
|
|
1743
|
-
|
|
1825
|
+
// usdRate already fetched at the top of the function.
|
|
1744
1826
|
|
|
1745
1827
|
// Current month's store count comes from dailyPricing — counted the same
|
|
1746
1828
|
// way as Brands & Billing's "Billing Stores": distinct ACTIVE stores that
|
|
@@ -1957,11 +2039,21 @@ export async function billingSummary( req, res ) {
|
|
|
1957
2039
|
// a client has no billing-group registered name.
|
|
1958
2040
|
const regNames = regNamesByClient.get( r.clientId ) || [];
|
|
1959
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;
|
|
1960
2048
|
return {
|
|
1961
2049
|
clientId: r.clientId,
|
|
1962
2050
|
clientName: r.clientName || registeredEntity || r.clientId,
|
|
1963
2051
|
registeredEntity,
|
|
1964
2052
|
registeredEntities: regNames.length ? regNames : ( r.registeredEntity ? [ r.registeredEntity ] : [] ),
|
|
2053
|
+
outstanding,
|
|
2054
|
+
aging0to30,
|
|
2055
|
+
aging30to60,
|
|
2056
|
+
agingOver60,
|
|
1965
2057
|
status: r.status,
|
|
1966
2058
|
products: r.products,
|
|
1967
2059
|
csm: r.csm,
|
|
@@ -2021,3 +2113,127 @@ export async function billingSummary( req, res ) {
|
|
|
2021
2113
|
return res.sendError( error, 500 );
|
|
2022
2114
|
}
|
|
2023
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)
|
|
@@ -324,7 +344,15 @@ export async function createInvoice( req, res ) {
|
|
|
324
344
|
$filter: {
|
|
325
345
|
input: '$stores',
|
|
326
346
|
as: 'item',
|
|
327
|
-
|
|
347
|
+
// A store is "running" if it has working days for ANY product.
|
|
348
|
+
// Edge products (tangoZone/tangoTraffic) use daysDifference;
|
|
349
|
+
// tangoTrax uses daysDifferenceTrax. A trax-only store has
|
|
350
|
+
// daysDifference = 0 but daysDifferenceTrax > 0 — counting only
|
|
351
|
+
// daysDifference would wrongly drop it and report stores: 0.
|
|
352
|
+
cond: { $and: [
|
|
353
|
+
{ $or: [ { $gt: [ '$$item.daysDifference', 0 ] }, { $gt: [ '$$item.daysDifferenceTrax', 0 ] } ] },
|
|
354
|
+
{ $in: [ '$$item.storeId', group.stores ] },
|
|
355
|
+
] },
|
|
328
356
|
},
|
|
329
357
|
},
|
|
330
358
|
},
|
|
@@ -440,17 +468,62 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
|
|
|
440
468
|
const billingMonthEnd = new Date( billingMonth.endOf( 'month' ).toISOString() );
|
|
441
469
|
const monthDays = billingMonth.daysInMonth();
|
|
442
470
|
const invoiceCurrency = symbolFor( invoiceInfo.currency );
|
|
443
|
-
// basepricing negotiatePrice is stored in
|
|
444
|
-
//
|
|
445
|
-
//
|
|
446
|
-
|
|
471
|
+
// basepricing negotiatePrice is stored in the client's own billing currency
|
|
472
|
+
// (INR for INR clients, the foreign currency for dollar/AED clients). Invoice
|
|
473
|
+
// generation uses negotiatePrice verbatim (no FX), so the annexure must too —
|
|
474
|
+
// converting here would make the annexure unit price disagree with the actual
|
|
475
|
+
// billed amount (e.g. a $45 negotiated price wrongly shown as $0.48).
|
|
447
476
|
|
|
448
|
-
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 } );
|
|
449
478
|
const billingTypeMap = {};
|
|
450
479
|
( annexClient?.planDetails?.product || [] ).forEach( ( p ) => {
|
|
451
480
|
billingTypeMap[p.productName] = p.billingType || 'perStore';
|
|
452
481
|
} );
|
|
453
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
|
+
|
|
454
527
|
const rows = await dailyPricingService.aggregate( [
|
|
455
528
|
{ $match: { clientId: invoiceInfo.clientId, dateISO: { $lte: billingMonthEnd } } },
|
|
456
529
|
{ $sort: { dateISO: -1 } },
|
|
@@ -469,25 +542,12 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
|
|
|
469
542
|
zoneCameraCount: { $ifNull: [ '$stores.zoneCameraCount', 0 ] },
|
|
470
543
|
} },
|
|
471
544
|
{ $match: { workingdays: { $gt: 0 } } },
|
|
472
|
-
|
|
473
|
-
{ $
|
|
474
|
-
from: 'basepricings',
|
|
475
|
-
let: { clientId: invoiceInfo.clientId },
|
|
476
|
-
pipeline: [
|
|
477
|
-
{ $match: { $expr: { $eq: [ '$clientId', '$$clientId' ] } } },
|
|
478
|
-
{ $project: { standard: 1 } },
|
|
479
|
-
],
|
|
480
|
-
as: 'basepricing',
|
|
481
|
-
} },
|
|
482
|
-
{ $unwind: { path: '$basepricing', preserveNullAndEmptyArrays: true } },
|
|
483
|
-
{ $project: {
|
|
484
|
-
productName: 1, workingdays: 1, storeName: 1, storeId: 1, edgefirstFileDate: 1,
|
|
485
|
-
zoneCount: 1, trafficCameraCount: 1, zoneCameraCount: 1,
|
|
486
|
-
standard: { $filter: { input: '$basepricing.standard', as: 'standard', cond: { $eq: [ '$$standard.productName', '$productName' ] } } },
|
|
487
|
-
} },
|
|
488
|
-
{ $unwind: { path: '$standard', preserveNullAndEmptyArrays: true } },
|
|
545
|
+
// Deterministic ordering so step-tier assignment is stable run to run.
|
|
546
|
+
{ $sort: { productName: 1, storeId: 1 } },
|
|
489
547
|
] );
|
|
490
548
|
|
|
549
|
+
// Track each store's 1-based position within its product to pick the step tier.
|
|
550
|
+
const productPosition = {};
|
|
491
551
|
const data = rows.map( ( s ) => {
|
|
492
552
|
const billingType = billingTypeMap[s.productName] || 'perStore';
|
|
493
553
|
// Same units rule as invoice generation: perZone / perCamera multiply by
|
|
@@ -504,9 +564,11 @@ async function buildAnnexureRows( invoiceInfo, getgroup ) {
|
|
|
504
564
|
units = s.trafficCameraCount;
|
|
505
565
|
}
|
|
506
566
|
}
|
|
507
|
-
|
|
508
|
-
//
|
|
509
|
-
|
|
567
|
+
productPosition[s.productName] = ( productPosition[s.productName] || 0 ) + 1;
|
|
568
|
+
// negotiatePrice is already in the invoice currency — use it verbatim,
|
|
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] );
|
|
510
572
|
const runningCost = s.workingdays >= monthDays ?
|
|
511
573
|
Math.round( price * units * 100 ) / 100 :
|
|
512
574
|
Math.round( ( price / monthDays ) * s.workingdays * units * 100 ) / 100;
|
|
@@ -1684,7 +1746,13 @@ async function stepPrice( group, getClient ) {
|
|
|
1684
1746
|
|
|
1685
1747
|
let stepPriceRecord = await basepricingService.findOne( { clientId: group.clientId } );
|
|
1686
1748
|
let data = products;
|
|
1687
|
-
|
|
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
|
+
} );
|
|
1688
1756
|
|
|
1689
1757
|
const applyPricing = ( data, pricing ) => {
|
|
1690
1758
|
let totalcount = 0;
|
|
@@ -1713,26 +1781,54 @@ async function stepPrice( group, getClient ) {
|
|
|
1713
1781
|
function processArray( array1, array2 ) {
|
|
1714
1782
|
let updatedArray = [];
|
|
1715
1783
|
|
|
1716
|
-
|
|
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
|
+
|
|
1717
1792
|
for ( let item of array1 ) {
|
|
1718
1793
|
let remainingStores = item.storeCount;
|
|
1794
|
+
let assigned = assignedByProduct[item.productName] || 0;
|
|
1719
1795
|
|
|
1720
1796
|
for ( let range of array2 ) {
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
console.log( firstPriceAssigned );
|
|
1725
|
-
let applicableStores = Math.min( remainingStores, max - min + 1 );
|
|
1726
|
-
updatedArray.push( {
|
|
1727
|
-
productName: item.productName,
|
|
1728
|
-
workingdays: item.workingdays,
|
|
1729
|
-
storeCount: applicableStores,
|
|
1730
|
-
price: firstPriceAssigned ? 1100 : range.negotiatePrice,
|
|
1731
|
-
} );
|
|
1732
|
-
remainingStores -= applicableStores;
|
|
1733
|
-
firstPriceAssigned = true;
|
|
1797
|
+
const [ min, max ] = range.storeRange.split( '-' ).map( Number );
|
|
1798
|
+
if ( remainingStores <= 0 ) {
|
|
1799
|
+
break;
|
|
1734
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;
|
|
1735
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;
|
|
1736
1832
|
}
|
|
1737
1833
|
return updatedArray;
|
|
1738
1834
|
}
|
|
@@ -1741,9 +1837,16 @@ async function stepPrice( group, getClient ) {
|
|
|
1741
1837
|
console.log( '++++++++++++', resultarray );
|
|
1742
1838
|
const result = applyPricing( resultarray, pricing );
|
|
1743
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.
|
|
1744
1847
|
const groupedData = result.reduce( ( acc, item ) => {
|
|
1745
1848
|
const { productName, period, runningCost } = item;
|
|
1746
|
-
const key = `${productName}_${period}_${item.
|
|
1849
|
+
const key = `${productName}_${period}_${item.price}`;
|
|
1747
1850
|
if ( !acc[key] ) {
|
|
1748
1851
|
acc[key] = {
|
|
1749
1852
|
productName,
|
|
@@ -1752,14 +1855,8 @@ async function stepPrice( group, getClient ) {
|
|
|
1752
1855
|
count: 0,
|
|
1753
1856
|
};
|
|
1754
1857
|
}
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
acc[key].count += item.storeCount;
|
|
1758
|
-
} else {
|
|
1759
|
-
acc[key].totalRunningCost = parseFloat( runningCost );
|
|
1760
|
-
acc[key].count = item.storeCount;
|
|
1761
|
-
}
|
|
1762
|
-
|
|
1858
|
+
acc[key].totalRunningCost += parseFloat( runningCost );
|
|
1859
|
+
acc[key].count += item.storeCount;
|
|
1763
1860
|
return acc;
|
|
1764
1861
|
}, {} );
|
|
1765
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
|
+
};
|