Pārlūkot izejas kodu

price inquiry

reset-do-picking-order
kelvin.yau pirms 1 nedēļas
vecāks
revīzija
d573a13bd9
9 mainītis faili ar 224 papildinājumiem un 12 dzēšanām
  1. +11
    -2
      src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt
  2. +9
    -0
      src/main/java/com/ffii/fpsms/modules/master/entity/Items.kt
  3. +1
    -1
      src/main/java/com/ffii/fpsms/modules/master/entity/Warehouse.kt
  4. +145
    -2
      src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt
  5. +32
    -2
      src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt
  6. +11
    -5
      src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderLineService.kt
  7. +5
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryInfo.kt
  8. +5
    -0
      src/main/resources/db/changelog/changes/20260314_01_KelvinY/01_alter_items_latest_market_unit_price.sql
  9. +5
    -0
      src/main/resources/db/changelog/changes/20260326_01_KelvinY/01_alter_items_latest_market_unit_price_two_decimals.sql

+ 11
- 2
src/main/java/com/ffii/fpsms/m18/service/M18PurchaseOrderService.kt Parādīt failu

@@ -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(


+ 9
- 0
src/main/java/com/ffii/fpsms/modules/master/entity/Items.kt Parādīt failu

@@ -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()


+ 1
- 1
src/main/java/com/ffii/fpsms/modules/master/entity/Warehouse.kt Parādīt failu

@@ -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
}

+ 145
- 2
src/main/java/com/ffii/fpsms/modules/master/service/ItemsService.kt Parādīt failu

@@ -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);
}


+ 32
- 2
src/main/java/com/ffii/fpsms/modules/master/web/ItemsController.kt Parādīt failu

@@ -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)
}
}
}

+ 11
- 5
src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderLineService.kt Parādīt failu

@@ -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)
}
}

+ 5
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/projection/InventoryInfo.kt Parādīt failu

@@ -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?
}

+ 5
- 0
src/main/resources/db/changelog/changes/20260314_01_KelvinY/01_alter_items_latest_market_unit_price.sql Parādīt failu

@@ -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;

+ 5
- 0
src/main/resources/db/changelog/changes/20260326_01_KelvinY/01_alter_items_latest_market_unit_price_two_decimals.sql Parādīt failu

@@ -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;

Notiek ielāde…
Atcelt
Saglabāt