Explorar el Código

QC添加溫度/濕度儲存

production
B.E.N.S.O.N hace 1 día
padre
commit
97a26b1a3b
Se han modificado 16 ficheros con 676 adiciones y 138 borrados
  1. +20
    -3
      src/main/java/com/ffii/fpsms/modules/common/mail/service/MailTemplateService.kt
  2. +11
    -0
      src/main/java/com/ffii/fpsms/modules/qc/entity/QcMeasureType.kt
  3. +46
    -0
      src/main/java/com/ffii/fpsms/modules/qc/entity/QcResultMeasurement.kt
  4. +11
    -0
      src/main/java/com/ffii/fpsms/modules/qc/entity/QcResultMeasurementRepository.kt
  5. +187
    -0
      src/main/java/com/ffii/fpsms/modules/qc/service/QcResultMeasurementService.kt
  6. +44
    -4
      src/main/java/com/ffii/fpsms/modules/qc/service/QcResultService.kt
  7. +3
    -3
      src/main/java/com/ffii/fpsms/modules/qc/web/QcResultController.kt
  8. +24
    -0
      src/main/java/com/ffii/fpsms/modules/qc/web/model/QcResultInfoResponse.kt
  9. +9
    -0
      src/main/java/com/ffii/fpsms/modules/qc/web/model/SaveQcMeasurementRequest.kt
  10. +1
    -0
      src/main/java/com/ffii/fpsms/modules/qc/web/model/SaveQcResultRequest.kt
  11. +102
    -30
      src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt
  12. +19
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt
  13. +135
    -92
      src/main/java/com/ffii/fpsms/modules/report/web/ItemQcFailReportController.kt
  14. +7
    -2
      src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt
  15. +25
    -0
      src/main/resources/db/changelog/changes/20260622_01_qc_result_measurement/01_create_qc_result_measurement.sql
  16. +32
    -4
      src/main/resources/jasper/ItemQCReport.jrxml

+ 20
- 3
src/main/java/com/ffii/fpsms/modules/common/mail/service/MailTemplateService.kt Ver fichero

@@ -5,6 +5,7 @@ import com.ffii.fpsms.modules.common.mail.entity.MailTemplateRepository
import com.ffii.fpsms.modules.common.mail.web.models.DownloadMailTemplateResponse import com.ffii.fpsms.modules.common.mail.web.models.DownloadMailTemplateResponse
import com.ffii.fpsms.modules.common.mail.web.models.MailTemplateRequest import com.ffii.fpsms.modules.common.mail.web.models.MailTemplateRequest
import com.ffii.fpsms.modules.qc.service.QcResultService import com.ffii.fpsms.modules.qc.service.QcResultService
import com.ffii.fpsms.modules.qc.web.model.QcResultInfoResponse
import com.ffii.fpsms.modules.stock.entity.StockInLineRepository import com.ffii.fpsms.modules.stock.entity.StockInLineRepository
import com.ffii.fpsms.modules.stock.service.InventoryLotService import com.ffii.fpsms.modules.stock.service.InventoryLotService
import com.itextpdf.html2pdf.ConverterProperties import com.itextpdf.html2pdf.ConverterProperties
@@ -14,6 +15,7 @@ import org.jsoup.Jsoup
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.math.BigDecimal import java.math.BigDecimal
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlin.jvm.optionals.getOrNull import kotlin.jvm.optionals.getOrNull


