Переглянути джерело

Merge remote-tracking branch 'origin/master'

master
CANCERYS\kw093 2 дні тому
джерело
коміт
47a1bcffe3
40 змінених файлів з 3216 додано та 627 видалено
  1. +2
    -0
      .gitignore
  2. +394
    -405
      python/Bag2.py
  3. +1718
    -0
      python/Bag3.py
  4. BIN
      python/__pycache__/Bag2.cpython-313.pyc
  5. +9
    -0
      python/bag3_settings.json
  6. +1
    -0
      python/installAndExe.txt
  7. +14
    -1
      src/main/java/com/ffii/fpsms/m18/M18GrnRules.kt
  8. +4
    -1
      src/main/java/com/ffii/fpsms/m18/entity/M18GoodsReceiptNoteLogRepository.kt
  9. +39
    -0
      src/main/java/com/ffii/fpsms/m18/service/M18MasterDataService.kt
  10. +5
    -0
      src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt
  11. +3
    -0
      src/main/java/com/ffii/fpsms/modules/common/SettingNames.java
  12. +43
    -1
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt
  13. +42
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/scheduler/LaserBag2AutoSendScheduler.kt
  14. +114
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/LaserBag2AutoSendService.kt
  15. +225
    -32
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt
  16. +31
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt
  17. +20
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2AutoSendReport.kt
  18. +8
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SendRequest.kt
  19. +4
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SendResponse.kt
  20. +2
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SettingsResponse.kt
  21. +22
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserLastReceiveSuccessDto.kt
  22. +6
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PlasticPrintRequest.kt
  23. +13
    -4
      src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt
  24. +20
    -0
      src/main/java/com/ffii/fpsms/modules/stock/entity/StockInLineRepository.kt
  25. +13
    -13
      src/main/java/com/ffii/fpsms/modules/stock/service/SearchCompletedDnService.kt
  26. +26
    -6
      src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt
  27. +8
    -0
      src/main/java/com/ffii/fpsms/py/PrintedQtyByChannel.kt
  28. +35
    -2
      src/main/java/com/ffii/fpsms/py/PyController.kt
  29. +6
    -0
      src/main/java/com/ffii/fpsms/py/PyJobOrderListItem.kt
  30. +35
    -0
      src/main/java/com/ffii/fpsms/py/PyJobOrderPrintSubmitRepository.kt
  31. +11
    -0
      src/main/java/com/ffii/fpsms/py/PyJobOrderPrintSubmitRequest.kt
  32. +9
    -0
      src/main/java/com/ffii/fpsms/py/PyJobOrderPrintSubmitResponse.kt
  33. +72
    -0
      src/main/java/com/ffii/fpsms/py/PyJobOrderPrintSubmitService.kt
  34. +8
    -0
      src/main/java/com/ffii/fpsms/py/PyPrintChannel.kt
  35. +35
    -0
      src/main/java/com/ffii/fpsms/py/entity/PyJobOrderPrintSubmit.kt
  36. +174
    -160
      src/main/resources/DeliveryNote/DeliveryNoteCartonLabelsPDF.jrxml
  37. +14
    -0
      src/main/resources/application.yml
  38. +22
    -0
      src/main/resources/db/changelog/changes/20260326_fai/01_create_py_job_order_print_submit.sql
  39. +8
    -0
      src/main/resources/db/changelog/changes/20260327_laser_last_receive_success/01_laser_print_last_receive_success.sql
  40. +1
    -2
      src/main/resources/fonts/fonts.xml

+ 2
- 0
.gitignore Переглянути файл

@@ -36,3 +36,5 @@ out/
### VS Code ### ### VS Code ###
.vscode/ .vscode/
package-lock.json package-lock.json
python/Bag3.spec
python/dist/Bag3.exe

+ 394
- 405
python/Bag2.py
Різницю між файлами не показано, бо вона завелика
Переглянути файл


+ 1718
- 0
python/Bag3.py
Різницю між файлами не показано, бо вона завелика
Переглянути файл


BIN
python/__pycache__/Bag2.cpython-313.pyc Переглянути файл


+ 9
- 0
python/bag3_settings.json Переглянути файл

@@ -0,0 +1,9 @@
{
"api_ip": "127.0.0.1",
"api_port": "8090",
"dabag_ip": "192.168.17.27",
"dabag_port": "3008",
"laser_ip": "192.168.18.68",
"laser_port": "45678",
"label_com": "TSC TTP-246M Pro"
}

+ 1
- 0
python/installAndExe.txt Переглянути файл

@@ -2,6 +2,7 @@ py -m pip install pyinstaller
py -m pip install --upgrade pyinstaller py -m pip install --upgrade pyinstaller
py -m PyInstaller --onefile --windowed --name "Bag1" Bag1.py py -m PyInstaller --onefile --windowed --name "Bag1" Bag1.py


py -m PyInstaller --onefile --windowed --name "Bag3" Bag3.py


python -m pip install pyinstaller python -m pip install pyinstaller
python -m pip install --upgrade pyinstaller python -m pip install --upgrade pyinstaller


+ 14
- 1
src/main/java/com/ffii/fpsms/m18/M18GrnRules.kt Переглянути файл

@@ -3,7 +3,20 @@ package com.ffii.fpsms.m18
/** /**
* M18 PO [createUid] is stored on [com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrder.m18CreatedUId]. * 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]). * 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 { object M18GrnRules {
val SKIP_GRN_FOR_M18_CREATED_UIDS: Set<Long> = setOf(2569L, 2676L)
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()
}
} }

+ 4
- 1
src/main/java/com/ffii/fpsms/m18/entity/M18GoodsReceiptNoteLogRepository.kt Переглянути файл

@@ -7,9 +7,12 @@ import java.time.LocalDateTime


interface M18GoodsReceiptNoteLogRepository : AbstractRepository<M18GoodsReceiptNoteLog, Long> { interface M18GoodsReceiptNoteLogRepository : AbstractRepository<M18GoodsReceiptNoteLog, Long> {


/** Returns true if a successful GRN was already created for this PO (avoids core_201 duplicate). */
/** Returns true if a successful GRN was already created for this PO (legacy / broad check). */
fun existsByPurchaseOrderIdAndStatusTrue(purchaseOrderId: Long): Boolean fun existsByPurchaseOrderIdAndStatusTrue(purchaseOrderId: Long): Boolean


/** True if this stock-in line was already included in a successful M18 GRN (one delivery = one GRN batch). */
fun existsByStockInLineIdAndStatusTrue(stockInLineId: Long): Boolean

/** /**
* GRN log rows that need M18 AN code backfill: have record id, no grn_code yet, * GRN log rows that need M18 AN code backfill: have record id, no grn_code yet,
* created in [start, end] inclusive (e.g. start = 4 days ago 00:00, end = now). * created in [start, end] inclusive (e.g. start = 4 days ago 00:00, end = now).


+ 39
- 0
src/main/java/com/ffii/fpsms/m18/service/M18MasterDataService.kt Переглянути файл

@@ -303,6 +303,45 @@ open class M18MasterDataService(
} }
} }


/** Sync one product/material from M18 by item code (search list, then load line — same idea as PO/DO by code). */
open fun saveProductByCode(code: String): SyncResult {
val trimmed = code.trim()
if (trimmed.isEmpty()) {
return SyncResult(totalProcessed = 1, totalSuccess = 0, totalFail = 1, query = "empty code")
}
ensureCunitSeededForAllIfEmpty()
val fromLocal = itemsService.findByCode(trimmed)?.m18Id
val m18Id = fromLocal ?: run {
val conds = "(code=equal=$trimmed)"
val listResponse = try {
getList<M18ProductListResponse>(
stSearch = StSearchType.PRODUCT.value,
params = null,
conds = conds,
request = M18CommonRequest(),
)
} catch (e: Exception) {
logger.error("(saveProductByCode) M18 search failed: ${e.message}", e)
null
}
listResponse?.values?.firstOrNull()?.id
}
if (m18Id == null) {
return SyncResult(
totalProcessed = 1,
totalSuccess = 0,
totalFail = 1,
query = "code=equal=$trimmed",
)
}
val result = saveProduct(m18Id)
return if (result != null) {
SyncResult(totalProcessed = 1, totalSuccess = 1, totalFail = 0, query = "code=equal=$trimmed")
} else {
SyncResult(totalProcessed = 1, totalSuccess = 0, totalFail = 1, query = "code=equal=$trimmed")
}
}

open fun saveProducts(request: M18CommonRequest): SyncResult { open fun saveProducts(request: M18CommonRequest): SyncResult {
logger.info("--------------------------------------------Start - Saving M18 Products / Materials--------------------------------------------") logger.info("--------------------------------------------Start - Saving M18 Products / Materials--------------------------------------------")
ensureCunitSeededForAllIfEmpty() ensureCunitSeededForAllIfEmpty()


+ 5
- 0
src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt Переглянути файл

@@ -74,6 +74,11 @@ class M18TestController (
fun testSyncDoByCode(@RequestParam code: String): SyncResult { fun testSyncDoByCode(@RequestParam code: String): SyncResult {
return m18DeliveryOrderService.saveDeliveryOrderByCode(code) return m18DeliveryOrderService.saveDeliveryOrderByCode(code)
} }

@GetMapping("/test/product-by-code")
fun testSyncProductByCode(@RequestParam code: String): SyncResult {
return m18MasterDataService.saveProductByCode(code)
}
// --------------------------------------------- Scheduler --------------------------------------------- /// // --------------------------------------------- Scheduler --------------------------------------------- ///
// @GetMapping("/schedule/po") // @GetMapping("/schedule/po")
// fun schedulePo(@RequestParam @Valid newCron: String) { // fun schedulePo(@RequestParam @Valid newCron: String) {


+ 3
- 0
src/main/java/com/ffii/fpsms/modules/common/SettingNames.java Переглянути файл

@@ -99,4 +99,7 @@ public abstract class SettingNames {
/** 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 (e.g. PP1175); blank = no filter (all packaging JOs) */
public static final String LASER_PRINT_ITEM_CODES = "LASER_PRINT.itemCodes"; 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.) */
public static final String LASER_PRINT_LAST_RECEIVE_SUCCESS = "LASER_PRINT.lastReceiveSuccess";

} }

+ 43
- 1
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DeliveryOrderService.kt Переглянути файл

@@ -1360,7 +1360,11 @@ open class DeliveryOrderService(
} }
params["deliveryNoteCode"] = doPickOrderRecord.deliveryNoteCode ?: "" params["deliveryNoteCode"] = doPickOrderRecord.deliveryNoteCode ?: ""
params["shopAddress"] = cartonLabelInfo[0].shopAddress ?: "" params["shopAddress"] = cartonLabelInfo[0].shopAddress ?: ""
params["shopName"] = doPickOrderRecord.shopName ?: cartonLabelInfo[0].shopName ?: ""
val rawShopLabel = doPickOrderRecord.shopName ?: cartonLabelInfo[0].shopName ?: ""
val parsedShopLabel = parseShopLabelForCartonLabel(rawShopLabel)
params["shopCode"] = parsedShopLabel.shopCode
params["shopCodeAbbr"] = parsedShopLabel.shopCodeAbbr
params["shopName"] = parsedShopLabel.shopNameForLabel
params["truckNo"] = doPickOrderRecord.truckLanceCode ?: "" params["truckNo"] = doPickOrderRecord.truckLanceCode ?: ""


for (cartonNumber in 1..request.numOfCarton) { for (cartonNumber in 1..request.numOfCarton) {
@@ -1374,6 +1378,44 @@ open class DeliveryOrderService(
) )
} }


private data class ParsedShopLabelForCartonLabel(
val shopCode: String,
val shopCodeAbbr: String,
val shopNameForLabel: String
)

