| @@ -61,6 +61,10 @@ class DeliveryOrderPickOrder { | |||||
| @Column(name = "cartonQty") | @Column(name = "cartonQty") | ||||
| var cartonQty: Int? = null | var cartonQty: Int? = null | ||||
| /** Merge lineage: equals own [id] until soft-deleted into a successor [TI-M] header. */ | |||||
| @Column(name = "relationshipId") | |||||
| var relationshipId: Long? = null | |||||
| @CreationTimestamp | @CreationTimestamp | ||||
| @Column(name = "created") | @Column(name = "created") | ||||
| var created: LocalDateTime? = null | var created: LocalDateTime? = null | ||||
| @@ -49,6 +49,10 @@ object WorkbenchReleaseTypeSupport { | |||||
| else -> BATCH | else -> BATCH | ||||
| } | } | ||||
| /** [TI-M] merged workbench ticket release type (batch-family merge). */ | |||||
| fun mergeTicketReleaseType(isSingleRelease: Boolean): String = | |||||
| if (isSingleRelease) IS_EXTRA_SINGLE else IS_EXTRA_BATCH | |||||
| fun upgradedReleaseTypeIfNeeded(currentType: String?, isExtraRelease: Boolean, isSingleRelease: Boolean): String? { | fun upgradedReleaseTypeIfNeeded(currentType: String?, isExtraRelease: Boolean, isSingleRelease: Boolean): String? { | ||||
| if (!isExtraRelease) return null | if (!isExtraRelease) return null | ||||
| val cur = currentType?.trim()?.lowercase().orEmpty() | val cur = currentType?.trim()?.lowercase().orEmpty() | ||||
| @@ -178,10 +178,14 @@ class DoWorkbenchController( | |||||
| */ | */ | ||||
| @PostMapping("/batch-release/async-v2") | @PostMapping("/batch-release/async-v2") | ||||
| fun startWorkbenchBatchReleaseAsyncV2( | fun startWorkbenchBatchReleaseAsyncV2( | ||||
| @RequestBody ids: List<Long>, | |||||
| @RequestBody request: WorkbenchBatchReleaseRequest, | |||||
| @RequestParam(defaultValue = "1") userId: Long | @RequestParam(defaultValue = "1") userId: Long | ||||
| ): MessageResponse { | ): MessageResponse { | ||||
| return doWorkbenchReleaseService.startBatchReleaseAsyncV2(ids, userId) | |||||
| return doWorkbenchReleaseService.startBatchReleaseAsyncV2( | |||||
| request.ids, | |||||
| userId, | |||||
| request.mergeExtraIntoLaneTicket, | |||||
| ) | |||||
| } | } | ||||
| /** | /** | ||||
| @@ -200,10 +204,14 @@ class DoWorkbenchController( | |||||
| /** Synchronous batch release V2 (same semantics as async-v2; for tools / tests). */ | /** Synchronous batch release V2 (same semantics as async-v2; for tools / tests). */ | ||||
| @PostMapping("/batch-release/sync-v2") | @PostMapping("/batch-release/sync-v2") | ||||
| fun workbenchBatchReleaseSyncV2( | fun workbenchBatchReleaseSyncV2( | ||||
| @RequestBody ids: List<Long>, | |||||
| @RequestBody request: WorkbenchBatchReleaseRequest, | |||||
| @RequestParam(defaultValue = "1") userId: Long | @RequestParam(defaultValue = "1") userId: Long | ||||
| ): MessageResponse { | ): MessageResponse { | ||||
| return doWorkbenchReleaseService.releaseBatchV2(ids, userId) | |||||
| return doWorkbenchReleaseService.releaseBatchV2( | |||||
| request.ids, | |||||
| userId, | |||||
| request.mergeExtraIntoLaneTicket, | |||||
| ) | |||||
| } | } | ||||
| @GetMapping("/batch-release/progress/{jobId}") | @GetMapping("/batch-release/progress/{jobId}") | ||||
| @@ -211,6 +219,22 @@ class DoWorkbenchController( | |||||
| return doWorkbenchReleaseService.getBatchReleaseProgress(jobId) | return doWorkbenchReleaseService.getBatchReleaseProgress(jobId) | ||||
| } | } | ||||
| /** Case 3: unassigned plain batch/single + isExtra tickets on the same lane (for merge UI). */ | |||||
| @GetMapping("/merge-ticket-candidates") | |||||
| fun getWorkbenchMergeTicketCandidates( | |||||
| @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) requiredDate: LocalDate, | |||||
| @RequestParam(required = false) shopSearch: String?, | |||||
| ): WorkbenchMergeTicketCandidatesResponse = | |||||
| doWorkbenchReleaseService.getMergeTicketCandidates(requiredDate, shopSearch) | |||||
| /** Case 3: merge selected batch/single + isExtra into a new [TI-M] ticket. */ | |||||
| @PostMapping("/merge-tickets") | |||||
| fun mergeWorkbenchTickets(@RequestBody request: WorkbenchMergeTicketsRequest): MessageResponse = | |||||
| doWorkbenchReleaseService.mergeTicketsCase3( | |||||
| request.batchOrSingleDopoId, | |||||
| request.isExtraDopoId, | |||||
| ) | |||||
| @GetMapping("/ticket-release-table/{startDate}&{endDate}") | @GetMapping("/ticket-release-table/{startDate}&{endDate}") | ||||
| fun getWorkbenchTicketReleaseTable( | fun getWorkbenchTicketReleaseTable( | ||||
| @PathVariable startDate: LocalDate, | @PathVariable startDate: LocalDate, | ||||
| @@ -0,0 +1,11 @@ | |||||
| package com.ffii.fpsms.modules.deliveryOrder.web.models | |||||
| /** | |||||
| * Workbench batch release body (async-v2 / sync-v2). | |||||
| * [mergeExtraIntoLaneTicket]: when true, isExtra DOs join batch/single family (isExtrabatch / isExtrasingle); | |||||
| * when false, standalone `releaseType=isExtra` tickets with `TI-E-` prefix. | |||||
| */ | |||||
| data class WorkbenchBatchReleaseRequest( | |||||
| val ids: List<Long> = emptyList(), | |||||
| val mergeExtraIntoLaneTicket: Boolean = true, | |||||
| ) | |||||
| @@ -0,0 +1,35 @@ | |||||
| package com.ffii.fpsms.modules.deliveryOrder.web.models | |||||
| import com.fasterxml.jackson.annotation.JsonFormat | |||||
| import java.time.LocalDate | |||||
| import java.time.LocalTime | |||||
| data class WorkbenchMergeTicketCandidate( | |||||
| val id: Long, | |||||
| val ticketNo: String?, | |||||
| val releaseType: String?, | |||||
| val shopId: Long?, | |||||
| val shopCode: String?, | |||||
| val shopName: String?, | |||||
| val storeId: String?, | |||||
| val truckId: Long?, | |||||
| @JsonFormat(pattern = "yyyy-MM-dd") | |||||
| val requiredDeliveryDate: LocalDate?, | |||||
| val truckLanceCode: String?, | |||||
| @JsonFormat(pattern = "HH:mm") | |||||
| val truckDepartureTime: LocalTime?, | |||||
| val loadingSequence: Int?, | |||||
| val deliveryOrderCodes: List<String>, | |||||
| /** Stable lane identity for same-truck merge matching (2/F, 4/F, truck-X). */ | |||||
| val laneKey: String, | |||||
| ) | |||||
| data class WorkbenchMergeTicketCandidatesResponse( | |||||
| val batchFamilyTickets: List<WorkbenchMergeTicketCandidate>, | |||||
| val isExtraTickets: List<WorkbenchMergeTicketCandidate>, | |||||
| ) | |||||
| data class WorkbenchMergeTicketsRequest( | |||||
| val batchOrSingleDopoId: Long, | |||||
| val isExtraDopoId: Long, | |||||
| ) | |||||
| @@ -3,6 +3,8 @@ package com.ffii.fpsms.modules.master.entity | |||||
| import com.fasterxml.jackson.annotation.JsonBackReference | import com.fasterxml.jackson.annotation.JsonBackReference | ||||
| import com.fasterxml.jackson.annotation.JsonManagedReference | import com.fasterxml.jackson.annotation.JsonManagedReference | ||||
| import com.ffii.core.entity.BaseEntity | import com.ffii.core.entity.BaseEntity | ||||
| import com.ffii.fpsms.modules.master.enums.BomStatus | |||||
| import com.ffii.fpsms.modules.master.enums.BomStatusConverter | |||||
| import jakarta.persistence.* | import jakarta.persistence.* | ||||
| import jakarta.validation.constraints.NotNull | import jakarta.validation.constraints.NotNull | ||||
| import jakarta.validation.constraints.Size | import jakarta.validation.constraints.Size | ||||
| @@ -87,4 +89,8 @@ open class Bom : BaseEntity<Long>() { | |||||
| @Column(name = "baseScore", precision = 14, scale = 2) | @Column(name = "baseScore", precision = 14, scale = 2) | ||||
| open var baseScore: BigDecimal? = null | open var baseScore: BigDecimal? = null | ||||
| @Column(name = "status", nullable = false, length = 20) | |||||
| @Convert(converter = BomStatusConverter::class) | |||||
| open var status: BomStatus = BomStatus.ACTIVE | |||||
| } | } | ||||
| @@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.master.entity | |||||
| import com.ffii.core.support.AbstractRepository | import com.ffii.core.support.AbstractRepository | ||||
| import com.ffii.fpsms.modules.master.entity.projections.BomCombo | import com.ffii.fpsms.modules.master.entity.projections.BomCombo | ||||
| import com.ffii.fpsms.modules.master.enums.BomStatus | |||||
| import org.springframework.stereotype.Repository | import org.springframework.stereotype.Repository | ||||
| import java.io.Serializable | import java.io.Serializable | ||||
| import org.springframework.data.jpa.repository.Query | import org.springframework.data.jpa.repository.Query | ||||
| @@ -27,8 +28,10 @@ interface BomRepository : AbstractRepository<Bom, Long> { | |||||
| fun findByItemIdAndDeletedIsFalse(itemId: Serializable): Bom? | fun findByItemIdAndDeletedIsFalse(itemId: Serializable): Bom? | ||||
| fun findBomComboByDeletedIsFalse(): List<BomCombo> | fun findBomComboByDeletedIsFalse(): List<BomCombo> | ||||
| fun findAllByItemIdAndDeletedIsFalse(itemId: Long): List<Bom> | |||||
| fun findBomComboByDeletedIsFalseAndStatus(status: BomStatus): List<BomCombo> | |||||
| fun findAllByItemIdAndDeletedIsFalse(itemId: Long): List<Bom> | |||||
| fun findAllByItemIdAndStatusAndDeletedIsFalse(itemId: Long, status: BomStatus): List<Bom> | |||||
| @Query("SELECT b.id FROM Bom b WHERE b.deleted = false ORDER BY b.id") | @Query("SELECT b.id FROM Bom b WHERE b.deleted = false ORDER BY b.id") | ||||
| fun findAllIdsByDeletedIsFalse(): List<Long> | fun findAllIdsByDeletedIsFalse(): List<Long> | ||||
| @@ -13,4 +13,6 @@ interface BomCombo { | |||||
| val outputQtyUom: String?; | val outputQtyUom: String?; | ||||
| @get:Value("#{target.description}") | @get:Value("#{target.description}") | ||||
| val description: String?; | val description: String?; | ||||
| @get:Value("#{target.status?.value}") | |||||
| val status: String?; | |||||
| } | } | ||||
| @@ -0,0 +1,12 @@ | |||||
| package com.ffii.fpsms.modules.master.enums | |||||
| enum class BomStatus(val value: String) { | |||||
| ACTIVE("active"), | |||||
| INACTIVE("inactive"); | |||||
| companion object { | |||||
| fun fromValue(value: String): BomStatus = | |||||
| entries.find { it.value == value } | |||||
| ?: throw IllegalArgumentException("Unknown BOM status: $value") | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,12 @@ | |||||
| package com.ffii.fpsms.modules.master.enums | |||||
| import jakarta.persistence.AttributeConverter | |||||
| import jakarta.persistence.Converter | |||||
| @Converter(autoApply = true) | |||||
| class BomStatusConverter : AttributeConverter<BomStatus, String> { | |||||
| override fun convertToDatabaseColumn(attribute: BomStatus?): String? = attribute?.value | |||||
| override fun convertToEntityAttribute(dbData: String?): BomStatus? = | |||||
| dbData?.let { BomStatus.fromValue(it) } | |||||
| } | |||||
| @@ -43,6 +43,8 @@ import com.ffii.fpsms.m18.entity.M18BomShopSyncLogRepository | |||||
| import com.ffii.fpsms.modules.common.SettingNames | import com.ffii.fpsms.modules.common.SettingNames | ||||
| import com.ffii.fpsms.modules.settings.entity.Settings | import com.ffii.fpsms.modules.settings.entity.Settings | ||||
| import com.ffii.fpsms.modules.settings.service.SettingsService | import com.ffii.fpsms.modules.settings.service.SettingsService | ||||
| import com.ffii.fpsms.modules.master.enums.BomStatus | |||||
| import com.ffii.core.exception.BadRequestException | |||||
| @Service | @Service | ||||
| open class BomService( | open class BomService( | ||||
| @@ -141,6 +143,11 @@ open class BomService( | |||||
| .minByOrNull { if (it.description == "FG") 0 else 1 } | .minByOrNull { if (it.description == "FG") 0 else 1 } | ||||
| ?: bomRepository.findAllByItemIdAndDeletedIsFalse(itemId).firstOrNull() | ?: bomRepository.findAllByItemIdAndDeletedIsFalse(itemId).firstOrNull() | ||||
| } | } | ||||
| open fun findByItemIdAndStatus(itemId: Long, status: BomStatus): Bom? { | |||||
| return bomRepository.findAllByItemIdAndStatusAndDeletedIsFalse(itemId, status) | |||||
| .minByOrNull { if (it.description == "FG") 0 else 1 } | |||||
| ?: bomRepository.findAllByItemIdAndStatusAndDeletedIsFalse(itemId, status).firstOrNull() | |||||
| } | |||||
| /** Resolve BOM header for a finished-good item code ([Items.code] on [Bom.item]). */ | /** Resolve BOM header for a finished-good item code ([Items.code] on [Bom.item]). */ | ||||
| open fun findBomSummaryByItemCode(itemCodeTrimmed: String): BomIdByItemCodeResponse { | open fun findBomSummaryByItemCode(itemCodeTrimmed: String): BomIdByItemCodeResponse { | ||||
| @@ -240,6 +247,13 @@ open class BomService( | |||||
| else -> "Other" | else -> "Other" | ||||
| } | } | ||||
| } | } | ||||
| request.status?.let { raw -> | |||||
| bom.status = try { | |||||
| BomStatus.fromValue(raw.trim().lowercase()) | |||||
| } catch (_: IllegalArgumentException) { | |||||
| throw BadRequestException("Invalid BOM status: $raw") | |||||
| } | |||||
| } | |||||
| val replaceMaterials = request.materials != null | val replaceMaterials = request.materials != null | ||||
| val replaceProcesses = request.processes != null | val replaceProcesses = request.processes != null | ||||
| @@ -2962,6 +2976,7 @@ println("=====================================") | |||||
| description = bom.description, | description = bom.description, | ||||
| outputQty = bom.outputQty, | outputQty = bom.outputQty, | ||||
| outputQtyUom = bom.outputQtyUom, | outputQtyUom = bom.outputQtyUom, | ||||
| status = bom.status.value, | |||||
| materials = materials, | materials = materials, | ||||
| processes = processes | processes = processes | ||||
| ) | ) | ||||
| @@ -726,7 +726,7 @@ open class ProductionScheduleService( | |||||
| LEFT JOIN items ON bom.itemId = items.id | LEFT JOIN items ON bom.itemId = items.id | ||||
| LEFT JOIN inventory ON items.id = inventory.itemId | LEFT JOIN inventory ON items.id = inventory.itemId | ||||
| left join item_fake_onhand on items.code = item_fake_onhand.itemCode | left join item_fake_onhand on items.code = item_fake_onhand.itemCode | ||||
| WHERE bom.deleted = 0 and bom.description = 'FG' | |||||
| WHERE bom.deleted = 0 and bom.description = 'FG' and bom.status = 'active' | |||||
| -- and bom.itemId != 16771 | -- and bom.itemId != 16771 | ||||
| ) AS i | ) AS i | ||||
| WHERE 1 | WHERE 1 | ||||
| @@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.master.web | |||||
| import com.ffii.fpsms.modules.master.entity.Bom | import com.ffii.fpsms.modules.master.entity.Bom | ||||
| import com.ffii.fpsms.modules.master.entity.BomRepository | import com.ffii.fpsms.modules.master.entity.BomRepository | ||||
| import com.ffii.fpsms.modules.master.enums.BomStatus | |||||
| import com.ffii.fpsms.modules.master.entity.projections.BomCombo | import com.ffii.fpsms.modules.master.entity.projections.BomCombo | ||||
| import com.ffii.fpsms.modules.master.service.BomService | import com.ffii.fpsms.modules.master.service.BomService | ||||
| import org.springframework.core.io.ByteArrayResource | import org.springframework.core.io.ByteArrayResource | ||||
| @@ -108,8 +109,14 @@ fun downloadBomFormatIssueLog( | |||||
| } | } | ||||
| @GetMapping("/combo") | @GetMapping("/combo") | ||||
| fun getCombo(): List<BomCombo> { | |||||
| return bomRepository.findBomComboByDeletedIsFalse(); | |||||
| fun getCombo( | |||||
| @RequestParam(defaultValue = "false") includeInactive: Boolean, | |||||
| ): List<BomCombo> { | |||||
| return if (includeInactive) { | |||||
| bomRepository.findBomComboByDeletedIsFalse() | |||||
| } else { | |||||
| bomRepository.findBomComboByDeletedIsFalseAndStatus(BomStatus.ACTIVE) | |||||
| } | |||||
| } | } | ||||
| @GetMapping("/combo/issues") | @GetMapping("/combo/issues") | ||||
| @@ -30,6 +30,7 @@ data class EditBomRequest( | |||||
| val complexity: Int? = null, | val complexity: Int? = null, | ||||
| val isDrink: Boolean? = null, | val isDrink: Boolean? = null, | ||||
| val isPowderMixture: Boolean? = null, | val isPowderMixture: Boolean? = null, | ||||
| val status: String? = null, | |||||
| // children | // children | ||||
| val materials: List<EditBomMaterialRequest>? = null, | val materials: List<EditBomMaterialRequest>? = null, | ||||
| @@ -122,6 +122,7 @@ data class BomDetailResponse( | |||||
| val description: String?, | val description: String?, | ||||
| val outputQty: BigDecimal?, | val outputQty: BigDecimal?, | ||||
| val outputQtyUom: String?, | val outputQtyUom: String?, | ||||
| val status: String?, | |||||
| val materials: List<BomMaterialDto>, | val materials: List<BomMaterialDto>, | ||||
| val processes: List<BomProcessDto> | val processes: List<BomProcessDto> | ||||
| ) | ) | ||||
| @@ -0,0 +1,7 @@ | |||||
| --liquibase formatted sql | |||||
| -- Add column relationshipId to delivery_order_pick_order | |||||
| --changeset Enson:20260609-01 | |||||
| ALTER TABLE delivery_order_pick_order | |||||
| ADD COLUMN relationshipId INT NULL; | |||||
| UPDATE delivery_order_pick_order SET relationshipId = id WHERE relationshipId IS NULL OR relationshipId = 0; | |||||
| @@ -0,0 +1,7 @@ | |||||
| --liquibase formatted sql | |||||
| --changeset Enson:20260609-bom-status | |||||
| ALTER TABLE bom | |||||
| ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'active' AFTER type; | |||||
| UPDATE bom SET status = 'active' WHERE status IS NULL OR status = ''; | |||||