@@ -33,6 +35,19 @@ open class MailTemplateService(


val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")


private fun formatFailedQcResultLine(result: QcResultInfoResponse): String {
val label = result.name?.takeIf { it.isNotBlank() }
?: result.code?.takeIf { it.isNotBlank() }
?: "N/A"
val criteria = result.description?.takeIf { it.isNotBlank() } ?: ""
val base = if (criteria.isNotBlank()) "$label - $criteria" else label
val measurementSuffix = result.measurementValue?.let { value ->
val unit = result.unit.orEmpty()
";實測值:$value$unit"
}.orEmpty()
return base + measurementSuffix
}

fun allMailTemplates(): List<MailTemplate> { fun allMailTemplates(): List<MailTemplate> {
return mailTemplateRepository.findAllByDeletedIsFalse(); return mailTemplateRepository.findAllByDeletedIsFalse();
} }
@@ -166,19 +181,21 @@ open class MailTemplateService(
val filteredResult = qcResults val filteredResult = qcResults
.groupBy { Pair(it.stockInLineId, it.qcItemId) } .groupBy { Pair(it.stockInLineId, it.qcItemId) }
.mapValues { (_, group) -> .mapValues { (_, group) ->
group.maxByOrNull { it.recordDate }
group.maxByOrNull { it.recordDate ?: LocalDateTime.MIN }
} }
.values .values
.filterNotNull() .filterNotNull()
.filter { !it.qcPassed } .filter { !it.qcPassed }
if (filteredResult.isNotEmpty()) { if (filteredResult.isNotEmpty()) {
qcDate = formatter.format(filteredResult.maxOf { it.recordDate })
filteredResult.mapNotNull { it.recordDate }.maxOrNull()?.let { latest ->
qcDate = formatter.format(latest)
}


val tempDoc = Jsoup.parse("") val tempDoc = Jsoup.parse("")
val element = tempDoc.appendElement("ul") val element = tempDoc.appendElement("ul")
for (result in filteredResult) { for (result in filteredResult) {
element.appendElement("li") element.appendElement("li")
.text("${result.code} - ${result.description}")
.text(formatFailedQcResultLine(result))
} }
tempDoc.outerHtml() tempDoc.outerHtml()
} else { } else {


+ 11
- 0
src/main/java/com/ffii/fpsms/modules/qc/entity/QcMeasureType.kt Ver fichero

@@ -0,0 +1,11 @@
package com.ffii.fpsms.modules.qc.entity

object QcMeasureType {
const val TEMPERATURE = "TEMPERATURE"
const val HUMIDITY = "HUMIDITY"
}

object QcFlowType {
const val IQC = "IQC"
const val EPQC = "EPQC"
}

+ 46
- 0
src/main/java/com/ffii/fpsms/modules/qc/entity/QcResultMeasurement.kt Ver fichero

@@ -0,0 +1,46 @@
package com.ffii.fpsms.modules.qc.entity

import com.ffii.core.entity.BaseEntity
import com.ffii.fpsms.modules.stock.entity.StockInLine
import jakarta.persistence.Column
import jakarta.persistence.Entity
import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.Table
import jakarta.validation.constraints.NotNull
import jakarta.validation.constraints.Size
import java.math.BigDecimal

@Entity
@Table(name = "qc_result_measurement")
open class QcResultMeasurement : BaseEntity<Long>() {
@NotNull
@ManyToOne
@JoinColumn(name = "qcResultId", nullable = false)
open var qcResult: QcResult? = null

@ManyToOne
@JoinColumn(name = "stockInLineId")
open var stockInLine: StockInLine? = null

@NotNull
@Size(max = 45)
@Column(name = "qcType", nullable = false, length = 45)
open var qcType: String? = null

@Size(max = 100)
@Column(name = "orderRefCode", length = 100)
open var orderRefCode: String? = null

@NotNull
@Size(max = 45)
@Column(name = "measureType", nullable = false, length = 45)
open var measureType: String? = null

@Column(name = "value", precision = 14, scale = 2)
open var value: BigDecimal? = null

@Size(max = 20)
@Column(name = "unit", length = 20)
open var unit: String? = null
}

+ 11
- 0
src/main/java/com/ffii/fpsms/modules/qc/entity/QcResultMeasurementRepository.kt Ver fichero

@@ -0,0 +1,11 @@
package com.ffii.fpsms.modules.qc.entity

import com.ffii.core.support.AbstractRepository
import org.springframework.stereotype.Repository

@Repository
interface QcResultMeasurementRepository : AbstractRepository<QcResultMeasurement, Long> {
fun findByQcResultIdAndMeasureTypeAndDeletedFalse(qcResultId: Long, measureType: String): QcResultMeasurement?

fun findAllByQcResultIdInAndDeletedFalse(qcResultIds: Collection<Long>): List<QcResultMeasurement>
}

+ 187
- 0
src/main/java/com/ffii/fpsms/modules/qc/service/QcResultMeasurementService.kt Ver fichero

@@ -0,0 +1,187 @@
package com.ffii.fpsms.modules.qc.service

import com.ffii.core.support.JdbcDao
import com.ffii.fpsms.modules.jobOrder.entity.JobOrderRepository
import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderRepository
import com.ffii.fpsms.modules.qc.entity.QcFlowType
import com.ffii.fpsms.modules.qc.entity.QcMeasureType
import com.ffii.fpsms.modules.qc.entity.QcResult
import com.ffii.fpsms.modules.qc.entity.QcResultMeasurement
import com.ffii.fpsms.modules.qc.entity.QcResultMeasurementRepository
import com.ffii.fpsms.modules.qc.web.model.SaveQcResultRequest
import com.ffii.fpsms.modules.stock.entity.StockInLine
import org.slf4j.LoggerFactory
import org.springframework.dao.DataAccessException
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional

@Service
open class QcResultMeasurementService(
private val qcResultMeasurementRepository: QcResultMeasurementRepository,
private val purchaseOrderRepository: PurchaseOrderRepository,
private val jobOrderRepository: JobOrderRepository,
private val jdbcDao: JdbcDao,
) {
private val logger = LoggerFactory.getLogger(QcResultMeasurementService::class.java)

@Volatile
private var measurementTableReady: Boolean? = null

@Volatile
private var loggedMissingTableWarning: Boolean = false

@Transactional
open fun syncMeasurement(
request: SaveQcResultRequest,
savedQcResult: QcResult,
stockInLine: StockInLine,
) {
if (request.measurement == null) {
return
}
if (!isMeasurementStorageReady()) {
return
}

try {
doSyncMeasurement(request, savedQcResult, stockInLine)
} catch (ex: DataAccessException) {
if (isMissingMeasurementTable(ex)) {
measurementTableReady = false
logMissingTableOnce()
return
}
throw ex
}
}

private fun doSyncMeasurement(
request: SaveQcResultRequest,
savedQcResult: QcResult,
stockInLine: StockInLine,
) {
val measurementRequest = request.measurement!!
val measureType = measurementRequest.measureType?.takeIf { it.isNotBlank() }
?: QcMeasureType.TEMPERATURE
val existing = savedQcResult.id?.let {
qcResultMeasurementRepository.findByQcResultIdAndMeasureTypeAndDeletedFalse(it, measureType)
}

val value = measurementRequest.value
if (value == null) {
existing?.let {
it.deleted = true
qcResultMeasurementRepository.saveAndFlush(it)
}
return
}

val measurement = existing ?: QcResultMeasurement()
measurement.apply {
qcResult = savedQcResult
this.stockInLine = stockInLine
qcType = resolveQcType(stockInLine)
orderRefCode = resolveOrderRefCode(stockInLine)
this.measureType = measureType
this.value = value
unit = measurementRequest.unit?.takeIf { it.isNotBlank() } ?: defaultUnitFor(measureType)
deleted = false
}
qcResultMeasurementRepository.saveAndFlush(measurement)
}

/**
* Returns false when Liquibase migration for [qc_result_measurement] has not been applied yet,
* so stock-in QC can complete without rolling back the parent transaction.
*/
private fun isMeasurementStorageReady(): Boolean {
if (measurementTableReady == true) {
return true
}

return try {
val exists = jdbcDao.queryForInt(
"""
SELECT COUNT(*)
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name = 'qc_result_measurement'
""".trimIndent(),
) > 0
measurementTableReady = exists
if (!exists) {
logMissingTableOnce()
}
exists
} catch (ex: Exception) {
measurementTableReady = false
if (!loggedMissingTableWarning) {
logger.warn(
"Unable to verify qc_result_measurement table; skipping QC measurement sync until migration is applied.",
ex,
)
loggedMissingTableWarning = true
}
false
}
}

private fun logMissingTableOnce() {
if (loggedMissingTableWarning) {
return
}
logger.warn(
"qc_result_measurement table is not available; skipping QC measurement sync. " +
"Apply Liquibase migration 20260622_01_qc_result_measurement to persist temperature/humidity values.",
)
loggedMissingTableWarning = true
}

private fun isMissingMeasurementTable(ex: Throwable): Boolean {
var current: Throwable? = ex
while (current != null) {
val message = current.message?.lowercase().orEmpty()
if (
message.contains("qc_result_measurement") &&
(message.contains("doesn't exist") ||
message.contains("does not exist") ||
message.contains("unknown table") ||
message.contains("invalid object name"))
) {
return true
}
current = current.cause
}
return false
}

open fun resolveQcType(stockInLine: StockInLine): String {
return if (stockInLine.jobOrder?.id != null) {
QcFlowType.EPQC
} else {
QcFlowType.IQC
}
}

open fun resolveOrderRefCode(stockInLine: StockInLine): String? {
val jobOrderId = stockInLine.jobOrder?.id
if (jobOrderId != null) {
return stockInLine.jobOrder?.code
?: jobOrderRepository.findById(jobOrderId).orElse(null)?.code
}
val purchaseOrderId = stockInLine.purchaseOrder?.id
if (purchaseOrderId != null) {
return stockInLine.purchaseOrder?.code
?: purchaseOrderRepository.findById(purchaseOrderId).orElse(null)?.code
}
return null
}

private fun defaultUnitFor(measureType: String): String? {
return when (measureType) {
QcMeasureType.TEMPERATURE -> "°C"
QcMeasureType.HUMIDITY -> "%"
else -> null
}
}
}

+ 44
- 4
src/main/java/com/ffii/fpsms/modules/qc/service/QcResultService.kt Ver fichero

@@ -6,8 +6,10 @@ import com.ffii.fpsms.modules.master.entity.ItemsRepository
import com.ffii.fpsms.modules.master.entity.QcItemRepository import com.ffii.fpsms.modules.master.entity.QcItemRepository
import com.ffii.fpsms.modules.master.web.models.MessageResponse import com.ffii.fpsms.modules.master.web.models.MessageResponse
import com.ffii.fpsms.modules.qc.entity.QcResult import com.ffii.fpsms.modules.qc.entity.QcResult
import com.ffii.fpsms.modules.qc.entity.QcResultMeasurementRepository
import com.ffii.fpsms.modules.qc.entity.QcResultRepository import com.ffii.fpsms.modules.qc.entity.QcResultRepository
import com.ffii.fpsms.modules.qc.entity.projection.QcResultInfo import com.ffii.fpsms.modules.qc.entity.projection.QcResultInfo
import com.ffii.fpsms.modules.qc.web.model.QcResultInfoResponse
import com.ffii.fpsms.modules.qc.web.model.SaveQcResultRequest import com.ffii.fpsms.modules.qc.web.model.SaveQcResultRequest
import com.ffii.fpsms.modules.stock.entity.StockInLineRepository import com.ffii.fpsms.modules.stock.entity.StockInLineRepository
import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository
@@ -19,6 +21,8 @@ import java.io.IOException
open class QcResultService( open class QcResultService(
private val jdbcDao: JdbcDao, private val jdbcDao: JdbcDao,
private val qcResultRepository: QcResultRepository, private val qcResultRepository: QcResultRepository,
private val qcResultMeasurementRepository: QcResultMeasurementRepository,
private val qcResultMeasurementService: QcResultMeasurementService,
private val qcItemRepository: QcItemRepository, private val qcItemRepository: QcItemRepository,
private val itemRepository: ItemsRepository, private val itemRepository: ItemsRepository,
private val stockInLineRepository: StockInLineRepository, private val stockInLineRepository: StockInLineRepository,
@@ -45,6 +49,9 @@ open class QcResultService(
this.qcPassed = request.qcPassed this.qcPassed = request.qcPassed
} }
val savedQcResult = saveAndFlush(qcResult) val savedQcResult = saveAndFlush(qcResult)
stockInLine?.let { sil ->
qcResultMeasurementService.syncMeasurement(request, savedQcResult, sil)
}
return MessageResponse( return MessageResponse(
id = savedQcResult.id, id = savedQcResult.id,
name = savedQcResult.qcItem!!.name, name = savedQcResult.qcItem!!.name,
@@ -55,10 +62,43 @@ open class QcResultService(
) )
} }


open fun getAllQcResultInfoByStockInLineId(stockInLineId: Long): List<QcResultInfo> {
return qcResultRepository.findQcResultInfoByStockInLineIdAndDeletedFalse(stockInLineId)
open fun getAllQcResultInfoByStockInLineId(stockInLineId: Long): List<QcResultInfoResponse> {
return enrichWithMeasurements(
qcResultRepository.findQcResultInfoByStockInLineIdAndDeletedFalse(stockInLineId)
)
}
open fun getAllQcResultInfoByStockOutLineId(stockOutLineId: Long): List<QcResultInfoResponse> {
return enrichWithMeasurements(
qcResultRepository.findQcResultInfoByStockOutLineIdAndDeletedFalse(stockOutLineId)
)
} }
open fun getAllQcResultInfoByStockOutLineId(stockOutLineId: Long): List<QcResultInfo> {
return qcResultRepository.findQcResultInfoByStockOutLineIdAndDeletedFalse(stockOutLineId)

private fun enrichWithMeasurements(results: List<QcResultInfo>): List<QcResultInfoResponse> {
if (results.isEmpty()) return emptyList()
val measurementsByQcResultId = qcResultMeasurementRepository
.findAllByQcResultIdInAndDeletedFalse(results.map { it.id })
.associateBy { it.qcResult?.id }
return results.map { info ->
val measurement = measurementsByQcResultId[info.id]
QcResultInfoResponse(
id = info.id,
qcItemId = info.qcItemId,
code = info.code,
name = info.name,
description = info.description,
remarks = info.remarks,
stockInLineId = info.stockInLineId,
stockOutLineId = info.stockOutLineId,
escalationLogId = info.escalationLogId,
failQty = info.failQty,
qcPassed = info.qcPassed,
recordDate = info.recordDate,
measurementValue = measurement?.value,
measureType = measurement?.measureType,
unit = measurement?.unit,
qcType = measurement?.qcType,
orderRefCode = measurement?.orderRefCode,
)
}
} }
} }

+ 3
- 3
src/main/java/com/ffii/fpsms/modules/qc/web/QcResultController.kt Ver fichero

@@ -1,8 +1,8 @@
package com.ffii.fpsms.modules.qc.web package com.ffii.fpsms.modules.qc.web


import com.ffii.fpsms.modules.master.web.models.MessageResponse import com.ffii.fpsms.modules.master.web.models.MessageResponse
import com.ffii.fpsms.modules.qc.entity.projection.QcResultInfo
import com.ffii.fpsms.modules.qc.service.QcResultService import com.ffii.fpsms.modules.qc.service.QcResultService
import com.ffii.fpsms.modules.qc.web.model.QcResultInfoResponse
import com.ffii.fpsms.modules.qc.web.model.SaveQcResultRequest import com.ffii.fpsms.modules.qc.web.model.SaveQcResultRequest
import jakarta.validation.Valid import jakarta.validation.Valid
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.*
@@ -19,12 +19,12 @@ class QcResultController(
} }


@GetMapping("/{stockInLineId}") @GetMapping("/{stockInLineId}")
fun getAllQcResultInfoByStockInLineId(@PathVariable stockInLineId: Long): List<QcResultInfo> {
fun getAllQcResultInfoByStockInLineId(@PathVariable stockInLineId: Long): List<QcResultInfoResponse> {
return qcResultService.getAllQcResultInfoByStockInLineId(stockInLineId) return qcResultService.getAllQcResultInfoByStockInLineId(stockInLineId)
} }


@GetMapping("/pick-order/{stockOutLineId}") @GetMapping("/pick-order/{stockOutLineId}")
fun getAllQcResultInfoByStockOutLineId(@PathVariable stockOutLineId: Long): List<QcResultInfo> {
fun getAllQcResultInfoByStockOutLineId(@PathVariable stockOutLineId: Long): List<QcResultInfoResponse> {
return qcResultService.getAllQcResultInfoByStockOutLineId(stockOutLineId) return qcResultService.getAllQcResultInfoByStockOutLineId(stockOutLineId)
} }
} }