private fun parseShopLabelForCartonLabel(rawInput: String): ParsedShopLabelForCartonLabel {
// Fixed input format: shopCode - shopName1-shopName2
val raw = rawInput.trim()

val (shopCodePartRaw, restPart) = raw.split(" - ", limit = 2).let { parts ->
(parts.getOrNull(0)?.trim().orEmpty()) to (parts.getOrNull(1)?.trim().orEmpty())
}

val shopCode = shopCodePartRaw.let { code ->
val trimmed = code.trim()
if (trimmed.length > 5) trimmed.substring(0, 5) else trimmed
}

val (shopName1, shopName2) = restPart.split("-", limit = 2).let { parts ->
(parts.getOrNull(0)?.trim().orEmpty()) to (parts.getOrNull(1)?.trim().orEmpty())
}

val shopNameForLabel = if (shopName2.isNotBlank()) {
"$shopName1\n$shopName2"
} else {
shopName1
}

val shopCodeAbbr = if (shopCode.length >= 2) shopCode.substring(0, 2) else shopCode

return ParsedShopLabelForCartonLabel(
shopCode = shopCode,
shopCodeAbbr = shopCodeAbbr,
shopNameForLabel = shopNameForLabel
)
}



//Print Carton Labels //Print Carton Labels
@Transactional @Transactional


+ 42
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/scheduler/LaserBag2AutoSendScheduler.kt Переглянути файл

@@ -0,0 +1,42 @@
package com.ffii.fpsms.modules.jobOrder.scheduler

import com.ffii.fpsms.modules.jobOrder.service.LaserBag2AutoSendService
import org.slf4j.LoggerFactory
import org.springframework.beans.factory.annotation.Value
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
import java.time.LocalDate

/**
* Periodically runs the same laser TCP send as /laserPrint (DB LASER_PRINT.host / port / itemCodes).
* Disabled by default; set laser.bag2.auto-send.enabled=true.
*/
@Component
class LaserBag2AutoSendScheduler(
private val laserBag2AutoSendService: LaserBag2AutoSendService,
@Value("\${laser.bag2.auto-send.enabled:false}") private val enabled: Boolean,
@Value("\${laser.bag2.auto-send.limit-per-run:1}") private val limitPerRun: Int,
) {
private val logger = LoggerFactory.getLogger(javaClass)

@Scheduled(fixedRateString = "\${laser.bag2.auto-send.interval-ms:60000}")
fun tick() {
if (!enabled) {
return
}
try {
val report = laserBag2AutoSendService.runAutoSend(
planStart = LocalDate.now(),
limitPerRun = limitPerRun,
)
logger.info(
"Laser Bag2 scheduler: processed {}/{} job orders for {}",
report.jobOrdersProcessed,
report.jobOrdersFound,
report.planStart,
)
} catch (e: Exception) {
logger.error("Laser Bag2 scheduler failed", e)
}
}
}

+ 114
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/service/LaserBag2AutoSendService.kt Переглянути файл

@@ -0,0 +1,114 @@
package com.ffii.fpsms.modules.jobOrder.service

import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2AutoSendReport
import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2JobSendResult
import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SendRequest
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.time.LocalDate

/**
* Finds packaging job orders for [planStart] using the same filter as [PlasticBagPrinterService.listLaserPrintJobOrders]
* ([LASER_PRINT.itemCodes]), then sends Bag2-style laser TCP payloads via [PlasticBagPrinterService.sendLaserBag2Job],
* which uses [com.ffii.fpsms.modules.common.SettingNames.LASER_PRINT_HOST] / [LASER_PRINT_PORT] from the database.
*
* Matches /laserPrint row click: [sendsPerJob] rounds with [delayBetweenSendsMs] between rounds (default 3 × 3s like the frontend).
*/
@Service
class LaserBag2AutoSendService(
private val plasticBagPrinterService: PlasticBagPrinterService,
) {
private val logger = LoggerFactory.getLogger(javaClass)

companion object {
/** Same as LaserPrint page (3 sends per row click). */
const val DEFAULT_SENDS_PER_JOB = 3
const val DEFAULT_DELAY_BETWEEN_SENDS_MS = 3000L
}

fun runAutoSend(
planStart: LocalDate,
limitPerRun: Int = 0,
sendsPerJob: Int = DEFAULT_SENDS_PER_JOB,
delayBetweenSendsMs: Long = DEFAULT_DELAY_BETWEEN_SENDS_MS,
): LaserBag2AutoSendReport {
val (reachable, laserIp, laserPort) = plasticBagPrinterService.probeLaserBag2Tcp()
if (!reachable) {
logger.warn("Connection failed to the laser print: {} / {}", laserIp, laserPort)
return LaserBag2AutoSendReport(
planStart = planStart,
jobOrdersFound = 0,
jobOrdersProcessed = 0,
results = emptyList(),
)
}

val orders = plasticBagPrinterService.listLaserPrintJobOrders(planStart)
val toProcess = if (limitPerRun > 0) orders.take(limitPerRun) else orders
val results = mutableListOf<LaserBag2JobSendResult>()

logger.info(
"Laser Bag2 auto-send: planStart={}, found={}, processing={}, sendsPerJob={}",
planStart,
orders.size,
toProcess.size,
sendsPerJob,
)

for (jo in toProcess) {
var lastMsg = ""
var overallOk = true
var lastPrinterAck: String? = null
var lastReceiveAck = false
for (attempt in 1..sendsPerJob) {
val resp = plasticBagPrinterService.sendLaserBag2Job(
LaserBag2SendRequest(
itemId = jo.itemId,
stockInLineId = jo.stockInLineId,
itemCode = jo.itemCode,
itemName = jo.itemName,
jobOrderId = jo.id,
jobOrderNo = jo.code,
lotNo = jo.lotNo,
source = "AUTO",
),
)
lastMsg = resp.message
lastPrinterAck = resp.printerAck
lastReceiveAck = resp.receiveAcknowledged
if (!resp.success) {
overallOk = false
logger.warn("Laser send failed jobOrderId={} attempt={}: {}", jo.id, attempt, resp.message)
break
}
if (attempt < sendsPerJob) {
try {
Thread.sleep(delayBetweenSendsMs)
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
overallOk = false
lastMsg = "Interrupted"
break
}
}
}
results.add(
LaserBag2JobSendResult(
jobOrderId = jo.id,
itemCode = jo.itemCode,
success = overallOk,
message = lastMsg,
printerAck = lastPrinterAck,
receiveAcknowledged = lastReceiveAck,
),
)
}

return LaserBag2AutoSendReport(
planStart = planStart,
jobOrdersFound = orders.size,
jobOrdersProcessed = toProcess.size,
results = results,
)
}
}

+ 225
- 32
src/main/java/com/ffii/fpsms/modules/jobOrder/service/PlasticBagPrinterService.kt Переглянути файл

@@ -7,12 +7,17 @@ import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository
import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SendRequest import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SendRequest
import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SendResponse import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SendResponse
import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SettingsResponse import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SettingsResponse
import com.ffii.fpsms.modules.jobOrder.web.model.LaserLastReceiveSuccessDto
import com.ffii.fpsms.modules.jobOrder.web.model.PrintRequest import com.ffii.fpsms.modules.jobOrder.web.model.PrintRequest
import com.ffii.fpsms.modules.jobOrder.web.model.LaserRequest import com.ffii.fpsms.modules.jobOrder.web.model.LaserRequest
import com.ffii.fpsms.modules.jobOrder.web.model.NgpclPushResponse
import com.ffii.fpsms.modules.jobOrder.web.model.OnPackQrJobOrderRequest import com.ffii.fpsms.modules.jobOrder.web.model.OnPackQrJobOrderRequest
import com.ffii.fpsms.modules.settings.service.SettingsService import com.ffii.fpsms.modules.settings.service.SettingsService
import com.ffii.fpsms.modules.stock.entity.StockInLineRepository import com.ffii.fpsms.modules.stock.entity.StockInLineRepository
import com.ffii.fpsms.py.PrintedQtyByChannel
import com.ffii.fpsms.py.PyJobOrderListItem import com.ffii.fpsms.py.PyJobOrderListItem
import com.ffii.fpsms.py.PyJobOrderPrintSubmitService
import org.springframework.core.env.Environment
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.awt.Color import java.awt.Color
import java.awt.Font import java.awt.Font
@@ -26,6 +31,10 @@ import javax.imageio.ImageIO
import com.google.zxing.BarcodeFormat import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter import com.google.zxing.qrcode.QRCodeWriter
import java.net.URI
import java.net.http.HttpClient
import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.net.Socket import java.net.Socket
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.io.PrintWriter import java.io.PrintWriter
@@ -39,17 +48,32 @@ import java.net.ConnectException
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
import org.springframework.core.io.ClassPathResource import org.springframework.core.io.ClassPathResource
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import com.fasterxml.jackson.databind.ObjectMapper
import java.time.Duration
import java.time.Instant
import java.time.LocalDate import java.time.LocalDate


// Data class to store bitmap bytes + width (for XML) // Data class to store bitmap bytes + width (for XML)
data class BitmapResult(val bytes: ByteArray, val width: Int) data class BitmapResult(val bytes: ByteArray, val width: Int)


/** One Bag2-style laser TCP attempt (internal to [PlasticBagPrinterService]). */
private data class LaserBag2TcpResult(
val success: Boolean,
val message: String,
val payload: String,
val printerAck: String?,
val receiveAcknowledged: Boolean,
)

