| @@ -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 { | ||||
| @@ -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" | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -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> | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -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, | |||||
| ) | |||||
| } | |||||
| } | } | ||||
| } | } | ||||
| @@ -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) | ||||
| } | } | ||||
| } | } | ||||
| @@ -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, | |||||
| ) | |||||
| @@ -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, | |||||
| ) | |||||
| @@ -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, | |||||
| ) | ) | ||||
| @@ -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 ")})" | ||||
| } | } | ||||
| } | |||||
| } | |||||
| @@ -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() | |||||
| } | |||||
| } | } | ||||
| @@ -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() | ||||
| } | } | ||||
| } | } | ||||
| @@ -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 | ||||
| } | } | ||||
| @@ -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`) | |||||
| ); | |||||
| @@ -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"/> | ||||