+ 24
- 0
src/main/java/com/ffii/fpsms/modules/qc/web/model/QcResultInfoResponse.kt Ver fichero

@@ -0,0 +1,24 @@
package com.ffii.fpsms.modules.qc.web.model

import java.math.BigDecimal
import java.time.LocalDateTime

data class QcResultInfoResponse(
val id: Long,
val qcItemId: Long?,
val code: String?,
val name: String?,
val description: String?,
val remarks: String?,
val stockInLineId: Long?,
val stockOutLineId: Long?,
val escalationLogId: Long?,
val failQty: Double?,
val qcPassed: Boolean,
val recordDate: LocalDateTime?,
val measurementValue: BigDecimal? = null,
val measureType: String? = null,
val unit: String? = null,
val qcType: String? = null,
val orderRefCode: String? = null,
)

+ 9
- 0
src/main/java/com/ffii/fpsms/modules/qc/web/model/SaveQcMeasurementRequest.kt Ver fichero

@@ -0,0 +1,9 @@
package com.ffii.fpsms.modules.qc.web.model

import java.math.BigDecimal

data class SaveQcMeasurementRequest(
val measureType: String? = null,
val value: BigDecimal? = null,
val unit: String? = null,
)

+ 1
- 0
src/main/java/com/ffii/fpsms/modules/qc/web/model/SaveQcResultRequest.kt Ver fichero

@@ -10,4 +10,5 @@ data class SaveQcResultRequest(
val qcPassed: Boolean, val qcPassed: Boolean,
val type: String?, val type: String?,
val remarks: String?, val remarks: String?,
val measurement: SaveQcMeasurementRequest? = null,
) )

+ 102
- 30
src/main/java/com/ffii/fpsms/modules/report/service/ItemQcFailReportService.kt Ver fichero

