| @@ -18,7 +18,7 @@ interface JobOrderRepository : AbstractRepository<JobOrder, Long> { | |||
| fun findLatestCodeByPrefix(prefix: String): String? | |||
| fun findJobOrderInfoByCodeContainsAndBomNameContainsAndDeletedIsFalseOrderByIdDesc(code: String, bomName: String, pageable: Pageable): Page<JobOrderInfo> | |||
| fun findByBom_Id(bomId: Long): List<JobOrder> | |||
| @Query( | |||
| nativeQuery = true, | |||
| value = """ | |||
| @@ -1757,7 +1757,7 @@ private fun normalizeFloor(raw: String): String { | |||
| val num = cleaned.replace(Regex("[^0-9]"), "") | |||
| return if (num.isNotEmpty()) "${num}F" else cleaned | |||
| } | |||
| open fun getAllJoPickOrders(): List<AllJoPickOrderResponse> { | |||
| open fun getAllJoPickOrders(isDrink: Boolean?): List<AllJoPickOrderResponse> { | |||
| println("=== getAllJoPickOrders ===") | |||
| return try { | |||
| @@ -1808,7 +1808,8 @@ open fun getAllJoPickOrders(): List<AllJoPickOrderResponse> { | |||
| val bom = jobOrder.bom | |||
| // 按 isDrink 过滤:null 表示不过滤(全部) | |||
| if (isDrink != null && bom?.isDrink != isDrink) return@mapNotNull null | |||
| println("BOM found: ${bom?.id}") | |||
| val item = bom?.item | |||
| @@ -1860,7 +1861,7 @@ open fun getAllJoPickOrders(): List<AllJoPickOrderResponse> { | |||
| } | |||
| println("Returning ${jobOrderPickOrders.size} released job order pick orders") | |||
| jobOrderPickOrders | |||
| jobOrderPickOrders.sortedByDescending { it.id } | |||
| } catch (e: Exception) { | |||
| println("❌ Error in getAllJoPickOrders: ${e.message}") | |||
| e.printStackTrace() | |||
| @@ -22,6 +22,7 @@ import com.ffii.fpsms.modules.pickOrder.service.PickOrderService | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.SavePickOrderLineRequest | |||
| import com.ffii.fpsms.modules.pickOrder.web.models.SavePickOrderRequest | |||
| import com.ffii.fpsms.modules.user.service.UserService | |||
| import com.google.gson.reflect.TypeToken | |||
| import org.springframework.data.domain.PageRequest | |||
| import org.springframework.stereotype.Service | |||
| @@ -69,6 +70,8 @@ import java.time.LocalDateTime | |||
| import com.ffii.fpsms.modules.master.entity.BomMaterialRepository | |||
| import com.ffii.fpsms.modules.master.service.ItemUomService | |||
| import com.ffii.fpsms.modules.master.web.models.ConvertUomByItemRequest | |||
| import com.ffii.fpsms.modules.stock.service.StockInLineService | |||
| import com.ffii.fpsms.modules.stock.web.model.SaveStockInLineRequest | |||
| @Service | |||
| open class JobOrderService( | |||
| val jobOrderRepository: JobOrderRepository, | |||
| @@ -87,6 +90,7 @@ open class JobOrderService( | |||
| val jobTypeRepository: JobTypeRepository, | |||
| val inventoryRepository: InventoryRepository, | |||
| val stockInLineRepository: StockInLineRepository, | |||
| val stockInLineService: StockInLineService, | |||
| val productProcessRepository: ProductProcessRepository, | |||
| val jobOrderBomMaterialRepository: JobOrderBomMaterialRepository, | |||
| val bomMaterialRepository: BomMaterialRepository, | |||
| @@ -437,7 +441,21 @@ open class JobOrderService( | |||
| } | |||
| val savedJo = jobOrderRepository.saveAndFlush(jo); | |||
| savedJo.bom?.item?.id?.let { fgItemId -> | |||
| stockInLineService.create( | |||
| SaveStockInLineRequest( | |||
| itemId = fgItemId, | |||
| acceptedQty = BigDecimal.ZERO, | |||
| acceptQty = null, | |||
| jobOrderId = savedJo.id, | |||
| status = "pending", | |||
| expiryDate = null, | |||
| productLotNo = null, | |||
| productionDate = null, | |||
| receiptDate = null, | |||
| ) | |||
| ) | |||
| } | |||
| return MessageResponse( | |||
| id = savedJo.id, | |||
| name = null, | |||
| @@ -243,8 +243,8 @@ fun recordSecondScanIssue( | |||
| return joPickOrderService.getCompletedJobOrderPickOrderLotDetailsForCompletedPick(pickOrderId) | |||
| } | |||
| @GetMapping("/AllJoPickOrder") | |||
| fun getAllJoPickOrder(): List<AllJoPickOrderResponse> { | |||
| return joPickOrderService.getAllJoPickOrders() | |||
| fun getAllJoPickOrder(@RequestParam(required = false) isDrink: Boolean?): List<AllJoPickOrderResponse> { | |||
| return joPickOrderService.getAllJoPickOrders(isDrink) | |||
| } | |||
| @GetMapping("/all-lots-hierarchical-by-pick-order/{pickOrderId}") | |||
| @@ -22,6 +22,8 @@ open class Bom : BaseEntity<Long>() { | |||
| @Column | |||
| open var isDense: Int? = null | |||
| @Column | |||
| open var isDrink: Boolean? = false | |||
| @Column | |||
| open var scrapRate: Int? = null | |||
| @Column | |||
| open var allergicSubstances: Int? = null | |||
| @@ -31,7 +33,7 @@ open class Bom : BaseEntity<Long>() { | |||
| @Column | |||
| open var complexity: Int? = null | |||
| @JsonBackReference | |||
| @OneToOne | |||
| @ManyToOne | |||
| @JoinColumn(name = "itemId") | |||
| open var item: Items? = null | |||
| @@ -5,5 +5,6 @@ import org.springframework.stereotype.Repository | |||
| @Repository | |||
| interface BomProcessMaterialRepository : AbstractRepository<BomProcessMaterial, Long> { | |||
| fun findByBomProcessIdAndBomMaterialId(bomProcessId: Long, bomMaterialId: Long): BomProcessMaterial?; | |||
| fun findByBomProcessIdAndBomMaterialId(bomProcessId: Long, bomMaterialId: Long): BomProcessMaterial? | |||
| fun findByBomProcess_IdIn(bomProcessIds: List<Long>): List<BomProcessMaterial> | |||
| } | |||
| @@ -16,6 +16,6 @@ interface BomRepository : AbstractRepository<Bom, Long> { | |||
| fun findByItemIdAndDeletedIsFalse(itemId: Serializable): Bom? | |||
| fun findBomComboByDeletedIsFalse(): List<BomCombo> | |||
| fun findAllByItemIdAndDeletedIsFalse(itemId: Long): List<Bom> | |||
| fun findByCodeAndDeletedIsFalse(code: String): Bom? | |||
| } | |||
| @@ -76,8 +76,8 @@ open class Items : BaseEntity<Long>() { | |||
| open var inventories: MutableList<Inventory> = mutableListOf() | |||
| @JsonManagedReference | |||
| @OneToOne(mappedBy = "item", cascade = [CascadeType.ALL], orphanRemoval = true) | |||
| open var bom: Bom? = null | |||
| @OneToMany(mappedBy = "item", cascade = [CascadeType.ALL], orphanRemoval = true) | |||
| open var boms: MutableList<Bom> = mutableListOf() | |||
| @ManyToOne | |||
| @JoinColumn(name = "categoryId") | |||
| @@ -11,15 +11,64 @@ import org.springframework.http.ResponseEntity | |||
| import org.springframework.web.bind.annotation.GetMapping | |||
| import org.springframework.web.bind.annotation.PathVariable | |||
| import org.springframework.web.bind.annotation.PostMapping | |||
| import org.springframework.web.bind.annotation.RequestBody | |||
| import org.springframework.web.bind.annotation.RequestMapping | |||
| import org.springframework.web.bind.annotation.RequestParam | |||
| import org.springframework.web.bind.annotation.RestController | |||
| import org.springframework.web.multipart.MultipartFile | |||
| import org.springframework.web.multipart.MultipartHttpServletRequest | |||
| import org.slf4j.LoggerFactory | |||
| import java.time.LocalDate | |||
| import jakarta.servlet.http.HttpServletRequest | |||
| import jakarta.servlet.http.Part | |||
| import com.ffii.fpsms.modules.master.web.models.BomFormatCheckResponse | |||
| import com.ffii.fpsms.modules.master.web.models.BomUploadResponse | |||
| import com.ffii.fpsms.modules.master.web.models.BomFormatCheckRequest | |||
| import com.ffii.fpsms.modules.master.web.models.ImportBomRequestPayload | |||
| import java.util.logging.Logger | |||
| @RestController | |||
| @RequestMapping("/bom") | |||
| class BomController ( | |||
| val bomService: BomService, private val bomRepository: BomRepository, | |||
| ) { | |||
| private val log = LoggerFactory.getLogger(BomController::class.java) | |||
| @PostMapping("/import-bom/upload") | |||
| fun uploadBomFiles(request: HttpServletRequest): BomUploadResponse { | |||
| val multipartRequest = request as? MultipartHttpServletRequest | |||
| if (multipartRequest != null) { | |||
| val files = multipartRequest.getFiles("files").toList().filterNot { it.isEmpty } | |||
| if (files.isNotEmpty()) { | |||
| log.info("import-bom/upload: using getFiles(\"files\"), count={}", files.size) | |||
| return bomService.uploadBomFiles(files) | |||
| } | |||
| // 先嘗試 Spring 包裝的 MultipartHttpServletRequest(與其他 Controller 一致) | |||
| log.info("import-bom/upload: received files count={}", files.size) | |||
| files.forEachIndexed { i, f -> | |||
| log.info("import-bom/upload: file[{}] originalFilename={}", i, f.originalFilename) | |||
| } | |||
| } | |||
| // 否則用 Servlet getParts()(Postman 等可能不會被包裝成 MultipartHttpServletRequest) | |||
| val allParts = try { | |||
| request.parts | |||
| } catch (e: Exception) { | |||
| log.info("import-bom/upload: request.parts failed, content-type may not be multipart: {}", e.message) | |||
| return BomUploadResponse(batchId = "", fileNames = emptyList()) | |||
| } | |||
| val parts = allParts.filter { it.name == "files" } | |||
| if (parts.isEmpty()) { | |||
| log.info("import-bom/upload: no parts with name \"files\". All part names: {}", allParts.map { it.name }) | |||
| return BomUploadResponse(batchId = "", fileNames = emptyList()) | |||
| } | |||
| log.info("import-bom/upload: using getParts(), count={}", parts.size) | |||
| return bomService.uploadBomFilesFromParts(parts) | |||
| } | |||
| @PostMapping("/import-bom/format-check") | |||
| fun checkBomFormat(@RequestBody request: BomFormatCheckRequest): BomFormatCheckResponse { | |||
| return bomService.checkBomExcelFormat(request.batchId) | |||
| } | |||
| @GetMapping | |||
| fun getBoms(): List<Bom> { | |||
| return bomService.findAll() | |||
| @@ -31,8 +80,8 @@ class BomController ( | |||
| } | |||
| @PostMapping("/import-bom") | |||
| fun importBom(): ResponseEntity<Resource> { | |||
| val reportResult = bomService.importBOM() | |||
| fun importBom(@RequestBody payload: ImportBomRequestPayload): ResponseEntity<Resource> { | |||
| val reportResult = bomService.importBOM(payload.batchId, payload.items) | |||
| val filename = "bom_excel_issue_log_${LocalDate.now()}.xlsx" | |||
| return ResponseEntity.ok() | |||
| @@ -27,4 +27,43 @@ data class ItemUomRequest( | |||
| val ratioD: BigDecimal?, | |||
| val ratioN: BigDecimal?, | |||
| val deleted: Boolean?, | |||
| ) | |||
| data class BomFormatIssue( | |||
| val fileName: String, // 檔名 | |||
| val problem: String // 問題描述(用來 group 的 key) | |||
| ) | |||
| // 回傳給前端用:問題 → 檔名列表 | |||
| data class BomFormatProblemGroup( | |||
| val problem: String, | |||
| val fileNames: List<String> | |||
| ) | |||
| data class BomFormatFileGroup( | |||
| val fileName: String, | |||
| val problems: List<String> | |||
| ) | |||
| data class BomUploadResponse( | |||
| val batchId: String, | |||
| val fileNames: List<String> | |||
| ) | |||
| data class BomFormatCheckRequest( | |||
| val batchId: String | |||
| ) | |||
| /** Format-check 回傳:正確檔名列表 + 失敗列表(檔名 + 問題) */ | |||
| data class BomFormatCheckResponse( | |||
| val correctFileNames: List<String>, | |||
| val failList: List<BomFormatFileGroup> | |||
| ) | |||
| data class ImportBomItemRequest( | |||
| val fileName: String, | |||
| val isAlsoWip: Boolean = false | |||
| ) | |||
| data class ImportBomRequestPayload( | |||
| val batchId: String, | |||
| val items: List<ImportBomItemRequest> | |||
| ) | |||
| @@ -12,5 +12,5 @@ interface ProductProcessRepository : JpaRepository<ProductProcess, Long>, JpaSpe | |||
| fun findByProductProcessCodeStartingWith(prefix: String): List<ProductProcess> | |||
| fun findByIdAndDeletedIsFalse(id: Long): Optional<ProductProcess> | |||
| fun findAllByDeletedIsFalse(): List<ProductProcess> | |||
| fun findByBom_Id(bomId: Long): List<ProductProcess> | |||
| } | |||
| @@ -52,7 +52,7 @@ import com.ffii.fpsms.modules.master.web.models.* | |||
| import java.math.RoundingMode | |||
| import java.time.LocalDate | |||
| import java.time.format.DateTimeFormatter | |||
| import com.ffii.fpsms.modules.stock.entity.StockInLineRepository | |||
| @Service | |||
| @Transactional | |||
| open class ProductProcessService( | |||
| @@ -78,7 +78,8 @@ open class ProductProcessService( | |||
| private val joPickOrderRepository: JoPickOrderRepository, | |||
| private val itemUomRepository: ItemUomRespository, | |||
| private val uomConversionRepository: UomConversionRepository, | |||
| private val itemUomService: ItemUomService | |||
| private val itemUomService: ItemUomService, | |||
| private val stockInLineRepository: StockInLineRepository | |||
| ) { | |||
| open fun findAll(pageable: Pageable): Page<ProductProcess> { | |||
| @@ -1446,10 +1447,13 @@ open class ProductProcessService( | |||
| val jobOrder = jobOrderRepository.findById(response.jobOrderId ?: 0L).orElse(null) | |||
| val stockInLineStatus = jobOrder?.stockInLines?.firstOrNull()?.status | |||
| stockInLineStatus != "completed" | |||
| && stockInLineStatus != "escalated" | |||
| // && stockInLineStatus != "escalated" // 你已經註解掉,會把 escalated 顯示出來 | |||
| && stockInLineStatus != "rejected" | |||
| && jobOrder?.status != JobOrderStatus.PLANNING | |||
| } | |||
| }.sortedWith( | |||
| compareByDescending<AllJoborderProductProcessInfoResponse> { it.date ?: LocalDate.MIN } | |||
| .thenBy { it.productionPriority ?: Int.MAX_VALUE } | |||
| ) | |||
| } | |||
| open fun updateProductProcessLineStartTime(productProcessLineId: Long): MessageResponse { | |||
| @@ -1567,20 +1571,36 @@ open class ProductProcessService( | |||
| jobOrder.status = JobOrderStatus.STORING | |||
| jobOrderRepository.save(jobOrder) | |||
| stockInLineService.create( | |||
| SaveStockInLineRequest( | |||
| itemId = productProcess?.item?.id ?: 0L, | |||
| acceptedQty = jobOrder?.reqQty ?: BigDecimal.ZERO, | |||
| productLotNo = jobOrder?.code, | |||
| productionDate = LocalDate.now(), | |||
| jobOrderId = jobOrder.id, | |||
| acceptQty = jobOrder?.reqQty ?: BigDecimal.ZERO, | |||
| //acceptQty = null, | |||
| expiryDate = null, | |||
| status = "pending", | |||
| val existingSil = jobOrder.id?.let { stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(it) } | |||
| if (existingSil != null) { | |||
| stockInLineService.update( | |||
| SaveStockInLineRequest( | |||
| id = existingSil.id, | |||
| itemId = existingSil.item?.id ?: productProcess?.item?.id ?: 0L, | |||
| acceptedQty = jobOrder.reqQty ?: BigDecimal.ZERO, | |||
| acceptQty = jobOrder.reqQty ?: BigDecimal.ZERO, | |||
| productLotNo = jobOrder.code, | |||
| productionDate = LocalDate.now(), | |||
| jobOrderId = jobOrder.id, | |||
| expiryDate = null, | |||
| status = "pending", | |||
| receiptDate = null, | |||
| ) | |||
| ) | |||
| ) | |||
| } else { | |||
| stockInLineService.create( | |||
| SaveStockInLineRequest( | |||
| itemId = productProcess?.item?.id ?: 0L, | |||
| acceptedQty = jobOrder?.reqQty ?: BigDecimal.ZERO, | |||
| productLotNo = jobOrder?.code, | |||
| productionDate = LocalDate.now(), | |||
| jobOrderId = jobOrder.id, | |||
| acceptQty = jobOrder?.reqQty ?: BigDecimal.ZERO, | |||
| expiryDate = null, | |||
| status = "pending", | |||
| ) | |||
| ) | |||
| } | |||
| } | |||
| } | |||
| return MessageResponse( | |||
| @@ -1609,19 +1629,38 @@ open class ProductProcessService( | |||
| if (jobOrder != null) { | |||
| jobOrder.status = JobOrderStatus.STORING | |||
| jobOrderRepository.save(jobOrder) | |||
| stockInLineService.create( | |||
| SaveStockInLineRequest( | |||
| itemId = productProcess?.item?.id ?: 0L, | |||
| acceptedQty = jobOrder?.reqQty ?: BigDecimal.ZERO, | |||
| productLotNo = jobOrder?.code, | |||
| productionDate = LocalDate.now(), | |||
| jobOrderId = jobOrder.id, | |||
| acceptQty = jobOrder?.reqQty ?: BigDecimal.ZERO, | |||
| expiryDate = null, | |||
| status = "", | |||
| val existingSil = jobOrder.id?.let { stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(it) } | |||
| if (existingSil != null) { | |||
| stockInLineService.update( | |||
| SaveStockInLineRequest( | |||
| id = existingSil.id, | |||
| itemId = existingSil.item?.id ?: productProcess?.item?.id ?: 0L, | |||
| acceptedQty = jobOrder.reqQty ?: BigDecimal.ZERO, | |||
| acceptQty = jobOrder.reqQty ?: BigDecimal.ZERO, | |||
| productLotNo = jobOrder.code, | |||
| productionDate = LocalDate.now(), | |||
| jobOrderId = jobOrder.id, | |||
| expiryDate = null, | |||
| status = "pending", | |||
| receiptDate = null, | |||
| ) | |||
| ) | |||
| ) | |||
| } else { | |||
| stockInLineService.create( | |||
| SaveStockInLineRequest( | |||
| itemId = productProcess?.item?.id ?: 0L, | |||
| acceptedQty = jobOrder?.reqQty ?: BigDecimal.ZERO, | |||
| productLotNo = jobOrder?.code, | |||
| productionDate = LocalDate.now(), | |||
| jobOrderId = jobOrder.id, | |||
| acceptQty = jobOrder?.reqQty ?: BigDecimal.ZERO, | |||
| expiryDate = null, | |||
| status = "", | |||
| ) | |||
| ) | |||
| } | |||
| } | |||
| } | |||
| @@ -12,7 +12,10 @@ interface EscalationLogInfo { | |||
| @get:Value("#{target.stockInLine?.purchaseOrderLine?.id}") | |||
| val polId: Long? | |||
| @get:Value("#{target.stockInLine?.jobOrder?.id}") | |||
| val jobOrderId: Long? | |||
| @get:Value("#{target.stockInLine?.jobOrder?.code}") | |||
| val jobOrderCode: String? | |||
| @get:Value("#{target.stockInLine?.stockIn?.code}") | |||
| val poCode: String? | |||
| @@ -55,4 +55,5 @@ fun searchStockInLines( | |||
| //AND sil.type IS NOT NULL | |||
| @Query("SELECT sil FROM StockInLine sil WHERE sil.item.id IN :itemIds AND sil.deleted = false") | |||
| fun findAllByItemIdInAndDeletedFalse(itemIds: List<Long>): List<StockInLine> | |||
| fun findFirstByJobOrder_IdAndDeletedFalse(jobOrderId: Long): StockInLine? | |||
| } | |||
| @@ -210,6 +210,9 @@ open class StockInLineService( | |||
| this.type = "Nor" | |||
| status = StockInLineStatus.PENDING.status | |||
| } | |||
| if (jo != null) { | |||
| stockInLine.lotNo = assignLotNo() | |||
| } | |||
| val savedSIL = saveAndFlush(stockInLine) | |||
| if (pol != null) { | |||
| //logger.info("[create] Stock-in line created with PO, running PO/GRN update for stockInLine id=${savedSIL.id}") | |||
| @@ -238,7 +241,7 @@ open class StockInLineService( | |||
| ) | |||
| val inventoryCount = jdbcDao.queryForInt(INVENTORY_COUNT.toString(), args) | |||
| // val newLotNo = CodeGenerator.generateCode(prefix = "MPO", itemId = stockInLine.item!!.id!!, count = inventoryCount) | |||
| val newLotNo = assignLotNo() | |||
| val newLotNo = stockInLine.lotNo ?: assignLotNo() | |||
| inventoryLot.apply { | |||
| this.item = stockInLine.item | |||
| this.stockInLine = stockInLine | |||
| @@ -27,6 +27,10 @@ spring: | |||
| logging: | |||
| config: 'classpath:log4j2.yml' | |||
| bom: | |||
| import: | |||
| temp-dir: ${java.io.tmpdir}/fpsms-bom-import | |||
| m18: | |||
| config: | |||
| grant-type: password | |||
| @@ -0,0 +1,10 @@ | |||
| -- liquibase formatted sql | |||
| -- changeset Enson:add_isDrink_to_bom | |||
| ALTER TABLE `fpsmsdb`.`bom` | |||
| ADD COLUMN `isDrink` TINYINT(1) NULL DEFAULT false AFTER `isDense`; | |||