| @@ -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.MailTemplateRequest | |||
| 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.service.InventoryLotService | |||
| import com.itextpdf.html2pdf.ConverterProperties | |||
| @@ -14,6 +15,7 @@ import org.jsoup.Jsoup | |||
| import org.springframework.stereotype.Service | |||
| import java.io.ByteArrayOutputStream | |||
| import java.math.BigDecimal | |||
| import java.time.LocalDateTime | |||
| import java.time.format.DateTimeFormatter | |||
| import kotlin.jvm.optionals.getOrNull | |||
| @@ -33,6 +35,19 @@ open class MailTemplateService( | |||
| 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> { | |||
| return mailTemplateRepository.findAllByDeletedIsFalse(); | |||
| } | |||
| @@ -166,19 +181,21 @@ open class MailTemplateService( | |||
| val filteredResult = qcResults | |||
| .groupBy { Pair(it.stockInLineId, it.qcItemId) } | |||
| .mapValues { (_, group) -> | |||
| group.maxByOrNull { it.recordDate } | |||
| group.maxByOrNull { it.recordDate ?: LocalDateTime.MIN } | |||
| } | |||
| .values | |||
| .filterNotNull() | |||
| .filter { !it.qcPassed } | |||
| 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 element = tempDoc.appendElement("ul") | |||
| for (result in filteredResult) { | |||
| element.appendElement("li") | |||
| .text("${result.code} - ${result.description}") | |||
| .text(formatFailedQcResultLine(result)) | |||
| } | |||
| tempDoc.outerHtml() | |||
| } 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.web.models.MessageResponse | |||
| 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.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.stock.entity.StockInLineRepository | |||
| import com.ffii.fpsms.modules.stock.entity.StockOutLIneRepository | |||
| @@ -19,6 +21,8 @@ import java.io.IOException | |||
| open class QcResultService( | |||
| private val jdbcDao: JdbcDao, | |||
| private val qcResultRepository: QcResultRepository, | |||
| private val qcResultMeasurementRepository: QcResultMeasurementRepository, | |||
| private val qcResultMeasurementService: QcResultMeasurementService, | |||
| private val qcItemRepository: QcItemRepository, | |||
| private val itemRepository: ItemsRepository, | |||
| private val stockInLineRepository: StockInLineRepository, | |||
| @@ -45,6 +49,9 @@ open class QcResultService( | |||
| this.qcPassed = request.qcPassed | |||
| } | |||
| val savedQcResult = saveAndFlush(qcResult) | |||
| stockInLine?.let { sil -> | |||
| qcResultMeasurementService.syncMeasurement(request, savedQcResult, sil) | |||
| } | |||
| return MessageResponse( | |||
| id = savedQcResult.id, | |||
| 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 | |||
| 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.web.model.QcResultInfoResponse | |||
| import com.ffii.fpsms.modules.qc.web.model.SaveQcResultRequest | |||
| import jakarta.validation.Valid | |||
| import org.springframework.web.bind.annotation.* | |||
| @@ -19,12 +19,12 @@ class QcResultController( | |||
| } | |||
| @GetMapping("/{stockInLineId}") | |||
| fun getAllQcResultInfoByStockInLineId(@PathVariable stockInLineId: Long): List<QcResultInfo> { | |||
| fun getAllQcResultInfoByStockInLineId(@PathVariable stockInLineId: Long): List<QcResultInfoResponse> { | |||
| return qcResultService.getAllQcResultInfoByStockInLineId(stockInLineId) | |||
| } | |||
| @GetMapping("/pick-order/{stockOutLineId}") | |||
| fun getAllQcResultInfoByStockOutLineId(@PathVariable stockOutLineId: Long): List<QcResultInfo> { | |||
| fun getAllQcResultInfoByStockOutLineId(@PathVariable stockOutLineId: Long): List<QcResultInfoResponse> { | |||
| 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 type: String?, | |||
| val remarks: String?, | |||
| val measurement: SaveQcMeasurementRequest? = null, | |||
| ) | |||
| @@ -8,15 +8,30 @@ open class ItemQcFailReportService( | |||
| private val jdbcDao: JdbcDao, | |||
| ) { | |||
| 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>> { | |||
| 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 stockCategorySql = buildMultiValueExactClause(stockCategory, "it.type", "stockCategory", 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 formattedDate = lastInDateStart.replace("/", "-") | |||
| @@ -39,10 +54,8 @@ open class ItemQcFailReportService( | |||
| COALESCE(sil.lotNo, il.lotNo, '') AS lotNo, | |||
| 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, | |||
| /* QC Template Used:取 qc_category.code (例如 A1/B4/X1) */ | |||
| COALESCE( | |||
| qc.name, | |||
| (SELECT name | |||
| @@ -50,33 +63,37 @@ open class ItemQcFailReportService( | |||
| WHERE isDefault = 1 | |||
| AND deleted = 0 | |||
| LIMIT 1), | |||
| qc.name, | |||
| '' | |||
| ) AS qcTemplate, | |||
| /* QC Criteria with Defect:优先用 qc_item_category.description,否则用 qc_item */ | |||
| 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, | |||
| /* 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 | |||
| WHEN sil.jobOrderId IS NOT NULL THEN COALESCE(jo.code, '') | |||
| ELSE COALESCE(po.code, '') | |||
| END AS orderRefNo | |||
| END AS orderRefNo, | |||
| qr.qcPassed AS qcPassed | |||
| FROM qc_result qr | |||
| INNER JOIN stock_in_line sil | |||
| @@ -85,6 +102,9 @@ open class ItemQcFailReportService( | |||
| INNER JOIN items it | |||
| ON qr.itemId = it.id | |||
| AND it.deleted = 0 | |||
| LEFT JOIN qc_result_measurement qrm | |||
| ON qrm.qcResultId = qr.id | |||
| AND qrm.deleted = 0 | |||
| LEFT JOIN item_uom iu | |||
| ON it.id = iu.itemId | |||
| AND iu.stockUnit = 1 | |||
| @@ -105,19 +125,19 @@ open class ItemQcFailReportService( | |||
| ON sil.jobOrderId = jo.id | |||
| AND jo.deleted = 0 | |||
| LEFT JOIN items_qc_category_mapping iqcm | |||
| LEFT JOIN items_qc_category_mapping iqcm | |||
| 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 | |||
| ON qc.id = iqcm.qcCategoryId | |||
| AND qc.deleted = 0 | |||
| AND qc.deleted = 0 | |||
| LEFT JOIN qc_item qi | |||
| ON qi.id = qr.qcItemId | |||
| AND qi.deleted = 0 | |||
| AND qi.deleted = 0 | |||
| LEFT JOIN qc_item_category qic | |||
| ON qic.qcItemId = qi.id | |||
| AND qic.qcCategoryId = COALESCE( | |||
| AND qic.qcCategoryId = COALESCE( | |||
| iqcm.qcCategoryId, | |||
| (SELECT id | |||
| FROM qc_category | |||
| @@ -128,13 +148,16 @@ open class ItemQcFailReportService( | |||
| WHERE | |||
| qr.deleted = 0 | |||
| AND qr.qcPassed = 0 | |||
| $qcItemScopeSql | |||
| $measuredValueSql | |||
| $qcTypeSql | |||
| $stockCategorySql | |||
| $itemCodeSql | |||
| $lastInDateStartSql | |||
| $lastInDateEndSql | |||
| ORDER BY | |||
| qr.qcPassed DESC, | |||
| it.type, | |||
| it.code, | |||
| COALESCE(sil.lotNo, il.lotNo, ''), | |||
| @@ -142,9 +165,58 @@ open class ItemQcFailReportService( | |||
| COALESCE(qic.`order`, 9999), | |||
| COALESCE(qi.code, '') | |||
| """.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( | |||
| @@ -182,4 +254,4 @@ open class ItemQcFailReportService( | |||
| } | |||
| return "AND (${conditions.joinToString(" OR ")})" | |||
| } | |||
| } | |||
| } | |||
| @@ -1716,5 +1716,24 @@ return result | |||
| val jasperPrint = JasperFillManager.fillReport(jasperReport, params, dataSource) | |||
| 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.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.ReportService | |||
| 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.IndexedColors | |||
| 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.util.WorkbookUtil | |||
| import org.apache.poi.xssf.usermodel.XSSFWorkbook | |||
| @@ -32,43 +27,47 @@ class ItemQcFailReportController( | |||
| @RequestParam(required = false) itemCode: String?, | |||
| @RequestParam(required = false) lastInDateStart: 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> { | |||
| 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( | |||
| stockCategory = stockCategory, | |||
| itemCode = itemCode, | |||
| lastInDateStart = lastInDateStart, | |||
| 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") | |||
| @@ -77,40 +76,117 @@ class ItemQcFailReportController( | |||
| @RequestParam(required = false) itemCode: String?, | |||
| @RequestParam(required = false) lastInDateStart: 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> { | |||
| val dbData = itemQcFailReportService.searchItemQcFailReport( | |||
| stockCategory = stockCategory, | |||
| itemCode = itemCode, | |||
| lastInDateStart = lastInDateStart, | |||
| 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, | |||
| 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" | |||
| ) | |||
| 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?, | |||
| lastInDateEnd: String? | |||
| lastInDateEnd: String?, | |||
| qcType: String?, | |||
| ): ByteArray { | |||
| 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) | |||
| var rowIndex = 0 | |||
| @@ -126,14 +202,12 @@ class ItemQcFailReportController( | |||
| } | |||
| titleStyle.setFont(titleFont) | |||
| // Title row | |||
| run { | |||
| val titleRowIndex = rowIndex++ | |||
| val row = sheet.createRow(titleRowIndex) | |||
| val cell = row.createCell(0) | |||
| cell.setCellValue(reportTitle) | |||
| cell.setCellValue("$reportTitle ($sheetLabel)") | |||
| cell.cellStyle = titleStyle | |||
| // Merge title across columns so the text won't be blocked by narrow col A. | |||
| sheet.addMergedRegion( | |||
| org.apache.poi.ss.util.CellRangeAddress( | |||
| titleRowIndex, | |||
| @@ -145,13 +219,13 @@ class ItemQcFailReportController( | |||
| row.heightInPoints = 24f | |||
| } | |||
| // Info row | |||
| run { | |||
| val row = sheet.createRow(rowIndex++) | |||
| val cell = row.createCell(0) | |||
| val startTxt = lastInDateStart ?: "" | |||
| val endTxt = lastInDateEnd ?: "" | |||
| cell.setCellValue("QC 不合格日期: ${startTxt} - ${endTxt}") | |||
| val qcTypeTxt = resolveQcTypeLabel(qcType) | |||
| cell.setCellValue("QC 檢測日期: ${startTxt} - ${endTxt} QC 類型: $qcTypeTxt") | |||
| } | |||
| val headerStyle = workbook.createCellStyle().apply { | |||
| @@ -218,15 +292,14 @@ class ItemQcFailReportController( | |||
| "到期日", | |||
| "QC 類型", | |||
| "QC 範本", | |||
| "不合格標準", | |||
| "檢測標準", | |||
| "批量", | |||
| "不合格數量", | |||
| "參考資料", | |||
| "實測值", | |||
| "備註", | |||
| "訂單/工單" | |||
| ) | |||
| // Header row | |||
| run { | |||
| val row = sheet.createRow(rowIndex++) | |||
| headers.forEachIndexed { col, h -> | |||
| @@ -234,12 +307,12 @@ class ItemQcFailReportController( | |||
| cell.setCellValue(h) | |||
| cell.cellStyle = headerStyle | |||
| } | |||
| // Increase header row height so wrapped header text won't be blocked/truncated. | |||
| row.heightInPoints = 35f | |||
| } | |||
| dbData.forEach { r -> | |||
| val row = sheet.createRow(rowIndex++) | |||
| fun writeText(col: Int, value: Any?) { | |||
| val cell = row.createCell(col) | |||
| cell.setCellValue(value?.toString() ?: "") | |||
| @@ -248,7 +321,6 @@ class ItemQcFailReportController( | |||
| fun writeNumber(col: Int, value: Any?) { | |||
| val raw = value?.toString()?.trim() ?: "" | |||
| // Some SQL strings may end with "."; Excel would display it as "4." otherwise. | |||
| val cleaned = raw.removeSuffix(".") | |||
| val bd = cleaned.toBigDecimalOrNull() | |||
| val cell = row.createCell(col) | |||
| @@ -259,7 +331,6 @@ class ItemQcFailReportController( | |||
| } else { | |||
| val stripped = bd.stripTrailingZeros() | |||
| if (stripped.scale() <= 0) { | |||
| // Render integer without decimal dot. | |||
| cell.setCellValue(stripped.toDouble()) | |||
| cell.cellStyle = integerNumberStyle | |||
| } else { | |||
| @@ -269,7 +340,6 @@ class ItemQcFailReportController( | |||
| } | |||
| } | |||
| // Keys must match ItemQcFailReportService SQL aliases | |||
| writeText(0, r["stockSubCategory"]) | |||
| writeText(1, r["itemNo"]) | |||
| writeText(2, r["itemName"]) | |||
| @@ -279,7 +349,6 @@ class ItemQcFailReportController( | |||
| writeText(6, r["qcType"]) | |||
| writeText(7, r["qcTemplate"]) | |||
| val defectCriteria = r["qcDefectCriteria"] | |||
| // Wrap this column because values can be long. | |||
| run { | |||
| val cell = row.createCell(8) | |||
| cell.setCellValue(defectCriteria?.toString() ?: "") | |||
| @@ -287,49 +356,25 @@ class ItemQcFailReportController( | |||
| } | |||
| writeNumber(9, r["lotQty"]) | |||
| writeNumber(10, r["defectQty"]) | |||
| writeText(11, r["refData"]) | |||
| writeText(11, r["measuredValue"]) | |||
| writeText(12, r["remark"]) | |||
| writeText(13, r["orderRefNo"]) | |||
| // If "不合格標準" is long, increase row height so wrapped text is visible. | |||
| 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 lines = maxOf( | |||
| 1, | |||
| 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) | |||
| } | |||
| // 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 { | |||
| @@ -339,5 +384,3 @@ class ItemQcFailReportController( | |||
| 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.qc.entity.QcResult | |||
| 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.sql.StockSql.SQL.INVENTORY_COUNT | |||
| import net.sf.jasperreports.engine.JasperCompileManager | |||
| @@ -82,6 +83,7 @@ open class StockInLineService( | |||
| private val polRepository: PurchaseOrderLineRepository, | |||
| private val qcItemsRepository: QcItemRepository, | |||
| private val qcResultRepository: QcResultRepository, | |||
| private val qcResultMeasurementService: QcResultMeasurementService, | |||
| private val escalationLogService: EscalationLogService, | |||
| private val escalationLogRepository: EscalationLogRepository, | |||
| private val stockInService: StockInService, | |||
| @@ -514,7 +516,6 @@ open class StockInLineService( | |||
| val qcResultEntries = request.qcResult!!.map { | |||
| val qcItem = qcItemsRepository.findById(it.qcItemId).orElseThrow() | |||
| val item = itemRepository.findById(stockInLine.item!!.id!!).orElseThrow() | |||
| val qcResult = QcResult(); | |||
| QcResult().apply { | |||
| this.qcItem = qcItem | |||
| @@ -527,7 +528,11 @@ open class StockInLineService( | |||
| 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 | |||
| } | |||
| @@ -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"> | |||
| <parameterDescription><![CDATA["lastOutDateStart"]]></parameterDescription> | |||
| </parameter> | |||
| <parameter name="reportSection" class="java.lang.String"> | |||
| <defaultValueExpression><![CDATA[""]]></defaultValueExpression> | |||
| </parameter> | |||
| <parameter name="qcTypeLabel" class="java.lang.String"> | |||
| <defaultValueExpression><![CDATA["全部"]]></defaultValueExpression> | |||
| </parameter> | |||
| <queryString> | |||
| <![CDATA[]]> | |||
| </queryString> | |||
| @@ -48,11 +54,12 @@ | |||
| <field name="qcDefectCriteria" class="java.lang.String"/> | |||
| <field name="lotQty" 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="orderRefNo" class="java.lang.String"/> | |||
| <field name="stockSubCategory" class="java.lang.String"/> | |||
| <group name="Group1" keepTogether="true"> | |||
| <groupExpression><![CDATA[$F{itemNo}]]></groupExpression> | |||
| <groupHeader> | |||
| <band height="18"> | |||
| <property name="com.jaspersoft.studio.layout" value="com.jaspersoft.studio.editor.layout.FreeLayout"/> | |||
| @@ -109,7 +116,7 @@ | |||
| <textElement textAlignment="Center" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10"/> | |||
| </textElement> | |||
| <text><![CDATA[備注]]></text> | |||
| <text><![CDATA[實測值]]></text> | |||
| </staticText> | |||
| <staticText> | |||
| <reportElement x="90" y="80" width="70" height="18" uuid="7db4a800-8573-408c-baad-f4f4885625c9"> | |||
| @@ -271,7 +278,7 @@ | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="12" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[最後入倉日期:]]></text> | |||
| <text><![CDATA[QC 檢測日期:]]></text> | |||
| </staticText> | |||
| <textField> | |||
| <reportElement x="100" y="53" width="560" height="23" uuid="96afd73f-53ed-42cd-9911-fdaac017d65b"/> | |||
| @@ -280,6 +287,27 @@ | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$P{lastInDateStart} + " 至 " + $P{lastInDateEnd}]]></textFieldExpression> | |||
| </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> | |||
| </pageHeader> | |||
| <detail> | |||
| @@ -331,7 +359,7 @@ | |||
| <textElement textAlignment="Center" verticalAlignment="Top"> | |||
| <font fontName="微軟正黑體"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{refData}]]></textFieldExpression> | |||
| <textFieldExpression><![CDATA[$F{measuredValue}]]></textFieldExpression> | |||
| </textField> | |||
| <textField textAdjust="StretchHeight"> | |||
| <reportElement x="690" y="0" width="108" height="17" uuid="a5e52468-0ed0-4dfb-a305-2873273101c0"/> | |||