| @@ -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" | |||||
| } | |||||
| @@ -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 | ||||
| @@ -12,7 +12,9 @@ import com.ffii.fpsms.modules.jobOrder.web.model.LaserRequest | |||||
| 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.stereotype.Service | import org.springframework.stereotype.Service | ||||
| import java.awt.Color | import java.awt.Color | ||||
| import java.awt.Font | import java.awt.Font | ||||
| @@ -50,6 +52,7 @@ class PlasticBagPrinterService( | |||||
| 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 logger = LoggerFactory.getLogger(javaClass) | private val logger = LoggerFactory.getLogger(javaClass) | ||||
| @@ -108,7 +111,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 +126,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 +144,9 @@ class PlasticBagPrinterService( | |||||
| stockInLineId = stockInLineId, | stockInLineId = stockInLineId, | ||||
| itemId = itemId, | itemId = itemId, | ||||
| lotNo = lotNo, | lotNo = lotNo, | ||||
| bagPrintedQty = p.bagPrintedQty, | |||||
| labelPrintedQty = p.labelPrintedQty, | |||||
| laserPrintedQty = p.laserPrintedQty, | |||||
| ) | ) | ||||
| } | } | ||||
| @@ -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, | |||||
| ) | |||||
| @@ -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, | |||||
| ) | ) | ||||
| } | } | ||||
| } | } | ||||
| @@ -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, | |||||
| ) | ) | ||||
| @@ -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>> | |||||
| } | |||||
| @@ -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, | |||||
| ) | |||||
| @@ -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, | |||||
| ) | |||||
| @@ -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, | |||||
| ) | |||||
| } | |||||
| } | |||||
| @@ -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" | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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.'; | |||||