3 Commity

24 zmienionych plików z 378 dodań i 141 usunięć
  1. +1
    -14
      src/main/java/com/ffii/fpsms/m18/M18GrnRules.kt
  2. +1
    -1
      src/main/java/com/ffii/fpsms/modules/common/SettingNames.java
  3. +1
    -1
      src/main/java/com/ffii/fpsms/modules/common/alert/SchedulerSyncAlertProperties.kt
  4. +2
    -2
      src/main/java/com/ffii/fpsms/modules/common/alert/SchedulerSyncAlertService.kt
  5. +253
    -0
      src/main/java/com/ffii/fpsms/modules/common/demo/DemoSandboxSeeder.kt
  6. +3
    -3
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoFloorSupplierSettingsService.kt
  7. +5
    -1
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoPickOrderService.kt
  8. +3
    -3
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt
  9. +1
    -1
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SettingsResponse.kt
  10. +3
    -3
      src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/projections/PurchaseOrderInfo.kt
  11. +2
    -2
      src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt
  12. +1
    -1
      src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt
  13. +1
    -10
      src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt
  14. +30
    -45
      src/main/java/com/ffii/fpsms/modules/report/service/StockItemConsumptionTrendReportService.kt
  15. +14
    -7
      src/main/java/com/ffii/fpsms/modules/report/service/StockLotOnhandReportService.kt
  16. +24
    -26
      src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt
  17. +14
    -2
      src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt
  18. +1
    -1
      src/main/java/com/ffii/fpsms/py/PyJobOrderListItem.kt
  19. +2
    -2
      src/main/java/com/ffii/fpsms/py/PyJobOrderListMapper.kt
  20. +2
    -2
      src/main/resources/application-prod.yml
  21. +1
    -1
      src/main/resources/application.yml
  22. +6
    -6
      src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/LaserBag2PayloadTest.kt
  23. +1
    -1
      src/test/kotlin/com/ffii/fpsms/modules/report/service/StockLedgerPdfSpeedTest.kt
  24. +6
    -6
      src/test/kotlin/com/ffii/fpsms/py/PyJobOrderListMapperTest.kt

+ 1
- 14
src/main/java/com/ffii/fpsms/m18/M18GrnRules.kt Wyświetl plik

@@ -3,20 +3,7 @@ package com.ffii.fpsms.m18
/**
* M18 PO [createUid] is stored on [com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrder.m18CreatedUId].
* For these M18 user ids, FPSMS does not post GRN to M18 (see [com.ffii.fpsms.modules.stock.service.StockInLineService]).
* Remarks are shown on PO stock-in traceability report ([com.ffii.fpsms.modules.report.service.ReportService.searchStockInTraceabilityReport]).
*/
object M18GrnRules {
private val REMARKS_FOR_M18_CREATED_UID: Map<Long, String> = mapOf(
2569L to "legato",
2676L to "xtech",
)

val SKIP_GRN_FOR_M18_CREATED_UIDS: Set<Long> = REMARKS_FOR_M18_CREATED_UID.keys

/** Display string for PDF/Excel: `2569 (legato)`, or plain id when unknown. */
fun formatM18CreatedUidForReport(uid: Long?): String {
if (uid == null) return ""
val remark = REMARKS_FOR_M18_CREATED_UID[uid]
return if (remark != null) "$uid ($remark)" else uid.toString()
}
val SKIP_GRN_FOR_M18_CREATED_UIDS: Set<Long> = setOf(2569L, 2676L)
}

+ 1
- 1
src/main/java/com/ffii/fpsms/modules/common/SettingNames.java Wyświetl plik

