let settings = input.config({ title: "FBA Shipments Sync by Seller Assistant", description: "This script syncs FBA Shipments. Get your [Seller Assistant API Key here](https://app.sellerassistant.app/settings/api-keys)", items: [ input.config.text("apiKey", { label: "Seller Assistant API Key", description: "Enter your Seller Assistant API key. Note: The API key will be visible to everyone who can view this base.", }), ], }); let apiKey = settings.apiKey; let plansTable = base.getTable("Plans"); let shipmentsTable = base.getTable("Shipments"); let itemsTable = base.getTable("Shipment Items"); // Run steps let choice = await input.buttonsAsync("What do you want to do?", [ { label: "šŸ“… Daily Sync (Last 90 Days)", value: "daily-sync" }, { label: "šŸ”„ Initial Setup (All Data)", value: "full-sync" }, { label: "šŸ“‹ Update Plans Only", value: "plans" }, { label: "🚢 Update Shipments Only", value: "list" }, { label: "šŸ“¦ Update Items Only", value: "items" } ]); const handlers = { "daily-sync": () => runDailySync(), "full-sync": () => runFullSync(), plans: () => updatePlansList(90), // Recent plans (90 days) list: () => updateShipmentList(90), // Recent shipments (90 days) items: () => updateShipmentItems(90), // Recent items (90 days) }; if (handlers[choice]) { await handlers[choice](); } else { console.warn(`Unknown choice: ${choice}`); } async function updatePlansList(daysFilter = 90) { let syncType = daysFilter ? `last ${daysFilter} days` : 'all plans'; output.text(`Updating Plans List (${syncType})...`); try { let allPlans = []; let statusesToFetch = ['ACTIVE', 'SHIPPED']; // Fetch plans with ACTIVE and SHIPPED statuses for (let status of statusesToFetch) { output.text(`Fetching ${status} plans...`); let nextToken = null; let pageCount = 0; let shouldContinue = true; // Fetch all pages for this status do { pageCount++; let url = `https://app.sellerassistant.app/api/v1/fba/inboundPlans?pageSize=30&status=${status}&sortBy=LAST_UPDATED_TIME&sortOrder=DESC`; if (nextToken) { url += `&paginationToken=${encodeURIComponent(nextToken)}`; } output.text(`Fetching ${status} plans page ${pageCount}...`); let response = await remoteFetchAsync(url, { method: 'GET', redirect: 'follow', headers: { 'X-Api-Key': apiKey, 'X-App-Name': 'airtable', 'Content-Type': 'application/json' } }); if (!response.ok) { output.text(`āš ļø Failed to fetch ${status} plans: ${response.status}`); break; } let data = await response.json(); output.text(`Retrieved ${data.inboundPlans.length} ${status} plans from page ${pageCount}`); // Check if we should apply date filtering if (daysFilter) { let cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - daysFilter); for (let plan of data.inboundPlans) { let lastUpdated; if (typeof plan.lastUpdatedAt === 'object' && plan.lastUpdatedAt.date) { lastUpdated = new Date(plan.lastUpdatedAt.date.replace(' ', 'T') + 'Z'); } else if (typeof plan.lastUpdatedAt === 'string') { lastUpdated = new Date(plan.lastUpdatedAt); } else { continue; } if (lastUpdated >= cutoffDate) { allPlans.push(plan); } else { // Found plan older than cutoff, stop fetching more pages for this status shouldContinue = false; break; } } } else { // No date filter, add all plans allPlans.push(...data.inboundPlans); } nextToken = data.pagination?.nextToken; // Rate limiting between pages if (nextToken && shouldContinue) { await new Promise(resolve => setTimeout(resolve, 1000)); } } while (nextToken && shouldContinue); output.text(`Completed fetching ${status} plans`); // Rate limiting between status requests await new Promise(resolve => setTimeout(resolve, 1000)); } output.text(`Total ${allPlans.length} plans to process across all statuses`); // Get existing records to check for updates let existingRecords = await plansTable.selectRecordsAsync(); let existingPlansMap = new Map(); for (let record of existingRecords.records) { existingPlansMap.set(record.getCellValue('inboundPlanId'), record); } let createdCount = 0; let updatedCount = 0; // Process each plan from the API for (let i = 0; i < allPlans.length; i++) { let plan = allPlans[i]; output.text(`Processing plan ${i + 1}/${allPlans.length}: ${plan.inboundPlanId}`); // Fetch detailed plan info to get shipments let detailResponse = await remoteFetchAsync(`https://app.sellerassistant.app/api/v1/fba/inboundPlans/${plan.inboundPlanId}`, { method: 'GET', redirect: 'follow', headers: { 'X-Api-Key': apiKey, 'X-App-Name': 'airtable', 'Content-Type': 'application/json' } }); let shipmentIds = []; if (detailResponse.ok) { let detailData = await detailResponse.json(); if (detailData.shipments && detailData.shipments.length > 0) { // For linked records, we need to find existing shipment records or create references // For now, we'll store the shipment IDs as text in a different field approach shipmentIds = detailData.shipments.map(s => s.shipmentId); } } else { output.text(`āš ļø Failed to fetch details for plan ${plan.inboundPlanId}: ${detailResponse.status}`); } // Handle date parsing for plan data let createdAt, lastUpdatedAt; if (typeof plan.createdAt === 'object' && plan.createdAt.date) { createdAt = new Date(plan.createdAt.date.replace(' ', 'T') + 'Z'); } else { createdAt = new Date(plan.createdAt); } if (typeof plan.lastUpdatedAt === 'object' && plan.lastUpdatedAt.date) { lastUpdatedAt = new Date(plan.lastUpdatedAt.date.replace(' ', 'T') + 'Z'); } else { lastUpdatedAt = new Date(plan.lastUpdatedAt); } let planData = { 'inboundPlanId': plan.inboundPlanId, 'createdAt': createdAt, 'lastUpdatedAt': lastUpdatedAt, 'marketplaceIds': plan.marketplaceIds.join(', '), 'name': plan.name || '', 'status': { name: plan.status } }; // Only add shipmentId if we have shipments and can find corresponding records if (shipmentIds.length > 0) { // Get existing shipment records to find matching IDs let shipmentRecords = await shipmentsTable.selectRecordsAsync(); let matchingShipments = []; for (let shipmentId of shipmentIds) { let matchingRecord = shipmentRecords.records.find(record => record.getCellValue('shipmentId') === shipmentId ); if (matchingRecord) { matchingShipments.push({ id: matchingRecord.id }); } } if (matchingShipments.length > 0) { planData['shipmentId'] = matchingShipments; } } let existingRecord = existingPlansMap.get(plan.inboundPlanId); if (existingRecord) { // Check if update is needed let needsUpdate = false; let existingDate = existingRecord.getCellValue('lastUpdatedAt'); let existingShipments = existingRecord.getCellValue('shipmentId') || []; let apiDate = planData.lastUpdatedAt; // Compare shipment links by IDs let existingShipmentIds = existingShipments.map(s => s.id).sort(); let newShipmentIds = (planData.shipmentId || []).map(s => s.id).sort(); let shipmentsChanged = JSON.stringify(existingShipmentIds) !== JSON.stringify(newShipmentIds); if (!existingDate || new Date(existingDate).getTime() !== apiDate.getTime() || shipmentsChanged) { needsUpdate = true; } if (needsUpdate) { await plansTable.updateRecordsAsync([{ id: existingRecord.id, fields: planData }]); updatedCount++; output.text(`āœ… Updated plan ${plan.inboundPlanId}`); } } else { await plansTable.createRecordsAsync([{ fields: planData }]); createdCount++; output.text(`āœ… Created plan ${plan.inboundPlanId}`); } // Rate limiting: wait 1 second between requests to respect 60 requests/minute limit if (i < allPlans.length - 1) { await new Promise(resolve => setTimeout(resolve, 1000)); } } // Summary if (createdCount > 0) { output.text(`āœ… Created ${createdCount} new plans`); } if (updatedCount > 0) { output.text(`āœ… Updated ${updatedCount} existing plans`); } if (createdCount === 0 && updatedCount === 0) { output.text(`āœ… All plans are up to date`); } output.text(`āœ… Plans sync completed successfully`); } catch (error) { output.text(`āŒ Error: ${error.message}`); } } async function runDailySync() { output.text(`šŸš€ Starting Daily Sync (Last 90 Days)...`); try { // Step 1: Update recent plans (90 days) output.text(`\nšŸ“‹ Step 1/3: Syncing recent plans...`); await updatePlansList(90); // Step 2: Update shipments for recent plans output.text(`\n🚢 Step 2/3: Syncing shipments...`); await updateShipmentList(90); // Step 3: Update shipment items for recent shipments output.text(`\nšŸ“¦ Step 3/3: Syncing shipment items...`); await updateShipmentItems(90); output.text(`\nāœ… Daily sync completed successfully!`); } catch (error) { output.text(`āŒ Daily sync failed: ${error.message}`); } } async function runFullSync() { output.text(`šŸš€ Starting Full Sync (All Data)...`); try { // Step 1: Update all plans output.text(`\nšŸ“‹ Step 1/3: Syncing all plans...`); await updatePlansList(null); // Step 2: Update all shipments output.text(`\n🚢 Step 2/3: Syncing all shipments...`); await updateShipmentList(null); // Step 3: Update all shipment items output.text(`\nšŸ“¦ Step 3/3: Syncing all shipment items...`); await updateShipmentItems(null); output.text(`\nāœ… Full sync completed successfully!`); } catch (error) { output.text(`āŒ Full sync failed: ${error.message}`); } } async function updateShipmentList(daysFilter = 90) { let syncType = daysFilter ? `last ${daysFilter} days` : 'all data'; output.text(`Updating Shipments List (${syncType})...`); try { // Fetch plans with ACTIVE and SHIPPED statuses from API output.text(`Fetching plans with ACTIVE and SHIPPED statuses from API...`); let allPlansFromAPI = []; let statusesToFetch = ['ACTIVE', 'SHIPPED']; for (let status of statusesToFetch) { output.text(`Fetching ${status} plans...`); let nextToken = null; let pageCount = 0; let shouldContinue = true; // Fetch all pages for this status do { pageCount++; let url = `https://app.sellerassistant.app/api/v1/fba/inboundPlans?pageSize=30&status=${status}&sortBy=LAST_UPDATED_TIME&sortOrder=DESC`; if (nextToken) { url += `&paginationToken=${encodeURIComponent(nextToken)}`; } let response = await remoteFetchAsync(url, { method: 'GET', redirect: 'follow', headers: { 'X-Api-Key': apiKey, 'X-App-Name': 'airtable', 'Content-Type': 'application/json' } }); if (response.ok) { let data = await response.json(); output.text(`Found ${data.inboundPlans.length} ${status} plans on page ${pageCount}`); // Apply date filtering if specified if (daysFilter) { let cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - daysFilter); for (let plan of data.inboundPlans) { let lastUpdated; if (typeof plan.lastUpdatedAt === 'object' && plan.lastUpdatedAt.date) { lastUpdated = new Date(plan.lastUpdatedAt.date.replace(' ', 'T') + 'Z'); } else if (typeof plan.lastUpdatedAt === 'string') { lastUpdated = new Date(plan.lastUpdatedAt); } else { continue; } if (lastUpdated >= cutoffDate) { allPlansFromAPI.push(plan); } else { shouldContinue = false; break; } } } else { allPlansFromAPI.push(...data.inboundPlans); } nextToken = data.pagination?.nextToken; } else { output.text(`āš ļø Failed to fetch ${status} plans: ${response.status}`); nextToken = null; break; } // Rate limiting between pages if (nextToken && shouldContinue) { await new Promise(resolve => setTimeout(resolve, 1000)); } } while (nextToken && shouldContinue); // Rate limiting between status requests await new Promise(resolve => setTimeout(resolve, 1000)); } output.text(`Total plans fetched from API: ${allPlansFromAPI.length}`); // Collect all unique shipment IDs from filtered plans let allShipmentIds = new Set(); for (let i = 0; i < allPlansFromAPI.length; i++) { let plan = allPlansFromAPI[i]; let planId = plan.inboundPlanId; output.text(`Fetching shipments for plan ${i + 1}/${allPlansFromAPI.length}: ${planId} (Status: ${plan.status})`); // Fetch plan details to get shipments let detailResponse = await remoteFetchAsync(`https://app.sellerassistant.app/api/v1/fba/inboundPlans/${planId}`, { method: 'GET', redirect: 'follow', headers: { 'X-Api-Key': apiKey, 'X-App-Name': 'airtable', 'Content-Type': 'application/json' } }); if (detailResponse.ok) { let detailData = await detailResponse.json(); output.text(`šŸ” Plan details for ${planId}: Found shipments: ${detailData.shipments ? detailData.shipments.length : 0}`); if (detailData.shipments && detailData.shipments.length > 0) { for (let shipment of detailData.shipments) { output.text(`šŸ” Found shipment: ${shipment.shipmentId} with status: ${shipment.status}`); allShipmentIds.add(JSON.stringify({ shipmentId: shipment.shipmentId, planId: planId, status: shipment.status })); } output.text(`Found ${detailData.shipments.length} shipments in plan ${planId}`); } else { output.text(`No shipments found in plan details for ${planId}`); } } else { output.text(`āš ļø Failed to fetch plan details for ${planId}: ${detailResponse.status}`); } // Rate limiting: wait 1 second between requests if (i < allPlansFromAPI.length - 1) { await new Promise(resolve => setTimeout(resolve, 1000)); } } output.text(`Found ${allShipmentIds.size} unique shipments`); // Get existing shipment records let existingRecords = await shipmentsTable.selectRecordsAsync(); let existingShipmentsMap = new Map(); for (let record of existingRecords.records) { existingShipmentsMap.set(record.getCellValue('shipmentId'), record); } let createdCount = 0; let updatedCount = 0; let shipmentsArray = Array.from(allShipmentIds); // Process each shipment with detailed API call for (let i = 0; i < shipmentsArray.length; i++) { let shipmentData = JSON.parse(shipmentsArray[i]); let { shipmentId, planId, status } = shipmentData; try { output.text(`Fetching details for shipment ${i + 1}/${shipmentsArray.length}: ${shipmentId}`); // Fetch detailed shipment info let shipmentDetailResponse = await remoteFetchAsync(`https://app.sellerassistant.app/api/v1/fba/inboundPlans/${planId}/shipments/${shipmentId}`, { method: 'GET', redirect: 'follow', headers: { 'X-Api-Key': apiKey, 'X-App-Name': 'airtable', 'Content-Type': 'application/json' } }); // Valid status options based on API spec let validStatuses = ['ABANDONED', 'CANCELLED', 'CHECKED_IN', 'CLOSED', 'DELETED', 'DELIVERED', 'IN_TRANSIT', 'MIXED', 'READY_TO_SHIP', 'RECEIVING', 'SHIPPED', 'UNCONFIRMED', 'WORKING']; let shipmentStatus = status || 'UNKNOWN'; if (!validStatuses.includes(shipmentStatus)) { shipmentStatus = 'UNKNOWN'; } // Find the plan record in Airtable to link to let existingPlansRecords = await plansTable.selectRecordsAsync(); let planRecord = existingPlansRecords.records.find(record => record.getCellValue('inboundPlanId') === planId ); let shipmentRecord = { 'shipmentId': shipmentId, 'status': { name: shipmentStatus } }; // Link to plan if found in Airtable if (planRecord) { shipmentRecord['inboundPlanId'] = [{ id: planRecord.id }]; } else { output.text(`āš ļø Plan ${planId} not found in Airtable - shipment will be created without plan link`); } if (shipmentDetailResponse.ok) { let shipmentDetail = await shipmentDetailResponse.json(); // Map additional fields if (shipmentDetail.name) { shipmentRecord['name'] = shipmentDetail.name; } else { shipmentRecord['name'] = `Shipment ${shipmentId.slice(-8)}`; } // Update status from detailed response if available if (shipmentDetail.status) { let detailedStatus = shipmentDetail.status; if (validStatuses.includes(detailedStatus)) { shipmentRecord['status'] = { name: detailedStatus }; } } // Add shipment confirmation ID if available if (shipmentDetail.shipmentConfirmationId) { shipmentRecord['shipmentConfirmationId'] = shipmentDetail.shipmentConfirmationId; } output.text(`āœ… Retrieved details for shipment ${shipmentId}`); } else { output.text(`āš ļø Failed to fetch details for shipment ${shipmentId}: ${shipmentDetailResponse.status}`); shipmentRecord['name'] = `Shipment ${shipmentId.slice(-8)}`; } // Fetch shipment fees with better error handling output.text(`Fetching fees for shipment ${shipmentId}`); try { let feesResponse = await remoteFetchAsync(`https://app.sellerassistant.app/api/v1/fba/inboundPlans/${planId}/shipments/${shipmentId}/fees`, { method: 'GET', redirect: 'follow', headers: { 'X-Api-Key': apiKey, 'X-App-Name': 'airtable', 'Content-Type': 'application/json' } }); if (feesResponse.ok) { let feesData = await feesResponse.json(); // Map placement fees if (feesData.placementFees && feesData.placementFees.length > 0) { let totalPlacementFees = 0; for (let placementOption of feesData.placementFees) { if (placementOption.fees && placementOption.fees.length > 0) { for (let fee of placementOption.fees) { if (fee.value && fee.value.amount) { totalPlacementFees += fee.value.amount; } } } } if (totalPlacementFees > 0) { shipmentRecord['placementFee'] = totalPlacementFees; } } // Map transportation fees if (feesData.transportationFees && feesData.transportationFees.length > 0) { let totalTransportationFees = 0; for (let transportOption of feesData.transportationFees) { if (transportOption.quote && transportOption.quote.cost && transportOption.quote.cost.amount) { totalTransportationFees += transportOption.quote.cost.amount; } } if (totalTransportationFees > 0) { shipmentRecord['transportationFee'] = totalTransportationFees; } } output.text(`āœ… Retrieved fees for shipment ${shipmentId}`); } else { output.text(`āš ļø Failed to fetch fees for shipment ${shipmentId}: ${feesResponse.status}`); } } catch (feeError) { output.text(`āš ļø Error fetching fees for shipment ${shipmentId}: ${feeError.message}`); } let existingRecord = existingShipmentsMap.get(shipmentId); if (existingRecord) { // Check if update is needed let needsUpdate = false; // Compare key fields to see if update is needed let existingPlanLinks = existingRecord.getCellValue('inboundPlanId') || []; let newPlanLinks = shipmentRecord.inboundPlanId || []; let planLinksChanged = JSON.stringify(existingPlanLinks.map(p => p.id).sort()) !== JSON.stringify(newPlanLinks.map(p => p.id).sort()); if (existingRecord.getCellValue('status')?.name !== shipmentRecord.status.name || existingRecord.getCellValue('name') !== shipmentRecord.name || existingRecord.getCellValue('placementFee') !== shipmentRecord.placementFee || existingRecord.getCellValue('transportationFee') !== shipmentRecord.transportationFee || existingRecord.getCellValue('shipmentConfirmationId') !== shipmentRecord.shipmentConfirmationId || planLinksChanged) { needsUpdate = true; } if (needsUpdate) { await shipmentsTable.updateRecordsAsync([{ id: existingRecord.id, fields: shipmentRecord }]); updatedCount++; output.text(`āœ… Updated shipment ${shipmentId}`); } } else { await shipmentsTable.createRecordsAsync([{ fields: shipmentRecord }]); createdCount++; output.text(`āœ… Created shipment ${shipmentId}`); } // Rate limiting: wait 1 second between requests if (i < shipmentsArray.length - 1) { await new Promise(resolve => setTimeout(resolve, 1000)); } } catch (shipmentError) { output.text(`āš ļø Error processing shipment ${shipmentId}: ${shipmentError.message}`); output.text(`āš ļø Plan ID: ${planId}`); } } // Summary if (createdCount > 0) { output.text(`āœ… Created ${createdCount} new shipment records`); } if (updatedCount > 0) { output.text(`āœ… Updated ${updatedCount} existing shipment records`); } if (createdCount === 0 && updatedCount === 0) { output.text(`āœ… All shipments are up to date`); } output.text(`āœ… Shipments sync completed successfully`); } catch (error) { output.text(`āŒ Error: ${error.message}`); } } async function updateShipmentItems(daysFilter = 90) { let syncType = daysFilter ? `last ${daysFilter} days` : 'all data'; output.text(`Updating Shipment Items (${syncType})...`); try { // Get existing shipments from Airtable let shipmentsRecords = await shipmentsTable.selectRecordsAsync(); output.text(`Found ${shipmentsRecords.records.length} shipments in Airtable`); let shipmentsToProcess = []; if (daysFilter) { // Filter shipments from recent plans let cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - daysFilter); // Get plan records to check dates let plansRecords = await plansTable.selectRecordsAsync(); let recentPlanIds = new Set(); for (let planRecord of plansRecords.records) { let lastUpdated = planRecord.getCellValue('lastUpdatedAt'); if (lastUpdated && new Date(lastUpdated) >= cutoffDate) { recentPlanIds.add(planRecord.getCellValue('inboundPlanId')); } } // Filter shipments that belong to recent plans for (let shipmentRecord of shipmentsRecords.records) { let linkedPlans = shipmentRecord.getCellValue('inboundPlanId') || []; for (let linkedPlan of linkedPlans) { let planRecord = plansRecords.records.find(p => p.id === linkedPlan.id); if (planRecord && recentPlanIds.has(planRecord.getCellValue('inboundPlanId'))) { shipmentsToProcess.push({ shipmentRecord: shipmentRecord, planId: planRecord.getCellValue('inboundPlanId') }); break; } } } } else { // Process all shipments let plansRecords = await plansTable.selectRecordsAsync(); for (let shipmentRecord of shipmentsRecords.records) { let linkedPlans = shipmentRecord.getCellValue('inboundPlanId') || []; if (linkedPlans.length > 0) { let linkedPlan = linkedPlans[0]; let planRecord = plansRecords.records.find(p => p.id === linkedPlan.id); if (planRecord) { shipmentsToProcess.push({ shipmentRecord: shipmentRecord, planId: planRecord.getCellValue('inboundPlanId') }); } } } } output.text(`Filtered to ${shipmentsToProcess.length} shipments to process`); // Get existing shipment items and shipments for key generation let existingItemsRecords = await itemsTable.selectRecordsAsync(); let allShipmentsRecords = await shipmentsTable.selectRecordsAsync(); let existingItemsMap = new Map(); for (let record of existingItemsRecords.records) { // shipmentId is a linked record (array), so get the linked record's shipmentId let linkedShipments = record.getCellValue('shipmentId'); if (linkedShipments && linkedShipments.length > 0) { // Find the actual shipment record to get the shipmentId string let linkedShipment = linkedShipments[0]; // Take first linked shipment let shipmentRecord = allShipmentsRecords.records.find(s => s.id === linkedShipment.id); if (shipmentRecord) { let shipmentIdString = shipmentRecord.getCellValue('shipmentId'); let key = `${shipmentIdString}_${record.getCellValue('msku')}`; existingItemsMap.set(key, record); } } } let createdCount = 0; let updatedCount = 0; // Process each shipment for (let i = 0; i < shipmentsToProcess.length; i++) { let { shipmentRecord, planId } = shipmentsToProcess[i]; let shipmentId = shipmentRecord.getCellValue('shipmentId'); output.text(`Fetching items for shipment ${i + 1}/${shipmentsToProcess.length}: ${shipmentId}`); // Fetch shipment items with detailed=true for additional data try { let itemsResponse = await remoteFetchAsync(`https://app.sellerassistant.app/api/v1/fba/inboundPlans/${planId}/shipments/${shipmentId}/items?pageSize=100&detailed=true`, { method: 'GET', redirect: 'follow', headers: { 'X-Api-Key': apiKey, 'X-App-Name': 'airtable', 'Content-Type': 'application/json' } }); if (itemsResponse.ok) { let itemsData = await itemsResponse.json(); if (itemsData.items && itemsData.items.length > 0) { for (let itemIndex = 0; itemIndex < itemsData.items.length; itemIndex++) { let item = itemsData.items[itemIndex]; try { // Calculate total prep fees let totalPrepFees = 0; if (item.prepInstructions && item.prepInstructions.length > 0) { for (let prep of item.prepInstructions) { if (prep.fee && prep.fee.amount) { totalPrepFees += prep.fee.amount; } } } let itemRecord = { 'shipmentId': [{ id: shipmentRecord.id }], // Link to shipment 'msku': item.msku, 'asin': item.asin, 'fnsku': item.fnsku, 'quantity': item.quantity, 'labelOwner': item.labelOwner, 'prepFees': totalPrepFees > 0 ? totalPrepFees : null }; // Add optional fields if present if (item.expiration) { itemRecord['expiration'] = new Date(item.expiration); } if (item.manufacturingLotCode) { itemRecord['manufacturingLotCode'] = item.manufacturingLotCode; } // Add detailed fields if available if (item.detailed) { if (item.detailed.quantityShipped !== undefined) { itemRecord['quantityShipped'] = item.detailed.quantityShipped; } if (item.detailed.quantityReceived !== undefined) { itemRecord['quantityReceived'] = item.detailed.quantityReceived; } if (item.detailed.quantityInCase !== undefined) { itemRecord['quantityInCase'] = item.detailed.quantityInCase; } } let itemKey = `${shipmentId}_${item.msku}`; let existingRecord = existingItemsMap.get(itemKey); if (existingRecord) { // Check if update is needed let needsUpdate = false; // Helper function to safely compare values (handles undefined/null) function valuesChanged(existingValue, newValue) { if (existingValue === null || existingValue === undefined) { return newValue !== null && newValue !== undefined; } if (newValue === null || newValue === undefined) { return existingValue !== null && existingValue !== undefined; } return existingValue !== newValue; } // Compare all fields that might change if (valuesChanged(existingRecord.getCellValue('quantity'), item.quantity) || valuesChanged(existingRecord.getCellValue('labelOwner'), item.labelOwner) || valuesChanged(existingRecord.getCellValue('prepFees'), itemRecord.prepFees) || valuesChanged(existingRecord.getCellValue('asin'), item.asin) || valuesChanged(existingRecord.getCellValue('fnsku'), item.fnsku) || valuesChanged(existingRecord.getCellValue('manufacturingLotCode'), itemRecord.manufacturingLotCode) || valuesChanged(existingRecord.getCellValue('quantityShipped'), itemRecord.quantityShipped) || valuesChanged(existingRecord.getCellValue('quantityReceived'), itemRecord.quantityReceived) || valuesChanged(existingRecord.getCellValue('quantityInCase'), itemRecord.quantityInCase)) { needsUpdate = true; } // Handle expiration date comparison separately (Date objects) let existingExpiration = existingRecord.getCellValue('expiration'); let newExpiration = itemRecord.expiration; if (existingExpiration && newExpiration) { if (new Date(existingExpiration).getTime() !== new Date(newExpiration).getTime()) { needsUpdate = true; } } else if (existingExpiration !== newExpiration) { needsUpdate = true; } if (needsUpdate) { await itemsTable.updateRecordsAsync([{ id: existingRecord.id, fields: itemRecord }]); updatedCount++; output.text(`āœ… Updated item ${item.msku} in shipment ${shipmentId}`); } } else { await itemsTable.createRecordsAsync([{ fields: itemRecord }]); createdCount++; output.text(`āœ… Created item ${item.msku} in shipment ${shipmentId}`); } } catch (itemError) { output.text(`āš ļø Error processing item ${itemIndex + 1} in shipment ${shipmentId}: ${itemError.message}`); output.text(`āš ļø Item MSKU: ${item?.msku || 'unknown'}`); } } output.text(`Found ${itemsData.items.length} items in shipment ${shipmentId}`); } else { output.text(`No items found for shipment ${shipmentId}`); } } else { output.text(`āš ļø Failed to fetch items for shipment ${shipmentId}: ${itemsResponse.status}`); } } catch (shipmentError) { output.text(`āš ļø Error fetching items for shipment ${shipmentId}: ${shipmentError.message}`); output.text(`āš ļø Plan ID: ${planId}`); } // Rate limiting: wait 1 second between requests if (i < shipmentsToProcess.length - 1) { await new Promise(resolve => setTimeout(resolve, 1000)); } } // Summary if (createdCount > 0) { output.text(`āœ… Created ${createdCount} new shipment item records`); } if (updatedCount > 0) { output.text(`āœ… Updated ${updatedCount} existing shipment item records`); } if (createdCount === 0 && updatedCount === 0) { output.text(`āœ… All shipment items are up to date`); } output.text(`āœ… Shipment items sync completed successfully`); } catch (error) { output.text(`āŒ Error: ${error.message}`); } }