This commit is contained in:
seb
2026-10-19 01:32:42 +02:00
parent c730244902
commit cce966d021
9 changed files with 181 additions and 22 deletions

View File

@@ -104,7 +104,7 @@ const shipment = await store.order.createShipment(googleAuthId, 10);
*Source: `src/store/order/delete.js`* *Source: `src/store/order/delete.js`*
Deletes an order by its id within the user's selected company. Deletes an order by its id within the user's selected company. Refuses to delete the order if any shipment manifest references it.
**Parameters** **Parameters**
@@ -120,7 +120,7 @@ Deletes an order by its id within the user's selected company.
**Throws** **Throws**
`Error` — If the order is not found. `Error` — If the order is not found or has shipment manifests.
**Example** **Example**

View File

@@ -28,7 +28,7 @@ Creates a shipping (package) for a shipment manifest. The number is allocated fr
**Throws** **Throws**
`Error` — If manifest is not found, carrier is not found, manifest is not a shipment, or input is invalid. `Error` — If manifest is not found, carrier is not found, manifest is not a shipment, shipment is already shipped, or input is invalid.
**Example** **Example**

View File

@@ -97,12 +97,29 @@ OUTPUT INSERTED.id, INSERTED.manifest_id, INSERTED.[number], INSERTED.tracking_c
WHERE id = @id; WHERE id = @id;
`; `;
export const CHECK_SHIPMENT_ALREADY_SHIPPED = `
SELECT TOP 1 1
FROM manifest m
WHERE m.id = @manifestId
AND (
EXISTS (
SELECT 1 FROM shipping s
WHERE s.manifest_id = m.id AND LOWER(s.[state]) = 'shipped'
)
OR EXISTS (
SELECT 1 FROM shipmentsummary ss
WHERE ss.manifest_id = m.id AND LOWER(ss.[state]) IN ('delivered', 'returned')
)
);
`;
export const CHECK_ORDER_SHIPPED = ` export const CHECK_ORDER_SHIPPED = `
SELECT TOP 1 s.id SELECT TOP 1 s.id
FROM shipping s FROM shipping s
WHERE s.manifest_id = @manifestId AND LOWER(s.[state]) = 'shipped'; WHERE s.manifest_id = @manifestId AND LOWER(s.[state]) = 'shipped';
`; `;
export const GET_ORDER_LINES_FOR_SHIPPING = ` export const GET_ORDER_LINES_FOR_SHIPPING = `
SELECT id, sku, name, amount SELECT id, sku, name, amount
FROM manifestarticle FROM manifestarticle

View File

@@ -51,7 +51,21 @@ FROM shipping
WHERE manifest_id = @id AND LOWER([state]) = 'shipped'; WHERE manifest_id = @id AND LOWER([state]) = 'shipped';
`; `;
export const CHECK_ORDER_HAS_SHIPMENTS = `
SELECT TOP 1 m.id
FROM manifest m
LEFT JOIN shipmentsummary ss ON ss.manifest_id = m.id AND ss.company_id = @companyId
WHERE m.company_id = @companyId
AND m.[type] = 'shipment'
AND (
m.[number] LIKE @pattern
OR ss.order_id = @orderId
OR ss.order_number = @orderNumber
);
`;
export const DELETE_SHIPMENT = ` export const DELETE_SHIPMENT = `
DELETE FROM manifest DELETE FROM manifest
WHERE id = @id AND company_id = @companyId AND [type] = 'shipment'; WHERE id = @id AND company_id = @companyId AND [type] = 'shipment';
`; `;

View File

@@ -18,6 +18,7 @@ export const {
SHIPPING_DELETE, SHIPPING_DELETE,
GET_SHIPPING_BY_ID, GET_SHIPPING_BY_ID,
SHIPPING_UPDATE, SHIPPING_UPDATE,
CHECK_SHIPMENT_ALREADY_SHIPPED,
CHECK_ORDER_SHIPPED, CHECK_ORDER_SHIPPED,
GET_ORDER_LINES_FOR_SHIPPING, GET_ORDER_LINES_FOR_SHIPPING,
GET_MANIFEST_HEADER_BY_ID, GET_MANIFEST_HEADER_BY_ID,

View File

@@ -11,5 +11,7 @@ export const {
GET_SHIPPED_AMOUNTS_BY_ORDER_PATTERN, GET_SHIPPED_AMOUNTS_BY_ORDER_PATTERN,
GET_SHIPMENT_HEADER, GET_SHIPMENT_HEADER,
CHECK_SHIPMENT_PACKAGES_SHIPPED, CHECK_SHIPMENT_PACKAGES_SHIPPED,
CHECK_ORDER_HAS_SHIPMENTS,
DELETE_SHIPMENT, DELETE_SHIPMENT,
} = queries; } = queries;

View File

