| @@ -213,6 +213,7 @@ open class M18PurchaseOrderService( | |||
| val successDetailList = mutableListOf<Long>() | |||
| val failList = mutableListOf<Long>() | |||
| val failDetailList = mutableListOf<Long>() | |||
| val affectedItemIds = mutableSetOf<Long>() | |||
| val poRefType = "Purchase Order" | |||
| val poLineRefType = "Purchase Order Line" | |||
| @@ -409,6 +410,7 @@ open class M18PurchaseOrderService( | |||
| successDetailList.add(line.id) | |||
| // logger.info("${poLineRefType}: Purchase order ID: ${purchaseOrderId} | M18 ID: ${purchaseOrder.id}") | |||
| logger.info("${poLineRefType}: Saved purchase order line. ID: ${savePurchaseOrderLineResponse.id} | M18 Line ID: ${line.id} | Purchase order ID: ${purchaseOrderId} | M18 ID: ${purchaseOrder.id}") | |||
| itemId?.let { affectedItemIds.add(it) } | |||
| } catch (e: Exception) { | |||
| failDetailList.add(line.id) | |||
| // logger.error("${poLineRefType}: Saving Failure!") | |||
| @@ -428,8 +430,9 @@ open class M18PurchaseOrderService( | |||
| // Mark as deleted any local PO lines that no longer exist in M18 (removed there) | |||
| if (purchaseOrderId != null) { | |||
| val m18LineIds = pot.map { it.id }.toSet() | |||
| val markedDeleted = | |||
| val (markedDeleted, deletedItemIds) = | |||
| purchaseOrderLineService.markDeletedLinesNotInM18(purchaseOrderId, m18LineIds) | |||
| affectedItemIds.addAll(deletedItemIds) | |||
| if (markedDeleted > 0) { | |||
| logger.info("${poLineRefType}: Marked $markedDeleted line(s) as deleted (not in M18). PO ID: $purchaseOrderId | M18 PO ID: ${purchaseOrder.id}") | |||
| } | |||
| @@ -515,11 +518,17 @@ open class M18PurchaseOrderService( | |||
| logger.error("Total Fail (${poLineRefType}) (${failDetailList.size}): $failDetailList") | |||
| // } | |||
| val feeMarked = purchaseOrderLineService.markDeletedLinesWithFeeItems() | |||
| val (feeMarked, feeItemIds) = purchaseOrderLineService.markDeletedLinesWithFeeItems() | |||
| affectedItemIds.addAll(feeItemIds) | |||
| if (feeMarked > 0) { | |||
| logger.info("Marked $feeMarked PO line(s) as deleted (isFee items).") | |||
| } | |||
| if (affectedItemIds.isNotEmpty()) { | |||
| itemsService.updateAverageUnitPriceForItems(affectedItemIds) | |||
| logger.info("Average unit price updated for ${affectedItemIds.size} item(s) after PO sync.") | |||
| } | |||
| logger.info("--------------------------------------------End - Saving M18 Purchase Order--------------------------------------------") | |||
| return SyncResult( | |||
| @@ -67,6 +67,15 @@ open class Items : BaseEntity<Long>() { | |||
| @Column(name = "m18LastModifyDate") | |||
| open var m18LastModifyDate: LocalDateTime? = null | |||
| @Column(name = "AverageUnitPrice", nullable = true, length = 255) | |||
| open var averageUnitPrice: String? = null | |||
| @Column(name = "LatestMarketUnitPrice", nullable = true) | |||
| open var latestMarketUnitPrice: Double? = null | |||
| @Column(name = "latestMupUpdatedDate", nullable = true) | |||
| open var latestMupUpdatedDate: LocalDateTime? = null | |||
| @JsonManagedReference | |||
| @OneToMany(mappedBy = "item", cascade = [CascadeType.ALL], orphanRemoval = true) | |||
| open var itemUoms: MutableList<ItemUom> = mutableListOf() | |||
| @@ -44,7 +44,7 @@ open class Warehouse : BaseEntity<Long>() { | |||
| @Column(name = "stockTakeSection", nullable = true, length = 255) | |||
| open var stockTakeSection: String? = null | |||
| @Column(name = "stockTakeSectionDescription", nullable = true, length = 255) | |||
| open var stockTakeSectionDescription: String? = null | |||
| } | |||
| @@ -15,11 +15,13 @@ import java.time.LocalDateTime | |||
| import java.time.format.DateTimeFormatter | |||
| import java.time.temporal.TemporalAdjusters | |||
| import kotlin.jvm.optionals.getOrNull | |||
| import com.ffii.core.utils.ExcelUtils | |||
| import org.apache.poi.ss.usermodel.Cell | |||
| import org.apache.poi.ss.usermodel.CellType | |||
| import org.apache.poi.ss.usermodel.Sheet | |||
| import org.apache.poi.ss.usermodel.Workbook | |||
| import org.apache.poi.xssf.usermodel.XSSFWorkbook | |||
| import java.io.ByteArrayOutputStream | |||
| import java.io.File | |||
| import java.io.FileInputStream | |||
| import java.math.BigDecimal | |||
| @@ -399,9 +401,15 @@ open class ItemsService( | |||
| "i.description, " + | |||
| "i.type, " + | |||
| "i.`LocationCode` as LocationCode, " + | |||
| "i.`qcCategoryId` as qcCategoryId " + | |||
| "i.`qcCategoryId` as qcCategoryId, " + | |||
| "i.AverageUnitPrice as averageUnitPrice, " + | |||
| "i.LatestMarketUnitPrice as latestMarketUnitPrice, " + | |||
| "DATE_FORMAT(i.latestMupUpdatedDate, '%Y-%m-%d %H:%i') as latestMupUpdatedDate, " + | |||
| "uc_p.udfudesc as purchaseUnit " + | |||
| "FROM items i " + | |||
| "WHERE i.deleted = FALSE" | |||
| "LEFT JOIN item_uom iu_p ON iu_p.itemId = i.id AND iu_p.deleted = false AND iu_p.purchaseUnit = true " + | |||
| "LEFT JOIN uom_conversion uc_p ON uc_p.id = iu_p.uomId " + | |||
| "WHERE i.deleted = false" | |||
| ); | |||
| if (args.containsKey("name")) { | |||
| @@ -415,6 +423,141 @@ open class ItemsService( | |||
| return jdbcDao.queryForList(sql.toString(), args); | |||
| } | |||
| /** | |||
| * Incremental update of AverageUnitPrice for the given items only. | |||
| * Recomputes weighted average (SUM(up*qty)/SUM(qty)) from purchase_order_line | |||
| * where po.orderDate >= '2026-01-01', then updates items.AverageUnitPrice. | |||
| * Items with no qualifying PO lines get AverageUnitPrice = null. | |||
| */ | |||
| @Transactional | |||
| open fun updateAverageUnitPriceForItems(itemIds: Set<Long>) { | |||
| if (itemIds.isEmpty()) return | |||
| val idList = itemIds.toList() | |||
| val args = mapOf("itemIds" to idList) | |||
| val avgSql = """ | |||
| SELECT pol.itemId AS itemId, | |||
| ROUND(CAST(SUM(pol.up * pol.qty) / NULLIF(SUM(pol.qty), 0) AS DECIMAL(14,2)), 2) AS avgUp | |||
| FROM purchase_order_line pol | |||
| JOIN purchase_order po ON po.id = pol.purchaseOrderId AND po.deleted = false | |||
| WHERE pol.itemId IN (:itemIds) AND pol.deleted = false | |||
| AND po.orderDate >= '2026-01-01' | |||
| AND pol.up IS NOT NULL AND pol.qty > 0 | |||
| GROUP BY pol.itemId | |||
| """.trimIndent() | |||
| val rows = jdbcDao.queryForList(avgSql, args) | |||
| val avgByItem = rows.mapNotNull { row -> | |||
| val id = (row["itemId"] as? Number)?.toLong() ?: return@mapNotNull null | |||
| val avg = row["avgUp"] as? BigDecimal ?: return@mapNotNull null | |||
| id to avg.toPlainString() | |||
| }.toMap() | |||
| idList.forEach { itemId -> | |||
| val item = itemsRepository.findById(itemId).orElse(null) ?: return@forEach | |||
| item.averageUnitPrice = avgByItem[itemId] | |||
| itemsRepository.saveAndFlush(item) | |||
| } | |||
| } | |||
| /** Column headers in Chinese for market unit price template/import */ | |||
| private val MARKET_UNIT_PRICE_HEADERS_ZH = listOf("項目編號", "項目名稱", "採購單位", "最新市場價格") | |||
| /** Accepted column 3 header (price column); 最新市場單位價格 allowed for backward compatibility. */ | |||
| private val ACCEPTED_COL3_ALIASES = setOf("最新市場價格", "最新市場單位價格", "Latest Market Unit Price", "Latest Market Price", "Market Unit Price") | |||
| /** | |||
| * Generates Excel template for latest market unit price upload. | |||
| * Columns: 項目編號, 項目名稱, 採購單位, 最新市場價格 (empty). | |||
| */ | |||
| open fun generateMarketUnitPriceTemplate(): ByteArray { | |||
| val data = getItemsByPage(emptyMap()) | |||
| val workbook = XSSFWorkbook() | |||
| val sheet = workbook.createSheet("Market Unit Price") | |||
| val headerRow = sheet.createRow(0) | |||
| MARKET_UNIT_PRICE_HEADERS_ZH.forEachIndexed { index, header -> | |||
| headerRow.createCell(index).setCellValue(header) | |||
| } | |||
| data.forEachIndexed { index, row -> | |||
| val r = sheet.createRow(index + 1) | |||
| r.createCell(0).setCellValue((row["code"]?.toString() ?: "").trim()) | |||
| r.createCell(1).setCellValue((row["name"]?.toString() ?: "").trim()) | |||
| r.createCell(2).setCellValue((row["purchaseUnit"]?.toString() ?: "").trim()) | |||
| r.createCell(3).setCellValue("") // empty for user to fill | |||
| } | |||
| val baos = ByteArrayOutputStream() | |||
| workbook.use { it.write(baos) } | |||
| return baos.toByteArray() | |||
| } | |||
| /** | |||
| * Imports latest market unit price from Excel. Validates header row matches expected format. | |||
| * @return map with "success", "updated" count, "errors" list | |||
| */ | |||
| @Transactional | |||
| open fun importMarketUnitPriceExcel(workbook: Workbook?): Map<String, Any> { | |||
| val errors = mutableListOf<String>() | |||
| if (workbook == null) { | |||
| return mapOf("success" to false, "updated" to 0, "errors" to listOf("No file or invalid Excel file.")) | |||
| } | |||
| val sheet = workbook.getSheetAt(0) ?: return mapOf("success" to false, "updated" to 0, "errors" to listOf("Sheet not found.")) | |||
| val headerRow = sheet.getRow(0) ?: return mapOf("success" to false, "updated" to 0, "errors" to listOf("Header row not found.")) | |||
| // Validate header format: exact 4 columns 項目編號, 項目名稱, 採購單位, 最新市場價格 | |||
| val actualHeaders = (0..3).map { ExcelUtils.getStringValue(headerRow.getCell(it)).trim() } | |||
| val expectedCol0 = listOf("項目編號", "Item Code") | |||
| val expectedCol1 = listOf("項目名稱", "Item Name") | |||
| val expectedCol2 = listOf("採購單位", "Purchase Unit") | |||
| val validFormat = (actualHeaders[0] in expectedCol0) | |||
| && (actualHeaders[1] in expectedCol1) | |||
| && (actualHeaders[2] in expectedCol2) | |||
| && (actualHeaders[3] in ACCEPTED_COL3_ALIASES) | |||
| if (!validFormat) { | |||
| errors.add("Invalid format: first row must be exactly 項目編號, 項目名稱, 採購單位, 最新市場價格 (or English: Item Code, Item Name, Purchase Unit, Latest Market Price). Got: ${actualHeaders.joinToString(", ")}") | |||
| return mapOf("success" to false, "updated" to 0, "errors" to errors) | |||
| } | |||
| if (sheet.lastRowNum < 1) { | |||
| errors.add("No data rows: file must contain at least one data row after the header.") | |||
| return mapOf("success" to false, "updated" to 0, "errors" to errors) | |||
| } | |||
| var updated = 0 | |||
| val now = LocalDateTime.now() | |||
| for (i in 1..sheet.lastRowNum) { | |||
| val row = sheet.getRow(i) ?: continue | |||
| val codeCell = row.getCell(0) | |||
| if (codeCell == null) { | |||
| errors.add("Row ${i + 1}: missing 項目編號 (column A).") | |||
| continue | |||
| } | |||
| val code = ExcelUtils.getStringValue(codeCell).trim() | |||
| val priceCell = row.getCell(3) | |||
| val priceStr = if (priceCell != null) ExcelUtils.getStringValue(priceCell).trim() else "" | |||
| if (code.isEmpty() && priceStr.isEmpty()) continue | |||
| if (code.isEmpty()) { | |||
| errors.add("Row ${i + 1}: 項目編號 (column A) is required when 最新市場價格 is filled.") | |||
| continue | |||
| } | |||
| // Empty 最新市場價格 is okay: skip row and do not update | |||
| if (priceStr.isEmpty()) continue | |||
| val price = try { | |||
| if (priceCell != null && priceCell.cellType == CellType.NUMERIC) ExcelUtils.getDoubleValue(priceCell) else priceStr.toDouble() | |||
| } catch (_: NumberFormatException) { | |||
| errors.add("Row ${i + 1}: invalid price '$priceStr' for item $code") | |||
| continue | |||
| } | |||
| if (price < 0) { | |||
| errors.add("Row ${i + 1}: price must be non-negative for item $code") | |||
| continue | |||
| } | |||
| val item = findByCode(code) | |||
| if (item == null) { | |||
| errors.add("Row ${i + 1}: item not found for code '$code'") | |||
| continue | |||
| } | |||
| item.latestMarketUnitPrice = price | |||
| item.latestMupUpdatedDate = now | |||
| itemsRepository.saveAndFlush(item) | |||
| updated++ | |||
| } | |||
| return mapOf("success" to true, "updated" to updated, "errors" to errors) | |||
| } | |||
| open fun findById(id: Long): Items? { | |||
| return itemsRepository.findByIdAndDeletedFalse(id); | |||
| } | |||
| @@ -11,11 +11,15 @@ import com.ffii.fpsms.modules.master.web.models.MessageResponse | |||
| import com.ffii.fpsms.modules.master.web.models.NewItemRequest | |||
| import jakarta.servlet.http.HttpServletRequest | |||
| import jakarta.validation.Valid | |||
| import org.springframework.http.HttpHeaders | |||
| import org.springframework.http.MediaType | |||
| import org.springframework.http.ResponseEntity | |||
| import org.springframework.web.bind.annotation.* | |||
| import org.springframework.web.multipart.MultipartFile | |||
| import org.apache.poi.xssf.usermodel.XSSFWorkbook | |||
| import java.util.Collections.emptyList | |||
| import org.slf4j.Logger | |||
| import org.slf4j.LoggerFactory | |||
| import org.springframework.http.ResponseEntity | |||
| @RestController | |||
| @RequestMapping("/items") | |||
| @@ -132,5 +136,31 @@ fun getItemsWithDetailsByPage(request: HttpServletRequest): RecordsRes<Map<Strin | |||
| return RecordsRes(paginatedList, fullList.size) | |||
| } | |||
| @GetMapping("/marketUnitPrice/template") | |||
| fun downloadMarketUnitPriceTemplate(): ResponseEntity<ByteArray> { | |||
| val bytes = itemsService.generateMarketUnitPriceTemplate() | |||
| val headers = HttpHeaders().apply { | |||
| contentType = MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") | |||
| setContentDispositionFormData("attachment", "market_unit_price_template.xlsx") | |||
| } | |||
| return ResponseEntity.ok().headers(headers).body(bytes) | |||
| } | |||
| @PostMapping("/marketUnitPrice/import") | |||
| fun importMarketUnitPrice(@RequestParam("file") file: MultipartFile): ResponseEntity<Map<String, Any>> { | |||
| if (file.isEmpty) { | |||
| return ResponseEntity.badRequest().body(mapOf("success" to false, "updated" to 0, "errors" to listOf("No file uploaded."))) | |||
| } | |||
| val workbook = try { | |||
| XSSFWorkbook(file.inputStream) | |||
| } catch (e: Exception) { | |||
| logger.warn("Failed to parse Excel file", e) | |||
| return ResponseEntity.badRequest().body(mapOf("success" to false, "updated" to 0, "errors" to listOf("Invalid Excel file."))) | |||
| } | |||
| workbook.use { | |||
| val result = itemsService.importMarketUnitPriceExcel(it) | |||
| return ResponseEntity.ok(result) | |||
| } | |||
| } | |||
| } | |||
| @@ -37,21 +37,23 @@ open class PurchaseOrderLineService( | |||
| /** | |||
| * Mark as deleted any local PO lines for this PO that were synced from M18 but whose M18 line id | |||
| * is not in the given set (i.e. the line was deleted in M18). | |||
| * @return number of lines marked as deleted | |||
| * @return Pair of (number of lines marked as deleted, set of itemIds of those lines) | |||
| */ | |||
| open fun markDeletedLinesNotInM18(purchaseOrderId: Long, m18LineIds: Set<Long>): Int { | |||
| open fun markDeletedLinesNotInM18(purchaseOrderId: Long, m18LineIds: Set<Long>): Pair<Int, Set<Long>> { | |||
| val linesFromM18 = | |||
| purchaseOrderLineRepository.findAllByPurchaseOrderIdAndDeletedIsFalseAndM18DataLogIsNotNull(purchaseOrderId) | |||
| val affectedItemIds = mutableSetOf<Long>() | |||
| var count = 0 | |||
| linesFromM18.forEach { line -> | |||
| val m18Id = line.m18DataLog?.m18Id ?: return@forEach | |||
| if (m18Id !in m18LineIds) { | |||
| line.item?.id?.let { affectedItemIds.add(it) } | |||
| line.deleted = true | |||
| purchaseOrderLineRepository.saveAndFlush(line) | |||
| count++ | |||
| } | |||
| } | |||
| return count | |||
| return Pair(count, affectedItemIds) | |||
| } | |||
| open fun findAllPoLineInfoByPoId(poId: Long): List<PurchaseOrderLineInfo> { | |||
| @@ -99,12 +101,16 @@ open class PurchaseOrderLineService( | |||
| return savedPurchaseOrderLine | |||
| } | |||
| open fun markDeletedLinesWithFeeItems(): Int { | |||
| /** | |||
| * @return Pair of (number of lines marked as deleted, set of itemIds of those lines) | |||
| */ | |||
| open fun markDeletedLinesWithFeeItems(): Pair<Int, Set<Long>> { | |||
| val feeLines = purchaseOrderLineRepository.findAllByDeletedIsFalseAndItemIsFeeTrue() | |||
| val affectedItemIds = feeLines.mapNotNull { it.item?.id }.toSet() | |||
| feeLines.forEach { line -> | |||
| line.deleted = true | |||
| purchaseOrderLineRepository.saveAndFlush(line) | |||
| } | |||
| return feeLines.size | |||
| return Pair(feeLines.size, affectedItemIds) | |||
| } | |||
| } | |||
| @@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.stock.entity.projection | |||
| import org.springframework.beans.factory.annotation.Value | |||
| import java.math.BigDecimal | |||
| import java.time.LocalDateTime | |||
| interface InventoryInfo{ | |||
| val id: Long? | |||
| @@ -52,4 +53,8 @@ interface InventoryInfo{ | |||
| @get:Value("#{target.currency?.name}") | |||
| val currencyName: String? | |||
| val status: String? | |||
| @get:Value("#{target.item.latestMarketUnitPrice}") | |||
| val latestMarketUnitPrice: Double? | |||
| @get:Value("#{target.item.latestMupUpdatedDate}") | |||
| val latestMupUpdatedDate: LocalDateTime? | |||
| } | |||
| @@ -0,0 +1,5 @@ | |||
| -- liquibase formatted sql | |||
| -- changeset <yourname>:alter_items_latest_market_unit_price_to_decimal | |||
| ALTER TABLE `fpsmsdb`.`items` | |||
| MODIFY COLUMN `LatestMarketUnitPrice` DECIMAL(19,4) NULL; | |||
| @@ -0,0 +1,5 @@ | |||
| -- liquibase formatted sql | |||
| -- changeset KelvinY:alter_items_latest_market_unit_price_two_decimals | |||
| ALTER TABLE `fpsmsdb`.`items` | |||
| MODIFY COLUMN `LatestMarketUnitPrice` DECIMAL(19,2) NULL; | |||