u
This commit is contained in:
123
src/store/numbers/ensureType.js
Normal file
123
src/store/numbers/ensureType.js
Normal file
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
ENSURE_TYPE as MSSQL_ENSURE_TYPE,
|
||||
GET_FOR_UPDATE as MSSQL_GET_FOR_UPDATE,
|
||||
UPDATE_BY_COMPANY_AND_ID as MSSQL_UPDATE_BY_COMPANY_AND_ID,
|
||||
} from '../../providers/mssql/queries/numbers.js';
|
||||
import {
|
||||
ENSURE_TYPE as SQLITE_ENSURE_TYPE,
|
||||
GET_FOR_UPDATE as SQLITE_GET_FOR_UPDATE,
|
||||
UPDATE_BY_COMPANY_AND_ID as SQLITE_UPDATE_BY_COMPANY_AND_ID,
|
||||
} from '../../providers/sqlite3/queries/numbers.js';
|
||||
|
||||
/**
|
||||
* Ensures a number sequence row exists for a company and type,
|
||||
* inferring the company's prefix style, padding, and next counter if not yet created or unconfigured.
|
||||
*
|
||||
* @param {object} runner - The database runner.
|
||||
* @param {number} companyId - The id of the company.
|
||||
* @param {string} type - The number type (e.g. 'order', 'quote', 'sku').
|
||||
* @returns {Promise<{id: number, type: string, prefix: string, padding: number, next: number}>} The sequence row.
|
||||
*/
|
||||
export async function ensureNumberType(runner, companyId, type) {
|
||||
const isSqlite = runner.dialect === 'sqlite3';
|
||||
const getSql = isSqlite ? SQLITE_GET_FOR_UPDATE : MSSQL_GET_FOR_UPDATE;
|
||||
const ensureSql = isSqlite ? SQLITE_ENSURE_TYPE : MSSQL_ENSURE_TYPE;
|
||||
const updateSql = isSqlite ? SQLITE_UPDATE_BY_COMPANY_AND_ID : MSSQL_UPDATE_BY_COMPANY_AND_ID;
|
||||
|
||||
let res = await runner.query(getSql, { companyId, type });
|
||||
let row = res.rows[0];
|
||||
|
||||
// If already exists and is configured (has prefix or padding > 0, or not order/quote), return row
|
||||
if (row && (row.prefix || row.padding > 0 || (type !== 'order' && type !== 'quote'))) {
|
||||
return row;
|
||||
}
|
||||
|
||||
// Find reference sequence for company to match prefix and padding style
|
||||
const listSql = isSqlite
|
||||
? 'SELECT "type", prefix, padding, "next" FROM numbers WHERE company_id = @companyId'
|
||||
: 'SELECT [type], prefix, padding, [next] FROM numbers WHERE company_id = @companyId';
|
||||
const existingSeqs = (await runner.query(listSql, { companyId })).rows;
|
||||
|
||||
const skuSeq = existingSeqs.find((r) => r.type === 'sku');
|
||||
const deliverySeq = existingSeqs.find((r) => r.type === 'delivery');
|
||||
const entitySeq = existingSeqs.find((r) => r.type === 'entity');
|
||||
const refSeq = skuSeq || deliverySeq || entitySeq;
|
||||
|
||||
const pad = refSeq ? Math.max(0, Number(refSeq.padding) || 0) : 6;
|
||||
const prefixStyle = refSeq?.prefix ?? '';
|
||||
|
||||
let prefix = '';
|
||||
if (type === 'shipment') {
|
||||
prefix = '-L-';
|
||||
} else if (type === 'quote') {
|
||||
if (prefixStyle.startsWith('SKU-') || prefixStyle.startsWith('DEL-') || prefixStyle.startsWith('C-')) {
|
||||
prefix = 'Q-';
|
||||
} else if (prefixStyle.startsWith('A-') || prefixStyle.startsWith('L-') || prefixStyle.startsWith('K-')) {
|
||||
prefix = 'AG-';
|
||||
} else {
|
||||
prefix = 'ANG-';
|
||||
}
|
||||
} else if (type === 'order') {
|
||||
if (prefixStyle.startsWith('SKU-') || prefixStyle.startsWith('DEL-') || prefixStyle.startsWith('C-')) {
|
||||
prefix = 'ORD-';
|
||||
} else if (prefixStyle.startsWith('A-') || prefixStyle.startsWith('L-') || prefixStyle.startsWith('K-')) {
|
||||
prefix = 'AU-';
|
||||
} else {
|
||||
prefix = 'AUF-';
|
||||
}
|
||||
} else if (type === 'adjustment') {
|
||||
prefix = prefixStyle.endsWith('-') ? 'KORR-' : '';
|
||||
}
|
||||
|
||||
const padding = (type === 'order' || type === 'quote') ? (pad || 6) : (type === 'adjustment' ? 5 : (refSeq?.padding ?? 0));
|
||||
|
||||
// Determine next counter by scanning manifest for existing documents
|
||||
let next = 1;
|
||||
if (type === 'order' || type === 'quote') {
|
||||
const manifestSql = isSqlite
|
||||
? 'SELECT "number" FROM manifest WHERE company_id = @companyId AND "type" = @type'
|
||||
: 'SELECT [number] FROM manifest WHERE company_id = @companyId AND [type] = @type';
|
||||
const manifests = (await runner.query(manifestSql, { companyId, type })).rows;
|
||||
let maxFound = 0;
|
||||
for (const m of manifests) {
|
||||
const numStr = String(m.number ?? '').trim();
|
||||
if (prefix && numStr.startsWith(prefix)) {
|
||||
const val = parseInt(numStr.slice(prefix.length), 10);
|
||||
if (!Number.isNaN(val) && val > maxFound) maxFound = val;
|
||||
} else if (!prefix) {
|
||||
const val = parseInt(numStr, 10);
|
||||
if (!Number.isNaN(val) && val > maxFound) maxFound = val;
|
||||
}
|
||||
}
|
||||
if (maxFound > 0) {
|
||||
next = maxFound + 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (row) {
|
||||
// Row existed but was empty unconfigured row (e.g. prefix: '', padding: 0)
|
||||
const effectiveNext = Math.max(next, row.next ?? 1);
|
||||
await runner.query(updateSql, {
|
||||
id: row.id,
|
||||
companyId,
|
||||
prefix,
|
||||
padding,
|
||||
next: effectiveNext,
|
||||
});
|
||||
row.prefix = prefix;
|
||||
row.padding = padding;
|
||||
row.next = effectiveNext;
|
||||
} else {
|
||||
await runner.query(ensureSql, {
|
||||
companyId,
|
||||
type,
|
||||
prefix,
|
||||
padding,
|
||||
next,
|
||||
});
|
||||
res = await runner.query(getSql, { companyId, type });
|
||||
row = res.rows[0];
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
@@ -1,20 +1,21 @@
|
||||
import {
|
||||
ENSURE_TYPE as MSSQL_ENSURE_TYPE,
|
||||
LIST_BY_COMPANY as MSSQL_LIST_BY_COMPANY,
|
||||
} from '../../providers/mssql/queries/numbers.js';
|
||||
import {
|
||||
ENSURE_TYPE as SQLITE_ENSURE_TYPE,
|
||||
LIST_BY_COMPANY as SQLITE_LIST_BY_COMPANY,
|
||||
} from '../../providers/sqlite3/queries/numbers.js';
|
||||
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
|
||||
import { ensureNumberType } from './ensureType.js';
|
||||
import makeRunner from '../runner.js';
|
||||
|
||||
const DEFAULT_TYPES = [
|
||||
{ type: 'sku', prefix: '' },
|
||||
{ type: 'entity', prefix: '' },
|
||||
{ type: 'delivery', prefix: '' },
|
||||
{ type: 'adjustment', prefix: '' },
|
||||
{ type: 'shipment', prefix: '-L-' },
|
||||
'sku',
|
||||
'entity',
|
||||
'delivery',
|
||||
'adjustment',
|
||||
'shipment',
|
||||
'quote',
|
||||
'order',
|
||||
];
|
||||
|
||||
function shape(row) {
|
||||
@@ -30,13 +31,12 @@ function shape(row) {
|
||||
export default function list(provider) {
|
||||
const access = resolveSelectedCompanyAccess(provider);
|
||||
const isSqlite = provider?.dialect === 'sqlite3';
|
||||
const ensureSql = isSqlite ? SQLITE_ENSURE_TYPE : MSSQL_ENSURE_TYPE;
|
||||
const sql =
|
||||
isSqlite ? SQLITE_LIST_BY_COMPANY : MSSQL_LIST_BY_COMPANY;
|
||||
|
||||
/**
|
||||
* Lists the number sequences of the user's selected company.
|
||||
* Ensures that default sequences ('sku', 'entity', 'delivery', 'adjustment', 'shipment') exist.
|
||||
* Ensures that default sequences ('sku', 'entity', 'delivery', 'adjustment', 'shipment', 'quote', 'order') exist.
|
||||
*
|
||||
* @param {number} googleAuthId - The id of the Google auth user.
|
||||
* @param {object} [tx] - Optional transaction to run within.
|
||||
@@ -52,8 +52,7 @@ export default function list(provider) {
|
||||
|
||||
for (const item of DEFAULT_TYPES) {
|
||||
const type = typeof item === 'string' ? item : item.type;
|
||||
const prefix = typeof item === 'string' ? (type === 'shipment' ? '-L-' : '') : item.prefix;
|
||||
await runner.query(ensureSql, { companyId, type, prefix });
|
||||
await ensureNumberType(runner, companyId, type);
|
||||
}
|
||||
|
||||
const result = await runner.query(sql, { companyId });
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import {
|
||||
ENSURE_TYPE as MSSQL_ENSURE_TYPE,
|
||||
GET_FOR_UPDATE as MSSQL_GET_FOR_UPDATE,
|
||||
INCREMENT as MSSQL_INCREMENT,
|
||||
} from '../../providers/mssql/queries/numbers.js';
|
||||
import {
|
||||
ENSURE_TYPE as SQLITE_ENSURE_TYPE,
|
||||
GET_FOR_UPDATE as SQLITE_GET_FOR_UPDATE,
|
||||
INCREMENT as SQLITE_INCREMENT,
|
||||
} from '../../providers/sqlite3/queries/numbers.js';
|
||||
import { ensureNumberType } from './ensureType.js';
|
||||
import makeRunner from '../runner.js';
|
||||
|
||||
function formatNumber(prefix, padding, num) {
|
||||
@@ -19,8 +16,6 @@ function formatNumber(prefix, padding, num) {
|
||||
|
||||
export default function nextNumber(provider) {
|
||||
const isSqlite = provider?.dialect === 'sqlite3';
|
||||
const ensureSql = isSqlite ? SQLITE_ENSURE_TYPE : MSSQL_ENSURE_TYPE;
|
||||
const getSql = isSqlite ? SQLITE_GET_FOR_UPDATE : MSSQL_GET_FOR_UPDATE;
|
||||
const incSql = isSqlite ? SQLITE_INCREMENT : MSSQL_INCREMENT;
|
||||
|
||||
/**
|
||||
@@ -37,10 +32,7 @@ export default function nextNumber(provider) {
|
||||
*/
|
||||
return async (companyId, type, tx) => {
|
||||
const doAlloc = async (runner) => {
|
||||
const prefix = type === 'shipment' ? '-L-' : '';
|
||||
await runner.query(ensureSql, { companyId, type, prefix });
|
||||
const res = await runner.query(getSql, { companyId, type });
|
||||
const row = res.rows[0];
|
||||
const row = await ensureNumberType(runner, companyId, type);
|
||||
if (!row) {
|
||||
throw new Error('Number type not found for company');
|
||||
}
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
import {
|
||||
ENSURE_TYPE as MSSQL_ENSURE_TYPE,
|
||||
GET_FOR_UPDATE as MSSQL_GET_FOR_UPDATE,
|
||||
} from '../../providers/mssql/queries/numbers.js';
|
||||
import {
|
||||
ENSURE_TYPE as SQLITE_ENSURE_TYPE,
|
||||
GET_FOR_UPDATE as SQLITE_GET_FOR_UPDATE,
|
||||
} from '../../providers/sqlite3/queries/numbers.js';
|
||||
import { ensureNumberType } from './ensureType.js';
|
||||
import makeRunner from '../runner.js';
|
||||
|
||||
function formatNumber(prefix, padding, num) {
|
||||
@@ -16,10 +9,6 @@ function formatNumber(prefix, padding, num) {
|
||||
}
|
||||
|
||||
export default function peekNumber(provider) {
|
||||
const isSqlite = provider?.dialect === 'sqlite3';
|
||||
const ensureSql = isSqlite ? SQLITE_ENSURE_TYPE : MSSQL_ENSURE_TYPE;
|
||||
const getSql = isSqlite ? SQLITE_GET_FOR_UPDATE : MSSQL_GET_FOR_UPDATE;
|
||||
|
||||
/**
|
||||
* Returns the next number for a company and type without consuming it.
|
||||
*
|
||||
@@ -34,10 +23,7 @@ export default function peekNumber(provider) {
|
||||
*/
|
||||
return async (companyId, type, tx) => {
|
||||
const doPeek = async (runner) => {
|
||||
const prefix = type === 'shipment' ? '-L-' : '';
|
||||
await runner.query(ensureSql, { companyId, type, prefix });
|
||||
const res = await runner.query(getSql, { companyId, type });
|
||||
const row = res.rows[0];
|
||||
const row = await ensureNumberType(runner, companyId, type);
|
||||
if (!row) {
|
||||
throw new Error('Number type not found for company');
|
||||
}
|
||||
|
||||
@@ -36,7 +36,8 @@ export default function createFromQuote(provider) {
|
||||
|
||||
/**
|
||||
* Creates a new sales order from an existing quote for the user's selected company.
|
||||
* Copies lines, entity, and external number from the quote unless explicitly overridden.
|
||||
* Copies lines and customer entity from the quote.
|
||||
* Sets external reference to the quote number (quote.number) unless explicitly overridden.
|
||||
* If order number is omitted, it is auto-allocated from the 'order' number sequence.
|
||||
*
|
||||
* @param {number} googleAuthId - The id of the Google auth user.
|
||||
@@ -46,11 +47,11 @@ export default function createFromQuote(provider) {
|
||||
* @param {string} [body.date] - The order date; defaults to current date if omitted.
|
||||
* @param {string} [body.createdAt] - Creation timestamp; defaults to current time if omitted.
|
||||
* @param {number} [body.entityId] - Customer entity id override; defaults to quote's entityId.
|
||||
* @param {string} [body.externalNumber] - Customer reference override; defaults to quote's externalNumber.
|
||||
* @param {string} [body.externalNumber] - Customer reference override; defaults to quote's number.
|
||||
* @param {Array<{sku?: string, name?: string, pu?: number, amount: number, net: number, rebate?: number, taxrate: number, comment?: string|null}>} [body.lines] - Lines override; defaults to quote's lines.
|
||||
* @param {object} [tx] - Optional transaction to run within.
|
||||
* @returns {Promise<{id: number, number: string, type: string, entityId: number|null, externalNumber: string|null, date: string, lines: Array<{id: number, sku: string|null, name: string|null, pu: number, amount: number, net: number, rebate: number, taxrate: number, comment: string|null}>}>} The created order.
|
||||
* @throws {Error} If the quote is not found, or the order is invalid.
|
||||
* @throws {Error} If the quote is not found, no entity is selected, or the order is invalid.
|
||||
*
|
||||
* @example
|
||||
* const order = await store.order.createFromQuote(42, 10);
|
||||
@@ -90,15 +91,19 @@ export default function createFromQuote(provider) {
|
||||
}
|
||||
|
||||
const targetEntityId = body.entityId !== undefined ? body.entityId : quoteDoc.entityId;
|
||||
if (targetEntityId == null) {
|
||||
throw new Error('No entity selected');
|
||||
}
|
||||
|
||||
const entityId = await assertCustomerEntity(
|
||||
runner,
|
||||
companyId,
|
||||
targetEntityId ?? null,
|
||||
targetEntityId,
|
||||
);
|
||||
|
||||
const externalNumber = body.externalNumber !== undefined
|
||||
? (body.externalNumber?.trim?.() ? body.externalNumber.trim() : null)
|
||||
: (quoteDoc.externalNumber ?? null);
|
||||
: (quoteDoc.number ?? null);
|
||||
|
||||
const rawLines = body.lines !== undefined ? body.lines : (quoteDoc.lines ?? []);
|
||||
const lines = normalizeLines(rawLines);
|
||||
|
||||
26
src/store/order/createShipment.js
Normal file
26
src/store/order/createShipment.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import shipmentCreate from '../shipment/create.js';
|
||||
|
||||
export default function createShipment(provider) {
|
||||
const create = shipmentCreate(provider);
|
||||
|
||||
/**
|
||||
* Creates a new shipment manifest for an order.
|
||||
* Can contain one, some, or all items from the parent order.
|
||||
* If lines is omitted or empty, all remaining unshipped items are included.
|
||||
*
|
||||
* @param {number} googleAuthId - The id of the Google auth user.
|
||||
* @param {number} orderId - The parent order manifest id.
|
||||
* @param {object} [input] - Optional overrides.
|
||||
* @param {Array<{sku: string, amount?: number, name?: string, pu?: number, net?: number, rebate?: number, taxrate?: number, comment?: string}>} [input.lines] - Positions (defaults to all remaining items).
|
||||
* @param {string|Date} [input.date] - Optional shipment date.
|
||||
* @param {string} [input.externalNumber] - Optional external delivery reference.
|
||||
* @param {object} [tx] - Optional transaction.
|
||||
* @returns {Promise<object>} The created shipment.
|
||||
*
|
||||
* @example
|
||||
* const shipment = await store.order.createShipment(googleAuthId, 10);
|
||||
*/
|
||||
return async (googleAuthId, orderId, input = {}, tx) => {
|
||||
return create(googleAuthId, { ...input, orderId }, tx);
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { insertLines, parseDate } from '../manifestLines.js';
|
||||
import makeRunner from '../runner.js';
|
||||
import { loadShipment, queriesFor } from './helpers.js';
|
||||
import { syncOrderSummary } from '../order/syncSummary.js';
|
||||
import { syncShipmentSummary } from './syncSummary.js';
|
||||
|
||||
function requireId(val, name) {
|
||||
const n = Number(val);
|
||||
@@ -25,35 +26,37 @@ export default function create(provider) {
|
||||
const q = queriesFor(provider);
|
||||
|
||||
/**
|
||||
* Creates a shipment manifest for an order.
|
||||
* Creates a shipment manifest for an order containing one, some, or all items from the order.
|
||||
* If input.lines is omitted or empty, all remaining unshipped items from the order are shipped.
|
||||
* On individual lines, amount is optional and defaults to the remaining unshipped quantity.
|
||||
*
|
||||
* @param {number} googleAuthId - The id of the Google auth user.
|
||||
* @param {object} input - The shipment input.
|
||||
* @param {number} input.orderId - The parent order manifest id.
|
||||
* @param {string|Date} [input.date] - Optional shipment date (defaults to current date).
|
||||
* @param {string} [input.externalNumber] - Optional external delivery note number.
|
||||
* @param {Array<{sku: string, amount: number, name?: string, pu?: number, net?: number, rebate?: number, taxrate?: number, comment?: string}>} input.lines - The shipment positions.
|
||||
* @param {string} [input.externalNumber] - Optional external delivery note number (defaults to order.externalNumber or order.number).
|
||||
* @param {Array<{sku: string, amount?: number, name?: string, pu?: number, net?: number, rebate?: number, taxrate?: number, comment?: string}>} [input.lines] - The shipment positions (optional).
|
||||
* @param {object} [tx] - Optional transaction.
|
||||
* @returns {Promise<object>} The created shipment manifest.
|
||||
* @throws {Error} If order not found, invalid lines, or article ceiling invariant exceeded.
|
||||
* @throws {Error} If order not found, invalid lines, all items already shipped, or article ceiling invariant exceeded.
|
||||
*
|
||||
* @example
|
||||
* const shipment = await store.shipment.create(googleAuthId, {
|
||||
* orderId: 10,
|
||||
* lines: [{ sku: 'ITEM-A', amount: 5 }]
|
||||
* });
|
||||
*
|
||||
* // Ship all remaining items from the order:
|
||||
* const fullShipment = await store.shipment.create(googleAuthId, { orderId: 10 });
|
||||
*/
|
||||
return async (googleAuthId, input = {}, tx) => {
|
||||
const orderId = requireId(input.orderId, 'Order');
|
||||
|
||||
if (!Array.isArray(input.lines) || input.lines.length === 0) {
|
||||
if (input.lines !== undefined && input.lines !== null && !Array.isArray(input.lines)) {
|
||||
throw new Error('Invalid lines');
|
||||
}
|
||||
|
||||
const shipmentDate = parseDate(input.date) || new Date();
|
||||
const externalNumber = input.externalNumber?.trim?.()
|
||||
? input.externalNumber.trim()
|
||||
: null;
|
||||
|
||||
const run = async (runner, innerTx) => {
|
||||
const { companyId } = await access(googleAuthId, runner);
|
||||
@@ -68,11 +71,19 @@ export default function create(provider) {
|
||||
throw new Error('Order not found');
|
||||
}
|
||||
|
||||
const externalNumber = input.externalNumber !== undefined
|
||||
? (input.externalNumber?.trim?.() ? input.externalNumber.trim() : null)
|
||||
: (order.externalNumber ?? order.number ?? null);
|
||||
|
||||
// 2. Retrieve parent order lines
|
||||
const orderLinesRes = await runner.query(q.GET_ORDER_LINES, {
|
||||
orderId,
|
||||
});
|
||||
const orderLines = orderLinesRes.rows ?? [];
|
||||
if (orderLines.length === 0) {
|
||||
throw new Error('Order has no lines');
|
||||
}
|
||||
|
||||
const orderAmountBySku = new Map();
|
||||
const orderLineBySku = new Map();
|
||||
|
||||
@@ -85,27 +96,11 @@ export default function create(provider) {
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Validate proposed lines SKUs and amounts
|
||||
const proposedBySku = new Map();
|
||||
for (const line of input.lines) {
|
||||
const sku = String(line?.sku ?? '').trim();
|
||||
const amount = Number(line?.amount);
|
||||
|
||||
if (!sku || !orderAmountBySku.has(sku)) {
|
||||
throw new Error(`Article (${sku}) is not part of order (${order.number})`);
|
||||
}
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
throw new Error('Invalid lines');
|
||||
}
|
||||
|
||||
proposedBySku.set(sku, (proposedBySku.get(sku) || 0) + amount);
|
||||
}
|
||||
|
||||
// 4. Retrieve shipment prefix from numbers table
|
||||
// 3. Retrieve shipment prefix from numbers table
|
||||
const prefixRes = await runner.query(q.GET_SHIPMENT_PREFIX, { companyId });
|
||||
const shipmentPrefix = prefixRes.rows[0]?.prefix?.trim?.() || '-L-';
|
||||
|
||||
// 5. Retrieve already shipped amounts across existing shipment manifests of this order
|
||||
// 4. Retrieve already shipped amounts across existing shipment manifests of this order
|
||||
const pattern = `${order.number}${shipmentPrefix}%`;
|
||||
const shippedRes = await runner.query(q.GET_SHIPPED_AMOUNTS_BY_ORDER_PATTERN, {
|
||||
companyId,
|
||||
@@ -118,16 +113,84 @@ export default function create(provider) {
|
||||
shippedAmountBySku.set(sku, Number(row.shippedAmount || 0));
|
||||
}
|
||||
|
||||
// 6. Quantity ceiling check
|
||||
for (const [sku, proposedAmount] of proposedBySku.entries()) {
|
||||
const alreadyShipped = shippedAmountBySku.get(sku) || 0;
|
||||
const ordered = orderAmountBySku.get(sku) || 0;
|
||||
if (alreadyShipped + proposedAmount > ordered) {
|
||||
throw new Error(`Shipment amount exceeds ordered amount for article (${sku})`);
|
||||
// 5. Determine lines to ship (one, some, or all items)
|
||||
const rawLines = input.lines;
|
||||
const isAutoAll = !Array.isArray(rawLines) || rawLines.length === 0;
|
||||
|
||||
const linesToInsert = [];
|
||||
const proposedBySku = new Map();
|
||||
|
||||
if (isAutoAll) {
|
||||
// Automatically ship all remaining unshipped items from the order
|
||||
for (const [sku, ordered] of orderAmountBySku.entries()) {
|
||||
const alreadyShipped = shippedAmountBySku.get(sku) || 0;
|
||||
const remaining = Math.round((ordered - alreadyShipped) * 1000) / 1000;
|
||||
if (remaining > 0) {
|
||||
const ol = orderLineBySku.get(sku);
|
||||
linesToInsert.push({
|
||||
sku,
|
||||
name: ol?.name ?? sku,
|
||||
pu: ol?.pu ?? 1,
|
||||
amount: remaining,
|
||||
net: Number(ol?.net ?? 0),
|
||||
rebate: Number(ol?.rebate ?? 0),
|
||||
taxrate: Number(ol?.taxrate ?? 0),
|
||||
comment: ol?.comment ?? null,
|
||||
});
|
||||
proposedBySku.set(sku, remaining);
|
||||
}
|
||||
}
|
||||
|
||||
if (linesToInsert.length === 0) {
|
||||
throw new Error('All items already shipped');
|
||||
}
|
||||
} else {
|
||||
// Specific lines requested (one, some, or all items)
|
||||
for (const line of rawLines) {
|
||||
const sku = String(line?.sku ?? '').trim();
|
||||
if (!sku || !orderAmountBySku.has(sku)) {
|
||||
throw new Error(`Article (${sku}) is not part of order (${order.number})`);
|
||||
}
|
||||
|
||||
const ordered = orderAmountBySku.get(sku) || 0;
|
||||
const alreadyShipped = shippedAmountBySku.get(sku) || 0;
|
||||
const remaining = Math.round((ordered - alreadyShipped) * 1000) / 1000;
|
||||
|
||||
let amount;
|
||||
if (line.amount !== undefined && line.amount !== null) {
|
||||
amount = Number(line.amount);
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
throw new Error('Invalid lines');
|
||||
}
|
||||
} else {
|
||||
// Default to remaining quantity
|
||||
if (remaining <= 0) {
|
||||
throw new Error(`Shipment amount exceeds ordered amount for article (${sku})`);
|
||||
}
|
||||
amount = remaining;
|
||||
}
|
||||
|
||||
const currentProposed = proposedBySku.get(sku) || 0;
|
||||
if (alreadyShipped + currentProposed + amount > ordered) {
|
||||
throw new Error(`Shipment amount exceeds ordered amount for article (${sku})`);
|
||||
}
|
||||
proposedBySku.set(sku, currentProposed + amount);
|
||||
|
||||
const ol = orderLineBySku.get(sku);
|
||||
linesToInsert.push({
|
||||
sku,
|
||||
name: line.name ?? ol?.name ?? sku,
|
||||
pu: line.pu !== undefined ? line.pu : (ol?.pu ?? 1),
|
||||
amount,
|
||||
net: line.net !== undefined ? Number(line.net) : Number(ol?.net ?? 0),
|
||||
rebate: line.rebate !== undefined ? Number(line.rebate) : Number(ol?.rebate ?? 0),
|
||||
taxrate: line.taxrate !== undefined ? Number(line.taxrate) : Number(ol?.taxrate ?? 0),
|
||||
comment: line.comment !== undefined ? (line.comment ? String(line.comment) : null) : (ol?.comment ?? null),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Resolve next index <y>
|
||||
// 6. Resolve next index <y>
|
||||
const existingShipmentsRes = await runner.query(q.LIST_SHIPMENTS_BY_ORDER_NUMBER_PATTERN, {
|
||||
companyId,
|
||||
pattern,
|
||||
@@ -148,7 +211,7 @@ export default function create(provider) {
|
||||
const nextY = Math.max(existingShipments.length + 1, maxY + 1);
|
||||
const shipmentNumber = `${order.number}${shipmentPrefix}${nextY}`;
|
||||
|
||||
// 8. Create shipment manifest header
|
||||
// 7. Create shipment manifest header
|
||||
const createdRes = await runner.query(q.CREATE_MANIFEST, {
|
||||
companyId,
|
||||
number: shipmentNumber,
|
||||
@@ -160,29 +223,22 @@ export default function create(provider) {
|
||||
});
|
||||
const manifestId = createdRes.rows[0]?.id ?? createdRes.insertId;
|
||||
|
||||
// 9. Prepare lines copying defaults from order lines
|
||||
const linesToInsert = input.lines.map((line) => {
|
||||
const sku = String(line.sku).trim();
|
||||
const ol = orderLineBySku.get(sku);
|
||||
return {
|
||||
sku,
|
||||
name: line.name ?? ol?.name ?? sku,
|
||||
pu: line.pu !== undefined ? line.pu : (ol?.pu ?? 1),
|
||||
amount: Number(line.amount),
|
||||
net: line.net !== undefined ? Number(line.net) : Number(ol?.net ?? 0),
|
||||
rebate: line.rebate !== undefined ? Number(line.rebate) : Number(ol?.rebate ?? 0),
|
||||
taxrate: line.taxrate !== undefined ? Number(line.taxrate) : Number(ol?.taxrate ?? 0),
|
||||
comment: line.comment !== undefined ? (line.comment ? String(line.comment) : null) : (ol?.comment ?? null),
|
||||
};
|
||||
});
|
||||
|
||||
// 8. Insert lines
|
||||
await insertLines(runner, {
|
||||
manifestId,
|
||||
lines: linesToInsert,
|
||||
ensureTax,
|
||||
});
|
||||
|
||||
// 9. Synchronize order summary row and shipment summary row
|
||||
await syncOrderSummary(runner, { companyId, manifestId: orderId });
|
||||
await syncShipmentSummary(runner, {
|
||||
companyId,
|
||||
manifestId,
|
||||
orderId,
|
||||
orderNumber: order.number,
|
||||
state: 'new',
|
||||
});
|
||||
|
||||
return loadShipment(runner, q, companyId, manifestId);
|
||||
};
|
||||
|
||||
281
src/store/shipment/list.js
Normal file
281
src/store/shipment/list.js
Normal file
@@ -0,0 +1,281 @@
|
||||
import {
|
||||
COUNT_MANIFESTS as MSSQL_COUNT_MANIFESTS,
|
||||
GET_MANIFEST_LINES_BY_MANIFEST_IDS as MSSQL_LINES_BY_MANIFEST_IDS,
|
||||
} from '../../providers/mssql/queries/manifestDoc.js';
|
||||
import {
|
||||
COUNT_MANIFESTS as SQLITE_COUNT_MANIFESTS,
|
||||
GET_MANIFEST_LINES_BY_MANIFEST_IDS as SQLITE_LINES_BY_MANIFEST_IDS,
|
||||
} from '../../providers/sqlite3/queries/manifestDoc.js';
|
||||
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
|
||||
import { shapeSummary } from '../manifestDoc/helpers.js';
|
||||
import { shapeLine } from '../manifestLines.js';
|
||||
import makeRunner from '../runner.js';
|
||||
|
||||
const SHIPMENT_TYPE = 'shipment';
|
||||
|
||||
export default function list(provider) {
|
||||
const access = resolveSelectedCompanyAccess(provider);
|
||||
const isSqlite = provider?.dialect === 'sqlite3';
|
||||
const sqlLinesByManifestIds = isSqlite
|
||||
? SQLITE_LINES_BY_MANIFEST_IDS
|
||||
: MSSQL_LINES_BY_MANIFEST_IDS;
|
||||
const sqlCountManifests = isSqlite
|
||||
? SQLITE_COUNT_MANIFESTS
|
||||
: MSSQL_COUNT_MANIFESTS;
|
||||
|
||||
/**
|
||||
* Lists the shipments 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 shipments to skip.
|
||||
* @param {number} [opts.limit=50] - Number of shipments to return.
|
||||
* @param {string} [opts.search] - Free-text search matching shipment number, order number, external number, tracking code, or customer name.
|
||||
* @param {'new'|'delivered'|'returned'} [opts.state] - Filter by delivery state.
|
||||
* @param {number} [opts.seekMonths] - Negative integer for cutoff months ago (e.g. -1).
|
||||
* @param {number} [opts.orderId] - Optional filter for parent order manifest id.
|
||||
* @param {object} [tx] - Optional transaction.
|
||||
* @returns {Promise<{shipments: Array<object>, total: number, unfilteredTotal: number, offset: number, limit: number}|Array<object>>}
|
||||
*/
|
||||
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() !== '') ||
|
||||
(typeof opts.state === 'string' && opts.state.trim() !== '') ||
|
||||
opts.seekMonths !== undefined ||
|
||||
opts.orderId !== 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: SHIPMENT_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`);
|
||||
}
|
||||
|
||||
// Parent order id filter
|
||||
if (opts?.orderId !== undefined && opts?.orderId !== null) {
|
||||
const parsedOrderId = Number(opts.orderId);
|
||||
if (Number.isInteger(parsedOrderId) && parsedOrderId > 0) {
|
||||
params.filterOrderId = parsedOrderId;
|
||||
conditions.push('ss.order_id = @filterOrderId');
|
||||
}
|
||||
}
|
||||
|
||||
// Delivery state filter ('new' | 'delivered' | 'returned')
|
||||
if (typeof opts?.state === 'string' && opts.state.trim() !== '') {
|
||||
params.filterState = opts.state.trim().toLowerCase();
|
||||
conditions.push("COALESCE(ss.state, 'new') = @filterState");
|
||||
}
|
||||
|
||||
// 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 ss.order_number LIKE @searchPattern
|
||||
OR ss.tracking_code LIKE @searchPattern
|
||||
OR ss.carrier 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
|
||||
)`);
|
||||
} else {
|
||||
conditions.push(`(
|
||||
m.[number] LIKE @searchPattern
|
||||
OR m.external_number LIKE @searchPattern
|
||||
OR ss.order_number LIKE @searchPattern
|
||||
OR ss.tracking_code LIKE @searchPattern
|
||||
OR ss.carrier 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
|
||||
)`);
|
||||
}
|
||||
}
|
||||
|
||||
const whereClause = conditions.join('\n AND ');
|
||||
const sqlCount = `
|
||||
SELECT COUNT(*) AS total
|
||||
FROM manifest m
|
||||
LEFT JOIN shipmentsummary ss ON ss.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 entityId,
|
||||
m.external_number AS externalNumber,
|
||||
m."date",
|
||||
m.created_at AS createdAt,
|
||||
ss.order_id AS orderId,
|
||||
ss.order_number AS orderNumber,
|
||||
ss.customer_name AS customerName,
|
||||
COALESCE(ss.positions, 0) AS positions,
|
||||
COALESCE(ss.total_quantity, 0) AS totalQuantity,
|
||||
COALESCE(ss.package_count, 0) AS packageCount,
|
||||
ss.carrier,
|
||||
ss.tracking_code AS trackingCode,
|
||||
COALESCE(ss.state, 'new') AS state
|
||||
FROM manifest m
|
||||
LEFT JOIN shipmentsummary ss ON ss.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 entityId,
|
||||
m.external_number AS externalNumber,
|
||||
m.[date],
|
||||
m.created_at AS createdAt,
|
||||
ss.order_id AS orderId,
|
||||
ss.order_number AS orderNumber,
|
||||
ss.customer_name AS customerName,
|
||||
COALESCE(ss.positions, 0) AS positions,
|
||||
COALESCE(ss.total_quantity, 0) AS totalQuantity,
|
||||
COALESCE(ss.package_count, 0) AS packageCount,
|
||||
ss.carrier,
|
||||
ss.tracking_code AS trackingCode,
|
||||
COALESCE(ss.state, 'new') AS state
|
||||
FROM manifest m
|
||||
LEFT JOIN shipmentsummary ss ON ss.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() !== '') ||
|
||||
(typeof opts?.state === 'string' && opts.state.trim() !== '') ||
|
||||
opts?.orderId !== 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: SHIPMENT_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: SHIPMENT_TYPE });
|
||||
unfilteredTotal = Number(countRes.rows[0]?.total ?? 0);
|
||||
} else {
|
||||
unfilteredTotal = total;
|
||||
}
|
||||
}
|
||||
|
||||
const manifestIds = manifestRows.map((r) => r.id);
|
||||
const linesByManifestId = new Map();
|
||||
|
||||
if (manifestIds.length > 0) {
|
||||
const linesResult = await runner.query(sqlLinesByManifestIds, {
|
||||
manifestIdsJson: JSON.stringify(manifestIds),
|
||||
});
|
||||
for (const row of linesResult.rows) {
|
||||
let lines = linesByManifestId.get(row.manifestId);
|
||||
if (!lines) {
|
||||
lines = [];
|
||||
linesByManifestId.set(row.manifestId, lines);
|
||||
}
|
||||
lines.push(shapeLine(row));
|
||||
}
|
||||
}
|
||||
|
||||
const shipments = manifestRows.map((row) => ({
|
||||
...shapeSummary(SHIPMENT_TYPE, row),
|
||||
lines: linesByManifestId.get(row.id) ?? [],
|
||||
orderId: row.orderId ?? null,
|
||||
orderNumber: row.orderNumber ?? null,
|
||||
customerName: row.customerName ?? undefined,
|
||||
positions: Number(row.positions || 0),
|
||||
totalQuantity: Number(row.totalQuantity || 0),
|
||||
packageCount: Number(row.packageCount || 0),
|
||||
carrier: row.carrier ?? null,
|
||||
trackingCode: row.trackingCode ?? null,
|
||||
state: row.state || 'new',
|
||||
}));
|
||||
|
||||
if (isPaged) {
|
||||
return {
|
||||
shipments,
|
||||
total,
|
||||
unfilteredTotal,
|
||||
offset,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
return shipments;
|
||||
};
|
||||
}
|
||||
20
src/store/shipment/rebuildSummaries.js
Normal file
20
src/store/shipment/rebuildSummaries.js
Normal file
@@ -0,0 +1,20 @@
|
||||
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
|
||||
import makeRunner from '../runner.js';
|
||||
import { rebuildShipmentSummaries } from './syncSummary.js';
|
||||
|
||||
export default function rebuildSummaries(provider) {
|
||||
const access = resolveSelectedCompanyAccess(provider);
|
||||
|
||||
/**
|
||||
* Rebuilds the `shipmentsummary` table for the user's selected company.
|
||||
*
|
||||
* @param {number} googleAuthId - The id of the Google auth user.
|
||||
* @param {object} [tx] - Optional transaction.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
return async (googleAuthId, tx) => {
|
||||
const runner = makeRunner(provider, tx);
|
||||
const { companyId } = await access(googleAuthId, runner);
|
||||
await rebuildShipmentSummaries(runner, { companyId });
|
||||
};
|
||||
}
|
||||
258
src/store/shipment/syncSummary.js
Normal file
258
src/store/shipment/syncSummary.js
Normal file
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Centralized sync and rebuild logic for the materialized `shipmentsummary` table.
|
||||
*/
|
||||
|
||||
const SQL_GET_SHIPMENT_PREFIX = `
|
||||
SELECT prefix FROM numbers WHERE company_id = @companyId AND [type] = 'shipment'
|
||||
`;
|
||||
|
||||
const SQL_GET_SHIPMENT_BY_ID_SQLITE = `
|
||||
SELECT m.id, m.company_id, m."number", m."date", m.created_at, m.entity_id
|
||||
FROM manifest m
|
||||
WHERE m.id = @manifestId AND m.company_id = @companyId AND m."type" = 'shipment'
|
||||
`;
|
||||
|
||||
const SQL_GET_SHIPMENT_BY_ID_MSSQL = `
|
||||
SELECT m.id, m.company_id, m.[number], m.[date], m.created_at, m.entity_id
|
||||
FROM manifest m
|
||||
WHERE m.id = @manifestId AND m.company_id = @companyId AND m.[type] = 'shipment'
|
||||
`;
|
||||
|
||||
const SQL_LINE_STATS = `
|
||||
SELECT
|
||||
COUNT(*) AS positions,
|
||||
COALESCE(SUM(amount), 0) AS total_quantity
|
||||
FROM manifestarticle
|
||||
WHERE manifest_id = @manifestId
|
||||
`;
|
||||
|
||||
const SQL_SHIPPING_STATS_SQLITE = `
|
||||
SELECT
|
||||
COUNT(id) AS package_count,
|
||||
(SELECT carrier FROM shipping WHERE manifest_id = @manifestId AND carrier IS NOT NULL AND TRIM(carrier) != '' ORDER BY id ASC LIMIT 1) AS carrier,
|
||||
(SELECT tracking_code FROM shipping WHERE manifest_id = @manifestId AND tracking_code IS NOT NULL AND TRIM(tracking_code) != '' ORDER BY id ASC LIMIT 1) AS tracking_code
|
||||
FROM shipping
|
||||
WHERE manifest_id = @manifestId
|
||||
`;
|
||||
|
||||
const SQL_SHIPPING_STATS_MSSQL = `
|
||||
SELECT
|
||||
COUNT(id) AS package_count,
|
||||
(SELECT TOP 1 carrier FROM shipping WHERE manifest_id = @manifestId AND carrier IS NOT NULL AND LTRIM(RTRIM(carrier)) != '' ORDER BY id ASC) AS carrier,
|
||||
(SELECT TOP 1 tracking_code FROM shipping WHERE manifest_id = @manifestId AND tracking_code IS NOT NULL AND LTRIM(RTRIM(tracking_code)) != '' ORDER BY id ASC) AS tracking_code
|
||||
FROM shipping
|
||||
WHERE manifest_id = @manifestId
|
||||
`;
|
||||
|
||||
const SQL_ORDER_BY_NUMBER_SQLITE = `
|
||||
SELECT id, "number"
|
||||
FROM manifest
|
||||
WHERE company_id = @companyId AND "type" = 'order' AND "number" = @orderNumber
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const SQL_ORDER_BY_NUMBER_MSSQL = `
|
||||
SELECT TOP 1 id, [number]
|
||||
FROM manifest
|
||||
WHERE company_id = @companyId AND [type] = 'order' AND [number] = @orderNumber
|
||||
`;
|
||||
|
||||
const SQL_CUSTOMER_NAME_SQLITE = `
|
||||
SELECT TRIM(COALESCE(first_name || ' ', '') || COALESCE(name, '')) AS customer_name
|
||||
FROM entity
|
||||
WHERE id = @entityId
|
||||
`;
|
||||
|
||||
const SQL_CUSTOMER_NAME_MSSQL = `
|
||||
SELECT LTRIM(RTRIM(ISNULL(first_name + ' ', '') + ISNULL(name, ''))) AS customer_name
|
||||
FROM entity
|
||||
WHERE id = @entityId
|
||||
`;
|
||||
|
||||
const SQL_UPSERT_SUMMARY_SQLITE = `
|
||||
INSERT INTO shipmentsummary (
|
||||
company_id, manifest_id, shipment_number, shipment_date, created_at,
|
||||
order_id, order_number, entity_id, customer_name, positions,
|
||||
total_quantity, package_count, carrier, tracking_code, state
|
||||
) VALUES (
|
||||
@companyId, @manifestId, @shipmentNumber, @shipmentDate, @createdAt,
|
||||
@orderId, @orderNumber, @entityId, @customerName, @positions,
|
||||
@totalQuantity, @packageCount, @carrier, @trackingCode, @state
|
||||
)
|
||||
ON CONFLICT(manifest_id) DO UPDATE SET
|
||||
shipment_number = excluded.shipment_number,
|
||||
shipment_date = excluded.shipment_date,
|
||||
created_at = excluded.created_at,
|
||||
order_id = excluded.order_id,
|
||||
order_number = excluded.order_number,
|
||||
entity_id = excluded.entity_id,
|
||||
customer_name = excluded.customer_name,
|
||||
positions = excluded.positions,
|
||||
total_quantity = excluded.total_quantity,
|
||||
package_count = excluded.package_count,
|
||||
carrier = excluded.carrier,
|
||||
tracking_code = excluded.tracking_code,
|
||||
state = excluded.state
|
||||
`;
|
||||
|
||||
const SQL_UPSERT_SUMMARY_MSSQL = `
|
||||
MERGE INTO shipmentsummary AS target
|
||||
USING (SELECT @manifestId AS manifest_id) AS source
|
||||
ON (target.manifest_id = source.manifest_id)
|
||||
WHEN MATCHED THEN
|
||||
UPDATE SET
|
||||
shipment_number = @shipmentNumber,
|
||||
shipment_date = @shipmentDate,
|
||||
created_at = @createdAt,
|
||||
order_id = @orderId,
|
||||
order_number = @orderNumber,
|
||||
entity_id = @entityId,
|
||||
customer_name = @customerName,
|
||||
positions = @positions,
|
||||
total_quantity = @totalQuantity,
|
||||
package_count = @packageCount,
|
||||
carrier = @carrier,
|
||||
tracking_code = @trackingCode,
|
||||
state = @state
|
||||
WHEN NOT MATCHED THEN
|
||||
INSERT (
|
||||
company_id, manifest_id, shipment_number, shipment_date, created_at,
|
||||
order_id, order_number, entity_id, customer_name, positions,
|
||||
total_quantity, package_count, carrier, tracking_code, state
|
||||
) VALUES (
|
||||
@companyId, @manifestId, @shipmentNumber, @shipmentDate, @createdAt,
|
||||
@orderId, @orderNumber, @entityId, @customerName, @positions,
|
||||
@totalQuantity, @packageCount, @carrier, @trackingCode, @state
|
||||
);
|
||||
`;
|
||||
|
||||
const VALID_STATES = new Set(['new', 'delivered', 'returned']);
|
||||
|
||||
/**
|
||||
* Recalculates and upserts a single shipment summary into `shipmentsummary`.
|
||||
*
|
||||
* @param {object} runner - Database runner.
|
||||
* @param {object} opts
|
||||
* @param {number} opts.companyId
|
||||
* @param {number} opts.manifestId - Shipment manifest id.
|
||||
* @param {number} [opts.orderId] - Optional parent order manifest id.
|
||||
* @param {string} [opts.orderNumber] - Optional parent order number.
|
||||
* @param {string} [opts.state] - Optional new state ('new' | 'delivered' | 'returned').
|
||||
*/
|
||||
export async function syncShipmentSummary(runner, { companyId, manifestId, orderId, orderNumber, state }) {
|
||||
const isSqlite = runner.dialect === 'sqlite3';
|
||||
|
||||
const res = await runner.query(
|
||||
isSqlite ? SQL_GET_SHIPMENT_BY_ID_SQLITE : SQL_GET_SHIPMENT_BY_ID_MSSQL,
|
||||
{ manifestId, companyId },
|
||||
);
|
||||
const shipment = res.rows[0];
|
||||
|
||||
if (!shipment) {
|
||||
await runner.query('DELETE FROM shipmentsummary WHERE manifest_id = @manifestId', { manifestId });
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Line stats
|
||||
const lineRes = await runner.query(SQL_LINE_STATS, { manifestId });
|
||||
const positions = Number(lineRes.rows[0]?.positions || 0);
|
||||
const totalQuantity = Math.round(Number(lineRes.rows[0]?.total_quantity || 0) * 1000) / 1000;
|
||||
|
||||
// 2. Shipping stats (packages, carrier, tracking_code)
|
||||
const shippingRes = await runner.query(
|
||||
isSqlite ? SQL_SHIPPING_STATS_SQLITE : SQL_SHIPPING_STATS_MSSQL,
|
||||
{ manifestId },
|
||||
);
|
||||
const packageCount = Number(shippingRes.rows[0]?.package_count || 0);
|
||||
const carrier = shippingRes.rows[0]?.carrier ? String(shippingRes.rows[0].carrier).trim() : null;
|
||||
const trackingCode = shippingRes.rows[0]?.tracking_code ? String(shippingRes.rows[0].tracking_code).trim() : null;
|
||||
|
||||
// 3. Resolve orderId and orderNumber
|
||||
let resolvedOrderId = orderId ?? null;
|
||||
let resolvedOrderNumber = orderNumber ?? null;
|
||||
|
||||
if (resolvedOrderId == null || !resolvedOrderNumber) {
|
||||
const prefixRes = await runner.query(SQL_GET_SHIPMENT_PREFIX, { companyId });
|
||||
const shipmentPrefix = prefixRes.rows[0]?.prefix?.trim?.() || '-L-';
|
||||
|
||||
const num = String(shipment.number || '');
|
||||
if (num.includes(shipmentPrefix)) {
|
||||
const parsedOrderNum = num.split(shipmentPrefix)[0];
|
||||
if (parsedOrderNum) {
|
||||
const ordRes = await runner.query(
|
||||
isSqlite ? SQL_ORDER_BY_NUMBER_SQLITE : SQL_ORDER_BY_NUMBER_MSSQL,
|
||||
{ companyId, orderNumber: parsedOrderNum },
|
||||
);
|
||||
if (ordRes.rows[0]) {
|
||||
resolvedOrderId = ordRes.rows[0].id;
|
||||
resolvedOrderNumber = ordRes.rows[0].number;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Customer name
|
||||
let customerName = null;
|
||||
if (shipment.entity_id) {
|
||||
const custRes = await runner.query(
|
||||
isSqlite ? SQL_CUSTOMER_NAME_SQLITE : SQL_CUSTOMER_NAME_MSSQL,
|
||||
{ entityId: shipment.entity_id },
|
||||
);
|
||||
customerName = custRes.rows[0]?.customer_name || null;
|
||||
}
|
||||
|
||||
// 5. State ('new' | 'delivered' | 'returned')
|
||||
let resolvedState = 'new';
|
||||
if (state && VALID_STATES.has(state)) {
|
||||
resolvedState = state;
|
||||
} else {
|
||||
// Preserve existing state if present
|
||||
const existingRes = await runner.query(
|
||||
'SELECT state FROM shipmentsummary WHERE manifest_id = @manifestId',
|
||||
{ manifestId },
|
||||
);
|
||||
const existingState = existingRes.rows[0]?.state;
|
||||
if (existingState && VALID_STATES.has(existingState)) {
|
||||
resolvedState = existingState;
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Upsert into shipmentsummary
|
||||
await runner.query(isSqlite ? SQL_UPSERT_SUMMARY_SQLITE : SQL_UPSERT_SUMMARY_MSSQL, {
|
||||
companyId,
|
||||
manifestId,
|
||||
shipmentNumber: shipment.number,
|
||||
shipmentDate: shipment.date,
|
||||
createdAt: shipment.created_at,
|
||||
orderId: resolvedOrderId,
|
||||
orderNumber: resolvedOrderNumber,
|
||||
entityId: shipment.entity_id ?? null,
|
||||
customerName,
|
||||
positions,
|
||||
totalQuantity,
|
||||
packageCount,
|
||||
carrier,
|
||||
trackingCode,
|
||||
state: resolvedState,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the `shipmentsummary` table for a company.
|
||||
*
|
||||
* @param {object} runner - Database runner.
|
||||
* @param {object} opts
|
||||
* @param {number} opts.companyId
|
||||
*/
|
||||
export async function rebuildShipmentSummaries(runner, { companyId }) {
|
||||
const isSqlite = runner.dialect === 'sqlite3';
|
||||
const sqlList = `
|
||||
SELECT id FROM manifest
|
||||
WHERE company_id = @companyId AND ${isSqlite ? '"type"' : '[type]'} = 'shipment'
|
||||
ORDER BY id ASC
|
||||
`;
|
||||
const listRes = await runner.query(sqlList, { companyId });
|
||||
for (const row of listRes.rows) {
|
||||
await syncShipmentSummary(runner, { companyId, manifestId: row.id });
|
||||
}
|
||||
}
|
||||
71
src/store/shipment/update.js
Normal file
71
src/store/shipment/update.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
|
||||
import makeRunner from '../runner.js';
|
||||
import { loadShipment, queriesFor } from './helpers.js';
|
||||
import { syncShipmentSummary } from './syncSummary.js';
|
||||
|
||||
function requireId(val, name) {
|
||||
const n = Number(val);
|
||||
if (!Number.isInteger(n) || n <= 0) {
|
||||
throw new Error(`${name} is required`);
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
const VALID_STATES = new Set(['new', 'delivered', 'returned']);
|
||||
|
||||
/**
|
||||
* Updates a shipment's state or metadata.
|
||||
*
|
||||
* @param {import('../../providers/provider.js').DatabaseProvider} provider
|
||||
*/
|
||||
export default function update(provider) {
|
||||
const access = resolveSelectedCompanyAccess(provider);
|
||||
const q = queriesFor(provider);
|
||||
|
||||
/**
|
||||
* Updates a shipment manifest.
|
||||
*
|
||||
* @param {number} googleAuthId - The id of the Google auth user.
|
||||
* @param {number} id - The shipment manifest ID.
|
||||
* @param {object} patch - Fields to update.
|
||||
* @param {'new'|'delivered'|'returned'} [patch.state] - Delivery state.
|
||||
* @param {object} [tx] - Optional transaction.
|
||||
* @returns {Promise<object>} The updated shipment manifest.
|
||||
* @throws {Error} If shipment not found or invalid state.
|
||||
*/
|
||||
return async (googleAuthId, id, patch = {}, tx) => {
|
||||
const shipmentId = requireId(id, 'Shipment');
|
||||
const runner = makeRunner(provider, tx);
|
||||
const { companyId } = await access(googleAuthId, runner);
|
||||
|
||||
const check = await runner.query(q.GET_SHIPMENT_HEADER, {
|
||||
id: shipmentId,
|
||||
companyId,
|
||||
});
|
||||
if (!check.rows[0]) {
|
||||
throw new Error('Shipment not found');
|
||||
}
|
||||
|
||||
if (patch.state !== undefined) {
|
||||
const stateStr = String(patch.state || '').trim().toLowerCase();
|
||||
if (!VALID_STATES.has(stateStr)) {
|
||||
throw new Error('Invalid state');
|
||||
}
|
||||
await syncShipmentSummary(runner, {
|
||||
companyId,
|
||||
manifestId: shipmentId,
|
||||
state: stateStr,
|
||||
});
|
||||
}
|
||||
|
||||
const shipment = await loadShipment(runner, q, companyId, shipmentId);
|
||||
const sumRes = await runner.query(
|
||||
'SELECT state FROM shipmentsummary WHERE manifest_id = @manifestId',
|
||||
{ manifestId: shipmentId },
|
||||
);
|
||||
return {
|
||||
...shipment,
|
||||
state: sumRes.rows[0]?.state || 'new',
|
||||
};
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user