@@ -1,11 +1,21 @@
import { import {
DELETE_MANIFEST as MSSQL_DELETE, DELETE_MANIFEST as MSSQL_DELETE,
GET_MANIFEST_HEADER as MSSQL_GET_HEADER,
ORDER_TYPE as MSSQL_ORDER_TYPE, ORDER_TYPE as MSSQL_ORDER_TYPE,
} from '../../providers/mssql/queries/manifestDoc.js'; } from '../../providers/mssql/queries/manifestDoc.js';
import { import {
DELETE_MANIFEST as SQLITE_DELETE, DELETE_MANIFEST as SQLITE_DELETE,
GET_MANIFEST_HEADER as SQLITE_GET_HEADER,
ORDER_TYPE as SQLITE_ORDER_TYPE, ORDER_TYPE as SQLITE_ORDER_TYPE,
} from '../../providers/sqlite3/queries/manifestDoc.js'; } from '../../providers/sqlite3/queries/manifestDoc.js';
import {
CHECK_ORDER_HAS_SHIPMENTS as MSSQL_CHECK_ORDER_HAS_SHIPMENTS,
GET_SHIPMENT_PREFIX as MSSQL_GET_SHIPMENT_PREFIX,
} from '../../providers/mssql/queries/shipment.js';
import {
CHECK_ORDER_HAS_SHIPMENTS as SQLITE_CHECK_ORDER_HAS_SHIPMENTS,
GET_SHIPMENT_PREFIX as SQLITE_GET_SHIPMENT_PREFIX,
} from '../../providers/sqlite3/queries/shipment.js';
import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js';
import makeRunner from '../runner.js'; import makeRunner from '../runner.js';
@@ -13,16 +23,24 @@ export default function del(provider) {
const access = resolveSelectedCompanyAccess(provider); const access = resolveSelectedCompanyAccess(provider);
const isSqlite = provider?.dialect === 'sqlite3'; const isSqlite = provider?.dialect === 'sqlite3';
const deleteSql = isSqlite ? SQLITE_DELETE : MSSQL_DELETE; const deleteSql = isSqlite ? SQLITE_DELETE : MSSQL_DELETE;
const getHeaderSql = isSqlite ? SQLITE_GET_HEADER : MSSQL_GET_HEADER;
const getShipmentPrefixSql = isSqlite
? SQLITE_GET_SHIPMENT_PREFIX
: MSSQL_GET_SHIPMENT_PREFIX;
const checkOrderHasShipmentsSql = isSqlite
? SQLITE_CHECK_ORDER_HAS_SHIPMENTS
: MSSQL_CHECK_ORDER_HAS_SHIPMENTS;
const orderType = isSqlite ? SQLITE_ORDER_TYPE : MSSQL_ORDER_TYPE; const orderType = isSqlite ? SQLITE_ORDER_TYPE : MSSQL_ORDER_TYPE;
/** /**
* Deletes an order by its id within the user's selected company. * Deletes an order by its id within the user's selected company.
* Refuses to delete the order if any shipment manifest references it.
* *
* @param {number} googleAuthId - The id of the Google auth user. * @param {number} googleAuthId - The id of the Google auth user.
* @param {number} id - The id of the order to delete. * @param {number} id - The id of the order to delete.
* @param {object} [tx] - Optional transaction. * @param {object} [tx] - Optional transaction.
* @returns {Promise<void>} * @returns {Promise<void>}
* @throws {Error} If the order is not found. * @throws {Error} If the order is not found or has shipment manifests.
* *
* @example * @example
* await store.order.delete(42, 5); * await store.order.delete(42, 5);
@@ -32,18 +50,52 @@ export default function del(provider) {
throw new Error('Order not found'); throw new Error('Order not found');
} }
const runner = makeRunner(provider, tx); const run = async (runner) => {
const { companyId } = await access(googleAuthId, runner); const { companyId } = await access(googleAuthId, runner);
const result = await runner.query(deleteSql, { const headerRes = await runner.query(getHeaderSql, {
id, id,
companyId, companyId,
type: orderType, type: orderType,
}); });
const order = headerRes.rows[0];
if (!order) {
throw new Error('Order not found');
}
if (!result.rowsAffected) { const prefixRes = await runner.query(getShipmentPrefixSql, { companyId });
throw new Error('Order not found'); const shipmentPrefix = prefixRes.rows[0]?.prefix?.trim?.() || '-L-';
const pattern = `${order.number}${shipmentPrefix}%`;
const checkRes = await runner.query(checkOrderHasShipmentsSql, {
companyId,
orderId: id,
orderNumber: order.number,
pattern,
});
if (checkRes.rows?.length > 0) {
throw new Error('Order has shipment manifests');
}
const result = await runner.query(deleteSql, {
id,
companyId,
type: orderType,
});
if (!result.rowsAffected) {
throw new Error('Order not found');
}
};
if (tx) {
return run(makeRunner(provider, tx));
} }
if (typeof provider?.transaction === 'function') {
return provider.transaction(run);
}
return run(makeRunner(provider));
}; };
} }

View File

@@ -40,7 +40,7 @@ export default function create(provider) {
* @param {number} [input.warehouseId] - Optional warehouse ID for stock deduction (defaults to company's first warehouse). * @param {number} [input.warehouseId] - Optional warehouse ID for stock deduction (defaults to company's first warehouse).
* @param {object} [tx] - Optional transaction to run within. * @param {object} [tx] - Optional transaction to run within.
* @returns {Promise<{id: number, manifestId: number, number: string, trackingCode: string|null, carrierId: number, carrier: string|null, type: string, state: string}>} * @returns {Promise<{id: number, manifestId: number, number: string, trackingCode: string|null, carrierId: number, carrier: string|null, type: string, state: string}>}
* @throws {Error} If manifest is not found, carrier is not found, manifest is not a shipment, or input is invalid. * @throws {Error} If manifest is not found, carrier is not found, manifest is not a shipment, shipment is already shipped, or input is invalid.
* *
* @example * @example
* await store.shipping.create(googleAuthId, { manifestId: 42, carrierId: 1, type: 'parcel', state: 'shipped', trackingCode: '1Z999' }); * await store.shipping.create(googleAuthId, { manifestId: 42, carrierId: 1, type: 'parcel', state: 'shipped', trackingCode: '1Z999' });
@@ -79,13 +79,12 @@ export default function create(provider) {
throw new Error('Carrier not found'); throw new Error('Carrier not found');
} }
let alreadyShipped = false; // Verify related shipment is not already shipped
if (isShipped) { const shippedCheck = await runner.query(q.CHECK_SHIPMENT_ALREADY_SHIPPED, {
// Check if shipment was already marked shipped by an existing package manifestId,
const shippedCheck = await runner.query(q.CHECK_ORDER_SHIPPED, { });
manifestId, if (shippedCheck.rows[0]) {
}); throw new Error('Shipment is already shipped');
alreadyShipped = Boolean(shippedCheck.rows[0]);
} }
// Allocate shipping number and insert shipping record // Allocate shipping number and insert shipping record
@@ -108,7 +107,7 @@ export default function create(provider) {
state, state,
}; };
if (isShipped && !alreadyShipped) { if (isShipped) {
// Load shipment manifest lines // Load shipment manifest lines
const linesRes = await runner.query(q.GET_ORDER_LINES_FOR_SHIPPING, { const linesRes = await runner.query(q.GET_ORDER_LINES_FOR_SHIPPING, {
manifestId, manifestId,

View File

@@ -192,6 +192,64 @@ describe('store.shipment', () => {
}); });
}); });
describe('shipping.create', () => {
it('allows adding shippings while shipment is unshipped, but refuses once shipped', async () => {
const order = await makeOrder();
const s = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 5 }] });
const carrier = await store.carrier.create(gid, { name: `Carrier ${order.number}`, hasPackages: true });
// First package: draft -> allowed
const p1 = await store.shipping.create(gid, {
manifestId: s.id,
carrierId: carrier.id,
type: 'parcel',
state: 'draft',
});
assert.ok(p1.id);
// Second package while still draft -> allowed
const p2 = await store.shipping.create(gid, {
manifestId: s.id,
carrierId: carrier.id,
type: 'parcel',
state: 'draft',
});
assert.ok(p2.id);
// Now mark package as shipped -> shipment is now shipped
await store.shipping.update(gid, p1.id, { state: 'shipped' });
// Attempting to add a new shipping to the already shipped shipment must fail
await assert.rejects(
() => store.shipping.create(gid, {
manifestId: s.id,
carrierId: carrier.id,
type: 'parcel',
state: 'draft',
}),
{ message: 'Shipment is already shipped' },
);
});
it('refuses adding shipping if shipment is in delivered state', async () => {
const order = await makeOrder();
const s = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 2 }] });
const carrier = await store.carrier.create(gid, { name: `Carrier Deliv ${order.number}`, hasPackages: true });
await store.shipment.update(gid, s.id, { state: 'delivered' });
await assert.rejects(
() => store.shipping.create(gid, {
manifestId: s.id,
carrierId: carrier.id,
type: 'parcel',
state: 'draft',
}),
{ message: 'Shipment is already shipped' },
);
});
});
describe('update', () => { describe('update', () => {
it('changes the state and validates it', async () => { it('changes the state and validates it', async () => {
const order = await makeOrder(); const order = await makeOrder();
@@ -242,8 +300,24 @@ describe('store.shipment', () => {
await assert.rejects(() => store.shipment.delete(gid, null), { message: 'Shipment is required' }); await assert.rejects(() => store.shipment.delete(gid, null), { message: 'Shipment is required' });
assert.ok(await store.shipment.getById(gid, s.id)); assert.ok(await store.shipment.getById(gid, s.id));
}); });
it('refuses to delete an order manifest when there is a shipment manifest referencing it', async () => {
const order = await makeOrder();
const s = await store.shipment.create(gid, { orderId: order.id, lines: [{ sku: 'SH-A', amount: 1 }] });
await assert.rejects(
() => store.order.delete(gid, order.id),
{ message: 'Order has shipment manifests' },
);
// Once all shipment manifests referencing the order are deleted, the order can be deleted
await store.shipment.delete(gid, s.id);
await store.order.delete(gid, order.id);
await assert.rejects(() => store.order.getById(gid, order.id), { message: 'Order not found' });
});
}); });
describe('list / rebuildSummaries', () => { describe('list / rebuildSummaries', () => {
let lt; let lt;
let o1; let o1;