From d573a13bd9322aa0ed17dbb1bcdaa2a5c5a20c56 Mon Sep 17 00:00:00 2001 From: "kelvin.yau" Date: Sun, 15 Mar 2026 01:39:43 +0800 Subject: [PATCH] price inquiry --- .../m18/service/M18PurchaseOrderService.kt | 13 +- .../ffii/fpsms/modules/master/entity/Items.kt | 9 ++ .../fpsms/modules/master/entity/Warehouse.kt | 2 +- .../modules/master/service/ItemsService.kt | 147 +++++++++++++++++- .../modules/master/web/ItemsController.kt | 34 +++- .../service/PurchaseOrderLineService.kt | 16 +- .../stock/entity/projection/InventoryInfo.kt | 5 + ...1_alter_items_latest_market_unit_price.sql | 5 + ..._latest_market_unit_price_two_decimals.sql | 5 + 9 files changed, 224 insertions(+), 12 deletions(-) create mode 100644 src/main/resources/db/changelog/changes/20260314_01_KelvinY/01_alter_items_latest_market_unit_price.sql create mode 100644 src/main/resources/db/changelog/changes/20260326_01_KelvinY/01_alter_items_latest_market_unit_price_two_decimals.sql diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt index 1f36616..04aed1a 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt +++ b/src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt @@ -213,6 +213,7 @@ open class M18PurchaseOrderService( val successDetailList = mutableListOf() val failList = mutableListOf() val failDetailList = mutableListOf() + val affectedItemIds = mutableSetOf() 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( diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/Items.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/Items.kt index b0fe8c2..5428626 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/Items.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/Items.kt @@ -67,6 +67,15 @@ open class Items : BaseEntity() { @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 = mutableListOf() diff --git a/src/main/java/com/ffii/fpsms/modules/master/entity/Warehouse.kt b/src/main/java/com/ffii/fpsms/modules/master/entity/Warehouse.kt index bd4a1b8..a25235a 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/entity/Warehouse.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/entity/Warehouse.kt @@ -44,7 +44,7 @@ open class Warehouse : BaseEntity() { @Column(name = "stockTakeSection", nullable = true, length = 255) open var stockTakeSection: String? = null - + @Column(name = "stockTakeSectionDescription", nullable = true, length = 255) open var stockTakeSectionDescription: String? = null } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt index c9fd9a9..f59903e 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt @@ -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) { + 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 { + val errors = mutableListOf() + 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); } diff --git a/src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt b/src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt index d89e80f..f256d6c 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt @@ -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 { + 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> { + 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) + } + } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderLineService.kt b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderLineService.kt index 99114ec..8909c13 100644 --- a/src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderLineService.kt +++ b/src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderLineService.kt @@ -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): Int { + open fun markDeletedLinesNotInM18(purchaseOrderId: Long, m18LineIds: Set): Pair> { val linesFromM18 = purchaseOrderLineRepository.findAllByPurchaseOrderIdAndDeletedIsFalseAndM18DataLogIsNotNull(purchaseOrderId) + val affectedItemIds = mutableSetOf() 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 { @@ -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> { 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) } } \ No newline at end of file diff --git a/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryInfo.kt b/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryInfo.kt index 39ed573..be8d777 100644 --- a/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryInfo.kt +++ b/src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryInfo.kt @@ -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? } diff --git a/src/main/resources/db/changelog/changes/20260314_01_KelvinY/01_alter_items_latest_market_unit_price.sql b/src/main/resources/db/changelog/changes/20260314_01_KelvinY/01_alter_items_latest_market_unit_price.sql new file mode 100644 index 0000000..ef8adf3 --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260314_01_KelvinY/01_alter_items_latest_market_unit_price.sql @@ -0,0 +1,5 @@ +-- liquibase formatted sql +-- changeset :alter_items_latest_market_unit_price_to_decimal + +ALTER TABLE `fpsmsdb`.`items` +MODIFY COLUMN `LatestMarketUnitPrice` DECIMAL(19,4) NULL; \ No newline at end of file diff --git a/src/main/resources/db/changelog/changes/20260326_01_KelvinY/01_alter_items_latest_market_unit_price_two_decimals.sql b/src/main/resources/db/changelog/changes/20260326_01_KelvinY/01_alter_items_latest_market_unit_price_two_decimals.sql new file mode 100644 index 0000000..922cf6e --- /dev/null +++ b/src/main/resources/db/changelog/changes/20260326_01_KelvinY/01_alter_items_latest_market_unit_price_two_decimals.sql @@ -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;