@@ -8,15 +8,30 @@ open class ItemQcFailReportService(
private val jdbcDao: JdbcDao, private val jdbcDao: JdbcDao,
) { ) {
fun searchItemQcFailReport( fun searchItemQcFailReport(
stockCategory: String?, // items.type (可逗号分隔)
itemCode: String?, // items.code (可逗号分隔, LIKE)
lastInDateStart: String?, // stock_in_line.receiptDate >=
lastInDateEnd: String?, // stock_in_line.receiptDate <
stockCategory: String?,
itemCode: String?,
lastInDateStart: String?,
lastInDateEnd: String?,
qcType: String?,
includeMeasurable: String?,
includeOther: String?,
measurableScope: String?,
): List<Map<String, Any>> { ): List<Map<String, Any>> {
val measurable = resolveIncludeMeasurable(includeMeasurable)
val other = isTruthy(includeOther)
val scope = resolveMeasurableScope(measurableScope, measurable, other)

if (!measurable && !other) {
return emptyList()
}

val args = mutableMapOf<String, Any>() val args = mutableMapOf<String, Any>()


val stockCategorySql = buildMultiValueExactClause(stockCategory, "it.type", "stockCategory", args) val stockCategorySql = buildMultiValueExactClause(stockCategory, "it.type", "stockCategory", args)
val itemCodeSql = buildMultiValueLikeClause(itemCode, "it.code", "itemCode", args) val itemCodeSql = buildMultiValueLikeClause(itemCode, "it.code", "itemCode", args)
val qcTypeSql = buildQcTypeClause(qcType)
val qcItemScopeSql = buildQcItemScopeClause(measurable, other)
val measuredValueSql = buildMeasuredValueClause(measurable, scope)


val lastInDateStartSql = if (!lastInDateStart.isNullOrBlank()) { val lastInDateStartSql = if (!lastInDateStart.isNullOrBlank()) {
val formattedDate = lastInDateStart.replace("/", "-") val formattedDate = lastInDateStart.replace("/", "-")
@@ -39,10 +54,8 @@ open class ItemQcFailReportService(
COALESCE(sil.lotNo, il.lotNo, '') AS lotNo, COALESCE(sil.lotNo, il.lotNo, '') AS lotNo,
COALESCE(DATE_FORMAT(COALESCE(sil.expiryDate, il.expiryDate), '%Y-%m-%d'), '') AS expiryDate, COALESCE(DATE_FORMAT(COALESCE(sil.expiryDate, il.expiryDate), '%Y-%m-%d'), '') AS expiryDate,


/* 用 stock_in_line.jobOrderId 来判 IQC/EPQC */
CASE WHEN sil.jobOrderId IS NOT NULL THEN 'EPQC' ELSE 'IQC' END AS qcType, CASE WHEN sil.jobOrderId IS NOT NULL THEN 'EPQC' ELSE 'IQC' END AS qcType,


/* QC Template Used:取 qc_category.code (例如 A1/B4/X1) */
COALESCE( COALESCE(
qc.name, qc.name,
(SELECT name (SELECT name
@@ -50,33 +63,37 @@ open class ItemQcFailReportService(
WHERE isDefault = 1 WHERE isDefault = 1
AND deleted = 0 AND deleted = 0
LIMIT 1), LIMIT 1),
qc.name,
'' ''
) AS qcTemplate, ) AS qcTemplate,


/* QC Criteria with Defect:优先用 qc_item_category.description,否则用 qc_item */
COALESCE(qic.description, qi.description, qi.name, '') AS qcDefectCriteria, COALESCE(qic.description, qi.description, qi.name, '') AS qcDefectCriteria,


/* Lot Qty / Defect Qty:按 jrxml 字段类型输出 String */
TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT( COALESCE(
CASE WHEN sil.purchaseOrderLineId IS NOT NULL
THEN sil.acceptedQty
WHEN iu_purchase.id IS NOT NULL AND iu.id IS NOT NULL
THEN sil.acceptedQty * (iu_purchase.ratioN / NULLIF(iu_purchase.ratioD, 0)) / (iu.ratioN / NULLIF(iu.ratioD, 0))
ELSE sil.acceptedQty END, 0), 2))) AS lotQty,
TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(
COALESCE(
CASE WHEN sil.purchaseOrderLineId IS NOT NULL
THEN sil.acceptedQty
WHEN iu_purchase.id IS NOT NULL AND iu.id IS NOT NULL
THEN sil.acceptedQty * (iu_purchase.ratioN / NULLIF(iu_purchase.ratioD, 0)) / (iu.ratioN / NULLIF(iu.ratioD, 0))
ELSE sil.acceptedQty END, 0), 2))) AS lotQty,
TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(COALESCE(qr.failQty, 0), 2))) AS defectQty, TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(COALESCE(qr.failQty, 0), 2))) AS defectQty,


/* Ref Data (e.g temp):目前库表只有 qc_result.remarks,可先放这里 */
COALESCE(qr.remarks, '') AS refData,
CASE
WHEN qrm.value IS NOT NULL THEN
CONCAT(
TRIM(TRAILING '.' FROM TRIM(TRAILING '0' FROM FORMAT(qrm.value, 2))),
COALESCE(qrm.unit, '')
)
ELSE ''
END AS measuredValue,


/* Remarks:若你之后有独立字段再补 */
'' AS remark,
COALESCE(qr.remarks, '') AS remark,


/* Order Ref No:IQC 用 purchase_order.code;EPQC 用 job_order.code */
CASE CASE
WHEN sil.jobOrderId IS NOT NULL THEN COALESCE(jo.code, '') WHEN sil.jobOrderId IS NOT NULL THEN COALESCE(jo.code, '')
ELSE COALESCE(po.code, '') ELSE COALESCE(po.code, '')
END AS orderRefNo
END AS orderRefNo,

qr.qcPassed AS qcPassed


FROM qc_result qr FROM qc_result qr
INNER JOIN stock_in_line sil INNER JOIN stock_in_line sil
@@ -85,6 +102,9 @@ open class ItemQcFailReportService(
INNER JOIN items it INNER JOIN items it
ON qr.itemId = it.id ON qr.itemId = it.id
AND it.deleted = 0 AND it.deleted = 0
LEFT JOIN qc_result_measurement qrm
ON qrm.qcResultId = qr.id
AND qrm.deleted = 0
LEFT JOIN item_uom iu LEFT JOIN item_uom iu
ON it.id = iu.itemId ON it.id = iu.itemId
AND iu.stockUnit = 1 AND iu.stockUnit = 1
@@ -105,19 +125,19 @@ open class ItemQcFailReportService(
ON sil.jobOrderId = jo.id ON sil.jobOrderId = jo.id
AND jo.deleted = 0 AND jo.deleted = 0


LEFT JOIN items_qc_category_mapping iqcm
LEFT JOIN items_qc_category_mapping iqcm
ON iqcm.itemId = it.id ON iqcm.itemId = it.id
AND iqcm.type = (CASE WHEN sil.jobOrderId IS NOT NULL THEN 'EPQC' ELSE 'IQC' END)
AND iqcm.type = (CASE WHEN sil.jobOrderId IS NOT NULL THEN 'EPQC' ELSE 'IQC' END)
LEFT JOIN qc_category qc LEFT JOIN qc_category qc
ON qc.id = iqcm.qcCategoryId ON qc.id = iqcm.qcCategoryId
AND qc.deleted = 0
AND qc.deleted = 0


LEFT JOIN qc_item qi LEFT JOIN qc_item qi
ON qi.id = qr.qcItemId ON qi.id = qr.qcItemId
AND qi.deleted = 0
AND qi.deleted = 0
LEFT JOIN qc_item_category qic LEFT JOIN qc_item_category qic
ON qic.qcItemId = qi.id ON qic.qcItemId = qi.id
AND qic.qcCategoryId = COALESCE(
AND qic.qcCategoryId = COALESCE(
iqcm.qcCategoryId, iqcm.qcCategoryId,
(SELECT id (SELECT id
FROM qc_category FROM qc_category
@@ -128,13 +148,16 @@ open class ItemQcFailReportService(


WHERE WHERE
qr.deleted = 0 qr.deleted = 0
AND qr.qcPassed = 0
$qcItemScopeSql
$measuredValueSql
$qcTypeSql
$stockCategorySql $stockCategorySql
$itemCodeSql $itemCodeSql
$lastInDateStartSql $lastInDateStartSql
$lastInDateEndSql $lastInDateEndSql


ORDER BY ORDER BY
qr.qcPassed DESC,
it.type, it.type,
it.code, it.code,
COALESCE(sil.lotNo, il.lotNo, ''), COALESCE(sil.lotNo, il.lotNo, ''),
@@ -142,9 +165,58 @@ open class ItemQcFailReportService(
COALESCE(qic.`order`, 9999), COALESCE(qic.`order`, 9999),
COALESCE(qi.code, '') COALESCE(qi.code, '')
""".trimIndent() """.trimIndent()
val result = jdbcDao.queryForList(sql, args)
return jdbcDao.queryForList(sql, args)
}

private fun resolveIncludeMeasurable(includeMeasurable: String?): Boolean {
return isTruthy(includeMeasurable)
}


return result
private fun isTruthy(value: String?): Boolean {
return value?.trim()?.lowercase() in listOf("true", "1", "yes")
}

private fun buildQcItemScopeClause(includeMeasurable: Boolean, includeOther: Boolean): String {
val parts = mutableListOf<String>()
if (includeMeasurable) {
parts.add("qi.name IN ('溫度', '濕度')")
}
if (includeOther) {
parts.add("(qi.name NOT IN ('溫度', '濕度') OR qi.name IS NULL)")
}
return "AND (${parts.joinToString(" OR ")})"
}

private fun resolveMeasurableScope(
measurableScope: String?,
includeMeasurable: Boolean,
includeOther: Boolean,
): String {
if (!measurableScope.isNullOrBlank()) {
return measurableScope.trim().lowercase()
}
return if (includeMeasurable && !includeOther) "with" else "all"
}

/**
* with = only temp/humidity rows with a filled measured value;
* all = all temp/humidity rows;
* without is handled by includeMeasurable=false on the API layer.
*/
private fun buildMeasuredValueClause(includeMeasurable: Boolean, measurableScope: String): String {
if (!includeMeasurable) return ""
return when (measurableScope) {
"with" -> "AND qrm.value IS NOT NULL"
else -> ""
}
}

private fun buildQcTypeClause(qcType: String?): String {
return when (qcType?.trim()?.uppercase()) {
"IQC" -> "AND sil.jobOrderId IS NULL"
"EPQC" -> "AND sil.jobOrderId IS NOT NULL"
else -> ""
}
} }


private fun buildMultiValueLikeClause( private fun buildMultiValueLikeClause(
@@ -182,4 +254,4 @@ open class ItemQcFailReportService(
} }
return "AND (${conditions.joinToString(" OR ")})" return "AND (${conditions.joinToString(" OR ")})"
} }
}
}

+ 19
- 0
src/main/java/com/ffii/fpsms/modules/report/service/ReportService.kt Ver fichero

@@ -1716,5 +1716,24 @@ return result
val jasperPrint = JasperFillManager.fillReport(jasperReport, params, dataSource) val jasperPrint = JasperFillManager.fillReport(jasperReport, params, dataSource)
return JasperExportManager.exportReportToPdf(jasperPrint) return JasperExportManager.exportReportToPdf(jasperPrint)
} }

fun mergePdfBytes(parts: List<ByteArray>): ByteArray {
if (parts.isEmpty()) return ByteArray(0)
if (parts.size == 1) return parts[0]

val out = java.io.ByteArrayOutputStream()
val writer = com.itextpdf.kernel.pdf.PdfWriter(out)
val pdfDoc = com.itextpdf.kernel.pdf.PdfDocument(writer)
val merger = com.itextpdf.kernel.utils.PdfMerger(pdfDoc)
parts.forEach { bytes ->
val src = com.itextpdf.kernel.pdf.PdfDocument(
com.itextpdf.kernel.pdf.PdfReader(java.io.ByteArrayInputStream(bytes))
)
merger.merge(src, 1, src.numberOfPages)
src.close()
}
pdfDoc.close()
return out.toByteArray()
}
} }