@Service @Service
class PlasticBagPrinterService( class PlasticBagPrinterService(
val jobOrderRepository: JobOrderRepository, val jobOrderRepository: JobOrderRepository,
private val jdbcDao: JdbcDao, private val jdbcDao: JdbcDao,
private val stockInLineRepository: StockInLineRepository, private val stockInLineRepository: StockInLineRepository,
private val settingsService: SettingsService, private val settingsService: SettingsService,
private val pyJobOrderPrintSubmitService: PyJobOrderPrintSubmitService,
private val environment: Environment,
private val objectMapper: ObjectMapper,
) { ) {
private val logger = LoggerFactory.getLogger(javaClass) private val logger = LoggerFactory.getLogger(javaClass)


@@ -75,7 +99,43 @@ class PlasticBagPrinterService(
val itemCodes = settingsService.findByName(SettingNames.LASER_PRINT_ITEM_CODES) val itemCodes = settingsService.findByName(SettingNames.LASER_PRINT_ITEM_CODES)
.map { it.value?.trim() ?: "" } .map { it.value?.trim() ?: "" }
.orElse(DEFAULT_LASER_ITEM_CODES) .orElse(DEFAULT_LASER_ITEM_CODES)
return LaserBag2SettingsResponse(host = host, port = port, itemCodes = itemCodes)
return LaserBag2SettingsResponse(
host = host,
port = port,
itemCodes = itemCodes,
lastReceiveSuccess = readLaserLastReceiveSuccessFromSettings(),
)
}

private fun readLaserLastReceiveSuccessFromSettings(): LaserLastReceiveSuccessDto? {
val raw = settingsService.findByName(SettingNames.LASER_PRINT_LAST_RECEIVE_SUCCESS)
.map { it.value }
.orElse(null)
?.trim()
.orEmpty()
if (raw.isBlank() || raw == "{}") return null
return try {
val dto = objectMapper.readValue(raw, LaserLastReceiveSuccessDto::class.java)
if (dto.sentAt.isNullOrBlank()) null else dto
} catch (e: Exception) {
logger.warn("Could not parse LASER_PRINT.lastReceiveSuccess: {}", e.message)
null
}
}

private fun persistLaserLastReceiveSuccess(request: LaserBag2SendRequest, printerAck: String?) {
val dto = LaserLastReceiveSuccessDto(
jobOrderId = request.jobOrderId,
jobOrderNo = request.jobOrderNo?.trim()?.takeIf { it.isNotEmpty() },
lotNo = request.lotNo?.trim()?.takeIf { it.isNotEmpty() },
itemId = request.itemId,
stockInLineId = request.stockInLineId,
printerAck = printerAck?.trim()?.takeIf { it.isNotEmpty() },
sentAt = Instant.now().toString(),
source = request.source?.trim()?.takeIf { it.isNotEmpty() } ?: "MANUAL",
)
val json = objectMapper.writeValueAsString(dto)
settingsService.update(SettingNames.LASER_PRINT_LAST_RECEIVE_SUCCESS, json)
} }


/** /**
@@ -108,7 +168,11 @@ class PlasticBagPrinterService(
allowed.contains(code) allowed.contains(code)
} }
} }
return filtered.map { jo -> toPyJobOrderListItem(jo) }
val ids = filtered.mapNotNull { it.id }
val printed = pyJobOrderPrintSubmitService.sumPrintedQtyByJobOrderIds(ids)
return filtered.map { jo ->
toPyJobOrderListItem(jo, printed[jo.id!!])
}
} }


private fun parseLaserItemCodeFilters(raw: String?): Set<String> { private fun parseLaserItemCodeFilters(raw: String?): Set<String> {
@@ -119,13 +183,14 @@ class PlasticBagPrinterService(
.toSet() .toSet()
} }


private fun toPyJobOrderListItem(jo: JobOrder): PyJobOrderListItem {
private fun toPyJobOrderListItem(jo: JobOrder, printed: PrintedQtyByChannel?): PyJobOrderListItem {
val itemCode = jo.bom?.item?.code ?: jo.bom?.code val itemCode = jo.bom?.item?.code ?: jo.bom?.code
val itemName = jo.bom?.name ?: jo.bom?.item?.name val itemName = jo.bom?.name ?: jo.bom?.item?.name
val itemId = jo.bom?.item?.id val itemId = jo.bom?.item?.id
val stockInLine = jo.id?.let { stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(it) } val stockInLine = jo.id?.let { stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(it) }
val stockInLineId = stockInLine?.id val stockInLineId = stockInLine?.id
val lotNo = stockInLine?.lotNo val lotNo = stockInLine?.lotNo
val p = printed ?: PrintedQtyByChannel()
return PyJobOrderListItem( return PyJobOrderListItem(
id = jo.id!!, id = jo.id!!,
code = jo.code, code = jo.code,
@@ -136,6 +201,9 @@ class PlasticBagPrinterService(
stockInLineId = stockInLineId, stockInLineId = stockInLineId,
itemId = itemId, itemId = itemId,
lotNo = lotNo, lotNo = lotNo,
bagPrintedQty = p.bagPrintedQty,
labelPrintedQty = p.labelPrintedQty,
laserPrintedQty = p.laserPrintedQty,
) )
} }


@@ -154,22 +222,39 @@ class PlasticBagPrinterService(
itemCode = request.itemCode, itemCode = request.itemCode,
itemName = request.itemName, itemName = request.itemName,
) )
if (first.first) {
return LaserBag2SendResponse(success = true, message = first.second, payloadSent = first.third)
val response = if (first.success) {
LaserBag2SendResponse(
success = true,
message = first.message,
payloadSent = first.payload,
printerAck = first.printerAck,
receiveAcknowledged = first.receiveAcknowledged,
)
} else {
val second = sendLaserBag2TcpOnce(
ip = ip,
port = port,
itemId = request.itemId,
stockInLineId = request.stockInLineId,
itemCode = request.itemCode,
itemName = request.itemName,
)
LaserBag2SendResponse(
success = second.success,
message = second.message,
payloadSent = second.payload,
printerAck = second.printerAck,
receiveAcknowledged = second.receiveAcknowledged,
)
} }
val second = sendLaserBag2TcpOnce(
ip = ip,
port = port,
itemId = request.itemId,
stockInLineId = request.stockInLineId,
itemCode = request.itemCode,
itemName = request.itemName,
)
return LaserBag2SendResponse(
success = second.first,
message = second.second,
payloadSent = second.third,
)
if (response.success && response.receiveAcknowledged) {
try {
persistLaserLastReceiveSuccess(request, response.printerAck)
} catch (e: Exception) {
logger.warn("Could not persist laser last receive success: {}", e.message)
}
}
return response
} }


private fun resolveLaserBag2Host(): String { private fun resolveLaserBag2Host(): String {
@@ -190,6 +275,17 @@ class PlasticBagPrinterService(
return v.toIntOrNull() ?: DEFAULT_LASER_BAG2_PORT return v.toIntOrNull() ?: DEFAULT_LASER_BAG2_PORT
} }


/**
* TCP connect probe to configured [LASER_PRINT.host] / [LASER_PRINT.port] (same endpoint as [sendLaserBag2Job] / auto-send).
* @return Triple(ok, host, port)
*/
fun probeLaserBag2Tcp(): Triple<Boolean, String, Int> {
val host = resolveLaserBag2Host()
val port = resolveLaserBag2Port()
val (ok, _) = checkTcpPrinter(host, port, "Laser")
return Triple(ok, host, port)
}

private fun sendLaserBag2TcpOnce( private fun sendLaserBag2TcpOnce(
ip: String, ip: String,
port: Int, port: Int,
@@ -197,7 +293,7 @@ class PlasticBagPrinterService(
stockInLineId: Long?, stockInLineId: Long?,
itemCode: String?, itemCode: String?,
itemName: String?, itemName: String?,
): Triple<Boolean, String, String> {
): LaserBag2TcpResult {
val codeStr = (itemCode ?: "").trim().replace(";", ",") val codeStr = (itemCode ?: "").trim().replace(";", ",")
val nameStr = (itemName ?: "").trim().replace(";", ",") val nameStr = (itemName ?: "").trim().replace(";", ",")
val payload = if (itemId != null && stockInLineId != null) { val payload = if (itemId != null && stockInLineId != null) {
@@ -214,27 +310,61 @@ class PlasticBagPrinterService(
val out = socket.getOutputStream() val out = socket.getOutputStream()
out.write(bytes) out.write(bytes)
out.flush() out.flush()
// Half-close the write side so the peer sees a clean end-of-send before we read the ack.
// Abrupt full socket close right after read often makes EZCAD log "Remote TCP client disconnected".
try {
socket.shutdownOutput()
} catch (_: Exception) {
}
var ackRaw: String? = null
var receiveAck = false
socket.soTimeout = 500 socket.soTimeout = 500
try { try {
val buf = ByteArray(4096) val buf = ByteArray(4096)
val n = socket.getInputStream().read(buf) val n = socket.getInputStream().read(buf)
if (n > 0) { if (n > 0) {
val ack = String(buf, 0, n, StandardCharsets.UTF_8).trim().lowercase()
if (ack.contains("receive") && !ack.contains("invalid")) {
return Triple(true, "已送出激光機:$payload(已確認)", payload)
ackRaw = String(buf, 0, n, StandardCharsets.UTF_8).trim()
val ackLower = ackRaw.lowercase()
if (ackLower.contains("receive") && !ackLower.contains("invalid")) {
receiveAck = true
logger.info(
"Laser TCP ack (receive): {}:{} sent={} ack={}",
ip,
port,
payload,
ackRaw,
)
} else if (ackRaw.isNotEmpty()) {
logger.info(
"Laser TCP response: {}:{} sent={} ack={}",
ip,
port,
payload,
ackRaw,
)
} }
} }
} catch (_: SocketTimeoutException) { } catch (_: SocketTimeoutException) {
// Same as Python: ignore read timeout, treat as sent
// Same as Python Bag3: ignore read timeout, payload was still sent
}
val msg = if (receiveAck) {
"已送出激光機:$payload(已確認)"
} else {
"已送出激光機:$payload"
} }
return Triple(true, "已送出激光機:$payload", payload)
return LaserBag2TcpResult(true, msg, payload, ackRaw, receiveAck)
} catch (e: ConnectException) { } catch (e: ConnectException) {
return Triple(false, "無法連線至 $ip:$port,請確認激光機已開機且 IP 正確。", payload)
return LaserBag2TcpResult(false, "無法連線至 $ip:$port,請確認激光機已開機且 IP 正確。", payload, null, false)
} catch (e: SocketTimeoutException) { } catch (e: SocketTimeoutException) {
return Triple(false, "連線逾時 ($ip:$port),請檢查網路與連接埠。", payload)
return LaserBag2TcpResult(false, "連線逾時 ($ip:$port),請檢查網路與連接埠。", payload, null, false)
} catch (e: Exception) { } catch (e: Exception) {
return Triple(false, "激光機送出失敗:${e.message}", payload)
return LaserBag2TcpResult(false, "激光機送出失敗:${e.message}", payload, null, false)
} finally { } finally {
try {
Thread.sleep(100)
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
}
try { try {
socket?.close() socket?.close()
} catch (_: Exception) { } catch (_: Exception) {
@@ -526,6 +656,59 @@ class PlasticBagPrinterService(
return baos.toByteArray() return baos.toByteArray()
} }


/**
* Builds the same ZIP as [generateOnPackQrTextZip] and POSTs it to [ngpcl.push-url] (application/zip).
* When the URL is blank, returns [NgpclPushResponse] with pushed=false so callers can fall back to manual download.
*/
fun pushOnPackQrTextZipToNgpcl(jobOrders: List<OnPackQrJobOrderRequest>): NgpclPushResponse {
val url = (environment.getProperty("ngpcl.push-url") ?: "").trim()
if (url.isEmpty()) {
return NgpclPushResponse(
pushed = false,
message = "NGPCL push URL not configured. Set ngpcl.push-url or NGPCL_PUSH_URL, or download the ZIP and transfer loose files manually.",
)
}
val zipBytes = try {
generateOnPackQrTextZip(jobOrders)
} catch (e: Exception) {
logger.warn("OnPack text ZIP generation failed before NGPCL push", e)
return NgpclPushResponse(
pushed = false,
message = e.message ?: "ZIP generation failed",
)
}
return try {
val client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(30))
.build()
val request = HttpRequest.newBuilder()
.uri(URI.create(url))
.timeout(Duration.ofMinutes(2))
.header("Content-Type", "application/zip")
.POST(HttpRequest.BodyPublishers.ofByteArray(zipBytes))
.build()
val response = client.send(request, HttpResponse.BodyHandlers.ofString())
val body = response.body()
if (response.statusCode() in 200..299) {
NgpclPushResponse(
pushed = true,
message = "NGPCL accepted (HTTP ${response.statusCode()})${if (body.isNotBlank()) ": ${body.take(300)}" else ""}",
)
} else {
NgpclPushResponse(
pushed = false,
message = "NGPCL returned HTTP ${response.statusCode()}: ${body.take(500)}",
)
}
} catch (e: Exception) {
logger.error("NGPCL push failed", e)
NgpclPushResponse(
pushed = false,
message = e.message ?: "Push failed: ${e.javaClass.simpleName}",
)
}
}

/** /**
* Returns uppercase item codes present in `onpack_qr` with the given [templateType] (`bmp` or `text`). * Returns uppercase item codes present in `onpack_qr` with the given [templateType] (`bmp` or `text`).
* Empty or NULL `template_type` is treated as `bmp` for backward compatibility. * Empty or NULL `template_type` is treated as `bmp` for backward compatibility.
@@ -759,7 +942,17 @@ class PlasticBagPrinterService(
): Pair<Boolean, String> { ): Pair<Boolean, String> {
return when (printerType.lowercase()) { return when (printerType.lowercase()) {
"dataflex" -> checkTcpPrinter(printerIp, printerPort ?: 3008, "DataFlex") "dataflex" -> checkTcpPrinter(printerIp, printerPort ?: 3008, "DataFlex")
"laser" -> checkTcpPrinter(printerIp, printerPort ?: 45678, "Laser")
// - /bagPrint: sends browser localStorage laser IP (may differ from DB — second machine).
// - /laserPrint: omit printerIp → use DB LASER_PRINT.* (same as sendLaserBag2Job).
// - Explicit empty string → not configured (do not fall back to DB).
"laser" -> {
val trimmed = printerIp?.trim().orEmpty()
when {
trimmed.isNotEmpty() -> checkTcpPrinter(trimmed, printerPort ?: 45678, "Laser")
printerIp == null -> checkTcpPrinter(resolveLaserBag2Host(), resolveLaserBag2Port(), "Laser")
else -> false to "Laser IP is not configured"
}
}
"label" -> { "label" -> {
val comPort = labelCom?.trim().orEmpty() val comPort = labelCom?.trim().orEmpty()
if (comPort.isBlank()) { if (comPort.isBlank()) {
@@ -781,14 +974,14 @@ class PlasticBagPrinterService(
return try { return try {
Socket().use { socket -> Socket().use { socket ->
socket.connect(InetSocketAddress(ip, port), 3000) socket.connect(InetSocketAddress(ip, port), 3000)
true to "$printerName connected"
true to "$printerName 已連線($ip:$port)"
} }
} catch (e: SocketTimeoutException) { } catch (e: SocketTimeoutException) {
false to "$printerName connection timed out"
false to "$printerName 連線逾時($ip:$port)"
} catch (e: ConnectException) { } catch (e: ConnectException) {
false to "$printerName connection refused"
false to "$printerName 無法連線($ip:$port)"
} catch (e: Exception) { } catch (e: Exception) {
false to "$printerName connection failed: ${e.message}"
false to "$printerName 連線失敗($ip:$port):${e.message}"
} }
} }




+ 31
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/web/PlasticBagPrinterController.kt Переглянути файл

@@ -1,12 +1,15 @@
package com.ffii.fpsms.modules.jobOrder.web package com.ffii.fpsms.modules.jobOrder.web


import com.ffii.fpsms.modules.jobOrder.service.LaserBag2AutoSendService
import com.ffii.fpsms.modules.jobOrder.service.PlasticBagPrinterService import com.ffii.fpsms.modules.jobOrder.service.PlasticBagPrinterService
import com.ffii.fpsms.modules.jobOrder.web.model.PrintRequest import com.ffii.fpsms.modules.jobOrder.web.model.PrintRequest
import com.ffii.fpsms.modules.jobOrder.web.model.LaserRequest import com.ffii.fpsms.modules.jobOrder.web.model.LaserRequest
import com.ffii.fpsms.modules.jobOrder.web.model.Laser2Request import com.ffii.fpsms.modules.jobOrder.web.model.Laser2Request
import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2AutoSendReport
import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SendRequest import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SendRequest
import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SendResponse import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SendResponse
import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SettingsResponse import com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SettingsResponse
import com.ffii.fpsms.modules.jobOrder.web.model.NgpclPushResponse
import com.ffii.fpsms.modules.jobOrder.web.model.OnPackQrDownloadRequest import com.ffii.fpsms.modules.jobOrder.web.model.OnPackQrDownloadRequest
import com.ffii.fpsms.modules.jobOrder.web.model.PrinterStatusRequest import com.ffii.fpsms.modules.jobOrder.web.model.PrinterStatusRequest
import com.ffii.fpsms.modules.jobOrder.web.model.PrinterStatusResponse import com.ffii.fpsms.modules.jobOrder.web.model.PrinterStatusResponse
@@ -24,6 +27,7 @@ import org.slf4j.LoggerFactory
@RequestMapping("/plastic") @RequestMapping("/plastic")
class PlasticBagPrinterController( class PlasticBagPrinterController(
private val plasticBagPrinterService: PlasticBagPrinterService, private val plasticBagPrinterService: PlasticBagPrinterService,
private val laserBag2AutoSendService: LaserBag2AutoSendService,
) { ) {
private val logger = LoggerFactory.getLogger(javaClass) private val logger = LoggerFactory.getLogger(javaClass)


@@ -58,6 +62,24 @@ class PlasticBagPrinterController(
} }
} }


/**
* Same as /laserPrint row workflow: list job orders for [planStart] filtered by LASER_PRINT.itemCodes,
* then for each (up to [limitPerRun], 0 = all) send laser TCP commands using LASER_PRINT.host/port (3× with 3s gap per job).
* For manual runs from /testing; scheduler uses [LaserBag2AutoSendScheduler].
*/
@PostMapping("/laser-bag2-auto-send")
fun runLaserBag2AutoSend(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) planStart: LocalDate?,
@RequestParam(required = false, defaultValue = "0") limitPerRun: Int,
): ResponseEntity<LaserBag2AutoSendReport> {
val date = planStart ?: LocalDate.now()
val report = laserBag2AutoSendService.runAutoSend(
planStart = date,
limitPerRun = limitPerRun,
)
return ResponseEntity.ok(report)
}

@PostMapping("/check-printer") @PostMapping("/check-printer")
fun checkPrinter(@RequestBody request: PrinterStatusRequest): ResponseEntity<PrinterStatusResponse> { fun checkPrinter(@RequestBody request: PrinterStatusRequest): ResponseEntity<PrinterStatusResponse> {
val (connected, message) = plasticBagPrinterService.checkPrinterConnection( val (connected, message) = plasticBagPrinterService.checkPrinterConnection(
@@ -152,6 +174,15 @@ class PlasticBagPrinterController(
} }
} }


/**
* Same payload as [downloadOnPackQrText], but POSTs the generated ZIP to `ngpcl.push-url` (application/zip).
* Returns JSON: `{ pushed, message }`. When push URL is unset, `pushed` is false — use download instead.
*/
@PostMapping("/ngpcl/push-onpack-qr-text")
fun pushOnPackQrTextToNgpcl(@RequestBody request: OnPackQrDownloadRequest): ResponseEntity<NgpclPushResponse> {
return ResponseEntity.ok(plasticBagPrinterService.pushOnPackQrTextZipToNgpcl(request.jobOrders))
}

/** /**
* Test API to generate and download the printer job files as a ZIP. * Test API to generate and download the printer job files as a ZIP.
* ONPACK2030 * ONPACK2030


+ 20
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2AutoSendReport.kt Переглянути файл

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

import java.time.LocalDate

/** Result of [com.ffii.fpsms.modules.jobOrder.service.LaserBag2AutoSendService.runAutoSend]. */
data class LaserBag2AutoSendReport(
val planStart: LocalDate,
val jobOrdersFound: Int,
val jobOrdersProcessed: Int,
val results: List<LaserBag2JobSendResult>,
)

data class LaserBag2JobSendResult(
val jobOrderId: Long,
val itemCode: String?,
val success: Boolean,
val message: String,
val printerAck: String? = null,
val receiveAcknowledged: Boolean = false,
)

+ 8
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SendRequest.kt Переглянути файл

@@ -3,6 +3,9 @@ package com.ffii.fpsms.modules.jobOrder.web.model
/** /**
* Body for Bag2.py-style laser TCP send: `json;itemCode;itemName;;` (UTF-8). * Body for Bag2.py-style laser TCP send: `json;itemCode;itemName;;` (UTF-8).
* Optional [printerIp] / [printerPort] override system settings [LASER_PRINT.host] / [LASER_PRINT.port]. * Optional [printerIp] / [printerPort] override system settings [LASER_PRINT.host] / [LASER_PRINT.port].
*
* Optional job metadata is used to persist [com.ffii.fpsms.modules.common.SettingNames.LASER_PRINT_LAST_RECEIVE_SUCCESS]
* when the printer returns a receive ack.
*/ */
data class LaserBag2SendRequest( data class LaserBag2SendRequest(
val itemId: Long? = null, val itemId: Long? = null,
@@ -11,4 +14,9 @@ data class LaserBag2SendRequest(
val itemName: String? = null, val itemName: String? = null,
val printerIp: String? = null, val printerIp: String? = null,
val printerPort: Int? = null, val printerPort: Int? = null,
val jobOrderId: Long? = null,
val jobOrderNo: String? = null,
val lotNo: String? = null,
/** AUTO (auto-send) or MANUAL (/laserPrint); optional. */
val source: String? = null,
) )

+ 4
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SendResponse.kt Переглянути файл

@@ -4,4 +4,8 @@ data class LaserBag2SendResponse(
val success: Boolean, val success: Boolean,
val message: String, val message: String,
val payloadSent: String? = null, val payloadSent: String? = null,
/** Raw bytes from the laser TCP peer after our payload (often `receive;;`). */
val printerAck: String? = null,
/** True when [printerAck] contained `receive` and not `invalid` (same rule as Bag3.py). */
val receiveAcknowledged: Boolean = false,
) )

+ 2
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2SettingsResponse.kt Переглянути файл

@@ -2,9 +2,11 @@ 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 (e.g. `PP1175` or `PP1175,AB123`). 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( data class LaserBag2SettingsResponse(
val host: String, val host: String,
val port: Int, val port: Int,
val itemCodes: String, val itemCodes: String,
val lastReceiveSuccess: LaserLastReceiveSuccessDto? = null,
) )

+ 22
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserLastReceiveSuccessDto.kt Переглянути файл

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

import com.fasterxml.jackson.annotation.JsonIgnoreProperties

/**
* Persisted in [com.ffii.fpsms.modules.common.SettingNames.LASER_PRINT_LAST_RECEIVE_SUCCESS] when the laser
* TCP peer returns a receive-style ack ([com.ffii.fpsms.modules.jobOrder.web.model.LaserBag2SendResponse.receiveAcknowledged]).
*/
@JsonIgnoreProperties(ignoreUnknown = true)
data class LaserLastReceiveSuccessDto(
val jobOrderId: Long? = null,
/** Job order code (工單號). */
val jobOrderNo: String? = null,
val lotNo: String? = null,
val itemId: Long? = null,
val stockInLineId: Long? = null,
val printerAck: String? = null,
/** ISO-8601 instant string (server UTC). */
val sentAt: String? = null,
/** e.g. AUTO (scheduler/auto-send) or MANUAL (/laserPrint row). */
val source: String? = null,
)

+ 6
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/PlasticPrintRequest.kt Переглянути файл

@@ -57,4 +57,10 @@ data class OnPackQrDownloadRequest(
data class OnPackQrJobOrderRequest( data class OnPackQrJobOrderRequest(
val jobOrderId: Long, val jobOrderId: Long,
val itemCode: String, val itemCode: String,
)

/** Result of POST /plastic/ngpcl/push-onpack-qr-text — server POSTs the lemon ZIP bytes to [ngpcl.push-url] when configured. */
data class NgpclPushResponse(
val pushed: Boolean,
val message: String,
) )

+ 13
- 4
src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt Переглянути файл

@@ -647,7 +647,7 @@ return result
} }


/** /**
* Queries the database for Stock In Traceability Report data.
* Queries the database for Stock In Traceability Report data (入倉追蹤 PDF).
* Joins stock_in_line, stock_in, items, qc_result, inventory_lot, inventory_lot_line, warehouse, and shop tables. * Joins stock_in_line, stock_in, items, qc_result, inventory_lot, inventory_lot_line, warehouse, and shop tables.
* Supports comma-separated values for stockCategory (items.type) and itemCode. * Supports comma-separated values for stockCategory (items.type) and itemCode.
*/ */
@@ -737,7 +737,7 @@ return result
$lastInDateEndSql $lastInDateEndSql
ORDER BY it.code, sil.lotNo ORDER BY it.code, sil.lotNo
""".trimIndent() """.trimIndent()
return jdbcDao.queryForList(sql, args) return jdbcDao.queryForList(sql, args)
} }


@@ -793,7 +793,8 @@ return result
MAX(ROUND(COALESCE(pol.up, 0) * COALESCE(sil.acceptedQty, 0), 2)) AS lineAmount, MAX(ROUND(COALESCE(pol.up, 0) * COALESCE(sil.acceptedQty, 0), 2)) AS lineAmount,
MAX(COALESCE(cur.code, '')) AS currencyCode, MAX(COALESCE(cur.code, '')) AS currencyCode,
MAX(grn.grn_code) AS grnCode, MAX(grn.grn_code) AS grnCode,
MAX(grn.m18_record_id) AS grnId
MAX(grn.m18_record_id) AS grnId,
MAX(po.m18CreatedUId) AS poM18CreatedUId
FROM stock_in_line sil FROM stock_in_line sil
LEFT JOIN items it ON sil.itemId = it.id LEFT JOIN items it ON sil.itemId = it.id
LEFT JOIN purchase_order po ON sil.purchaseOrderId = po.id LEFT JOIN purchase_order po ON sil.purchaseOrderId = po.id
@@ -855,7 +856,15 @@ return result
"lineAmount" to (row["lineAmount"]?.let { n -> (n as? Number)?.toDouble() } ?: 0.0), "lineAmount" to (row["lineAmount"]?.let { n -> (n as? Number)?.toDouble() } ?: 0.0),
"currencyCode" to row["currencyCode"], "currencyCode" to row["currencyCode"],
"grnCode" to row["grnCode"], "grnCode" to row["grnCode"],
"grnId" to row["grnId"]
"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()
},
),
) )
} }
} }


