import * as mssqlQueries from '../../providers/mssql/queries/delivery.js'; import * as sqliteQueries from '../../providers/sqlite3/queries/delivery.js'; import makeRunner from '../runner.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import { shapeSummary } from './helpers.js'; import { shapeAttachmentMeta } from './attachments.js'; export default function list(provider) { const access = resolveSelectedCompanyAccess(provider); const isSqlite = provider?.dialect === 'sqlite3'; const queries = isSqlite ? sqliteQueries : mssqlQueries; const sqlCountManifests = isSqlite ? 'SELECT COUNT(*) AS total FROM manifest WHERE company_id = @companyId AND "type" = @type' : 'SELECT COUNT(*) AS total FROM manifest WHERE company_id = @companyId AND [type] = @type'; /** * Lists the deliveries of the user's selected company. * * @param {number} googleAuthId - The id of the Google auth user. * @param {object} [opts] - Optional pagination and filter options. * @param {number} [opts.offset=0] - Number of deliveries to skip. * @param {number} [opts.limit=50] - Number of deliveries to return. * @param {string} [opts.search] - Free-text search matching delivery number, external number, or supplier name. * @param {boolean} [opts.onlyUnbooked=false] - When true, returns only deliveries with unbooked stock items. * @param {number} [opts.supplierId] - Filter by supplier entity id. * @param {number} [opts.seekMonths] - Negative integer for cutoff months ago (e.g. -1). * @param {object} [tx] - Optional transaction. * @returns {Promise<{deliveries: Array, total: number, unfilteredTotal: number, offset: number, limit: number}|Array>} */ return async (googleAuthId, opts, tx) => { let offset = null; let limit = null; let isPaged = false; let resolvedTx = tx; const hasFilter = opts != null && typeof opts === 'object' && !opts._isTx && ( opts.offset !== undefined || opts.limit !== undefined || (typeof opts.search === 'string' && opts.search.trim() !== '') || opts.onlyUnbooked === true || opts.supplierId !== undefined || opts.seekMonths !== undefined ); if (opts != null && typeof opts === 'object' && !opts._isTx) { if (hasFilter || opts.offset !== undefined || opts.limit !== undefined) { isPaged = true; const parsedOffset = Number(opts.offset ?? 0); const parsedLimit = Number(opts.limit ?? 50); if (!Number.isInteger(parsedOffset) || parsedOffset < 0) { throw new Error('Invalid offset'); } if (!Number.isInteger(parsedLimit) || parsedLimit < 1) { throw new Error('Invalid limit'); } offset = parsedOffset; limit = parsedLimit; } } else if (opts != null) { resolvedTx = opts; } const runner = makeRunner(provider, resolvedTx); const { companyId } = await access(googleAuthId, runner); const conditions = ['m.company_id = @companyId', `m.${isSqlite ? '"type"' : '[type]'} = @type`]; const params = { companyId, type: queries.DELIVERY_TYPE, }; // Date cutoff via seekMonths (strictly integer) if (opts?.seekMonths !== undefined) { if (typeof opts.seekMonths !== 'number' || !Number.isInteger(opts.seekMonths)) { throw new Error('Invalid seekMonths'); } const d = new Date(); d.setMonth(d.getMonth() - Math.abs(opts.seekMonths)); params.dateTo = d.toISOString(); conditions.push(`m.${isSqlite ? '"date"' : '[date]'} <= @dateTo`); } // Filter by supplierId if (opts?.supplierId !== undefined && opts.supplierId !== null) { const supplierId = Number(opts.supplierId); if (!Number.isInteger(supplierId) || supplierId <= 0) { throw new Error('Invalid supplierId'); } params.supplierId = supplierId; conditions.push('m.entity_id = @supplierId'); } // Free-text search if (typeof opts?.search === 'string' && opts.search.trim() !== '') { params.searchPattern = `%${opts.search.trim()}%`; if (isSqlite) { conditions.push(`( m."number" LIKE @searchPattern OR m.external_number LIKE @searchPattern OR e.name LIKE @searchPattern OR e.first_name LIKE @searchPattern OR (IFNULL(e.first_name, '') || ' ' || IFNULL(e.name, '')) LIKE @searchPattern OR (IFNULL(e.name, '') || ' ' || IFNULL(e.first_name, '')) LIKE @searchPattern OR ds.delivery_number LIKE @searchPattern OR ds.supplier_name LIKE @searchPattern )`); } else { conditions.push(`( m.[number] LIKE @searchPattern OR m.external_number LIKE @searchPattern OR e.name LIKE @searchPattern OR e.first_name LIKE @searchPattern OR (ISNULL(e.first_name, '') + ' ' + ISNULL(e.name, '')) LIKE @searchPattern OR (ISNULL(e.name, '') + ' ' + ISNULL(e.first_name, '')) LIKE @searchPattern OR ds.delivery_number LIKE @searchPattern OR ds.supplier_name LIKE @searchPattern )`); } } // Only unbooked deliveries (has stock positions that are not yet booked) if (opts?.onlyUnbooked === true) { conditions.push('(COALESCE(ds.unbooked_positions, 0) > 0 OR (COALESCE(ds.is_booked, 0) = 0 AND COALESCE(ds.stock_positions, 0) > 0))'); } const whereClause = conditions.join('\n AND '); const sqlCount = ` SELECT COUNT(*) AS total FROM manifest m LEFT JOIN deliverysummary ds ON ds.manifest_id = m.id LEFT JOIN entity e ON e.id = m.entity_id WHERE ${whereClause} `; let sqlList; if (isSqlite) { sqlList = ` SELECT m.id, m."number", m."type", m.entity_id AS supplierId, m.external_number AS externalNumber, m."date", m.created_at AS createdAt, ds.positions, ds.gross_total AS grossTotal, ds.net_total AS netTotal, ds.total_quantity AS totalQuantity, ds.stock_positions AS stockPositions, ds.booked_positions AS bookedPositions, ds.unbooked_positions AS unbookedPositions, ds.stock_quantity AS stockQuantity, ds.booked_quantity AS bookedQuantity, ds.unbooked_quantity AS unbookedQuantity, ds.is_booked AS isBooked, ds.supplier_name AS supplierName FROM manifest m LEFT JOIN deliverysummary ds ON ds.manifest_id = m.id LEFT JOIN entity e ON e.id = m.entity_id WHERE ${whereClause} ORDER BY m."date" DESC, m.id DESC ${isPaged ? 'LIMIT @limit OFFSET @offset' : ''} `; } else { sqlList = ` SELECT m.id, m.[number], m.[type], m.entity_id AS supplierId, m.external_number AS externalNumber, m.[date], m.created_at AS createdAt, ds.positions, ds.gross_total AS grossTotal, ds.net_total AS netTotal, ds.total_quantity AS totalQuantity, ds.stock_positions AS stockPositions, ds.booked_positions AS bookedPositions, ds.unbooked_positions AS unbookedPositions, ds.stock_quantity AS stockQuantity, ds.booked_quantity AS bookedQuantity, ds.unbooked_quantity AS unbookedQuantity, ds.is_booked AS isBooked, ds.supplier_name AS supplierName FROM manifest m LEFT JOIN deliverysummary ds ON ds.manifest_id = m.id LEFT JOIN entity e ON e.id = m.entity_id WHERE ${whereClause} ORDER BY m.[date] DESC, m.id DESC ${isPaged ? 'OFFSET @offset ROWS FETCH NEXT @limit ROWS ONLY' : ''} `; } const hasUserFilters = ( opts?.seekMonths !== undefined || (typeof opts?.search === 'string' && opts.search.trim() !== '') || opts?.onlyUnbooked === true || opts?.supplierId !== undefined ); let manifestRows; let total = 0; let unfilteredTotal = 0; if (isPaged) { params.offset = offset; params.limit = limit; const [countResult, listResult, unfilteredCountResult] = await Promise.all([ runner.query(sqlCount, params), runner.query(sqlList, params), hasUserFilters ? runner.query(sqlCountManifests, { companyId, type: queries.DELIVERY_TYPE }) : null, ]); total = Number(countResult.rows[0]?.total ?? 0); unfilteredTotal = hasUserFilters ? Number(unfilteredCountResult?.rows[0]?.total ?? 0) : total; manifestRows = listResult.rows; } else { const result = await runner.query(sqlList, params); manifestRows = result.rows; total = manifestRows.length; if (hasUserFilters) { const countRes = await runner.query(sqlCountManifests, { companyId, type: queries.DELIVERY_TYPE }); unfilteredTotal = Number(countRes.rows[0]?.total ?? 0); } else { unfilteredTotal = total; } } const manifestIds = manifestRows.map((r) => r.id); const filesByManifest = new Map(); if (manifestIds.length > 0) { const sqlFiles = isSqlite ? `SELECT ma.id, ma.manifest_id AS manifestId, ma.file_id AS fileId, ma.sort, f.hash, f.mimetype, f.name, f.size, f.created, f.modified, f.thumbx, f.thumby, f.meta FROM manifestattachment ma INNER JOIN "file" f ON f.id = ma.file_id WHERE ma.manifest_id IN (SELECT value FROM json_each(@manifestIdsJson)) ORDER BY ma.manifest_id, ma.sort ASC, ma.id ASC` : `SELECT ma.id, ma.manifest_id AS manifestId, ma.file_id AS fileId, ma.sort, f.hash, f.mimetype, f.name, f.size, f.created, f.modified, f.thumbavif, f.thumbx, f.thumby, f.meta FROM manifestattachment ma INNER JOIN [file] f ON f.id = ma.file_id WHERE ma.manifest_id IN (SELECT CAST(value AS INT) FROM OPENJSON(@manifestIdsJson)) ORDER BY ma.manifest_id, ma.sort ASC, ma.id ASC`; const filesResult = await runner.query(sqlFiles, { manifestIdsJson: JSON.stringify(manifestIds), }); for (const row of filesResult.rows) { const list = filesByManifest.get(row.manifestId) ?? []; list.push(shapeAttachmentMeta(row)); filesByManifest.set(row.manifestId, list); } } const deliveries = manifestRows.map((row) => ({ ...shapeSummary(row), positions: row.positions !== undefined && row.positions !== null ? Number(row.positions) : 0, grossTotal: row.grossTotal !== undefined && row.grossTotal !== null ? Number(row.grossTotal) : 0, netTotal: row.netTotal !== undefined && row.netTotal !== null ? Number(row.netTotal) : 0, totalQuantity: row.totalQuantity !== undefined && row.totalQuantity !== null ? Number(row.totalQuantity) : 0, stockPositions: row.stockPositions !== undefined && row.stockPositions !== null ? Number(row.stockPositions) : 0, bookedPositions: row.bookedPositions !== undefined && row.bookedPositions !== null ? Number(row.bookedPositions) : 0, unbookedPositions: row.unbookedPositions !== undefined && row.unbookedPositions !== null ? Number(row.unbookedPositions) : 0, stockQuantity: row.stockQuantity !== undefined && row.stockQuantity !== null ? Number(row.stockQuantity) : 0, bookedQuantity: row.bookedQuantity !== undefined && row.bookedQuantity !== null ? Number(row.bookedQuantity) : 0, unbookedQuantity: row.unbookedQuantity !== undefined && row.unbookedQuantity !== null ? Number(row.unbookedQuantity) : 0, isBooked: row.isBooked !== undefined && row.isBooked !== null ? Boolean(row.isBooked) : false, supplierName: row.supplierName ?? undefined, ledger: { totalPositions: Number(row.stockPositions ?? row.totalPositions ?? 0), completelyOnLedgerPositions: Number(row.bookedPositions ?? row.completelyOnLedgerPositions ?? 0), bookedPositions: Number(row.bookedPositions ?? 0), unbookedPositions: Number(row.unbookedPositions ?? 0), totalQuantity: Number(row.stockQuantity ?? 0), bookedQuantity: Number(row.bookedQuantity ?? 0), unbookedQuantity: Number(row.unbookedQuantity ?? 0), isBooked: Boolean(row.isBooked), }, files: filesByManifest.get(row.id) ?? [], })); if (isPaged) { return { deliveries, total, unfilteredTotal, offset, limit, }; } return deliveries; }; }