From b705a8acb6b5325e185e5bc78acc4155cc5d8a9f Mon Sep 17 00:00:00 2001 From: "kelvin.yau" Date: Sat, 13 Jun 2026 01:26:46 +0800 Subject: [PATCH] replenishment update --- .../deliveryOrder/entity/DoReplenishment.kt | 4 + .../entity/DoReplenishmentRepository.kt | 13 ++- .../service/DeliveryOrderService.kt | 11 ++ .../service/DoReplenishmentService.kt | 110 ++++++++++++++++++ .../web/models/DoDetailResponse.kt | 2 + .../web/models/DoReplenishmentModels.kt | 5 + .../02_add_do_replenishment_reason.sql | 17 +++ 7 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 src/main/resources/db/changelog/changes/20260611_KelvinY/02_add_do_replenishment_reason.sql diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoReplenishment.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoReplenishment.kt index 04fe50c..f606589 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoReplenishment.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoReplenishment.kt @@ -94,6 +94,10 @@ open class DoReplenishment : BaseEntity() { @Column(name = "status", nullable = false, length = 20) open var status: String = STATUS_PENDING + @Size(max = 500) + @Column(name = "reason", length = 500) + open var reason: String? = null + companion object { const val STATUS_PENDING = "pending" const val STATUS_PROCESSING = "processing" diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoReplenishmentRepository.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoReplenishmentRepository.kt index 33c178d..5b7492a 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoReplenishmentRepository.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/entity/DoReplenishmentRepository.kt @@ -28,8 +28,19 @@ interface DoReplenishmentRepository : AbstractRepository @Query( """ SELECT r FROM DoReplenishment r + LEFT JOIN DeliveryOrder targetDo ON targetDo.id = r.targetDoId AND targetDo.deleted = false WHERE r.deleted = false - AND (:deliveryDate IS NULL OR r.deliveryDate = :deliveryDate) + AND ( + :deliveryDate IS NULL + OR ( + targetDo.id IS NOT NULL + AND DATE(targetDo.estimatedArrivalDate) = :deliveryDate + ) + OR ( + targetDo.id IS NULL + AND r.deliveryDate = :deliveryDate + ) + ) AND (:status IS NULL OR r.status = :status) ORDER BY r.created DESC, r.id DESC """, 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 f26fccc..65fe0ee 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 @@ -591,6 +591,16 @@ open class DeliveryOrderService( val stockQtyByItemId = itemIds.associateWith { itemId -> computeAvailableStockQtyForItem(itemId) } + val fallbackQtyByItemId = deliveryOrder.deliveryOrderLines.mapNotNull { line -> + val itemId = line.item?.id ?: return@mapNotNull null + itemId to (line.qty ?: BigDecimal.ZERO) + }.toMap() + val actualShippedQtyByItemId = try { + doReplenishmentService.resolveActualShippedQtyForDeliveryOrder(id, fallbackQtyByItemId) + } catch (ex: Exception) { + log.warn("Failed to resolve actual shipped qty for delivery order {}: {}", id, ex.message) + fallbackQtyByItemId + } return DoDetailResponse( id = deliveryOrder.id!!, @@ -617,6 +627,7 @@ open class DeliveryOrderService( id = line.id!!, itemNo = line.itemNo, qty = line.qty, + actualShippedQty = itemId?.let { actualShippedQtyByItemId[it] }, price = line.price, status = line.status?.value, itemName = line.item?.name, diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReplenishmentService.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReplenishmentService.kt index f0431d8..2cc20c8 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReplenishmentService.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoReplenishmentService.kt @@ -1,6 +1,7 @@ package com.ffii.fpsms.modules.deliveryOrder.service import com.ffii.core.support.JdbcDao +import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderLineRepository import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderRecordRepository import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderRepository import com.ffii.fpsms.modules.deliveryOrder.entity.DoReplenishment @@ -18,6 +19,7 @@ import com.ffii.fpsms.modules.master.entity.UomConversionRepository import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLine import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLineRepository import com.ffii.fpsms.modules.pickOrder.entity.PickOrderRepository +import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository import com.ffii.fpsms.modules.pickOrder.enums.PickOrderLineStatus import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -31,7 +33,9 @@ open class DoReplenishmentService( private val deliveryOrderRepository: DeliveryOrderRepository, private val deliveryOrderLineRepository: DeliveryOrderLineRepository, private val doPickOrderRepository: DoPickOrderRepository, + private val doPickOrderLineRepository: DoPickOrderLineRepository, private val doPickOrderRecordRepository: DoPickOrderRecordRepository, + private val stockOutLIneRepository: StockOutLIneRepository, private val uomConversionRepository: UomConversionRepository, private val pickOrderRepository: PickOrderRepository, private val pickOrderLineRepository: PickOrderLineRepository, @@ -72,6 +76,7 @@ open class DoReplenishmentService( existingPending.truckLaneCode = resolveSourceDoTruckLaneCode(deliveryOrder, lineReq.truckLaneCode) } + lineReq.reason?.trim()?.takeIf { it.isNotEmpty() }?.let { existingPending.reason = it } created += doReplenishmentRepository.save(existingPending) continue } @@ -108,6 +113,7 @@ open class DoReplenishmentService( shopName = shop?.name truckLaneCode = resolveSourceDoTruckLaneCode(deliveryOrder, lineReq.truckLaneCode) status = DoReplenishment.STATUS_PENDING + reason = lineReq.reason?.trim()?.takeIf { it.isNotEmpty() } } created += doReplenishmentRepository.save(entity) } @@ -382,6 +388,7 @@ open class DoReplenishmentService( merged[key] = existing.copy( replenishQty = existing.replenishQty.add(line.replenishQty), truckLaneCode = existing.truckLaneCode?.takeIf { it.isNotBlank() } ?: line.truckLaneCode, + reason = existing.reason?.takeIf { it.isNotBlank() } ?: line.reason, ) } } @@ -408,6 +415,77 @@ open class DoReplenishmentService( return requestTruckLaneCode?.trim()?.takeIf { it.isNotEmpty() } } + /** + * Actual shipped qty per item on a completed source DO: sum of [stock_out_line.qty] + * for the linked pick order line. Falls back to [fallbackQtyByItemId] when no pick link exists + * (same rule as delivery note PDF). + */ + open fun resolveActualShippedQtyForDeliveryOrder( + doId: Long, + fallbackQtyByItemId: Map = emptyMap(), + ): Map { + val keys = fallbackQtyByItemId.keys.map { doId to it } + if (keys.isEmpty()) { + return emptyMap() + } + return resolveActualShippedQtyBySourceKeys(keys, keys.associateWith { fallbackQtyByItemId[it.second]!! }) + .mapKeys { it.key.second } + } + + private fun resolveActualShippedQtyBySourceKeys( + keys: List>, + fallbackQtyByKey: Map, BigDecimal>, + ): Map, BigDecimal> { + if (keys.isEmpty()) { + return emptyMap() + } + + val doIds = keys.map { it.first }.distinct() + val pickOrderIdByDoId = doIds.mapNotNull { doId -> + resolvePickOrderIdForDo(doId)?.let { doId to it } + }.toMap() + + val pickOrderIds = pickOrderIdByDoId.values.distinct() + val pickOrderLines = if (pickOrderIds.isEmpty()) { + emptyList() + } else { + pickOrderLineRepository.findAllByPickOrderIdInAndDeletedFalse(pickOrderIds) + } + val pickOrderLinesByPoId = pickOrderLines.groupBy { it.pickOrder?.id } + + val polIds = pickOrderLines.mapNotNull { it.id } + val stockOutQtyByPolId = if (polIds.isEmpty()) { + emptyMap() + } else { + stockOutLIneRepository.findAllByPickOrderLineIdInAndDeletedFalse(polIds) + .groupBy { it.pickOrderLine?.id } + .mapValues { (_, lines) -> + lines.fold(BigDecimal.ZERO) { acc, line -> + acc.add(BigDecimal.valueOf(line.qty ?: 0.0)) + } + } + } + + return keys.associateWith { (doId, itemId) -> + val pickOrderId = pickOrderIdByDoId[doId] + val polId = pickOrderId?.let { poId -> + pickOrderLinesByPoId[poId]?.firstOrNull { it.item?.id == itemId }?.id + } + if (polId != null) { + stockOutQtyByPolId[polId] ?: BigDecimal.ZERO + } else { + fallbackQtyByKey[doId to itemId] ?: BigDecimal.ZERO + } + } + } + + private fun resolvePickOrderIdForDo(doId: Long): Long? { + pickOrderRepository.findByDeliveryOrderId(doId).firstOrNull()?.id?.let { return it } + return doPickOrderLineRepository.findByDoOrderIdAndDeletedFalse(doId) + .mapNotNull { it.pickOrderId } + .firstOrNull() + } + private fun toResponses(entities: List): List { val uomIds = entities.mapNotNull { it.uomId }.distinct() val shortUomById = if (uomIds.isEmpty()) { @@ -418,6 +496,35 @@ open class DoReplenishmentService( } } + val sourceLineIds = entities.mapNotNull { it.sourceDoLineId }.distinct() + val sourceLineQtyById = if (sourceLineIds.isEmpty()) { + emptyMap() + } else { + deliveryOrderLineRepository.findAllById(sourceLineIds).associate { line -> + line.id!! to line.qty + } + } + + val targetDoIds = entities.mapNotNull { it.targetDoId }.distinct() + val targetDoEtaById = if (targetDoIds.isEmpty()) { + emptyMap() + } else { + deliveryOrderRepository.findAllById(targetDoIds).associate { deliveryOrder -> + deliveryOrder.id!! to deliveryOrder.estimatedArrivalDate?.toLocalDate() + } + } + + val sourceKeys = entities.map { it.sourceDoId!! to it.itemId!! }.distinct() + val fallbackQtyBySourceKey = entities.associate { row -> + (row.sourceDoId!! to row.itemId!!) to ( + row.sourceDoLineId?.let { sourceLineQtyById[it] } ?: BigDecimal.ZERO + ) + } + val actualShippedQtyBySourceKey = resolveActualShippedQtyBySourceKeys( + keys = sourceKeys, + fallbackQtyByKey = fallbackQtyBySourceKey, + ) + return entities.map { row -> DoReplenishmentResponse( id = row.id!!, @@ -429,6 +536,7 @@ open class DoReplenishmentService( itemId = row.itemId!!, itemNo = row.itemNo, itemName = row.itemName, + originalQty = actualShippedQtyBySourceKey[row.sourceDoId!! to row.itemId!!], replenishQty = row.replenishQty!!, shortUom = row.uomId?.let { shortUomById[it] }, shopCode = row.shopCode, @@ -436,9 +544,11 @@ open class DoReplenishmentService( truckLaneCode = row.truckLaneCode, targetDoId = row.targetDoId, targetDoCode = row.targetDoCode, + targetDoEstimatedArrivalDate = row.targetDoId?.let { targetDoEtaById[it] }, pickOrderLineId = row.pickOrderLineId, deliveryOrderPickOrderId = row.deliveryOrderPickOrderId, status = row.status, + reason = row.reason, created = row.created, ) } 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 acb1cf6..6a82ef9 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 @@ -31,6 +31,8 @@ data class DoDetailLineResponse( val id: Long, val itemNo: String?, val qty: java.math.BigDecimal?, + /** Sum of stock_out_line.qty for the linked pick order line; falls back to [qty] when unavailable. */ + val actualShippedQty: java.math.BigDecimal?, val price: java.math.BigDecimal?, val status: String?, val itemName: String?, diff --git a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoReplenishmentModels.kt b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoReplenishmentModels.kt index 052aa77..a432a7a 100644 --- a/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoReplenishmentModels.kt +++ b/src/main/java/com/ffii/fpsms/modules/deliveryOrder/web/models/DoReplenishmentModels.kt @@ -21,6 +21,7 @@ data class SubmitDoReplenishmentLineRequest( @field:Positive val replenishQty: BigDecimal, val truckLaneCode: String? = null, + val reason: String? = null, ) data class SubmitDoReplenishmentRequest( @@ -40,6 +41,7 @@ data class DoReplenishmentResponse( val itemId: Long, val itemNo: String?, val itemName: String?, + val originalQty: BigDecimal?, val replenishQty: BigDecimal, val shortUom: String?, val shopCode: String?, @@ -47,9 +49,12 @@ data class DoReplenishmentResponse( val truckLaneCode: String?, val targetDoId: Long?, val targetDoCode: String?, + @JsonFormat(pattern = "yyyy-MM-dd") + val targetDoEstimatedArrivalDate: LocalDate?, val pickOrderLineId: Long?, val deliveryOrderPickOrderId: Long?, val status: String, + val reason: String?, @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") val created: LocalDateTime?, ) diff --git a/src/main/resources/db/changelog/changes/20260611_KelvinY/02_add_do_replenishment_reason.sql b/src/main/resources/db/changelog/changes/20260611_KelvinY/02_add_do_replenishment_reason.sql new file mode 100644 index 0000000..d547266 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260611_KelvinY/02_add_do_replenishment_reason.sql @@ -0,0 +1,17 @@ +-- liquibase formatted sql +-- changeset KelvinY:20260611_02_add_do_replenishment_reason +-- preconditions onFail:MARK_RAN +-- precondition-sql-check expectedResult:1 SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = 'do_replenishment' + +SET @col_dr_reason := ( + SELECT COUNT(*) FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'do_replenishment' AND column_name = 'reason' +); +SET @sql_dr_reason := IF( + @col_dr_reason = 0, + 'ALTER TABLE `do_replenishment` ADD COLUMN `reason` VARCHAR(500) NULL DEFAULT NULL AFTER `status`', + 'SELECT 1' +); +PREPARE stmt_dr_reason FROM @sql_dr_reason; +EXECUTE stmt_dr_reason; +DEALLOCATE PREPARE stmt_dr_reason;