Просмотр исходного кода

price inquiry

reset-do-picking-order
kelvin.yau 1 неделю назад
Родитель
Сommit
d573a13bd9
9 измененных файлов: 224 добавлений и 12 удалений
  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 Просмотреть файл

@@ -213,6 +213,7 @@ open class M18PurchaseOrderService(
val successDetailList = mutableListOf<Long>() val successDetailList = mutableListOf<Long>()
val failList = mutableListOf<Long>() val failList = mutableListOf<Long>()
val failDetailList = mutableListOf<Long>() val failDetailList = mutableListOf<Long>()
val affectedItemIds = mutableSetOf<Long>()


val poRefType = "Purchase Order" val poRefType = "Purchase Order"
val poLineRefType = "Purchase Order Line" val poLineRefType = "Purchase Order Line"
@@ -409,6 +410,7 @@ open class M18PurchaseOrderService(
successDetailList.add(line.id) successDetailList.add(line.id)
// logger.info("${poLineRefType}: Purchase order ID: ${purchaseOrderId} | M18 ID: ${purchaseOrder.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}") 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) { } catch (e: Exception) {
failDetailList.add(line.id) failDetailList.add(line.id)
// logger.error("${poLineRefType}: Saving Failure!") // 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) // Mark as deleted any local PO lines that no longer exist in M18 (removed there)
if (purchaseOrderId != null) { if (purchaseOrderId != null) {
val m18LineIds = pot.map { it.id }.toSet() val m18LineIds = pot.map { it.id }.toSet()
val markedDeleted =
val (markedDeleted, deletedItemIds) =
purchaseOrderLineService.markDeletedLinesNotInM18(purchaseOrderId, m18LineIds) purchaseOrderLineService.markDeletedLinesNotInM18(purchaseOrderId, m18LineIds)
affectedItemIds.addAll(deletedItemIds)
if (markedDeleted > 0) { if (markedDeleted > 0) {
logger.info("${poLineRefType}: Marked $markedDeleted line(s) as deleted (not in M18). PO ID: $purchaseOrderId | M18 PO ID: ${purchaseOrder.id}") 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") logger.error("Total Fail (${poLineRefType}) (${failDetailList.size}): $failDetailList")
// } // }


val feeMarked = purchaseOrderLineService.markDeletedLinesWithFeeItems()
val (feeMarked, feeItemIds) = purchaseOrderLineService.markDeletedLinesWithFeeItems()
affectedItemIds.addAll(feeItemIds)
if (feeMarked > 0) { if (feeMarked > 0) {
logger.info("Marked $feeMarked PO line(s) as deleted (isFee items).") 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--------------------------------------------") logger.info("--------------------------------------------End - Saving M18 Purchase Order--------------------------------------------")


return SyncResult( return SyncResult(


+ 9
- 0
src/main/java/com/ffii/fpsms/modules/master/entity/Items.kt Просмотреть файл

@@ -67,6 +67,15 @@ open class Items : BaseEntity<Long>() {
@Column(name = "m18LastModifyDate") @Column(name = "m18LastModifyDate")
open var m18LastModifyDate: LocalDateTime? = null 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 @JsonManagedReference
@OneToMany(mappedBy = "item", cascade = [CascadeType.ALL], orphanRemoval = true) @OneToMany(mappedBy = "item", cascade = [CascadeType.ALL], orphanRemoval = true)
open var itemUoms: MutableList<ItemUom> = mutableListOf() open var itemUoms: MutableList<ItemUom> = mutableListOf()


+ 1
- 1
src/main/java/com/ffii/fpsms/modules/master/entity/Warehouse.kt Просмотреть файл

@@ -44,7 +44,7 @@ open class Warehouse : BaseEntity<Long>() {


@Column(name = "stockTakeSection", nullable = true, length = 255) @Column(name = "stockTakeSection", nullable = true, length = 255)
open var stockTakeSection: String? = null open var stockTakeSection: String? = null
@Column(name = "stockTakeSectionDescription", nullable = true, length = 255) @Column(name = "stockTakeSectionDescription", nullable = true, length = 255)
open var stockTakeSectionDescription: String? = null open var stockTakeSectionDescription: String? = null
} }

+ 145
- 2
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.format.DateTimeFormatter
import java.time.temporal.TemporalAdjusters import java.time.temporal.TemporalAdjusters
import kotlin.jvm.optionals.getOrNull import kotlin.jvm.optionals.getOrNull
import com.ffii.core.utils.ExcelUtils
import org.apache.poi.ss.usermodel.Cell import org.apache.poi.ss.usermodel.Cell
import org.apache.poi.ss.usermodel.CellType import org.apache.poi.ss.usermodel.CellType
import org.apache.poi.ss.usermodel.Sheet import org.apache.poi.ss.usermodel.Sheet
import org.apache.poi.ss.usermodel.Workbook import org.apache.poi.ss.usermodel.Workbook
import org.apache.poi.xssf.usermodel.XSSFWorkbook import org.apache.poi.xssf.usermodel.XSSFWorkbook
import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.math.BigDecimal import java.math.BigDecimal
@@ -399,9 +401,15 @@ open class ItemsService(
"i.description, " + "i.description, " +
"i.type, " + "i.type, " +
"i.`LocationCode` as LocationCode, " + "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 " + "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")) { if (args.containsKey("name")) {
@@ -415,6 +423,141 @@ open class ItemsService(
return jdbcDao.queryForList(sql.toString(), args); 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? { open fun findById(id: Long): Items? {
return itemsRepository.findByIdAndDeletedFalse(id); return itemsRepository.findByIdAndDeletedFalse(id);
} }


+ 32
- 2
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 com.ffii.fpsms.modules.master.web.models.NewItemRequest
import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletRequest
import jakarta.validation.Valid 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.bind.annotation.*
import org.springframework.web.multipart.MultipartFile
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import java.util.Collections.emptyList import java.util.Collections.emptyList
import org.slf4j.Logger import org.slf4j.Logger
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.http.ResponseEntity


@RestController @RestController
@RequestMapping("/items") @RequestMapping("/items")
@@ -132,5 +136,31 @@ fun getItemsWithDetailsByPage(request: HttpServletRequest): RecordsRes<Map<Strin


return RecordsRes(paginatedList, fullList.size) 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 Просмотреть файл

@@ -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 * 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). * 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 = val linesFromM18 =
purchaseOrderLineRepository.findAllByPurchaseOrderIdAndDeletedIsFalseAndM18DataLogIsNotNull(purchaseOrderId) purchaseOrderLineRepository.findAllByPurchaseOrderIdAndDeletedIsFalseAndM18DataLogIsNotNull(purchaseOrderId)
val affectedItemIds = mutableSetOf<Long>()
var count = 0 var count = 0
linesFromM18.forEach { line -> linesFromM18.forEach { line ->
val m18Id = line.m18DataLog?.m18Id ?: return@forEach val m18Id = line.m18DataLog?.m18Id ?: return@forEach
if (m18Id !in m18LineIds) { if (m18Id !in m18LineIds) {
line.item?.id?.let { affectedItemIds.add(it) }
line.deleted = true line.deleted = true
purchaseOrderLineRepository.saveAndFlush(line) purchaseOrderLineRepository.saveAndFlush(line)
count++ count++
} }
} }
return count
return Pair(count, affectedItemIds)
} }


open fun findAllPoLineInfoByPoId(poId: Long): List<PurchaseOrderLineInfo> { open fun findAllPoLineInfoByPoId(poId: Long): List<PurchaseOrderLineInfo> {
@@ -99,12 +101,16 @@ open class PurchaseOrderLineService(
return savedPurchaseOrderLine 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 feeLines = purchaseOrderLineRepository.findAllByDeletedIsFalseAndItemIsFeeTrue()
val affectedItemIds = feeLines.mapNotNull { it.item?.id }.toSet()
feeLines.forEach { line -> feeLines.forEach { line ->
line.deleted = true line.deleted = true
purchaseOrderLineRepository.saveAndFlush(line) 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 Просмотреть файл

@@ -2,6 +2,7 @@ package com.ffii.fpsms.modules.stock.entity.projection


import org.springframework.beans.factory.annotation.Value import org.springframework.beans.factory.annotation.Value
import java.math.BigDecimal import java.math.BigDecimal
import java.time.LocalDateTime


interface InventoryInfo{ interface InventoryInfo{
val id: Long? val id: Long?
@@ -52,4 +53,8 @@ interface InventoryInfo{
@get:Value("#{target.currency?.name}") @get:Value("#{target.currency?.name}")
val currencyName: String? val currencyName: String?
val status: 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 Просмотреть файл

@@ -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 Просмотреть файл

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

Загрузка…
Отмена
Сохранить