diff --git a/.github/workflows/ember.yml b/.github/workflows/ember.yml index 2f2c9660..97b2a7e6 100644 --- a/.github/workflows/ember.yml +++ b/.github/workflows/ember.yml @@ -8,29 +8,33 @@ on: pull_request: branches: [ main ] +env: + NODE_VERSION: 22.x + jobs: build: runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [18.x] # Build on Node.js 18 - steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - - name: Setup Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v2 + - name: Setup Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 with: - node-version: ${{ matrix.node-version }} + node-version: ${{ env.NODE_VERSION }} - name: Setup pnpm - uses: pnpm/action-setup@v2.0.1 + uses: pnpm/action-setup@v4 with: version: latest - name: Install Dependencies - run: pnpm install + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm run lint + + - name: Test + run: pnpm run test:ember - name: Build run: pnpm run build @@ -40,20 +44,20 @@ jobs: runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/') steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - - name: Setup Node.js 18.x - uses: actions/setup-node@v2 + - name: Setup Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 with: - node-version: 18.x + node-version: ${{ env.NODE_VERSION }} - name: Setup pnpm - uses: pnpm/action-setup@v2.0.1 + uses: pnpm/action-setup@v4 with: version: latest - name: Install Dependencies - run: pnpm install + run: pnpm install --frozen-lockfile - name: Build run: pnpm run build @@ -69,20 +73,20 @@ jobs: runs-on: ubuntu-latest if: startsWith(github.ref, 'refs/tags/') steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - - name: Setup Node.js 18.x - uses: actions/setup-node@v2 + - name: Setup Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 with: - node-version: 18.x + node-version: ${{ env.NODE_VERSION }} - name: Setup pnpm - uses: pnpm/action-setup@v2.0.1 + uses: pnpm/action-setup@v4 with: version: latest - name: Install Dependencies - run: pnpm install + run: pnpm install --frozen-lockfile - name: Build run: pnpm run build diff --git a/.github/workflows/server.yml b/.github/workflows/server.yml index badbe2fc..68833a4a 100644 --- a/.github/workflows/server.yml +++ b/.github/workflows/server.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -34,8 +34,11 @@ jobs: - name: Install dependencies run: composer install --prefer-dist --no-progress - - name: Run Lint - run: composer lint + - name: Run PHP CS Fixer Dry Run + run: composer test:lint - # - name: Run Tests -- Will add tests back after phppest issue https://github.com/pestphp/pest/issues/920 is fixed - # run: composer test:unit + - name: Run Static Analysis + run: composer test:types + + - name: Run Tests + run: composer test:unit diff --git a/.template-lintrc.js b/.template-lintrc.js index 07d5ad82..066b577c 100644 --- a/.template-lintrc.js +++ b/.template-lintrc.js @@ -5,7 +5,6 @@ module.exports = { rules: { 'no-invalid-interactive': 'off', 'no-yield-only': 'off', - 'no-down-event-binding': 'off', 'table-groups': 'off', 'link-href-attributes': 'off', 'require-input-label': 'off', diff --git a/README.md b/README.md index 6719e387..6b3a7390 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,10 @@ This monorepo contains both the frontend and backend components of the Pallet extension for Fleetbase. The frontend is built using Ember.js and the backend is implemented in PHP. -* PHP 7.3.0 or above -* Ember.js v4.8 or above -* Ember CLI v4.8 or above -* Node.js v18 or above +* PHP 8.0 or above +* Ember.js v5.4 or above +* Ember CLI v5.4 or above +* Node.js v22 for CI builds ## Structure @@ -56,16 +56,72 @@ This monorepo contains both the frontend and backend components of the Pallet ex Install the PHP packages using Composer: ```bash -composer require fleetbase/core-api -composer require fleetbase/fleetops -composer require fleetbase/pallet +composer require fleetbase/pallet-api ``` ### Frontend Install the Ember.js Engine/Addon: ```bash -pnpm install @fleetbase/pallet +pnpm install @fleetbase/pallet-engine +``` + +## Storefront Inventory Integration + +Pallet is the canonical inventory authority for Storefront products. Storefront products and variants should be linked to Pallet products and variants by durable UUID fields, not by SKU alone: + +* `pallet_products.storefront_product_uuid` +* `pallet_product_variants.storefront_variant_uuid` + +SKU and barcode matching can be used for assisted lookup, but checkout enforcement should use the explicit Storefront link fields. + +### Internal API Contract + +All Storefront integration endpoints are scoped to the authenticated company under the protected internal API prefix: + +```text +pallet/int/v1/storefront/inventory/resolve +pallet/int/v1/storefront/inventory/availability +pallet/int/v1/storefront/inventory/availability-batch +pallet/int/v1/storefront/inventory/link +pallet/int/v1/storefront/inventory/unlink +pallet/int/v1/storefront/inventory/reserve +pallet/int/v1/storefront/inventory/reserve-batch +pallet/int/v1/storefront/inventory/reservations/context +pallet/int/v1/storefront/inventory/reservations/{id}/release +pallet/int/v1/storefront/inventory/reservations/{id}/commit +pallet/int/v1/storefront/inventory/reservations/release-batch +pallet/int/v1/storefront/inventory/reservations/commit-batch +pallet/int/v1/storefront/inventory/reservations/release-context +pallet/int/v1/storefront/inventory/reservations/commit-context +``` + +Storefront should call `availability` or `availability-batch` when cart quantities change, `reserve` or `reserve-batch` when checkout starts, `release` when a checkout expires or is cancelled, and `commit` when an order is captured or fulfilled. Batch and context release/commit endpoints are available for checkout lifecycles that store reservation UUIDs directly or only retain a Storefront checkout/cart/order context. Context release can release expired active reservations so abandoned checkout stock is returned; context commit only uses non-expired active reservations. Availability responses include a stable `inventory_summary` object with total, available, reserved, out-of-stock, low-stock, reorder, Pallet UUID, and Storefront UUID fields. + +Checkout reservations can include `storefront_checkout_uuid`, `storefront_cart_uuid`, `storefront_order_uuid`, `storefront_line_uuid`, and `storefront_reservation_key`. The reservation key is idempotent: retrying the same reservation key with the same quantity returns the existing active reservation; retrying it with a different quantity returns a conflict so checkout cannot double-reserve a cart line. + +Product links can be created one product at a time: + +```json +{ + "pallet_product_uuid": "product_...", + "storefront_product_uuid": "..." +} +``` + +Variant links can be supplied individually with `pallet_variant_uuid` and `storefront_variant_uuid`, or in a batch: + +```json +{ + "pallet_product_uuid": "product_...", + "storefront_product_uuid": "...", + "variants": [ + { + "pallet_variant_uuid": "variant_...", + "storefront_variant_uuid": "..." + } + ] +} ``` ## Usage @@ -75,6 +131,7 @@ pnpm install @fleetbase/pallet 🧹 Keep a modern codebase with **PHP CS Fixer**: ```bash composer lint +composer lint:fix ``` ⚗️ Run static analysis using **PHPStan**: @@ -120,4 +177,4 @@ pnpm build See the Contributing Guide for details on how to contribute to this project. ## License -This project is licensed under the MIT License. \ No newline at end of file +This project is licensed under the AGPL-3.0-or-later License. diff --git a/addon/adapters/bin-location.js b/addon/adapters/bin-location.js new file mode 100644 index 00000000..5728057c --- /dev/null +++ b/addon/adapters/bin-location.js @@ -0,0 +1 @@ +export { default } from './pallet'; diff --git a/addon/adapters/cycle-count-item.js b/addon/adapters/cycle-count-item.js new file mode 100644 index 00000000..5728057c --- /dev/null +++ b/addon/adapters/cycle-count-item.js @@ -0,0 +1 @@ +export { default } from './pallet'; diff --git a/addon/adapters/cycle-count.js b/addon/adapters/cycle-count.js new file mode 100644 index 00000000..5728057c --- /dev/null +++ b/addon/adapters/cycle-count.js @@ -0,0 +1 @@ +export { default } from './pallet'; diff --git a/addon/adapters/inventory-reservation.js b/addon/adapters/inventory-reservation.js new file mode 100644 index 00000000..5728057c --- /dev/null +++ b/addon/adapters/inventory-reservation.js @@ -0,0 +1 @@ +export { default } from './pallet'; diff --git a/addon/adapters/pallet-product-variant.js b/addon/adapters/pallet-product-variant.js new file mode 100644 index 00000000..0fc98c4d --- /dev/null +++ b/addon/adapters/pallet-product-variant.js @@ -0,0 +1,7 @@ +import PalletAdapter from './pallet'; + +export default class PalletProductVariantAdapter extends PalletAdapter { + pathForType() { + return 'product-variants'; + } +} diff --git a/addon/adapters/pick-list-item.js b/addon/adapters/pick-list-item.js new file mode 100644 index 00000000..5728057c --- /dev/null +++ b/addon/adapters/pick-list-item.js @@ -0,0 +1 @@ +export { default } from './pallet'; diff --git a/addon/adapters/pick-list.js b/addon/adapters/pick-list.js new file mode 100644 index 00000000..5728057c --- /dev/null +++ b/addon/adapters/pick-list.js @@ -0,0 +1 @@ +export { default } from './pallet'; diff --git a/addon/adapters/purchase-order-item.js b/addon/adapters/purchase-order-item.js new file mode 100644 index 00000000..5728057c --- /dev/null +++ b/addon/adapters/purchase-order-item.js @@ -0,0 +1 @@ +export { default } from './pallet'; diff --git a/addon/adapters/sales-order-item.js b/addon/adapters/sales-order-item.js new file mode 100644 index 00000000..5728057c --- /dev/null +++ b/addon/adapters/sales-order-item.js @@ -0,0 +1 @@ +export { default } from './pallet'; diff --git a/addon/adapters/stock-transfer-item.js b/addon/adapters/stock-transfer-item.js new file mode 100644 index 00000000..5728057c --- /dev/null +++ b/addon/adapters/stock-transfer-item.js @@ -0,0 +1 @@ +export { default } from './pallet'; diff --git a/addon/adapters/stock-transfer.js b/addon/adapters/stock-transfer.js new file mode 100644 index 00000000..5728057c --- /dev/null +++ b/addon/adapters/stock-transfer.js @@ -0,0 +1 @@ +export { default } from './pallet'; diff --git a/addon/adapters/warehouse-zone.js b/addon/adapters/warehouse-zone.js new file mode 100644 index 00000000..5728057c --- /dev/null +++ b/addon/adapters/warehouse-zone.js @@ -0,0 +1 @@ +export { default } from './pallet'; diff --git a/addon/adapters/wave.js b/addon/adapters/wave.js new file mode 100644 index 00000000..5728057c --- /dev/null +++ b/addon/adapters/wave.js @@ -0,0 +1 @@ +export { default } from './pallet'; diff --git a/addon/components/admin/product-category.hbs b/addon/components/admin/product-category.hbs index 7819d8cb..d46678b2 100644 --- a/addon/components/admin/product-category.hbs +++ b/addon/components/admin/product-category.hbs @@ -1,6 +1,6 @@
-
- \ No newline at end of file + diff --git a/addon/components/batch-form-panel.js b/addon/components/batch-form-panel.js index a43f1fda..a4ecfe5b 100644 --- a/addon/components/batch-form-panel.js +++ b/addon/components/batch-form-panel.js @@ -2,6 +2,7 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { inject as service } from '@ember/service'; import { action } from '@ember/object'; +import { task } from 'ember-concurrency'; import contextComponentCallback from '@fleetbase/ember-core/utils/context-component-callback'; import applyContextComponentArguments from '@fleetbase/ember-core/utils/apply-context-component-arguments'; @@ -37,12 +38,6 @@ export default class BatchFormPanelComponent extends Component { */ @tracked context; - /** - * Indicates whether the component is in a loading state. - * @type {boolean} - */ - @tracked isLoading = false; - /** * Fuel Report status * @type {Array} @@ -58,6 +53,17 @@ export default class BatchFormPanelComponent extends Component { applyContextComponentArguments(this); } + getRecordUuid(record) { + return record?.uuid ?? record?.id; + } + + @action setProduct(product) { + this.batch.product = product; + this.batch.product_uuid = this.getRecordUuid(product); + this.batch.variant = null; + this.batch.variant_uuid = null; + } + /** * Sets the overlay context. * @@ -75,31 +81,21 @@ export default class BatchFormPanelComponent extends Component { * @action * @returns {Promise} */ - @action save() { + @task *saveTask() { const { batch } = this; this.loader.showLoader('.next-content-overlay-panel-container', { loadingMessage: 'Saving batch...', preserveTargetPosition: true }); - this.isLoading = true; - contextComponentCallback(this, 'onBeforeSave', batch); try { - return batch - .save() - .then((batch) => { - this.notifications.success(`Batch saved successfully.`); - contextComponentCallback(this, 'onAfterSave', batch); - }) - .catch((error) => { - this.notifications.serverError(error); - }) - .finally(() => { - this.loader.removeLoader('.next-content-overlay-panel-container '); - this.isLoading = false; - }); + const savedBatch = yield batch.save(); + this.notifications.success(`Batch saved successfully.`); + contextComponentCallback(this, 'onAfterSave', savedBatch); + return savedBatch; } catch (error) { + this.notifications.serverError(error); + } finally { this.loader.removeLoader('.next-content-overlay-panel-container '); - this.isLoading = false; } } diff --git a/addon/components/batch/details.hbs b/addon/components/batch/details.hbs new file mode 100644 index 00000000..3c19d99f --- /dev/null +++ b/addon/components/batch/details.hbs @@ -0,0 +1,38 @@ +
+ +
+
+
{{t "common.id"}}
+ {{n-a @resource.public_id}} +
+
+
{{t "batch.fields.batch-number"}}
+ {{n-a @resource.batch_number}} +
+
+
{{t "batch.fields.product"}}
+
{{n-a @resource.product.name}}
+
+
+
Variant
+
{{n-a @resource.variant.display_name @resource.variant.name}}
+
+
+
{{t "batch.fields.production-date"}}
+
{{format-date @resource.manufacture_date_at}}
+
+
+
{{t "batch.fields.expiry-date"}}
+
{{format-date @resource.expiry_date_at}}
+
+
+
{{t "inventory.fields.quantity"}}
+
{{format-number @resource.quantity}}
+
+
+
+ + + + +
diff --git a/addon/components/batch/details.js b/addon/components/batch/details.js new file mode 100644 index 00000000..1fd04947 --- /dev/null +++ b/addon/components/batch/details.js @@ -0,0 +1,3 @@ +import Component from '@glimmer/component'; + +export default class BatchDetailsComponent extends Component {} diff --git a/addon/components/batch/form.hbs b/addon/components/batch/form.hbs new file mode 100644 index 00000000..f19711d6 --- /dev/null +++ b/addon/components/batch/form.hbs @@ -0,0 +1,37 @@ +
+ +
+ + + + + + {{model.name}} + + + + + + + + + + + +
+
+ + + + +
diff --git a/addon/components/batch/form.js b/addon/components/batch/form.js new file mode 100644 index 00000000..4990df3d --- /dev/null +++ b/addon/components/batch/form.js @@ -0,0 +1,15 @@ +import Component from '@glimmer/component'; +import { action } from '@ember/object'; + +export default class BatchFormComponent extends Component { + getRecordUuid(record) { + return record?.uuid ?? record?.id; + } + + @action setProduct(product) { + this.args.resource.product = product; + this.args.resource.product_uuid = this.getRecordUuid(product); + this.args.resource.variant = null; + this.args.resource.variant_uuid = null; + } +} diff --git a/addon/components/batch/panel-header.hbs b/addon/components/batch/panel-header.hbs new file mode 100644 index 00000000..8394d8c0 --- /dev/null +++ b/addon/components/batch/panel-header.hbs @@ -0,0 +1,13 @@ +
+
+
{{@resource.batch_number}}
+
+ {{n-a @resource.product.name}} +
+
+
+ {{#if @resource.expiry_date_at}} + Expires: {{format-date @resource.expiry_date_at}} + {{/if}} +
+
diff --git a/addon/components/batch/panel-header.js b/addon/components/batch/panel-header.js new file mode 100644 index 00000000..e63a1f0b --- /dev/null +++ b/addon/components/batch/panel-header.js @@ -0,0 +1,3 @@ +import Component from '@glimmer/component'; + +export default class BatchPanelHeaderComponent extends Component {} diff --git a/addon/components/batch/pill.hbs b/addon/components/batch/pill.hbs new file mode 100644 index 00000000..da9263d2 --- /dev/null +++ b/addon/components/batch/pill.hbs @@ -0,0 +1,3 @@ +
+ {{@resource.batch_number}} +
diff --git a/addon/components/batch/pill.js b/addon/components/batch/pill.js new file mode 100644 index 00000000..5f7d1ba3 --- /dev/null +++ b/addon/components/batch/pill.js @@ -0,0 +1,3 @@ +import Component from '@glimmer/component'; + +export default class BatchPillComponent extends Component {} diff --git a/addon/components/cell/product-info.hbs b/addon/components/cell/product-info.hbs index 48c8d039..d1260c17 100644 --- a/addon/components/cell/product-info.hbs +++ b/addon/components/cell/product-info.hbs @@ -1,9 +1,25 @@ -
- {{this.product.name}} -
- - {{get-default-value this.product.name}} - -

{{get @row 'description'}}

+
+ +
+ +
+ SKU {{n-a this.product.sku "not set"}} + {{#if this.product.barcode}} + Barcode {{this.product.barcode}} + {{/if}} + {{#if this.product.internal_id}} + {{this.product.internal_id}} + {{/if}} +
+
{{n-a this.product.description "No product description"}}
+
+ {{n-a this.categoryName "Uncategorized"}} + {{n-a this.supplierName "No supplier"}} + {{this.variantCountLabel}} +
-
\ No newline at end of file +
diff --git a/addon/components/cell/product-info.js b/addon/components/cell/product-info.js index 24771abc..76c5d213 100644 --- a/addon/components/cell/product-info.js +++ b/addon/components/cell/product-info.js @@ -12,6 +12,19 @@ export default class CellProductInfoComponent extends Component { return row; } + get categoryName() { + return this.product?.category?.name ?? this.product?.category?.label ?? this.product?.category; + } + + get supplierName() { + return this.product?.supplier?.name; + } + + get variantCountLabel() { + const count = Number(this.product?.variant_count ?? this.product?.variants?.length ?? 0); + return count === 1 ? '1 variant' : `${count} variants`; + } + @action onClick(event) { const { row, column, onClick } = this.args; diff --git a/addon/components/cell/product-price.hbs b/addon/components/cell/product-price.hbs new file mode 100644 index 00000000..5e4fe758 --- /dev/null +++ b/addon/components/cell/product-price.hbs @@ -0,0 +1,10 @@ +
+
{{format-currency this.price this.currency}}
+
+ Cost {{format-currency this.cost this.currency}} + {{#if this.hasSalePrice}} + Sale {{format-currency this.salePrice this.currency}} + {{/if}} +
+
Declared {{format-currency this.declaredValue this.currency}}
+
diff --git a/addon/components/cell/product-price.js b/addon/components/cell/product-price.js new file mode 100644 index 00000000..141e4284 --- /dev/null +++ b/addon/components/cell/product-price.js @@ -0,0 +1,31 @@ +import Component from '@glimmer/component'; + +export default class CellProductPriceComponent extends Component { + get product() { + return this.args.row; + } + + get currency() { + return this.product?.currency; + } + + get price() { + return Number(this.product?.unit_price ?? 0); + } + + get cost() { + return Number(this.product?.unit_cost ?? 0); + } + + get salePrice() { + return Number(this.product?.sale_price ?? 0); + } + + get declaredValue() { + return Number(this.product?.declared_value ?? 0); + } + + get hasSalePrice() { + return this.salePrice > 0; + } +} diff --git a/addon/components/cell/product-stock.hbs b/addon/components/cell/product-stock.hbs new file mode 100644 index 00000000..01d1b0d2 --- /dev/null +++ b/addon/components/cell/product-stock.hbs @@ -0,0 +1,22 @@ +
+
+ {{this.stockLabel}} +
+
+ + {{format-number this.available}} + Available + + + {{format-number this.reserved}} + Reserved + + + {{format-number this.total}} + Total + +
+ {{#if this.isLowStock}} +
At or below reorder point
+ {{/if}} +
diff --git a/addon/components/cell/product-stock.js b/addon/components/cell/product-stock.js new file mode 100644 index 00000000..4c8d2c05 --- /dev/null +++ b/addon/components/cell/product-stock.js @@ -0,0 +1,55 @@ +import Component from '@glimmer/component'; + +export default class CellProductStockComponent extends Component { + get product() { + return this.args.row; + } + + get available() { + return Number(this.product?.storefrontAvailableQuantity ?? this.product?.available_stock ?? 0); + } + + get reserved() { + return Number(this.product?.storefrontReservedQuantity ?? this.product?.reserved_stock ?? 0); + } + + get total() { + return Number(this.product?.storefrontTotalQuantity ?? this.product?.total_stock ?? 0); + } + + get reorderPoint() { + return Number(this.product?.storefrontReorderPoint ?? this.product?.reorder_point ?? 0); + } + + get isOutOfStock() { + return this.product?.storefrontOutOfStock ?? this.product?.is_out_of_stock ?? this.available <= 0; + } + + get isLowStock() { + return !this.isOutOfStock && this.reorderPoint > 0 && this.available <= this.reorderPoint; + } + + get stockBadgeStatus() { + if (this.isOutOfStock) { + return 'danger'; + } + + if (this.isLowStock) { + return 'warning'; + } + + return 'success'; + } + + get stockLabel() { + if (this.isOutOfStock) { + return 'Out of stock'; + } + + if (this.isLowStock) { + return 'Low stock'; + } + + return 'Available'; + } +} diff --git a/addon/components/cell/product-storefront.hbs b/addon/components/cell/product-storefront.hbs new file mode 100644 index 00000000..44750d90 --- /dev/null +++ b/addon/components/cell/product-storefront.hbs @@ -0,0 +1,10 @@ +
+ {{this.label}} + {{#if this.product.storefront_product_uuid}} + + {{this.shortStorefrontId}} + + {{else}} + No Storefront link + {{/if}} +
diff --git a/addon/components/cell/product-storefront.js b/addon/components/cell/product-storefront.js new file mode 100644 index 00000000..c072107e --- /dev/null +++ b/addon/components/cell/product-storefront.js @@ -0,0 +1,28 @@ +import Component from '@glimmer/component'; + +export default class CellProductStorefrontComponent extends Component { + get product() { + return this.args.row; + } + + get isLinked() { + return Boolean(this.product?.storefront_product_uuid); + } + + get badgeStatus() { + return this.isLinked ? 'success' : 'warning'; + } + + get label() { + return this.isLinked ? 'Linked' : 'Unlinked'; + } + + get shortStorefrontId() { + const id = this.product?.storefront_product_uuid; + if (!id || id.length <= 14) { + return id; + } + + return `${id.slice(0, 8)}...${id.slice(-4)}`; + } +} diff --git a/addon/components/cell/product-traceability.hbs b/addon/components/cell/product-traceability.hbs new file mode 100644 index 00000000..33e21c7f --- /dev/null +++ b/addon/components/cell/product-traceability.hbs @@ -0,0 +1,18 @@ +
+ {{#if this.hasFlags}} + {{#if this.product.is_serialized}} + Serialized + {{/if}} + {{#if this.product.is_lot_tracked}} + Lot tracked + {{/if}} + {{#if this.product.is_perishable}} + Perishable + {{/if}} + {{#if this.product.requires_quality_check}} + QC required + {{/if}} + {{else}} + Standard tracking + {{/if}} +
diff --git a/addon/components/cell/product-traceability.js b/addon/components/cell/product-traceability.js new file mode 100644 index 00000000..d530765f --- /dev/null +++ b/addon/components/cell/product-traceability.js @@ -0,0 +1,11 @@ +import Component from '@glimmer/component'; + +export default class CellProductTraceabilityComponent extends Component { + get product() { + return this.args.row; + } + + get hasFlags() { + return Boolean(this.product?.is_serialized || this.product?.is_lot_tracked || this.product?.is_perishable || this.product?.requires_quality_check); + } +} diff --git a/addon/components/fulfill-sales-order-form-panel.hbs b/addon/components/fulfill-sales-order-form-panel.hbs new file mode 100644 index 00000000..eb17b8a0 --- /dev/null +++ b/addon/components/fulfill-sales-order-form-panel.hbs @@ -0,0 +1,150 @@ + + +
+ + diff --git a/addon/components/fulfill-sales-order-form-panel.js b/addon/components/fulfill-sales-order-form-panel.js new file mode 100644 index 00000000..a7e3e93f --- /dev/null +++ b/addon/components/fulfill-sales-order-form-panel.js @@ -0,0 +1,208 @@ +import Component from '@glimmer/component'; +import { inject as service } from '@ember/service'; +import { tracked } from '@glimmer/tracking'; +import { action } from '@ember/object'; +import { task } from 'ember-concurrency-decorators'; + +/** + * FulfillSalesOrderFormPanel + * + * A slide-over panel that allows warehouse staff to process the fulfillment of + * a Sales Order. Each line item shows the ordered quantity, already-fulfilled + * quantity, and an input for the quantity being fulfilled now. + * Supports full and partial fulfillments. On save, calls POST /sales-orders/:id/fulfill. + * Uses FEFO (First Expired, First Out) inventory selection on the backend by default. + */ +export default class FulfillSalesOrderFormPanelComponent extends Component { + /** + * @service notifications + */ + @service notifications; + + /** + * @service fetch + */ + @service fetch; + + /** + * @service store + */ + @service store; + + /** + * Per-item fulfillment data keyed by item UUID. + * Each entry: { quantity_fulfilled, notes } + * + * @type {Object} + * @tracked + */ + @tracked fulfillmentData = {}; + + constructor() { + super(...arguments); + this._initFulfillmentData(); + } + + /** + * Returns the sales order from either the direct arg or the context panel `context` arg. + * + * @type {Object} + */ + get salesOrder() { + return this.args.salesOrder || this.args.context; + } + + /** + * Initialise the fulfillmentData map with one entry per pending/partial item. + */ + _initFulfillmentData() { + const salesOrder = this.salesOrder; + if (!salesOrder || !salesOrder.items) { + return; + } + + const data = {}; + salesOrder.items.forEach((item) => { + if (['pending', 'partial'].includes(item.status)) { + data[item.id] = { + uuid: item.id, + inventory: item.inventory, + inventory_uuid: item.inventory_uuid, + quantity_fulfilled: item.outstanding_quantity || 0, + notes: '', + }; + } + }); + this.fulfillmentData = data; + } + + /** + * Returns the items that are eligible for fulfillment (pending or partial). + * + * @type {Array} + */ + get fulfillableItems() { + const salesOrder = this.salesOrder; + if (!salesOrder || !salesOrder.items) { + return []; + } + return salesOrder.items.filter((item) => ['pending', 'partial'].includes(item.status)); + } + + /** + * Returns true if there are no fulfillable items. + * + * @type {Boolean} + */ + get hasNoFulfillableItems() { + return this.fulfillableItems.length === 0; + } + + /** + * Returns the fulfillment entry for a given item. + * + * @param {Object} item + * @returns {Object} + */ + getFulfillmentEntry(item) { + return this.fulfillmentData[item.id] || {}; + } + + valueFromEvent(value) { + return value?.target ? value.target.value : value; + } + + normalizeQuantity(value, maxQuantity) { + const numericValue = Number(this.valueFromEvent(value)); + const normalized = Number.isFinite(numericValue) ? numericValue : 0; + return Math.max(0, Math.min(normalized, Number(maxQuantity) || 0)); + } + + getRecordUuid(record) { + return record?.uuid ?? record?.id; + } + + /** + * Updates a field in the fulfillment entry for a given item. + * + * @action + * @param {Object} item + * @param {String} field + * @param {*} value + */ + @action updateFulfillmentField(item, field, value) { + const current = this.fulfillmentData[item.id] || {}; + const normalizedValue = field === 'quantity_fulfilled' ? this.normalizeQuantity(value, item.outstanding_quantity) : this.valueFromEvent(value); + this.fulfillmentData = { + ...this.fulfillmentData, + [item.id]: { ...current, [field]: normalizedValue }, + }; + } + + @action setFulfillmentInventory(item, inventory) { + const current = this.fulfillmentData[item.id] || {}; + this.fulfillmentData = { + ...this.fulfillmentData, + [item.id]: { + ...current, + inventory, + inventory_uuid: this.getRecordUuid(inventory), + }, + }; + } + + /** + * Submits the fulfillment to the API. + * + * @task + */ + @task *fulfillOrder() { + const salesOrder = this.salesOrder; + const items = Object.values(this.fulfillmentData).filter((entry) => Number(entry.quantity_fulfilled) > 0); + + if (items.length === 0) { + this.notifications.warning('Please enter at least one quantity to fulfill.'); + return; + } + + try { + const response = yield this.fetch.post(`sales-orders/${salesOrder.public_id}/fulfill`, { items }, { namespace: 'pallet/int/v1' }); + + // Reload the SO record from the store to reflect updated status and items + yield salesOrder.reload(); + + this.notifications.success(`Sales Order ${salesOrder.public_id} fulfilled successfully.`); + + if (typeof this.args.onFulfilled === 'function') { + this.args.onFulfilled(response); + } + + if (typeof this.args.onPressCancel === 'function') { + this.args.onPressCancel(); + } + } catch (error) { + const payload = error?.payload; + if (payload?.insufficient_stock) { + // Show a detailed insufficient stock error + const lines = payload.insufficient_stock.map( + (s) => `Product ${s.product_uuid}${s.variant_uuid ? ` / Variant ${s.variant_uuid}` : ''}: requested ${s.requested}, available ${s.available}` + ); + this.notifications.serverError({ payload: { errors: [payload.error, ...lines] } }); + } else { + const message = payload?.error || error?.message || 'Failed to fulfill sales order.'; + this.notifications.serverError({ payload: { errors: [message] } }); + } + } + } + + /** + * Handles the cancel/close action. + * + * @action + */ + @action onPressCancel() { + if (typeof this.args.onPressCancel === 'function') { + this.args.onPressCancel(); + } + } +} diff --git a/addon/components/inventory-form-panel.hbs b/addon/components/inventory-form-panel.hbs index 2df14492..5aab3be2 100644 --- a/addon/components/inventory-form-panel.hbs +++ b/addon/components/inventory-form-panel.hbs @@ -3,7 +3,7 @@ @position="right" @noBackdrop={{true}} @fullHeight={{true}} - @isResizeble={{or this.isResizable @isResizable}} + @isResizable={{or this.isResizable @isResizable}} @width={{or this.width @width "600px"}} > {{#if this.inventory.id}}