+ 135
- 92
src/main/java/com/ffii/fpsms/modules/report/web/ItemQcFailReportController.kt Ver fichero

@@ -2,10 +2,6 @@ package com.ffii.fpsms.modules.report.web


import org.springframework.http.* import org.springframework.http.*
import org.springframework.web.bind.annotation.* import org.springframework.web.bind.annotation.*
import java.time.LocalDate
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import java.math.BigDecimal
import com.ffii.fpsms.modules.report.service.ItemQcFailReportService import com.ffii.fpsms.modules.report.service.ItemQcFailReportService
import com.ffii.fpsms.modules.report.service.ReportService import com.ffii.fpsms.modules.report.service.ReportService
import org.apache.poi.ss.usermodel.BorderStyle import org.apache.poi.ss.usermodel.BorderStyle
@@ -13,7 +9,6 @@ import org.apache.poi.ss.usermodel.FillPatternType
import org.apache.poi.ss.usermodel.HorizontalAlignment import org.apache.poi.ss.usermodel.HorizontalAlignment
import org.apache.poi.ss.usermodel.IndexedColors import org.apache.poi.ss.usermodel.IndexedColors
import org.apache.poi.ss.usermodel.VerticalAlignment import org.apache.poi.ss.usermodel.VerticalAlignment
import org.apache.poi.ss.usermodel.DataFormat
import org.apache.poi.ss.usermodel.Workbook import org.apache.poi.ss.usermodel.Workbook
import org.apache.poi.ss.util.WorkbookUtil import org.apache.poi.ss.util.WorkbookUtil
import org.apache.poi.xssf.usermodel.XSSFWorkbook import org.apache.poi.xssf.usermodel.XSSFWorkbook
@@ -32,43 +27,47 @@ class ItemQcFailReportController(
@RequestParam(required = false) itemCode: String?, @RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) lastInDateStart: String?, @RequestParam(required = false) lastInDateStart: String?,
@RequestParam(required = false) lastInDateEnd: String?, @RequestParam(required = false) lastInDateEnd: String?,
@RequestParam(required = false) qcType: String?,
@RequestParam(required = false, defaultValue = "true") includeMeasurable: String?,
@RequestParam(required = false, defaultValue = "false") includeOther: String?,
@RequestParam(required = false, defaultValue = "all") measurableScope: String?,
): ResponseEntity<ByteArray> { ): ResponseEntity<ByteArray> {
val parameters = mutableMapOf<String, Any>()

parameters["stockCategory"] = stockCategory ?: "All"
parameters["stockSubCategory"] = stockCategory ?: "All"
parameters["itemNo"] = itemCode ?: "All"
parameters["year"] = java.time.LocalDate.now().year.toString()
parameters["reportDate"] = java.time.LocalDate.now().format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd"))
parameters["reportTime"] = java.time.LocalTime.now().format(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss"))

parameters["storeLocation"] = ""
parameters["balanceFilterStart"] = ""
parameters["balanceFilterEnd"] = ""
parameters["lastInDateStart"] = lastInDateStart ?: ""
parameters["lastInDateEnd"] = lastInDateEnd ?: ""
parameters["lastOutDateStart"] = ""
parameters["lastOutDateEnd"] = ""

val dbData = itemQcFailReportService.searchItemQcFailReport( val dbData = itemQcFailReportService.searchItemQcFailReport(
stockCategory = stockCategory, stockCategory = stockCategory,
itemCode = itemCode, itemCode = itemCode,
lastInDateStart = lastInDateStart, lastInDateStart = lastInDateStart,
lastInDateEnd = lastInDateEnd, lastInDateEnd = lastInDateEnd,
qcType = qcType,
includeMeasurable = includeMeasurable,
includeOther = includeOther,
measurableScope = measurableScope,
) )


val pdfBytes = reportService.createPdfResponse(
"/jasper/ItemQCReport.jrxml",
parameters,
dbData
)
val passedRows = dbData.filter { isQcPassed(it) }
val failedRows = dbData.filter { !isQcPassed(it) }


val headers = org.springframework.http.HttpHeaders().apply {
contentType = org.springframework.http.MediaType.APPLICATION_PDF
setContentDispositionFormData("attachment", "ItemQCFailReport.pdf")
set("filename", "ItemQCFailReport.pdf")
val pdfParts = mutableListOf<ByteArray>()
if (passedRows.isNotEmpty()) {
pdfParts.add(createItemQcPdf(stockCategory, itemCode, lastInDateStart, lastInDateEnd, qcType, passedRows, "合格"))
} }
return org.springframework.http.ResponseEntity(pdfBytes, headers, org.springframework.http.HttpStatus.OK)
if (failedRows.isNotEmpty()) {
pdfParts.add(createItemQcPdf(stockCategory, itemCode, lastInDateStart, lastInDateEnd, qcType, failedRows, "不合格"))
}

val pdfBytes = when {
pdfParts.isEmpty() -> createItemQcPdf(
stockCategory, itemCode, lastInDateStart, lastInDateEnd, qcType, emptyList(), "合格"
)
pdfParts.size == 1 -> pdfParts[0]
else -> reportService.mergePdfBytes(pdfParts)
}

val headers = HttpHeaders().apply {
contentType = MediaType.APPLICATION_PDF
setContentDispositionFormData("attachment", "ItemQCReport.pdf")
set("filename", "ItemQCReport.pdf")
}
return ResponseEntity(pdfBytes, headers, HttpStatus.OK)
} }


