| @@ -0,0 +1,43 @@ | |||
| package com.ffii.fpsms.modules.stock.entity | |||
| import com.fasterxml.jackson.annotation.JsonBackReference | |||
| import com.ffii.core.entity.BaseEntity | |||
| import com.ffii.fpsms.modules.master.entity.Items | |||
| import jakarta.persistence.* | |||
| import jakarta.validation.constraints.NotNull | |||
| import java.math.BigDecimal | |||
| @Entity | |||
| @Table(name = "stock_adjustment_record") | |||
| open class StockAdjustmentRecord : BaseEntity<Long>() { | |||
| @NotNull | |||
| @ManyToOne | |||
| @JoinColumn(name = "itemId", nullable = false) | |||
| open var item: Items? = null | |||
| @Column(name = "itemCode", length = 50) | |||
| open var itemCode: String? = null | |||
| @Column(name = "itemName", length = 200) | |||
| open var itemName: String? = null | |||
| @Column(name = "lotNo", length = 512) | |||
| open var lotNo: String? = null | |||
| @Column(name = "inQty", precision = 14, scale = 2) | |||
| open var inQty: BigDecimal? = null | |||
| @Column(name = "outQty", precision = 14, scale = 2) | |||
| open var outQty: BigDecimal? = null | |||
| @JsonBackReference | |||
| @ManyToOne | |||
| @JoinColumn(name = "stockInLineId") | |||
| open var stockInLine: StockInLine? = null | |||
| @JsonBackReference | |||
| @ManyToOne | |||
| @JoinColumn(name = "stockOutLineId") | |||
| open var stockOutLine: StockOutLine? = null | |||
| } | |||
| @@ -0,0 +1,7 @@ | |||
| package com.ffii.fpsms.modules.stock.entity | |||
| import com.ffii.core.support.AbstractRepository | |||
| import org.springframework.stereotype.Repository | |||
| @Repository | |||
| interface StockAdjustmentRecordRepository : AbstractRepository<StockAdjustmentRecord, Long> | |||
| @@ -40,6 +40,13 @@ import com.ffii.fpsms.modules.stock.web.model.ScannedLotInfo | |||
| import com.ffii.fpsms.modules.stock.web.model.SameItemLotInfo | |||
| import com.ffii.fpsms.modules.jobOrder.service.JobOrderService | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.ExportFGStockInLabelRequest | |||
| import com.ffii.fpsms.modules.master.service.PrinterService | |||
| import com.ffii.fpsms.modules.stock.web.model.PrintLabelForInventoryLotLineRequest | |||
| import com.ffii.fpsms.modules.jobOrder.web.model.PrintFGStockInLabelRequest | |||
| import com.ffii.core.utils.ZebraPrinterUtil | |||
| import net.sf.jasperreports.engine.JasperExportManager | |||
| import java.io.File | |||
| import net.sf.jasperreports.engine.JasperPrint | |||
| @Service | |||
| open class InventoryLotLineService( | |||
| @@ -49,6 +56,7 @@ open class InventoryLotLineService( | |||
| private val itemUomRespository: ItemUomRespository, | |||
| private val stockInLineRepository: StockInLineRepository, | |||
| private val inventoryRepository: InventoryRepository, | |||
| private val printerService: PrinterService, | |||
| @Lazy | |||
| private val jobOrderService: JobOrderService | |||
| ) { | |||
| @@ -217,7 +225,6 @@ open class InventoryLotLineService( | |||
| // .minus(inventoryLotLine.outQty ?: zero) | |||
| // .minus(inventoryLotLine.holdQty ?: zero) | |||
| // Accepted qty at stock-in, no conversion (stays e.g. 50000 even after stock out) | |||
| field["acceptedQty"] = "%.2f".format(info.acceptedQty) | |||
| val stockItemUom = itemUomRespository.findBaseUnitByItemIdAndStockUnitIsTrueAndDeletedIsFalse(info.itemId) | |||
| @@ -267,6 +274,56 @@ open class InventoryLotLineService( | |||
| } | |||
| } | |||
| @Transactional | |||
| open fun printLabelForInventoryLotLine(request: PrintLabelForInventoryLotLineRequest) { | |||
| val printer = printerService.findById(request.printerId) | |||
| ?: throw NoSuchElementException("No such printer") | |||
| val inventoryLotLine = inventoryLotLineRepository.findById(request.inventoryLotLineId).orElseThrow() | |||
| val stockInLine = inventoryLotLine.inventoryLot?.stockInLine | |||
| when { | |||
| stockInLine?.jobOrder != null -> { | |||
| jobOrderService.printFGStockInLabel( | |||
| PrintFGStockInLabelRequest( | |||
| stockInLineId = stockInLine.id!!, | |||
| printerId = request.printerId, | |||
| printQty = request.printQty | |||
| ) | |||
| ) | |||
| } | |||
| else -> { | |||
| val pdf = if (stockInLine?.stockTransferRecord != null) { | |||
| val targetLocation = stockInLine.stockTransferRecord?.targetLocation ?: "" | |||
| exportStockInLineQrcode( | |||
| LotLineToQrcode(inventoryLotLineId = request.inventoryLotLineId, isTransfer = "轉倉至 $targetLocation") | |||
| ) | |||
| } else { | |||
| exportStockInLineQrcode(LotLineToQrcode(inventoryLotLineId = request.inventoryLotLineId)) | |||
| } | |||
| val jasperPrint = pdf["report"] as JasperPrint | |||
| val tempPdfFile = File.createTempFile("print_job_", ".pdf") | |||
| try { | |||
| JasperExportManager.exportReportToPdfFile(jasperPrint, tempPdfFile.absolutePath) | |||
| val printQty = if (request.printQty == null || request.printQty <= 0) 1 else request.printQty | |||
| printer.ip?.let { ip -> | |||
| printer.port?.let { port -> | |||
| ZebraPrinterUtil.printPdfToZebra( | |||
| tempPdfFile, | |||
| ip, | |||
| port, | |||
| printQty, | |||
| ZebraPrinterUtil.PrintDirection.ROTATED, | |||
| printer.dpi | |||
| ) | |||
| } | |||
| } | |||
| } finally { | |||
| tempPdfFile.delete() | |||
| } | |||
| } | |||
| } | |||
| } | |||
| @Transactional | |||
| open fun updateInventoryLotLineQuantities(request: UpdateInventoryLotLineQuantitiesRequest): MessageResponse { | |||
| try { | |||
| @@ -0,0 +1,164 @@ | |||
| package com.ffii.fpsms.modules.stock.service | |||
| import com.ffii.fpsms.modules.master.web.models.MessageResponse | |||
| import com.ffii.fpsms.modules.stock.entity.InventoryLotLine | |||
| import com.ffii.fpsms.modules.stock.entity.InventoryLotLineRepository | |||
| import com.ffii.fpsms.modules.stock.web.model.StockAdjustmentRequest | |||
| import com.ffii.fpsms.modules.stock.web.model.StockInRequest | |||
| import com.ffii.fpsms.modules.stock.web.model.StockOutRequest | |||
| import org.springframework.stereotype.Service | |||
| import org.springframework.transaction.annotation.Transactional | |||
| import java.math.BigDecimal | |||
| import java.time.LocalDate | |||
| import com.ffii.fpsms.modules.stock.entity.StockAdjustmentRecord | |||
| import com.ffii.fpsms.modules.stock.entity.StockAdjustmentRecordRepository | |||
| import com.ffii.fpsms.modules.stock.entity.StockInLine | |||
| import com.ffii.fpsms.modules.stock.entity.StockOutLine | |||
| @Service | |||
| open class StockAdjustmentService( | |||
| private val stockInLineService: StockInLineService, | |||
| private val stockOutLineService: StockOutLineService, | |||
| private val inventoryLotLineRepository: InventoryLotLineRepository, | |||
| private val stockAdjustmentRecordRepository: StockAdjustmentRecordRepository | |||
| ) { | |||
| @Transactional | |||
| open fun submit(request: StockAdjustmentRequest): MessageResponse { | |||
| val originalById = request.originalLines.filter { it.id > 0 }.associateBy { it.id } | |||
| val currentIds = request.currentLines.map { it.id }.toSet() | |||
| // Branch 4: Removed entries — stock out full qty and mark unavailable | |||
| val removed = request.originalLines.filter { it.id > 0 && it.id !in currentIds } | |||
| for (line in removed) { | |||
| val stockOutLine = stockOutLineService.createStockOut( | |||
| StockOutRequest( | |||
| inventoryLotLineId = line.id, | |||
| qty = line.adjustedQty.toDouble(), | |||
| type = "ADJ" | |||
| ) | |||
| ) | |||
| saveAdjustmentRecordForStockOut(stockOutLine) | |||
| } | |||
| for (current in request.currentLines) { | |||
| // Branch 2: New entry — createStockIn (OPEN or ADJ) | |||
| if (current.isNew) { | |||
| val stockType = if (current.isOpeningInventory) "OPEN" else "ADJ" | |||
| val stockInLine = stockInLineService.createStockIn( | |||
| StockInRequest( | |||
| itemId = current.itemId, | |||
| itemNo = current.itemNo, | |||
| demandQty = null, | |||
| acceptedQty = current.adjustedQty, | |||
| expiryDate = LocalDate.parse(current.expiryDate), | |||
| lotNo = current.lotNo?.takeIf { it.isNotBlank() }, | |||
| productLotNo = current.productlotNo?.takeIf { it.isNotBlank() }, | |||
| dnNo = current.dnNo?.takeIf { it.isNotBlank() }, | |||
| type = stockType, | |||
| warehouseId = current.warehouseId | |||
| ) | |||
| ) | |||
| saveAdjustmentRecordForStockIn(stockInLine) | |||
| continue | |||
| } | |||
| // Branch 1 & 3: Existing line — compare qty | |||
| val original = originalById[current.id] ?: continue | |||
| val diff = current.adjustedQty.subtract(original.adjustedQty) | |||
| if (diff.compareTo(BigDecimal.ZERO) == 0) continue // Branch 1: no change | |||
| if (diff.compareTo(BigDecimal.ZERO) > 0) { | |||
| // Branch 2 (qty up): createStockIn | |||
| val inventoryLotLine = inventoryLotLineRepository.findById(current.id) | |||
| .orElseThrow { IllegalArgumentException("InventoryLotLine not found: ${current.id}") } | |||
| val stockInRequest = buildStockInRequestFromExistingLotLine(inventoryLotLine, diff) | |||
| val stockInLine = stockInLineService.createStockIn(stockInRequest) | |||
| saveAdjustmentRecordForStockIn(stockInLine) | |||
| } else { | |||
| // Branch 3 (qty down): createStockOut | |||
| val stockOutLine = stockOutLineService.createStockOut( | |||
| StockOutRequest( | |||
| inventoryLotLineId = current.id, | |||
| qty = diff.abs().toDouble(), | |||
| type = "ADJ" | |||
| ) | |||
| ) | |||
| saveAdjustmentRecordForStockOut(stockOutLine) | |||
| } | |||
| } | |||
| return MessageResponse( | |||
| id = null, | |||
| name = "Stock Adjustment", | |||
| code = "STOCK_ADJUSTMENT_SUBMITTED", | |||
| type = "success", | |||
| message = "Stock adjustment completed successfully", | |||
| errorPosition = null | |||
| ) | |||
| } | |||
| private fun buildStockInRequestFromExistingLotLine( | |||
| inventoryLotLine: InventoryLotLine, | |||
| acceptedQty: BigDecimal | |||
| ): StockInRequest { | |||
| val inventoryLot = inventoryLotLine.inventoryLot | |||
| ?: throw IllegalArgumentException("InventoryLotLine must have an associated InventoryLot") | |||
| val item = inventoryLot.item | |||
| ?: throw IllegalArgumentException("InventoryLot must have an associated item") | |||
| val itemId = item.id | |||
| ?: throw IllegalArgumentException("Item must have an id") | |||
| val itemCode = item.code | |||
| ?: throw IllegalArgumentException("Item must have a code") | |||
| val expiryDate = inventoryLot.expiryDate | |||
| ?: throw IllegalArgumentException("InventoryLot must have an expiryDate") | |||
| val lotNo = inventoryLot.lotNo | |||
| val productLotNo = inventoryLot.stockInLine?.productLotNo | |||
| val dnNo = inventoryLot.stockInLine?.dnNo | |||
| val warehouseId = inventoryLotLine.warehouse?.id | |||
| ?: throw IllegalArgumentException("InventoryLotLine must have a warehouse") | |||
| return StockInRequest( | |||
| itemId = itemId, | |||
| itemNo = itemCode, | |||
| demandQty = null, | |||
| acceptedQty = acceptedQty, | |||
| expiryDate = expiryDate, | |||
| lotNo = lotNo, | |||
| productLotNo = productLotNo, | |||
| dnNo = dnNo, | |||
| type = "ADJ", | |||
| warehouseId = warehouseId | |||
| ) | |||
| } | |||
| private fun saveAdjustmentRecordForStockIn(stockInLine: StockInLine) { | |||
| val item = stockInLine.item ?: return | |||
| val record = StockAdjustmentRecord().apply { | |||
| this.item = item | |||
| this.itemCode = stockInLine.itemNo ?: item.code | |||
| this.itemName = item.name | |||
| this.lotNo = stockInLine.lotNo | |||
| this.inQty = stockInLine.acceptedQty | |||
| this.outQty = null | |||
| this.stockInLine = stockInLine | |||
| this.stockOutLine = null | |||
| } | |||
| stockAdjustmentRecordRepository.save(record) | |||
| } | |||
| private fun saveAdjustmentRecordForStockOut(stockOutLine: StockOutLine) { | |||
| val item = stockOutLine.item ?: return | |||
| val lotNo = stockOutLine.inventoryLotLine?.inventoryLot?.lotNo | |||
| val record = StockAdjustmentRecord().apply { | |||
| this.item = item | |||
| this.itemCode = item.code | |||
| this.itemName = item.name | |||
| this.lotNo = lotNo | |||
| this.inQty = null | |||
| this.outQty = BigDecimal.valueOf(stockOutLine.qty ?: 0.0) | |||
| this.stockInLine = null | |||
| this.stockOutLine = stockOutLine | |||
| } | |||
| stockAdjustmentRecordRepository.save(record) | |||
| } | |||
| } | |||
| @@ -23,6 +23,7 @@ import java.io.UnsupportedEncodingException | |||
| import java.math.BigDecimal | |||
| import java.text.ParseException | |||
| import com.ffii.fpsms.modules.master.web.models.MessageResponse | |||
| import com.ffii.fpsms.modules.stock.web.model.PrintLabelForInventoryLotLineRequest | |||
| import com.ffii.fpsms.modules.stock.web.model.UpdateInventoryLotLineStatusRequest | |||
| import com.ffii.fpsms.modules.stock.web.model.QrCodeAnalysisRequest | |||
| import com.ffii.fpsms.modules.stock.web.model.QrCodeAnalysisResponse | |||
| @@ -73,7 +74,7 @@ class InventoryLotLineController ( | |||
| ) | |||
| } | |||
| @PostMapping("/print-label") | |||
| @PostMapping("/download-label") | |||
| @Throws(UnsupportedEncodingException::class, NoSuchMessageException::class, ParseException::class, Exception::class) | |||
| fun printLabel(@Valid @RequestBody request: LotLineToQrcode, response: HttpServletResponse) { | |||
| response.characterEncoding = "utf-8"; | |||
| @@ -84,6 +85,12 @@ class InventoryLotLineController ( | |||
| response.addHeader("filename", "${pdf["fileName"]}.pdf") | |||
| out.write(JasperExportManager.exportReportToPdf(jasperPrint)); | |||
| } | |||
| @GetMapping("/print-label") | |||
| fun printLabel(@ModelAttribute request: PrintLabelForInventoryLotLineRequest) { | |||
| inventoryLotLineService.printLabelForInventoryLotLine(request) | |||
| } | |||
| @PostMapping("/updateStatus") | |||
| fun updateInventoryLotLineStatus(@RequestBody request: UpdateInventoryLotLineStatusRequest): MessageResponse { | |||
| println("=== DEBUG: updateInventoryLotLineStatus Controller ===") | |||
| @@ -0,0 +1,18 @@ | |||
| package com.ffii.fpsms.modules.stock.web | |||
| import com.ffii.fpsms.modules.master.web.models.MessageResponse | |||
| import com.ffii.fpsms.modules.stock.service.StockAdjustmentService | |||
| import com.ffii.fpsms.modules.stock.web.model.StockAdjustmentRequest | |||
| import jakarta.validation.Valid | |||
| import org.springframework.web.bind.annotation.* | |||
| @RestController | |||
| @RequestMapping("/stockAdjustment") | |||
| class StockAdjustmentController( | |||
| private val stockAdjustmentService: StockAdjustmentService | |||
| ) { | |||
| @PostMapping("/submit") | |||
| fun submit(@Valid @RequestBody request: StockAdjustmentRequest): MessageResponse { | |||
| return stockAdjustmentService.submit(request) | |||
| } | |||
| } | |||
| @@ -43,7 +43,7 @@ class StockInLineController( | |||
| return stockInLineService.update(newItem) | |||
| } | |||
| @PostMapping("/print-label") | |||
| @PostMapping("/download-label") | |||
| @Throws(UnsupportedEncodingException::class, NoSuchMessageException::class, ParseException::class, Exception::class) | |||
| fun printLabel(@Valid @RequestBody request: ExportQrCodeRequest, response: HttpServletResponse) { | |||
| response.characterEncoding = "utf-8"; | |||
| @@ -55,7 +55,7 @@ class StockInLineController( | |||
| out.write(JasperExportManager.exportReportToPdf(jasperPrint)); | |||
| } | |||
| @GetMapping("/printQrCode") | |||
| @GetMapping("/print-label") | |||
| fun printQrCode(@ModelAttribute request: PrintQrCodeForSilRequest) { | |||
| stockInLineService.printQrCode(request) | |||
| } | |||
| @@ -0,0 +1,7 @@ | |||
| package com.ffii.fpsms.modules.stock.web.model | |||
| data class PrintLabelForInventoryLotLineRequest( | |||
| val inventoryLotLineId: Long, | |||
| val printerId: Long, | |||
| val printQty: Int? = null, | |||
| ) | |||
| @@ -0,0 +1,24 @@ | |||
| package com.ffii.fpsms.modules.stock.web.model | |||
| import java.math.BigDecimal | |||
| data class StockAdjustmentLineRequest( | |||
| val id: Long, | |||
| val lotNo: String? = null, | |||
| val adjustedQty: BigDecimal, | |||
| val productlotNo: String? = null, | |||
| val dnNo: String? = null, | |||
| val isOpeningInventory: Boolean = false, | |||
| val isNew: Boolean = false, | |||
| val itemId: Long, | |||
| val itemNo: String, | |||
| val expiryDate: String, | |||
| val warehouseId: Long, | |||
| val uom: String? = null | |||
| ) | |||
| data class StockAdjustmentRequest( | |||
| val itemId: Long, | |||
| val originalLines: List<StockAdjustmentLineRequest>, | |||
| val currentLines: List<StockAdjustmentLineRequest> | |||
| ) | |||
| @@ -0,0 +1,29 @@ | |||
| -- liquibase formatted sql | |||
| -- changeset KelvinY:create_stock_adjustment_record_table | |||
| CREATE TABLE IF NOT EXISTS `stock_adjustment_record` ( | |||
| `id` BIGINT NOT NULL AUTO_INCREMENT, | |||
| `created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, | |||
| `createdBy` VARCHAR(255) DEFAULT NULL, | |||
| `version` INT NOT NULL DEFAULT 0, | |||
| `modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, | |||
| `modifiedBy` VARCHAR(255) DEFAULT NULL, | |||
| `deleted` TINYINT(1) NOT NULL DEFAULT 0, | |||
| `itemId` INT NOT NULL, | |||
| `itemCode` VARCHAR(50) DEFAULT NULL, | |||
| `itemName` VARCHAR(200) DEFAULT NULL, | |||
| `lotNo` VARCHAR(512) DEFAULT NULL, | |||
| `inQty` DECIMAL(14,2) DEFAULT NULL, | |||
| `outQty` DECIMAL(14,2) DEFAULT NULL, | |||
| `stockInLineId` INT DEFAULT NULL, | |||
| `stockOutLineId` INT DEFAULT NULL, | |||
| PRIMARY KEY (`id`), | |||
| INDEX `idx_stock_adjustment_record_itemId` (`itemId`), | |||
| INDEX `idx_stock_adjustment_record_stockInLineId` (`stockInLineId`), | |||
| INDEX `idx_stock_adjustment_record_stockOutLineId` (`stockOutLineId`), | |||
| CONSTRAINT `fk_stock_adjustment_record_item` FOREIGN KEY (`itemId`) REFERENCES `items` (`id`), | |||
| CONSTRAINT `fk_stock_adjustment_record_stock_in_line` FOREIGN KEY (`stockInLineId`) REFERENCES `stock_in_line` (`id`), | |||
| CONSTRAINT `fk_stock_adjustment_record_stock_out_line` FOREIGN KEY (`stockOutLineId`) REFERENCES `stock_out_line` (`id`) | |||
| ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; | |||