diff --git a/.cursor/rules/frontend-prevent-duplicate-api-calls.mdc b/.cursor/rules/frontend-prevent-duplicate-api-calls.mdc new file mode 100644 index 0000000..c325f98 --- /dev/null +++ b/.cursor/rules/frontend-prevent-duplicate-api-calls.mdc @@ -0,0 +1,33 @@ +--- +description: Prevent double-click / duplicate API calls from frontend UI +alwaysApply: true +--- + +# Prevent duplicated API calls (frontend) + +When wiring UI actions (buttons, row actions, dialogs) to backend APIs, **always prevent double submission**. Relying on `setState` + `disabled` alone is not sufficient because rapid double-click can fire twice before React re-renders. + +- **Must**: add an **in-flight lock** (e.g. `useRef(false)`) and early-return if already running. +- **Must**: keep the UI disabled/loading (`disabled={isLoading}`) for user feedback. +- **Must**: clear the lock in `finally` so it always releases. +- **Should**: if the same endpoint can be triggered from multiple places, consider a shared “single-flight” helper (dedupe by `method+url+body` key). + +Example pattern: + +```tsx +const inFlightRef = useRef(false); +const [isSaving, setIsSaving] = useState(false); + +const onSave = async () => { + if (inFlightRef.current) return; + inFlightRef.current = true; + setIsSaving(true); + try { + await doRequest(); + } finally { + setIsSaving(false); + inFlightRef.current = false; + } +}; +``` + diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt index 118691d..52c2576 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt +++ b/src/main/java/com/ffii/fpsms/m18/service/M18DeliveryOrderService.kt @@ -12,6 +12,7 @@ import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderLineStatus import com.ffii.fpsms.modules.deliveryOrder.enums.DeliveryOrderStatus import com.ffii.fpsms.modules.deliveryOrder.service.DeliveryOrderLineService import com.ffii.fpsms.modules.deliveryOrder.service.DeliveryOrderService +import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderRepository import com.ffii.fpsms.modules.deliveryOrder.web.models.SaveDeliveryOrderLineRequest import com.ffii.fpsms.modules.deliveryOrder.web.models.SaveDeliveryOrderRequest import com.ffii.fpsms.modules.master.entity.ItemUom @@ -35,6 +36,7 @@ open class M18DeliveryOrderService( val apiCallerService: ApiCallerService, val m18DataLogService: M18DataLogService, val deliveryOrderService: DeliveryOrderService, + val deliveryOrderRepository: DeliveryOrderRepository, val deliveryOrderLineService: DeliveryOrderLineService, val itemsService: ItemsService, val shopService: ShopService, @@ -106,7 +108,6 @@ open class M18DeliveryOrderService( if (request.dDateEqual != null) { shopPoConds += "=and=(${dDateEqualConds})" } - logger.info("shopPoConds: ${shopPoConds}") val shopPoParams = M18PurchaseOrderListRequest( @@ -153,18 +154,35 @@ open class M18DeliveryOrderService( open fun saveDeliveryOrders(request: M18CommonRequest): SyncResult { val deliveryOrdersWithType = getDeliveryOrdersWithType(request) - return saveDeliveryOrdersWithPreparedList(deliveryOrdersWithType) + return saveDeliveryOrdersWithPreparedList(deliveryOrdersWithType, syncIsEtra = false) } /** * Sync a single M18 shop PO / delivery order by document [code], same search pattern as * [com.ffii.fpsms.m18.service.M18PurchaseOrderService.savePurchaseOrderByCode]. + * + * @param isEtraSync when true, persist local `delivery_order.isEtra=true` (manual DO(加單) sync). + * No M18-side "加單" filtering is used. + * @param newOnly when true, skip if a non-deleted local DO already exists with the same `code`. */ - open fun saveDeliveryOrderByCode(code: String): SyncResult { + open fun saveDeliveryOrderByCode( + code: String, + isEtraSync: Boolean = false, + newOnly: Boolean = false, + ): SyncResult { + if (newOnly && deliveryOrderRepository.existsByCodeAndDeletedIsFalse(code)) { + return SyncResult( + totalProcessed = 1, + totalSuccess = 0, + totalFail = 0, + query = "skipped (newOnly=true): delivery_order.code already exists: $code", + ) + } + val conds = "(code=equal=$code)" val searchRequest = M18PurchaseOrderListRequest( stSearch = "po", params = null, - conds = "(code=equal=$code)" + conds = conds ) val doListResponse = try { apiCallerService.get( @@ -183,20 +201,21 @@ open class M18DeliveryOrderService( totalProcessed = 1, totalSuccess = 0, totalFail = 1, - query = "code=equal=$code" + query = conds ) } val prepared = M18PurchaseOrderListResponseWithType( valuesWithType = mutableListOf(Pair(PurchaseOrderType.SHOP, doListResponse)), - query = "code=equal=$code" + query = conds ) - return saveDeliveryOrdersWithPreparedList(prepared) + return saveDeliveryOrdersWithPreparedList(prepared, syncIsEtra = isEtraSync) } private fun saveDeliveryOrdersWithPreparedList( - deliveryOrdersWithType: M18PurchaseOrderListResponseWithType? + deliveryOrdersWithType: M18PurchaseOrderListResponseWithType?, + syncIsEtra: Boolean = false, ): SyncResult { logger.info("--------------------------------------------Start - Saving M18 Delivery Order--------------------------------------------") @@ -283,7 +302,8 @@ open class M18DeliveryOrderService( m18DataLogId = saveM18DeliveryOrderLog.id, handlerId = null, m18BeId = mainpo.beId, - deleted = mainpo.udfIsVoid == true + deleted = mainpo.udfIsVoid == true, + isEtra = syncIsEtra, ) val saveDeliveryOrderResponse = diff --git a/src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt b/src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt index 44922b4..5fbafb9 100644 --- a/src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt +++ b/src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt @@ -72,7 +72,14 @@ class M18TestController ( @GetMapping("/test/do-by-code") fun testSyncDoByCode(@RequestParam code: String): SyncResult { - return m18DeliveryOrderService.saveDeliveryOrderByCode(code) + return m18DeliveryOrderService.saveDeliveryOrderByCode(code, isEtraSync = false) + } + + /** DO(加單):手動按 code 同步,並寫入本地 [DeliveryOrder.isEtra]=true(不做 M18 端加單條件過濾) */ + @GetMapping("/test/do-by-code-extra") + fun testSyncDoByCodeExtra(@RequestParam code: String): SyncResult { + // 加單 tab: only sync when it's a NEW order (not existing in local system) + return m18DeliveryOrderService.saveDeliveryOrderByCode(code, isEtraSync = true, newOnly = true) } @GetMapping("/test/product-by-code") diff --git a/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt b/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt index fe3a746..ba3b879 100644 --- a/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt +++ b/src/main/java/com/ffii/fpsms/modules/common/scheduler/service/SchedulerService.kt @@ -491,6 +491,30 @@ open class SchedulerService( result = result, start = currentTime ) + + // Extra DO sync window: after DO2, also sync ETA = today or tomorrow (normal sync; does NOT set isEtra). + try { + val extraStart = LocalDateTime.now() + val requestExtra = M18CommonRequest( + dDateFrom = today.format(dateTimeStringFormat), + dDateTo = tmr.format(dateTimeStringFormat), + ) + val extraResult = m18DeliveryOrderService.saveDeliveryOrders(requestExtra) + saveSyncLog( + type = "DO2_EXTRA", + status = "SUCCESS", + result = extraResult, + start = extraStart, + ) + } catch (e: Exception) { + logger.error("DO2_EXTRA sync failed: ${e.message}", e) + saveSyncLog( + type = "DO2_EXTRA", + status = "FAIL", + error = e.message, + start = LocalDateTime.now(), + ) + } } open fun getPostCompletedDnAndProcessGrn( diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrder.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrder.kt index 44ecd51..d9dc6f2 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrder.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrder.kt @@ -62,4 +62,8 @@ open class DeliveryOrder: BaseEntity() { @Column(name = "m18BeId") open var m18BeId: Long? = null + + /** 加單:由 M18「加單」專用同步標記;一般 DO 為 false */ + @Column(name = "isEtra", nullable = false) + open var isEtra: Boolean = false } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderRepository.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderRepository.kt index 7a54aab..51c9260 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DeliveryOrderRepository.kt @@ -15,6 +15,8 @@ import com.ffii.fpsms.modules.deliveryOrder.web.models.* import com.ffii.fpsms.modules.deliveryOrder.entity.models.* @Repository interface DeliveryOrderRepository : AbstractRepository { + fun existsByCodeAndDeletedIsFalse(code: String): Boolean + @Query(""" select d from DeliveryOrder d where d.deleted = false @@ -109,6 +111,7 @@ fun searchDoLite( and (:status is null or d.status = :status) and (:etaStart is null or d.estimatedArrivalDate >= :etaStart) and (:etaEnd is null or d.estimatedArrivalDate < :etaEnd) + and (:isEtra is null or d.isEtra = :isEtra) order by d.id desc """) fun searchDoLitePage( @@ -117,6 +120,7 @@ fun searchDoLitePage( @Param("status") status: DeliveryOrderStatus?, @Param("etaStart") etaStart: LocalDateTime?, @Param("etaEnd") etaEnd: LocalDateTime?, + @Param("isEtra") isEtra: Boolean?, pageable: Pageable ): Page @@ -132,6 +136,7 @@ fun searchDoLitePage( and (:status is null or d.status = :status) and (:etaStart is null or d.estimatedArrivalDate >= :etaStart) and (:etaEnd is null or d.estimatedArrivalDate < :etaEnd) + and (:isEtra is null or d.isEtra = :isEtra) and d.supplier is not null and d.supplier.code in :allowedSupplierCodes order by d.id desc @@ -143,6 +148,7 @@ fun searchDoLitePageWithSupplierCodes( @Param("status") status: DeliveryOrderStatus?, @Param("etaStart") etaStart: LocalDateTime?, @Param("etaEnd") etaEnd: LocalDateTime?, + @Param("isEtra") isEtra: Boolean?, @Param("allowedSupplierCodes") allowedSupplierCodes: List, pageable: Pageable, ): Page diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/models/DeliveryOrderInfo.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/models/DeliveryOrderInfo.kt index 1ff21e8..f806cb0 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/models/DeliveryOrderInfo.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/models/DeliveryOrderInfo.kt @@ -47,6 +47,9 @@ interface DeliveryOrderInfoLite { val supplierCode: String? @get:Value("#{target.shop?.addr3}") val shopAddress: String? + + @get:Value("#{target.isEtra}") + val isEtra: Boolean } data class DeliveryOrderInfoLiteDto( val id: Long, @@ -57,5 +60,6 @@ data class DeliveryOrderInfoLiteDto( val shopName: String?, val supplierName: String?, val shopAddress: String?, - val truckLanceCode: String? + val truckLanceCode: String?, + val isEtra: Boolean = false, ) \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt index 13b9a50..3f7ef62 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt @@ -147,6 +147,7 @@ open class DeliveryOrderService( pageSize: Int?, truckLanceCode: String?, floor: String? = null, + isEtra: Boolean? = null, ): RecordsRes { val page = (pageNum ?: 1) - 1 @@ -168,6 +169,7 @@ open class DeliveryOrderService( status = statusEnum, etaStart = etaStart, etaEnd = etaEnd, + isEtra = isEtra, allowedSupplierCodes = allowedForFloor, pageable = PageRequest.of(0, 100_000), ) @@ -245,7 +247,8 @@ open class DeliveryOrderService( shopName = info.shopName, supplierName = info.supplierName, shopAddress = info.shopAddress, - truckLanceCode = calculatedTruckLanceCode + truckLanceCode = calculatedTruckLanceCode, + isEtra = deliveryOrdersMap[info.id]?.isEtra ?: info.isEtra, ) }.filter { dto -> val dtoTruckLanceCode = dto.truckLanceCode?.lowercase() ?: "" @@ -276,6 +279,7 @@ open class DeliveryOrderService( status = statusEnum, etaStart = etaStart, etaEnd = etaEnd, + isEtra = isEtra, allowedSupplierCodes = allowedSupplierCodes, pageable = PageRequest.of(page.coerceAtLeast(0), size), ) @@ -311,6 +315,7 @@ open class DeliveryOrderService( supplierName = info.supplierName, shopAddress = info.shopAddress, truckLanceCode = calculatedTruckLanceCode, + isEtra = deliveryOrder?.isEtra ?: info.isEtra, ) } @@ -333,6 +338,7 @@ open class DeliveryOrderService( pageSize: Int?, truckLanceCode: String?, floor: String? = null, + isEtra: Boolean? = null, ): RecordsRes { val mode = TruckLaneSearchSpec.parse(truckLanceCode) if (mode is TruckLaneSearchSpec.Mode.NoFilter) { @@ -345,6 +351,7 @@ open class DeliveryOrderService( pageSize, null, floor, + isEtra, ) } val pageIdx = (pageNum ?: 1).coerceAtLeast(1) - 1 @@ -360,6 +367,7 @@ open class DeliveryOrderService( statusEnum = statusEnum, etaStart = etaStart, etaEnd = etaEnd, + isEtra = isEtra, allowedSupplierCodes = allowedSupplierCodesForFloor(floor), lanePredicate = lanePredicate, ) @@ -383,6 +391,7 @@ open class DeliveryOrderService( pageNum: Int?, pageSize: Int?, floor: String? = null, + isEtra: Boolean? = null, ): RecordsRes { val page = (pageNum ?: 1) - 1 val size = pageSize ?: 10 @@ -397,6 +406,7 @@ open class DeliveryOrderService( status = statusEnum, etaStart = etaStart, etaEnd = etaEnd, + isEtra = isEtra, allowedSupplierCodes = allowedSupplierCodes, pageable = PageRequest.of(0, 100_000), ) @@ -435,6 +445,7 @@ open class DeliveryOrderService( supplierName = info.supplierName, shopAddress = info.shopAddress, truckLanceCode = calculatedTruckLanceCode, + isEtra = deliveryOrdersMap[info.id]?.isEtra ?: info.isEtra, ) }.filter { dto -> TruckLaneSearchSpec.isUnassignedResolvedLane(dto.truckLanceCode) } @@ -476,6 +487,7 @@ open class DeliveryOrderService( estimatedArrivalDate = deliveryOrder.estimatedArrivalDate, completeDate = deliveryOrder.completeDate, status = deliveryOrder.status?.value, + isEtra = deliveryOrder.isEtra, deliveryOrderLines = deliveryOrder.deliveryOrderLines.map { line -> DoDetailLineResponse( id = line.id!!, @@ -796,6 +808,7 @@ open class DeliveryOrderService( this.handler = handler m18BeId = request.m18BeId this.deleted = request.deleted + isEtra = request.isEtra ?: false } val savedDeliveryOrder = deliveryOrderRepository.saveAndFlush(deliveryOrder).let { @@ -2091,6 +2104,7 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } statusEnum: DeliveryOrderStatus?, etaStart: LocalDateTime?, etaEnd: LocalDateTime?, + isEtra: Boolean?, allowedSupplierCodes: List, lanePredicate: (String?) -> Boolean, ): List { @@ -2104,6 +2118,7 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } status = statusEnum, etaStart = etaStart, etaEnd = etaEnd, + isEtra = isEtra, allowedSupplierCodes = allowedSupplierCodes, pageable = PageRequest.of(dbPage, 500), ) @@ -2179,6 +2194,7 @@ val inventoryLotLine = illId?.let { inventoryLotLineMap[it] } supplierName = info.supplierName, shopAddress = info.shopAddress, truckLanceCode = calculatedTruckLanceCode, + isEtra = deliveryOrder?.isEtra ?: info.isEtra, ) } } diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DeliveryOrderController.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DeliveryOrderController.kt index acba85b..8836198 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DeliveryOrderController.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/DeliveryOrderController.kt @@ -72,6 +72,7 @@ class DeliveryOrderController( pageSize = request.pageSize, truckLanceCode = request.truckLanceCode, floor = request.floor, + isEtra = request.isEtra, ) } @@ -88,6 +89,7 @@ class DeliveryOrderController( pageNum = request.pageNum, pageSize = request.pageSize, floor = request.floor, + isEtra = request.isEtra, ) } @@ -106,6 +108,7 @@ class DeliveryOrderController( pageSize = request.pageSize, truckLanceCode = request.truckLanceCode, floor = request.floor, + isEtra = request.isEtra, ) } diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt index 768e3f1..4643119 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoDetailResponse.kt @@ -18,6 +18,8 @@ data class DoDetailResponse( @JsonFormat(pattern = "yyyy-MM-dd") val completeDate: LocalDateTime?, val status: String?, + /** 加單 DO(M18 加單專用同步) */ + val isEtra: Boolean = false, val deliveryOrderLines: List ) diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/ReleaseDoRequest.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/ReleaseDoRequest.kt index 478e123..8ecd928 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/ReleaseDoRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/ReleaseDoRequest.kt @@ -33,4 +33,6 @@ data class SearchDeliveryOrderInfoRequest( val truckLanceCode: String?, /** `ALL`/`All`/null:P06B+P07+P06D;`2F`:P07+P06D;`4F`:P06B。車線-X 亦依供應商歸屬出現在對應樓層。 */ val floor: String? = null, + /** null:不篩 isEtra;true/false:只顯示加單或非加單 DO */ + val isEtra: Boolean? = null, ) diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/SaveDeliveryOrderRequest.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/SaveDeliveryOrderRequest.kt index c119531..bc89a77 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/SaveDeliveryOrderRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/SaveDeliveryOrderRequest.kt @@ -20,6 +20,7 @@ data class SaveDeliveryOrderRequest( val handlerId: Long?, val m18BeId: Long?, val deleted: Boolean? = false, + val isEtra: Boolean? = false, ) data class SaveDeliveryOrderStatusRequest( diff --git a/src/main/resources/db/changelog/changes/20260508_01_fai/01_delivery_order_is_etra.sql b/src/main/resources/db/changelog/changes/20260508_01_fai/01_delivery_order_is_etra.sql new file mode 100644 index 0000000..c91d20c --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260508_01_fai/01_delivery_order_is_etra.sql @@ -0,0 +1,5 @@ +-- liquibase formatted sql +-- changeset fpsms:20260508_delivery_order_is_etra + +ALTER TABLE `delivery_order` + ADD COLUMN `isEtra` TINYINT(1) NOT NULL DEFAULT 0 AFTER `m18BeId`;