tango-app-api-payment-subscription 3.5.22 → 3.5.24
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 +3 -3
- package/src/controllers/billing.controllers.js +88 -55
- package/src/controllers/brandsBilling.controller.js +430 -69
- package/src/controllers/estimate.controller.js +111 -17
- package/src/controllers/invoice.controller.js +53 -8
- package/src/controllers/paymentSubscription.controllers.js +82 -2
- package/src/controllers/purchaseOrder.controller.js +112 -4
- package/src/hbs/estimatePdf.hbs +12 -0
- package/src/hbs/invoicepaymentemail.hbs +4 -0
- package/src/routes/billing.routes.js +7 -8
- package/src/routes/brandsBilling.routes.js +12 -12
- package/src/routes/invoice.routes.js +4 -3
- package/src/routes/paymentSubscription.routes.js +5 -7
- package/src/services/clientPayment.services.js +21 -0
- package/src/services/purchaseOrder.service.js +4 -0
|
@@ -8,7 +8,7 @@ import * as basePriceService from '../services/basePrice.service.js';
|
|
|
8
8
|
import * as cameraService from '../services/camera.service.js';
|
|
9
9
|
import * as planogramService from '../services/planogram.service.js';
|
|
10
10
|
import dayjs from 'dayjs';
|
|
11
|
-
import { logger, checkFileExist, signedUrl, download, insertOpenSearchData, getOpenSearchData } from 'tango-app-api-middleware';
|
|
11
|
+
import { logger, checkFileExist, signedUrl, download, insertOpenSearchData, getOpenSearchData, searchOpenSearchData, scrollResponse, clearScroll } from 'tango-app-api-middleware';
|
|
12
12
|
import * as XLSX from 'xlsx';
|
|
13
13
|
import ExcelJS from 'exceljs';
|
|
14
14
|
import { bulkUpdateBillingGroupRowSchema } from '../dtos/validation.dtos.js';
|
|
@@ -428,6 +428,15 @@ export async function brandInvoiceList( req, res ) {
|
|
|
428
428
|
},
|
|
429
429
|
} ];
|
|
430
430
|
|
|
431
|
+
// Invoice type sub-toggle: 'advance' shows only advance invoices (invoice #
|
|
432
|
+
// starts with 'TINV-'); 'all' shows only regular invoices (NOT TINV-*).
|
|
433
|
+
// Applied to the whole pipeline so list, count, summary and cards match.
|
|
434
|
+
if ( req.body.invoiceType === 'advance' ) {
|
|
435
|
+
query.push( { $match: { invoice: { $regex: '^TINV-' } } } );
|
|
436
|
+
} else if ( req.body.invoiceType === 'all' ) {
|
|
437
|
+
query.push( { $match: { invoice: { $not: { $regex: '^TINV-' } } } } );
|
|
438
|
+
}
|
|
439
|
+
|
|
431
440
|
// Client users only ever see APPROVED invoices — the approval pipeline
|
|
432
441
|
// (pendingCsm/pendingFinance/pendingApproval/pending) is internal to tango
|
|
433
442
|
// and must not be exposed to clients, even in the raw list response.
|
|
@@ -580,16 +589,18 @@ export async function brandInvoiceList( req, res ) {
|
|
|
580
589
|
}
|
|
581
590
|
|
|
582
591
|
// Footer totals are shown in a single ₹ figure, but individual invoices may
|
|
583
|
-
// be in
|
|
584
|
-
// summing so the ₹ total is correct
|
|
585
|
-
|
|
586
|
-
const
|
|
587
|
-
|
|
592
|
+
// be in a foreign currency ($, €, S$, AED). Convert each to INR at today's
|
|
593
|
+
// rate (per-currency, not just USD) before summing so the ₹ total is correct.
|
|
594
|
+
const rateByCurrency = {};
|
|
595
|
+
for ( const cur of new Set( allInvoices.map( ( c ) => String( c.currency || 'inr' ) ) ) ) {
|
|
596
|
+
rateByCurrency[cur] = await getInrRate( cur );
|
|
597
|
+
}
|
|
598
|
+
const toInr = ( inv, value ) => ( Number( value ) || 0 ) * ( rateByCurrency[String( inv.currency || 'inr' )] || 1 );
|
|
588
599
|
let summary = {
|
|
589
600
|
totalInvoices: allInvoices.length,
|
|
590
601
|
totalInvoiced: Math.round( allInvoices.reduce( ( sum, inv ) => sum + toInr( inv, inv.totalAmount ), 0 ) ),
|
|
591
602
|
// Footer totals over the FULL filtered set (not just the current page):
|
|
592
|
-
// stores, amount excl. GST and amount incl. GST (
|
|
603
|
+
// stores, amount excl. GST and amount incl. GST (foreign → INR converted).
|
|
593
604
|
totalStores: allInvoices.reduce( ( sum, inv ) => sum + ( inv.stores || 0 ), 0 ),
|
|
594
605
|
totalAmountExclGst: Math.round( allInvoices.reduce( ( sum, inv ) => sum + toInr( inv, inv.amount ), 0 ) ),
|
|
595
606
|
totalAmountInclGst: Math.round( allInvoices.reduce( ( sum, inv ) => sum + toInr( inv, inv.totalAmount ), 0 ) ),
|
|
@@ -598,6 +609,41 @@ export async function brandInvoiceList( req, res ) {
|
|
|
598
609
|
paid: allInvoices.filter( ( inv ) => inv.paymentStatus === 'paid' ).length,
|
|
599
610
|
};
|
|
600
611
|
|
|
612
|
+
// Header cards — Outstanding / Overdue / Pending Payment — each split into a
|
|
613
|
+
// GST excl (sum of `amount`) and incl (sum of `totalAmount`) figure, all
|
|
614
|
+
// converted to INR. Same shape/semantics as the All Invoices page cards.
|
|
615
|
+
const nowDate = new Date();
|
|
616
|
+
const cards = {
|
|
617
|
+
outstandingExclAmount: 0, outstandingInclAmount: 0, outstandingCount: 0,
|
|
618
|
+
overdueExclAmount: 0, overdueInclAmount: 0, overdueCount: 0,
|
|
619
|
+
pendingPaymentExclAmount: 0, pendingPaymentInclAmount: 0, pendingPaymentCount: 0,
|
|
620
|
+
};
|
|
621
|
+
for ( const inv of allInvoices ) {
|
|
622
|
+
if ( inv.paymentStatus === 'paid' ) {
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
const excl = toInr( inv, inv.amount );
|
|
626
|
+
const incl = toInr( inv, inv.totalAmount || inv.amount );
|
|
627
|
+
cards.outstandingExclAmount += excl;
|
|
628
|
+
cards.outstandingInclAmount += incl;
|
|
629
|
+
cards.outstandingCount += 1;
|
|
630
|
+
if ( inv.dueDate && new Date( inv.dueDate ) < nowDate ) {
|
|
631
|
+
cards.overdueExclAmount += excl;
|
|
632
|
+
cards.overdueInclAmount += incl;
|
|
633
|
+
cards.overdueCount += 1;
|
|
634
|
+
}
|
|
635
|
+
if ( inv.status === 'approved' ) {
|
|
636
|
+
cards.pendingPaymentExclAmount += excl;
|
|
637
|
+
cards.pendingPaymentInclAmount += incl;
|
|
638
|
+
cards.pendingPaymentCount += 1;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
for ( const k of Object.keys( cards ) ) {
|
|
642
|
+
if ( k.endsWith( 'Amount' ) ) {
|
|
643
|
+
cards[k] = Math.round( cards[k] );
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
601
647
|
if ( req.body.export ) {
|
|
602
648
|
// Mirror the on-screen table exactly: same columns (excl. GST and incl.
|
|
603
649
|
// GST as separate amounts), same currency symbol, and the same status
|
|
@@ -653,7 +699,7 @@ export async function brandInvoiceList( req, res ) {
|
|
|
653
699
|
clientInfo.totalStores = billingGroups.reduce( ( sum, bg ) => sum + ( bg.stores ? bg.stores.length : 0 ), 0 );
|
|
654
700
|
clientInfo.totalGroups = billingGroups.length;
|
|
655
701
|
|
|
656
|
-
res.sendSuccess( { clientInfo, summary, count, data: invoiceList } );
|
|
702
|
+
res.sendSuccess( { clientInfo, summary, cards, count, data: invoiceList } );
|
|
657
703
|
} catch ( error ) {
|
|
658
704
|
logger.error( { error: error, function: 'brandInvoiceList' } );
|
|
659
705
|
return res.sendError( error, 500 );
|
|
@@ -688,6 +734,27 @@ export async function latestDailyPricing( req, res ) {
|
|
|
688
734
|
let stores = record.stores || [];
|
|
689
735
|
let count = stores.length;
|
|
690
736
|
|
|
737
|
+
// The daily-pricing store objects don't carry the store's country — it lives
|
|
738
|
+
// in the stores collection under storeProfile.country. Build a
|
|
739
|
+
// storeId → country map for this client and stamp `country` onto each store
|
|
740
|
+
// so the UI can show it as a column and filter by it.
|
|
741
|
+
const countryByStore = new Map();
|
|
742
|
+
try {
|
|
743
|
+
const storeDocs = await storeService.find(
|
|
744
|
+
{ clientId: req.body.clientId },
|
|
745
|
+
{ 'storeId': 1, 'storeProfile.country': 1 },
|
|
746
|
+
);
|
|
747
|
+
for ( const s of ( storeDocs || [] ) ) {
|
|
748
|
+
countryByStore.set( String( s.storeId ), s?.storeProfile?.country || '' );
|
|
749
|
+
}
|
|
750
|
+
} catch ( cErr ) {
|
|
751
|
+
logger.error( { error: cErr, function: 'latestDailyPricing.country', clientId: req.body.clientId } );
|
|
752
|
+
}
|
|
753
|
+
stores = stores.map( ( s ) => {
|
|
754
|
+
const base = s._doc || s;
|
|
755
|
+
return { ...base, country: countryByStore.get( String( base.storeId ) ) || '' };
|
|
756
|
+
} );
|
|
757
|
+
|
|
691
758
|
// statusFilter may be an array (multi-select) or a legacy string. An empty
|
|
692
759
|
// value / empty array means "all statuses".
|
|
693
760
|
const statusFilterList = Array.isArray( req.body.statusFilter ) ?
|
|
@@ -698,6 +765,15 @@ export async function latestDailyPricing( req, res ) {
|
|
|
698
765
|
stores = stores.filter( ( s ) => allowed.has( s.status ) );
|
|
699
766
|
}
|
|
700
767
|
|
|
768
|
+
// countryFilter may be an array (multi-select) or a string; empty = all.
|
|
769
|
+
const countryFilterList = Array.isArray( req.body.countryFilter ) ?
|
|
770
|
+
req.body.countryFilter.filter( Boolean ) :
|
|
771
|
+
( req.body.countryFilter ? [ req.body.countryFilter ] : [] );
|
|
772
|
+
if ( countryFilterList.length ) {
|
|
773
|
+
const allowedCountries = new Set( countryFilterList );
|
|
774
|
+
stores = stores.filter( ( s ) => allowedCountries.has( s.country ) );
|
|
775
|
+
}
|
|
776
|
+
|
|
701
777
|
if ( req.body.searchValue && req.body.searchValue !== '' ) {
|
|
702
778
|
const searchRegex = new RegExp( req.body.searchValue, 'i' );
|
|
703
779
|
stores = stores.filter( ( s ) => searchRegex.test( s.storeName ) || searchRegex.test( s.storeId ) );
|
|
@@ -928,6 +1004,11 @@ export async function latestDailyPricing( req, res ) {
|
|
|
928
1004
|
.sort( ( a, b ) => a.firstFileDate.localeCompare( b.firstFileDate ) );
|
|
929
1005
|
const newlyOnboardedStores = newlyOnboardedStoreList.length;
|
|
930
1006
|
|
|
1007
|
+
// Unique, sorted, non-empty countries across ALL of the client's stores
|
|
1008
|
+
// (from the map above) — feeds the Country filter dropdown independent of
|
|
1009
|
+
// the current page / status / country selection.
|
|
1010
|
+
const countries = [ ...new Set( [ ...countryByStore.values() ].filter( Boolean ) ) ].sort();
|
|
1011
|
+
|
|
931
1012
|
let data = {
|
|
932
1013
|
clientId: record.clientId,
|
|
933
1014
|
brandName: record.brandName,
|
|
@@ -938,6 +1019,7 @@ export async function latestDailyPricing( req, res ) {
|
|
|
938
1019
|
status: record.status,
|
|
939
1020
|
proRate: record.proRate,
|
|
940
1021
|
count,
|
|
1022
|
+
countries,
|
|
941
1023
|
newlyOnboardedStores,
|
|
942
1024
|
newlyOnboardedStoreList,
|
|
943
1025
|
data: storeList,
|
|
@@ -1709,30 +1791,60 @@ export async function bulkUpdateBillingGroups( req, res ) {
|
|
|
1709
1791
|
// including the current. Filtering/sorting happen client-side (the dataset
|
|
1710
1792
|
// is one row per client).
|
|
1711
1793
|
// ---------------------------------------------------------------------------
|
|
1794
|
+
// Live USD-based FX rates (cached 6h) from open.er-api.com. The response gives
|
|
1795
|
+
// every currency relative to USD (rates.INR, rates.EUR, rates.SGD, …), so we
|
|
1796
|
+
// cache the whole table and derive any currency→INR rate as rates.INR / rates.X.
|
|
1797
|
+
let fxRatesCache = { rates: null, at: 0 };
|
|
1798
|
+
async function getUsdRates() {
|
|
1799
|
+
if ( fxRatesCache.rates && ( Date.now() - fxRatesCache.at ) < 6 * 60 * 60 * 1000 ) {
|
|
1800
|
+
return fxRatesCache.rates;
|
|
1801
|
+
}
|
|
1802
|
+
try {
|
|
1803
|
+
const resp = await fetch( 'https://open.er-api.com/v6/latest/USD', { signal: AbortSignal.timeout( 4000 ) } );
|
|
1804
|
+
const body = await resp.json();
|
|
1805
|
+
if ( body?.rates && Number( body.rates.INR ) > 0 ) {
|
|
1806
|
+
fxRatesCache = { rates: body.rates, at: Date.now() };
|
|
1807
|
+
return body.rates;
|
|
1808
|
+
}
|
|
1809
|
+
} catch ( err ) {
|
|
1810
|
+
logger.error( { error: err, function: 'getUsdRates' } );
|
|
1811
|
+
}
|
|
1812
|
+
return fxRatesCache.rates; // may be null on cold-start failure
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1712
1815
|
// Today's USD->INR rate for dollar-priced clients. Live rate (cached 6h)
|
|
1713
1816
|
// with an env override (USD_INR_RATE) and a last-known/static fallback so
|
|
1714
1817
|
// the summary never fails because a rate API is down.
|
|
1715
|
-
let usdRateCache = { rate: null, at: 0 };
|
|
1716
1818
|
export async function getUsdInrRate() {
|
|
1717
1819
|
const override = Number( process.env.USD_INR_RATE );
|
|
1718
1820
|
if ( override > 0 ) {
|
|
1719
1821
|
return override;
|
|
1720
1822
|
}
|
|
1721
|
-
|
|
1722
|
-
|
|
1823
|
+
const rates = await getUsdRates();
|
|
1824
|
+
const rate = Number( rates?.INR );
|
|
1825
|
+
return rate > 0 ? rate : 83.33;
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
// The app's own currency codes → ISO codes used by the FX API.
|
|
1829
|
+
const CURRENCY_ISO = { inr: 'INR', dollar: 'USD', usd: 'USD', euro: 'EUR', eur: 'EUR', singaporedollar: 'SGD', sgd: 'SGD', aed: 'AED' };
|
|
1830
|
+
// Static last-resort rates (currency→INR) if the FX API is unreachable on a
|
|
1831
|
+
// cold start. Approximate; only used so totals aren't wildly wrong.
|
|
1832
|
+
const FALLBACK_INR = { INR: 1, USD: 83.33, EUR: 90, SGD: 62, AED: 22.7 };
|
|
1833
|
+
|
|
1834
|
+
// Multiplier to convert an amount in `currency` to INR. INR → 1. Unknown
|
|
1835
|
+
// currencies fall back to 1 (treated as already-INR) so nothing is dropped.
|
|
1836
|
+
export async function getInrRate( currency ) {
|
|
1837
|
+
const iso = CURRENCY_ISO[String( currency || '' ).toLowerCase()];
|
|
1838
|
+
if ( !iso || iso === 'INR' ) {
|
|
1839
|
+
return 1;
|
|
1723
1840
|
}
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
usdRateCache = { rate, at: Date.now() };
|
|
1730
|
-
return rate;
|
|
1731
|
-
}
|
|
1732
|
-
} catch ( err ) {
|
|
1733
|
-
logger.error( { error: err, function: 'getUsdInrRate' } );
|
|
1841
|
+
const rates = await getUsdRates();
|
|
1842
|
+
const inr = Number( rates?.INR );
|
|
1843
|
+
const cur = Number( rates?.[iso] );
|
|
1844
|
+
if ( inr > 0 && cur > 0 ) {
|
|
1845
|
+
return inr / cur; // 1 <currency> = (INR per USD) / (currency per USD) INR
|
|
1734
1846
|
}
|
|
1735
|
-
return
|
|
1847
|
+
return FALLBACK_INR[iso] || 1;
|
|
1736
1848
|
}
|
|
1737
1849
|
|
|
1738
1850
|
export async function billingSummary( req, res ) {
|
|
@@ -2185,6 +2297,11 @@ const ADDITIONAL_PRODUCT_PRICES = {
|
|
|
2185
2297
|
planogram: 770,
|
|
2186
2298
|
aiManager: 500,
|
|
2187
2299
|
};
|
|
2300
|
+
// Product Lambda Function URLs (VMS / Run AI). Each returns the list of billable
|
|
2301
|
+
// store IDs for a client; used both for the detailed export and for the invoice
|
|
2302
|
+
// additional-products quantity (scoped to a billing group's stores).
|
|
2303
|
+
const RUN_AI_LAMBDA_URL = 'https://mdm3mf7wuficgv3jjspkws2nu40azlsc.lambda-url.ap-south-1.on.aws/';
|
|
2304
|
+
const VMS_LAMBDA_URL = 'https://ppf3l3mxc2lorh5hkrsj6zwyim0bupxw.lambda-url.ap-south-1.on.aws/';
|
|
2188
2305
|
// OpenSearch index holding extra billed products (VMS, Run AI, etc.). Each
|
|
2189
2306
|
// record is a daily snapshot carrying its own quantity + price per product.
|
|
2190
2307
|
const BILLING_DETAILS_INDEX = ( () => {
|
|
@@ -2199,12 +2316,37 @@ const BILLING_DETAILS_INDEX = ( () => {
|
|
|
2199
2316
|
// that has none (currently only clientId 11). Shared by the Billing Breakdown
|
|
2200
2317
|
// table (additionalProducts controller) AND invoice generation, so both stay in
|
|
2201
2318
|
// sync. Each entry: { productName, quantity, price, total }.
|
|
2202
|
-
|
|
2319
|
+
//
|
|
2320
|
+
// storeIds (optional): when a non-empty array is passed, the Eyetest / Planogram
|
|
2321
|
+
// / AI Manager quantities are restricted to those stores only — used by invoice
|
|
2322
|
+
// generation so a billing group is billed for its assigned stores, not the whole
|
|
2323
|
+
// brand. When omitted (e.g. the brand-wide Billing Breakdown endpoint) the counts
|
|
2324
|
+
// stay client-wide.
|
|
2325
|
+
//
|
|
2326
|
+
// Run AI / VMS are sourced by caller: invoice generation (storeIds passed) uses
|
|
2327
|
+
// the product Lambdas (called brand-wide, then intersected with the group's
|
|
2328
|
+
// stores locally); the brand-wide Billing Breakdown (no storeIds) uses the
|
|
2329
|
+
// billing_details snapshot instead.
|
|
2330
|
+
//
|
|
2331
|
+
// Remote Optum is store-scoped for invoices too: its quantity becomes the count
|
|
2332
|
+
// of remote_optom_steps_summary records for the group over the latest inserted
|
|
2333
|
+
// month in that index (see remoteOptumRecordCount). Factory is dropped from
|
|
2334
|
+
// invoices entirely. The brand-wide Billing Breakdown keeps taking Remote Optum /
|
|
2335
|
+
// Factory from the billing_details snapshot as-is.
|
|
2336
|
+
export async function getAdditionalProducts( clientId, storeIds ) {
|
|
2203
2337
|
clientId = String( clientId ?? '' );
|
|
2204
2338
|
if ( clientId !== ADDITIONAL_PRODUCTS_CLIENT_ID ) {
|
|
2205
2339
|
return [];
|
|
2206
2340
|
}
|
|
2207
2341
|
|
|
2342
|
+
// Restrict the Mongo aggregations to the billing group's stores when provided.
|
|
2343
|
+
// group.stores holds storeIds, matched via $in against each collection's
|
|
2344
|
+
// storeId field (top-level for cameras/planograms, stores.storeId once
|
|
2345
|
+
// unwound for daily-pricing).
|
|
2346
|
+
const scopeStores = Array.isArray( storeIds ) && storeIds.length;
|
|
2347
|
+
const storeMatch = scopeStores ? { storeId: { $in: storeIds } } : {};
|
|
2348
|
+
const unwoundStoreMatch = scopeStores ? { 'stores.storeId': { $in: storeIds } } : {};
|
|
2349
|
+
|
|
2208
2350
|
// Eyetest: distinct stores in the cameras collection that have an eye-test
|
|
2209
2351
|
// stream with a QR code.
|
|
2210
2352
|
const eyetestRows = await cameraService.aggregate( [
|
|
@@ -2212,6 +2354,7 @@ export async function getAdditionalProducts( clientId ) {
|
|
|
2212
2354
|
{ clientId: clientId },
|
|
2213
2355
|
{ isEyeTestStream: true },
|
|
2214
2356
|
{ qrCode: { $exists: true } },
|
|
2357
|
+
storeMatch,
|
|
2215
2358
|
] } },
|
|
2216
2359
|
{ $group: { _id: '$storeId' } },
|
|
2217
2360
|
{ $count: 'stores' },
|
|
@@ -2220,7 +2363,7 @@ export async function getAdditionalProducts( clientId ) {
|
|
|
2220
2363
|
|
|
2221
2364
|
// Planogram: distinct storeName in the planograms collection.
|
|
2222
2365
|
const planogramRows = await planogramService.aggregate( [
|
|
2223
|
-
{ $match: { clientId: clientId } },
|
|
2366
|
+
{ $match: { clientId: clientId, ...storeMatch } },
|
|
2224
2367
|
{ $group: { _id: null, storecount: { $addToSet: '$storeName' } } },
|
|
2225
2368
|
{ $project: { count: { $size: '$storecount' } } },
|
|
2226
2369
|
] );
|
|
@@ -2234,6 +2377,7 @@ export async function getAdditionalProducts( clientId ) {
|
|
|
2234
2377
|
{ $sort: { dateISO: -1 } },
|
|
2235
2378
|
{ $limit: 1 },
|
|
2236
2379
|
{ $unwind: '$stores' },
|
|
2380
|
+
...( scopeStores ? [ { $match: unwoundStoreMatch } ] : [] ),
|
|
2237
2381
|
{ $unwind: '$stores.products' },
|
|
2238
2382
|
{ $match: { 'stores.products.productName': 'tangoTraffic', 'stores.products.workingdays': { $gt: 1 } } },
|
|
2239
2383
|
{ $group: { _id: '$stores.storeId' } },
|
|
@@ -2254,10 +2398,45 @@ export async function getAdditionalProducts( clientId ) {
|
|
|
2254
2398
|
build( 'AI Manager', aiManagerQty, ADDITIONAL_PRODUCT_PRICES.aiManager ),
|
|
2255
2399
|
];
|
|
2256
2400
|
|
|
2401
|
+
// Run AI and VMS quantities come from two different sources depending on the
|
|
2402
|
+
// caller:
|
|
2403
|
+
// - Invoice generation (storeIds passed): use the product Lambdas. The
|
|
2404
|
+
// Lambda's own store_ids filter is unreliable, so lambdaStoreCount calls it
|
|
2405
|
+
// brand-wide and intersects the returned store list with the group's stores
|
|
2406
|
+
// locally — giving a per-group scoped count. Unit price from billing_details.
|
|
2407
|
+
// - Brand-wide Billing Breakdown (no storeIds): use the billing_details
|
|
2408
|
+
// OpenSearch snapshot instead (handled by the loop below), same as Remote
|
|
2409
|
+
// Optum / Factory.
|
|
2410
|
+
// lambdaSourced marks which productNames the loop below must skip so they are
|
|
2411
|
+
// not counted twice — only populated in the invoice (scoped) case.
|
|
2412
|
+
const lambdaSourced = new Set();
|
|
2413
|
+
if ( scopeStores ) {
|
|
2414
|
+
const [ runAiQty, vmsQty, runAiPrice, vmsPrice, optumQty, optumPrice ] = await Promise.all( [
|
|
2415
|
+
lambdaStoreCount( clientId, RUN_AI_LAMBDA_URL, storeIds ),
|
|
2416
|
+
lambdaStoreCount( clientId, VMS_LAMBDA_URL, storeIds ),
|
|
2417
|
+
billingDetailsPrice( clientId, 'Run AI' ),
|
|
2418
|
+
billingDetailsPrice( clientId, 'VMS' ),
|
|
2419
|
+
remoteOptumRecordCount( clientId, storeIds ),
|
|
2420
|
+
billingDetailsPrice( clientId, 'Remote Optum' ),
|
|
2421
|
+
] );
|
|
2422
|
+
products.push( build( 'Run AI', runAiQty, runAiPrice ) );
|
|
2423
|
+
products.push( build( 'VMS', vmsQty, vmsPrice ) );
|
|
2424
|
+
products.push( build( 'Remote Optum', optumQty, optumPrice ) );
|
|
2425
|
+
// Skipped in the billing_details loop below so they are not counted twice.
|
|
2426
|
+
lambdaSourced.add( 'run ai' );
|
|
2427
|
+
lambdaSourced.add( 'vms' );
|
|
2428
|
+
lambdaSourced.add( 'remote optum' );
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2257
2431
|
// Extra products from the billing_details OpenSearch index — the LATEST daily
|
|
2258
2432
|
// snapshot for this client. quantity/price are stored as strings, so coerce
|
|
2259
|
-
// them.
|
|
2260
|
-
//
|
|
2433
|
+
// them. In the invoice (scoped) case VMS / Run AI / Remote Optum are skipped
|
|
2434
|
+
// here (already added store-scoped above) to avoid double-counting; in the
|
|
2435
|
+
// brand-wide case they are taken from this snapshot like the other extra
|
|
2436
|
+
// products. If OpenSearch is unreachable we just skip these rows rather than
|
|
2437
|
+
// failing the whole list. Factory is excluded from invoices (scoped) but still
|
|
2438
|
+
// shown in the brand-wide Billing Breakdown table.
|
|
2439
|
+
const invoiceExcluded = scopeStores ? new Set( [ 'factory' ] ) : new Set();
|
|
2261
2440
|
try {
|
|
2262
2441
|
const osRes = await getOpenSearchData( BILLING_DETAILS_INDEX, {
|
|
2263
2442
|
size: 1,
|
|
@@ -2267,6 +2446,10 @@ export async function getAdditionalProducts( clientId ) {
|
|
|
2267
2446
|
const hits = osRes?.body?.hits?.hits || osRes?.hits?.hits || [];
|
|
2268
2447
|
const osProducts = hits[0]?._source?.products || [];
|
|
2269
2448
|
for ( const p of osProducts ) {
|
|
2449
|
+
const nameKey = String( p.productName ).toLowerCase();
|
|
2450
|
+
if ( lambdaSourced.has( nameKey ) || invoiceExcluded.has( nameKey ) ) {
|
|
2451
|
+
continue;
|
|
2452
|
+
}
|
|
2270
2453
|
const quantity = Number( p.quantity ) || 0;
|
|
2271
2454
|
const price = Number( p.price ) || 0;
|
|
2272
2455
|
products.push( build( p.productName, quantity, price ) );
|
|
@@ -2281,8 +2464,13 @@ export async function getAdditionalProducts( clientId ) {
|
|
|
2281
2464
|
export async function additionalProducts( req, res ) {
|
|
2282
2465
|
try {
|
|
2283
2466
|
const clientId = String( req.query?.clientId ?? req.body?.clientId ?? '' );
|
|
2284
|
-
const products = await
|
|
2285
|
-
|
|
2467
|
+
const [ products, billingDate ] = await Promise.all( [
|
|
2468
|
+
getAdditionalProducts( clientId ),
|
|
2469
|
+
billingDetailsDate( clientId ),
|
|
2470
|
+
] );
|
|
2471
|
+
// billingDate (YYYY-MM-DD) is the billing_details snapshot behind the counts;
|
|
2472
|
+
// the UI passes it back to the export endpoint so exports match the cards.
|
|
2473
|
+
return res.sendSuccess( { currency: 'inr', products, billingDate } );
|
|
2286
2474
|
} catch ( error ) {
|
|
2287
2475
|
logger.error( { error: error, function: 'additionalProducts' } );
|
|
2288
2476
|
return res.sendError( error, 500 );
|
|
@@ -2309,6 +2497,35 @@ async function lamdaServiceCall( url, data ) {
|
|
|
2309
2497
|
}
|
|
2310
2498
|
}
|
|
2311
2499
|
|
|
2500
|
+
// Date (YYYY-MM-DD) of the latest billing_details OpenSearch snapshot for a
|
|
2501
|
+
// client — the snapshot whose product counts back the Additional Products cards.
|
|
2502
|
+
// The UI passes this back to the export endpoint so the export reflects the same
|
|
2503
|
+
// day as the card. Returns '' if unavailable; date_string is normalised to
|
|
2504
|
+
// YYYY-MM-DD (it may be stored as DD-MM-YYYY).
|
|
2505
|
+
async function billingDetailsDate( clientId ) {
|
|
2506
|
+
try {
|
|
2507
|
+
const osRes = await getOpenSearchData( BILLING_DETAILS_INDEX, {
|
|
2508
|
+
size: 1,
|
|
2509
|
+
query: { term: { 'client_id': clientId } },
|
|
2510
|
+
sort: [ { date_string: { order: 'desc' } } ],
|
|
2511
|
+
} );
|
|
2512
|
+
const hits = osRes?.body?.hits?.hits || osRes?.hits?.hits || [];
|
|
2513
|
+
const raw = String( hits[0]?._source?.date_string || '' ).trim();
|
|
2514
|
+
if ( !raw ) {
|
|
2515
|
+
return '';
|
|
2516
|
+
}
|
|
2517
|
+
// Accept DD-MM-YYYY or YYYY-MM-DD; return YYYY-MM-DD.
|
|
2518
|
+
const ddmmyyyy = raw.match( /^(\d{2})-(\d{2})-(\d{4})$/ );
|
|
2519
|
+
if ( ddmmyyyy ) {
|
|
2520
|
+
return `${ddmmyyyy[3]}-${ddmmyyyy[2]}-${ddmmyyyy[1]}`;
|
|
2521
|
+
}
|
|
2522
|
+
return raw;
|
|
2523
|
+
} catch ( osErr ) {
|
|
2524
|
+
logger.error( { error: osErr, function: 'billingDetailsDate', clientId } );
|
|
2525
|
+
return '';
|
|
2526
|
+
}
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2312
2529
|
// Unit price for a product from the latest billing_details OpenSearch record.
|
|
2313
2530
|
// productName match is case-insensitive. Returns 0 if not found / unavailable.
|
|
2314
2531
|
async function billingDetailsPrice( clientId, productName ) {
|
|
@@ -2328,13 +2545,82 @@ async function billingDetailsPrice( clientId, productName ) {
|
|
|
2328
2545
|
}
|
|
2329
2546
|
}
|
|
2330
2547
|
|
|
2548
|
+
// Date (YYYY-MM-DD) of the most recent record in the remote_optom_steps_summary
|
|
2549
|
+
// index — the latest day audit data was inserted. Date is a keyword so it sorts
|
|
2550
|
+
// lexically. Returns '' if the index is empty / unreachable.
|
|
2551
|
+
async function remoteOptumLatestDate() {
|
|
2552
|
+
try {
|
|
2553
|
+
const osRes = await getOpenSearchData( 'remote_optom_steps_summary', {
|
|
2554
|
+
size: 1,
|
|
2555
|
+
_source: [ 'Date' ],
|
|
2556
|
+
sort: [ { 'Date': { order: 'desc' } } ],
|
|
2557
|
+
} );
|
|
2558
|
+
const hits = osRes?.body?.hits?.hits || osRes?.hits?.hits || [];
|
|
2559
|
+
return String( hits[0]?._source?.Date || '' ).trim();
|
|
2560
|
+
} catch ( osErr ) {
|
|
2561
|
+
logger.error( { error: osErr, function: 'remoteOptumLatestDate' } );
|
|
2562
|
+
return '';
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
|
|
2566
|
+
// Remote Optum billed quantity for a billing group = number of audit records in
|
|
2567
|
+
// the remote_optom_steps_summary index that belong to the group's stores, over
|
|
2568
|
+
// the range [to_date's month-start .. to_date], where to_date is the latest
|
|
2569
|
+
// record date in the index (e.g. to_date 2026-06-30 → from_date 2026-06-01). That
|
|
2570
|
+
// index keys by store NAME (no storeId), so group.stores (storeIds) are first
|
|
2571
|
+
// mapped to names via the stores collection, then matched with a terms filter on
|
|
2572
|
+
// storeName.keyword. Date is a keyword (YYYY-MM-DD per record), so the range is
|
|
2573
|
+
// lexical. size:0 + track_total_hits gives the exact count without paging past
|
|
2574
|
+
// the 10k window. Returns 0 on any failure or when the group has no matching
|
|
2575
|
+
// store names / the index is empty.
|
|
2576
|
+
async function remoteOptumRecordCount( clientId, storeIds ) {
|
|
2577
|
+
try {
|
|
2578
|
+
if ( !Array.isArray( storeIds ) || !storeIds.length ) {
|
|
2579
|
+
return 0;
|
|
2580
|
+
}
|
|
2581
|
+
const toDate = await remoteOptumLatestDate();
|
|
2582
|
+
if ( !toDate ) {
|
|
2583
|
+
return 0;
|
|
2584
|
+
}
|
|
2585
|
+
const fromDate = dayjs( toDate ).startOf( 'month' ).format( 'YYYY-MM-DD' );
|
|
2586
|
+
const stores = await storeService.find(
|
|
2587
|
+
{ clientId: clientId, storeId: { $in: storeIds } },
|
|
2588
|
+
{ storeName: 1 },
|
|
2589
|
+
);
|
|
2590
|
+
const storeNames = [ ...new Set(
|
|
2591
|
+
stores.map( ( s ) => String( s.storeName || '' ).trim() ).filter( Boolean ),
|
|
2592
|
+
) ];
|
|
2593
|
+
if ( !storeNames.length ) {
|
|
2594
|
+
return 0;
|
|
2595
|
+
}
|
|
2596
|
+
const osRes = await getOpenSearchData( 'remote_optom_steps_summary', {
|
|
2597
|
+
size: 0,
|
|
2598
|
+
track_total_hits: true,
|
|
2599
|
+
query: { bool: { filter: [
|
|
2600
|
+
{ range: { 'Date': { gte: fromDate, lte: toDate } } },
|
|
2601
|
+
{ terms: { 'storeName.keyword': storeNames } },
|
|
2602
|
+
] } },
|
|
2603
|
+
} );
|
|
2604
|
+
const total = osRes?.body?.hits?.total?.value ?? osRes?.hits?.total?.value ?? 0;
|
|
2605
|
+
return Number( total ) || 0;
|
|
2606
|
+
} catch ( osErr ) {
|
|
2607
|
+
logger.error( { error: osErr, function: 'remoteOptumRecordCount', clientId } );
|
|
2608
|
+
return 0;
|
|
2609
|
+
}
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2331
2612
|
// Build the Lambda-backed export rows (VMS / Run AI / etc.). Calls the product's
|
|
2332
2613
|
// Lambda for store_names (storeIds), maps them to the stores collection for
|
|
2333
2614
|
// name + country (Zone), with current-month days and the billing_details price.
|
|
2334
|
-
|
|
2335
|
-
|
|
2615
|
+
// reqDate (YYYY-MM-DD, optional) is the billing_details snapshot date the UI
|
|
2616
|
+
// picked so the export matches the card count; it is sent to the Lambda as
|
|
2617
|
+
// to_date (DD-MM-YYYY). Defaults to today when absent.
|
|
2618
|
+
async function lambdaStoreExport( clientId, lambdaUrl, billingProductName, reqDate ) {
|
|
2619
|
+
// reqDate is ISO YYYY-MM-DD, which dayjs parses natively; reformat to the
|
|
2620
|
+
// Lambda's DD-MM-YYYY. Fall back to today when absent / unparseable.
|
|
2621
|
+
const parsed = reqDate ? dayjs( reqDate ) : null;
|
|
2622
|
+
const toDate = parsed && parsed.isValid() ? parsed.format( 'DD-MM-YYYY' ) : dayjs().format( 'DD-MM-YYYY' );
|
|
2336
2623
|
const lambdaResult = await lamdaServiceCall( lambdaUrl, { to_date: toDate, client_id: clientId } );
|
|
2337
|
-
console.log( '🚀 ~ lambdaStoreExport ~ lambdaResult:', lambdaResult );
|
|
2338
2624
|
// These Lambdas return the storeIds under either `store_ids` (Run AI) or
|
|
2339
2625
|
// `store_names` (VMS) with no status_code. Accept whichever is present; data
|
|
2340
2626
|
// availability = a non-empty array.
|
|
@@ -2362,6 +2648,33 @@ async function lambdaStoreExport( clientId, lambdaUrl, billingProductName ) {
|
|
|
2362
2648
|
} );
|
|
2363
2649
|
}
|
|
2364
2650
|
|
|
2651
|
+
// Count the billable stores a product Lambda (VMS / Run AI) reports, optionally
|
|
2652
|
+
// scoped to a billing group's stores. The Lambda's own store_ids request filter
|
|
2653
|
+
// is unreliable — passing store_ids returns 0 even for IDs it itself reports as
|
|
2654
|
+
// billable — so we always call it BRAND-WIDE (client only) to get the true store
|
|
2655
|
+
// list, then intersect that list with the group's stores locally. Both sides use
|
|
2656
|
+
// the same `clientId-n` storeId format (e.g. `11-2911`), matching how the Mongo
|
|
2657
|
+
// aggregations scope by group.stores. Returns 0 on any failure (Lambda
|
|
2658
|
+
// unreachable / empty), so the caller just omits the line rather than failing the
|
|
2659
|
+
// whole invoice.
|
|
2660
|
+
async function lambdaStoreCount( clientId, lambdaUrl, storeIds ) {
|
|
2661
|
+
const toDate = dayjs().format( 'DD-MM-YYYY' );
|
|
2662
|
+
const lambdaResult = await lamdaServiceCall( lambdaUrl, { to_date: toDate, client_id: clientId } );
|
|
2663
|
+
// The Lambdas return the storeIds under either `store_ids` (Run AI) or
|
|
2664
|
+
// `store_names` (VMS); accept whichever is present.
|
|
2665
|
+
const rawIds = ( lambdaResult && Array.isArray( lambdaResult.store_ids ) ) ? lambdaResult.store_ids :
|
|
2666
|
+
( ( lambdaResult && Array.isArray( lambdaResult.store_names ) ) ? lambdaResult.store_names : [] );
|
|
2667
|
+
const ids = rawIds.map( ( s ) => String( s ) );
|
|
2668
|
+
// Scope to the billing group's stores when provided; otherwise (brand-wide
|
|
2669
|
+
// Billing Breakdown) count every store the Lambda reports.
|
|
2670
|
+
const scope = Array.isArray( storeIds ) && storeIds.length ? storeIds.map( ( s ) => String( s ) ) : null;
|
|
2671
|
+
if ( scope ) {
|
|
2672
|
+
const allowed = new Set( scope );
|
|
2673
|
+
return ids.filter( ( id ) => allowed.has( id ) ).length;
|
|
2674
|
+
}
|
|
2675
|
+
return ids.length;
|
|
2676
|
+
}
|
|
2677
|
+
|
|
2365
2678
|
// tangoTraffic working days per store, from the latest daily-pricing doc.
|
|
2366
2679
|
// Map<storeId, workingdays>; stores with no tangoTraffic record are absent.
|
|
2367
2680
|
async function tangoTrafficDaysByStore( clientId ) {
|
|
@@ -2531,8 +2844,9 @@ export async function additionalProductExport( req, res ) {
|
|
|
2531
2844
|
if ( product === 'vms' ) {
|
|
2532
2845
|
const exportData = await lambdaStoreExport(
|
|
2533
2846
|
clientId,
|
|
2534
|
-
|
|
2847
|
+
VMS_LAMBDA_URL,
|
|
2535
2848
|
'VMS',
|
|
2849
|
+
reqDate,
|
|
2536
2850
|
);
|
|
2537
2851
|
if ( !exportData.length ) {
|
|
2538
2852
|
return res.sendError( 'No data', 204 );
|
|
@@ -2544,10 +2858,10 @@ export async function additionalProductExport( req, res ) {
|
|
|
2544
2858
|
if ( product === 'runai' ) {
|
|
2545
2859
|
const exportData = await lambdaStoreExport(
|
|
2546
2860
|
clientId,
|
|
2547
|
-
|
|
2861
|
+
RUN_AI_LAMBDA_URL,
|
|
2548
2862
|
'Run AI',
|
|
2863
|
+
reqDate,
|
|
2549
2864
|
);
|
|
2550
|
-
console.log( '🚀 ~ additionalProductExport ~ exportData:', exportData );
|
|
2551
2865
|
if ( !exportData.length ) {
|
|
2552
2866
|
return res.sendError( 'No data', 204 );
|
|
2553
2867
|
}
|
|
@@ -2556,56 +2870,103 @@ export async function additionalProductExport( req, res ) {
|
|
|
2556
2870
|
}
|
|
2557
2871
|
|
|
2558
2872
|
if ( product === 'remoteoptum' ) {
|
|
2559
|
-
// Detailed optom-audit export from the remote_optom_steps_summary index
|
|
2560
|
-
//
|
|
2561
|
-
//
|
|
2562
|
-
//
|
|
2873
|
+
// Detailed optom-audit export from the remote_optom_steps_summary index.
|
|
2874
|
+
// to_date is the date from the payload (reqDate); from_date is the first
|
|
2875
|
+
// day of that date's month — so the export covers month-start through the
|
|
2876
|
+
// requested date. Each record is one row; the nested `steps` object is
|
|
2877
|
+
// flattened into TRUE/FALSE columns in a fixed (alphabetical) order.
|
|
2563
2878
|
const STEP_COLUMNS = [
|
|
2564
2879
|
'Adjust-Phoropter', 'DuoChrome-Test', 'Explanation', 'Final-Prescription',
|
|
2565
2880
|
'Handover', 'History-Taking', 'JCC', 'Near-Vision', 'Personal-Intro',
|
|
2566
2881
|
'Subjective-Refraction', 'Trial-Frame', 'VA-Check',
|
|
2567
2882
|
];
|
|
2568
|
-
|
|
2883
|
+
const fromDate = dayjs( reqDate ).startOf( 'month' ).format( 'YYYY-MM-DD' );
|
|
2884
|
+
const HEADER_COLUMNS = [
|
|
2885
|
+
'storeName', 'engagementId', 'queue_id', 'optm_name', 'optm_id',
|
|
2886
|
+
'optm_Emailid', 'Date', 'StartTime', 'EndTime', 'Duration (min)',
|
|
2887
|
+
...STEP_COLUMNS,
|
|
2888
|
+
];
|
|
2889
|
+
// Flatten one OpenSearch hit into a row array aligned with HEADER_COLUMNS.
|
|
2890
|
+
const hitToRow = ( h ) => {
|
|
2891
|
+
const s = h._source || {};
|
|
2892
|
+
const steps = s.steps || {};
|
|
2893
|
+
return [
|
|
2894
|
+
s.storeName || '', s.engagementId || '', s.queue_id || '',
|
|
2895
|
+
s.optm_name || '', s.optm_id || '', s.optm_Emailid || '',
|
|
2896
|
+
s.Date || '', s.StartTime || '', s.EndTime || '',
|
|
2897
|
+
s['Duration (min)'] != null ? s['Duration (min)'] : '',
|
|
2898
|
+
...STEP_COLUMNS.map( ( col ) => ( steps[col] === true ? 'TRUE' : 'FALSE' ) ),
|
|
2899
|
+
];
|
|
2900
|
+
};
|
|
2901
|
+
|
|
2902
|
+
// NOTE: do NOT sort on StartTime — it's an analyzed text field, and sorting
|
|
2903
|
+
// on it makes OpenSearch return ZERO hits. Date is a keyword (YYYY-MM-DD),
|
|
2904
|
+
// so the month-to-date span is a lexical range; rows come back in index
|
|
2905
|
+
// order. A whole month can be hundreds of thousands of records, so we scroll
|
|
2906
|
+
// through pages AND stream each page straight into an ExcelJS streaming
|
|
2907
|
+
// workbook — building the full .xlsx in memory (the old excel4node path)
|
|
2908
|
+
// blew the heap / gateway timeout at this scale, causing 502s.
|
|
2909
|
+
let firstRes;
|
|
2569
2910
|
try {
|
|
2570
|
-
|
|
2571
|
-
// A single day is well under the 10k window; pull up to the cap.
|
|
2572
|
-
// NOTE: do NOT sort on StartTime — it's an analyzed text field, and
|
|
2573
|
-
// sorting on it makes OpenSearch return ZERO hits. The day filter is
|
|
2574
|
-
// enough; rows come back in index order.
|
|
2911
|
+
firstRes = await searchOpenSearchData( 'remote_optom_steps_summary', {
|
|
2575
2912
|
size: 10000,
|
|
2576
|
-
query: {
|
|
2913
|
+
query: { range: { 'Date': { gte: fromDate, lte: reqDate } } },
|
|
2577
2914
|
} );
|
|
2578
|
-
optomHits = osRes?.body?.hits?.hits || osRes?.hits?.hits || [];
|
|
2579
2915
|
} catch ( osErr ) {
|
|
2580
2916
|
logger.error( { error: osErr, function: 'additionalProductExport.remoteOptum', clientId } );
|
|
2581
2917
|
return res.sendError( 'Failed to fetch Remote Optum data', 502 );
|
|
2582
2918
|
}
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
'Date': s.Date || '',
|
|
2595
|
-
'StartTime': s.StartTime || '',
|
|
2596
|
-
'EndTime': s.EndTime || '',
|
|
2597
|
-
'Duration (min)': s['Duration (min)'] != null ? s['Duration (min)'] : '',
|
|
2598
|
-
};
|
|
2599
|
-
for ( const col of STEP_COLUMNS ) {
|
|
2600
|
-
row[col] = steps[col] === true ? 'TRUE' : 'FALSE';
|
|
2919
|
+
let body = firstRes?.body || firstRes;
|
|
2920
|
+
let hits = body?.hits?.hits || [];
|
|
2921
|
+
let scrollId = body?._scroll_id;
|
|
2922
|
+
// Nothing to export — respond BEFORE any stream headers are sent.
|
|
2923
|
+
if ( !hits.length ) {
|
|
2924
|
+
if ( scrollId ) {
|
|
2925
|
+
try {
|
|
2926
|
+
await clearScroll( scrollId );
|
|
2927
|
+
} catch ( clearErr ) {
|
|
2928
|
+
logger.error( { error: clearErr, function: 'additionalProductExport.remoteOptum.clearScroll' } );
|
|
2929
|
+
}
|
|
2601
2930
|
}
|
|
2602
|
-
return row;
|
|
2603
|
-
} );
|
|
2604
|
-
|
|
2605
|
-
if ( !exportData.length ) {
|
|
2606
2931
|
return res.sendError( 'No data', 204 );
|
|
2607
2932
|
}
|
|
2608
|
-
|
|
2933
|
+
|
|
2934
|
+
// From here the response is a streamed workbook; headers are committed, so
|
|
2935
|
+
// a mid-stream failure can only abort the connection, not send a new status.
|
|
2936
|
+
res.setHeader( 'Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' );
|
|
2937
|
+
res.setHeader( 'Content-Disposition', `attachment; filename="Remote Optum-${clientId}.xlsx"` );
|
|
2938
|
+
const workbook = new ExcelJS.stream.xlsx.WorkbookWriter( { stream: res, useStyles: false } );
|
|
2939
|
+
const ws = workbook.addWorksheet( 'Worksheet Name' );
|
|
2940
|
+
ws.addRow( HEADER_COLUMNS ).commit();
|
|
2941
|
+
try {
|
|
2942
|
+
while ( hits.length ) {
|
|
2943
|
+
for ( const h of hits ) {
|
|
2944
|
+
ws.addRow( hitToRow( h ) ).commit();
|
|
2945
|
+
}
|
|
2946
|
+
if ( !scrollId ) {
|
|
2947
|
+
break;
|
|
2948
|
+
}
|
|
2949
|
+
const osRes = await scrollResponse( scrollId );
|
|
2950
|
+
body = osRes?.body || osRes;
|
|
2951
|
+
hits = body?.hits?.hits || [];
|
|
2952
|
+
scrollId = body?._scroll_id || scrollId;
|
|
2953
|
+
}
|
|
2954
|
+
ws.commit();
|
|
2955
|
+
await workbook.commit();
|
|
2956
|
+
} catch ( streamErr ) {
|
|
2957
|
+
// Headers are already sent — log and tear down the connection so the
|
|
2958
|
+
// client sees a failed/aborted download rather than a truncated file.
|
|
2959
|
+
logger.error( { error: streamErr, function: 'additionalProductExport.remoteOptum.stream', clientId } );
|
|
2960
|
+
res.destroy( streamErr );
|
|
2961
|
+
} finally {
|
|
2962
|
+
if ( scrollId ) {
|
|
2963
|
+
try {
|
|
2964
|
+
await clearScroll( scrollId );
|
|
2965
|
+
} catch ( clearErr ) {
|
|
2966
|
+
logger.error( { error: clearErr, function: 'additionalProductExport.remoteOptum.clearScroll' } );
|
|
2967
|
+
}
|
|
2968
|
+
}
|
|
2969
|
+
}
|
|
2609
2970
|
return;
|
|
2610
2971
|
}
|
|
2611
2972
|
|