@things-factory/operato-wms 4.3.795 → 4.3.797

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,7 @@ import '../components/worksheet-information-popup'
7
7
  import './serial-number-popup'
8
8
  import './tote-popup'
9
9
  import './vas-list-popup'
10
+ import './picking-same-carton-popup'
10
11
 
11
12
  import gql from 'graphql-tag'
12
13
  import { css, html } from 'lit-element'
@@ -1311,14 +1312,27 @@ class PickingProduct extends connect(store)(localize(i18next)(PageView)) {
1311
1312
  const scannedCartonId = this.cartonInput.value.trim()
1312
1313
  if (scannedCartonId) {
1313
1314
  let candidates = (this.data?.records || []).filter(
1314
- rec => rec.cartonId === scannedCartonId && rec.status !== 'DONE' && rec.status !== 'MISSING'
1315
+ rec =>
1316
+ rec.cartonId === scannedCartonId &&
1317
+ rec.status !== 'DONE' &&
1318
+ rec.status !== 'MISSING' &&
1319
+ !rec.completed &&
1320
+ rec.pickedQty < rec.releaseQty
1315
1321
  )
1316
- candidates = this._filterCandidatesByInputQty(candidates)
1317
-
1318
1322
  // Validate carton id: must have at least one candidate
1319
1323
  if (!candidates || candidates.length === 0) {
1320
1324
  this._focusOnCartonNoInput()
1321
- throw new Error(i18next.t('text.invalid_carton_id'))
1325
+ const hasFullyPickedForCarton = (this.data?.records || []).some(
1326
+ rec =>
1327
+ rec.cartonId === scannedCartonId &&
1328
+ rec.status !== 'DONE' &&
1329
+ rec.status !== 'MISSING' &&
1330
+ !rec.completed &&
1331
+ rec.pickedQty >= rec.releaseQty
1332
+ )
1333
+ throw new Error(
1334
+ i18next.t(hasFullyPickedForCarton ? 'text.qty_exceed_release_qty' : 'text.invalid_carton_id')
1335
+ )
1322
1336
  }
1323
1337
 
1324
1338
  if (this._enableProductScanning) {
@@ -1328,11 +1342,14 @@ class PickingProduct extends connect(store)(localize(i18next)(PageView)) {
1328
1342
  throw new Error(i18next.t('text.empty_product_barcode'))
1329
1343
  }
1330
1344
 
1345
+ this._popupCancelled = false
1331
1346
  const matchedRecord = await this._selectRecordByGtinFromCandidates(
1332
1347
  this.productBarcodeInput.value.trim(),
1333
1348
  candidates
1334
1349
  )
1335
1350
 
1351
+ if (this._popupCancelled) return { value: false }
1352
+
1336
1353
  if (!matchedRecord) {
1337
1354
  this._focusOnProductBarcodeInput()
1338
1355
  this.productBarcodeInput.value = ''
@@ -1345,7 +1362,7 @@ class PickingProduct extends connect(store)(localize(i18next)(PageView)) {
1345
1362
  }
1346
1363
  } else {
1347
1364
  // Product scanning disabled: pick a deterministic candidate
1348
- const matchedRecord = this._pickBestCandidate(candidates)
1365
+ const matchedRecord = this._pickBestCandidate(this._filterCandidatesByInputQty(candidates))
1349
1366
  if (
1350
1367
  matchedRecord &&
1351
1368
  (!this._selectedOrderInventory || this._selectedOrderInventory.name !== matchedRecord.name)
@@ -1409,14 +1426,36 @@ class PickingProduct extends connect(store)(localize(i18next)(PageView)) {
1409
1426
  if (this._pageSettings.enablePickingRandomSeq == true) {
1410
1427
  let cartonInputValue = this.cartonInput.value
1411
1428
  let candidates = this.data.records.filter(
1412
- rec => rec.cartonId === cartonInputValue && rec.status !== 'DONE' && rec.status !== 'MISSING'
1429
+ rec =>
1430
+ rec.cartonId === cartonInputValue &&
1431
+ rec.status !== 'DONE' &&
1432
+ rec.status !== 'MISSING' &&
1433
+ !rec.completed &&
1434
+ rec.pickedQty < rec.releaseQty
1413
1435
  )
1414
- candidates = this._filterCandidatesByInputQty(candidates)
1436
+ // If no candidates remain, check if it's because all matching items are already fully picked
1437
+ if (!candidates.length) {
1438
+ const hasFullyPickedForCarton = this.data.records.some(
1439
+ rec =>
1440
+ rec.cartonId === cartonInputValue &&
1441
+ rec.status !== 'DONE' &&
1442
+ rec.status !== 'MISSING' &&
1443
+ !rec.completed &&
1444
+ rec.pickedQty >= rec.releaseQty
1445
+ )
1446
+ if (hasFullyPickedForCarton) {
1447
+ this._focusOnCartonNoInput()
1448
+ throw new Error(i18next.t('text.qty_exceed_release_qty'))
1449
+ }
1450
+ }
1451
+
1415
1452
  if (this._enableProductScanning && this.productBarcodeInput?.value) {
1453
+ this._popupCancelled = false
1416
1454
  foundRecord = await this._selectRecordByGtinFromCandidates(
1417
1455
  this.productBarcodeInput.value.trim(),
1418
1456
  candidates
1419
1457
  )
1458
+ if (this._popupCancelled) return { value: false }
1420
1459
  }
1421
1460
  // If product barcode is provided but no GTIN match, stop without fallback
1422
1461
  if (this._enableProductScanning && this.productBarcodeInput?.value && !foundRecord) {
@@ -1427,7 +1466,7 @@ class PickingProduct extends connect(store)(localize(i18next)(PageView)) {
1427
1466
 
1428
1467
  // No product barcode: deterministically choose best candidate among duplicates
1429
1468
  if (!foundRecord && (!this._enableProductScanning || !this.productBarcodeInput?.value)) {
1430
- foundRecord = this._pickBestCandidate(candidates)
1469
+ foundRecord = this._pickBestCandidate(this._filterCandidatesByInputQty(candidates))
1431
1470
  }
1432
1471
 
1433
1472
  if (foundRecord) {
@@ -2486,7 +2525,9 @@ class PickingProduct extends connect(store)(localize(i18next)(PageView)) {
2486
2525
  _filterCandidatesByInputQty(candidates) {
2487
2526
  const typedQty = this._getTypedQty()
2488
2527
  if (!typedQty || !Array.isArray(candidates) || !candidates.length) return candidates
2489
- const matches = candidates.filter(rec => parseFloat(rec?.releaseQty) === typedQty)
2528
+ const matches = candidates.filter(
2529
+ rec => (parseFloat(rec?.releaseQty) - parseFloat(rec?.pickedQty ?? 0)) === typedQty
2530
+ )
2490
2531
  return matches.length ? matches : candidates
2491
2532
  }
2492
2533
 
@@ -2521,9 +2562,6 @@ class PickingProduct extends connect(store)(localize(i18next)(PageView)) {
2521
2562
  try {
2522
2563
  if (!gtin || !candidates?.length) return null
2523
2564
 
2524
- // Apply qty-based narrowing first when duplicates exist
2525
- candidates = this._filterCandidatesByInputQty(candidates)
2526
-
2527
2565
  // Use checkGtin to resolve product-packing info to match candidate rows
2528
2566
  const response = await client.query({
2529
2567
  query: gql`
@@ -2563,16 +2601,77 @@ class PickingProduct extends connect(store)(localize(i18next)(PageView)) {
2563
2601
  itm.packingSize === rec.packingSize
2564
2602
  )
2565
2603
  )
2566
- if (exactMatches.length) return this._pickBestCandidate(exactMatches)
2604
+ if (exactMatches.length) return await this._pickBestCandidateOrShowPopup(exactMatches)
2567
2605
 
2568
2606
  const productOnlyMatches = candidates.filter(rec => items.some(itm => itm.product?.id === rec?.product?.id))
2569
- if (productOnlyMatches.length) return this._pickBestCandidate(productOnlyMatches)
2607
+ if (productOnlyMatches.length) return await this._pickBestCandidateOrShowPopup(productOnlyMatches)
2570
2608
 
2571
2609
  return null
2572
2610
  } catch (e) {
2573
2611
  return null
2574
2612
  }
2575
2613
  }
2614
+
2615
+ _hasDuplicateSameProduct(candidates) {
2616
+ return this._getDuplicateSameProductRows(candidates).length > 0
2617
+ }
2618
+
2619
+ _getDuplicateSameProductRows(candidates) {
2620
+ if (!Array.isArray(candidates) || candidates.length <= 1) return []
2621
+
2622
+ // Group by cartonId + SKU, return only rows that belong to a duplicate group
2623
+ const groups = {}
2624
+ candidates.forEach(c => {
2625
+ const key = `${c?.cartonId || ''}|${c?.product?.sku || ''}`
2626
+ if (!groups[key]) groups[key] = []
2627
+ groups[key].push(c)
2628
+ })
2629
+
2630
+ const duplicateRows = []
2631
+ for (const rows of Object.values(groups)) {
2632
+ if (rows.length > 1) duplicateRows.push(...rows)
2633
+ }
2634
+ return duplicateRows
2635
+ }
2636
+
2637
+ _showPickingSameCartonPopup(candidates) {
2638
+ return new Promise(resolve => {
2639
+ openPopup(
2640
+ html`
2641
+ <picking-same-carton-popup
2642
+ .pickingInventories="${candidates}"
2643
+ @selection="${e => {
2644
+ resolve(e.detail)
2645
+ }}"
2646
+ ></picking-same-carton-popup>
2647
+ `,
2648
+ {
2649
+ backdrop: true,
2650
+ size: 'large',
2651
+ title: i18next.t('title.inventory_selection')
2652
+ }
2653
+ )
2654
+ })
2655
+ }
2656
+
2657
+ async _pickBestCandidateOrShowPopup(candidates) {
2658
+ if (!Array.isArray(candidates) || !candidates.length) return null
2659
+
2660
+ // If multiple candidates have the same cartonId + SKU, show popup for user to choose
2661
+ const duplicateRows = this._getDuplicateSameProductRows(candidates)
2662
+ if (duplicateRows.length) {
2663
+ const selectedRecord = await this._showPickingSameCartonPopup(duplicateRows)
2664
+ if (!selectedRecord) {
2665
+ // User cancelled the popup — signal callers to do nothing
2666
+ this._popupCancelled = true
2667
+ return null
2668
+ }
2669
+ return selectedRecord
2670
+ }
2671
+
2672
+ // Otherwise, use the existing logic to pick the best candidate
2673
+ return this._pickBestCandidate(candidates)
2674
+ }
2576
2675
  }
2577
2676
 
2578
2677
  window.customElements.define('picking-product', PickingProduct)
@@ -0,0 +1,217 @@
1
+ import '@things-factory/grist-ui'
2
+
3
+ import { css, html, LitElement } from 'lit-element'
4
+ import { connect } from 'pwa-helpers/connect-mixin'
5
+
6
+ import { SingleColumnFormStyles } from '@things-factory/form-ui'
7
+ import { i18next, localize } from '@things-factory/i18n-base'
8
+ import { store } from '@things-factory/shell'
9
+ import { isMobileDevice } from '@things-factory/utils'
10
+
11
+ class PickingSameCartonPopup extends connect(store)(localize(i18next)(LitElement)) {
12
+ static get properties() {
13
+ return {
14
+ config: Object,
15
+ data: Object,
16
+ pickingInventories: Array
17
+ }
18
+ }
19
+
20
+ constructor() {
21
+ super()
22
+ }
23
+
24
+ static get styles() {
25
+ return [
26
+ SingleColumnFormStyles,
27
+ css`
28
+ .button-container {
29
+ padding: 10px 0 12px 0;
30
+ text-align: center;
31
+ }
32
+ .button-container mwc-button {
33
+ margin: 0 5px;
34
+ }
35
+ data-grist {
36
+ overflow-y: hidden;
37
+ flex: 1;
38
+ }
39
+ .grist {
40
+ display: flex;
41
+ flex-direction: column;
42
+ flex: 1;
43
+ overflow-y: auto;
44
+ }
45
+ :host {
46
+ padding: 10px;
47
+ display: flex;
48
+ flex-direction: column;
49
+ overflow: hidden;
50
+ background-color: white;
51
+ }
52
+ .header {
53
+ padding: 10px;
54
+ font-weight: bold;
55
+ font-size: 14px;
56
+ color: var(--secondary-color);
57
+ border-bottom: 1px solid var(--secondary-color);
58
+ margin-bottom: 10px;
59
+ }
60
+ `
61
+ ]
62
+ }
63
+
64
+ get dataGrist() {
65
+ return this.shadowRoot.querySelector('data-grist')
66
+ }
67
+
68
+ updated(changeProps) {
69
+ if (changeProps.has('pickingInventories')) {
70
+ if (this.pickingInventories && this.pickingInventories.length) {
71
+ this.data = {
72
+ records: this.pickingInventories.map(inv => ({
73
+ ...inv,
74
+ locationName: inv.location?.name || ''
75
+ }))
76
+ }
77
+ }
78
+ }
79
+ }
80
+
81
+ render() {
82
+ return html`
83
+ <div class="header">
84
+ ${i18next.t('text.please_select_an_inventory')}
85
+ </div>
86
+
87
+ <div class="grist">
88
+ <data-grist
89
+ id="grist"
90
+ .mode=${isMobileDevice() ? 'LIST' : 'GRID'}
91
+ .config=${this.config}
92
+ .data="${this.data}"
93
+ ></data-grist>
94
+ </div>
95
+
96
+ <div class="button-container">
97
+ <mwc-button
98
+ raised
99
+ @click="${this._selectPickingInventory.bind(this)}"
100
+ label="${i18next.t('button.select')}"
101
+ ></mwc-button>
102
+ <mwc-button
103
+ @click="${() => {
104
+ this.dispatchEvent(new CustomEvent('selection', { detail: null }))
105
+ history.back()
106
+ }}"
107
+ label="${i18next.t('button.cancel')}"
108
+ ></mwc-button>
109
+ </div>
110
+ `
111
+ }
112
+
113
+ firstUpdated() {
114
+ this.config = {
115
+ pagination: { infinite: true },
116
+ rows: { selectable: { multiple: false }, appendable: false },
117
+ list: {
118
+ fields: [
119
+ 'locationName',
120
+ 'palletId',
121
+ 'batchId',
122
+ 'batchIdRef',
123
+ 'expirationDate',
124
+ 'qty',
125
+ 'releaseQty',
126
+ 'packingType'
127
+ ]
128
+ },
129
+ columns: [
130
+ { type: 'gutter', gutterName: 'sequence' },
131
+ { type: 'gutter', gutterName: 'row-selector', multiple: false, header: { renderer: () => '' } },
132
+ { name: 'name', hidden: true },
133
+ {
134
+ type: 'string',
135
+ name: 'locationName',
136
+ label: true,
137
+ header: i18next.t('field.location'),
138
+ width: 120
139
+ },
140
+ {
141
+ type: 'string',
142
+ name: 'palletId',
143
+ label: true,
144
+ header: i18next.t('field.pallet_id'),
145
+ width: 120
146
+ },
147
+ {
148
+ type: 'string',
149
+ name: 'batchId',
150
+ label: true,
151
+ header: i18next.t('field.batch_no'),
152
+ width: 100
153
+ },
154
+ {
155
+ type: 'string',
156
+ name: 'batchIdRef',
157
+ label: true,
158
+ header: i18next.t('field.lot_id'),
159
+ width: 100
160
+ },
161
+ {
162
+ type: 'date',
163
+ name: 'expirationDate',
164
+ label: true,
165
+ header: i18next.t('field.expiration'),
166
+ width: 100
167
+ },
168
+ {
169
+ type: 'float',
170
+ name: 'qty',
171
+ label: true,
172
+ header: i18next.t('field.available_qty'),
173
+ width: 80
174
+ },
175
+ {
176
+ type: 'float',
177
+ name: 'releaseQty',
178
+ label: true,
179
+ header: i18next.t('field.release_qty'),
180
+ width: 80
181
+ },
182
+ {
183
+ type: 'string',
184
+ name: 'packingType',
185
+ label: true,
186
+ header: i18next.t('field.packing_type'),
187
+ width: 100
188
+ }
189
+ ]
190
+ }
191
+ }
192
+
193
+ async _selectPickingInventory() {
194
+ try {
195
+ const selectedInventory = this.dataGrist.selected[0]
196
+ if (!selectedInventory) throw new Error(i18next.t('text.please_select_inventory'))
197
+
198
+ this.dispatchEvent(new CustomEvent('selection', { detail: selectedInventory }))
199
+ history.back()
200
+ } catch (e) {
201
+ this._showToast(e)
202
+ }
203
+ }
204
+
205
+ _showToast({ type, message }) {
206
+ document.dispatchEvent(
207
+ new CustomEvent('notify', {
208
+ detail: {
209
+ type,
210
+ message
211
+ }
212
+ })
213
+ )
214
+ }
215
+ }
216
+
217
+ window.customElements.define('picking-same-carton-popup', PickingSameCartonPopup)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/operato-wms",
3
- "version": "4.3.795",
3
+ "version": "4.3.797",
4
4
  "main": "dist-server/index.js",
5
5
  "browser": "client/index.js",
6
6
  "things-factory": true,
@@ -108,7 +108,7 @@
108
108
  "@things-factory/transport-base": "^4.3.767",
109
109
  "@things-factory/tutorial-ui": "^4.3.767",
110
110
  "@things-factory/warehouse-base": "^4.3.791",
111
- "@things-factory/worksheet-base": "^4.3.795"
111
+ "@things-factory/worksheet-base": "^4.3.797"
112
112
  },
113
113
  "devDependencies": {
114
114
  "@things-factory/builder": "^4.3.767",
@@ -117,5 +117,5 @@
117
117
  "cypress-localstorage-commands": "^1.6.1",
118
118
  "eslint-plugin-cypress": "^2.12.1"
119
119
  },
120
- "gitHead": "8159de8fb17815c04d55e515316e41bb226f1450"
120
+ "gitHead": "1acd354f47dfc15a5670841ddfb51e338f61d15f"
121
121
  }