@GetMapping("/print-item-qc-fail-excel") @GetMapping("/print-item-qc-fail-excel")
@@ -77,40 +76,117 @@ class ItemQcFailReportController(
@RequestParam(required = false) itemCode: String?, @RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) lastInDateStart: String?, @RequestParam(required = false) lastInDateStart: String?,
@RequestParam(required = false) lastInDateEnd: String?, @RequestParam(required = false) lastInDateEnd: String?,
@RequestParam(required = false) qcType: String?,
@RequestParam(required = false, defaultValue = "true") includeMeasurable: String?,
@RequestParam(required = false, defaultValue = "false") includeOther: String?,
@RequestParam(required = false, defaultValue = "all") measurableScope: String?,
): ResponseEntity<ByteArray> { ): ResponseEntity<ByteArray> {
val dbData = itemQcFailReportService.searchItemQcFailReport( val dbData = itemQcFailReportService.searchItemQcFailReport(
stockCategory = stockCategory, stockCategory = stockCategory,
itemCode = itemCode, itemCode = itemCode,
lastInDateStart = lastInDateStart, lastInDateStart = lastInDateStart,
lastInDateEnd = lastInDateEnd, lastInDateEnd = lastInDateEnd,
qcType = qcType,
includeMeasurable = includeMeasurable,
includeOther = includeOther,
measurableScope = measurableScope,
) )


val reportTitle = "庫存品質檢測報告"
val excelBytes = createItemQcFailExcel(
dbData = dbData,
reportTitle = reportTitle,
val passedRows = dbData.filter { isQcPassed(it) }
val failedRows = dbData.filter { !isQcPassed(it) }

val excelBytes = createItemQcMeasurementExcel(
passedRows = passedRows,
failedRows = failedRows,
lastInDateStart = lastInDateStart, lastInDateStart = lastInDateStart,
lastInDateEnd = lastInDateEnd
lastInDateEnd = lastInDateEnd,
qcType = qcType,
) )


val headers = org.springframework.http.HttpHeaders().apply {
contentType = org.springframework.http.MediaType.parseMediaType(
val headers = HttpHeaders().apply {
contentType = MediaType.parseMediaType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
) )
setContentDispositionFormData("attachment", "ItemQCFailReport.xlsx")
set("filename", "ItemQCFailReport.xlsx")
setContentDispositionFormData("attachment", "ItemQCReport.xlsx")
set("filename", "ItemQCReport.xlsx")
} }
return org.springframework.http.ResponseEntity(excelBytes, headers, org.springframework.http.HttpStatus.OK)
return ResponseEntity(excelBytes, headers, HttpStatus.OK)
} }