+ 20
- 0
src/main/java/com/ffii/fpsms/modules/stock/entity/StockInLineRepository.kt Переглянути файл

@@ -80,6 +80,26 @@ fun findFirstByJobOrder_IdAndDeletedFalse(jobOrderId: Long): StockInLine?
""") """)
fun findCompletedByPurchaseOrderIdAndDeletedFalseWithItemNames(@Param("purchaseOrderId") purchaseOrderId: Long): List<StockInLine> fun findCompletedByPurchaseOrderIdAndDeletedFalseWithItemNames(@Param("purchaseOrderId") purchaseOrderId: Long): List<StockInLine>


/** Completed stock-in lines for this PO whose calendar receipt day matches (one M18 GRN per delivery date / DN batch). */
@Query(
"""
SELECT DISTINCT sil FROM StockInLine sil
LEFT JOIN FETCH sil.item
LEFT JOIN FETCH sil.purchaseOrderLine pol
LEFT JOIN FETCH pol.item
WHERE sil.purchaseOrder.id = :purchaseOrderId
AND sil.deleted = false
AND sil.status = 'completed'
AND sil.receiptDate IS NOT NULL
AND DATE(sil.receiptDate) = :receiptDate
ORDER BY sil.id
"""
)
fun findCompletedByPurchaseOrderIdAndReceiptDateAndDeletedFalseWithItemNames(
@Param("purchaseOrderId") purchaseOrderId: Long,
@Param("receiptDate") receiptDate: LocalDate,
): List<StockInLine>

