From f8cc01baf0cec358e373d3f36c37a8db9bf757cc Mon Sep 17 00:00:00 2001 From: seb Date: Sat, 17 Oct 2026 05:10:28 +0200 Subject: [PATCH] u --- API.carrier.md | 176 +++++++++++++++++++++ API.md | 9 +- API.shipping.md | 8 +- scripts/test-carrier.js | 142 +++++++++++++++++ scripts/test-order-shipping-ledger.js | 7 + scripts/test-shipment.js | 24 ++- src/index.js | 15 ++ src/providers/mssql/purgeSql.js | 1 + src/providers/mssql/queries/carrier.js | 78 +++++++++ src/providers/mssql/queries/logistics.js | 28 ++-- src/providers/mssql/schema.js | 22 ++- src/providers/sqlite3/queries/carrier.js | 16 ++ src/providers/sqlite3/queries/logistics.js | 1 + src/providers/sqlite3/schema.js | 19 ++- src/store/carrier/create.js | 97 ++++++++++++ src/store/carrier/delete.js | 62 ++++++++ src/store/carrier/getById.js | 50 ++++++ src/store/carrier/list.js | 39 +++++ src/store/carrier/order.js | 79 +++++++++ src/store/carrier/update.js | 122 ++++++++++++++ src/store/shipment/helpers.js | 1 + src/store/shipment/syncSummary.js | 20 +-- src/store/shipping/create.js | 31 ++-- src/store/shipping/listByManifestId.js | 1 + src/store/shipping/update.js | 40 +++-- 25 files changed, 1034 insertions(+), 54 deletions(-) create mode 100644 API.carrier.md create mode 100644 scripts/test-carrier.js create mode 100644 src/providers/mssql/queries/carrier.js create mode 100644 src/providers/sqlite3/queries/carrier.js create mode 100644 src/store/carrier/create.js create mode 100644 src/store/carrier/delete.js create mode 100644 src/store/carrier/getById.js create mode 100644 src/store/carrier/list.js create mode 100644 src/store/carrier/order.js create mode 100644 src/store/carrier/update.js diff --git a/API.carrier.md b/API.carrier.md new file mode 100644 index 0000000..8df390a --- /dev/null +++ b/API.carrier.md @@ -0,0 +1,176 @@ +# carrier + +This document is auto-generated from the JSDoc comments in `src/store` by running `npm run api-doc`. + +## `carrier.create` + +*Source: `src/store/carrier/create.js`* + +Creates a new carrier for the user's selected company. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `googleAuthId` | `number` | The id of the Google auth user. | +| `input` | `object` | Carrier input. | +| `input.name` | `string` | The carrier name. | +| `[input.sort]` | `number` | Optional sort order (auto-increments if omitted). | +| `[input.hasPackages=false]` | `boolean` | Whether this carrier uses packages. | +| `[input.isReverse=false]` | `boolean` | Whether this carrier handles reverse shipments. | +| `[tx]` | `object` | Optional transaction to run within. | + +**Returns** + +`Promise<{id: number, name: string, sort: number, hasPackages: boolean, isReverse: boolean}>` — The created carrier. + +**Throws** + +`Error` — If name is missing, sort is invalid, or carrier already exists. + +**Example** + +```js +const c = await store.carrier.create(googleAuthId, { name: 'DHL', hasPackages: true }); +// => { id: 1, name: 'DHL', sort: 0, hasPackages: true, isReverse: false } +``` + +## `carrier.delete` + +*Source: `src/store/carrier/delete.js`* + +Deletes a carrier for the user's selected company, unless referenced in shipping. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `googleAuthId` | `number` | The id of the Google auth user. | +| `id` | `number` | The carrier ID to delete. | +| `[tx]` | `object` | Optional transaction to run within. | + +**Returns** + +`Promise` + +**Throws** + +`Error` — If carrier not found or in use in shipping. + +**Example** + +```js +await store.carrier.delete(googleAuthId, 1); +``` + +## `carrier.getById` + +*Source: `src/store/carrier/getById.js`* + +Retrieves a carrier by its ID for the user's selected company. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `googleAuthId` | `number` | The id of the Google auth user. | +| `id` | `number` | The carrier ID. | +| `[tx]` | `object` | Optional transaction to run within. | + +**Returns** + +`Promise<{id: number, name: string, sort: number, hasPackages: boolean, isReverse: boolean}|undefined>` — The carrier or undefined. + +**Example** + +```js +const c = await store.carrier.getById(googleAuthId, 1); +// => { id: 1, name: 'DHL', sort: 0, hasPackages: true, isReverse: false } +``` + +## `carrier.list` + +*Source: `src/store/carrier/list.js`* + +Lists all carriers for the user's selected company. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `googleAuthId` | `number` | The id of the Google auth user. | +| `[tx]` | `object` | Optional transaction to run within. | + +**Returns** + +`Promise>` — List of carriers. + +**Example** + +```js +const carriers = await store.carrier.list(googleAuthId); +// => [{ id: 1, name: 'DHL', sort: 0, hasPackages: true, isReverse: false }] +``` + +## `carrier.order` + +*Source: `src/store/carrier/order.js`* + +Reorders the carriers of the user's selected company. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `googleAuthId` | `number` | The id of the Google auth user. | +| `input` | `object` | The reorder input. | +| `input.carrierIds` | `Array` | The carrier ids in their new order. | +| `[tx]` | `object` | Optional transaction to run within. | + +**Returns** + +`Promise<{carrierIds: Array}>` — The applied order. + +**Throws** + +`Error` — If the order is invalid. + +**Example** + +```js +await store.carrier.order(googleAuthId, { carrierIds: [2, 1, 3] }); +``` + +## `carrier.update` + +*Source: `src/store/carrier/update.js`* + +Updates a carrier name, sort order, hasPackages, and/or isReverse for the user's selected company. + +**Parameters** + +| Name | Type | Description | +| --- | --- | --- | +| `googleAuthId` | `number` | The id of the Google auth user. | +| `id` | `number` | The carrier ID to update. | +| `input` | `object` | Update payload. | +| `[input.name]` | `string` | The new carrier name. | +| `[input.sort]` | `number` | The new sort order. | +| `[input.hasPackages]` | `boolean` | Whether carrier uses packages. | +| `[input.isReverse]` | `boolean` | Whether carrier handles reverse shipments. | +| `[tx]` | `object` | Optional transaction to run within. | + +**Returns** + +`Promise<{id: number, name: string, sort: number, hasPackages: boolean, isReverse: boolean}>` — The updated carrier. + +**Throws** + +`Error` — If carrier not found, name is empty, sort is invalid, or carrier already exists. + +**Example** + +```js +const updated = await store.carrier.update(googleAuthId, 1, { name: 'DHL Express', hasPackages: true }); +// => { id: 1, name: 'DHL Express', sort: 0, hasPackages: true, isReverse: false } +``` diff --git a/API.md b/API.md index 83e1e6d..1c97c03 100644 --- a/API.md +++ b/API.md @@ -224,7 +224,7 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met - `syncSummary(): void` - `update(provider: import('../../providers/provider.js').DatabaseProvider): void` - [shipping](API.shipping.md) - - `create(googleAuthId: number, input: {manifestId: number, type: string, state: string, trackingCode?: string, carrier?: string, warehouseId?: number}, tx?: object): Promise<{id: number, manifestId: number, number: string, trackingCode: string|null, carrier: string|null, type: string, state: string}>` + - `create(googleAuthId: number, input: {manifestId: number, carrierId: number, type: string, state: string, trackingCode?: string, warehouseId?: number}, tx?: object): Promise<{id: number, manifestId: number, number: string, trackingCode: string|null, carrierId: number, carrier: string|null, type: string, state: string}>` - `delete(googleAuthId: number, id: number, tx?: object): Promise` - `listByManifestId(googleAuthId: number, manifestId: number, tx?: object): Promise>` - `update(provider: import('../../providers/provider.js').DatabaseProvider): void` @@ -335,6 +335,13 @@ The store is exposed as a `LibDashStore` instance (see `src/index.js`). Each met - [articlelist](API.articlelist.md) - `getItemsByArticleSku(googleAuthId: number, sku: string, tx?: object): Promise>` - `setItemsByArticleSku(googleAuthId: number, sku: string, items?: Array<{articleId?: number, sku?: string, count?: number, sort?: number}>, tx?: object): Promise>` +- [carrier](API.carrier.md) + - `create(googleAuthId: number, input: {name: string, sort?: number, hasPackages?: boolean, isReverse?: boolean}, tx?: object): Promise<{id: number, name: string, sort: number, hasPackages: boolean, isReverse: boolean}>` + - `delete(googleAuthId: number, id: number, tx?: object): Promise` + - `getById(googleAuthId: number, id: number, tx?: object): Promise<{id: number, name: string, sort: number, hasPackages: boolean, isReverse: boolean}|undefined>` + - `list(googleAuthId: number, tx?: object): Promise>` + - `order(googleAuthId: number, input: {carrierIds: Array}, tx?: object): Promise<{carrierIds: Array}>` + - `update(googleAuthId: number, id: number, input: {name?: string, sort?: number, hasPackages?: boolean, isReverse?: boolean}, tx?: object): Promise<{id: number, name: string, sort: number, hasPackages: boolean, isReverse: boolean}>` - [categoryattributevalue](API.categoryattributevalue.md) - `listByCategoryId(googleAuthId: number, categoryId: number, tx?: object): Promise>` - `setByCategoryId(googleAuthId: number, categoryId: number, values: Array, tx?: object): Promise>` diff --git a/API.shipping.md b/API.shipping.md index c06be3b..3b2c6d4 100644 --- a/API.shipping.md +++ b/API.shipping.md @@ -15,25 +15,25 @@ Creates a shipping (package) for a shipment manifest. The number is allocated fr | `googleAuthId` | `number` | The id of the Google auth user. | | `input` | `object` | Shipping input. | | `input.manifestId` | `number` | The shipment manifest id. | +| `input.carrierId` | `number` | The carrier id. | | `input.type` | `string` | Shipping type. | | `input.state` | `string` | Shipping state ('shipped' or other). | | `[input.trackingCode]` | `string` | Carrier tracking code. | -| `[input.carrier]` | `string` | Carrier name. | | `[input.warehouseId]` | `number` | Optional warehouse ID for stock deduction (defaults to company's first warehouse). | | `[tx]` | `object` | Optional transaction to run within. | **Returns** -`Promise<{id: number, manifestId: number, number: string, trackingCode: string|null, carrier: string|null, type: string, state: string}>` +`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, manifest is not a shipment, or input is invalid. +`Error` — If manifest is not found, carrier is not found, manifest is not a shipment, or input is invalid. **Example** ```js -await store.shipping.create(googleAuthId, { manifestId: 42, type: 'parcel', state: 'shipped', trackingCode: '1Z999', carrier: 'UPS' }); +await store.shipping.create(googleAuthId, { manifestId: 42, carrierId: 1, type: 'parcel', state: 'shipped', trackingCode: '1Z999' }); ``` ## `shipping.delete` diff --git a/scripts/test-carrier.js b/scripts/test-carrier.js new file mode 100644 index 0000000..f11d6c4 --- /dev/null +++ b/scripts/test-carrier.js @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import LibDashStore from '../src/index.js'; +import { BetterSqlite3Provider } from '../src/providers/sqlite3/index.js'; + +async function run() { + console.log('--- Testing Carrier Store API ---'); + + const tempDir = mkdtempSync(join(tmpdir(), 'test-carrier-')); + const dbFile = join(tempDir, 'test.db'); + const storageDir = join(tempDir, 'storage'); + + const provider = new BetterSqlite3Provider({ + filename: dbFile, + storageDir, + }); + + await provider.initialize(); + const store = await new LibDashStore(provider); + + try { + const googleAuthId = await store.google_auth.add('carrier-test@example.com'); + const company = await store.company.create({ + name: 'Carrier Test Corp', + iso639: 'de', + taxrate: 19, + reducedtaxrate: 7, + googleAuthId, + }); + await store.google_auth.setSelectedCompany(googleAuthId, company.id); + + // 1. Create carrier with defaults + const c1 = await store.carrier.create(googleAuthId, { name: 'DHL' }); + assert.equal(c1.name, 'DHL'); + assert.equal(c1.sort, 0); + assert.equal(c1.hasPackages, false); + assert.equal(c1.isReverse, false); + console.log('PASS: Carrier 1 created with defaults'); + + // 2. Create carrier with full flags + const c2 = await store.carrier.create(googleAuthId, { + name: 'UPS', + sort: 5, + hasPackages: true, + isReverse: true, + }); + assert.equal(c2.name, 'UPS'); + assert.equal(c2.sort, 5); + assert.equal(c2.hasPackages, true); + assert.equal(c2.isReverse, true); + console.log('PASS: Carrier 2 created with custom flags'); + + // 3. Auto-increment sort + const c3 = await store.carrier.create(googleAuthId, { + name: 'DPD', + hasPackages: true, + }); + assert.equal(c3.name, 'DPD'); + assert.equal(c3.sort, 6); + assert.equal(c3.hasPackages, true); + assert.equal(c3.isReverse, false); + console.log('PASS: Carrier 3 auto-incremented sort'); + + // 4. Duplicate name validation + await assert.rejects( + () => store.carrier.create(googleAuthId, { name: 'DHL' }), + { message: 'Carrier already exists' }, + ); + console.log('PASS: Duplicate carrier rejected'); + + // 5. Empty name validation + await assert.rejects( + () => store.carrier.create(googleAuthId, { name: ' ' }), + { message: 'Name required' }, + ); + console.log('PASS: Empty carrier name rejected'); + + // 6. getById + const fetched = await store.carrier.getById(googleAuthId, c2.id); + assert.deepEqual(fetched, c2); + const notFound = await store.carrier.getById(googleAuthId, 9999); + assert.equal(notFound, undefined); + console.log('PASS: getById fetched carrier correctly'); + + // 7. list + const list = await store.carrier.list(googleAuthId); + assert.equal(list.length, 3); + assert.equal(list[0].name, 'DHL'); + assert.equal(list[1].name, 'UPS'); + assert.equal(list[2].name, 'DPD'); + console.log('PASS: list returned sorted carriers'); + + // 8. update + const updated = await store.carrier.update(googleAuthId, c1.id, { + name: 'DHL Express', + hasPackages: true, + isReverse: false, + }); + assert.equal(updated.name, 'DHL Express'); + assert.equal(updated.hasPackages, true); + console.log('PASS: update modified carrier'); + + // 9. reorder + const ordered = await store.carrier.order(googleAuthId, { + carrierIds: [c3.id, c2.id, c1.id], + }); + assert.deepEqual(ordered.carrierIds, [c3.id, c2.id, c1.id]); + const listAfterOrder = await store.carrier.list(googleAuthId); + assert.equal(listAfterOrder[0].id, c3.id); + assert.equal(listAfterOrder[0].sort, 0); + assert.equal(listAfterOrder[1].id, c2.id); + assert.equal(listAfterOrder[1].sort, 1); + assert.equal(listAfterOrder[2].id, c1.id); + assert.equal(listAfterOrder[2].sort, 2); + console.log('PASS: order successfully reordered carriers'); + + // 10. delete + await store.carrier.delete(googleAuthId, c3.id); + const listAfterDelete = await store.carrier.list(googleAuthId); + assert.equal(listAfterDelete.length, 2); + console.log('PASS: delete removed carrier'); + + await assert.rejects( + () => store.carrier.delete(googleAuthId, c3.id), + { message: 'Carrier not found' }, + ); + console.log('PASS: delete non-existent carrier rejected'); + + console.log('--- ALL CARRIER TESTS PASSED! ---'); + } finally { + try { + rmSync(tempDir, { recursive: true, force: true }); + } catch {} + } +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/test-order-shipping-ledger.js b/scripts/test-order-shipping-ledger.js index 4f48c38..1a7af12 100644 --- a/scripts/test-order-shipping-ledger.js +++ b/scripts/test-order-shipping-ledger.js @@ -54,6 +54,10 @@ async function run() { name: 'Max Mustermann', }); + const carrier = await store.carrier.create(googleAuthId, { + name: 'Standard Carrier', + }); + // 2. Setup tracked article and untracked article const trackedArt = await store.article.createUnderParent({ googleAuthId, @@ -117,6 +121,7 @@ async function run() { }); const shippingOpen = await store.shipping.create(googleAuthId, { manifestId: shipment.id, + carrierId: carrier.id, type: 'parcel', state: 'open', }); @@ -219,6 +224,7 @@ async function run() { async () => { await store.shipping.create(googleAuthId, { manifestId: order.id, + carrierId: carrier.id, type: 'express', state: 'shipped', }); @@ -251,6 +257,7 @@ async function run() { const directShipped = await store.shipping.create(googleAuthId, { manifestId: shipment2.id, + carrierId: carrier.id, type: 'parcel', state: 'shipped', }); diff --git a/scripts/test-shipment.js b/scripts/test-shipment.js index 7b9c9bb..6586738 100644 --- a/scripts/test-shipment.js +++ b/scripts/test-shipment.js @@ -150,29 +150,47 @@ async function run() { } assert.ok(exhaustedErrorThrown, 'Expected exhausted error to be thrown'); - // 5. Add packages to Shipment 1 with tracking codes + // 5. Add packages to Shipment 1 with tracking codes and carrier + const carrier = await store.carrier.create(googleAuthId, { + name: 'DHL', + hasPackages: true, + isReverse: false, + }); + assert.equal(carrier.name, 'DHL'); + assert.equal(carrier.hasPackages, true); + assert.equal(carrier.isReverse, false); + const pkg1 = await store.shipping.create(googleAuthId, { manifestId: shipment1.id, + carrierId: carrier.id, type: 'parcel', state: 'draft', trackingCode: 'TRACK-111', - carrier: 'DHL', }); assert.equal(pkg1.trackingCode, 'TRACK-111'); + assert.equal(pkg1.carrierId, carrier.id); assert.equal(pkg1.carrier, 'DHL'); assert.equal(pkg1.state, 'draft'); console.log('PASS: Package 1 created for Shipment 1:', pkg1.number, pkg1.trackingCode); const pkg2 = await store.shipping.create(googleAuthId, { manifestId: shipment1.id, + carrierId: carrier.id, type: 'parcel', state: 'draft', trackingCode: 'TRACK-222', - carrier: 'DHL', }); assert.equal(pkg2.trackingCode, 'TRACK-222'); + assert.equal(pkg2.carrierId, carrier.id); console.log('PASS: Package 2 created for Shipment 1:', pkg2.number, pkg2.trackingCode); + // Verify carrier in use prevents deletion + await assert.rejects( + () => store.carrier.delete(googleAuthId, carrier.id), + { message: 'Carrier in use' }, + ); + console.log('PASS: Carrier delete rejected while referenced in shipping'); + // Check listByOrderId const orderShipments = await store.shipment.listByOrderId(googleAuthId, order.id); assert.equal(orderShipments.length, 2); diff --git a/src/index.js b/src/index.js index f22ee0a..f014704 100644 --- a/src/index.js +++ b/src/index.js @@ -165,6 +165,12 @@ import warehouseList from './store/warehouse/list.js'; import warehouseUpdate from './store/warehouse/update.js'; import warehouseDelete from './store/warehouse/delete.js'; import warehouseOrder from './store/warehouse/order.js'; +import carrierCreate from './store/carrier/create.js'; +import carrierGetById from './store/carrier/getById.js'; +import carrierList from './store/carrier/list.js'; +import carrierUpdate from './store/carrier/update.js'; +import carrierDelete from './store/carrier/delete.js'; +import carrierOrder from './store/carrier/order.js'; import printerCreate from './store/printer/create.js'; import printerGetById from './store/printer/getById.js'; import printerList from './store/printer/list.js'; @@ -559,6 +565,15 @@ export default class LibDashStore { order: warehouseOrder(provider), }; + this.carrier = { + create: carrierCreate(provider), + getById: carrierGetById(provider), + list: carrierList(provider), + update: carrierUpdate(provider), + delete: carrierDelete(provider), + order: carrierOrder(provider), + }; + this.printer = { create: printerCreate(provider), getById: printerGetById(provider), diff --git a/src/providers/mssql/purgeSql.js b/src/providers/mssql/purgeSql.js index 55bc56f..2aaf5c1 100644 --- a/src/providers/mssql/purgeSql.js +++ b/src/providers/mssql/purgeSql.js @@ -7,6 +7,7 @@ export const TABLES = [ 'ordersummary', 'package', 'shipping', + 'carrier', 'payment', 'account', 'articleattributevalue', diff --git a/src/providers/mssql/queries/carrier.js b/src/providers/mssql/queries/carrier.js new file mode 100644 index 0000000..3db98ec --- /dev/null +++ b/src/providers/mssql/queries/carrier.js @@ -0,0 +1,78 @@ +export const LIST_BY_COMPANY = ` +SELECT + id, + company_id, + name, + sort, + has_packages, + is_reverse +FROM carrier +WHERE company_id = @companyId +ORDER BY sort ASC, name ASC, id ASC; +`; + +export const GET_BY_COMPANY_AND_ID = ` +SELECT + id, + company_id, + name, + sort, + has_packages, + is_reverse +FROM carrier +WHERE id = @id AND company_id = @companyId; +`; + +export const GET_BY_COMPANY_AND_NAME = ` +SELECT + id, + company_id, + name, + sort, + has_packages, + is_reverse +FROM carrier +WHERE company_id = @companyId AND name = @name; +`; + +export const GET_NEXT_SORT = ` +SELECT ISNULL(MAX(sort), -1) + 1 AS sort +FROM carrier +WHERE company_id = @companyId; +`; + +export const CREATE = ` +INSERT INTO carrier (company_id, name, sort, has_packages, is_reverse) +OUTPUT INSERTED.id, INSERTED.company_id, INSERTED.name, INSERTED.sort, INSERTED.has_packages, INSERTED.is_reverse +VALUES (@companyId, @name, @sort, @hasPackages, @isReverse); +`; + +export const UPDATE = ` +UPDATE carrier +SET name = @name, sort = @sort, has_packages = @hasPackages, is_reverse = @isReverse +OUTPUT INSERTED.id, INSERTED.company_id, INSERTED.name, INSERTED.sort, INSERTED.has_packages, INSERTED.is_reverse +WHERE id = @id AND company_id = @companyId; +`; + +export const LIST_IDS_BY_COMPANY = ` +SELECT id +FROM carrier +WHERE company_id = @companyId; +`; + +export const UPDATE_SORT = ` +UPDATE carrier +SET sort = @sort +WHERE id = @id AND company_id = @companyId; +`; + +export const DELETE_BY_COMPANY_AND_ID = ` +DELETE FROM carrier +WHERE id = @id AND company_id = @companyId; +`; + +export const CHECK_SHIPPING_USAGE = ` +SELECT TOP 1 id +FROM shipping +WHERE carrier_id = @carrierId; +`; diff --git a/src/providers/mssql/queries/logistics.js b/src/providers/mssql/queries/logistics.js index dffa9ab..92221fe 100644 --- a/src/providers/mssql/queries/logistics.js +++ b/src/providers/mssql/queries/logistics.js @@ -56,17 +56,24 @@ WHERE id = @id AND manifest_id IN (SELECT m.id FROM manifest m WHERE m.company_id = @companyId) `; +export const CHECK_CARRIER = ` +SELECT TOP 1 id, name +FROM carrier +WHERE id = @carrierId AND company_id = @companyId; +`; + export const SHIPPING_CREATE = ` -INSERT INTO shipping (manifest_id, [number], tracking_code, carrier, [type], [state]) -OUTPUT INSERTED.id, INSERTED.manifest_id, INSERTED.[number], INSERTED.tracking_code, INSERTED.carrier, INSERTED.[type], INSERTED.[state] -VALUES (@manifestId, @number, @trackingCode, @carrier, @type, @state) +INSERT INTO shipping (manifest_id, [number], tracking_code, carrier_id, [type], [state]) +OUTPUT INSERTED.id, INSERTED.manifest_id, INSERTED.[number], INSERTED.tracking_code, INSERTED.carrier_id, INSERTED.[type], INSERTED.[state] +VALUES (@manifestId, @number, @trackingCode, @carrierId, @type, @state) `; export const SHIPPING_LIST_BY_MANIFEST = ` -SELECT id, manifest_id, [number], tracking_code, carrier, [type], [state] -FROM shipping -WHERE manifest_id = @manifestId -ORDER BY id ASC +SELECT s.id, s.manifest_id, s.[number], s.tracking_code, s.carrier_id, c.name AS carrier, s.[type], s.[state] +FROM shipping s +LEFT JOIN carrier c ON c.id = s.carrier_id +WHERE s.manifest_id = @manifestId +ORDER BY s.id ASC `; export const SHIPPING_DELETE = ` @@ -76,16 +83,17 @@ WHERE id = @id `; export const GET_SHIPPING_BY_ID = ` -SELECT s.id, s.manifest_id AS manifestId, s.[number], s.tracking_code, s.carrier, s.[type], s.[state] +SELECT s.id, s.manifest_id AS manifestId, s.[number], s.tracking_code, s.carrier_id AS carrierId, c.name AS carrier, s.[type], s.[state] FROM shipping s INNER JOIN manifest m ON m.id = s.manifest_id +LEFT JOIN carrier c ON c.id = s.carrier_id WHERE s.id = @id AND m.company_id = @companyId; `; export const SHIPPING_UPDATE = ` UPDATE shipping -SET [type] = @type, [state] = @state, tracking_code = @trackingCode, carrier = @carrier -OUTPUT INSERTED.id, INSERTED.manifest_id, INSERTED.[number], INSERTED.tracking_code, INSERTED.carrier, INSERTED.[type], INSERTED.[state] +SET [type] = @type, [state] = @state, tracking_code = @trackingCode, carrier_id = @carrierId +OUTPUT INSERTED.id, INSERTED.manifest_id, INSERTED.[number], INSERTED.tracking_code, INSERTED.carrier_id, INSERTED.[type], INSERTED.[state] WHERE id = @id; `; diff --git a/src/providers/mssql/schema.js b/src/providers/mssql/schema.js index 86f62fc..eb22a2c 100644 --- a/src/providers/mssql/schema.js +++ b/src/providers/mssql/schema.js @@ -1144,6 +1144,22 @@ BEGIN END `; +export const INIT_CARRIER = ` +IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'carrier') +BEGIN + CREATE TABLE carrier ( + id INT IDENTITY(1,1) PRIMARY KEY, + company_id INT NOT NULL, + name NVARCHAR(255) NOT NULL, + sort INT NOT NULL CONSTRAINT DF_carrier_sort DEFAULT 0, + has_packages BIT NOT NULL CONSTRAINT DF_carrier_has_packages DEFAULT 0, + is_reverse BIT NOT NULL CONSTRAINT DF_carrier_is_reverse DEFAULT 0, + CONSTRAINT FK_carrier_company FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE, + CONSTRAINT UQ_carrier_company_name UNIQUE (company_id, name) + ); +END +`; + export const INIT_SHIPPING = ` IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'shipping') BEGIN @@ -1152,10 +1168,11 @@ BEGIN manifest_id INT NOT NULL, [number] NVARCHAR(100) NOT NULL, tracking_code NVARCHAR(255) NULL, - carrier NVARCHAR(100) NULL, + carrier_id INT NOT NULL, [type] NVARCHAR(100) NOT NULL, [state] NVARCHAR(100) NOT NULL, - CONSTRAINT FK_shipping_manifest FOREIGN KEY (manifest_id) REFERENCES manifest(id) ON DELETE CASCADE + CONSTRAINT FK_shipping_manifest FOREIGN KEY (manifest_id) REFERENCES manifest(id) ON DELETE CASCADE, + CONSTRAINT FK_shipping_carrier FOREIGN KEY (carrier_id) REFERENCES carrier(id) ON DELETE NO ACTION ); END `; @@ -1461,6 +1478,7 @@ export const SCHEMA_SCRIPTS = [ INIT_MANIFESTATTACHMENT_TRIGGER, INIT_ACCOUNT, INIT_PAYMENT, + INIT_CARRIER, INIT_SHIPPING, INIT_PACKAGE, INIT_ORDERSUMMARY, diff --git a/src/providers/sqlite3/queries/carrier.js b/src/providers/sqlite3/queries/carrier.js new file mode 100644 index 0000000..e82e3a2 --- /dev/null +++ b/src/providers/sqlite3/queries/carrier.js @@ -0,0 +1,16 @@ +import * as source from '../../mssql/queries/carrier.js'; +import { translateQueries } from './translate.js'; + +const queries = translateQueries(source, 'carrier'); +export const { + LIST_BY_COMPANY, + GET_BY_COMPANY_AND_ID, + GET_BY_COMPANY_AND_NAME, + GET_NEXT_SORT, + CREATE, + UPDATE, + LIST_IDS_BY_COMPANY, + UPDATE_SORT, + DELETE_BY_COMPANY_AND_ID, + CHECK_SHIPPING_USAGE, +} = queries; diff --git a/src/providers/sqlite3/queries/logistics.js b/src/providers/sqlite3/queries/logistics.js index 1d12569..051313a 100644 --- a/src/providers/sqlite3/queries/logistics.js +++ b/src/providers/sqlite3/queries/logistics.js @@ -4,6 +4,7 @@ import { translateQueries } from './translate.js'; const queries = translateQueries(source, 'logistics'); export const { CHECK_ACCOUNT_COMPANY, + CHECK_CARRIER, CHECK_ENTITYADDRESS_COMPANY, CHECK_SHIPPING_COMPANY, ACCOUNT_CREATE, diff --git a/src/providers/sqlite3/schema.js b/src/providers/sqlite3/schema.js index a6a4f85..07daa52 100644 --- a/src/providers/sqlite3/schema.js +++ b/src/providers/sqlite3/schema.js @@ -818,16 +818,30 @@ CREATE TABLE IF NOT EXISTS payment ( ); `; +export const INIT_CARRIER = ` +CREATE TABLE IF NOT EXISTS carrier ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + company_id INTEGER NOT NULL, + name TEXT NOT NULL, + sort INTEGER NOT NULL DEFAULT 0, + has_packages INTEGER NOT NULL DEFAULT 0, + is_reverse INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY (company_id) REFERENCES company(id) ON DELETE CASCADE, + UNIQUE (company_id, name) +); +`; + export const INIT_SHIPPING = ` CREATE TABLE IF NOT EXISTS shipping ( id INTEGER PRIMARY KEY AUTOINCREMENT, manifest_id INTEGER NOT NULL, "number" TEXT NOT NULL, tracking_code TEXT NULL, - carrier TEXT NULL, + carrier_id INTEGER NOT NULL, "type" TEXT NOT NULL, "state" TEXT NOT NULL, - FOREIGN KEY (manifest_id) REFERENCES manifest(id) ON DELETE CASCADE + FOREIGN KEY (manifest_id) REFERENCES manifest(id) ON DELETE CASCADE, + FOREIGN KEY (carrier_id) REFERENCES carrier(id) ON DELETE NO ACTION ); `; @@ -1090,6 +1104,7 @@ export const SCHEMA_SCRIPTS = [ INIT_MANIFESTATTACHMENT_TRIGGER, INIT_ACCOUNT, INIT_PAYMENT, + INIT_CARRIER, INIT_SHIPPING, INIT_PACKAGE, INIT_ORDERSUMMARY, diff --git a/src/store/carrier/create.js b/src/store/carrier/create.js new file mode 100644 index 0000000..0a20507 --- /dev/null +++ b/src/store/carrier/create.js @@ -0,0 +1,97 @@ +import { + CREATE as MSSQL_CREATE, + GET_NEXT_SORT as MSSQL_GET_NEXT_SORT, +} from '../../providers/mssql/queries/carrier.js'; +import { + CREATE as SQLITE_CREATE, + GET_NEXT_SORT as SQLITE_GET_NEXT_SORT, +} from '../../providers/sqlite3/queries/carrier.js'; +import { isUniqueConstraintError } from '../../providers/provider.js'; +import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; + +function shape(row) { + return { + id: row.id, + name: row.name, + sort: row.sort, + hasPackages: Boolean(row.has_packages ?? row.hasPackages), + isReverse: Boolean(row.is_reverse ?? row.isReverse), + }; +} + +export default function create(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const getSortSql = isSqlite ? SQLITE_GET_NEXT_SORT : MSSQL_GET_NEXT_SORT; + const sql = isSqlite ? SQLITE_CREATE : MSSQL_CREATE; + + /** + * Creates a new carrier for the user's selected company. + * + * @param {number} googleAuthId - The id of the Google auth user. + * @param {object} input - Carrier input. + * @param {string} input.name - The carrier name. + * @param {number} [input.sort] - Optional sort order (auto-increments if omitted). + * @param {boolean} [input.hasPackages=false] - Whether this carrier uses packages. + * @param {boolean} [input.isReverse=false] - Whether this carrier handles reverse shipments. + * @param {object} [tx] - Optional transaction to run within. + * @returns {Promise<{id: number, name: string, sort: number, hasPackages: boolean, isReverse: boolean}>} The created carrier. + * @throws {Error} If name is missing, sort is invalid, or carrier already exists. + * + * @example + * const c = await store.carrier.create(googleAuthId, { name: 'DHL', hasPackages: true }); + * // => { id: 1, name: 'DHL', sort: 0, hasPackages: true, isReverse: false } + */ + return async (googleAuthId, input = {}, tx) => { + const rawName = input?.name; + const cleaned = typeof rawName === 'string' ? rawName.trim() : ''; + if (!cleaned) { + throw new Error('Name required'); + } + + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, runner); + + let sort = input.sort; + if (sort === undefined || sort === null) { + const next = await runner.query(getSortSql, { companyId }); + sort = next.rows[0]?.sort ?? 0; + } else if (!Number.isFinite(sort)) { + throw new Error('Invalid sort'); + } + + const hasPackages = Boolean(input.hasPackages ?? input.has_packages); + const isReverse = Boolean(input.isReverse ?? input.is_reverse); + + try { + const result = await runner.query(sql, { + companyId, + name: cleaned, + sort, + hasPackages: hasPackages ? 1 : 0, + isReverse: isReverse ? 1 : 0, + }); + + const row = result.rows[0] ?? { + id: result.insertId, + company_id: companyId, + name: cleaned, + sort, + has_packages: hasPackages ? 1 : 0, + is_reverse: isReverse ? 1 : 0, + }; + return shape(row); + } catch (err) { + if ( + isUniqueConstraintError(err) || + err?.number === 2627 || + err?.number === 2601 || + err?.code === 'SQLITE_CONSTRAINT_UNIQUE' + ) { + throw new Error('Carrier already exists'); + } + throw err; + } + }; +} diff --git a/src/store/carrier/delete.js b/src/store/carrier/delete.js new file mode 100644 index 0000000..7204982 --- /dev/null +++ b/src/store/carrier/delete.js @@ -0,0 +1,62 @@ +import { + DELETE_BY_COMPANY_AND_ID as MSSQL_DELETE, + CHECK_SHIPPING_USAGE as MSSQL_CHECK_USAGE, +} from '../../providers/mssql/queries/carrier.js'; +import { + DELETE_BY_COMPANY_AND_ID as SQLITE_DELETE, + CHECK_SHIPPING_USAGE as SQLITE_CHECK_USAGE, +} from '../../providers/sqlite3/queries/carrier.js'; +import { isForeignKeyConstraintError } from '../../providers/provider.js'; +import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; + +export default function del(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const deleteSql = isSqlite ? SQLITE_DELETE : MSSQL_DELETE; + const checkUsageSql = isSqlite ? SQLITE_CHECK_USAGE : MSSQL_CHECK_USAGE; + + /** + * Deletes a carrier for the user's selected company, unless referenced in shipping. + * + * @param {number} googleAuthId - The id of the Google auth user. + * @param {number} id - The carrier ID to delete. + * @param {object} [tx] - Optional transaction to run within. + * @returns {Promise} + * @throws {Error} If carrier not found or in use in shipping. + * + * @example + * await store.carrier.delete(googleAuthId, 1); + */ + return async (googleAuthId, id, tx) => { + const numericId = Number(id); + if (!Number.isInteger(numericId) || numericId <= 0) { + throw new Error('Carrier not found'); + } + + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, runner); + + // Pre-check if carrier is referenced in shipping + const usage = await runner.query(checkUsageSql, { carrierId: numericId }); + if (usage.rows && usage.rows.length > 0) { + throw new Error('Carrier in use'); + } + + try { + const result = await runner.query(deleteSql, { + id: numericId, + companyId, + }); + + if (!result.rowsAffected) { + throw new Error('Carrier not found'); + } + } catch (err) { + if (isForeignKeyConstraintError(err)) { + throw new Error('Carrier in use'); + } + throw err; + } + }; +} diff --git a/src/store/carrier/getById.js b/src/store/carrier/getById.js new file mode 100644 index 0000000..f215150 --- /dev/null +++ b/src/store/carrier/getById.js @@ -0,0 +1,50 @@ +import { GET_BY_COMPANY_AND_ID as MSSQL_GET_BY_ID } from '../../providers/mssql/queries/carrier.js'; +import { GET_BY_COMPANY_AND_ID as SQLITE_GET_BY_ID } from '../../providers/sqlite3/queries/carrier.js'; +import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; + +function shape(row) { + return { + id: row.id, + name: row.name, + sort: row.sort, + hasPackages: Boolean(row.has_packages ?? row.hasPackages), + isReverse: Boolean(row.is_reverse ?? row.isReverse), + }; +} + +export default function getById(provider) { + const access = resolveSelectedCompanyAccess(provider); + const sql = + provider?.dialect === 'sqlite3' ? SQLITE_GET_BY_ID : MSSQL_GET_BY_ID; + + /** + * Retrieves a carrier by its ID for the user's selected company. + * + * @param {number} googleAuthId - The id of the Google auth user. + * @param {number} id - The carrier ID. + * @param {object} [tx] - Optional transaction to run within. + * @returns {Promise<{id: number, name: string, sort: number, hasPackages: boolean, isReverse: boolean}|undefined>} The carrier or undefined. + * + * @example + * const c = await store.carrier.getById(googleAuthId, 1); + * // => { id: 1, name: 'DHL', sort: 0, hasPackages: true, isReverse: false } + */ + return async (googleAuthId, id, tx) => { + const numericId = Number(id); + if (!Number.isInteger(numericId) || numericId <= 0) { + return undefined; + } + + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, runner); + + const result = await runner.query(sql, { + id: numericId, + companyId, + }); + + const row = result.rows[0]; + return row ? shape(row) : undefined; + }; +} diff --git a/src/store/carrier/list.js b/src/store/carrier/list.js new file mode 100644 index 0000000..758ba8c --- /dev/null +++ b/src/store/carrier/list.js @@ -0,0 +1,39 @@ +import { LIST_BY_COMPANY as MSSQL_LIST } from '../../providers/mssql/queries/carrier.js'; +import { LIST_BY_COMPANY as SQLITE_LIST } from '../../providers/sqlite3/queries/carrier.js'; +import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; + +function shape(row) { + return { + id: row.id, + name: row.name, + sort: row.sort, + hasPackages: Boolean(row.has_packages ?? row.hasPackages), + isReverse: Boolean(row.is_reverse ?? row.isReverse), + }; +} + +export default function list(provider) { + const access = resolveSelectedCompanyAccess(provider); + const sql = + provider?.dialect === 'sqlite3' ? SQLITE_LIST : MSSQL_LIST; + + /** + * Lists all carriers for the user's selected company. + * + * @param {number} googleAuthId - The id of the Google auth user. + * @param {object} [tx] - Optional transaction to run within. + * @returns {Promise>} List of carriers. + * + * @example + * const carriers = await store.carrier.list(googleAuthId); + * // => [{ id: 1, name: 'DHL', sort: 0, hasPackages: true, isReverse: false }] + */ + return async (googleAuthId, tx) => { + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, runner); + + const result = await runner.query(sql, { companyId }); + return result.rows.map(shape); + }; +} diff --git a/src/store/carrier/order.js b/src/store/carrier/order.js new file mode 100644 index 0000000..c67b259 --- /dev/null +++ b/src/store/carrier/order.js @@ -0,0 +1,79 @@ +import { + LIST_IDS_BY_COMPANY as MSSQL_LIST_IDS_BY_COMPANY, + UPDATE_SORT as MSSQL_UPDATE_SORT, +} from '../../providers/mssql/queries/carrier.js'; +import { + LIST_IDS_BY_COMPANY as SQLITE_LIST_IDS_BY_COMPANY, + UPDATE_SORT as SQLITE_UPDATE_SORT, +} from '../../providers/sqlite3/queries/carrier.js'; +import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; + +export default function order(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const listIdsSql = isSqlite + ? SQLITE_LIST_IDS_BY_COMPANY + : MSSQL_LIST_IDS_BY_COMPANY; + const updateSortSql = isSqlite ? SQLITE_UPDATE_SORT : MSSQL_UPDATE_SORT; + + /** + * Reorders the carriers of the user's selected company. + * + * @param {number} googleAuthId - The id of the Google auth user. + * @param {object} input - The reorder input. + * @param {Array} input.carrierIds - The carrier ids in their new order. + * @param {object} [tx] - Optional transaction to run within. + * @returns {Promise<{carrierIds: Array}>} The applied order. + * @throws {Error} If the order is invalid. + * + * @example + * await store.carrier.order(googleAuthId, { carrierIds: [2, 1, 3] }); + */ + return async (googleAuthId, { carrierIds } = {}, tx) => { + if (!Array.isArray(carrierIds)) { + throw new Error('Invalid carrier order'); + } + + const runOrder = async (runner) => { + const { companyId } = await access(googleAuthId, runner); + + const items = await runner.query(listIdsSql, { companyId }); + const existing = new Set(items.rows.map((r) => r.id)); + + if (carrierIds.length !== existing.size) { + throw new Error('Invalid carrier order'); + } + + const seen = new Set(); + for (const carrierId of carrierIds) { + if ( + !Number.isFinite(carrierId) || + !existing.has(carrierId) || + seen.has(carrierId) + ) { + throw new Error('Invalid carrier order'); + } + seen.add(carrierId); + } + + for (let sort = 0; sort < carrierIds.length; sort++) { + await runner.query(updateSortSql, { + id: carrierIds[sort], + companyId, + sort, + }); + } + + return { carrierIds }; + }; + + if (tx) { + return runOrder(makeRunner(provider, tx)); + } + if (typeof provider?.transaction === 'function') { + return provider.transaction(runOrder); + } + return runOrder(makeRunner(provider)); + }; +} diff --git a/src/store/carrier/update.js b/src/store/carrier/update.js new file mode 100644 index 0000000..2df72fb --- /dev/null +++ b/src/store/carrier/update.js @@ -0,0 +1,122 @@ +import { + GET_BY_COMPANY_AND_ID as MSSQL_GET_BY_ID, + UPDATE as MSSQL_UPDATE, +} from '../../providers/mssql/queries/carrier.js'; +import { + GET_BY_COMPANY_AND_ID as SQLITE_GET_BY_ID, + UPDATE as SQLITE_UPDATE, +} from '../../providers/sqlite3/queries/carrier.js'; +import { isUniqueConstraintError } from '../../providers/provider.js'; +import resolveSelectedCompanyAccess from '../companyuser/resolveSelectedCompanyAccess.js'; +import makeRunner from '../runner.js'; + +function shape(row) { + return { + id: row.id, + name: row.name, + sort: row.sort, + hasPackages: Boolean(row.has_packages ?? row.hasPackages), + isReverse: Boolean(row.is_reverse ?? row.isReverse), + }; +} + +export default function update(provider) { + const access = resolveSelectedCompanyAccess(provider); + const isSqlite = provider?.dialect === 'sqlite3'; + const getSql = isSqlite ? SQLITE_GET_BY_ID : MSSQL_GET_BY_ID; + const updateSql = isSqlite ? SQLITE_UPDATE : MSSQL_UPDATE; + + /** + * Updates a carrier name, sort order, hasPackages, and/or isReverse for the user's selected company. + * + * @param {number} googleAuthId - The id of the Google auth user. + * @param {number} id - The carrier ID to update. + * @param {object} input - Update payload. + * @param {string} [input.name] - The new carrier name. + * @param {number} [input.sort] - The new sort order. + * @param {boolean} [input.hasPackages] - Whether carrier uses packages. + * @param {boolean} [input.isReverse] - Whether carrier handles reverse shipments. + * @param {object} [tx] - Optional transaction to run within. + * @returns {Promise<{id: number, name: string, sort: number, hasPackages: boolean, isReverse: boolean}>} The updated carrier. + * @throws {Error} If carrier not found, name is empty, sort is invalid, or carrier already exists. + * + * @example + * const updated = await store.carrier.update(googleAuthId, 1, { name: 'DHL Express', hasPackages: true }); + * // => { id: 1, name: 'DHL Express', sort: 0, hasPackages: true, isReverse: false } + */ + return async (googleAuthId, id, input = {}, tx) => { + const numericId = Number(id); + if (!Number.isInteger(numericId) || numericId <= 0) { + throw new Error('Carrier not found'); + } + + const runner = makeRunner(provider, tx); + const { companyId } = await access(googleAuthId, runner); + + const existing = await runner.query(getSql, { id: numericId, companyId }); + const current = existing.rows[0]; + if (!current) { + throw new Error('Carrier not found'); + } + + let name = current.name; + if (input.name !== undefined) { + const cleaned = typeof input.name === 'string' ? input.name.trim() : ''; + if (!cleaned) { + throw new Error('Name required'); + } + name = cleaned; + } + + let sort = current.sort; + if (input.sort !== undefined) { + if (!Number.isFinite(input.sort)) { + throw new Error('Invalid sort'); + } + sort = input.sort; + } + + const hasPackagesVal = input.hasPackages !== undefined + ? input.hasPackages + : input.has_packages; + const hasPackages = hasPackagesVal !== undefined + ? Boolean(hasPackagesVal) + : Boolean(current.has_packages ?? current.hasPackages); + + const isReverseVal = input.isReverse !== undefined + ? input.isReverse + : input.is_reverse; + const isReverse = isReverseVal !== undefined + ? Boolean(isReverseVal) + : Boolean(current.is_reverse ?? current.isReverse); + + try { + await runner.query(updateSql, { + id: numericId, + companyId, + name, + sort, + hasPackages: hasPackages ? 1 : 0, + isReverse: isReverse ? 1 : 0, + }); + + return shape({ + id: numericId, + name, + sort, + has_packages: hasPackages ? 1 : 0, + is_reverse: isReverse ? 1 : 0, + }); + } catch (err) { + if ( + isUniqueConstraintError(err) || + err?.number === 2627 || + err?.number === 2601 || + err?.code === 'SQLITE_CONSTRAINT_UNIQUE' + ) { + throw new Error('Carrier already exists'); + } + throw err; + } + }; +} diff --git a/src/store/shipment/helpers.js b/src/store/shipment/helpers.js index 6499edd..f94cf35 100644 --- a/src/store/shipment/helpers.js +++ b/src/store/shipment/helpers.js @@ -28,6 +28,7 @@ export function shapeShipping(row) { manifestId: row.manifest_id ?? row.manifestId, number: row.number, trackingCode: row.tracking_code ?? row.trackingCode ?? null, + carrierId: row.carrier_id ?? row.carrierId, carrier: row.carrier ?? null, type: row.type, state: row.state, diff --git a/src/store/shipment/syncSummary.js b/src/store/shipment/syncSummary.js index a8dbb71..0994937 100644 --- a/src/store/shipment/syncSummary.js +++ b/src/store/shipment/syncSummary.js @@ -28,20 +28,20 @@ const SQL_LINE_STATS = ` 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 + COUNT(s.id) AS package_count, + (SELECT c.name FROM shipping s INNER JOIN carrier c ON c.id = s.carrier_id WHERE s.manifest_id = @manifestId AND c.name IS NOT NULL AND TRIM(c.name) != '' ORDER BY s.id ASC LIMIT 1) AS carrier, + (SELECT s.tracking_code FROM shipping s WHERE s.manifest_id = @manifestId AND s.tracking_code IS NOT NULL AND TRIM(s.tracking_code) != '' ORDER BY s.id ASC LIMIT 1) AS tracking_code + FROM shipping s + WHERE s.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 + COUNT(s.id) AS package_count, + (SELECT TOP 1 c.name FROM shipping s INNER JOIN carrier c ON c.id = s.carrier_id WHERE s.manifest_id = @manifestId AND c.name IS NOT NULL AND LTRIM(RTRIM(c.name)) != '' ORDER BY s.id ASC) AS carrier, + (SELECT TOP 1 s.tracking_code FROM shipping s WHERE s.manifest_id = @manifestId AND s.tracking_code IS NOT NULL AND LTRIM(RTRIM(s.tracking_code)) != '' ORDER BY s.id ASC) AS tracking_code + FROM shipping s + WHERE s.manifest_id = @manifestId `; const SQL_ORDER_BY_NUMBER_SQLITE = ` diff --git a/src/store/shipping/create.js b/src/store/shipping/create.js index 55f23a4..e462d9f 100644 --- a/src/store/shipping/create.js +++ b/src/store/shipping/create.js @@ -5,13 +5,14 @@ import stockShip from '../stock/ship.js'; import makeRunner from '../runner.js'; import { syncShipmentSummary } from '../shipment/syncSummary.js'; -function shape(row) { +function shape(row, carrierName) { return { id: row.id, manifestId: row.manifest_id ?? row.manifestId, number: row.number, trackingCode: row.tracking_code ?? row.trackingCode ?? null, - carrier: row.carrier ?? null, + carrierId: row.carrier_id ?? row.carrierId, + carrier: carrierName ?? row.carrier ?? null, type: row.type, state: row.state, }; @@ -32,24 +33,24 @@ export default function create(provider) { * @param {number} googleAuthId - The id of the Google auth user. * @param {object} input - Shipping input. * @param {number} input.manifestId - The shipment manifest id. + * @param {number} input.carrierId - The carrier id. * @param {string} input.type - Shipping type. * @param {string} input.state - Shipping state ('shipped' or other). * @param {string} [input.trackingCode] - Carrier tracking code. - * @param {string} [input.carrier] - Carrier name. * @param {number} [input.warehouseId] - Optional warehouse ID for stock deduction (defaults to company's first warehouse). * @param {object} [tx] - Optional transaction to run within. - * @returns {Promise<{id: number, manifestId: number, number: string, trackingCode: string|null, carrier: string|null, type: string, state: string}>} - * @throws {Error} If manifest is not found, manifest is not a shipment, or input is invalid. + * @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. * * @example - * await store.shipping.create(googleAuthId, { manifestId: 42, type: 'parcel', state: 'shipped', trackingCode: '1Z999', carrier: 'UPS' }); + * await store.shipping.create(googleAuthId, { manifestId: 42, carrierId: 1, type: 'parcel', state: 'shipped', trackingCode: '1Z999' }); */ return async (googleAuthId, input = {}, tx) => { const manifestId = requireId(input.manifestId, 'Manifest'); + const carrierId = requireId(input.carrierId, 'Carrier'); const type = requireText(input.type, 'Shipping type'); const state = requireText(input.state, 'Shipping state'); const trackingCode = input.trackingCode ? String(input.trackingCode).trim() : null; - const carrier = input.carrier ? String(input.carrier).trim() : null; const isShipped = state.trim().toLowerCase() === 'shipped'; const run = async (runner, innerTx) => { @@ -68,6 +69,16 @@ export default function create(provider) { throw new Error('Manifest is not a shipment'); } + // Verify carrier exists and belongs to company + const carrierRes = await runner.query(q.CHECK_CARRIER, { + carrierId, + companyId, + }); + const carrierRow = carrierRes.rows[0]; + if (!carrierRow) { + throw new Error('Carrier not found'); + } + let alreadyShipped = false; if (isShipped) { // Check if shipment was already marked shipped by an existing package @@ -83,7 +94,7 @@ export default function create(provider) { manifestId, number, trackingCode, - carrier, + carrierId, type, state, }); @@ -92,7 +103,7 @@ export default function create(provider) { manifest_id: manifestId, number, tracking_code: trackingCode, - carrier, + carrier_id: carrierId, type, state, }; @@ -146,7 +157,7 @@ export default function create(provider) { await syncShipmentSummary(runner, { companyId, manifestId }); - return shape(row); + return shape(row, carrierRow.name); }; if (tx) { diff --git a/src/store/shipping/listByManifestId.js b/src/store/shipping/listByManifestId.js index ae909dd..d5d24e2 100644 --- a/src/store/shipping/listByManifestId.js +++ b/src/store/shipping/listByManifestId.js @@ -31,6 +31,7 @@ export default function listByManifestId(provider) { manifestId: row.manifest_id, number: row.number, trackingCode: row.tracking_code ?? null, + carrierId: row.carrier_id, carrier: row.carrier ?? null, type: row.type, state: row.state, diff --git a/src/store/shipping/update.js b/src/store/shipping/update.js index 899d799..fe071d5 100644 --- a/src/store/shipping/update.js +++ b/src/store/shipping/update.js @@ -4,13 +4,14 @@ import stockShip from '../stock/ship.js'; import makeRunner from '../runner.js'; import { syncShipmentSummary } from '../shipment/syncSummary.js'; -function shape(row) { +function shape(row, carrierName) { return { id: row.id, manifestId: row.manifest_id ?? row.manifestId, number: row.number, trackingCode: row.tracking_code ?? row.trackingCode ?? null, - carrier: row.carrier ?? null, + carrierId: row.carrier_id ?? row.carrierId, + carrier: carrierName ?? row.carrier ?? null, type: row.type, state: row.state, }; @@ -37,11 +38,11 @@ export default function update(provider) { * @param {string} [patch.type] - Shipping type. * @param {string} [patch.state] - Shipping state. * @param {string} [patch.trackingCode] - Carrier tracking code. - * @param {string} [patch.carrier] - Carrier name. + * @param {number} [patch.carrierId] - Carrier ID. * @param {number} [patch.warehouseId] - Warehouse ID if transitioning to 'shipped'. * @param {object} [tx] - Optional transaction to run within. - * @returns {Promise<{id: number, manifestId: number, number: string, trackingCode: string|null, carrier: string|null, type: string, state: string}>} - * @throws {Error} If shipping is not found, cannot be undone, or manifest not found. + * @returns {Promise<{id: number, manifestId: number, number: string, trackingCode: string|null, carrierId: number, carrier: string|null, type: string, state: string}>} + * @throws {Error} If shipping is not found, carrier is not found, cannot be undone, or manifest not found. */ return async (googleAuthId, id, patch = {}, tx) => { const shippingId = requireId(id, 'Shipping'); @@ -63,7 +64,9 @@ export default function update(provider) { const hasState = Object.prototype.hasOwnProperty.call(patch, 'state'); const hasType = Object.prototype.hasOwnProperty.call(patch, 'type'); const hasTrackingCode = Object.prototype.hasOwnProperty.call(patch, 'trackingCode'); - const hasCarrier = Object.prototype.hasOwnProperty.call(patch, 'carrier'); + const hasCarrierId = + Object.prototype.hasOwnProperty.call(patch, 'carrierId') || + Object.prototype.hasOwnProperty.call(patch, 'carrier_id'); if (isCurrentlyShipped) { if ( @@ -83,9 +86,22 @@ export default function update(provider) { const nextTrackingCode = hasTrackingCode ? (patch.trackingCode ? String(patch.trackingCode).trim() : null) : (current.tracking_code ?? current.trackingCode ?? null); - const nextCarrier = hasCarrier - ? (patch.carrier ? String(patch.carrier).trim() : null) - : (current.carrier ?? null); + + let nextCarrierId = current.carrierId ?? current.carrier_id; + let nextCarrierName = current.carrier ?? null; + if (hasCarrierId) { + const rawCarrierId = patch.carrierId ?? patch.carrier_id; + nextCarrierId = requireId(rawCarrierId, 'Carrier'); + const carrierRes = await runner.query(q.CHECK_CARRIER, { + carrierId: nextCarrierId, + companyId, + }); + const carrierRow = carrierRes.rows[0]; + if (!carrierRow) { + throw new Error('Carrier not found'); + } + nextCarrierName = carrierRow.name; + } const willBeShipped = !isCurrentlyShipped && String(nextState).trim().toLowerCase() === 'shipped'; @@ -153,7 +169,7 @@ export default function update(provider) { type: nextType, state: nextState, trackingCode: nextTrackingCode, - carrier: nextCarrier, + carrierId: nextCarrierId, }); const updatedRow = updateRes.rows[0] ?? { @@ -161,14 +177,14 @@ export default function update(provider) { manifest_id: current.manifestId, number: current.number, tracking_code: nextTrackingCode, - carrier: nextCarrier, + carrier_id: nextCarrierId, type: nextType, state: nextState, }; await syncShipmentSummary(runner, { companyId, manifestId: current.manifestId }); - return shape(updatedRow); + return shape(updatedRow, nextCarrierName); }; if (tx) {