@@ -129,7 +129,7 @@ public abstract class SettingNames {
/** Laser marking TCP port (default 45678) */
public static final String LASER_PRINT_PORT = "LASER_PRINT.port";

/** Comma-separated BOM item codes shown on /laserPrint job list (e.g. PP1175); blank = no filter (all packaging JOs) */
/** Comma-separated BOM item codes shown on /laserPrint job list; blank = no filter (all packaging JOs) */
public static final String LASER_PRINT_ITEM_CODES = "LASER_PRINT.itemCodes";

/** JSON: last laser TCP send where printer returned receive (job order no., lot, itemId/stockInLineId, etc.) */


+ 1
- 1
src/main/java/com/ffii/fpsms/modules/common/alert/SchedulerSyncAlertProperties.kt Wyświetl plik

@@ -46,7 +46,7 @@ data class SmsProperties(
data class EmailAlertProperties(
/** Send sync alerts by email (uses DB MAIL.smtp.* — Office 365). */
val enabled: Boolean = false,
/** Comma-separated recipients, e.g. [email protected],[email protected] */
/** Comma-separated recipients. */
val toAddresses: String = "",
/** Subject prefix; full subject is prefix + job + code in brackets. */
val subjectPrefix: String = "FPSMS M18 sync alert",


+ 2
- 2
src/main/java/com/ffii/fpsms/modules/common/alert/SchedulerSyncAlertService.kt Wyświetl plik

@@ -424,7 +424,7 @@ open class SchedulerSyncAlertService(
.map { it.trim() }
.filter { it.isNotBlank() }
if (emailTo.isEmpty()) {
return "Set scheduler.sync-alert.email.to-addresses (e.g. [email protected])"
return "Set scheduler.sync-alert.email.to-addresses"
}
if (!syncAlertEmailSender.isSmtpConfigured()) {
return syncAlertEmailSender.smtpConfigIssue()
@@ -452,7 +452,7 @@ open class SchedulerSyncAlertService(
e.message?.contains("not authenticated", ignoreCase = true) == true ->
"\n\nOffice 365 fix checklist:\n" +
"1. UPDATE settings SET value='true' WHERE name='MAIL.smtp.auth';\n" +
"2. MAIL.smtp.username must match the mailbox ([email protected])\n" +
"2. MAIL.smtp.username must match the sending mailbox\n" +
"3. Use an App password if MFA is on (not your normal login password)\n" +
"4. IT must enable 'Authenticated SMTP' for this mailbox in Exchange admin\n" +
"5. Restart backend after changing settings (SMTP client is cached)"


+ 253
- 0
src/main/java/com/ffii/fpsms/modules/common/demo/DemoSandboxSeeder.kt Wyświetl plik

@@ -135,14 +135,18 @@ class DemoSandboxSeeder(
refuseIfLiveSchemaReadable()

transactionTemplate.executeWithoutResult {
clearLivePrinterDefaults()
ensureAuthorities()
val existing = userRepository.findByUsernameAndDeletedFalse(DEMO_USERNAME)
if (existing.isPresent) {
assignDemoAuthorities(existing.get().id)
backfillDemoPurchaseLinePrices()
seedDashboardTrucks()
log.info("Demo sandbox already present (user '{}').", DEMO_USERNAME)
return@executeWithoutResult
}
seed()
seedDashboardTrucks()
log.info("Demo sandbox seeded. Sign in as {} / {}", DEMO_USERNAME, DEMO_PASSWORD)
}
}
@@ -151,6 +155,26 @@ class DemoSandboxSeeder(
* The demo login must not be able to read the live company schema.
* MySQL error 1142/1044 means access denied; 1049 means that schema is not visible.
*/
/** Drop live printer host and item-code defaults copied in with the schema. */
private fun clearLivePrinterDefaults() {
jdbcTemplate.update(
"""
UPDATE settings
SET value = ''
WHERE name = 'LASER_PRINT.host'
AND value IN ('192.168.18.77', '192.168.17.10')
""".trimIndent(),
)
jdbcTemplate.update(
"""
UPDATE settings
SET value = ''
WHERE name = 'LASER_PRINT.itemCodes'
AND value IN ('PP1175', 'PP2211')
""".trimIndent(),
)
}

private fun refuseIfLiveSchemaReadable() {
try {
jdbcTemplate.queryForObject("SELECT 1 FROM fpsmsdb.`user` LIMIT 1", Int::class.java)
@@ -171,6 +195,233 @@ class DemoSandboxSeeder(
throw IllegalStateException("Demo database login can read the live schema. Refusing to start.")
}

/**
* Truck board reads delivery_order_pick_order for today, tomorrow, and the day after.
* If those rows are still on an earlier day, shift them to the current date.
* Opening the dashboard does this, so a demo on a later day still has trucks to show.
*/
@Synchronized
fun alignDashboardTrucksToToday() {
val anchored = jdbcTemplate.query(
"""
SELECT requiredDeliveryDate
FROM delivery_order_pick_order
WHERE ticketNo = 'DEMO-DASH-0-2F-AM-A' AND deleted = 0
LIMIT 1
""".trimIndent(),
) { rs, _ -> rs.getDate("requiredDeliveryDate")?.toLocalDate() }.firstOrNull()
if (anchored == LocalDate.now()) return
seedDashboardTrucks()
}

private fun seedDashboardTrucks() {
val loafId = demoItemId("DEMO-FG-01")
val bunId = demoItemId("DEMO-FG-02")
val pcsId = jdbcTemplate.query(
"SELECT id FROM uom_conversion WHERE code = 'PCS' AND deleted = 0 ORDER BY id LIMIT 1",
) { rs, _ -> rs.getLong("id") }.firstOrNull()

for (truck in dashboardTrucks()) {
val inserted = jdbcTemplate.update(
"""
INSERT INTO delivery_order_pick_order (
storeId, requiredDeliveryDate, truckDepartureTime, truckLanceCode,
shopCode, shopName, loadingSequence, ticketNo, ticketStatus,
handlerName, createdBy, modifiedBy, deleted
)
SELECT ?, DATE_ADD(CURDATE(), INTERVAL ? DAY), ?, ?, ?, ?, ?, ?, ?, '示範倉務', 'demo', 'demo', 0
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM delivery_order_pick_order WHERE ticketNo = ?
)
""".trimIndent(),
truck.storeId,
truck.dayOffset,
truck.departure,
truck.lane,
truck.shopCode,
truck.shopName,
truck.sequence,
truck.ticketNo,
truck.status,
truck.ticketNo,
)
jdbcTemplate.update(
"""
UPDATE delivery_order_pick_order
SET storeId = ?,
requiredDeliveryDate = DATE_ADD(CURDATE(), INTERVAL ? DAY),
truckDepartureTime = ?,
truckLanceCode = ?,
shopCode = ?,
shopName = ?,
loadingSequence = ?,
ticketStatus = ?,
handlerName = '示範倉務',
ticketReleaseTime = CASE
WHEN ? = 'pending' THEN NULL
ELSE TIMESTAMP(DATE_ADD(CURDATE(), INTERVAL ? DAY), ?)
END,
ticketCompleteDateTime = CASE
WHEN ? = 'completed' THEN TIMESTAMP(DATE_ADD(CURDATE(), INTERVAL ? DAY), ?)
ELSE NULL
END,
deleted = 0
WHERE ticketNo = ?
""".trimIndent(),
truck.storeId,
truck.dayOffset,
truck.departure,
truck.lane,
truck.shopCode,
truck.shopName,
truck.sequence,
truck.status,
truck.status,
truck.dayOffset,
truck.releaseTime,
truck.status,
truck.dayOffset,
truck.completeTime,
truck.ticketNo,
)
jdbcTemplate.update(
"""
INSERT INTO truck (
TruckLanceCode, DepartureTime, ShopCode, ShopName, LoadingSequence,
Store_id, districtReference, remark, deleted
)
SELECT ?, ?, ?, ?, ?, ?, '示範區', '示範車線', 0
FROM DUAL
WHERE NOT EXISTS (
SELECT 1 FROM truck
WHERE deleted = 0
AND TruckLanceCode = ?
AND Store_id = ?
AND DepartureTime = ?
)
""".trimIndent(),
truck.lane,
truck.departure,
truck.shopCode,
truck.shopName,
truck.sequence,
truck.storeId,
truck.lane,
truck.storeId,
truck.departure,
)
if (loafId != null && bunId != null && pcsId != null) {
seedDashboardPickLines(truck, loafId, bunId, pcsId)
}
if (inserted > 0) {
log.info("Added demo truck ticket {}.", truck.ticketNo)
}
}
}

private fun seedDashboardPickLines(truck: DemoDashTruck, loafId: Long, bunId: Long, pcsId: Long) {
val pickCode = "DEMO-DASH-PK-${truck.ticketNo}"
jdbcTemplate.update(
"""
INSERT INTO pick_order (code, status, type, targetDate, deliveryOrderPickOrderId, deleted)
SELECT ?, 'released', 'do', TIMESTAMP(DATE_ADD(CURDATE(), INTERVAL ? DAY), ?), d.id, 0
FROM delivery_order_pick_order d
WHERE d.ticketNo = ?
AND NOT EXISTS (SELECT 1 FROM pick_order p WHERE p.code = ?)
""".trimIndent(),
pickCode,
truck.dayOffset,
truck.departure,
truck.ticketNo,
pickCode,
)
jdbcTemplate.update(
"""
UPDATE pick_order p
JOIN delivery_order_pick_order d ON d.ticketNo = ?
SET p.deliveryOrderPickOrderId = d.id,
p.targetDate = TIMESTAMP(DATE_ADD(CURDATE(), INTERVAL ? DAY), ?),
p.deleted = 0
WHERE p.code = ?
""".trimIndent(),
truck.ticketNo,
truck.dayOffset,
truck.departure,
pickCode,
)
for ((itemId, qty) in listOf(loafId to "6", bunId to "4")) {
jdbcTemplate.update(
"""
INSERT INTO pick_order_line (poId, itemId, qty, uomId, status, deleted)
SELECT p.id, ?, ?, ?, 'pending', 0
FROM pick_order p
WHERE p.code = ?
AND NOT EXISTS (
SELECT 1 FROM pick_order_line l
WHERE l.poId = p.id AND l.itemId = ? AND l.deleted = 0
)
""".trimIndent(),
itemId,
qty,
pcsId,
pickCode,
itemId,
)
}
}

private fun demoItemId(code: String): Long? {
return jdbcTemplate.query(
"SELECT id FROM items WHERE code = ? AND deleted = 0 ORDER BY id LIMIT 1",
{ rs, _ -> rs.getLong("id") },
code,
).firstOrNull()
}

private fun dashboardTrucks(): List<DemoDashTruck> = listOf(
DemoDashTruck("DEMO-DASH-0-2F-AM-A", 0, "2/F", "示範-早班", "08:30:00", "DEMO-SHP", "海港咖啡店", 1, "completed", "07:10:00", "07:48:00"),
DemoDashTruck("DEMO-DASH-0-2F-AM-B", 0, "2/F", "示範-早班", "08:30:00", "DEMO-BAK", "山景餅店", 2, "completed", "07:20:00", "07:55:00"),
DemoDashTruck("DEMO-DASH-0-2F-PM-A", 0, "2/F", "示範-午班", "14:30:00", "DEMO-BAY", "灣畔餐廳", 1, "released", "12:05:00", null),
DemoDashTruck("DEMO-DASH-0-2F-PM-B", 0, "2/F", "示範-午班", "14:30:00", "DEMO-CAMP", "校園小食", 2, "pending", null, null),
DemoDashTruck("DEMO-DASH-0-4F-PM-A", 0, "4/F", "示範-晚班", "17:00:00", "DEMO-SHP", "海港咖啡店", 1, "pending", null, null),
DemoDashTruck("DEMO-DASH-1-2F-AM-A", 1, "2/F", "示範-早班", "09:00:00", "DEMO-SHP", "海港咖啡店", 1, "pending", null, null),
DemoDashTruck("DEMO-DASH-1-2F-AM-B", 1, "2/F", "示範-早班", "09:00:00", "DEMO-BAK", "山景餅店", 2, "pending", null, null),
DemoDashTruck("DEMO-DASH-1-4F-NOON-A", 1, "4/F", "示範-午班", "13:30:00", "DEMO-BAY", "灣畔餐廳", 1, "pending", null, null),
DemoDashTruck("DEMO-DASH-2-2F-AM-A", 2, "2/F", "示範-早班", "09:15:00", "DEMO-SHP", "海港咖啡店", 1, "pending", null, null),
DemoDashTruck("DEMO-DASH-2-4F-EVE-A", 2, "4/F", "示範-晚班", "16:00:00", "DEMO-CAMP", "校園小食", 1, "pending", null, null),
)

private data class DemoDashTruck(
val ticketNo: String,
val dayOffset: Int,
val storeId: String,
val lane: String,
val departure: String,
val shopCode: String,
val shopName: String,
val sequence: Int,
val status: String,
val releaseTime: String?,
val completeTime: String?,
)

/** Existing sandboxes were seeded without a unit price, which made PO detail fail. */
private fun backfillDemoPurchaseLinePrices() {
val updated = jdbcTemplate.update(
"""
UPDATE purchase_order_line pol
INNER JOIN purchase_order po ON po.id = pol.purchaseOrderId
SET pol.price = 12.50, pol.up = 12.50
WHERE po.code LIKE 'DEMO-PO-%'
AND (pol.price IS NULL OR pol.up IS NULL)
""".trimIndent(),
)
if (updated > 0) {
log.info("Filled unit price on {} demo purchase order line(s).", updated)
}
}

private fun ensureAuthorities() {
val roles = listOf(
"ADMIN" to "行政",
@@ -481,6 +732,8 @@ class DemoSandboxSeeder(
uomM18 = uom
this.qty = qty
qtyM18 = qty
up = BigDecimal("12.50")
price = BigDecimal("12.50")
this.status = status
m18DataLog = dataLog
})


+ 3
- 3
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoFloorSupplierSettingsService.kt Wyświetl plik

@@ -13,8 +13,8 @@ open class DoFloorSupplierSettingsService(
private const val SETTING_DO_FLOOR_SUPPLIERS_2F = "DO.floor.suppliers.2F"
private const val SETTING_DO_FLOOR_SUPPLIERS_4F = "DO.floor.suppliers.4F"

private val DEFAULT_SUPPLIERS_2F = listOf("P07", "P06D", "P06Y")
private val DEFAULT_SUPPLIERS_4F = listOf("P06B")
private val DEFAULT_SUPPLIERS_2F = listOf("DEMO-SUP")
private val DEFAULT_SUPPLIERS_4F = listOf("DEMO-SUP")
}

open fun supplierCodesFromSetting(settingName: String, defaultList: List<String>): List<String> {
@@ -33,7 +33,7 @@ open class DoFloorSupplierSettingsService(
}

open fun allowedSupplierCodesForFloor(floor: String?): List<String> {
val f = floor?.trim()?.uppercase(Locale.ROOT).orEmpty()
val f = floor?.trim()?.replace("/", "")?.uppercase(Locale.ROOT).orEmpty()
val (codes2F, codes4F) = loadDoFloorSupplierLists()
return when {
f.isEmpty() || f == "ALL" || f == "All" -> (codes2F + codes4F).distinct()


+ 5
- 1
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoPickOrderService.kt Wyświetl plik

@@ -45,8 +45,10 @@ import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderLineRepository
import com.ffii.fpsms.modules.deliveryOrder.entity.DeliveryOrderRepository
import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderLineRecordRepository
import com.ffii.fpsms.modules.deliveryOrder.entity.DoPickOrderLineRecord
import org.springframework.beans.factory.ObjectProvider
import org.springframework.context.annotation.Lazy
import com.ffii.fpsms.modules.pickOrder.entity.PickOrderLineRepository
import com.ffii.fpsms.modules.common.demo.DemoSandboxSeeder
import com.ffii.fpsms.modules.deliveryOrder.web.models.TicketReleaseTableResponse
import com.ffii.fpsms.modules.deliveryOrder.web.models.TruckScheduleDashboardResponse
import com.ffii.fpsms.modules.deliveryOrder.web.models.SearchDeliveryOrderInfoRequest
@@ -64,7 +66,8 @@ open class DoPickOrderService(
private val doPickOrderLineRepository: DoPickOrderLineRepository,
@Lazy private val deliveryOrderRepository: DeliveryOrderRepository,
private val doPickOrderLineRecordRepository: DoPickOrderLineRecordRepository,
private val pickOrderLineRepository: PickOrderLineRepository
private val pickOrderLineRepository: PickOrderLineRepository,
private val demoSandboxSeeder: ObjectProvider<DemoSandboxSeeder>,
) {
open fun findReleasedDoPickOrders(): List<DoPickOrder> {
return doPickOrderRepository.findByTicketStatusIn(
@@ -847,6 +850,7 @@ open class DoPickOrderService(
* Groups DoPickOrder and DoPickOrderRecord data to provide summary statistics.
*/
open fun getTruckScheduleDashboard(targetDate: LocalDate): List<TruckScheduleDashboardResponse> {
demoSandboxSeeder.getIfAvailable()?.alignDashboardTrucksToToday()
// Source of truth: delivery_order_pick_order (+ linked pick_order / pick_order_line)
//
// NOTE: delivery_order_pick_order 沒有 truckId 欄位;dashboard 的 truckId 目前僅作為展示/鍵值用途,


+ 3
- 3
src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt Wyświetl plik

@@ -106,9 +106,9 @@ class PlasticBagPrinterService(
private val hongKongZoneId = ZoneId.of("Asia/Hong_Kong")

companion object {
private const val DEFAULT_LASER_BAG2_HOST = "192.168.18.77"
private const val DEFAULT_LASER_BAG2_HOST = ""
private const val DEFAULT_LASER_BAG2_PORT = 45678
private const val DEFAULT_LASER_ITEM_CODES = "PP1175"
private const val DEFAULT_LASER_ITEM_CODES = ""
private const val PACKAGING_PROCESS_NAME = "包裝"
/** Designer product BMP canvas (LOGO), 1-bit. */
private const val ONPACK_PRODUCT_BMP_WIDTH = 593
@@ -1226,7 +1226,7 @@ class PlasticBagPrinterService(
return resource.inputStream.use { it.readBytes() }
}

/** Template files on classpath use uppercase code, e.g. `onpack2030_2/PP1175.image`. */
/** Template files on classpath use uppercase code, e.g. `onpack2030_2/DEMO-FG-01.image`. */
private fun loadOnPack2030_2ImageTemplateOrNull(codeLower: String): ByteArray? {
onPackTemplateFileService.loadImage(OnPackTemplateFileService.MACHINE_LEMON, codeLower)?.let { return it }
val resourcePath = "onpack2030_2/${codeLower.uppercase()}.image"


+ 1
- 1
src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SettingsResponse.kt Wyświetl plik

@@ -1,7 +1,7 @@
package com.ffii.fpsms.modules.jobOrder.web.model

/**
* @param itemCodes Comma-separated item codes for the laser job list filter (e.g. `PP1175` or `PP1175,AB123`). Empty string means no filter (show all packaging job orders).
* @param itemCodes Comma-separated item codes for the laser job list filter. Empty string means no filter (show all packaging job orders).
* @param lastReceiveSuccess Last job where the laser printer returned a receive ack (from settings JSON); null if never recorded or empty.
*/
data class LaserBag2SettingsResponse(


+ 3
- 3
src/main/java/com/ffii/fpsms/modules/purchaseOrder/entity/projections/PurchaseOrderInfo.kt Wyświetl plik

@@ -8,9 +8,9 @@ import java.time.LocalDateTime
interface PurchaseOrderInfo {
val id: Long
val code: String
val orderDate: LocalDateTime
val estimatedArrivalDate: LocalDateTime
val completeDate: LocalDateTime
val orderDate: LocalDateTime?
val estimatedArrivalDate: LocalDateTime?
val completeDate: LocalDateTime?
val status: String
@get:Value("#{target.supplier?.name}")
val supplier: String?


+ 2
- 2
src/main/java/com/ffii/fpsms/modules/purchaseOrder/service/PurchaseOrderService.kt Wyświetl plik

@@ -268,7 +268,7 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> {
return purchaseOrderRepository.findAll()
}

open fun getDetailedPo(id: Long): Map<String, Any> {
open fun getDetailedPo(id: Long): Map<String, Any?> {
// REMINDER: po code have duplication
val po = purchaseOrderRepository.findPurchaseOrderInfoByIdAndDeletedIsFalse(id)
val pol = polRepository.findAllByPurchaseOrderIdAndDeletedIsFalse(id)
@@ -336,7 +336,7 @@ open fun getPoSummariesByIds(ids: List<Long>): List<PurchaseOrderSummary> {
.sumOf { line -> line.putAwayLines?.sumOf { it.qty?.takeIf { qty -> qty > BigDecimal.ZERO } ?: BigDecimal.ZERO } ?: BigDecimal.ZERO},
receivedQty = thisPol.stockInLines.sumOf { it.acceptedQty ?: BigDecimal.ZERO },
uom = thisPol.uomM18 ?: thisPol.uom!!,
price = thisPol.price!!,
price = thisPol.price ?: thisPol.up ?: BigDecimal.ZERO,
status = thisPol.status!!.toString(),
stockInLine = inLine,
qcItems = qcItems,


+ 1
- 1
src/main/java/com/ffii/fpsms/modules/purchaseOrder/web/PurchaseOrderController.kt Wyświetl plik

@@ -98,7 +98,7 @@ class PurchaseOrderController(
}

@GetMapping("/detail/{id}") // purchaseOrderId
fun getDetailedPo(@PathVariable id: Long): Map<String, Any> {
fun getDetailedPo(@PathVariable id: Long): Map<String, Any?> {

/*
// tested the sample printing function by a pdf file


+ 1
- 10
src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt Wyświetl plik

@@ -831,8 +831,7 @@ return result
MAX(ROUND(COALESCE(pol.up, 0) * COALESCE(sil.acceptedQty, 0), 2)) AS lineAmount,
MAX(COALESCE(cur.code, '')) AS currencyCode,
MAX(grn.grn_code) AS grnCode,
MAX(grn.m18_record_id) AS grnId,
MAX(po.m18CreatedUId) AS poM18CreatedUId
MAX(grn.m18_record_id) AS grnId
FROM stock_in_line sil
LEFT JOIN items it ON sil.itemId = it.id
LEFT JOIN purchase_order po ON sil.purchaseOrderId = po.id
@@ -895,14 +894,6 @@ return result
"currencyCode" to row["currencyCode"],
"grnCode" to row["grnCode"],
"grnId" to row["grnId"],
"poM18CreatorDisplay" to M18GrnRules.formatM18CreatedUidForReport(
when (val v = row["poM18CreatedUId"]) {
null -> null
is Number -> v.toLong()
is BigDecimal -> v.toLong()
else -> v.toString().toLongOrNull()
},
),
)
}
}


+ 30
- 45
src/main/java/com/ffii/fpsms/modules/report/service/StockItemConsumptionTrendReportService.kt Wyświetl plik

@@ -55,10 +55,36 @@ class StockItemConsumptionTrendReportService(
return "AND (${conditions.joinToString(" OR ")})"
}

private val itemTypes = listOf("mat", "sfg", "fg")

/** Item types used by the demo catalogue. "All" includes every one of them. */
private fun itemTypeFilterSql(
stockCategory: String?,
args: MutableMap<String, Any>,
paramPrefix: String,
): String {
val selected = if (
stockCategory.isNullOrBlank() ||
stockCategory == "All" ||
stockCategory.contains("All")
) {
itemTypes
} else {
stockCategory.split(",").map { it.trim() }.filter { it in itemTypes }
}
val categories = selected.ifEmpty { itemTypes }
val conditions = categories.mapIndexed { index, cat ->
val paramName = "${paramPrefix}_$index"
args[paramName] = cat
"it.type = :$paramName"
}
return "AND (${conditions.joinToString(" OR ")})"
}

/**
* Queries the database for Stock Item Consumption Trend Report data.
* Flow:
* 1. Filter items by type (MAT, WIP) - this is the stockCategory filter
* 1. Filter items by type (mat, sfg, fg) - this is the stockCategory filter
* 2. Use items.code to match stock_ledger.itemCode
* 3. Aggregate stock_ledger.outQty by month based on stock_ledger.modified
* Supports comma-separated values for stockCategory, stockSubCategory, and itemCode.
@@ -72,30 +98,7 @@ class StockItemConsumptionTrendReportService(
): List<Map<String, Any>> {
val args = mutableMapOf<String, Any>()
// Filter by stockCategory from items.type (MAT/WIP/NM/FG/CMB)
// Supports multiple categories separated by comma (e.g., "MAT,WIP,NM")
// Valid categories: MAT, WIP, NM, FG, CMB
// If "All" is selected or empty, show all valid categories
// If specific categories are selected, show only those
val validCategories = setOf("MAT", "WIP", "NM", "FG", "CMB")
val stockCategorySql = if (!stockCategory.isNullOrBlank() && stockCategory != "All" && !stockCategory.contains("All")) {
// Handle multiple categories (comma-separated)
val categories = stockCategory.split(",").map { it.trim() }.filter { it.isNotBlank() && it != "All" && validCategories.contains(it) }
if (categories.isNotEmpty()) {
val conditions = categories.mapIndexed { index, cat ->
val paramName = "stockCategory_$index"
args[paramName] = cat
"it.type = :$paramName"
}
"AND (${conditions.joinToString(" OR ")})"
} else {
// If no valid categories, default to all valid categories
"AND (it.type = 'MAT' OR it.type = 'WIP' OR it.type = 'NM' OR it.type = 'FG' OR it.type = 'CMB')"
}
} else {
// If "All" or empty, filter to all valid categories
"AND (it.type = 'MAT' OR it.type = 'WIP' OR it.type = 'NM' OR it.type = 'FG' OR it.type = 'CMB')"
}
val stockCategorySql = itemTypeFilterSql(stockCategory, args, "stockCategory")
// itemCode now contains prefixes (first 2 letters), so we use LIKE to match codes starting with those prefixes
val itemCodeSql = if (!itemCode.isNullOrBlank()) {
@@ -176,30 +179,12 @@ class StockItemConsumptionTrendReportService(

/**
* Gets list of item code prefixes (first 2 letters) based on stockCategory filter.
* Supports multiple categories separated by comma (e.g., "MAT,WIP,NM").
* If stockCategory is "All" or null, returns prefixes for all valid categories (MAT, WIP, NM, FG, CMB).
* If stockCategory is "All" or null, returns prefixes for mat, sfg, and fg.
* Returns a list of maps with "prefix" key.
*/
fun getStockItemCodePrefixes(stockCategory: String?): List<Map<String, String>> {
val args = mutableMapOf<String, Any>()
val validCategories = setOf("MAT", "WIP", "NM", "FG", "CMB")
val stockCategorySql = if (!stockCategory.isNullOrBlank() && stockCategory != "All" && !stockCategory.contains("All")) {
// Handle multiple categories (comma-separated)
val categories = stockCategory.split(",").map { it.trim() }.filter { it.isNotBlank() && it != "All" && validCategories.contains(it) }
if (categories.isNotEmpty()) {
val conditions = categories.mapIndexed { index, cat ->
val paramName = "stockCategory_$index"
args[paramName] = cat
"it.type = :$paramName"
}
"AND (${conditions.joinToString(" OR ")})"
} else {
"AND (it.type = 'MAT' OR it.type = 'WIP' OR it.type = 'NM' OR it.type = 'FG' OR it.type = 'CMB')"
}
} else {
"AND (it.type = 'MAT' OR it.type = 'WIP' OR it.type = 'NM' OR it.type = 'FG' OR it.type = 'CMB')"
}
val stockCategorySql = itemTypeFilterSql(stockCategory, args, "stockCategory")

val sql = """
SELECT DISTINCT LEFT(it.code, 2) as prefix


+ 14
- 7
src/main/java/com/ffii/fpsms/modules/report/service/StockLotOnhandReportService.kt Wyświetl plik

@@ -178,7 +178,11 @@ stock_one AS (
4
)
END AS avgUnitPriceRaw,
UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) AS rootPoPrefix
CASE
WHEN root_sil.jobOrderId IS NOT NULL THEN 'production'
WHEN root_sil.purchaseOrderId IS NOT NULL THEN 'purchase'
ELSE 'other'
END AS lotOriginKey
FROM inventory_lot_line ill
INNER JOIN inventory_lot il
ON il.id = ill.inventoryLotId AND il.deleted = 0
@@ -372,8 +376,8 @@ stock_one AS (
val avg = toDouble(r["avgUnitPriceRaw"])
val key = "${r["itemNo"]}|${r["unitOfMeasure"]}"
val last = lastTrnByLot[lotId]
val origin = r["rootPoPrefix"]?.toString().orEmpty().uppercase()
val showPrice = origin == "PP" || origin == "PF"
val origin = r["lotOriginKey"]?.toString().orEmpty()
val showPrice = origin == "purchase"
val row = HashMap<String, Any>(24)
row["inventoryLotLineId"] = lotId.toString()
row["remarks"] = r["remarks"] ?: ""
@@ -419,14 +423,17 @@ stock_one AS (
return v.toString().toLongOrNull() ?: 0L
}

/** PP/PF = root PO code prefix after TRF walk; other = not PP/PF (ADJ, TRF of other origin, JO, OPEN, …). */
/** purchase = root stock-in from a PO; production = job order; other = neither. */
private fun buildLotOriginFilterSql(lotOrigin: String?): String {
val v = lotOrigin?.trim().orEmpty()
if (v.isBlank() || v.equals("All", ignoreCase = true)) return ""
return when (v.lowercase()) {
"pp" -> "AND UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) = 'PP'"
"pf" -> "AND UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) = 'PF'"
"other" -> "AND UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) NOT IN ('PP', 'PF')"
"purchase" -> "AND root_sil.purchaseOrderId IS NOT NULL AND root_sil.jobOrderId IS NULL"
"production" -> "AND root_sil.jobOrderId IS NOT NULL"
"other" -> """
AND (root_sil.purchaseOrderId IS NULL OR root_sil.purchaseOrderId = 0)
AND root_sil.jobOrderId IS NULL
""".trimIndent()
else -> ""
}
}


+ 24
- 26
src/main/java/com/ffii/fpsms/modules/report/service/StockTakeVarianceReportService.kt Wyświetl plik

@@ -79,14 +79,11 @@ lot_root_origin AS (

private const val LOT_ROOT_CATEGORY_SQL = """
CASE
WHEN root_sil.jobOrderId IS NOT NULL THEN '工廠生產'
WHEN root_sil.jobOrderId IS NOT NULL THEN '生產'
WHEN UPPER(TRIM(COALESCE(root_sil.type, ''))) = 'OPEN' THEN '期初存貨'
WHEN UPPER(TRIM(COALESCE(root_sil.type, ''))) = 'ADJ' THEN '倉存調整'
WHEN UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) = 'PP' THEN 'PP'
WHEN UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) = 'PF' THEN 'PF'
WHEN UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) = 'TOA' THEN 'TOA'
WHEN root_sil.purchaseOrderId IS NOT NULL THEN '採購入倉'
ELSE '其他入倉'
WHEN root_sil.purchaseOrderId IS NOT NULL THEN '採購'
ELSE '其他'
END"""

private const val LOT_TYPE_DISPLAY_SQL = """
@@ -1007,26 +1004,27 @@ ORDER BY
* - 篩選用 root_sil / root_po(根來源類型)
*/
private fun buildLotTypeFilterClause(type: String?): String {
val normalized = type?.trim().orEmpty()
if (normalized.isBlank() || normalized.equals("all", ignoreCase = true)) return ""
return when (normalized) {
"PP" -> "AND UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) = 'PP'"
"PF" -> "AND UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) = 'PF'"
"TOA" -> "AND UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) = 'TOA'"
"工廠生產" -> "AND root_sil.jobOrderId IS NOT NULL"
"倉存調整" -> "AND UPPER(TRIM(COALESCE(root_sil.type, ''))) = 'ADJ'"
"期初存貨" -> "AND UPPER(TRIM(COALESCE(root_sil.type, ''))) = 'OPEN'"
"採購入倉" -> """
AND root_sil.purchaseOrderId IS NOT NULL
AND UPPER(LEFT(TRIM(COALESCE(root_po.code, '')), 2)) NOT IN ('PP', 'PF', 'TOA')
""".trimIndent()
"其他入倉" -> """
AND (root_sil.purchaseOrderId IS NULL OR root_sil.purchaseOrderId = 0)
AND root_sil.jobOrderId IS NULL
AND UPPER(TRIM(COALESCE(root_sil.type, ''))) NOT IN ('OPEN', 'ADJ')
""".trimIndent()
else -> ""
}
val parts = type?.split(",")
?.map { it.trim() }
?.filter { it.isNotBlank() && !it.equals("all", ignoreCase = true) }
.orEmpty()
if (parts.isEmpty()) return ""
val predicates = parts.mapNotNull { lotTypePredicate(it) }
if (predicates.isEmpty()) return ""
return "AND (" + predicates.joinToString(" OR ") { "($it)" } + ")"
}

private fun lotTypePredicate(type: String): String? = when (type) {
"purchase" -> "root_sil.purchaseOrderId IS NOT NULL AND root_sil.jobOrderId IS NULL"
"production" -> "root_sil.jobOrderId IS NOT NULL"
"adjustment" -> "UPPER(TRIM(COALESCE(root_sil.type, ''))) = 'ADJ'"
"opening" -> "UPPER(TRIM(COALESCE(root_sil.type, ''))) = 'OPEN'"
"other" -> """
(root_sil.purchaseOrderId IS NULL OR root_sil.purchaseOrderId = 0)
AND root_sil.jobOrderId IS NULL
AND UPPER(TRIM(COALESCE(root_sil.type, ''))) NOT IN ('OPEN', 'ADJ')
""".trimIndent()
else -> null
}

/** LIKE 多值工具方法 */


+ 14
- 2
src/main/java/com/ffii/fpsms/modules/report/web/ReportController.kt Wyświetl plik

@@ -14,8 +14,10 @@ import org.apache.poi.ss.util.CellRangeAddress
import org.apache.poi.ss.util.WorkbookUtil
import org.apache.poi.xssf.usermodel.XSSFCellStyle
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.*
import org.springframework.web.bind.annotation.*
import org.springframework.web.server.ResponseStatusException
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.time.LocalDate
@@ -33,7 +35,13 @@ class ReportController(
private val reportService: ReportService,
private val m18BomShopSyncReportService: M18BomShopSyncReportService,
private val shopOrderReplenishmentReportService: ShopOrderReplenishmentReportService,
@Value("\${fpsms.demo-mode:false}") private val demoMode: Boolean,
) {
private fun refuseIfDemo() {
if (demoMode) {
throw ResponseStatusException(HttpStatus.FORBIDDEN, "Unavailable in demo mode")
}
}
private data class ExcelStyles(
val title: XSSFCellStyle,
val subtitle: XSSFCellStyle,
@@ -1238,18 +1246,21 @@ class ReportController(
@RequestParam(required = false) syncDateEnd: String?,
@RequestParam(required = false) finishedItemCode: String?,
@RequestParam(required = false) syncStatus: String?,
): Map<String, Any> =
m18BomShopSyncReportService.searchBomShopSyncHistoryReport(
): Map<String, Any> {
refuseIfDemo()
return m18BomShopSyncReportService.searchBomShopSyncHistoryReport(
syncDateStart = syncDateStart,
syncDateEnd = syncDateEnd,
finishedItemCode = finishedItemCode,
syncStatus = syncStatus,
)
}

@PostMapping("/bom-shop-sync-history")
fun getBomShopSyncHistoryReportPost(
@RequestBody(required = false) req: ReportItemFilterRequest?,
): Map<String, Any> {
refuseIfDemo()
val r = req ?: ReportItemFilterRequest()
return m18BomShopSyncReportService.searchBomShopSyncHistoryReport(
syncDateStart = r.syncDateStart,
@@ -1301,6 +1312,7 @@ class ReportController(
@RequestParam receiptDate: String,
@RequestParam(required = false, defaultValue = "json") format: String,
): Any {
refuseIfDemo()
val rows = reportService.searchGrnPreviewM18(receiptDate)
if (format.equals("csv", ignoreCase = true)) {
val headers = listOf(


+ 1
- 1
src/main/java/com/ffii/fpsms/py/PyJobOrderListItem.kt Wyświetl plik

@@ -10,7 +10,7 @@ import java.time.LocalDateTime
* stockInLineId and itemId are for QR code: {"itemId": xxx, "stockInLineId": xxx}
* lotNo replaces job order no. on the label display.
* [itemName] is BOM/item display name plus stock UOM [udfudesc] in parentheses when appropriate;
* GET /py/laser-job-orders (/laserPrint) may omit that suffix for specific item codes (e.g. PP1175) due to laser name length.
* GET /py/laser-job-orders (/laserPrint) may omit that suffix for item codes listed for the laser name-length limit.
*/
data class PyJobOrderListItem(
val id: Long,


+ 2
- 2
src/main/java/com/ffii/fpsms/py/PyJobOrderListMapper.kt Wyświetl plik

@@ -57,9 +57,9 @@ object PyJobOrderListMapper {

/**
* Item codes where /laserPrint must not append stock UOM to [itemName] (laser field length limit).
* BOM base name only, e.g. PP1175 "鮮檸檬汁(P+4)" without "(1包X2磅)" suffix.
* Empty on the demo branch so no live item code is special-cased.
*/
private val LASER_OMIT_APPENDED_STOCK_UOM_ITEM_CODES = setOf("PP1175")
private val LASER_OMIT_APPENDED_STOCK_UOM_ITEM_CODES = emptySet<String>()

/**
* Display name for GET /py/laser-job-orders and /plastic/laser-job-orders (/laserPrint page).


+ 2
- 2
src/main/resources/application-prod.yml Wyświetl plik

@@ -61,10 +61,10 @@ scheduler:
to-numbers: ""
email:
enabled: true
to-addresses: "[email protected],[email protected]"
to-addresses: ""
po-line:
enabled: true
# From = MAIL.smtp.username in DB (e.g. [email protected] + Gmail app password)
# From = MAIL.smtp.username in DB settings.

# Laser Bag2 (/laserPrint) TCP auto-send; uses LASER_PRINT host/port/itemCodes from DB and sends first matching job only.
laser:


+ 1
- 1
src/main/resources/application.yml Wyświetl plik

@@ -57,7 +57,7 @@ scheduler:
content-sid: ${TWILIO_WHATSAPP_CONTENT_SID:}
email:
enabled: ${SYNC_ALERT_EMAIL_ENABLED:true}
to-addresses: "[email protected],[email protected]"
to-addresses: ""
do1:
min-records-processed: 400
grace-minutes-after-schedule: 30


+ 6
- 6
src/test/kotlin/com/ffii/fpsms/modules/jobOrder/service/LaserBag2PayloadTest.kt Wyświetl plik

@@ -11,20 +11,20 @@ class LaserBag2PayloadTest {
@Test
fun payload_without_expiry_keeps_original_three_fields() {
assertEquals(
"{\"itemId\":1,\"stockInLineId\":2};PP1175;鮮檸檬汁;;",
PlasticBagPrinterService.buildLaserBag2Payload(1, 2, "PP1175", "鮮檸檬汁", null),
"{\"itemId\":1,\"stockInLineId\":2};DEMO-FG-01;鄉村麵包;;",
PlasticBagPrinterService.buildLaserBag2Payload(1, 2, "DEMO-FG-01", "鄉村麵包", null),
)
assertEquals(
"0;PP1175;鮮檸檬汁;;",
PlasticBagPrinterService.buildLaserBag2Payload(null, null, "PP1175", "鮮檸檬汁"),
"0;DEMO-FG-01;鄉村麵包;;",
PlasticBagPrinterService.buildLaserBag2Payload(null, null, "DEMO-FG-01", "鄉村麵包"),
)
}

@Test
fun payload_with_iso_expiry_adds_fourth_print_label_field() {
assertEquals(
"{\"itemId\":1,\"stockInLineId\":2};PP1175;鮮檸檬汁;Expiry Date 20260821;;",
PlasticBagPrinterService.buildLaserBag2Payload(1, 2, "PP1175", "鮮檸檬汁", "2026-08-21"),
"{\"itemId\":1,\"stockInLineId\":2};DEMO-FG-01;鄉村麵包;Expiry Date 20260821;;",
PlasticBagPrinterService.buildLaserBag2Payload(1, 2, "DEMO-FG-01", "鄉村麵包", "2026-08-21"),
)
}



+ 1
- 1
src/test/kotlin/com/ffii/fpsms/modules/report/service/StockLedgerPdfSpeedTest.kt Wyświetl plik

@@ -23,7 +23,7 @@ class StockLedgerPdfSpeedTest {
"stockOut" to "0",
"cumBalance" to "1,210",
"orderRefNo" to "DO-20260901-${i % 1000}",
"storeLocation" to "W201",
"storeLocation" to "DEMO-A",
"totalStockIn" to "400",
"totalStockOut" to "0",
"totalCumBalance" to "1,210",


+ 6
- 6
src/test/kotlin/com/ffii/fpsms/py/PyJobOrderListMapperTest.kt Wyświetl plik

@@ -66,14 +66,14 @@ class PyJobOrderListMapperTest {
}

@Test
fun buildDisplayItemNameForLaser_omits_appended_uom_for_PP1175() {
fun buildDisplayItemNameForLaser_appends_uom_when_name_has_none() {
assertEquals(
"鮮檸檬汁(P+4)",
PyJobOrderListMapper.buildDisplayItemNameForLaser("鮮檸檬汁(P+4)", stockUom, "PP1175"),
"鄉村麵包($stockUom)",
PyJobOrderListMapper.buildDisplayItemNameForLaser("鄉村麵包", stockUom, "DEMO-FG-01"),
)
assertEquals(
"咖哩汁(1包X2磅)",
PyJobOrderListMapper.buildDisplayItemNameForLaser("咖哩汁(1包X2磅)", stockUom, "PP1080"),
"奶包(1包X2磅)",
PyJobOrderListMapper.buildDisplayItemNameForLaser("奶包", stockUom, "DEMO-FG-02"),
)
}

@@ -92,7 +92,7 @@ class PyJobOrderListMapperTest {
@Test
fun shelfLifeForItem_is_null_when_item_missing() {
val (days, useMinus18, expiry) = PyJobOrderListMapper.shelfLifeForItem(
"PP1175",
"DEMO-FG-01",
emptyMap(),
LocalDate.of(2026, 8, 20),
)


Ładowanie…
Anuluj
Zapisz