@Query(""" @Query("""
SELECT sil FROM StockInLine sil SELECT sil FROM StockInLine sil
WHERE sil.receiptDate IS NOT NULL WHERE sil.receiptDate IS NOT NULL


+ 13
- 13
src/main/java/com/ffii/fpsms/modules/stock/service/SearchCompletedDnService.kt Переглянути файл

@@ -15,6 +15,7 @@ import java.time.LocalDateTime
/** /**
* Search completed stock-in lines (DN) by receipt date and process M18 GRN creation. * Search completed stock-in lines (DN) by receipt date and process M18 GRN creation.
* Query: receiptDate = yesterday, status = completed, purchaseOrderId not null. * Query: receiptDate = yesterday, status = completed, purchaseOrderId not null.
* Same PO may appear on multiple dates; each receipt date batch can produce its own M18 GRN.
*/ */
@Service @Service
open class SearchCompletedDnService( open class SearchCompletedDnService(
@@ -82,7 +83,7 @@ open class SearchCompletedDnService(


/** /**
* Post completed DNs and process each related purchase order for M18 GRN creation. * Post completed DNs and process each related purchase order for M18 GRN creation.
* Triggered by scheduler. One GRN per Purchase Order, with the PO lines it received (acceptedQty as ant qty).
* Triggered by scheduler. One GRN per PO **per receipt date** for lines completed on that date (same PO may receive multiple GRNs on different days).
* @param receiptDate Default: yesterday * @param receiptDate Default: yesterday
* @param skipFirst For testing/manual trigger: skip the first N POs. 1 = skip 1st, process from 2nd. 0 = process from 1st. * @param skipFirst For testing/manual trigger: skip the first N POs. 1 = skip 1st, process from 2nd. 0 = process from 1st.
* @param limitToFirst For testing/manual trigger: process only the next N POs after skip. 1 = one PO. null = all remaining POs. * @param limitToFirst For testing/manual trigger: process only the next N POs after skip. 1 = one PO. null = all remaining POs.
@@ -101,7 +102,7 @@ open class SearchCompletedDnService(
toProcess.forEach { (poId, silList) -> toProcess.forEach { (poId, silList) ->
silList.firstOrNull()?.let { first -> silList.firstOrNull()?.let { first ->
try { try {
stockInLineService.processPurchaseOrderForGrn(first)
stockInLineService.processPurchaseOrderForGrn(first, grnReceiptDate = receiptDate)
} catch (e: Exception) { } catch (e: Exception) {
logger.error("[postCompletedDnAndProcessGrn] Failed for PO id=$poId: ${e.message}", e) logger.error("[postCompletedDnAndProcessGrn] Failed for PO id=$poId: ${e.message}", e)
} }
@@ -111,13 +112,9 @@ open class SearchCompletedDnService(
} }


/** /**
* Retry GRN creation for completed stock-in lines where the PO does not have a SUCCESS log in
* `m18_goods_receipt_note_log`.
*
* Grouping behavior matches normal scheduler:
* - iterate by `receiptDate` day window
* - for each day, group by `PO (purchaseOrderId)`
* - process one GRN per PO using the first line from that PO group
* Retry GRN creation for completed stock-in lines where **some** line in that PO batch (same receipt date)
* has no SUCCESS row in `m18_goods_receipt_note_log` (matched by `stock_in_line_id`).
* Same PO can receive multiple GRNs on different delivery/receipt dates.
*/ */
@Transactional @Transactional
open fun postCompletedDnAndProcessGrnWithMissingRetry( open fun postCompletedDnAndProcessGrnWithMissingRetry(
@@ -172,9 +169,12 @@ open class SearchCompletedDnService(
val byPo = lines.groupBy { it.purchaseOrder?.id ?: 0L }.filterKeys { it != 0L } val byPo = lines.groupBy { it.purchaseOrder?.id ?: 0L }.filterKeys { it != 0L }
val entries = byPo.entries.toList() val entries = byPo.entries.toList()


// Only retry POs that do NOT have any successful GRN log.
val missingEntries = entries.filter { (poId, _) ->
!m18GoodsReceiptNoteLogRepository.existsByPurchaseOrderIdAndStatusTrue(poId)
// Per delivery date: retry if any completed line for this PO on this date lacks a successful GRN log.
val missingEntries = entries.filter { (_, silList) ->
silList.any { sil ->
val id = sil.id ?: return@any true
!m18GoodsReceiptNoteLogRepository.existsByStockInLineIdAndStatusTrue(id)
}
} }


val toProcess = missingEntries val toProcess = missingEntries
@@ -190,7 +190,7 @@ open class SearchCompletedDnService(
toProcess.forEach { (poId, silList) -> toProcess.forEach { (poId, silList) ->
silList.firstOrNull()?.let { first -> silList.firstOrNull()?.let { first ->
try { try {
stockInLineService.processPurchaseOrderForGrn(first)
stockInLineService.processPurchaseOrderForGrn(first, grnReceiptDate = receiptDate)
} catch (e: Exception) { } catch (e: Exception) {
logger.error("[postCompletedDnAndProcessMissingGrnForReceiptDate] Failed for PO id=$poId: ${e.message}", e) logger.error("[postCompletedDnAndProcessMissingGrnForReceiptDate] Failed for PO id=$poId: ${e.message}", e)
} }


+ 26
- 6
src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt Переглянути файл

@@ -660,10 +660,10 @@ open class StockInLineService(


/** /**
* Processes a purchase order for M18 GRN creation. Updates PO/line status and creates GRN if applicable. * Processes a purchase order for M18 GRN creation. Updates PO/line status and creates GRN if applicable.
* Called by SearchCompletedDnService (scheduler postCompletedDnAndProcessGrn) for batch processing of yesterday's completed DNs.
* @param grnReceiptDate When set (scheduler paths), only completed lines on that calendar receipt day are included — separate deliveries get separate GRNs.
*/ */
open fun processPurchaseOrderForGrn(stockInLine: StockInLine) {
tryUpdatePurchaseOrderAndCreateGrnIfCompleted(stockInLine)
open fun processPurchaseOrderForGrn(stockInLine: StockInLine, grnReceiptDate: LocalDate? = null) {
tryUpdatePurchaseOrderAndCreateGrnIfCompleted(stockInLine, grnReceiptDate = grnReceiptDate)
} }


/** /**
@@ -671,7 +671,10 @@ open class StockInLineService(
* creates M18 Goods Receipt Note. Called after saving stock-in line for both * creates M18 Goods Receipt Note. Called after saving stock-in line for both
* RECEIVED and PENDING/ESCALATED status flows. * RECEIVED and PENDING/ESCALATED status flows.
*/ */
private fun tryUpdatePurchaseOrderAndCreateGrnIfCompleted(savedStockInLine: StockInLine) {
private fun tryUpdatePurchaseOrderAndCreateGrnIfCompleted(
savedStockInLine: StockInLine,
grnReceiptDate: LocalDate? = null,
) {
if (savedStockInLine.purchaseOrderLine == null) return if (savedStockInLine.purchaseOrderLine == null) return
val pol = savedStockInLine.purchaseOrderLine ?: return val pol = savedStockInLine.purchaseOrderLine ?: return
updatePurchaseOrderLineStatus(pol) updatePurchaseOrderLineStatus(pol)
@@ -682,12 +685,29 @@ open class StockInLineService(
// Align POL.m18Lot with M18 before GRN (sourceLot must match M18 PO line lot or AN save may fail). // Align POL.m18Lot with M18 before GRN (sourceLot must match M18 PO line lot or AN save may fail).
syncPurchaseOrderLineM18LotFromM18(savedPo) syncPurchaseOrderLineM18LotFromM18(savedPo)


// Defensive: load only completed stock-in lines for the PO, so GRN payload can't include pending/escalated.
val linesForGrn = stockInLineRepository.findCompletedByPurchaseOrderIdAndDeletedFalseWithItemNames(savedPo.id!!)
// Completed lines for GRN: either this receipt date only (multi-delivery POs) or all completed on PO (legacy).
val linesForGrn = if (grnReceiptDate != null) {
stockInLineRepository.findCompletedByPurchaseOrderIdAndReceiptDateAndDeletedFalseWithItemNames(
savedPo.id!!,
grnReceiptDate,
)
} else {
stockInLineRepository.findCompletedByPurchaseOrderIdAndDeletedFalseWithItemNames(savedPo.id!!)
}
if (linesForGrn.isEmpty()) { if (linesForGrn.isEmpty()) {
logger.info("[tryUpdatePurchaseOrderAndCreateGrnIfCompleted] DEBUG: Skipping M18 GRN - no stock-in lines for PO id=${savedPo.id} code=${savedPo.code}") logger.info("[tryUpdatePurchaseOrderAndCreateGrnIfCompleted] DEBUG: Skipping M18 GRN - no stock-in lines for PO id=${savedPo.id} code=${savedPo.code}")
return return
} }
if (grnReceiptDate != null && linesForGrn.all { sil ->
val id = sil.id ?: return@all false
m18GoodsReceiptNoteLogRepository.existsByStockInLineIdAndStatusTrue(id)
}) {
logger.info(
"[tryUpdatePurchaseOrderAndCreateGrnIfCompleted] Skipping M18 GRN — all ${linesForGrn.size} line(s) for PO id=${savedPo.id} " +
"code=${savedPo.code} on receipt date $grnReceiptDate already have successful GRN logs"
)
return
}
if (savedPo.m18BeId == null || savedPo.supplier?.m18Id == null || savedPo.currency?.m18Id == null) { if (savedPo.m18BeId == null || savedPo.supplier?.m18Id == null || savedPo.currency?.m18Id == null) {
logger.info("[tryUpdatePurchaseOrderAndCreateGrnIfCompleted] DEBUG: Skipping M18 GRN - missing M18 ids for PO id=${savedPo.id} code=${savedPo.code}. m18BeId=${savedPo.m18BeId}, supplier.m18Id=${savedPo.supplier?.m18Id}, currency.m18Id=${savedPo.currency?.m18Id}") logger.info("[tryUpdatePurchaseOrderAndCreateGrnIfCompleted] DEBUG: Skipping M18 GRN - missing M18 ids for PO id=${savedPo.id} code=${savedPo.code}. m18BeId=${savedPo.m18BeId}, supplier.m18Id=${savedPo.supplier?.m18Id}, currency.m18Id=${savedPo.currency?.m18Id}")
return return


+ 8
- 0
src/main/java/com/ffii/fpsms/py/PrintedQtyByChannel.kt Переглянути файл

@@ -0,0 +1,8 @@
package com.ffii.fpsms.py

/** Per–job-order cumulative printed qty, split by printer channel (not mixed). */
data class PrintedQtyByChannel(
val bagPrintedQty: Long = 0,
val labelPrintedQty: Long = 0,
val laserPrintedQty: Long = 0,
)

+ 35
- 2
src/main/java/com/ffii/fpsms/py/PyController.kt Переглянути файл

@@ -7,9 +7,13 @@ import com.ffii.fpsms.modules.stock.entity.StockInLineRepository
import org.springframework.format.annotation.DateTimeFormat import org.springframework.format.annotation.DateTimeFormat
import org.springframework.http.ResponseEntity import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import org.springframework.http.HttpStatus
import java.time.LocalDate import java.time.LocalDate
import java.time.LocalDateTime import java.time.LocalDateTime


@@ -23,6 +27,7 @@ open class PyController(
private val jobOrderRepository: JobOrderRepository, private val jobOrderRepository: JobOrderRepository,
private val stockInLineRepository: StockInLineRepository, private val stockInLineRepository: StockInLineRepository,
private val plasticBagPrinterService: PlasticBagPrinterService, private val plasticBagPrinterService: PlasticBagPrinterService,
private val pyJobOrderPrintSubmitService: PyJobOrderPrintSubmitService,
) { ) {
companion object { companion object {
private const val PACKAGING_PROCESS_NAME = "包裝" private const val PACKAGING_PROCESS_NAME = "包裝"
@@ -46,10 +51,34 @@ open class PyController(
dayEndExclusive, dayEndExclusive,
PACKAGING_PROCESS_NAME, PACKAGING_PROCESS_NAME,
) )
val list = orders.map { jo -> toListItem(jo) }
val ids = orders.mapNotNull { it.id }
val printed = pyJobOrderPrintSubmitService.sumPrintedQtyByJobOrderIds(ids)
val list = orders.map { jo ->
toListItem(jo, printed[jo.id!!])
}
return ResponseEntity.ok(list) return ResponseEntity.ok(list)
} }


/**
* Record a print submit from Bag2 (e.g. 標簽機). No login.
* POST /py/job-order-print-submit
* Body: { "jobOrderId": 1, "qty": 10, "printChannel": "LABEL" | "DATAFLEX" | "LASER" }
*/
@PostMapping("/job-order-print-submit")
open fun submitJobOrderPrint(
@RequestBody body: PyJobOrderPrintSubmitRequest,
): ResponseEntity<PyJobOrderPrintSubmitResponse> {
val channel = body.printChannel?.trim()?.takeIf { it.isNotEmpty() } ?: PyPrintChannel.LABEL
if (
channel != PyPrintChannel.LABEL &&
channel != PyPrintChannel.DATAFLEX &&
channel != PyPrintChannel.LASER
) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported printChannel: $channel")
}
return ResponseEntity.ok(pyJobOrderPrintSubmitService.recordPrint(body.jobOrderId, body.qty, channel))
}

/** /**
* Same as [listJobOrders] but filtered by system setting [com.ffii.fpsms.modules.common.SettingNames.LASER_PRINT_ITEM_CODES] * Same as [listJobOrders] but filtered by system setting [com.ffii.fpsms.modules.common.SettingNames.LASER_PRINT_ITEM_CODES]
* (comma-separated item codes). Public — no login (same as /py/job-orders). * (comma-separated item codes). Public — no login (same as /py/job-orders).
@@ -62,13 +91,14 @@ open class PyController(
return ResponseEntity.ok(plasticBagPrinterService.listLaserPrintJobOrders(date)) return ResponseEntity.ok(plasticBagPrinterService.listLaserPrintJobOrders(date))
} }


private fun toListItem(jo: JobOrder): PyJobOrderListItem {
private fun toListItem(jo: JobOrder, printed: PrintedQtyByChannel?): PyJobOrderListItem {
val itemCode = jo.bom?.item?.code ?: jo.bom?.code val itemCode = jo.bom?.item?.code ?: jo.bom?.code
val itemName = jo.bom?.name ?: jo.bom?.item?.name val itemName = jo.bom?.name ?: jo.bom?.item?.name
val itemId = jo.bom?.item?.id val itemId = jo.bom?.item?.id
val stockInLine = jo.id?.let { stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(it) } val stockInLine = jo.id?.let { stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(it) }
val stockInLineId = stockInLine?.id val stockInLineId = stockInLine?.id
val lotNo = stockInLine?.lotNo val lotNo = stockInLine?.lotNo
val p = printed ?: PrintedQtyByChannel()
return PyJobOrderListItem( return PyJobOrderListItem(
id = jo.id!!, id = jo.id!!,
code = jo.code, code = jo.code,
@@ -79,6 +109,9 @@ open class PyController(
stockInLineId = stockInLineId, stockInLineId = stockInLineId,
itemId = itemId, itemId = itemId,
lotNo = lotNo, lotNo = lotNo,
bagPrintedQty = p.bagPrintedQty,
labelPrintedQty = p.labelPrintedQty,
laserPrintedQty = p.laserPrintedQty,
) )
} }
} }

+ 6
- 0
src/main/java/com/ffii/fpsms/py/PyJobOrderListItem.kt Переглянути файл

@@ -19,4 +19,10 @@ data class PyJobOrderListItem(
val stockInLineId: Long?, val stockInLineId: Long?,
val itemId: Long?, val itemId: Long?,
val lotNo: String?, val lotNo: String?,
/** Cumulative qty from 打袋機 DataFlex submits (DATAFLEX). */
val bagPrintedQty: Long = 0,
/** Cumulative qty from 標簽機 submits (LABEL). */
val labelPrintedQty: Long = 0,
/** Cumulative qty from 激光機 submits (LASER). */
val laserPrintedQty: Long = 0,
) )

+ 35
- 0
src/main/java/com/ffii/fpsms/py/PyJobOrderPrintSubmitRepository.kt Переглянути файл

@@ -0,0 +1,35 @@
package com.ffii.fpsms.py

import com.ffii.core.support.AbstractRepository
import com.ffii.fpsms.py.entity.PyJobOrderPrintSubmit
import org.springframework.data.jpa.repository.Query
import org.springframework.data.repository.query.Param

interface PyJobOrderPrintSubmitRepository : AbstractRepository<PyJobOrderPrintSubmit, Long> {

@Query(
value =
"SELECT job_order_id, COALESCE(SUM(qty), 0) FROM py_job_order_print_submit " +
"WHERE deleted = 0 AND job_order_id IN (:ids) AND print_channel = :channel " +
"GROUP BY job_order_id",
nativeQuery = true,
)
fun sumQtyGroupedByJobOrderId(
@Param("ids") ids: List<Long>,
@Param("channel") channel: String,
): List<Array<Any>>

/**
* One row per (job_order_id, print_channel) with summed qty.
*/
@Query(
value =
"SELECT job_order_id, print_channel, COALESCE(SUM(qty), 0) FROM py_job_order_print_submit " +
"WHERE deleted = 0 AND job_order_id IN (:ids) " +
"GROUP BY job_order_id, print_channel",
nativeQuery = true,
)
fun sumQtyGroupedByJobOrderIdAndChannel(
@Param("ids") ids: List<Long>,
): List<Array<Any>>
}

+ 11
- 0
src/main/java/com/ffii/fpsms/py/PyJobOrderPrintSubmitRequest.kt Переглянути файл

@@ -0,0 +1,11 @@
package com.ffii.fpsms.py

/**
* POST /py/job-order-print-submit
*/
data class PyJobOrderPrintSubmitRequest(
val jobOrderId: Long,
val qty: Int,
/** [PyPrintChannel.LABEL] | [PyPrintChannel.DATAFLEX] | [PyPrintChannel.LASER]; omit or blank → LABEL. */
val printChannel: String? = null,
)

+ 9
- 0
src/main/java/com/ffii/fpsms/py/PyJobOrderPrintSubmitResponse.kt Переглянути файл

@@ -0,0 +1,9 @@
package com.ffii.fpsms.py

data class PyJobOrderPrintSubmitResponse(
val jobOrderId: Long,
val submittedQty: Int,
val printChannel: String,
/** Cumulative printed qty for this job order and [printChannel] after this submit. */
val cumulativeQtyForChannel: Long,
)

+ 72
- 0
src/main/java/com/ffii/fpsms/py/PyJobOrderPrintSubmitService.kt Переглянути файл

@@ -0,0 +1,72 @@
package com.ffii.fpsms.py

import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository
import com.ffii.fpsms.py.entity.PyJobOrderPrintSubmit
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.server.ResponseStatusException

@Service
open class PyJobOrderPrintSubmitService(
private val repository: PyJobOrderPrintSubmitRepository,
private val jobOrderRepository: JobOrderRepository,
) {

/**
* Cumulative printed qty per job order, split by channel (打袋 / 標籤 / 激光).
*/
open fun sumPrintedQtyByJobOrderIds(ids: List<Long>): Map<Long, PrintedQtyByChannel> {
if (ids.isEmpty()) return emptyMap()
val rows = repository.sumQtyGroupedByJobOrderIdAndChannel(ids)
val out = mutableMapOf<Long, PrintedQtyByChannel>()
for (row in rows) {
val jobOrderId = (row[0] as Number).toLong()
val channel = (row[1] as String).trim()
val qty = (row[2] as Number).toLong()
val cur = out.getOrDefault(jobOrderId, PrintedQtyByChannel())
out[jobOrderId] =
when (channel) {
PyPrintChannel.DATAFLEX -> cur.copy(bagPrintedQty = qty)
PyPrintChannel.LABEL -> cur.copy(labelPrintedQty = qty)
PyPrintChannel.LASER -> cur.copy(laserPrintedQty = qty)
else -> cur
}
}
return out
}

private fun sumPrintedByJobOrderIdsAndChannel(ids: List<Long>, channel: String): Map<Long, Long> {
if (ids.isEmpty()) return emptyMap()
val rows = repository.sumQtyGroupedByJobOrderId(ids, channel)
return rows.associate { row ->
(row[0] as Number).toLong() to (row[1] as Number).toLong()
}
}

/**
* Persist one submit row and return cumulative print total for that job order and channel.
*/
@Transactional
open fun recordPrint(jobOrderId: Long, qty: Int, printChannel: String): PyJobOrderPrintSubmitResponse {
if (qty < 1) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, "qty must be at least 1")
}
val jo = jobOrderRepository.findById(jobOrderId).orElseThrow {
ResponseStatusException(HttpStatus.NOT_FOUND, "Job order not found: $jobOrderId")
}
val row = PyJobOrderPrintSubmit().apply {
jobOrder = jo
this.qty = qty
this.printChannel = printChannel
}
repository.save(row)
val total = sumPrintedByJobOrderIdsAndChannel(listOf(jobOrderId), printChannel)[jobOrderId] ?: qty.toLong()
return PyJobOrderPrintSubmitResponse(
jobOrderId = jobOrderId,
submittedQty = qty,
printChannel = printChannel,
cumulativeQtyForChannel = total,
)
}
}

+ 8
- 0
src/main/java/com/ffii/fpsms/py/PyPrintChannel.kt Переглянути файл

@@ -0,0 +1,8 @@
package com.ffii.fpsms.py

/** Values for [com.ffii.fpsms.py.entity.PyJobOrderPrintSubmit.printChannel]. */
object PyPrintChannel {
const val LABEL = "LABEL"
const val DATAFLEX = "DATAFLEX"
const val LASER = "LASER"
}

+ 35
- 0
src/main/java/com/ffii/fpsms/py/entity/PyJobOrderPrintSubmit.kt Переглянути файл

@@ -0,0 +1,35 @@
package com.ffii.fpsms.py.entity

import com.ffii.core.entity.BaseEntity
import com.ffii.fpsms.modules.jobOrder.entity.JobOrder
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.FetchType
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Size

/**
* One row each time a Bag/py client submits a print quantity for a job order (per printer channel).
* [printChannel] distinguishes 打袋 (DATAFLEX), 標籤 (LABEL), 激光 (LASER); cumulative [qty] per channel.
*/
@Entity
@Table(name = "py_job_order_print_submit")
open class PyJobOrderPrintSubmit : BaseEntity<Long>() {

@NotNull
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "job_order_id", nullable = false, columnDefinition = "INT")
open var jobOrder: JobOrder? = null

@NotNull
@Column(name = "qty", nullable = false)
open var qty: Int? = null

@Size(max = 32)
@NotNull
@Column(name = "print_channel", nullable = false, length = 32)
open var printChannel: String? = null
}

+ 174
- 160
src/main/resources/DeliveryNote/DeliveryNoteCartonLabelsPDF.jrxml Переглянути файл

@@ -1,166 +1,180 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!-- Created with Jaspersoft Studio version 6.21.3.final using JasperReports Library version 6.21.3-4a3078d20785ebe464f18037d738d12fc98c13cf --> <!-- Created with Jaspersoft Studio version 6.21.3.final using JasperReports Library version 6.21.3-4a3078d20785ebe464f18037d738d12fc98c13cf -->
<jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://jasperreports.sourceforge.net/jasperreports http://jasperreports.sourceforge.net/xsd/jasperreport.xsd" name="Blank_A4" pageWidth="425" pageHeight="283" columnWidth="385" leftMargin="20" rightMargin="20" topMargin="20" bottomMargin="20" uuid="baa9f270-b398-4f1c-b01e-ba216b7997e9"> <jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://jasperreports.sourceforge.net/jasperreports http://jasperreports.sourceforge.net/xsd/jasperreport.xsd" name="Blank_A4" pageWidth="425" pageHeight="283" columnWidth="385" leftMargin="20" rightMargin="20" topMargin="20" bottomMargin="20" uuid="baa9f270-b398-4f1c-b01e-ba216b7997e9">
<property name="com.jaspersoft.studio.unit." value="pixel"/>
<property name="com.jaspersoft.studio.unit.pageHeight" value="pixel"/>
<property name="com.jaspersoft.studio.unit.pageWidth" value="pixel"/>
<property name="com.jaspersoft.studio.unit.topMargin" value="pixel"/>
<property name="com.jaspersoft.studio.unit.bottomMargin" value="pixel"/>
<property name="com.jaspersoft.studio.unit.leftMargin" value="pixel"/>
<property name="com.jaspersoft.studio.unit.rightMargin" value="pixel"/>
<property name="com.jaspersoft.studio.unit.columnWidth" value="pixel"/>
<property name="com.jaspersoft.studio.unit.columnSpacing" value="pixel"/>
<parameter name="shopPurchaseOrderNo" class="java.lang.String"/>
<parameter name="shopName" class="java.lang.String"/>
<parameter name="shopAddress" class="java.lang.String"/>
<parameter name="deliveryNoteCode" class="java.lang.String"/>
<parameter name="truckNo" class="java.lang.String"/>
<queryString>
<![CDATA[]]>
</queryString>
<background>
<band splitType="Stretch"/>
</background>
<detail>
<band height="243" splitType="Stretch">
<property name="com.jaspersoft.studio.unit.height" value="px"/>
<textField>
<reportElement x="140" y="180" width="240" height="30" uuid="8fac39f8-4936-43a5-8e1f-1afbc8ccca9c">
<property name="com.jaspersoft.studio.unit.height" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/>
</reportElement>
<textElement verticalAlignment="Middle">
<font fontName="微軟正黑體" size="16"/>
</textElement>
<textFieldExpression><![CDATA[$P{shopPurchaseOrderNo}]]></textFieldExpression>
</textField>
<staticText>
<reportElement x="0" y="180" width="140" height="30" uuid="e03fcb92-259c-4427-a68e-60fe5924d763">
<property name="com.jaspersoft.studio.unit.width" value="px"/>
<property name="com.jaspersoft.studio.unit.height" value="px"/>
</reportElement>
<textElement verticalAlignment="Middle">
<font fontName="微軟正黑體" size="16"/>
</textElement>
<text><![CDATA[店鋪採購單編號:
<property name="com.jaspersoft.studio.unit." value="pixel"/>
<property name="com.jaspersoft.studio.unit.pageHeight" value="pixel"/>
<property name="com.jaspersoft.studio.unit.pageWidth" value="pixel"/>
<property name="com.jaspersoft.studio.unit.topMargin" value="pixel"/>
<property name="com.jaspersoft.studio.unit.bottomMargin" value="pixel"/>
<property name="com.jaspersoft.studio.unit.leftMargin" value="pixel"/>
<property name="com.jaspersoft.studio.unit.rightMargin" value="pixel"/>
<property name="com.jaspersoft.studio.unit.columnWidth" value="pixel"/>
<property name="com.jaspersoft.studio.unit.columnSpacing" value="pixel"/>
<parameter name="shopPurchaseOrderNo" class="java.lang.String"/>
<parameter name="shopName" class="java.lang.String"/>
<parameter name="shopAddress" class="java.lang.String"/>
<parameter name="deliveryNoteCode" class="java.lang.String"/>
<parameter name="truckNo" class="java.lang.String"/>
<parameter name="shopCode" class="java.lang.String"/>
<parameter name="shopCodeAbbr" class="java.lang.String"/>
<queryString>
<![CDATA[]]>
</queryString>
<background>
<band splitType="Stretch"/>
</background>
<detail>
<band height="243" splitType="Stretch">
<property name="com.jaspersoft.studio.unit.height" value="px"/>
<textField>
<reportElement x="142" y="210" width="240" height="30" uuid="8fac39f8-4936-43a5-8e1f-1afbc8ccca9c">
<property name="com.jaspersoft.studio.unit.height" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/>
</reportElement>
<textElement verticalAlignment="Middle">
<font fontName="微軟正黑體" size="16"/>
</textElement>
<textFieldExpression><![CDATA[$P{shopPurchaseOrderNo}]]></textFieldExpression>
</textField>
<staticText>
<reportElement x="2" y="210" width="140" height="30" uuid="e03fcb92-259c-4427-a68e-60fe5924d763">
<property name="com.jaspersoft.studio.unit.width" value="px"/>
<property name="com.jaspersoft.studio.unit.height" value="px"/>
</reportElement>
<textElement verticalAlignment="Middle">
<font fontName="微軟正黑體" size="16"/>
</textElement>
<text><![CDATA[店鋪採購單編號:
]]></text> ]]></text>
</staticText>
<staticText>
<reportElement x="0" y="213" width="140" height="30" uuid="c8e417ed-73ce-4349-b83c-e59e25258544">
<property name="com.jaspersoft.studio.unit.width" value="px"/>
<property name="com.jaspersoft.studio.unit.height" value="px"/>
</reportElement>
<textElement verticalAlignment="Middle">
<font fontName="微軟正黑體" size="16"/>
</textElement>
<text><![CDATA[箱數:]]></text>
</staticText>
<staticText>
<reportElement x="0" y="150" width="140" height="30" uuid="f3ffd4ee-0513-41a5-94d7-f1fdb9966a76">
<property name="com.jaspersoft.studio.unit.width" value="px"/>
<property name="com.jaspersoft.studio.unit.height" value="px"/>
</reportElement>
<textElement verticalAlignment="Middle">
<font fontName="微軟正黑體" size="16"/>
</textElement>
<text><![CDATA[送貨單編號:]]></text>
</staticText>
<textField>
<reportElement x="140" y="150" width="240" height="30" uuid="4319059b-9096-4c49-8275-287be93d3e6a">
<property name="com.jaspersoft.studio.unit.height" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/>
</reportElement>
<textElement verticalAlignment="Middle">
<font fontName="微軟正黑體" size="16"/>
</textElement>
<textFieldExpression><![CDATA[$P{deliveryNoteCode}]]></textFieldExpression>
</textField>
<textField textAdjust="ScaleFont">
<reportElement x="0" y="0" width="380" height="40" uuid="9a440925-1bd4-4001-9b4b-7163ac27551e">
<property name="com.jaspersoft.studio.unit.width" value="px"/>
<property name="com.jaspersoft.studio.unit.height" value="px"/>
</reportElement>
<textElement verticalAlignment="Middle">
<font fontName="微軟正黑體" size="18" isBold="true" isUnderline="false"/>
</textElement>
<textFieldExpression><![CDATA[$P{shopName}]]></textFieldExpression>
</textField>
<textField textAdjust="ScaleFont">
<reportElement x="0" y="40" width="380" height="70" uuid="26b2c156-341b-4f59-abce-bd84ea000d9d">
<property name="com.jaspersoft.studio.unit.width" value="px"/>
<property name="com.jaspersoft.studio.unit.height" value="px"/>
</reportElement>
<textElement verticalAlignment="Top">
<font fontName="微軟正黑體" size="16" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$P{shopAddress}]]></textFieldExpression>
</textField>
<staticText>
<reportElement x="255" y="213" width="35" height="30" uuid="7467bc85-22c2-4b2a-bed0-a2e82a5dba6d">
<property name="com.jaspersoft.studio.unit.width" value="px"/>
<property name="com.jaspersoft.studio.unit.height" value="px"/>
</reportElement>
<textElement textAlignment="Center" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="16"/>
</textElement>
<text><![CDATA[箱]]></text>
</staticText>
<textField>
<reportElement x="140" y="213" width="40" height="30" uuid="e340a673-9fdc-4559-8431-af8ea391c472">
<property name="com.jaspersoft.studio.unit.width" value="px"/>
<property name="com.jaspersoft.studio.unit.height" value="px"/>
</reportElement>
<textElement textAlignment="Center" verticalAlignment="Middle">
<font size="16"/>
</textElement>
<textFieldExpression><![CDATA[$V{PAGE_NUMBER}]]></textFieldExpression>
</textField>
<textField evaluationTime="Report">
<reportElement x="215" y="213" width="40" height="30" uuid="89ccad73-0571-4291-ae26-7804925d47eb">
<property name="com.jaspersoft.studio.unit.width" value="px"/>
<property name="com.jaspersoft.studio.unit.height" value="px"/>
</reportElement>
<textElement textAlignment="Center" verticalAlignment="Middle">
<font size="16"/>
</textElement>
<textFieldExpression><![CDATA[$V{PAGE_NUMBER}]]></textFieldExpression>
</textField>
<staticText>
<reportElement x="180" y="213" width="35" height="30" uuid="1cdd7507-299b-406b-baab-d2c23e44eeb0">
<property name="com.jaspersoft.studio.unit.width" value="px"/>
<property name="com.jaspersoft.studio.unit.height" value="px"/>
</reportElement>
<textElement textAlignment="Center" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="16"/>
</textElement>
<text><![CDATA[/]]></text>
</staticText>
<staticText>
<reportElement x="0" y="120" width="140" height="30" uuid="c8b9fafb-9e8b-479f-9a9f-dadda7854f95">
<property name="com.jaspersoft.studio.unit.width" value="px"/>
<property name="com.jaspersoft.studio.unit.height" value="px"/>
</reportElement>
<textElement verticalAlignment="Middle">
<font fontName="微軟正黑體" size="16"/>
</textElement>
<text><![CDATA[貨車班次:
</staticText>
<staticText>
<reportElement x="2" y="180" width="140" height="30" uuid="f3ffd4ee-0513-41a5-94d7-f1fdb9966a76">
<property name="com.jaspersoft.studio.unit.width" value="px"/>
<property name="com.jaspersoft.studio.unit.height" value="px"/>
</reportElement>
<textElement verticalAlignment="Middle">
<font fontName="微軟正黑體" size="16"/>
</textElement>
<text><![CDATA[送貨單編號:]]></text>
</staticText>
<textField>
<reportElement x="142" y="180" width="240" height="30" uuid="4319059b-9096-4c49-8275-287be93d3e6a">
<property name="com.jaspersoft.studio.unit.height" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/>
</reportElement>
<textElement verticalAlignment="Middle">
<font fontName="微軟正黑體" size="16"/>
</textElement>
<textFieldExpression><![CDATA[$P{deliveryNoteCode}]]></textFieldExpression>
</textField>
<textField textAdjust="ScaleFont">
<reportElement x="0" y="0" width="220" height="50" uuid="9a440925-1bd4-4001-9b4b-7163ac27551e">
<property name="com.jaspersoft.studio.unit.height" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/>
</reportElement>
<textElement textAlignment="Left" verticalAlignment="Top">
<font fontName="微軟正黑體" size="38" isBold="true" isUnderline="false"/>
</textElement>
<textFieldExpression><![CDATA[$P{shopCode}]]></textFieldExpression>
</textField>
<staticText>
<reportElement x="2" y="150" width="140" height="30" uuid="c8b9fafb-9e8b-479f-9a9f-dadda7854f95">
<property name="com.jaspersoft.studio.unit.width" value="px"/>
<property name="com.jaspersoft.studio.unit.height" value="px"/>
</reportElement>
<textElement verticalAlignment="Middle">
<font fontName="微軟正黑體" size="16"/>
</textElement>
<text><![CDATA[貨車班次:
]]></text> ]]></text>
</staticText>
<textField>
<reportElement x="140" y="120" width="240" height="30" uuid="57f8e4fa-cea0-42c5-b9e5-a33f0a2710b8">
<property name="com.jaspersoft.studio.unit.height" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/>
</reportElement>
<textElement verticalAlignment="Middle">
<font fontName="微軟正黑體" size="16" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$P{truckNo}]]></textFieldExpression>
</textField>
<line>
<reportElement x="0" y="110" width="380" height="1" uuid="3e37c027-d6e9-4a88-b64d-58ba1dd3b22e">
<property name="com.jaspersoft.studio.unit.height" value="px"/>
</reportElement>
</line>
</band>
</detail>
</staticText>
<textField>
<reportElement x="142" y="150" width="240" height="30" uuid="57f8e4fa-cea0-42c5-b9e5-a33f0a2710b8">
<property name="com.jaspersoft.studio.unit.height" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/>
</reportElement>
<textElement verticalAlignment="Middle">
<font fontName="微軟正黑體" size="16" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$P{truckNo}]]></textFieldExpression>
</textField>
<line>
<reportElement x="2" y="140" width="380" height="1" uuid="3e37c027-d6e9-4a88-b64d-58ba1dd3b22e">
<property name="com.jaspersoft.studio.unit.height" value="px"/>
</reportElement>
</line>
<textField textAdjust="ScaleFont">
<reportElement x="230" y="0" width="152" height="99" uuid="ed6f8ce7-e351-4eeb-9f95-49d64e7ed2dd">
<property name="com.jaspersoft.studio.unit.height" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/>
</reportElement>
<box>
<pen lineWidth="4.0"/>
</box>
<textElement textAlignment="Center" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="100" isBold="true" isUnderline="false"/>
</textElement>
<textFieldExpression><![CDATA[$P{shopCodeAbbr}]]></textFieldExpression>
</textField>
<textField textAdjust="ScaleFont">
<reportElement x="0" y="50" width="220" height="49" uuid="75a47bc6-5830-4636-9c62-1285163bf0b6">
<property name="com.jaspersoft.studio.unit.height" value="px"/>
</reportElement>
<textElement textAlignment="Left" verticalAlignment="Top">
<font fontName="微軟正黑體" size="22" isBold="true" isUnderline="false"/>
</textElement>
<textFieldExpression><![CDATA[$P{shopName}]]></textFieldExpression>
</textField>
<staticText>
<reportElement x="2" y="106" width="140" height="30" uuid="0ccaeebc-681b-449e-b547-97fc86c35662">
<property name="com.jaspersoft.studio.unit.width" value="px"/>
<property name="com.jaspersoft.studio.unit.height" value="px"/>
</reportElement>
<textElement verticalAlignment="Middle">
<font fontName="微軟正黑體" size="16"/>
</textElement>
<text><![CDATA[箱數:]]></text>
</staticText>
<staticText>
<reportElement x="262" y="106" width="120" height="30" uuid="05bc180b-a58d-4ad8-95f6-bc3090ee2c2d">
<property name="com.jaspersoft.studio.unit.width" value="px"/>
<property name="com.jaspersoft.studio.unit.height" value="px"/>
</reportElement>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="16"/>
</textElement>
<text><![CDATA[ 箱(蛋類除外)]]></text>
</staticText>
<textField>
<reportElement x="142" y="106" width="40" height="30" uuid="dab335a5-e253-498c-a9bf-b9707d8e1099">
<property name="com.jaspersoft.studio.unit.width" value="px"/>
<property name="com.jaspersoft.studio.unit.height" value="px"/>
</reportElement>
<textElement textAlignment="Center" verticalAlignment="Middle">
<font size="16"/>
</textElement>
<textFieldExpression><![CDATA[$V{PAGE_NUMBER}]]></textFieldExpression>
</textField>
<textField evaluationTime="Report">
<reportElement x="217" y="106" width="40" height="30" uuid="66d50bad-7b39-49c1-b127-4d763133ee0c">
<property name="com.jaspersoft.studio.unit.width" value="px"/>
<property name="com.jaspersoft.studio.unit.height" value="px"/>
</reportElement>
<textElement textAlignment="Center" verticalAlignment="Middle">
<font size="16"/>
</textElement>
<textFieldExpression><![CDATA[$V{PAGE_NUMBER}]]></textFieldExpression>
</textField>
<staticText>
<reportElement x="182" y="106" width="35" height="30" uuid="73c18ae5-a07b-4215-a753-fa72d6db87eb">
<property name="com.jaspersoft.studio.unit.width" value="px"/>
<property name="com.jaspersoft.studio.unit.height" value="px"/>
</reportElement>
<textElement textAlignment="Center" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="16"/>
</textElement>
<text><![CDATA[/]]></text>
</staticText>
</band>
</detail>
</jasperReport> </jasperReport>

+ 14
- 0
src/main/resources/application.yml Переглянути файл

@@ -50,6 +50,20 @@ jwt:
logging: logging:
config: 'classpath:log4j2.yml' config: 'classpath:log4j2.yml'


# Optional NGPCL gateway: receives the same bytes as /plastic/download-onpack-qr-text (Content-Type: application/zip).
# Leave empty to disable; set NGPCL_PUSH_URL in production if you expose an HTTP receiver for the lemon OnPack ZIP.
ngpcl:
push-url: ${NGPCL_PUSH_URL:}

# Laser Bag2 (/laserPrint) auto-send: same as listing + TCP send using DB LASER_PRINT.host / port / itemCodes.
# Scheduler is off by default. limit-per-run: max job orders per tick (1 = first matching only); 0 = all matches (heavy).
laser:
bag2:
auto-send:
enabled: false
interval-ms: 60000
limit-per-run: 1

bom: bom:
import: import:
temp-dir: ${java.io.tmpdir}/fpsms-bom-import temp-dir: ${java.io.tmpdir}/fpsms-bom-import


+ 22
- 0
src/main/resources/db/changelog/changes/20260326_fai/01_create_py_job_order_print_submit.sql Переглянути файл

@@ -0,0 +1,22 @@
--liquibase formatted sql
--changeset fai:20260326_py_job_order_print_submit

CREATE TABLE IF NOT EXISTS `py_job_order_print_submit` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`createdBy` VARCHAR(30) NULL DEFAULT NULL,
`version` INT NOT NULL DEFAULT '0',
`modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`modifiedBy` VARCHAR(30) NULL DEFAULT NULL,
`deleted` TINYINT(1) NOT NULL DEFAULT '0',

`job_order_id` INT NOT NULL COMMENT 'FK job_order (must match job_order.id type)',
`qty` INT NOT NULL COMMENT 'Quantity printed this submit (labels, bags, etc.)',
`print_channel` VARCHAR(32) NOT NULL DEFAULT 'LABEL' COMMENT 'LABEL=標簽機, DATAFLEX=打袋機, …',

CONSTRAINT `pk_py_job_order_print_submit` PRIMARY KEY (`id`),
KEY `idx_py_jops_job_order` (`job_order_id`),
KEY `idx_py_jops_channel_created` (`print_channel`, `created`),
CONSTRAINT `fk_py_jops_job_order` FOREIGN KEY (`job_order_id`) REFERENCES `job_order` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Per-submit print qty for Bag2/py clients; cumulative per job order for wastage/stock.';

+ 8
- 0
src/main/resources/db/changelog/changes/20260327_laser_last_receive_success/01_laser_print_last_receive_success.sql Переглянути файл

@@ -0,0 +1,8 @@
--liquibase formatted sql
--changeset fpsms:20260327_laser_print_last_receive_success

INSERT INTO `settings` (`name`, `value`, `category`, `type`)
SELECT 'LASER_PRINT.lastReceiveSuccess', '{}', 'LASER', 'string'
WHERE NOT EXISTS (
SELECT 1 FROM `settings` s WHERE s.name = 'LASER_PRINT.lastReceiveSuccess'
);

+ 1
- 2
src/main/resources/fonts/fonts.xml Переглянути файл

@@ -24,5 +24,4 @@
<export key="net.sf.jasperreports.xhtml">'華文宋體', Arial, Helvetica, sans-serif</export> <export key="net.sf.jasperreports.xhtml">'華文宋體', Arial, Helvetica, sans-serif</export>
</exportFonts> </exportFonts>
</fontFamily> </fontFamily>
</fontFamilies>

</fontFamilies>

Завантаження…
Відмінити
Зберегти