private fun createItemQcFailExcel(
dbData: List<Map<String, Any>>,
reportTitle: String,
private fun createItemQcPdf(
stockCategory: String?,
itemCode: String?,
lastInDateStart: String?,
lastInDateEnd: String?,
qcType: String?,
rows: List<Map<String, Any>>,
reportSection: String,
): ByteArray {
val parameters = mutableMapOf<String, Any>()
parameters["stockCategory"] = stockCategory ?: "All"
parameters["stockSubCategory"] = stockCategory ?: "All"
parameters["itemNo"] = itemCode ?: "All"
parameters["year"] = java.time.LocalDate.now().year.toString()
parameters["reportDate"] = java.time.LocalDate.now()
.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd"))
parameters["reportTime"] = java.time.LocalTime.now()
.format(java.time.format.DateTimeFormatter.ofPattern("HH:mm:ss"))
parameters["storeLocation"] = ""
parameters["balanceFilterStart"] = ""
parameters["balanceFilterEnd"] = ""
parameters["lastInDateStart"] = lastInDateStart ?: ""
parameters["lastInDateEnd"] = lastInDateEnd ?: ""
parameters["lastOutDateStart"] = ""
parameters["lastOutDateEnd"] = ""
parameters["reportSection"] = reportSection
parameters["qcTypeLabel"] = resolveQcTypeLabel(qcType)

return reportService.createPdfResponse(
"/jasper/ItemQCReport.jrxml",
parameters,
rows,
)
}

private fun resolveQcTypeLabel(qcType: String?): String {
return when (qcType?.trim()?.uppercase()) {
"IQC" -> "IQC"
"EPQC" -> "EPQC"
else -> "全部"
}
}

private fun isQcPassed(row: Map<String, Any>): Boolean {
return when (val value = row["qcPassed"]) {
is Boolean -> value
is Number -> value.toInt() == 1
else -> value?.toString() in listOf("1", "true", "TRUE")
}
}

private fun createItemQcMeasurementExcel(
passedRows: List<Map<String, Any>>,
failedRows: List<Map<String, Any>>,
lastInDateStart: String?, lastInDateStart: String?,
lastInDateEnd: String?
lastInDateEnd: String?,
qcType: String?,
): ByteArray { ): ByteArray {
val workbook: Workbook = XSSFWorkbook() val workbook: Workbook = XSSFWorkbook()
val safeSheetName = WorkbookUtil.createSafeSheetName(reportTitle)
writeQcSheet(workbook, "合格", passedRows, lastInDateStart, lastInDateEnd, qcType)
writeQcSheet(workbook, "不合格", failedRows, lastInDateStart, lastInDateEnd, qcType)
return workbookToByteArray(workbook)
}

private fun writeQcSheet(
workbook: Workbook,
sheetLabel: String,
dbData: List<Map<String, Any>>,
lastInDateStart: String?,
lastInDateEnd: String?,
qcType: String?,
) {
val reportTitle = "庫存品質檢測報告"
val safeSheetName = WorkbookUtil.createSafeSheetName(sheetLabel)
val sheet = workbook.createSheet(safeSheetName) val sheet = workbook.createSheet(safeSheetName)


var rowIndex = 0 var rowIndex = 0
@@ -126,14 +202,12 @@ class ItemQcFailReportController(
} }
titleStyle.setFont(titleFont) titleStyle.setFont(titleFont)


// Title row
run { run {
val titleRowIndex = rowIndex++ val titleRowIndex = rowIndex++
val row = sheet.createRow(titleRowIndex) val row = sheet.createRow(titleRowIndex)
val cell = row.createCell(0) val cell = row.createCell(0)
cell.setCellValue(reportTitle)
cell.setCellValue("$reportTitle ($sheetLabel)")
cell.cellStyle = titleStyle cell.cellStyle = titleStyle
// Merge title across columns so the text won't be blocked by narrow col A.
sheet.addMergedRegion( sheet.addMergedRegion(
org.apache.poi.ss.util.CellRangeAddress( org.apache.poi.ss.util.CellRangeAddress(
titleRowIndex, titleRowIndex,
@@ -145,13 +219,13 @@ class ItemQcFailReportController(
row.heightInPoints = 24f row.heightInPoints = 24f
} }


// Info row
run { run {
val row = sheet.createRow(rowIndex++) val row = sheet.createRow(rowIndex++)
val cell = row.createCell(0) val cell = row.createCell(0)
val startTxt = lastInDateStart ?: "" val startTxt = lastInDateStart ?: ""
val endTxt = lastInDateEnd ?: "" val endTxt = lastInDateEnd ?: ""
cell.setCellValue("QC 不合格日期: ${startTxt} - ${endTxt}")
val qcTypeTxt = resolveQcTypeLabel(qcType)
cell.setCellValue("QC 檢測日期: ${startTxt} - ${endTxt} QC 類型: $qcTypeTxt")
} }


val headerStyle = workbook.createCellStyle().apply { val headerStyle = workbook.createCellStyle().apply {
@@ -218,15 +292,14 @@ class ItemQcFailReportController(
"到期日", "到期日",
"QC 類型", "QC 類型",
"QC 範本", "QC 範本",
"不合格標準",
"檢測標準",
"批量", "批量",
"不合格數量", "不合格數量",
"參考資料",
"實測值",
"備註", "備註",
"訂單/工單" "訂單/工單"
) )


// Header row
run { run {
val row = sheet.createRow(rowIndex++) val row = sheet.createRow(rowIndex++)
headers.forEachIndexed { col, h -> headers.forEachIndexed { col, h ->
@@ -234,12 +307,12 @@ class ItemQcFailReportController(
cell.setCellValue(h) cell.setCellValue(h)
cell.cellStyle = headerStyle cell.cellStyle = headerStyle
} }
// Increase header row height so wrapped header text won't be blocked/truncated.
row.heightInPoints = 35f row.heightInPoints = 35f
} }


dbData.forEach { r -> dbData.forEach { r ->
val row = sheet.createRow(rowIndex++) val row = sheet.createRow(rowIndex++)

fun writeText(col: Int, value: Any?) { fun writeText(col: Int, value: Any?) {
val cell = row.createCell(col) val cell = row.createCell(col)
cell.setCellValue(value?.toString() ?: "") cell.setCellValue(value?.toString() ?: "")
@@ -248,7 +321,6 @@ class ItemQcFailReportController(


fun writeNumber(col: Int, value: Any?) { fun writeNumber(col: Int, value: Any?) {
val raw = value?.toString()?.trim() ?: "" val raw = value?.toString()?.trim() ?: ""
// Some SQL strings may end with "."; Excel would display it as "4." otherwise.
val cleaned = raw.removeSuffix(".") val cleaned = raw.removeSuffix(".")
val bd = cleaned.toBigDecimalOrNull() val bd = cleaned.toBigDecimalOrNull()
val cell = row.createCell(col) val cell = row.createCell(col)
@@ -259,7 +331,6 @@ class ItemQcFailReportController(
} else { } else {
val stripped = bd.stripTrailingZeros() val stripped = bd.stripTrailingZeros()
if (stripped.scale() <= 0) { if (stripped.scale() <= 0) {
// Render integer without decimal dot.
cell.setCellValue(stripped.toDouble()) cell.setCellValue(stripped.toDouble())
cell.cellStyle = integerNumberStyle cell.cellStyle = integerNumberStyle
} else { } else {
@@ -269,7 +340,6 @@ class ItemQcFailReportController(
} }
} }


// Keys must match ItemQcFailReportService SQL aliases
writeText(0, r["stockSubCategory"]) writeText(0, r["stockSubCategory"])
writeText(1, r["itemNo"]) writeText(1, r["itemNo"])
writeText(2, r["itemName"]) writeText(2, r["itemName"])
@@ -279,7 +349,6 @@ class ItemQcFailReportController(
writeText(6, r["qcType"]) writeText(6, r["qcType"])
writeText(7, r["qcTemplate"]) writeText(7, r["qcTemplate"])
val defectCriteria = r["qcDefectCriteria"] val defectCriteria = r["qcDefectCriteria"]
// Wrap this column because values can be long.
run { run {
val cell = row.createCell(8) val cell = row.createCell(8)
cell.setCellValue(defectCriteria?.toString() ?: "") cell.setCellValue(defectCriteria?.toString() ?: "")
@@ -287,49 +356,25 @@ class ItemQcFailReportController(
} }
writeNumber(9, r["lotQty"]) writeNumber(9, r["lotQty"])
writeNumber(10, r["defectQty"]) writeNumber(10, r["defectQty"])
writeText(11, r["refData"])
writeText(11, r["measuredValue"])
writeText(12, r["remark"]) writeText(12, r["remark"])
writeText(13, r["orderRefNo"]) writeText(13, r["orderRefNo"])


// If "不合格標準" is long, increase row height so wrapped text is visible.
val defectText = defectCriteria?.toString() ?: "" val defectText = defectCriteria?.toString() ?: ""
// Estimate wrap lines using the known column width (approx chars per line).
// This avoids ugly/uneven row heights from simple length thresholds.
val charsPerLine = 38.0 val charsPerLine = 38.0
val lines = maxOf( val lines = maxOf(
1, 1,
kotlin.math.ceil(defectText.length / charsPerLine).toInt() kotlin.math.ceil(defectText.length / charsPerLine).toInt()
) )
val baseHeight = 18f // single-line
val lineHeight = 13.5f // additional lines
val baseHeight = 18f
val lineHeight = 13.5f
row.heightInPoints = (baseHeight + (lines - 1) * lineHeight).coerceIn(18f, 110f) row.heightInPoints = (baseHeight + (lines - 1) * lineHeight).coerceIn(18f, 110f)
} }


// Column widths: keep reasonable defaults, then auto-size a bit (optional)
run {
// Set explicit column widths (Excel units = 1/256th of a character width).
val widths = intArrayOf(
12, // stockSubCategory
16, // itemNo
18, // itemName
10, // unit
14, // lotNo
12, // expiryDate
10, // qcType
20, // qcTemplate
42, // qcDefectCriteria (wrap)
12, // lotQty
14, // defectQty
22, // refData
16, // remark
18, // orderRefNo
)
for (col in 0 until widths.size) {
sheet.setColumnWidth(col, widths[col] * 256)
}
val widths = intArrayOf(12, 16, 18, 10, 14, 12, 10, 20, 42, 12, 14, 14, 16, 18)
for (col in widths.indices) {
sheet.setColumnWidth(col, widths[col] * 256)
} }

return workbookToByteArray(workbook)
} }


private fun workbookToByteArray(workbook: Workbook): ByteArray { private fun workbookToByteArray(workbook: Workbook): ByteArray {
@@ -339,5 +384,3 @@ class ItemQcFailReportController(
return out.toByteArray() return out.toByteArray()
} }
} }



+ 7
- 2
src/main/java/com/ffii/fpsms/modules/stock/service/StockInLineService.kt Ver fichero

@@ -11,6 +11,7 @@ import com.ffii.fpsms.modules.master.web.models.MessageResponse
import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLineRepository import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLineRepository
import com.ffii.fpsms.modules.qc.entity.QcResult import com.ffii.fpsms.modules.qc.entity.QcResult
import com.ffii.fpsms.modules.qc.entity.QcResultRepository import com.ffii.fpsms.modules.qc.entity.QcResultRepository
import com.ffii.fpsms.modules.qc.service.QcResultMeasurementService
import com.ffii.fpsms.modules.stock.entity.* import com.ffii.fpsms.modules.stock.entity.*
import com.ffii.fpsms.modules.stock.sql.StockSql.SQL.INVENTORY_COUNT import com.ffii.fpsms.modules.stock.sql.StockSql.SQL.INVENTORY_COUNT
import net.sf.jasperreports.engine.JasperCompileManager import net.sf.jasperreports.engine.JasperCompileManager
@@ -82,6 +83,7 @@ open class StockInLineService(
private val polRepository: PurchaseOrderLineRepository, private val polRepository: PurchaseOrderLineRepository,
private val qcItemsRepository: QcItemRepository, private val qcItemsRepository: QcItemRepository,
private val qcResultRepository: QcResultRepository, private val qcResultRepository: QcResultRepository,
private val qcResultMeasurementService: QcResultMeasurementService,
private val escalationLogService: EscalationLogService, private val escalationLogService: EscalationLogService,
private val escalationLogRepository: EscalationLogRepository, private val escalationLogRepository: EscalationLogRepository,
private val stockInService: StockInService, private val stockInService: StockInService,
@@ -514,7 +516,6 @@ open class StockInLineService(
val qcResultEntries = request.qcResult!!.map { val qcResultEntries = request.qcResult!!.map {
val qcItem = qcItemsRepository.findById(it.qcItemId).orElseThrow() val qcItem = qcItemsRepository.findById(it.qcItemId).orElseThrow()
val item = itemRepository.findById(stockInLine.item!!.id!!).orElseThrow() val item = itemRepository.findById(stockInLine.item!!.id!!).orElseThrow()
val qcResult = QcResult();


QcResult().apply { QcResult().apply {
this.qcItem = qcItem this.qcItem = qcItem
@@ -527,7 +528,11 @@ open class StockInLineService(
this.escalationLog = escalationLogService.find(escLogId).getOrNull() this.escalationLog = escalationLogService.find(escLogId).getOrNull()
} }
} }
return qcResultRepository.saveAllAndFlush(qcResultEntries)
val savedResults = qcResultRepository.saveAllAndFlush(qcResultEntries)
request.qcResult!!.zip(savedResults).forEach { (req, saved) ->
qcResultMeasurementService.syncMeasurement(req, saved, stockInLine)
}
return savedResults
} }
return null return null
} }


+ 25
- 0
src/main/resources/db/changelog/changes/20260622_01_qc_result_measurement/01_create_qc_result_measurement.sql Ver fichero

@@ -0,0 +1,25 @@
--liquibase formatted sql

--changeset fpsms:create qc_result_measurement
CREATE TABLE `qc_result_measurement`
(
`id` INT NOT NULL AUTO_INCREMENT,
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`createdBy` VARCHAR(30) NULL,
`version` INT NOT NULL DEFAULT 0,
`modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`modifiedBy` VARCHAR(30) NULL,
`deleted` TINYINT(1) NOT NULL DEFAULT 0,
`qcResultId` INT NOT NULL,
`stockInLineId` INT NULL,
`qcType` VARCHAR(45) NOT NULL,
`orderRefCode` VARCHAR(100) NULL,
`measureType` VARCHAR(45) NOT NULL,
`value` DECIMAL(14, 2) NULL,
`unit` VARCHAR(20) NULL,
CONSTRAINT PK_QC_RESULT_MEASUREMENT PRIMARY KEY (`id`),
CONSTRAINT FK_QCRM_TO_QC_RESULT_ON_QC_RESULT_ID
FOREIGN KEY (`qcResultId`) REFERENCES `qc_result` (`id`),
CONSTRAINT FK_QCRM_TO_STOCK_IN_LINE_ON_STOCK_IN_LINE_ID
FOREIGN KEY (`stockInLineId`) REFERENCES `stock_in_line` (`id`)
);

+ 32
- 4
src/main/resources/jasper/ItemQCReport.jrxml Ver fichero

@@ -35,6 +35,12 @@
<parameter name="lastOutDateEnd" class="java.lang.String"> <parameter name="lastOutDateEnd" class="java.lang.String">
<parameterDescription><![CDATA["lastOutDateStart"]]></parameterDescription> <parameterDescription><![CDATA["lastOutDateStart"]]></parameterDescription>
</parameter> </parameter>
<parameter name="reportSection" class="java.lang.String">
<defaultValueExpression><![CDATA[""]]></defaultValueExpression>
</parameter>
<parameter name="qcTypeLabel" class="java.lang.String">
<defaultValueExpression><![CDATA["全部"]]></defaultValueExpression>
</parameter>
<queryString> <queryString>
<![CDATA[]]> <![CDATA[]]>
</queryString> </queryString>
@@ -48,11 +54,12 @@
<field name="qcDefectCriteria" class="java.lang.String"/> <field name="qcDefectCriteria" class="java.lang.String"/>
<field name="lotQty" class="java.lang.String"/> <field name="lotQty" class="java.lang.String"/>
<field name="defectQty" class="java.lang.String"/> <field name="defectQty" class="java.lang.String"/>
<field name="refData" class="java.lang.String"/>
<field name="measuredValue" class="java.lang.String"/>
<field name="remark" class="java.lang.String"/> <field name="remark" class="java.lang.String"/>
<field name="orderRefNo" class="java.lang.String"/> <field name="orderRefNo" class="java.lang.String"/>
<field name="stockSubCategory" class="java.lang.String"/> <field name="stockSubCategory" class="java.lang.String"/>
<group name="Group1" keepTogether="true"> <group name="Group1" keepTogether="true">
<groupExpression><![CDATA[$F{itemNo}]]></groupExpression>
<groupHeader> <groupHeader>
<band height="18"> <band height="18">
<property name="com.jaspersoft.studio.layout" value="com.jaspersoft.studio.editor.layout.FreeLayout"/> <property name="com.jaspersoft.studio.layout" value="com.jaspersoft.studio.editor.layout.FreeLayout"/>
@@ -109,7 +116,7 @@
<textElement textAlignment="Center" verticalAlignment="Middle"> <textElement textAlignment="Center" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10"/> <font fontName="微軟正黑體" size="10"/>
</textElement> </textElement>
<text><![CDATA[備注]]></text>
<text><![CDATA[實測值]]></text>
</staticText> </staticText>
<staticText> <staticText>
<reportElement x="90" y="80" width="70" height="18" uuid="7db4a800-8573-408c-baad-f4f4885625c9"> <reportElement x="90" y="80" width="70" height="18" uuid="7db4a800-8573-408c-baad-f4f4885625c9">
@@ -271,7 +278,7 @@
<textElement textAlignment="Left" verticalAlignment="Middle"> <textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="12" isBold="true"/> <font fontName="微軟正黑體" size="12" isBold="true"/>
</textElement> </textElement>
<text><![CDATA[最後入倉日期:]]></text>
<text><![CDATA[QC 檢測日期:]]></text>
</staticText> </staticText>
<textField> <textField>
<reportElement x="100" y="53" width="560" height="23" uuid="96afd73f-53ed-42cd-9911-fdaac017d65b"/> <reportElement x="100" y="53" width="560" height="23" uuid="96afd73f-53ed-42cd-9911-fdaac017d65b"/>
@@ -280,6 +287,27 @@
</textElement> </textElement>
<textFieldExpression><![CDATA[$P{lastInDateStart} + " 至 " + $P{lastInDateEnd}]]></textFieldExpression> <textFieldExpression><![CDATA[$P{lastInDateStart} + " 至 " + $P{lastInDateEnd}]]></textFieldExpression>
</textField> </textField>
<textField isBlankWhenNull="true">
<reportElement x="322" y="23" width="158" height="20" uuid="a1b2c3d4-e5f6-7890-abcd-ef1234567890"/>
<textElement textAlignment="Center" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="12" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$P{reportSection} != null && !$P{reportSection}.isEmpty() ? "(" + $P{reportSection} + ")" : ""]]></textFieldExpression>
</textField>
<staticText>
<reportElement x="450" y="53" width="70" height="23" uuid="b2c3d4e5-f6a7-8901-bcde-f12345678901"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="12" isBold="true"/>
</textElement>
<text><![CDATA[QC 類型:]]></text>
</staticText>
<textField>
<reportElement x="520" y="53" width="80" height="23" uuid="c3d4e5f6-a7b8-9012-cdef-123456789012"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="12" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$P{qcTypeLabel}]]></textFieldExpression>
</textField>
</band> </band>
</pageHeader> </pageHeader>
<detail> <detail>
@@ -331,7 +359,7 @@
<textElement textAlignment="Center" verticalAlignment="Top"> <textElement textAlignment="Center" verticalAlignment="Top">
<font fontName="微軟正黑體"/> <font fontName="微軟正黑體"/>
</textElement> </textElement>
<textFieldExpression><![CDATA[$F{refData}]]></textFieldExpression>
<textFieldExpression><![CDATA[$F{measuredValue}]]></textFieldExpression>
</textField> </textField>
<textField textAdjust="StretchHeight"> <textField textAdjust="StretchHeight">
<reportElement x="690" y="0" width="108" height="17" uuid="a5e52468-0ed0-4dfb-a305-2873273101c0"/> <reportElement x="690" y="0" width="108" height="17" uuid="a5e52468-0ed0-4dfb-a305-2873273101c0"/>


Cargando…
Cancelar
Guardar