| @@ -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; | |||
| } | |||
| }; | |||
| ``` | |||
| @@ -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<M18PurchaseOrderListResponse, M18PurchaseOrderListRequest>( | |||
| @@ -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 = | |||
| @@ -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") | |||
| @@ -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( | |||
| @@ -62,4 +62,8 @@ open class DeliveryOrder: BaseEntity<Long>() { | |||
| @Column(name = "m18BeId") | |||
| open var m18BeId: Long? = null | |||
| /** 加單:由 M18「加單」專用同步標記;一般 DO 為 false */ | |||
| @Column(name = "isEtra", nullable = false) | |||
| open var isEtra: Boolean = false | |||
| } | |||
| @@ -15,6 +15,8 @@ import com.ffii.fpsms.modules.deliveryOrder.web.models.* | |||
| import com.ffii.fpsms.modules.deliveryOrder.entity.models.* | |||
| @Repository | |||
| interface DeliveryOrderRepository : AbstractRepository<DeliveryOrder, Long> { | |||
| 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<DeliveryOrderInfoLite> | |||
| @@ -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<String>, | |||
| pageable: Pageable, | |||
| ): Page<DeliveryOrderInfoLite> | |||
| @@ -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, | |||
| ) | |||
| @@ -147,6 +147,7 @@ open class DeliveryOrderService( | |||
| pageSize: Int?, | |||
| truckLanceCode: String?, | |||
| floor: String? = null, | |||
| isEtra: Boolean? = null, | |||
| ): RecordsRes<DeliveryOrderInfoLiteDto> { | |||
| 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<DeliveryOrderInfoLiteDto> { | |||
| 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<DeliveryOrderInfoLiteDto> { | |||
| 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<String>, | |||
| lanePredicate: (String?) -> Boolean, | |||
| ): List<DeliveryOrderInfoLiteDto> { | |||
| @@ -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, | |||
| ) | |||
| } | |||
| } | |||
| @@ -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, | |||
| ) | |||
| } | |||
| @@ -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<DoDetailLineResponse> | |||
| ) | |||
| @@ -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, | |||
| ) | |||
| @@ -20,6 +20,7 @@ data class SaveDeliveryOrderRequest( | |||
| val handlerId: Long?, | |||
| val m18BeId: Long?, | |||
| val deleted: Boolean? = false, | |||
| val isEtra: Boolean? = false, | |||
| ) | |||
| data class SaveDeliveryOrderStatusRequest( | |||
| @@ -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`; | |||