Kaynağa Gözat

Merge remote-tracking branch 'origin/master'

master
CANCERYS\kw093 2 gün önce
ebeveyn
işleme
47a1bcffe3
40 değiştirilmiş dosya ile 3216 ekleme ve 627 silme
  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 Dosyayı Görüntüle

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

+ 394
- 405
python/Bag2.py
Dosya farkı çok büyük olduğundan ihmal edildi
Dosyayı Görüntüle


+ 1718
- 0
python/Bag3.py
Dosya farkı çok büyük olduğundan ihmal edildi
Dosyayı Görüntüle


BIN
python/__pycache__/Bag2.cpython-313.pyc Dosyayı Görüntüle


+ 9
- 0
python/bag3_settings.json Dosyayı Görüntüle

@@ -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 Dosyayı Görüntüle

@@ -2,6 +2,7 @@ py -m pip install pyinstaller
py -m pip install --upgrade pyinstaller
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 --upgrade pyinstaller


+ 14
- 1
src/main/java/com/ffii/fpsms/m18/M18GrnRules.kt Dosyayı Görüntüle

@@ -3,7 +3,20 @@ 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 {
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 Dosyayı Görüntüle

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

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

/** 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,
* 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 Dosyayı Görüntüle

@@ -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 {
logger.info("--------------------------------------------Start - Saving M18 Products / Materials--------------------------------------------")
ensureCunitSeededForAllIfEmpty()


+ 5
- 0
src/main/java/com/ffii/fpsms/m18/web/M18TestController.kt Dosyayı Görüntüle

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

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


+ 3
- 0
src/main/java/com/ffii/fpsms/modules/common/SettingNames.java Dosyayı Görüntüle

@@ -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) */
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 Dosyayı Görüntüle

@@ -1360,7 +1360,11 @@ open class DeliveryOrderService(
}
params["deliveryNoteCode"] = doPickOrderRecord.deliveryNoteCode ?: ""
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 ?: ""

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


+ 42
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/scheduler/LaserBag2AutoSendScheduler.kt Dosyayı Görüntüle

@@ -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 Dosyayı Görüntüle

@@ -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 Dosyayı Görüntüle

@@ -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.LaserBag2SendResponse
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.LaserRequest
import com.ffii.fpsms.modules.jobOrder.web.model.NgpclPushResponse
import com.ffii.fpsms.modules.jobOrder.web.model.OnPackQrJobOrderRequest
import com.ffii.fpsms.modules.settings.service.SettingsService
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.PyJobOrderPrintSubmitService
import org.springframework.core.env.Environment
import org.springframework.stereotype.Service
import java.awt.Color
import java.awt.Font
@@ -26,6 +31,10 @@ import javax.imageio.ImageIO
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
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.InetSocketAddress
import java.io.PrintWriter
@@ -39,17 +48,32 @@ import java.net.ConnectException
import java.net.SocketTimeoutException
import org.springframework.core.io.ClassPathResource
import org.slf4j.LoggerFactory
import com.fasterxml.jackson.databind.ObjectMapper
import java.time.Duration
import java.time.Instant
import java.time.LocalDate

// Data class to store bitmap bytes + width (for XML)
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
class PlasticBagPrinterService(
val jobOrderRepository: JobOrderRepository,
private val jdbcDao: JdbcDao,
private val stockInLineRepository: StockInLineRepository,
private val settingsService: SettingsService,
private val pyJobOrderPrintSubmitService: PyJobOrderPrintSubmitService,
private val environment: Environment,
private val objectMapper: ObjectMapper,
) {
private val logger = LoggerFactory.getLogger(javaClass)

@@ -75,7 +99,43 @@ class PlasticBagPrinterService(
val itemCodes = settingsService.findByName(SettingNames.LASER_PRINT_ITEM_CODES)
.map { it.value?.trim() ?: "" }
.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)
}
}
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> {
@@ -119,13 +183,14 @@ class PlasticBagPrinterService(
.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 itemName = jo.bom?.name ?: jo.bom?.item?.name
val itemId = jo.bom?.item?.id
val stockInLine = jo.id?.let { stockInLineRepository.findFirstByJobOrder_IdAndDeletedFalse(it) }
val stockInLineId = stockInLine?.id
val lotNo = stockInLine?.lotNo
val p = printed ?: PrintedQtyByChannel()
return PyJobOrderListItem(
id = jo.id!!,
code = jo.code,
@@ -136,6 +201,9 @@ class PlasticBagPrinterService(
stockInLineId = stockInLineId,
itemId = itemId,
lotNo = lotNo,
bagPrintedQty = p.bagPrintedQty,
labelPrintedQty = p.labelPrintedQty,
laserPrintedQty = p.laserPrintedQty,
)
}

@@ -154,22 +222,39 @@ class PlasticBagPrinterService(
itemCode = request.itemCode,
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 {
@@ -190,6 +275,17 @@ class PlasticBagPrinterService(
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(
ip: String,
port: Int,
@@ -197,7 +293,7 @@ class PlasticBagPrinterService(
stockInLineId: Long?,
itemCode: String?,
itemName: String?,
): Triple<Boolean, String, String> {
): LaserBag2TcpResult {
val codeStr = (itemCode ?: "").trim().replace(";", ",")
val nameStr = (itemName ?: "").trim().replace(";", ",")
val payload = if (itemId != null && stockInLineId != null) {
@@ -214,27 +310,61 @@ class PlasticBagPrinterService(
val out = socket.getOutputStream()
out.write(bytes)
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
try {
val buf = ByteArray(4096)
val n = socket.getInputStream().read(buf)
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) {
// 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) {
return Triple(false, "無法連線至 $ip:$port,請確認激光機已開機且 IP 正確。", payload)
return LaserBag2TcpResult(false, "無法連線至 $ip:$port,請確認激光機已開機且 IP 正確。", payload, null, false)
} catch (e: SocketTimeoutException) {
return Triple(false, "連線逾時 ($ip:$port),請檢查網路與連接埠。", payload)
return LaserBag2TcpResult(false, "連線逾時 ($ip:$port),請檢查網路與連接埠。", payload, null, false)
} catch (e: Exception) {
return Triple(false, "激光機送出失敗:${e.message}", payload)
return LaserBag2TcpResult(false, "激光機送出失敗:${e.message}", payload, null, false)
} finally {
try {
Thread.sleep(100)
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
}
try {
socket?.close()
} catch (_: Exception) {
@@ -526,6 +656,59 @@ class PlasticBagPrinterService(
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`).
* Empty or NULL `template_type` is treated as `bmp` for backward compatibility.
@@ -759,7 +942,17 @@ class PlasticBagPrinterService(
): Pair<Boolean, String> {
return when (printerType.lowercase()) {
"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" -> {
val comPort = labelCom?.trim().orEmpty()
if (comPort.isBlank()) {
@@ -781,14 +974,14 @@ class PlasticBagPrinterService(
return try {
Socket().use { socket ->
socket.connect(InetSocketAddress(ip, port), 3000)
true to "$printerName connected"
true to "$printerName 已連線($ip:$port)"
}
} catch (e: SocketTimeoutException) {
false to "$printerName connection timed out"
false to "$printerName 連線逾時($ip:$port)"
} catch (e: ConnectException) {
false to "$printerName connection refused"
false to "$printerName 無法連線($ip:$port)"
} 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 Dosyayı Görüntüle

@@ -1,12 +1,15 @@
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.web.model.PrintRequest
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.LaserBag2AutoSendReport
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.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.PrinterStatusRequest
import com.ffii.fpsms.modules.jobOrder.web.model.PrinterStatusResponse
@@ -24,6 +27,7 @@ import org.slf4j.LoggerFactory
@RequestMapping("/plastic")
class PlasticBagPrinterController(
private val plasticBagPrinterService: PlasticBagPrinterService,
private val laserBag2AutoSendService: LaserBag2AutoSendService,
) {
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")
fun checkPrinter(@RequestBody request: PrinterStatusRequest): ResponseEntity<PrinterStatusResponse> {
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.
* ONPACK2030


+ 20
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserBag2AutoSendReport.kt Dosyayı Görüntüle

@@ -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 Dosyayı Görüntüle

@@ -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).
* 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(
val itemId: Long? = null,
@@ -11,4 +14,9 @@ data class LaserBag2SendRequest(
val itemName: String? = null,
val printerIp: String? = 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 Dosyayı Görüntüle

@@ -4,4 +4,8 @@ data class LaserBag2SendResponse(
val success: Boolean,
val message: String,
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 Dosyayı Görüntüle

@@ -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 lastReceiveSuccess Last job where the laser printer returned a receive ack (from settings JSON); null if never recorded or empty.
*/
data class LaserBag2SettingsResponse(
val host: String,
val port: Int,
val itemCodes: String,
val lastReceiveSuccess: LaserLastReceiveSuccessDto? = null,
)

+ 22
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/web/model/LaserLastReceiveSuccessDto.kt Dosyayı Görüntüle

@@ -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 Dosyayı Görüntüle

@@ -57,4 +57,10 @@ data class OnPackQrDownloadRequest(
data class OnPackQrJobOrderRequest(
val jobOrderId: Long,
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 Dosyayı Görüntüle

@@ -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.
* Supports comma-separated values for stockCategory (items.type) and itemCode.
*/
@@ -737,7 +737,7 @@ return result
$lastInDateEndSql
ORDER BY it.code, sil.lotNo
""".trimIndent()
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(COALESCE(cur.code, '')) AS currencyCode,
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
LEFT JOIN items it ON sil.itemId = it.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),
"currencyCode" to row["currencyCode"],
"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 Dosyayı Görüntüle

@@ -80,6 +80,26 @@ fun findFirstByJobOrder_IdAndDeletedFalse(jobOrderId: Long): 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("""
SELECT sil FROM StockInLine sil
WHERE sil.receiptDate IS NOT NULL


+ 13
- 13
src/main/java/com/ffii/fpsms/modules/stock/service/SearchCompletedDnService.kt Dosyayı Görüntüle

@@ -15,6 +15,7 @@ import java.time.LocalDateTime
/**
* Search completed stock-in lines (DN) by receipt date and process M18 GRN creation.
* 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
open class SearchCompletedDnService(
@@ -82,7 +83,7 @@ open class SearchCompletedDnService(

/**
* 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 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.
@@ -101,7 +102,7 @@ open class SearchCompletedDnService(
toProcess.forEach { (poId, silList) ->
silList.firstOrNull()?.let { first ->
try {
stockInLineService.processPurchaseOrderForGrn(first)
stockInLineService.processPurchaseOrderForGrn(first, grnReceiptDate = receiptDate)
} catch (e: Exception) {
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
open fun postCompletedDnAndProcessGrnWithMissingRetry(
@@ -172,9 +169,12 @@ open class SearchCompletedDnService(
val byPo = lines.groupBy { it.purchaseOrder?.id ?: 0L }.filterKeys { it != 0L }
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
@@ -190,7 +190,7 @@ open class SearchCompletedDnService(
toProcess.forEach { (poId, silList) ->
silList.firstOrNull()?.let { first ->
try {
stockInLineService.processPurchaseOrderForGrn(first)
stockInLineService.processPurchaseOrderForGrn(first, grnReceiptDate = receiptDate)
} catch (e: Exception) {
logger.error("[postCompletedDnAndProcessMissingGrnForReceiptDate] Failed for PO id=$poId: ${e.message}", e)
}


+ 26
- 6
src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt Dosyayı Görüntüle

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

/**
* 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
* RECEIVED and PENDING/ESCALATED status flows.
*/
private fun tryUpdatePurchaseOrderAndCreateGrnIfCompleted(savedStockInLine: StockInLine) {
private fun tryUpdatePurchaseOrderAndCreateGrnIfCompleted(
savedStockInLine: StockInLine,
grnReceiptDate: LocalDate? = null,
) {
if (savedStockInLine.purchaseOrderLine == null) return
val pol = savedStockInLine.purchaseOrderLine ?: return
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).
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()) {
logger.info("[tryUpdatePurchaseOrderAndCreateGrnIfCompleted] DEBUG: Skipping M18 GRN - no stock-in lines for PO id=${savedPo.id} code=${savedPo.code}")
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) {
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


+ 8
- 0
src/main/java/com/ffii/fpsms/py/PrintedQtyByChannel.kt Dosyayı Görüntüle

@@ -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 Dosyayı Görüntüle

@@ -7,9 +7,13 @@ import com.ffii.fpsms.modules.stock.entity.StockInLineRepository
import org.springframework.format.annotation.DateTimeFormat
import org.springframework.http.ResponseEntity
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.RequestParam
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.LocalDateTime

@@ -23,6 +27,7 @@ open class PyController(
private val jobOrderRepository: JobOrderRepository,
private val stockInLineRepository: StockInLineRepository,
private val plasticBagPrinterService: PlasticBagPrinterService,
private val pyJobOrderPrintSubmitService: PyJobOrderPrintSubmitService,
) {
companion object {
private const val PACKAGING_PROCESS_NAME = "包裝"
@@ -46,10 +51,34 @@ open class PyController(
dayEndExclusive,
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)
}

/**
* 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]
* (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))
}

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

+ 6
- 0
src/main/java/com/ffii/fpsms/py/PyJobOrderListItem.kt Dosyayı Görüntüle

@@ -19,4 +19,10 @@ data class PyJobOrderListItem(
val stockInLineId: Long?,
val itemId: Long?,
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 Dosyayı Görüntüle

@@ -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 Dosyayı Görüntüle

@@ -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 Dosyayı Görüntüle

@@ -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 Dosyayı Görüntüle

@@ -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 Dosyayı Görüntüle

@@ -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 Dosyayı Görüntüle

@@ -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 Dosyayı Görüntüle

@@ -1,166 +1,180 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- 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">
<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>
</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>
</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>

+ 14
- 0
src/main/resources/application.yml Dosyayı Görüntüle

@@ -50,6 +50,20 @@ jwt:
logging:
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:
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 Dosyayı Görüntüle

@@ -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 Dosyayı Görüntüle

@@ -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 Dosyayı Görüntüle

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

</fontFamilies>

Yükleniyor…
İptal
Kaydet