| @@ -2,6 +2,9 @@ package com.ffii.fpsms.modules.report.service | |||
| import org.springframework.stereotype.Service | |||
| import com.ffii.core.support.JdbcDao | |||
| import java.math.BigDecimal | |||
| import java.time.LocalDate | |||
| import java.time.LocalDateTime | |||
| @Service | |||
| class SemiFGProductionAnalysisReportService( | |||
| @@ -31,6 +34,12 @@ class SemiFGProductionAnalysisReportService( | |||
| return "AND (${conditions.joinToString(" OR ")})" | |||
| } | |||
| private fun categoryClause(stockCategory: String?, paramPrefix: String, args: MutableMap<String, Any>): String { | |||
| val types = resolveSemiFgCategoryTypes(stockCategory) | |||
| if (types.isEmpty()) return "" | |||
| return buildMultiValueExactClause(types.joinToString(","), "it.type", paramPrefix, args) | |||
| } | |||
| /** | |||
| * Helper function to build SQL clause for comma-separated values with exact match. | |||
| * Supports multiple values like "val1, val2, val3" and generates OR conditions with =. | |||
| @@ -56,164 +65,134 @@ class SemiFGProductionAnalysisReportService( | |||
| } | |||
| /** | |||
| * Queries the database for Semi FG Production Analysis Report data. | |||
| * Aligned with [ReportService.searchStockInTraceabilityReport] totals for the same filters: | |||
| * - stock_in_line driven (no stock_ledger gate); INNER JOIN bom so only items that exist as BOM rows appear | |||
| * - Include only stock_in_line rows with non-null jobOrderId | |||
| * - Exclude stock_in_line rows with status = 'Pending' | |||
| * - stockCategory → items.type (exact, comma-separated); itemCode → items.code (LIKE, comma-separated) | |||
| * - Date path: | |||
| * stock_in_line.productLotNo (must be present) -> | |||
| * stock_in_line.inventoryLotId -> inventory_lot.id -> | |||
| * inventory_lot.stockInDate (used for month/year/date filters) | |||
| * - Quantity source: stock_in_line.acceptedQty | |||
| * - QC any fail → line qty 0 (same as traceability stockInQty) | |||
| * - One row per stockInLineId per month before pivot; all lines counted (not only job orders) | |||
| * Put-away lines behind the FG / Semi-FG production analysis report. | |||
| * Same inclusion rules as the previous yearly pivot: | |||
| * BOM item, product lot present, job order present, status not Pending, | |||
| * QC fail forces the line quantity to 0. | |||
| * The date is inventory_lot.stockInDate when the product lot is present. | |||
| */ | |||
| fun searchSemiFGProductionAnalysisReport( | |||
| stockCategory: String?, | |||
| stockSubCategory: String?, | |||
| itemCode: String?, | |||
| year: String?, | |||
| lastOutDateStart: String?, | |||
| lastOutDateEnd: String? | |||
| ): List<Map<String, Any>> { | |||
| val args = mutableMapOf<String, Any>() | |||
| val stockCategorySql = if (!itemCode.isNullOrBlank()) { | |||
| "" | |||
| } else if (!stockCategory.isNullOrBlank() && stockCategory != "All" && !stockCategory.contains("All")) { | |||
| buildMultiValueExactClause(stockCategory, "it.type", "semiSc", args) | |||
| } else { | |||
| "" | |||
| } | |||
| val stockSubCategorySql = buildMultiValueLikeClause(stockSubCategory, "ic.sub", "stockSubCategory", args) | |||
| val itemCodeSql = buildMultiValueLikeClause(itemCode, "it.code", "semiItem", args) | |||
| val yearSql = if (!year.isNullOrBlank() && year != "All") { | |||
| args["year"] = year | |||
| "AND YEAR(CASE WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate ELSE si.productionDate END) = :year" | |||
| } else { | |||
| "" | |||
| } | |||
| val lastOutDateStartSql = if (!lastOutDateStart.isNullOrBlank()) { | |||
| val formattedDate = lastOutDateStart.replace("/", "-") | |||
| args["lastOutDateStart"] = formattedDate | |||
| "AND DATE(CASE WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate ELSE si.productionDate END) >= DATE(:lastOutDateStart)" | |||
| } else "" | |||
| val lastOutDateEndSql = if (!lastOutDateEnd.isNullOrBlank()) { | |||
| val formattedDate = lastOutDateEnd.replace("/", "-") | |||
| args["lastOutDateEnd"] = formattedDate | |||
| "AND DATE(CASE WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate ELSE si.productionDate END) <= DATE(:lastOutDateEnd)" | |||
| } else "" | |||
| fun loadReport(filter: SemiFgReportFilter, today: LocalDate = LocalDate.now()): SemiFgReportData { | |||
| val window = SemiFgProductionAnalysisLayout.resolveWindow(filter, today) | |||
| val args = mutableMapOf<String, Any>( | |||
| "rangeStart" to window.start.toString(), | |||
| "rangeEnd" to window.endInclusive.toString(), | |||
| ) | |||
| val stockCategorySql = categoryClause(filter.stockCategory, "semiSc", args) | |||
| val stockSubCategorySql = buildMultiValueLikeClause(filter.stockSubCategory, "ic.sub", "stockSubCategory", args) | |||
| val itemCodeSql = buildSemiFgItemCodeClause(filter.itemCode, "it.code", "semiItem", args) | |||
| val prodDateExpr = """ | |||
| CASE | |||
| WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate | |||
| ELSE si.productionDate | |||
| END | |||
| """.trimIndent() | |||
| val sql = """ | |||
| WITH qr_agg AS ( | |||
| SELECT | |||
| qr.stockInLineId, | |||
| MAX(CASE WHEN qr.qcPassed = 0 THEN 1 ELSE 0 END) AS qcFailed | |||
| FROM qc_result qr | |||
| WHERE qr.deleted = 0 | |||
| GROUP BY qr.stockInLineId | |||
| ), | |||
| base AS ( | |||
| SELECT | |||
| MAX(base.itemNo) AS itemNo, | |||
| MAX(base.itemName) AS itemName, | |||
| MAX(base.unitOfMeasure) AS unitOfMeasure, | |||
| MAX(base.prodDate) AS prodDate, | |||
| MAX(base.jobOrderCode) AS jobOrderCode, | |||
| MAX(base.productLotNo) AS productLotNo, | |||
| base.stockInLineId AS stockInLineId, | |||
| MAX(base.linePutAwayQty) AS qty, | |||
| MAX(base.qcFailed) AS qcFailed | |||
| FROM ( | |||
| SELECT | |||
| COALESCE(it.code, '') AS itemNo, | |||
| COALESCE(it.name, '') AS itemName, | |||
| COALESCE(ic.sub, '') AS stockSubCategory, | |||
| COALESCE(uc.udfudesc, '') AS unitOfMeasure, | |||
| MONTH( | |||
| CASE | |||
| WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate | |||
| ELSE si.productionDate | |||
| END | |||
| ) AS mon, | |||
| DATE($prodDateExpr) AS prodDate, | |||
| COALESCE(jo.code, '') AS jobOrderCode, | |||
| COALESCE(si.productLotNo, '') AS productLotNo, | |||
| si.id AS stockInLineId, | |||
| CASE WHEN COALESCE(qr_agg.qcFailed, 0) = 1 THEN 0 | |||
| ELSE COALESCE(si.acceptedQty, 0) | |||
| END AS linePutAwayQty | |||
| END AS linePutAwayQty, | |||
| COALESCE(qr_agg.qcFailed, 0) AS qcFailed | |||
| FROM stock_in_line si | |||
| INNER JOIN items it ON si.itemId = it.id | |||
| INNER JOIN bom b ON b.code = it.code AND b.deleted = false | |||
| LEFT JOIN inventory_lot il ON il.id = si.inventoryLotId AND il.deleted = false | |||
| LEFT JOIN qr_agg ON qr_agg.stockInLineId = si.id | |||
| LEFT JOIN ( | |||
| SELECT | |||
| qr.stockInLineId, | |||
| MAX(CASE WHEN qr.qcPassed = 0 THEN 1 ELSE 0 END) AS qcFailed | |||
| FROM qc_result qr | |||
| WHERE qr.deleted = 0 | |||
| GROUP BY qr.stockInLineId | |||
| ) qr_agg ON qr_agg.stockInLineId = si.id | |||
| LEFT JOIN job_order jo ON jo.id = si.jobOrderId AND jo.deleted = false | |||
| LEFT JOIN item_category ic ON it.categoryId = ic.id | |||
| LEFT JOIN item_uom iu ON it.id = iu.itemId AND iu.stockUnit = true | |||
| LEFT JOIN uom_conversion uc ON iu.uomId = uc.id | |||
| WHERE si.deleted = false | |||
| AND si.productLotNo IS NOT NULL | |||
| AND TRIM(si.productLotNo) <> '' | |||
| AND ( | |||
| CASE | |||
| WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate | |||
| ELSE si.productionDate | |||
| END | |||
| ) IS NOT NULL | |||
| AND ($prodDateExpr) IS NOT NULL | |||
| AND si.jobOrderId IS NOT NULL | |||
| AND (si.status IS NULL OR si.status <> 'Pending') | |||
| AND DATE($prodDateExpr) >= DATE(:rangeStart) | |||
| AND DATE($prodDateExpr) <= DATE(:rangeEnd) | |||
| $stockCategorySql | |||
| $stockSubCategorySql | |||
| $itemCodeSql | |||
| $yearSql | |||
| $lastOutDateStartSql | |||
| $lastOutDateEndSql | |||
| ), | |||
| dedup AS ( | |||
| SELECT | |||
| itemNo, | |||
| itemName, | |||
| stockSubCategory, | |||
| unitOfMeasure, | |||
| mon, | |||
| stockInLineId, | |||
| MAX(linePutAwayQty) AS linePutAwayQty | |||
| FROM base | |||
| GROUP BY itemNo, itemName, stockSubCategory, unitOfMeasure, mon, stockInLineId | |||
| ) | |||
| SELECT | |||
| MAX(d.stockSubCategory) AS stockSubCategory, | |||
| d.itemNo AS itemNo, | |||
| MAX(d.itemName) AS itemName, | |||
| MAX(d.unitOfMeasure) AS unitOfMeasure, | |||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 1 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyJan, | |||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 2 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyFeb, | |||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 3 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyMar, | |||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 4 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyApr, | |||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 5 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyMay, | |||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 6 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyJun, | |||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 7 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyJul, | |||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 8 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyAug, | |||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 9 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtySep, | |||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 10 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyOct, | |||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 11 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyNov, | |||
| CAST(COALESCE(SUM(CASE WHEN d.mon = 12 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyDec, | |||
| CAST(COALESCE(SUM(d.linePutAwayQty), 0) AS CHAR) AS totalProductionQty | |||
| FROM dedup d | |||
| GROUP BY d.itemNo | |||
| HAVING COALESCE(SUM(d.linePutAwayQty), 0) > 0 | |||
| ORDER BY d.itemNo | |||
| ) base | |||
| GROUP BY base.stockInLineId | |||
| ORDER BY itemNo, prodDate, jobOrderCode, productLotNo | |||
| """.trimIndent() | |||
| return jdbcDao.queryForList(sql, args) | |||
| val lines = jdbcDao.queryForList(sql, args).mapNotNull { row -> toLine(row) } | |||
| return SemiFgReportData(window, lines) | |||
| } | |||
| private fun toLine(row: Map<String, Any?>): SemiFgLine? { | |||
| val prodDate = asLocalDate(row["prodDate"]) ?: return null | |||
| val itemNo = row["itemNo"]?.toString()?.trim().orEmpty() | |||
| if (itemNo.isEmpty()) return null | |||
| return SemiFgLine( | |||
| prodDate = prodDate, | |||
| itemNo = itemNo, | |||
| itemName = row["itemName"]?.toString().orEmpty(), | |||
| unitOfMeasure = row["unitOfMeasure"]?.toString().orEmpty(), | |||
| jobOrderCode = row["jobOrderCode"]?.toString().orEmpty(), | |||
| productLotNo = row["productLotNo"]?.toString().orEmpty(), | |||
| qty = asBigDecimal(row["qty"]), | |||
| qcFailed = asBoolean(row["qcFailed"]), | |||
| ) | |||
| } | |||
| private fun asLocalDate(value: Any?): LocalDate? = when (value) { | |||
| is LocalDate -> value | |||
| is LocalDateTime -> value.toLocalDate() | |||
| is java.sql.Date -> value.toLocalDate() | |||
| is java.sql.Timestamp -> value.toLocalDateTime().toLocalDate() | |||
| is String -> runCatching { LocalDate.parse(value.take(10)) }.getOrNull() | |||
| else -> null | |||
| } | |||
| private fun asBigDecimal(value: Any?): BigDecimal = when (value) { | |||
| null -> BigDecimal.ZERO | |||
| is BigDecimal -> value | |||
| is Number -> BigDecimal(value.toString()) | |||
| else -> value.toString().replace(",", "").toBigDecimalOrNull() ?: BigDecimal.ZERO | |||
| } | |||
| private fun asBoolean(value: Any?): Boolean = when (value) { | |||
| is Boolean -> value | |||
| is Number -> value.toInt() != 0 | |||
| is String -> value == "1" || value.equals("true", ignoreCase = true) | |||
| else -> false | |||
| } | |||
| /** | |||
| * Gets list of item codes (bom.code) with names based on stockCategory filter. | |||
| * Supports multiple categories separated by comma (e.g., "FG,WIP"). | |||
| * If stockCategory is "All" or null, returns all codes. | |||
| * If stockCategory is "FG" or "WIP" or "FG,WIP", returns codes matching those descriptions. | |||
| * Returns a list of maps with "code" and "name" keys. | |||
| * Item codes on a BOM, with names. All (or a blank category) is FG and WIP. | |||
| * Material is `mat`. An item code typed later is applied on top of this category. | |||
| */ | |||
| fun getSemiFGItemCodes(stockCategory: String?): List<Map<String, String>> { | |||
| val args = mutableMapOf<String, Any>() | |||
| val stockCategorySql = if (!stockCategory.isNullOrBlank() && stockCategory != "All" && !stockCategory.contains("All")) { | |||
| buildMultiValueExactClause(stockCategory, "it.type", "semiFgCodesSc", args) | |||
| } else { | |||
| "" | |||
| } | |||
| val stockCategorySql = categoryClause(stockCategory, "semiFgCodesSc", args) | |||
| val sql = """ | |||
| SELECT DISTINCT b.code, COALESCE(it.name, b.name, '') AS name | |||
| @@ -239,18 +218,11 @@ class SemiFGProductionAnalysisReportService( | |||
| } | |||
| /** | |||
| * Gets list of item codes with their category (FG/WIP) and name based on stockCategory filter. | |||
| * Supports multiple categories separated by comma (e.g., "FG,WIP"). | |||
| * Returns a list of maps with "code", "category", and "name" keys. | |||
| * Item codes with type and name. All (or a blank category) is FG and WIP. | |||
| */ | |||
| fun getSemiFGItemCodesWithCategory(stockCategory: String?): List<Map<String, String>> { | |||
| val args = mutableMapOf<String, Any>() | |||
| val stockCategorySql = if (!stockCategory.isNullOrBlank() && stockCategory != "All" && !stockCategory.contains("All")) { | |||
| buildMultiValueExactClause(stockCategory, "it.type", "semiFgCodesCatSc", args) | |||
| } else { | |||
| "" | |||
| } | |||
| val stockCategorySql = categoryClause(stockCategory, "semiFgCodesCatSc", args) | |||
| val sql = """ | |||
| SELECT DISTINCT b.code, COALESCE(it.type, '') AS category, COALESCE(it.name, b.name, '') AS name | |||
| @@ -276,3 +248,49 @@ class SemiFGProductionAnalysisReportService( | |||
| } | |||
| } | |||
| } | |||
| /** All, blank, or no category means finished goods and semi-finished goods. Material is `mat`. */ | |||
| internal fun resolveSemiFgCategoryTypes(stockCategory: String?): List<String> { | |||
| val selected = ReportMultiValueTokens.split(stockCategory) | |||
| val explicit = selected | |||
| .filter { !it.equals("All", ignoreCase = true) } | |||
| .map { normalizeSemiFgType(it) } | |||
| .distinct() | |||
| val wantsAll = selected.isEmpty() || selected.any { it.equals("All", ignoreCase = true) } | |||
| return when { | |||
| wantsAll && explicit.isEmpty() -> listOf("FG", "WIP") | |||
| wantsAll -> (listOf("FG", "WIP") + explicit).distinct() | |||
| else -> explicit | |||
| } | |||
| } | |||
| private fun normalizeSemiFgType(token: String): String = when { | |||
| token.equals("mat", ignoreCase = true) || token.equals("material", ignoreCase = true) || token == "材料" -> "mat" | |||
| token.equals("fg", ignoreCase = true) -> "FG" | |||
| token.equals("wip", ignoreCase = true) -> "WIP" | |||
| else -> token | |||
| } | |||
| /** | |||
| * A full item code matches that code only. Tokens that already contain % or _ stay as LIKE. | |||
| */ | |||
| internal fun buildSemiFgItemCodeClause( | |||
| itemCode: String?, | |||
| columnName: String, | |||
| paramPrefix: String, | |||
| args: MutableMap<String, Any>, | |||
| ): String { | |||
| val tokens = ReportMultiValueTokens.split(itemCode) | |||
| if (tokens.isEmpty()) return "" | |||
| val conditions = tokens.mapIndexed { index, value -> | |||
| val name = "${paramPrefix}_$index" | |||
| if ('%' in value || '_' in value) { | |||
| args[name] = value | |||
| "$columnName LIKE :$name" | |||
| } else { | |||
| args[name] = value.uppercase() | |||
| "UPPER($columnName) = :$name" | |||
| } | |||
| } | |||
| return "AND (${conditions.joinToString(" OR ")})" | |||
| } | |||
| @@ -0,0 +1,475 @@ | |||
| package com.ffii.fpsms.modules.report.service | |||
| import java.math.BigDecimal | |||
| import java.math.RoundingMode | |||
| import java.time.DayOfWeek | |||
| import java.time.LocalDate | |||
| import java.time.YearMonth | |||
| import java.time.format.DateTimeFormatter | |||
| import java.time.format.DateTimeParseException | |||
| import java.time.temporal.TemporalAdjusters | |||
| enum class SemiFgView { | |||
| DAY, | |||
| WEEK, | |||
| MONTH, | |||
| YEAR, | |||
| RANGE, | |||
| } | |||
| class SemiFgReportRequestException(message: String) : IllegalArgumentException(message) | |||
| data class SemiFgReportFilter( | |||
| val view: String? = null, | |||
| val stockCategory: String? = null, | |||
| val stockSubCategory: String? = null, | |||
| val itemCode: String? = null, | |||
| val year: String? = null, | |||
| val lastOutDateStart: String? = null, | |||
| val lastOutDateEnd: String? = null, | |||
| val reportDate: String? = null, | |||
| val reportMonth: String? = null, | |||
| val lang: String? = null, | |||
| val reportWeek: String? = null, | |||
| ) | |||
| data class SemiFgLine( | |||
| val prodDate: LocalDate, | |||
| val itemNo: String, | |||
| val itemName: String, | |||
| val unitOfMeasure: String, | |||
| val jobOrderCode: String, | |||
| val productLotNo: String, | |||
| val qty: BigDecimal, | |||
| val qcFailed: Boolean, | |||
| ) | |||
| data class SemiFgItem( | |||
| val itemNo: String, | |||
| val itemName: String, | |||
| val unitOfMeasure: String, | |||
| ) | |||
| data class SemiFgWindow( | |||
| val view: SemiFgView, | |||
| val start: LocalDate, | |||
| val endInclusive: LocalDate, | |||
| val periodLabel: String, | |||
| val day: LocalDate? = null, | |||
| val month: YearMonth? = null, | |||
| val year: Int? = null, | |||
| val periods: List<YearMonth> = emptyList(), | |||
| /** Set for a custom range of 31 days or fewer. Each entry is one summary column. */ | |||
| val dayColumns: List<LocalDate> = emptyList(), | |||
| ) | |||
| data class SemiFgReportData( | |||
| val window: SemiFgWindow, | |||
| val lines: List<SemiFgLine>, | |||
| ) { | |||
| val view: SemiFgView get() = window.view | |||
| } | |||
| data class SemiFgListRow( | |||
| val period: String, | |||
| val itemNo: String, | |||
| val itemName: String, | |||
| val unitOfMeasure: String, | |||
| val qty: BigDecimal, | |||
| ) | |||
| object SemiFgProductionAnalysisLayout { | |||
| const val MAX_RANGE_MONTHS = 24 | |||
| val MONTH_LABELS: List<String> = listOf( | |||
| "一月", "二月", "三月", "四月", "五月", "六月", | |||
| "七月", "八月", "九月", "十月", "十一月", "十二月", | |||
| ) | |||
| private val isoDate: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE | |||
| private val isoMonth: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM") | |||
| fun resolveWindow(filter: SemiFgReportFilter, today: LocalDate = LocalDate.now()): SemiFgWindow { | |||
| val explicit = filter.view?.trim()?.lowercase()?.takeIf { it.isNotEmpty() } | |||
| val view = when (explicit) { | |||
| null, "year" -> SemiFgView.YEAR | |||
| "day" -> SemiFgView.DAY | |||
| "week" -> SemiFgView.WEEK | |||
| "month" -> SemiFgView.MONTH | |||
| "range" -> SemiFgView.RANGE | |||
| else -> throw SemiFgReportRequestException("未知的檢視") | |||
| } | |||
| return when (view) { | |||
| SemiFgView.DAY -> { | |||
| val day = parseDate(filter.reportDate) | |||
| ?: parseDate(filter.lastOutDateStart)?.takeIf { it == parseDate(filter.lastOutDateEnd) } | |||
| ?: throw SemiFgReportRequestException("請選擇日期") | |||
| SemiFgWindow( | |||
| view = view, | |||
| start = day, | |||
| endInclusive = day, | |||
| periodLabel = "單日 $day", | |||
| day = day, | |||
| ) | |||
| } | |||
| SemiFgView.WEEK -> { | |||
| val anchor = parseDate(filter.reportWeek) | |||
| ?: throw SemiFgReportRequestException("請選擇日期") | |||
| val start = anchor.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) | |||
| val end = start.plusDays(6) | |||
| SemiFgWindow( | |||
| view = view, | |||
| start = start, | |||
| endInclusive = end, | |||
| periodLabel = "單週 $start 至 $end", | |||
| dayColumns = datesBetween(start, end), | |||
| ) | |||
| } | |||
| SemiFgView.MONTH -> { | |||
| val month = parseMonth(filter.reportMonth) | |||
| ?: throw SemiFgReportRequestException("請選擇月份(YYYY-MM)") | |||
| SemiFgWindow( | |||
| view = view, | |||
| start = month.atDay(1), | |||
| endInclusive = month.atEndOfMonth(), | |||
| periodLabel = "單月 $month", | |||
| month = month, | |||
| ) | |||
| } | |||
| SemiFgView.YEAR -> { | |||
| val year = parseYear(filter.year) ?: today.year | |||
| var start = LocalDate.of(year, 1, 1) | |||
| var end = LocalDate.of(year, 12, 31) | |||
| if (explicit == null) { | |||
| parseDate(filter.lastOutDateStart)?.let { if (it.isAfter(start)) start = it } | |||
| parseDate(filter.lastOutDateEnd)?.let { if (it.isBefore(end)) end = it } | |||
| } | |||
| if (end.isBefore(start)) { | |||
| end = start | |||
| } | |||
| SemiFgWindow( | |||
| view = view, | |||
| start = start, | |||
| endInclusive = end, | |||
| periodLabel = "全年 $year", | |||
| year = year, | |||
| periods = (1..12).map { YearMonth.of(year, it) }, | |||
| ) | |||
| } | |||
| SemiFgView.RANGE -> { | |||
| val start = parseDate(filter.lastOutDateStart) | |||
| ?: throw SemiFgReportRequestException("請選擇完成生產日期(由)") | |||
| val end = parseDate(filter.lastOutDateEnd) | |||
| ?: throw SemiFgReportRequestException("請選擇完成生產日期(至)") | |||
| if (start.isAfter(end)) { | |||
| throw SemiFgReportRequestException("開始日期不可晚於結束日期") | |||
| } | |||
| val months = monthsBetween(YearMonth.from(start), YearMonth.from(end)) | |||
| if (months.size > MAX_RANGE_MONTHS) { | |||
| throw SemiFgReportRequestException("自訂日期最多 $MAX_RANGE_MONTHS 個月") | |||
| } | |||
| val dayColumns = if (!end.isAfter(start.plusMonths(1))) datesBetween(start, end) else emptyList() | |||
| SemiFgWindow( | |||
| view = view, | |||
| start = start, | |||
| endInclusive = end, | |||
| periodLabel = "自訂 $start 至 $end", | |||
| periods = if (dayColumns.isEmpty()) months else emptyList(), | |||
| dayColumns = dayColumns, | |||
| ) | |||
| } | |||
| } | |||
| } | |||
| private fun monthsBetween(from: YearMonth, to: YearMonth): List<YearMonth> = | |||
| generateSequence(from) { prev -> prev.plusMonths(1).takeIf { !it.isAfter(to) } }.toList() | |||
| private fun datesBetween(start: LocalDate, end: LocalDate): List<LocalDate> = | |||
| generateSequence(start) { prev -> prev.plusDays(1).takeIf { !it.isAfter(end) } }.toList() | |||
| fun summaryItems(lines: List<SemiFgLine>, qtyDesc: Boolean = false): List<SemiFgItem> { | |||
| val grouped = linkedMapOf<String, SemiFgItem>() | |||
| val totals = linkedMapOf<String, BigDecimal>() | |||
| for (line in lines) { | |||
| if (line.itemNo.isBlank()) continue | |||
| val existing = grouped[line.itemNo] | |||
| if (existing == null) { | |||
| grouped[line.itemNo] = SemiFgItem(line.itemNo, line.itemName, line.unitOfMeasure) | |||
| } else if (existing.itemName.isBlank() && line.itemName.isNotBlank()) { | |||
| grouped[line.itemNo] = existing.copy(itemName = line.itemName, unitOfMeasure = line.unitOfMeasure.ifBlank { existing.unitOfMeasure }) | |||
| } | |||
| totals[line.itemNo] = (totals[line.itemNo] ?: BigDecimal.ZERO) + line.qty | |||
| } | |||
| val items = grouped.values.toList() | |||
| return if (qtyDesc) { | |||
| items.sortedWith(compareByDescending<SemiFgItem> { totals[it.itemNo] }.thenBy { it.itemNo }) | |||
| } else { | |||
| items.sortedBy { it.itemNo } | |||
| } | |||
| } | |||
| fun dailyQty(lines: List<SemiFgLine>): Map<String, Map<LocalDate, BigDecimal>> { | |||
| val out = linkedMapOf<String, MutableMap<LocalDate, BigDecimal>>() | |||
| for (line in lines) { | |||
| val byDate = out.getOrPut(line.itemNo) { linkedMapOf() } | |||
| byDate[line.prodDate] = (byDate[line.prodDate] ?: BigDecimal.ZERO) + line.qty | |||
| } | |||
| return out | |||
| } | |||
| fun anchorKey(view: SemiFgView, itemNo: String, date: LocalDate): String = | |||
| "$itemNo|${filterPeriod(view, date)}" | |||
| fun anchorKey(window: SemiFgWindow, itemNo: String, date: LocalDate): String = | |||
| "$itemNo|${filterPeriod(window, date)}" | |||
| fun filterPeriod(view: SemiFgView, date: LocalDate): String = when (view) { | |||
| SemiFgView.DAY, SemiFgView.WEEK, SemiFgView.MONTH -> date.format(isoDate) | |||
| SemiFgView.YEAR, SemiFgView.RANGE -> YearMonth.from(date).format(isoMonth) | |||
| } | |||
| fun filterPeriod(window: SemiFgWindow, date: LocalDate): String = | |||
| if (window.view == SemiFgView.RANGE && window.dayColumns.isNotEmpty()) { | |||
| date.format(isoDate) | |||
| } else { | |||
| filterPeriod(window.view, date) | |||
| } | |||
| fun qtyInMonthWithin( | |||
| daily: Map<String, Map<LocalDate, BigDecimal>>, | |||
| itemNo: String, | |||
| month: YearMonth, | |||
| start: LocalDate, | |||
| endInclusive: LocalDate, | |||
| ): BigDecimal = | |||
| daily[itemNo].orEmpty() | |||
| .filterKeys { date -> | |||
| YearMonth.from(date) == month && !date.isBefore(start) && !date.isAfter(endInclusive) | |||
| } | |||
| .values | |||
| .fold(BigDecimal.ZERO, BigDecimal::add) | |||
| fun roundQty(value: BigDecimal): Long = | |||
| value.setScale(0, RoundingMode.HALF_UP).longValueExact() | |||
| fun jobOrderCount(lines: List<SemiFgLine>, itemNo: String): Int { | |||
| val codes = lines.asSequence() | |||
| .filter { it.itemNo == itemNo && it.qty > BigDecimal.ZERO } | |||
| .map { it.jobOrderCode.trim() } | |||
| .filter { it.isNotEmpty() } | |||
| .distinct() | |||
| .toList() | |||
| if (codes.isNotEmpty()) return codes.size | |||
| return lines.count { it.itemNo == itemNo && it.qty > BigDecimal.ZERO } | |||
| } | |||
| fun qtyOnDay(daily: Map<String, Map<LocalDate, BigDecimal>>, itemNo: String, day: LocalDate): BigDecimal = | |||
| daily[itemNo]?.get(day) ?: BigDecimal.ZERO | |||
| fun qtyInMonth(daily: Map<String, Map<LocalDate, BigDecimal>>, itemNo: String, month: YearMonth): BigDecimal = | |||
| daily[itemNo].orEmpty() | |||
| .filterKeys { YearMonth.from(it) == month } | |||
| .values | |||
| .fold(BigDecimal.ZERO, BigDecimal::add) | |||
| fun yearPivotRows(data: SemiFgReportData): List<Map<String, Any>> { | |||
| val year = data.window.year ?: return emptyList() | |||
| val daily = dailyQty(data.lines) | |||
| return summaryItems(data.lines).map { item -> | |||
| val months = (1..12).map { month -> | |||
| qtyInMonth(daily, item.itemNo, YearMonth.of(year, month)) | |||
| } | |||
| val total = months.fold(BigDecimal.ZERO, BigDecimal::add) | |||
| mapOf( | |||
| "itemNo" to item.itemNo, | |||
| "itemName" to item.itemName, | |||
| "unitOfMeasure" to item.unitOfMeasure, | |||
| "qtyJan" to months[0], | |||
| "qtyFeb" to months[1], | |||
| "qtyMar" to months[2], | |||
| "qtyApr" to months[3], | |||
| "qtyMay" to months[4], | |||
| "qtyJun" to months[5], | |||
| "qtyJul" to months[6], | |||
| "qtyAug" to months[7], | |||
| "qtySep" to months[8], | |||
| "qtyOct" to months[9], | |||
| "qtyNov" to months[10], | |||
| "qtyDec" to months[11], | |||
| "totalProductionQty" to total.stripTrailingZeros().toPlainString(), | |||
| ) | |||
| } | |||
| } | |||
| fun listRows(data: SemiFgReportData): List<SemiFgListRow> { | |||
| val daily = dailyQty(data.lines) | |||
| return when (data.view) { | |||
| SemiFgView.DAY -> { | |||
| val day = data.window.day ?: return emptyList() | |||
| summaryItems(data.lines, qtyDesc = true).map { item -> | |||
| SemiFgListRow( | |||
| period = day.toString(), | |||
| itemNo = item.itemNo, | |||
| itemName = item.itemName, | |||
| unitOfMeasure = item.unitOfMeasure, | |||
| qty = qtyOnDay(daily, item.itemNo, day), | |||
| ) | |||
| } | |||
| } | |||
| SemiFgView.MONTH -> { | |||
| val month = data.window.month ?: return emptyList() | |||
| val rows = mutableListOf<SemiFgListRow>() | |||
| var cursor = month.atDay(1) | |||
| val end = month.atEndOfMonth() | |||
| while (!cursor.isAfter(end)) { | |||
| for (item in summaryItems(data.lines)) { | |||
| val qty = qtyOnDay(daily, item.itemNo, cursor) | |||
| if (qty > BigDecimal.ZERO) { | |||
| rows += SemiFgListRow(cursor.toString(), item.itemNo, item.itemName, item.unitOfMeasure, qty) | |||
| } | |||
| } | |||
| cursor = cursor.plusDays(1) | |||
| } | |||
| appendItemsWithNoQty(rows, summaryItems(data.lines), month.toString()) | |||
| rows | |||
| } | |||
| SemiFgView.WEEK, SemiFgView.RANGE -> rangeListRows(data, daily) | |||
| SemiFgView.YEAR -> emptyList() | |||
| } | |||
| } | |||
| private fun rangeListRows( | |||
| data: SemiFgReportData, | |||
| daily: Map<String, Map<LocalDate, BigDecimal>>, | |||
| ): List<SemiFgListRow> { | |||
| val rows = mutableListOf<SemiFgListRow>() | |||
| val items = summaryItems(data.lines) | |||
| if (data.window.dayColumns.isNotEmpty()) { | |||
| for (date in data.window.dayColumns) { | |||
| for (item in items) { | |||
| val qty = qtyOnDay(daily, item.itemNo, date) | |||
| if (qty > BigDecimal.ZERO) { | |||
| rows += SemiFgListRow(date.toString(), item.itemNo, item.itemName, item.unitOfMeasure, qty) | |||
| } | |||
| } | |||
| } | |||
| appendItemsWithNoQty(rows, items, data.window.start.toString()) | |||
| return rows | |||
| } | |||
| for (period in data.window.periods) { | |||
| for (item in items) { | |||
| val qty = qtyInMonthWithin(daily, item.itemNo, period, data.window.start, data.window.endInclusive) | |||
| if (qty > BigDecimal.ZERO) { | |||
| rows += SemiFgListRow(period.toString(), item.itemNo, item.itemName, item.unitOfMeasure, qty) | |||
| } | |||
| } | |||
| } | |||
| appendItemsWithNoQty(rows, items, data.window.periods.firstOrNull()?.toString() ?: data.window.start.toString()) | |||
| return rows | |||
| } | |||
| private fun appendItemsWithNoQty(rows: MutableList<SemiFgListRow>, items: List<SemiFgItem>, period: String) { | |||
| val seen = rows.map { it.itemNo }.toSet() | |||
| for (item in items) { | |||
| if (item.itemNo in seen) continue | |||
| rows += SemiFgListRow(period, item.itemNo, item.itemName, item.unitOfMeasure, BigDecimal.ZERO) | |||
| } | |||
| } | |||
| fun chartCategories(data: SemiFgReportData, items: List<SemiFgItem>): List<String> { | |||
| return when (data.view) { | |||
| SemiFgView.DAY -> items.map { it.itemNo } | |||
| SemiFgView.MONTH -> { | |||
| val month = data.window.month ?: return emptyList() | |||
| (1..month.lengthOfMonth()).map { it.toString() } | |||
| } | |||
| SemiFgView.YEAR -> MONTH_LABELS | |||
| SemiFgView.WEEK -> data.window.dayColumns.map { it.toString() } | |||
| SemiFgView.RANGE -> if (data.window.dayColumns.isNotEmpty()) { | |||
| data.window.dayColumns.map { it.toString() } | |||
| } else { | |||
| data.window.periods.map { it.toString() } | |||
| } | |||
| } | |||
| } | |||
| fun periodOptions(data: SemiFgReportData): List<String> { | |||
| return when (data.view) { | |||
| SemiFgView.DAY -> listOfNotNull(data.window.day?.toString()) | |||
| SemiFgView.MONTH -> { | |||
| val month = data.window.month ?: return emptyList() | |||
| (1..month.lengthOfMonth()).map { month.atDay(it).toString() } | |||
| } | |||
| SemiFgView.YEAR -> data.window.periods.map { it.toString() } | |||
| SemiFgView.WEEK -> data.window.dayColumns.map { it.toString() } | |||
| SemiFgView.RANGE -> chartCategories(data, emptyList()) | |||
| } | |||
| } | |||
| fun chartQty( | |||
| data: SemiFgReportData, | |||
| daily: Map<String, Map<LocalDate, BigDecimal>>, | |||
| itemNo: String, | |||
| category: String, | |||
| ): BigDecimal { | |||
| return when (data.view) { | |||
| SemiFgView.DAY -> if (itemNo == category) { | |||
| daily[itemNo].orEmpty().values.fold(BigDecimal.ZERO, BigDecimal::add) | |||
| } else { | |||
| BigDecimal.ZERO | |||
| } | |||
| SemiFgView.MONTH -> { | |||
| val month = data.window.month ?: return BigDecimal.ZERO | |||
| val day = category.toIntOrNull() ?: return BigDecimal.ZERO | |||
| if (day !in 1..month.lengthOfMonth()) BigDecimal.ZERO | |||
| else qtyOnDay(daily, itemNo, month.atDay(day)) | |||
| } | |||
| SemiFgView.YEAR -> { | |||
| val index = MONTH_LABELS.indexOf(category) | |||
| val year = data.window.year ?: return BigDecimal.ZERO | |||
| if (index < 0) BigDecimal.ZERO else qtyInMonth(daily, itemNo, YearMonth.of(year, index + 1)) | |||
| } | |||
| SemiFgView.WEEK -> { | |||
| val day = runCatching { LocalDate.parse(category) }.getOrNull() ?: return BigDecimal.ZERO | |||
| qtyOnDay(daily, itemNo, day) | |||
| } | |||
| SemiFgView.RANGE -> { | |||
| if (data.window.dayColumns.isNotEmpty()) { | |||
| val day = runCatching { LocalDate.parse(category) }.getOrNull() ?: return BigDecimal.ZERO | |||
| qtyOnDay(daily, itemNo, day) | |||
| } else { | |||
| val period = runCatching { YearMonth.parse(category) }.getOrNull() ?: return BigDecimal.ZERO | |||
| qtyInMonthWithin(daily, itemNo, period, data.window.start, data.window.endInclusive) | |||
| } | |||
| } | |||
| } | |||
| } | |||
| private fun parseDate(raw: String?): LocalDate? { | |||
| val text = raw?.trim()?.replace("/", "-")?.takeIf { it.isNotEmpty() && !it.equals("All", true) } ?: return null | |||
| return try { | |||
| LocalDate.parse(text.take(10), isoDate) | |||
| } catch (_: DateTimeParseException) { | |||
| throw SemiFgReportRequestException("日期格式須為 YYYY-MM-DD") | |||
| } | |||
| } | |||
| private fun parseMonth(raw: String?): YearMonth? { | |||
| val text = raw?.trim()?.replace("/", "-")?.takeIf { it.isNotEmpty() && !it.equals("All", true) } ?: return null | |||
| val normalized = if (text.length >= 7) text.take(7) else text | |||
| return try { | |||
| YearMonth.parse(normalized, isoMonth) | |||
| } catch (_: DateTimeParseException) { | |||
| throw SemiFgReportRequestException("月份格式須為 YYYY-MM") | |||
| } | |||
| } | |||
| private fun parseYear(raw: String?): Int? { | |||
| val text = raw?.trim()?.takeIf { it.isNotEmpty() && !it.equals("All", true) } ?: return null | |||
| val year = text.toIntOrNull() | |||
| if (year == null || year !in 1900..9999) { | |||
| throw SemiFgReportRequestException("請輸入四位數年份") | |||
| } | |||
| return year | |||
| } | |||
| } | |||
| @@ -0,0 +1,719 @@ | |||
| package com.ffii.fpsms.modules.report.service | |||
| import org.apache.poi.common.usermodel.HyperlinkType | |||
| import org.apache.poi.ss.usermodel.Cell | |||
| import org.apache.poi.ss.usermodel.BorderStyle | |||
| import org.apache.poi.ss.usermodel.DataValidationConstraint | |||
| import org.apache.poi.ss.usermodel.DataValidationHelper | |||
| 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.SheetVisibility | |||
| import org.apache.poi.ss.usermodel.VerticalAlignment | |||
| import org.apache.poi.ss.util.CellRangeAddress | |||
| import org.apache.poi.ss.util.CellRangeAddressList | |||
| import org.apache.poi.ss.util.CellReference | |||
| import org.apache.poi.ss.util.WorkbookUtil | |||
| import org.apache.poi.xddf.usermodel.chart.AxisPosition | |||
| import org.apache.poi.xddf.usermodel.chart.BarDirection | |||
| import org.apache.poi.xddf.usermodel.chart.ChartTypes | |||
| import org.apache.poi.xddf.usermodel.chart.XDDFBarChartData | |||
| import org.apache.poi.xddf.usermodel.chart.XDDFDataSourcesFactory | |||
| import org.apache.poi.xssf.usermodel.XSSFCellStyle | |||
| import org.apache.poi.xssf.usermodel.XSSFClientAnchor | |||
| import org.apache.poi.xssf.usermodel.XSSFSheet | |||
| import org.apache.poi.xssf.usermodel.XSSFWorkbook | |||
| import java.io.ByteArrayOutputStream | |||
| import java.math.BigDecimal | |||
| import java.time.LocalDate | |||
| import java.time.LocalTime | |||
| import java.time.YearMonth | |||
| import java.time.format.DateTimeFormatter | |||
| /** | |||
| * Excel workbook for the FG / Semi-FG production analysis report. | |||
| * Summary holds the view matrix. The item code links to a highlighted block on Detail. | |||
| */ | |||
| class SemiFgProductionAnalysisWorkbook { | |||
| private companion object { | |||
| const val DETAIL_LAST_COL = 7 | |||
| } | |||
| fun build(data: SemiFgReportData, lang: String? = null): ByteArray { | |||
| val text = SemiFgReportText.of(lang) | |||
| val workbook = XSSFWorkbook() | |||
| val styles = Styles(workbook) | |||
| val summaryName = WorkbookUtil.createSafeSheetName(text.sheetSummary) | |||
| val detailName = WorkbookUtil.createSafeSheetName(text.sheetDetail) | |||
| val chartName = WorkbookUtil.createSafeSheetName(text.sheetChart) | |||
| val chartDataName = WorkbookUtil.createSafeSheetName(text.sheetChartData) | |||
| val summary = workbook.createSheet(summaryName) | |||
| val detail = workbook.createSheet(detailName) | |||
| val chartData = workbook.createSheet(chartDataName) | |||
| val anchors = writeDetail(detail, data, styles, text) | |||
| writeSummary(summary, data, styles, detailName, anchors, text) | |||
| writeChart(workbook.createSheet(chartName), chartData, data, chartDataName, text) | |||
| workbook.setSheetVisibility(workbook.getSheetIndex(chartDataName), SheetVisibility.HIDDEN) | |||
| workbook.setSheetOrder(summaryName, 0) | |||
| workbook.setSheetOrder(detailName, 1) | |||
| workbook.setSheetOrder(chartName, 2) | |||
| val output = ByteArrayOutputStream() | |||
| workbook.use { it.write(output) } | |||
| return output.toByteArray() | |||
| } | |||
| private fun writeSummary( | |||
| sheet: XSSFSheet, | |||
| data: SemiFgReportData, | |||
| styles: Styles, | |||
| detailSheetName: String, | |||
| anchors: Map<String, DetailSpan>, | |||
| text: SemiFgReportText, | |||
| ) { | |||
| val items = when (data.view) { | |||
| SemiFgView.DAY -> SemiFgProductionAnalysisLayout.summaryItems(data.lines, qtyDesc = true) | |||
| else -> SemiFgProductionAnalysisLayout.summaryItems(data.lines) | |||
| } | |||
| val daily = SemiFgProductionAnalysisLayout.dailyQty(data.lines) | |||
| val columns = summaryColumnCount(data) | |||
| var rowIndex = 0 | |||
| val titleRow = sheet.createRow(rowIndex++) | |||
| titleRow.heightInPoints = 28f | |||
| val titleCell = titleRow.createCell(0) | |||
| titleCell.setCellValue(text.title) | |||
| titleCell.cellStyle = styles.title | |||
| if (columns > 1) { | |||
| sheet.addMergedRegion(CellRangeAddress(0, 0, 0, columns - 1)) | |||
| } | |||
| val infoRow = sheet.createRow(rowIndex++) | |||
| infoRow.createCell(0).apply { | |||
| setCellValue(text.viewLine(data.window)) | |||
| cellStyle = styles.info | |||
| } | |||
| infoRow.createCell(minOf(3, columns - 1)).apply { | |||
| setCellValue(text.reportDateLine()) | |||
| cellStyle = styles.info | |||
| } | |||
| if (columns > 6) { | |||
| infoRow.createCell(6).apply { | |||
| setCellValue(text.reportTimeLine()) | |||
| cellStyle = styles.info | |||
| } | |||
| } | |||
| val hintRow = sheet.createRow(rowIndex++) | |||
| hintRow.createCell(0).apply { | |||
| setCellValue(text.detailHint()) | |||
| cellStyle = styles.info | |||
| } | |||
| rowIndex++ | |||
| val headerRowIndex = rowIndex | |||
| when (data.view) { | |||
| SemiFgView.DAY -> writeDaySummary(sheet, rowIndex, data, items, daily, styles, detailSheetName, anchors, text) | |||
| SemiFgView.WEEK -> writeRangeSummary(sheet, rowIndex, data, items, daily, styles, detailSheetName, anchors, text) | |||
| SemiFgView.MONTH -> writeMonthSummary(sheet, rowIndex, data, items, daily, styles, detailSheetName, anchors, text) | |||
| SemiFgView.YEAR -> writeYearSummary(sheet, rowIndex, data, items, daily, styles, detailSheetName, anchors, text) | |||
| SemiFgView.RANGE -> writeRangeSummary(sheet, rowIndex, data, items, daily, styles, detailSheetName, anchors, text) | |||
| } | |||
| sheet.createFreezePane(0, headerRowIndex + 1) | |||
| applySummaryWidths(sheet, data) | |||
| } | |||
| private fun summaryColumnCount(data: SemiFgReportData): Int = when (data.view) { | |||
| SemiFgView.DAY -> 5 | |||
| SemiFgView.WEEK -> 3 + data.window.dayColumns.size + 1 | |||
| SemiFgView.MONTH -> 3 + (data.window.month?.lengthOfMonth() ?: 31) + 1 | |||
| SemiFgView.YEAR -> 16 | |||
| SemiFgView.RANGE -> if (data.window.dayColumns.isNotEmpty()) { | |||
| 3 + data.window.dayColumns.size + 1 | |||
| } else { | |||
| 3 + data.window.periods.size + 1 | |||
| } | |||
| } | |||
| private fun writeDaySummary( | |||
| sheet: XSSFSheet, | |||
| startRow: Int, | |||
| data: SemiFgReportData, | |||
| items: List<SemiFgItem>, | |||
| daily: Map<String, Map<LocalDate, BigDecimal>>, | |||
| styles: Styles, | |||
| detailSheetName: String, | |||
| anchors: Map<String, DetailSpan>, | |||
| text: SemiFgReportText, | |||
| ): Int { | |||
| var rowIndex = startRow | |||
| val headers = listOf(text.itemNo, text.itemName, text.uom, text.jobOrders, text.putAwayQty) | |||
| writeHeader(sheet, rowIndex++, headers, styles) | |||
| val day = data.window.day | |||
| if (items.isEmpty() || day == null) { | |||
| writeEmptyRow(sheet, rowIndex++, headers.size, styles) | |||
| return rowIndex | |||
| } | |||
| var total = BigDecimal.ZERO | |||
| for (item in items) { | |||
| val qty = SemiFgProductionAnalysisLayout.qtyOnDay(daily, item.itemNo, day) | |||
| total += qty | |||
| val anchor = anchors[SemiFgProductionAnalysisLayout.anchorKey(SemiFgView.DAY, item.itemNo, day)] | |||
| val row = sheet.createRow(rowIndex++) | |||
| setText(row, 0, item.itemNo, styles, detailSheetName, anchor, link = true) | |||
| setText(row, 1, item.itemName.ifBlank { "-" }, styles) | |||
| setText(row, 2, item.unitOfMeasure.ifBlank { "-" }, styles) | |||
| setWholeNumber(row, 3, SemiFgProductionAnalysisLayout.jobOrderCount(data.lines, item.itemNo).toBigDecimal(), styles) | |||
| setWholeNumber(row, 4, qty, styles) | |||
| } | |||
| writeTotalRow(sheet, rowIndex++, text.total, 4, total, styles, leadingBlanks = 3) | |||
| return rowIndex | |||
| } | |||
| private fun writeMonthSummary( | |||
| sheet: XSSFSheet, | |||
| startRow: Int, | |||
| data: SemiFgReportData, | |||
| items: List<SemiFgItem>, | |||
| daily: Map<String, Map<LocalDate, BigDecimal>>, | |||
| styles: Styles, | |||
| detailSheetName: String, | |||
| anchors: Map<String, DetailSpan>, | |||
| text: SemiFgReportText, | |||
| ): Int { | |||
| var rowIndex = startRow | |||
| val month = data.window.month | |||
| val days = month?.lengthOfMonth() ?: 0 | |||
| val headers = listOf(text.itemNo, text.itemName, text.uom) + (1..days).map { it.toString() } + text.monthTotal | |||
| writeHeader(sheet, rowIndex++, headers, styles) | |||
| if (items.isEmpty() || month == null) { | |||
| writeEmptyRow(sheet, rowIndex++, headers.size, styles) | |||
| return rowIndex | |||
| } | |||
| val dayTotals = Array(days) { BigDecimal.ZERO } | |||
| var grand = BigDecimal.ZERO | |||
| for (item in items) { | |||
| val row = sheet.createRow(rowIndex++) | |||
| val firstAnchor = firstAnchor(anchors, item.itemNo) | |||
| setText(row, 0, item.itemNo, styles, detailSheetName, firstAnchor, link = true) | |||
| setText(row, 1, item.itemName.ifBlank { "-" }, styles) | |||
| setText(row, 2, item.unitOfMeasure.ifBlank { "-" }, styles) | |||
| var rowTotal = BigDecimal.ZERO | |||
| for (day in 1..days) { | |||
| val date = month.atDay(day) | |||
| val qty = SemiFgProductionAnalysisLayout.qtyOnDay(daily, item.itemNo, date) | |||
| rowTotal += qty | |||
| dayTotals[day - 1] += qty | |||
| setWholeNumber(row, 2 + day, qty, styles) | |||
| } | |||
| grand += rowTotal | |||
| setWholeNumber(row, 3 + days, rowTotal, styles) | |||
| } | |||
| val totalRow = sheet.createRow(rowIndex++) | |||
| setText(totalRow, 0, text.total, styles) | |||
| setText(totalRow, 1, "", styles) | |||
| setText(totalRow, 2, "", styles) | |||
| dayTotals.forEachIndexed { index, qty -> | |||
| setWholeNumber(totalRow, 3 + index, qty, styles) | |||
| } | |||
| setWholeNumber(totalRow, 3 + days, grand, styles) | |||
| return rowIndex | |||
| } | |||
| private fun writeRangeSummary( | |||
| sheet: XSSFSheet, | |||
| startRow: Int, | |||
| data: SemiFgReportData, | |||
| items: List<SemiFgItem>, | |||
| daily: Map<String, Map<LocalDate, BigDecimal>>, | |||
| styles: Styles, | |||
| detailSheetName: String, | |||
| anchors: Map<String, DetailSpan>, | |||
| text: SemiFgReportText, | |||
| ): Int { | |||
| val columns: List<Pair<String, (String) -> BigDecimal>> = if (data.window.dayColumns.isNotEmpty()) { | |||
| val sameMonth = data.window.dayColumns.map { YearMonth.from(it) }.distinct().size == 1 | |||
| data.window.dayColumns.map { date -> | |||
| val heading = if (sameMonth) date.dayOfMonth.toString() else "%d/%d".format(date.dayOfMonth, date.monthValue) | |||
| heading to { itemNo: String -> | |||
| SemiFgProductionAnalysisLayout.qtyOnDay(daily, itemNo, date) | |||
| } | |||
| } | |||
| } else { | |||
| data.window.periods.map { period -> | |||
| period.toString() to { itemNo: String -> | |||
| SemiFgProductionAnalysisLayout.qtyInMonthWithin( | |||
| daily, itemNo, period, data.window.start, data.window.endInclusive, | |||
| ) | |||
| } | |||
| } | |||
| } | |||
| var rowIndex = startRow | |||
| val headers = listOf(text.itemNo, text.itemName, text.uom) + columns.map { it.first } + text.total | |||
| writeHeader(sheet, rowIndex++, headers, styles) | |||
| if (items.isEmpty() || columns.isEmpty()) { | |||
| writeEmptyRow(sheet, rowIndex++, headers.size, styles) | |||
| return rowIndex | |||
| } | |||
| val columnTotals = Array(columns.size) { BigDecimal.ZERO } | |||
| var grand = BigDecimal.ZERO | |||
| for (item in items) { | |||
| val row = sheet.createRow(rowIndex++) | |||
| val firstAnchor = firstAnchor(anchors, item.itemNo) | |||
| setText(row, 0, item.itemNo, styles, detailSheetName, firstAnchor, link = true) | |||
| setText(row, 1, item.itemName.ifBlank { "-" }, styles) | |||
| setText(row, 2, item.unitOfMeasure.ifBlank { "-" }, styles) | |||
| var rowTotal = BigDecimal.ZERO | |||
| columns.forEachIndexed { index, (_, qtyOf) -> | |||
| val qty = qtyOf(item.itemNo) | |||
| rowTotal += qty | |||
| columnTotals[index] += qty | |||
| setWholeNumber(row, 3 + index, qty, styles) | |||
| } | |||
| grand += rowTotal | |||
| setWholeNumber(row, 3 + columns.size, rowTotal, styles) | |||
| } | |||
| val totalRow = sheet.createRow(rowIndex++) | |||
| setText(totalRow, 0, text.total, styles) | |||
| setText(totalRow, 1, "", styles) | |||
| setText(totalRow, 2, "", styles) | |||
| columnTotals.forEachIndexed { index, qty -> | |||
| setWholeNumber(totalRow, 3 + index, qty, styles) | |||
| } | |||
| setWholeNumber(totalRow, 3 + columns.size, grand, styles) | |||
| return rowIndex | |||
| } | |||
| private fun writeYearSummary( | |||
| sheet: XSSFSheet, | |||
| startRow: Int, | |||
| data: SemiFgReportData, | |||
| items: List<SemiFgItem>, | |||
| daily: Map<String, Map<LocalDate, BigDecimal>>, | |||
| styles: Styles, | |||
| detailSheetName: String, | |||
| anchors: Map<String, DetailSpan>, | |||
| text: SemiFgReportText, | |||
| ): Int { | |||
| var rowIndex = startRow | |||
| val headers = listOf(text.itemNo, text.itemName, text.uom) + text.monthLabels + text.putAwayTotal | |||
| writeHeader(sheet, rowIndex++, headers, styles) | |||
| val year = data.window.year | |||
| if (items.isEmpty() || year == null) { | |||
| writeEmptyRow(sheet, rowIndex++, headers.size, styles) | |||
| return rowIndex | |||
| } | |||
| val monthTotals = Array(12) { BigDecimal.ZERO } | |||
| var grand = BigDecimal.ZERO | |||
| for (item in items) { | |||
| val row = sheet.createRow(rowIndex++) | |||
| val firstAnchor = firstAnchor(anchors, item.itemNo) | |||
| setText(row, 0, item.itemNo, styles, detailSheetName, firstAnchor, link = true) | |||
| setText(row, 1, item.itemName.ifBlank { "-" }, styles) | |||
| setText(row, 2, item.unitOfMeasure.ifBlank { "-" }, styles) | |||
| var rowTotal = BigDecimal.ZERO | |||
| for (month in 1..12) { | |||
| val period = YearMonth.of(year, month) | |||
| val qty = SemiFgProductionAnalysisLayout.qtyInMonth(daily, item.itemNo, period) | |||
| rowTotal += qty | |||
| monthTotals[month - 1] += qty | |||
| setWholeNumber(row, 2 + month, qty, styles) | |||
| } | |||
| grand += rowTotal | |||
| setWholeNumber(row, 15, rowTotal, styles) | |||
| } | |||
| val totalRow = sheet.createRow(rowIndex++) | |||
| setText(totalRow, 0, text.total, styles) | |||
| setText(totalRow, 1, "", styles) | |||
| setText(totalRow, 2, "", styles) | |||
| monthTotals.forEachIndexed { index, qty -> | |||
| setWholeNumber(totalRow, 3 + index, qty, styles) | |||
| } | |||
| setWholeNumber(totalRow, 15, grand, styles) | |||
| return rowIndex | |||
| } | |||
| private fun writeDetail(sheet: XSSFSheet, data: SemiFgReportData, styles: Styles, text: SemiFgReportText): Map<String, DetailSpan> { | |||
| val headers = listOf(text.date, text.itemNo, text.itemName, text.uom, text.jobOrderNo, text.lotNo, text.putAwayQty, text.qc) | |||
| writeHeader(sheet, 0, headers, styles) | |||
| val anchors = linkedMapOf<String, DetailSpan>() | |||
| val grouped = data.lines | |||
| .sortedWith(compareBy({ it.itemNo }, { it.prodDate }, { it.jobOrderCode }, { it.productLotNo })) | |||
| .groupBy { it.itemNo } | |||
| var rowIndex = 1 | |||
| if (grouped.isEmpty()) { | |||
| writeEmptyRow(sheet, rowIndex, headers.size, styles) | |||
| return anchors | |||
| } | |||
| for ((itemNo, lines) in grouped) { | |||
| val firstRow = rowIndex | |||
| val header = sheet.createRow(rowIndex) | |||
| header.heightInPoints = 22f | |||
| header.createCell(0).cellStyle = styles.sectionEdge | |||
| header.createCell(1).apply { | |||
| setCellValue(itemNo) | |||
| cellStyle = styles.sectionCode | |||
| } | |||
| header.createCell(2).apply { | |||
| setCellValue(lines.first().itemName.ifBlank { "" }) | |||
| cellStyle = styles.sectionName | |||
| } | |||
| for (col in 3 until headers.size) { | |||
| header.createCell(col).cellStyle = styles.sectionEdge | |||
| } | |||
| rowIndex++ | |||
| for (line in lines) { | |||
| val row = sheet.createRow(rowIndex++) | |||
| setText(row, 0, line.prodDate.toString(), styles) | |||
| setText(row, 1, line.itemNo, styles) | |||
| setText(row, 2, line.itemName.ifBlank { "-" }, styles) | |||
| setText(row, 3, line.unitOfMeasure.ifBlank { "-" }, styles) | |||
| setText(row, 4, line.jobOrderCode.ifBlank { "-" }, styles) | |||
| setText(row, 5, line.productLotNo.ifBlank { "-" }, styles) | |||
| setWholeNumber(row, 6, line.qty, styles) | |||
| setText(row, 7, if (line.qcFailed) text.qcFail else text.qcPass, styles) | |||
| } | |||
| val span = DetailSpan(firstRow, rowIndex - 1) | |||
| anchors[itemNo] = span | |||
| for (line in lines) { | |||
| anchors[SemiFgProductionAnalysisLayout.anchorKey(data.window, line.itemNo, line.prodDate)] = span | |||
| } | |||
| } | |||
| val widths = intArrayOf(22, 18, 28, 12, 18, 18, 12, 10) | |||
| widths.forEachIndexed { idx, width -> sheet.setColumnWidth(idx, width * 256) } | |||
| return anchors | |||
| } | |||
| private fun writeChart( | |||
| chartSheet: XSSFSheet, | |||
| chartData: XSSFSheet, | |||
| data: SemiFgReportData, | |||
| chartDataName: String, | |||
| text: SemiFgReportText, | |||
| ) { | |||
| val items = when (data.view) { | |||
| SemiFgView.DAY -> SemiFgProductionAnalysisLayout.summaryItems(data.lines, qtyDesc = true) | |||
| else -> SemiFgProductionAnalysisLayout.summaryItems(data.lines) | |||
| } | |||
| val rawCategories = SemiFgProductionAnalysisLayout.chartCategories(data, items) | |||
| val categories = if (data.view == SemiFgView.YEAR) text.monthLabels else rawCategories | |||
| val daily = SemiFgProductionAnalysisLayout.dailyQty(data.lines) | |||
| val dataHeader = chartData.createRow(0) | |||
| dataHeader.createCell(0).setCellValue(text.itemNo) | |||
| dataHeader.createCell(1).setCellValue(text.period) | |||
| dataHeader.createCell(2).setCellValue(text.qty) | |||
| var dataRow = 1 | |||
| for (item in items) { | |||
| categories.forEachIndexed { index, category -> | |||
| val qtyCategory = if (data.view == SemiFgView.YEAR) { | |||
| SemiFgProductionAnalysisLayout.MONTH_LABELS[index] | |||
| } else { | |||
| category | |||
| } | |||
| val row = chartData.createRow(dataRow++) | |||
| row.createCell(0).setCellValue(item.itemNo) | |||
| row.createCell(1).setCellValue(category) | |||
| row.createCell(2).setCellValue( | |||
| SemiFgProductionAnalysisLayout.roundQty( | |||
| SemiFgProductionAnalysisLayout.chartQty(data, daily, item.itemNo, qtyCategory), | |||
| ).toDouble(), | |||
| ) | |||
| } | |||
| } | |||
| val itemList = listOf(text.all) + items.map { it.itemNo } | |||
| val listCol = 40 | |||
| itemList.forEachIndexed { index, value -> | |||
| val row = chartData.getRow(index) ?: chartData.createRow(index) | |||
| row.createCell(listCol).setCellValue(value) | |||
| } | |||
| chartSheet.setDisplayGridlines(false) | |||
| val labelStyle = chartSheet.workbook.createCellStyle() as XSSFCellStyle | |||
| labelStyle.alignment = HorizontalAlignment.LEFT | |||
| labelStyle.verticalAlignment = VerticalAlignment.CENTER | |||
| labelStyle.setFont(chartSheet.workbook.createFont().apply { bold = true }) | |||
| val choiceStyle = chartSheet.workbook.createCellStyle() as XSSFCellStyle | |||
| choiceStyle.alignment = HorizontalAlignment.LEFT | |||
| choiceStyle.verticalAlignment = VerticalAlignment.CENTER | |||
| choiceStyle.borderBottom = BorderStyle.THIN | |||
| choiceStyle.borderTop = BorderStyle.THIN | |||
| choiceStyle.borderLeft = BorderStyle.THIN | |||
| choiceStyle.borderRight = BorderStyle.THIN | |||
| choiceStyle.fillForegroundColor = IndexedColors.GREY_25_PERCENT.index | |||
| choiceStyle.fillPattern = FillPatternType.SOLID_FOREGROUND | |||
| val labelRow = chartSheet.createRow(0) | |||
| labelRow.heightInPoints = 22f | |||
| labelRow.createCell(0).apply { | |||
| setCellValue(text.selectItem) | |||
| cellStyle = labelStyle | |||
| } | |||
| labelRow.createCell(1).apply { | |||
| setCellValue(text.all) | |||
| cellStyle = choiceStyle | |||
| } | |||
| addListValidation( | |||
| chartSheet, | |||
| "'$chartDataName'!\$${CellReference.convertNumToColString(listCol)}\$1:\$${CellReference.convertNumToColString(listCol)}\$${itemList.size}", | |||
| 0, | |||
| 1, | |||
| text.pickItemError, | |||
| text.invalidChoice, | |||
| ) | |||
| if (categories.isEmpty()) { | |||
| chartSheet.createRow(2).createCell(0).setCellValue(text.noData) | |||
| } else { | |||
| val labelRowIndex = 0 | |||
| val keyRowIndex = 1 | |||
| val valueRowIndex = 2 | |||
| val firstCol = 4 | |||
| val labels = chartLabels(data, categories) | |||
| val sheetRef = chartSheet.sheetName.replace("'", "''") | |||
| labels.forEachIndexed { index, label -> | |||
| val col = firstCol + index | |||
| val labelCellRow = chartData.getRow(labelRowIndex) ?: chartData.createRow(labelRowIndex) | |||
| val keyCellRow = chartData.getRow(keyRowIndex) ?: chartData.createRow(keyRowIndex) | |||
| val valueCellRow = chartData.getRow(valueRowIndex) ?: chartData.createRow(valueRowIndex) | |||
| labelCellRow.createCell(col).setCellValue(label) | |||
| keyCellRow.createCell(col).setCellValue(categories[index]) | |||
| val keyRef = "${CellReference.convertNumToColString(col)}${keyRowIndex + 1}" | |||
| valueCellRow.createCell(col).cellFormula = | |||
| """IF('$sheetRef'!${'$'}B${'$'}1="${text.all}",SUMIF('$chartDataName'!B:B,$keyRef,'$chartDataName'!C:C),SUMIFS('$chartDataName'!C:C,'$chartDataName'!A:A,'$sheetRef'!${'$'}B${'$'}1,'$chartDataName'!B:B,$keyRef))""" | |||
| } | |||
| chartSheet.workbook.creationHelper.createFormulaEvaluator().evaluateAll() | |||
| val lastCol = firstCol + categories.size - 1 | |||
| val drawing = chartSheet.createDrawingPatriarch() | |||
| val anchor = XSSFClientAnchor(0, 0, 0, 0, 0, 2, 12, 18) | |||
| val chart = drawing.createChart(anchor) | |||
| chart.setTitleText(text.chartTitle(data.window)) | |||
| chart.setTitleOverlay(false) | |||
| val bottomAxis = chart.createCategoryAxis(AxisPosition.BOTTOM) | |||
| bottomAxis.setTitle(chartAxisTitle(data, text)) | |||
| val leftAxis = chart.createValueAxis(AxisPosition.LEFT) | |||
| leftAxis.setTitle(text.producedQty) | |||
| val categoriesSource = XDDFDataSourcesFactory.fromStringCellRange( | |||
| chartData, | |||
| CellRangeAddress(labelRowIndex, labelRowIndex, firstCol, lastCol), | |||
| ) | |||
| val valuesSource = XDDFDataSourcesFactory.fromNumericCellRange( | |||
| chartData, | |||
| CellRangeAddress(valueRowIndex, valueRowIndex, firstCol, lastCol), | |||
| ) | |||
| val barData = chart.createData(ChartTypes.BAR, bottomAxis, leftAxis) as XDDFBarChartData | |||
| barData.barDirection = BarDirection.COL | |||
| val series = barData.addSeries(categoriesSource, valuesSource) | |||
| series.setTitle(text.producedQty, null) | |||
| chart.plot(barData) | |||
| } | |||
| val note = chartSheet.createRow(19) | |||
| note.heightInPoints = 32f | |||
| note.createCell(0).setCellValue(text.chartNote()) | |||
| chartSheet.addMergedRegion(CellRangeAddress(19, 19, 0, 6)) | |||
| chartSheet.setColumnWidth(0, 24 * 256) | |||
| chartSheet.setColumnWidth(1, 22 * 256) | |||
| } | |||
| private fun chartLabels(data: SemiFgReportData, keys: List<String>): List<String> { | |||
| val days = data.window.dayColumns | |||
| if ((data.view != SemiFgView.RANGE && data.view != SemiFgView.WEEK) || days.isEmpty()) return keys | |||
| val sameMonth = days.map { YearMonth.from(it) }.distinct().size == 1 | |||
| return if (sameMonth) { | |||
| days.map { it.dayOfMonth.toString() } | |||
| } else { | |||
| days.map { "%d/%d".format(it.dayOfMonth, it.monthValue) } | |||
| } | |||
| } | |||
| private fun chartAxisTitle(data: SemiFgReportData, text: SemiFgReportText): String = when (data.view) { | |||
| SemiFgView.DAY -> text.axisItem | |||
| SemiFgView.WEEK -> text.axisDay | |||
| SemiFgView.MONTH -> text.axisDay | |||
| SemiFgView.YEAR -> text.axisMonth | |||
| SemiFgView.RANGE -> if (data.window.dayColumns.isNotEmpty()) text.axisDate else text.axisPeriod | |||
| } | |||
| private fun addListValidation( | |||
| sheet: XSSFSheet, | |||
| formula: String, | |||
| row: Int, | |||
| col: Int, | |||
| error: String, | |||
| errorTitle: String, | |||
| ) { | |||
| if (formula.isBlank()) return | |||
| val helper: DataValidationHelper = sheet.dataValidationHelper | |||
| val constraint: DataValidationConstraint = helper.createFormulaListConstraint(formula) | |||
| val validation = helper.createValidation(constraint, CellRangeAddressList(row, row, col, col)) | |||
| validation.showErrorBox = true | |||
| validation.createErrorBox(errorTitle, error) | |||
| sheet.addValidationData(validation) | |||
| } | |||
| private fun applySummaryWidths(sheet: XSSFSheet, data: SemiFgReportData) { | |||
| sheet.setColumnWidth(0, 18 * 256) | |||
| sheet.setColumnWidth(1, 24 * 256) | |||
| sheet.setColumnWidth(2, 12 * 256) | |||
| val narrow = when { | |||
| data.view == SemiFgView.MONTH || data.view == SemiFgView.WEEK -> 11 * 256 | |||
| data.view == SemiFgView.RANGE && data.window.dayColumns.isNotEmpty() -> 11 * 256 | |||
| else -> 12 * 256 | |||
| } | |||
| val last = summaryColumnCount(data) | |||
| for (col in 3 until last) { | |||
| sheet.setColumnWidth(col, if (col == last - 1) 16 * 256 else narrow) | |||
| } | |||
| } | |||
| private fun writeHeader(sheet: XSSFSheet, rowIndex: Int, headers: List<String>, styles: Styles) { | |||
| val row = sheet.createRow(rowIndex) | |||
| headers.forEachIndexed { index, name -> | |||
| row.createCell(index).apply { | |||
| setCellValue(name) | |||
| cellStyle = styles.header | |||
| } | |||
| } | |||
| } | |||
| private fun writeEmptyRow(sheet: XSSFSheet, rowIndex: Int, columns: Int, styles: Styles) { | |||
| val row = sheet.createRow(rowIndex) | |||
| for (col in 0 until columns) { | |||
| row.createCell(col).apply { | |||
| setCellValue("-") | |||
| cellStyle = styles.dash | |||
| } | |||
| } | |||
| } | |||
| private fun writeTotalRow( | |||
| sheet: XSSFSheet, | |||
| rowIndex: Int, | |||
| label: String, | |||
| qtyColumn: Int, | |||
| qty: BigDecimal, | |||
| styles: Styles, | |||
| leadingBlanks: Int, | |||
| ) { | |||
| val row = sheet.createRow(rowIndex) | |||
| setText(row, 0, label, styles) | |||
| for (col in 1..leadingBlanks) { | |||
| setText(row, col, "", styles) | |||
| } | |||
| setWholeNumber(row, qtyColumn, qty, styles) | |||
| } | |||
| private fun firstAnchor(anchors: Map<String, DetailSpan>, itemNo: String): DetailSpan? = | |||
| anchors.keys.filter { it.startsWith("$itemNo|") }.minOrNull()?.let { anchors[it] } | |||
| private fun setText( | |||
| row: org.apache.poi.ss.usermodel.Row, | |||
| col: Int, | |||
| value: String, | |||
| styles: Styles, | |||
| detailSheetName: String = "", | |||
| anchor: DetailSpan? = null, | |||
| link: Boolean = false, | |||
| ) { | |||
| val cell = row.createCell(col) | |||
| cell.setCellValue(value) | |||
| if (link && anchor != null && detailSheetName.isNotEmpty()) { | |||
| cell.cellStyle = styles.linkText | |||
| linkTo(cell, detailSheetName, anchor) | |||
| } else { | |||
| cell.cellStyle = styles.text | |||
| } | |||
| } | |||
| private fun setWholeNumber( | |||
| row: org.apache.poi.ss.usermodel.Row, | |||
| col: Int, | |||
| value: BigDecimal, | |||
| styles: Styles, | |||
| ) { | |||
| val cell = row.createCell(col) | |||
| val rounded = SemiFgProductionAnalysisLayout.roundQty(value) | |||
| if (rounded == 0L) { | |||
| cell.setCellValue("-") | |||
| cell.cellStyle = styles.dash | |||
| return | |||
| } | |||
| cell.setCellValue(rounded.toDouble()) | |||
| cell.cellStyle = styles.number | |||
| } | |||
| /** Clicking the summary code selects this item's rows on Detail. Excel drops the highlight when another cell is selected. */ | |||
| private fun linkTo(cell: Cell, detailSheetName: String, span: DetailSpan) { | |||
| val link = cell.sheet.workbook.creationHelper.createHyperlink(HyperlinkType.DOCUMENT) | |||
| val start = span.firstRow + 1 | |||
| val end = span.lastRow + 1 | |||
| val lastCol = CellReference.convertNumToColString(DETAIL_LAST_COL) | |||
| link.address = "'$detailSheetName'!A$start:$lastCol$end" | |||
| cell.hyperlink = link | |||
| } | |||
| private data class DetailSpan(val firstRow: Int, val lastRow: Int) | |||
| private class Styles(workbook: XSSFWorkbook) { | |||
| private val format = workbook.creationHelper.createDataFormat().getFormat("#,##0") | |||
| private val linkFont = workbook.createFont().apply { | |||
| color = IndexedColors.BLUE.index | |||
| underline = org.apache.poi.ss.usermodel.Font.U_SINGLE | |||
| } | |||
| val title: XSSFCellStyle = newStyle(workbook).apply { | |||
| alignment = HorizontalAlignment.CENTER | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| setFont(workbook.createFont().apply { | |||
| bold = true | |||
| fontHeightInPoints = 16 | |||
| }) | |||
| } | |||
| val info: XSSFCellStyle = newStyle(workbook).apply { | |||
| alignment = HorizontalAlignment.LEFT | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| } | |||
| val header: XSSFCellStyle = bordered(workbook).apply { | |||
| alignment = HorizontalAlignment.CENTER | |||
| fillForegroundColor = IndexedColors.GREY_25_PERCENT.index | |||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||
| setFont(workbook.createFont().apply { bold = true }) | |||
| } | |||
| val text: XSSFCellStyle = bordered(workbook).apply { alignment = HorizontalAlignment.LEFT } | |||
| val linkText: XSSFCellStyle = bordered(workbook).apply { | |||
| alignment = HorizontalAlignment.LEFT | |||
| setFont(linkFont) | |||
| } | |||
| val number: XSSFCellStyle = bordered(workbook).apply { | |||
| alignment = HorizontalAlignment.RIGHT | |||
| dataFormat = format | |||
| } | |||
| val dash: XSSFCellStyle = bordered(workbook).apply { alignment = HorizontalAlignment.RIGHT } | |||
| val sectionCode: XSSFCellStyle = bordered(workbook).apply { | |||
| alignment = HorizontalAlignment.LEFT | |||
| borderBottom = BorderStyle.MEDIUM | |||
| setFont(workbook.createFont().apply { | |||
| bold = true | |||
| fontHeightInPoints = 14 | |||
| }) | |||
| } | |||
| val sectionName: XSSFCellStyle = bordered(workbook).apply { | |||
| alignment = HorizontalAlignment.LEFT | |||
| borderBottom = BorderStyle.MEDIUM | |||
| setFont(workbook.createFont().apply { bold = true }) | |||
| } | |||
| val sectionEdge: XSSFCellStyle = bordered(workbook).apply { | |||
| borderBottom = BorderStyle.MEDIUM | |||
| } | |||
| private fun newStyle(workbook: XSSFWorkbook): XSSFCellStyle = | |||
| workbook.createCellStyle() as XSSFCellStyle | |||
| private fun bordered(workbook: XSSFWorkbook): XSSFCellStyle = newStyle(workbook).apply { | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| borderTop = BorderStyle.THIN | |||
| borderBottom = BorderStyle.THIN | |||
| borderLeft = BorderStyle.THIN | |||
| borderRight = BorderStyle.THIN | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,97 @@ | |||
| package com.ffii.fpsms.modules.report.service | |||
| import java.time.LocalDate | |||
| import java.time.LocalTime | |||
| import java.time.format.DateTimeFormatter | |||
| /** Column headings and sheet names for the FG / Semi-FG production analysis report. */ | |||
| class SemiFgReportText(val english: Boolean) { | |||
| fun periodHeading(window: SemiFgWindow): String = when (window.view) { | |||
| SemiFgView.DAY -> "$viewDay ${window.day}" | |||
| SemiFgView.WEEK -> "$viewWeek ${window.start} – ${window.endInclusive}" | |||
| SemiFgView.MONTH -> "$viewMonth ${window.month}" | |||
| SemiFgView.YEAR -> "$viewYear ${window.year}" | |||
| SemiFgView.RANGE -> "$viewRange ${window.start} – ${window.endInclusive}" | |||
| } | |||
| val title = t("成品/半成品生產分析報告", "FG / Semi-FG Production Analysis Report") | |||
| val itemNo = t("貨品編號", "Item code") | |||
| val itemName = t("貨品名稱", "Item name") | |||
| val uom = t("單位", "UOM") | |||
| val jobOrders = t("工單數", "Job orders") | |||
| val putAwayQty = t("上架數量", "Put-away qty") | |||
| val putAwayTotal = t("上架總計", "Put-away total") | |||
| val monthTotal = t("月合計", "Month total") | |||
| val total = t("合計", "Total") | |||
| val period = t("期間", "Period") | |||
| val date = t("日期", "Date") | |||
| val jobOrderNo = t("工單編號", "Job order") | |||
| val lotNo = t("批號", "Lot no.") | |||
| val qc = "QC" | |||
| val qcPass = t("合格", "Pass") | |||
| val qcFail = t("不合格", "Fail") | |||
| val all = t("全部", "All") | |||
| val noData = t("無資料", "No data") | |||
| val qty = t("數量", "Qty") | |||
| val selectItem = t("選擇貨品編號", "Item code") | |||
| val selectPeriod = t("選擇期間", "Period") | |||
| val invalidChoice = t("無效選擇", "Invalid choice") | |||
| val pickItemError = t("請從清單中選擇貨品編號或「全部」。", "Choose an item code or All.") | |||
| val pickPeriodError = t("請從清單中選擇期間或「全部」。", "Choose a period or All.") | |||
| val producedQty = t("生產數量", "Quantity") | |||
| val axisItem = t("貨品編號", "Item code") | |||
| val axisDay = t("日", "Day") | |||
| val axisMonth = t("月份", "Month") | |||
| val axisPeriod = t("期間", "Period") | |||
| val axisDate = t("日期", "Date") | |||
| val sheetSummary = t("彙總", "Summary") | |||
| val sheetDetail = t("明細", "Detail") | |||
| val sheetChart = t("生產圖表", "Chart") | |||
| val sheetChartData = "ChartData" | |||
| val viewDay = t("單日", "Day") | |||
| val viewWeek = t("單週", "Week") | |||
| val viewMonth = t("單月", "Month") | |||
| val viewYear = t("全年", "Year") | |||
| val viewRange = t("自訂", "Custom") | |||
| val pageLabel = t("頁數", "Page") | |||
| val reportDateLabel = t("報告日期:", "Report date: ") | |||
| val reportTimeLabel = t("報告時間:", "Report time: ") | |||
| val yearLabel = t("年份:", "Year: ") | |||
| val productionDateLabel = t("完成生產日期:", "Production dates:") | |||
| val toLabel = t("至", "to") | |||
| val monthLabels: List<String> = if (english) { | |||
| listOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec") | |||
| } else { | |||
| SemiFgProductionAnalysisLayout.MONTH_LABELS | |||
| } | |||
| fun viewLine(window: SemiFgWindow): String = | |||
| t("檢視:", "View: ") + periodHeading(window) | |||
| fun reportDateLine(today: LocalDate = LocalDate.now()): String = | |||
| reportDateLabel + today.format(DateTimeFormatter.ISO_LOCAL_DATE) | |||
| fun reportTimeLine(time: LocalTime = LocalTime.now()): String = | |||
| reportTimeLabel + time.format(DateTimeFormatter.ofPattern("HH:mm:ss")) | |||
| fun detailHint(): String = t( | |||
| "點選貨品編號會在「明細」選取該貨品的所有列。", | |||
| "Click an item code to select that item's rows on Detail.", | |||
| ) | |||
| fun chartNote(): String = t( | |||
| "點選「彙總」的貨品編號,會在「明細」選取該貨品的所有列。", | |||
| "On Summary, click an item code to select that item's rows on Detail.", | |||
| ) | |||
| fun chartTitle(window: SemiFgWindow): String = | |||
| t("生產數量(", "Quantity (") + periodHeading(window) + t(")", ")") | |||
| private fun t(zh: String, en: String): String = if (english) en else zh | |||
| companion object { | |||
| fun of(lang: String?): SemiFgReportText = | |||
| SemiFgReportText(lang?.trim()?.lowercase()?.startsWith("en") == true) | |||
| } | |||
| } | |||
| @@ -24,6 +24,11 @@ data class ReportItemFilterRequest( | |||
| val supplier: String? = null, | |||
| val poCode: String? = null, | |||
| val grnCode: String? = null, | |||
| val view: String? = null, | |||
| val reportDate: String? = null, | |||
| val reportMonth: String? = null, | |||
| val reportWeek: String? = null, | |||
| val lang: String? = null, | |||
| ) { | |||
| fun combinedItemCode(): String? = | |||
| ReportMultiValueTokens.combine(itemCode, itemCodePaste, itemCodes) | |||
| @@ -1,35 +1,27 @@ | |||
| package com.ffii.fpsms.modules.report.web | |||
| import net.sf.jasperreports.engine.* | |||
| import org.springframework.http.* | |||
| import org.springframework.web.bind.annotation.* | |||
| import java.io.ByteArrayOutputStream | |||
| import com.ffii.fpsms.modules.report.service.ReportService | |||
| import com.ffii.fpsms.modules.report.service.SemiFgProductionAnalysisLayout | |||
| import com.ffii.fpsms.modules.report.service.SemiFgProductionAnalysisWorkbook | |||
| import com.ffii.fpsms.modules.report.service.SemiFgReportFilter | |||
| import com.ffii.fpsms.modules.report.service.SemiFgReportText | |||
| import com.ffii.fpsms.modules.report.service.SemiFgReportRequestException | |||
| import com.ffii.fpsms.modules.report.service.SemiFgView | |||
| import com.ffii.fpsms.modules.report.service.SemiFGProductionAnalysisReportService | |||
| import org.springframework.http.HttpHeaders | |||
| import org.springframework.http.HttpStatus | |||
| import org.springframework.http.MediaType | |||
| import org.springframework.http.ResponseEntity | |||
| import org.springframework.web.bind.annotation.GetMapping | |||
| import org.springframework.web.bind.annotation.PostMapping | |||
| import org.springframework.web.bind.annotation.RequestBody | |||
| import org.springframework.web.bind.annotation.RequestMapping | |||
| import org.springframework.web.bind.annotation.RequestParam | |||
| import org.springframework.web.bind.annotation.RestController | |||
| import org.springframework.web.server.ResponseStatusException | |||
| import java.time.LocalDate | |||
| import java.time.LocalTime | |||
| import java.time.format.DateTimeFormatter | |||
| import com.ffii.fpsms.modules.report.service.SemiFGProductionAnalysisReportService | |||
| import com.ffii.fpsms.modules.report.service.ReportService | |||
| import org.apache.poi.ss.usermodel.BorderStyle | |||
| 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.util.WorkbookUtil | |||
| import org.apache.poi.ss.usermodel.VerticalAlignment | |||
| import org.apache.poi.ss.usermodel.DataValidationConstraint | |||
| import org.apache.poi.ss.usermodel.DataValidationHelper | |||
| import org.apache.poi.ss.usermodel.SheetVisibility | |||
| import org.apache.poi.ss.util.CellRangeAddress | |||
| import org.apache.poi.ss.util.CellRangeAddressList | |||
| import org.apache.poi.ss.util.CellReference | |||
| import org.apache.poi.xddf.usermodel.chart.AxisPosition | |||
| import org.apache.poi.xddf.usermodel.chart.BarDirection | |||
| import org.apache.poi.xddf.usermodel.chart.ChartTypes | |||
| import org.apache.poi.xddf.usermodel.chart.LegendPosition | |||
| import org.apache.poi.xddf.usermodel.chart.XDDFBarChartData | |||
| import org.apache.poi.xddf.usermodel.chart.XDDFDataSourcesFactory | |||
| import org.apache.poi.xssf.usermodel.XSSFClientAnchor | |||
| import org.apache.poi.xssf.usermodel.XSSFWorkbook | |||
| import kotlin.math.roundToLong | |||
| @RestController | |||
| @RequestMapping("/report") | |||
| @@ -37,6 +29,7 @@ class SemiFGProductionAnalysisReportController( | |||
| private val semiFGProductionAnalysisReportService: SemiFGProductionAnalysisReportService, | |||
| private val reportService: ReportService, | |||
| ) { | |||
| private val workbook = SemiFgProductionAnalysisWorkbook() | |||
| @GetMapping("/print-semi-fg-production-analysis") | |||
| fun generateSemiFGProductionAnalysisReport( | |||
| @@ -45,73 +38,23 @@ class SemiFGProductionAnalysisReportController( | |||
| @RequestParam(required = false) itemCode: String?, | |||
| @RequestParam(required = false) year: String?, | |||
| @RequestParam(required = false) lastOutDateStart: String?, | |||
| @RequestParam(required = false) lastOutDateEnd: String? | |||
| ): ResponseEntity<ByteArray> = | |||
| buildSemiFgProductionAnalysisPdf( | |||
| stockCategory, stockSubCategory, itemCode, year, lastOutDateStart, lastOutDateEnd, | |||
| ) | |||
| @RequestParam(required = false) lastOutDateEnd: String?, | |||
| @RequestParam(required = false) view: String?, | |||
| @RequestParam(required = false) reportDate: String?, | |||
| @RequestParam(required = false) reportMonth: String?, | |||
| @RequestParam(required = false) reportWeek: String?, | |||
| ): ResponseEntity<ByteArray> = buildPdf( | |||
| SemiFgReportFilter( | |||
| view, stockCategory, stockSubCategory, itemCode, year, | |||
| lastOutDateStart, lastOutDateEnd, reportDate, reportMonth, | |||
| reportWeek = reportWeek, | |||
| ), | |||
| ) | |||
| @PostMapping("/print-semi-fg-production-analysis") | |||
| fun generateSemiFGProductionAnalysisReportPost( | |||
| @RequestBody(required = false) req: ReportItemFilterRequest?, | |||
| ): ResponseEntity<ByteArray> { | |||
| val r = req ?: ReportItemFilterRequest() | |||
| return buildSemiFgProductionAnalysisPdf( | |||
| r.stockCategory, | |||
| r.stockSubCategory, | |||
| r.combinedItemCode(), | |||
| r.year, | |||
| r.lastOutDateStart, | |||
| r.lastOutDateEnd, | |||
| ) | |||
| } | |||
| private fun buildSemiFgProductionAnalysisPdf( | |||
| stockCategory: String?, | |||
| stockSubCategory: String?, | |||
| itemCode: String?, | |||
| year: String?, | |||
| lastOutDateStart: String?, | |||
| lastOutDateEnd: String?, | |||
| ): ResponseEntity<ByteArray> { | |||
| val parameters = mutableMapOf<String, Any>() | |||
| // Set report header parameters | |||
| parameters["stockCategory"] = stockCategory ?: "All" | |||
| parameters["stockSubCategory"] = stockSubCategory ?: "All" | |||
| parameters["itemNo"] = itemCode ?: "All" | |||
| parameters["year"] = year ?: LocalDate.now().year.toString() | |||
| parameters["reportDate"] = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd")) | |||
| parameters["reportTime"] = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) | |||
| parameters["lastOutDateStart"] = lastOutDateStart ?: "" | |||
| parameters["lastOutDateEnd"] = lastOutDateEnd ?: "" | |||
| parameters["deliveryPeriodStart"] = "" | |||
| parameters["deliveryPeriodEnd"] = "" | |||
| // Query the DB to get a list of data | |||
| val dbData = semiFGProductionAnalysisReportService.searchSemiFGProductionAnalysisReport( | |||
| stockCategory, | |||
| stockSubCategory, | |||
| itemCode, | |||
| year, | |||
| lastOutDateStart, | |||
| lastOutDateEnd | |||
| ) | |||
| val pdfBytes = reportService.createPdfResponse( | |||
| "/jasper/SemiFGProductionAnalysisReport.jrxml", | |||
| parameters, | |||
| dbData | |||
| ) | |||
| val headers = HttpHeaders().apply { | |||
| contentType = MediaType.APPLICATION_PDF | |||
| setContentDispositionFormData("attachment", "SemiFGProductionAnalysisReport.pdf") | |||
| set("filename", "SemiFGProductionAnalysisReport.pdf") | |||
| } | |||
| return ResponseEntity(pdfBytes, headers, HttpStatus.OK) | |||
| } | |||
| ): ResponseEntity<ByteArray> = buildPdf(filterOf(req)) | |||
| @GetMapping("/print-semi-fg-production-analysis-excel") | |||
| fun exportSemiFGProductionAnalysisReportExcel( | |||
| @@ -120,401 +63,156 @@ class SemiFGProductionAnalysisReportController( | |||
| @RequestParam(required = false) itemCode: String?, | |||
| @RequestParam(required = false) year: String?, | |||
| @RequestParam(required = false) lastOutDateStart: String?, | |||
| @RequestParam(required = false) lastOutDateEnd: String? | |||
| ): ResponseEntity<ByteArray> = | |||
| buildSemiFgProductionAnalysisExcel( | |||
| stockCategory, stockSubCategory, itemCode, year, lastOutDateStart, lastOutDateEnd, | |||
| ) | |||
| @RequestParam(required = false) lastOutDateEnd: String?, | |||
| @RequestParam(required = false) view: String?, | |||
| @RequestParam(required = false) reportDate: String?, | |||
| @RequestParam(required = false) reportMonth: String?, | |||
| @RequestParam(required = false) reportWeek: String?, | |||
| ): ResponseEntity<ByteArray> = buildExcel( | |||
| SemiFgReportFilter( | |||
| view, stockCategory, stockSubCategory, itemCode, year, | |||
| lastOutDateStart, lastOutDateEnd, reportDate, reportMonth, | |||
| reportWeek = reportWeek, | |||
| ), | |||
| ) | |||
| @PostMapping("/print-semi-fg-production-analysis-excel") | |||
| fun exportSemiFGProductionAnalysisReportExcelPost( | |||
| @RequestBody(required = false) req: ReportItemFilterRequest?, | |||
| ): ResponseEntity<ByteArray> { | |||
| val r = req ?: ReportItemFilterRequest() | |||
| return buildSemiFgProductionAnalysisExcel( | |||
| r.stockCategory, | |||
| r.stockSubCategory, | |||
| r.combinedItemCode(), | |||
| r.year, | |||
| r.lastOutDateStart, | |||
| r.lastOutDateEnd, | |||
| ) | |||
| } | |||
| private fun buildSemiFgProductionAnalysisExcel( | |||
| stockCategory: String?, | |||
| stockSubCategory: String?, | |||
| itemCode: String?, | |||
| year: String?, | |||
| lastOutDateStart: String?, | |||
| lastOutDateEnd: String?, | |||
| ): ResponseEntity<ByteArray> { | |||
| val dbData = semiFGProductionAnalysisReportService.searchSemiFGProductionAnalysisReport( | |||
| stockCategory, | |||
| stockSubCategory, | |||
| itemCode, | |||
| year, | |||
| lastOutDateStart, | |||
| lastOutDateEnd | |||
| ) | |||
| val excelBytes = createSemiFGProductionAnalysisExcel( | |||
| dbData = dbData, | |||
| reportTitle = "成品/半成品生產分析報告", | |||
| year = year | |||
| ) | |||
| ): ResponseEntity<ByteArray> = buildExcel(filterOf(req)) | |||
| val headers = HttpHeaders().apply { | |||
| contentType = MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") | |||
| setContentDispositionFormData("attachment", "SemiFGProductionAnalysisReport.xlsx") | |||
| set("filename", "SemiFGProductionAnalysisReport.xlsx") | |||
| } | |||
| return ResponseEntity(excelBytes, headers, HttpStatus.OK) | |||
| @GetMapping("/semi-fg-item-codes") | |||
| fun getSemiFGItemCodes( | |||
| @RequestParam(required = false) stockCategory: String?, | |||
| ): ResponseEntity<List<Map<String, String>>> { | |||
| val itemCodes = semiFGProductionAnalysisReportService.getSemiFGItemCodes(stockCategory) | |||
| return ResponseEntity(itemCodes, HttpStatus.OK) | |||
| } | |||
| private fun createSemiFGProductionAnalysisExcel( | |||
| dbData: List<Map<String, Any>>, | |||
| reportTitle: String, | |||
| year: String? | |||
| ): ByteArray { | |||
| val workbook = XSSFWorkbook() | |||
| val safeSheetName = WorkbookUtil.createSafeSheetName(reportTitle) | |||
| val sheet = workbook.createSheet(safeSheetName) | |||
| val totalColumns = 16 | |||
| var rowIndex = 0 | |||
| val titleStyle = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.CENTER | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| } | |||
| val titleFont = workbook.createFont().apply { | |||
| bold = true | |||
| fontHeightInPoints = 16 | |||
| } | |||
| titleStyle.setFont(titleFont) | |||
| val infoStyle = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.LEFT | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| } | |||
| val headerStyle = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.CENTER | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| fillForegroundColor = IndexedColors.GREY_25_PERCENT.index | |||
| fillPattern = FillPatternType.SOLID_FOREGROUND | |||
| borderTop = BorderStyle.THIN | |||
| borderBottom = BorderStyle.THIN | |||
| borderLeft = BorderStyle.THIN | |||
| borderRight = BorderStyle.THIN | |||
| } | |||
| val headerFont = workbook.createFont().apply { bold = true } | |||
| headerStyle.setFont(headerFont) | |||
| val textStyle = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.LEFT | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| borderTop = BorderStyle.THIN | |||
| borderBottom = BorderStyle.THIN | |||
| borderLeft = BorderStyle.THIN | |||
| borderRight = BorderStyle.THIN | |||
| } | |||
| val numberStyle = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.RIGHT | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| borderTop = BorderStyle.THIN | |||
| borderBottom = BorderStyle.THIN | |||
| borderLeft = BorderStyle.THIN | |||
| borderRight = BorderStyle.THIN | |||
| dataFormat = workbook.creationHelper.createDataFormat().getFormat("#,##0") | |||
| } | |||
| val dashStyle = workbook.createCellStyle().apply { | |||
| alignment = HorizontalAlignment.RIGHT | |||
| verticalAlignment = VerticalAlignment.CENTER | |||
| borderTop = BorderStyle.THIN | |||
| borderBottom = BorderStyle.THIN | |||
| borderLeft = BorderStyle.THIN | |||
| borderRight = BorderStyle.THIN | |||
| } | |||
| val titleRow = sheet.createRow(rowIndex++) | |||
| titleRow.heightInPoints = 28f | |||
| val titleCell = titleRow.createCell(0) | |||
| titleCell.setCellValue(reportTitle) | |||
| titleCell.cellStyle = titleStyle | |||
| sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1)) | |||
| val infoRow = sheet.createRow(rowIndex++) | |||
| val reportDateCell = infoRow.createCell(0) | |||
| reportDateCell.setCellValue("報告日期:${LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))}") | |||
| reportDateCell.cellStyle = infoStyle | |||
| val reportTimeCell = infoRow.createCell(6) | |||
| reportTimeCell.setCellValue("報告時間:${LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))}") | |||
| reportTimeCell.cellStyle = infoStyle | |||
| val reportYearCell = infoRow.createCell(11) | |||
| reportYearCell.setCellValue("年份:${year?.takeIf { it.isNotBlank() && it != "All" } ?: "All"}") | |||
| reportYearCell.cellStyle = infoStyle | |||
| sheet.addMergedRegion(CellRangeAddress(1, 1, 0, 5)) | |||
| sheet.addMergedRegion(CellRangeAddress(1, 1, 6, 10)) | |||
| sheet.addMergedRegion(CellRangeAddress(1, 1, 11, 15)) | |||
| rowIndex++ // spacer row | |||
| val headers = listOf( | |||
| "貨品編號", | |||
| "貨品名稱", | |||
| "單位", | |||
| "一月", | |||
| "二月", | |||
| "三月", | |||
| "四月", | |||
| "五月", | |||
| "六月", | |||
| "七月", | |||
| "八月", | |||
| "九月", | |||
| "十月", | |||
| "十一月", | |||
| "十二月", | |||
| "上架總計" | |||
| ) | |||
| val headerRow = sheet.createRow(rowIndex++) | |||
| headers.forEachIndexed { index, name -> | |||
| val cell = headerRow.createCell(index) | |||
| cell.setCellValue(name) | |||
| cell.cellStyle = headerStyle | |||
| } | |||
| if (dbData.isEmpty()) { | |||
| val dataRow = sheet.createRow(rowIndex++) | |||
| setTextCell(dataRow, 0, "-", textStyle) | |||
| setTextCell(dataRow, 1, "-", textStyle) | |||
| setTextCell(dataRow, 2, "-", textStyle) | |||
| for (col in 3 until totalColumns) { | |||
| setDashCell(dataRow, col, dashStyle) | |||
| } | |||
| } else { | |||
| dbData.forEach { row -> | |||
| val dataRow = sheet.createRow(rowIndex++) | |||
| setTextCell(dataRow, 0, row["itemNo"]?.toString()?.ifBlank { "-" } ?: "-", textStyle) | |||
| setTextCell(dataRow, 1, row["itemName"]?.toString()?.ifBlank { "-" } ?: "-", textStyle) | |||
| setTextCell(dataRow, 2, row["unitOfMeasure"]?.toString()?.ifBlank { "-" } ?: "-", textStyle) | |||
| setNumberCell(dataRow, 3, row["qtyJan"], numberStyle, dashStyle) | |||
| setNumberCell(dataRow, 4, row["qtyFeb"], numberStyle, dashStyle) | |||
| setNumberCell(dataRow, 5, row["qtyMar"], numberStyle, dashStyle) | |||
| setNumberCell(dataRow, 6, row["qtyApr"], numberStyle, dashStyle) | |||
| setNumberCell(dataRow, 7, row["qtyMay"], numberStyle, dashStyle) | |||
| setNumberCell(dataRow, 8, row["qtyJun"], numberStyle, dashStyle) | |||
| setNumberCell(dataRow, 9, row["qtyJul"], numberStyle, dashStyle) | |||
| setNumberCell(dataRow, 10, row["qtyAug"], numberStyle, dashStyle) | |||
| setNumberCell(dataRow, 11, row["qtySep"], numberStyle, dashStyle) | |||
| setNumberCell(dataRow, 12, row["qtyOct"], numberStyle, dashStyle) | |||
| setNumberCell(dataRow, 13, row["qtyNov"], numberStyle, dashStyle) | |||
| setNumberCell(dataRow, 14, row["qtyDec"], numberStyle, dashStyle) | |||
| setNumberCell(dataRow, 15, row["totalProductionQty"], numberStyle, dashStyle) | |||
| } | |||
| } | |||
| val widths = intArrayOf(18, 24, 12, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 12) | |||
| widths.forEachIndexed { idx, width -> sheet.setColumnWidth(idx, width * 256) } | |||
| addSemiFGProductionChartSheets(workbook, dbData) | |||
| val output = ByteArrayOutputStream() | |||
| workbook.use { it.write(output) } | |||
| return output.toByteArray() | |||
| @GetMapping("/semi-fg-item-codes-with-category") | |||
| fun getSemiFGItemCodesWithCategory( | |||
| @RequestParam(required = false) stockCategory: String?, | |||
| ): ResponseEntity<List<Map<String, String>>> { | |||
| val itemCodesWithCategory = semiFGProductionAnalysisReportService.getSemiFGItemCodesWithCategory(stockCategory) | |||
| return ResponseEntity(itemCodesWithCategory, HttpStatus.OK) | |||
| } | |||
| /** | |||
| * Adds a visible chart sheet with: | |||
| * - Cell B1 dropdown to choose 貨品編號 or 「全部」(sum of all items) | |||
| * - Formulas that pull monthly qty from a very-hidden data sheet | |||
| * - Column bar chart bound to those formula cells (updates when selection changes) | |||
| */ | |||
| private fun addSemiFGProductionChartSheets(workbook: XSSFWorkbook, dbData: List<Map<String, Any>>) { | |||
| val monthKeys = listOf( | |||
| "qtyJan", "qtyFeb", "qtyMar", "qtyApr", "qtyMay", "qtyJun", | |||
| "qtyJul", "qtyAug", "qtySep", "qtyOct", "qtyNov", "qtyDec", | |||
| ) | |||
| val monthLabels = listOf( | |||
| "一月", "二月", "三月", "四月", "五月", "六月", | |||
| "七月", "八月", "九月", "十月", "十一月", "十二月", | |||
| private fun filterOf(req: ReportItemFilterRequest?): SemiFgReportFilter { | |||
| val r = req ?: ReportItemFilterRequest() | |||
| return SemiFgReportFilter( | |||
| view = r.view, | |||
| stockCategory = r.stockCategory, | |||
| stockSubCategory = r.stockSubCategory, | |||
| itemCode = r.combinedItemCode(), | |||
| year = r.year, | |||
| lastOutDateStart = r.lastOutDateStart, | |||
| lastOutDateEnd = r.lastOutDateEnd, | |||
| reportDate = r.reportDate, | |||
| reportMonth = r.reportMonth, | |||
| lang = r.lang, | |||
| reportWeek = r.reportWeek, | |||
| ) | |||
| val dataSheetName = WorkbookUtil.createSafeSheetName("圖表數據") | |||
| val dataSheet = workbook.createSheet(dataSheetName) | |||
| val headerRow = dataSheet.createRow(0) | |||
| headerRow.createCell(0).setCellValue("貨品編號") | |||
| monthLabels.forEachIndexed { i, label -> | |||
| headerRow.createCell(1 + i).setCellValue(label) | |||
| } | |||
| dbData.forEachIndexed { idx, row -> | |||
| val r = dataSheet.createRow(1 + idx) | |||
| r.createCell(0).setCellValue(row["itemNo"]?.toString() ?: "") | |||
| monthKeys.forEachIndexed { mi, key -> | |||
| r.createCell(1 + mi).setCellValue(parseNumericForExcelChart(row[key])) | |||
| } | |||
| } | |||
| val zColIndex = CellReference.convertColStringToIndex("Z") | |||
| val zHeader = dataSheet.getRow(0) ?: dataSheet.createRow(0) | |||
| zHeader.createCell(zColIndex).setCellValue("全部") | |||
| dbData.forEachIndexed { idx, row -> | |||
| val r = dataSheet.getRow(1 + idx) ?: dataSheet.createRow(1 + idx) | |||
| r.createCell(zColIndex).setCellValue(row["itemNo"]?.toString() ?: "") | |||
| } | |||
| val lastDataRow1Based = 1 + dbData.size | |||
| val zListEnd1Based = 1 + dbData.size | |||
| val chartSheetName = WorkbookUtil.createSafeSheetName("生產圖表") | |||
| val chartSheet = workbook.createSheet(chartSheetName) | |||
| val labelR = chartSheet.createRow(0) | |||
| labelR.createCell(0).setCellValue("選擇貨品編號") | |||
| val filterCell = labelR.createCell(1) | |||
| val defaultItemNo = dbData.firstOrNull()?.get("itemNo")?.toString()?.trim() | |||
| filterCell.setCellValue(defaultItemNo?.takeIf { it.isNotBlank() } ?: "全部") | |||
| val dvHelper: DataValidationHelper = chartSheet.dataValidationHelper | |||
| val listFormula = "'$dataSheetName'!\$Z\$1:\$Z\$$zListEnd1Based" | |||
| val constraint: DataValidationConstraint = dvHelper.createFormulaListConstraint(listFormula) | |||
| val regions = CellRangeAddressList(0, 0, 1, 1) | |||
| val validation = dvHelper.createValidation(constraint, regions) | |||
| validation.showErrorBox = true | |||
| validation.createErrorBox("無效選擇", "請從清單中選擇貨品編號或「全部」。") | |||
| validation.showPromptBox = true | |||
| validation.createPromptBox("篩選", "選擇貨品編號檢視該品項各月數量;選擇「全部」顯示所有品項加總。") | |||
| chartSheet.addValidationData(validation) | |||
| val catRowIndex = 3 | |||
| val valRowIndex = 4 | |||
| val firstMonthCol = 1 | |||
| val lastMonthCol = 12 | |||
| val catRow = chartSheet.createRow(catRowIndex) | |||
| val valRow = chartSheet.createRow(valRowIndex) | |||
| for (i in 0 until 12) { | |||
| catRow.createCell(firstMonthCol + i).setCellValue(monthLabels[i]) | |||
| val colLetter = CellReference.convertNumToColString(firstMonthCol + i) | |||
| val formula = if (lastDataRow1Based < 2) { | |||
| "0" | |||
| } else { | |||
| "IF(TRIM(\$B\$1)=\"全部\",SUM('$dataSheetName'!$colLetter\$2:$colLetter\$$lastDataRow1Based),IFERROR(INDEX('$dataSheetName'!$colLetter\$2:$colLetter\$$lastDataRow1Based,MATCH(TRIM(\$B\$1),'$dataSheetName'!\$A\$2:\$A\$$lastDataRow1Based,0)),0))" | |||
| } | |||
| valRow.createCell(firstMonthCol + i).setCellFormula(formula) | |||
| } | |||
| catRow.createCell(lastMonthCol + 1).setCellValue("總和") | |||
| valRow.createCell(lastMonthCol + 1).setCellFormula("SUM(B5:M5)") | |||
| val drawing = chartSheet.createDrawingPatriarch() | |||
| val anchor = XSSFClientAnchor(0, 0, 0, 0, 1, 6, 14, 28) | |||
| val chart = drawing.createChart(anchor) | |||
| chart.setTitleText("各月生產數量(依選擇之貨品編號)") | |||
| chart.setTitleOverlay(false) | |||
| val legend = chart.getOrAddLegend() | |||
| legend.position = LegendPosition.BOTTOM | |||
| val bottomAxis = chart.createCategoryAxis(AxisPosition.BOTTOM) | |||
| bottomAxis.setTitle("月份") | |||
| val leftAxis = chart.createValueAxis(AxisPosition.LEFT) | |||
| leftAxis.setTitle("生產數量") | |||
| val catRangeAddr = CellRangeAddress(catRowIndex, catRowIndex, firstMonthCol, lastMonthCol) | |||
| val valRangeAddr = CellRangeAddress(valRowIndex, valRowIndex, firstMonthCol, lastMonthCol) | |||
| val categories = XDDFDataSourcesFactory.fromStringCellRange(chartSheet, catRangeAddr) | |||
| val values = XDDFDataSourcesFactory.fromNumericCellRange(chartSheet, valRangeAddr) | |||
| val barData = chart.createData(ChartTypes.BAR, bottomAxis, leftAxis) as XDDFBarChartData | |||
| barData.barDirection = BarDirection.COL | |||
| val series = barData.addSeries(categories, values) | |||
| series.setTitle("生產數量", null) | |||
| chart.plot(barData) | |||
| chartSheet.setColumnWidth(0, 18 * 256) | |||
| chartSheet.setColumnWidth(1, 10 * 256) | |||
| chartSheet.setColumnWidth(lastMonthCol + 1, 12 * 256) | |||
| workbook.setSheetVisibility(workbook.getSheetIndex(dataSheet), SheetVisibility.VERY_HIDDEN) | |||
| workbook.setSheetOrder(chartSheetName, 1) | |||
| } | |||
| private fun parseNumericForExcelChart(value: Any?): Double { | |||
| if (value == null) return 0.0 | |||
| return when (value) { | |||
| is Number -> value.toDouble() | |||
| is String -> { | |||
| val s = value.replace(",", "").trim() | |||
| if (s.isEmpty() || s == "-") return 0.0 | |||
| s.toDoubleOrNull() ?: 0.0 | |||
| private fun buildPdf(filter: SemiFgReportFilter): ResponseEntity<ByteArray> { | |||
| val data = load(filter) | |||
| val text = SemiFgReportText.of(filter.lang) | |||
| val today = LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE) | |||
| val time = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")) | |||
| val pdfBytes = if (data.view == SemiFgView.YEAR) { | |||
| val parameters = mutableMapOf<String, Any>( | |||
| "stockCategory" to (filter.stockCategory ?: "All"), | |||
| "stockSubCategory" to (filter.stockSubCategory ?: "All"), | |||
| "itemNo" to (filter.itemCode ?: "All"), | |||
| "year" to (data.window.year?.toString() ?: ""), | |||
| "reportDate" to today, | |||
| "reportTime" to time, | |||
| "lastOutDateStart" to data.window.start.toString(), | |||
| "lastOutDateEnd" to data.window.endInclusive.toString(), | |||
| "deliveryPeriodStart" to "", | |||
| "deliveryPeriodEnd" to "", | |||
| ) | |||
| parameters.putAll(pdfHeaderParameters(text)) | |||
| reportService.createPdfResponse( | |||
| "/jasper/SemiFGProductionAnalysisReport.jrxml", | |||
| parameters, | |||
| SemiFgProductionAnalysisLayout.yearPivotRows(data), | |||
| ) | |||
| } else { | |||
| val rows = SemiFgProductionAnalysisLayout.listRows(data).map { | |||
| mapOf( | |||
| "period" to it.period, | |||
| "itemNo" to it.itemNo, | |||
| "itemName" to it.itemName, | |||
| "unitOfMeasure" to it.unitOfMeasure, | |||
| "qtyText" to SemiFgProductionAnalysisLayout.roundQty(it.qty).toString(), | |||
| ) | |||
| }.ifEmpty { | |||
| listOf( | |||
| mapOf( | |||
| "period" to "-", | |||
| "itemNo" to "-", | |||
| "itemName" to "-", | |||
| "unitOfMeasure" to "-", | |||
| "qtyText" to "-", | |||
| ), | |||
| ) | |||
| } | |||
| else -> value.toString().replace(",", "").trim().toDoubleOrNull() ?: 0.0 | |||
| } | |||
| } | |||
| private fun setTextCell( | |||
| row: org.apache.poi.ss.usermodel.Row, | |||
| col: Int, | |||
| value: Any?, | |||
| style: org.apache.poi.ss.usermodel.CellStyle | |||
| ) { | |||
| val cell = row.createCell(col) | |||
| cell.setCellValue(value?.toString() ?: "") | |||
| cell.cellStyle = style | |||
| } | |||
| private fun setNumberCell( | |||
| row: org.apache.poi.ss.usermodel.Row, | |||
| col: Int, | |||
| value: Any?, | |||
| style: org.apache.poi.ss.usermodel.CellStyle, | |||
| dashStyle: org.apache.poi.ss.usermodel.CellStyle | |||
| ) { | |||
| val cell = row.createCell(col) | |||
| val parsed = when (value) { | |||
| is Number -> value.toDouble() | |||
| is String -> value.replace(",", "").toDoubleOrNull() ?: 0.0 | |||
| else -> value?.toString()?.replace(",", "")?.toDoubleOrNull() ?: 0.0 | |||
| val parameters = mutableMapOf<String, Any>( | |||
| "reportTitle" to text.title, | |||
| "periodLabel" to text.periodHeading(data.window), | |||
| "reportDate" to today, | |||
| "reportTime" to time, | |||
| ) | |||
| parameters.putAll(pdfHeaderParameters(text)) | |||
| reportService.createPdfResponse( | |||
| "/jasper/SemiFGProductionAnalysisListReport.jrxml", | |||
| parameters, | |||
| rows, | |||
| ) | |||
| } | |||
| if (parsed == 0.0) { | |||
| cell.setCellValue("-") | |||
| cell.cellStyle = dashStyle | |||
| } else { | |||
| cell.setCellValue(parsed.roundToLong().toDouble()) | |||
| cell.cellStyle = style | |||
| val headers = HttpHeaders().apply { | |||
| contentType = MediaType.APPLICATION_PDF | |||
| setContentDispositionFormData("attachment", "SemiFGProductionAnalysisReport.pdf") | |||
| set("filename", "SemiFGProductionAnalysisReport.pdf") | |||
| } | |||
| return ResponseEntity(pdfBytes, headers, HttpStatus.OK) | |||
| } | |||
| private fun setDashCell( | |||
| row: org.apache.poi.ss.usermodel.Row, | |||
| col: Int, | |||
| style: org.apache.poi.ss.usermodel.CellStyle | |||
| ) { | |||
| val cell = row.createCell(col) | |||
| cell.setCellValue("-") | |||
| cell.cellStyle = style | |||
| private fun buildExcel(filter: SemiFgReportFilter): ResponseEntity<ByteArray> { | |||
| val bytes = workbook.build(load(filter), filter.lang) | |||
| val headers = HttpHeaders().apply { | |||
| contentType = MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") | |||
| setContentDispositionFormData("attachment", "SemiFGProductionAnalysisReport.xlsx") | |||
| set("filename", "SemiFGProductionAnalysisReport.xlsx") | |||
| } | |||
| return ResponseEntity(bytes, headers, HttpStatus.OK) | |||
| } | |||
| @GetMapping("/semi-fg-item-codes") | |||
| fun getSemiFGItemCodes( | |||
| @RequestParam(required = false) stockCategory: String? | |||
| ): ResponseEntity<List<Map<String, String>>> { | |||
| val itemCodes = semiFGProductionAnalysisReportService.getSemiFGItemCodes(stockCategory) | |||
| return ResponseEntity(itemCodes, HttpStatus.OK) | |||
| private fun pdfHeaderParameters(text: SemiFgReportText): Map<String, Any> { | |||
| val months = listOf("hJan", "hFeb", "hMar", "hApr", "hMay", "hJun", "hJul", "hAug", "hSep", "hOct", "hNov", "hDec") | |||
| val headers = mutableMapOf<String, Any>( | |||
| "hTitle" to text.title, | |||
| "hPage" to text.pageLabel, | |||
| "hReportDate" to text.reportDateLabel, | |||
| "hReportTime" to text.reportTimeLabel, | |||
| "hYear" to text.yearLabel, | |||
| "hProdDate" to text.productionDateLabel, | |||
| "hTo" to text.toLabel, | |||
| "hItemNo" to text.itemNo, | |||
| "hItemName" to text.itemName, | |||
| "hUom" to text.uom, | |||
| "hTotal" to text.putAwayTotal, | |||
| "hPeriod" to text.period, | |||
| "hQty" to text.putAwayQty, | |||
| ) | |||
| months.forEachIndexed { index, name -> headers[name] = text.monthLabels[index] } | |||
| return headers | |||
| } | |||
| @GetMapping("/semi-fg-item-codes-with-category") | |||
| fun getSemiFGItemCodesWithCategory( | |||
| @RequestParam(required = false) stockCategory: String? | |||
| ): ResponseEntity<List<Map<String, String>>> { | |||
| val itemCodesWithCategory = semiFGProductionAnalysisReportService.getSemiFGItemCodesWithCategory(stockCategory) | |||
| return ResponseEntity(itemCodesWithCategory, HttpStatus.OK) | |||
| private fun load(filter: SemiFgReportFilter) = try { | |||
| semiFGProductionAnalysisReportService.loadReport(filter) | |||
| } catch (ex: SemiFgReportRequestException) { | |||
| throw ResponseStatusException(HttpStatus.BAD_REQUEST, ex.message) | |||
| } | |||
| } | |||
| @@ -0,0 +1,142 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | |||
| <jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://jasperreports.sourceforge.net/jasperreports http://jasperreports.sourceforge.net/xsd/jasperreport.xsd" name="SemiFGProductionAnalysisListReport" pageWidth="842" pageHeight="595" orientation="Landscape" columnWidth="802" leftMargin="20" rightMargin="20" topMargin="20" bottomMargin="20" uuid="7c1a0e2b-4f6d-4a1e-9b3c-2d8e5f0a1b44"> | |||
| <parameter name="reportTitle" class="java.lang.String"/> | |||
| <parameter name="periodLabel" class="java.lang.String"/> | |||
| <parameter name="reportDate" class="java.lang.String"/> | |||
| <parameter name="reportTime" class="java.lang.String"/> | |||
| <parameter name="hTitle" class="java.lang.String"/> | |||
| <parameter name="hPage" class="java.lang.String"/> | |||
| <parameter name="hReportDate" class="java.lang.String"/> | |||
| <parameter name="hReportTime" class="java.lang.String"/> | |||
| <parameter name="hYear" class="java.lang.String"/> | |||
| <parameter name="hProdDate" class="java.lang.String"/> | |||
| <parameter name="hTo" class="java.lang.String"/> | |||
| <parameter name="hItemNo" class="java.lang.String"/> | |||
| <parameter name="hItemName" class="java.lang.String"/> | |||
| <parameter name="hUom" class="java.lang.String"/> | |||
| <parameter name="hTotal" class="java.lang.String"/> | |||
| <parameter name="hPeriod" class="java.lang.String"/> | |||
| <parameter name="hQty" class="java.lang.String"/> | |||
| <parameter name="hJan" class="java.lang.String"/> | |||
| <parameter name="hFeb" class="java.lang.String"/> | |||
| <parameter name="hMar" class="java.lang.String"/> | |||
| <parameter name="hApr" class="java.lang.String"/> | |||
| <parameter name="hMay" class="java.lang.String"/> | |||
| <parameter name="hJun" class="java.lang.String"/> | |||
| <parameter name="hJul" class="java.lang.String"/> | |||
| <parameter name="hAug" class="java.lang.String"/> | |||
| <parameter name="hSep" class="java.lang.String"/> | |||
| <parameter name="hOct" class="java.lang.String"/> | |||
| <parameter name="hNov" class="java.lang.String"/> | |||
| <parameter name="hDec" class="java.lang.String"/> | |||
| <field name="period" class="java.lang.String"/> | |||
| <field name="itemNo" class="java.lang.String"/> | |||
| <field name="itemName" class="java.lang.String"/> | |||
| <field name="unitOfMeasure" class="java.lang.String"/> | |||
| <field name="qtyText" class="java.lang.String"/> | |||
| <pageHeader> | |||
| <band height="70"> | |||
| <textField> | |||
| <reportElement x="0" y="0" width="802" height="28" uuid="a1b2c3d4-e5f6-7890-abcd-ef1234567890"/> | |||
| <textElement textAlignment="Center" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="16" isBold="true"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$P{hTitle}]]></textFieldExpression></textField> | |||
| <textField> | |||
| <reportElement x="0" y="30" width="400" height="18" uuid="b2c3d4e5-f6a7-8901-bcde-f12345678901"/> | |||
| <textElement verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="11" isBold="true"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$P{periodLabel}]]></textFieldExpression> | |||
| </textField> | |||
| <textField> | |||
| <reportElement x="420" y="30" width="180" height="18" uuid="c3d4e5f6-a7b8-9012-cdef-123456789012"/> | |||
| <textElement verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$P{hReportDate} + $P{reportDate}]]></textFieldExpression> | |||
| </textField> | |||
| <textField> | |||
| <reportElement x="610" y="30" width="180" height="18" uuid="d4e5f6a7-b8c9-0123-def0-234567890123"/> | |||
| <textElement verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$P{hReportTime} + $P{reportTime}]]></textFieldExpression> | |||
| </textField> | |||
| </band> | |||
| </pageHeader> | |||
| <columnHeader> | |||
| <band height="24"> | |||
| <textField> | |||
| <reportElement x="0" y="0" width="120" height="20" uuid="e5f6a7b8-c9d0-1234-ef01-345678901234"/> | |||
| <textElement verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10" isBold="true"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$P{hPeriod}]]></textFieldExpression></textField> | |||
| <textField> | |||
| <reportElement x="120" y="0" width="120" height="20" uuid="f6a7b8c9-d0e1-2345-f012-456789012345"/> | |||
| <textElement verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10" isBold="true"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$P{hItemNo}]]></textFieldExpression></textField> | |||
| <textField> | |||
| <reportElement x="240" y="0" width="320" height="20" uuid="a7b8c9d0-e1f2-3456-0123-567890123456"/> | |||
| <textElement verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10" isBold="true"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$P{hItemName}]]></textFieldExpression></textField> | |||
| <textField> | |||
| <reportElement x="560" y="0" width="100" height="20" uuid="b8c9d0e1-f2a3-4567-1234-678901234567"/> | |||
| <textElement verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10" isBold="true"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$P{hUom}]]></textFieldExpression></textField> | |||
| <textField> | |||
| <reportElement x="660" y="0" width="120" height="20" uuid="c9d0e1f2-a3b4-5678-2345-789012345678"/> | |||
| <textElement textAlignment="Right" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10" isBold="true"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$P{hQty}]]></textFieldExpression></textField> | |||
| </band> | |||
| </columnHeader> | |||
| <detail> | |||
| <band height="20"> | |||
| <textField> | |||
| <reportElement x="0" y="0" width="120" height="18" uuid="d0e1f2a3-b4c5-6789-3456-890123456789"/> | |||
| <textElement verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{period}]]></textFieldExpression> | |||
| </textField> | |||
| <textField> | |||
| <reportElement x="120" y="0" width="120" height="18" uuid="e1f2a3b4-c5d6-7890-4567-901234567890"/> | |||
| <textElement verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{itemNo}]]></textFieldExpression> | |||
| </textField> | |||
| <textField> | |||
| <reportElement x="240" y="0" width="320" height="18" uuid="f2a3b4c5-d6e7-8901-5678-012345678901"/> | |||
| <textElement verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{itemName}]]></textFieldExpression> | |||
| </textField> | |||
| <textField> | |||
| <reportElement x="560" y="0" width="100" height="18" uuid="a3b4c5d6-e7f8-9012-6789-123456789012"/> | |||
| <textElement verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{unitOfMeasure}]]></textFieldExpression> | |||
| </textField> | |||
| <textField> | |||
| <reportElement x="660" y="0" width="120" height="18" uuid="b4c5d6e7-f8a9-0123-7890-234567890123"/> | |||
| <textElement textAlignment="Right" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$F{qtyText}]]></textFieldExpression> | |||
| </textField> | |||
| </band> | |||
| </detail> | |||
| </jasperReport> | |||
| @@ -32,6 +32,32 @@ | |||
| <parameter name="lastOutDateEnd" class="java.lang.String"> | |||
| <parameterDescription><![CDATA["lastOutDateStart"]]></parameterDescription> | |||
| </parameter> | |||
| <parameter name="hTitle" class="java.lang.String"/> | |||
| <parameter name="hPage" class="java.lang.String"/> | |||
| <parameter name="hReportDate" class="java.lang.String"/> | |||
| <parameter name="hReportTime" class="java.lang.String"/> | |||
| <parameter name="hYear" class="java.lang.String"/> | |||
| <parameter name="hProdDate" class="java.lang.String"/> | |||
| <parameter name="hTo" class="java.lang.String"/> | |||
| <parameter name="hItemNo" class="java.lang.String"/> | |||
| <parameter name="hItemName" class="java.lang.String"/> | |||
| <parameter name="hUom" class="java.lang.String"/> | |||
| <parameter name="hTotal" class="java.lang.String"/> | |||
| <parameter name="hPeriod" class="java.lang.String"/> | |||
| <parameter name="hQty" class="java.lang.String"/> | |||
| <parameter name="hJan" class="java.lang.String"/> | |||
| <parameter name="hFeb" class="java.lang.String"/> | |||
| <parameter name="hMar" class="java.lang.String"/> | |||
| <parameter name="hApr" class="java.lang.String"/> | |||
| <parameter name="hMay" class="java.lang.String"/> | |||
| <parameter name="hJun" class="java.lang.String"/> | |||
| <parameter name="hJul" class="java.lang.String"/> | |||
| <parameter name="hAug" class="java.lang.String"/> | |||
| <parameter name="hSep" class="java.lang.String"/> | |||
| <parameter name="hOct" class="java.lang.String"/> | |||
| <parameter name="hNov" class="java.lang.String"/> | |||
| <parameter name="hDec" class="java.lang.String"/> | |||
| <queryString> | |||
| <![CDATA[select * from fpsmsdb.items , fpsmsdb.stock_out_line]]> | |||
| </queryString> | |||
| @@ -366,7 +392,7 @@ | |||
| <textElement textAlignment="Center" verticalAlignment="Middle"/> | |||
| <textFieldExpression><![CDATA[$V{PAGE_NUMBER}]]></textFieldExpression> | |||
| </textField> | |||
| <staticText> | |||
| <textField> | |||
| <reportElement x="677" y="0" width="40" height="23" uuid="5a9e0fa9-418d-4838-9f82-e2f336e5bce7"> | |||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| @@ -374,8 +400,7 @@ | |||
| <textElement textAlignment="Center" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="12"/> | |||
| </textElement> | |||
| <text><![CDATA[頁數]]></text> | |||
| </staticText> | |||
| <textFieldExpression><![CDATA[$P{hPage}]]></textFieldExpression></textField> | |||
| <staticText> | |||
| <reportElement x="747" y="0" width="21" height="23" uuid="26d3d09c-48aa-4870-822a-c403e7faddfa"> | |||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | |||
| @@ -393,7 +418,7 @@ | |||
| <textElement textAlignment="Center" verticalAlignment="Middle"/> | |||
| <textFieldExpression><![CDATA[$V{PAGE_NUMBER}]]></textFieldExpression> | |||
| </textField> | |||
| <staticText> | |||
| <textField> | |||
| <reportElement x="10" y="30" width="90" height="23" uuid="0daddd8b-ca61-42af-9c2b-cbf11b7d7dac"> | |||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| @@ -401,8 +426,7 @@ | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="12" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[報告日期:]]></text> | |||
| </staticText> | |||
| <textFieldExpression><![CDATA[$P{hReportDate}]]></textFieldExpression></textField> | |||
| <textField> | |||
| <reportElement x="708" y="52" width="90" height="23" uuid="53b66bc3-4925-4340-add0-b4f2069a76c1"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| @@ -410,7 +434,7 @@ | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$P{year}]]></textFieldExpression> | |||
| </textField> | |||
| <staticText> | |||
| <textField> | |||
| <reportElement x="640" y="29" width="68" height="23" uuid="e2be43a3-3570-4a4e-a671-d96f872a5ad7"> | |||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| @@ -418,9 +442,8 @@ | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="12" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[報告時間:]]></text> | |||
| </staticText> | |||
| <staticText> | |||
| <textFieldExpression><![CDATA[$P{hReportTime}]]></textFieldExpression></textField> | |||
| <textField> | |||
| <reportElement x="640" y="52" width="68" height="23" uuid="29ed871d-3417-4978-9799-964389143451"> | |||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| @@ -428,8 +451,7 @@ | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="12" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[年份:]]></text> | |||
| </staticText> | |||
| <textFieldExpression><![CDATA[$P{hYear}]]></textFieldExpression></textField> | |||
| <textField> | |||
| <reportElement x="708" y="29" width="90" height="23" uuid="cb1fbb06-e953-40e5-bdbe-5fc219f7c884"/> | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| @@ -444,7 +466,7 @@ | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$P{reportDate}]]></textFieldExpression> | |||
| </textField> | |||
| <staticText> | |||
| <textField> | |||
| <reportElement x="300" y="0" width="210" height="23" uuid="7451001b-6d5a-438c-82db-2b9e687d9a27"> | |||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| @@ -452,8 +474,7 @@ | |||
| <textElement textAlignment="Center" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="16" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[成品/半成品生產分析報告]]></text> | |||
| </staticText> | |||
| <textFieldExpression><![CDATA[$P{hTitle}]]></textFieldExpression></textField> | |||
| <textField> | |||
| <reportElement x="100" y="53" width="336" height="23" uuid="b51949e1-c40a-4db0-a799-4243555893fd"> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| @@ -461,9 +482,9 @@ | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="12" isBold="true"/> | |||
| </textElement> | |||
| <textFieldExpression><![CDATA[$P{lastOutDateStart} + " 至 " + $P{lastOutDateEnd}]]></textFieldExpression> | |||
| <textFieldExpression><![CDATA[$P{lastOutDateStart} + " " + $P{hTo} + " " + $P{lastOutDateEnd}]]></textFieldExpression> | |||
| </textField> | |||
| <staticText> | |||
| <textField> | |||
| <reportElement x="10" y="53" width="90" height="23" uuid="fb09a559-f5fa-4e56-a891-87c600a2745a"> | |||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| @@ -472,8 +493,7 @@ | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="12" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[完成生產日期:]]></text> | |||
| </staticText> | |||
| <textFieldExpression><![CDATA[$P{hProdDate}]]></textFieldExpression></textField> | |||
| <line> | |||
| <reportElement x="0" y="75" width="799" height="1" uuid="f04b0470-72e7-42b6-80fd-f1dc4ea5fa66"> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| @@ -488,16 +508,15 @@ | |||
| </pageHeader> | |||
| <columnHeader> | |||
| <band height="28"> | |||
| <staticText> | |||
| <textField> | |||
| <reportElement x="200" y="0" width="45" height="20" uuid="e4267f38-4a00-4a22-965d-9ab531262592"> | |||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | |||
| </reportElement> | |||
| <textElement textAlignment="Right" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[一月]]></text> | |||
| </staticText> | |||
| <staticText> | |||
| <textFieldExpression><![CDATA[$P{hJan}]]></textFieldExpression></textField> | |||
| <textField> | |||
| <reportElement x="650" y="0" width="45" height="20" uuid="e51758d1-2a7e-4fef-8b41-206e9aa6c611"> | |||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| @@ -505,9 +524,8 @@ | |||
| <textElement textAlignment="Right" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[十一月]]></text> | |||
| </staticText> | |||
| <staticText> | |||
| <textFieldExpression><![CDATA[$P{hNov}]]></textFieldExpression></textField> | |||
| <textField> | |||
| <reportElement x="695" y="0" width="45" height="20" uuid="d7c79995-7b3c-4622-8d08-6b4a87345cbe"> | |||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| @@ -515,9 +533,8 @@ | |||
| <textElement textAlignment="Right" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[十二月]]></text> | |||
| </staticText> | |||
| <staticText> | |||
| <textFieldExpression><![CDATA[$P{hDec}]]></textFieldExpression></textField> | |||
| <textField> | |||
| <reportElement x="290" y="0" width="45" height="20" uuid="ac124afb-3aa8-4ec6-b4a8-8de161e8bbc5"> | |||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| @@ -525,9 +542,8 @@ | |||
| <textElement textAlignment="Right" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[三月]]></text> | |||
| </staticText> | |||
| <staticText> | |||
| <textFieldExpression><![CDATA[$P{hMar}]]></textFieldExpression></textField> | |||
| <textField> | |||
| <reportElement x="740" y="0" width="50" height="20" uuid="f092d4db-d5c5-4515-a826-8fec621a8992"> | |||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| @@ -535,9 +551,8 @@ | |||
| <textElement textAlignment="Right" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[上架總計]]></text> | |||
| </staticText> | |||
| <staticText> | |||
| <textFieldExpression><![CDATA[$P{hTotal}]]></textFieldExpression></textField> | |||
| <textField> | |||
| <reportElement x="560" y="0" width="45" height="20" uuid="85c77a9b-c044-4bc2-8cd9-3b0058e4b74e"> | |||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| @@ -545,9 +560,8 @@ | |||
| <textElement textAlignment="Right" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[九月]]></text> | |||
| </staticText> | |||
| <staticText> | |||
| <textFieldExpression><![CDATA[$P{hSep}]]></textFieldExpression></textField> | |||
| <textField> | |||
| <reportElement x="245" y="0" width="45" height="20" uuid="ec7698ae-c92b-4b44-9f7a-28f10ba5f385"> | |||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| @@ -555,9 +569,8 @@ | |||
| <textElement textAlignment="Right" verticalAlignment="Middle" markup="html"> | |||
| <font fontName="微軟正黑體" size="10" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[二月]]></text> | |||
| </staticText> | |||
| <staticText> | |||
| <textFieldExpression><![CDATA[$P{hFeb}]]></textFieldExpression></textField> | |||
| <textField> | |||
| <reportElement x="380" y="0" width="45" height="20" uuid="5204319c-5b4e-421b-9493-4dd988caca52"> | |||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| @@ -565,9 +578,8 @@ | |||
| <textElement textAlignment="Right" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[五月]]></text> | |||
| </staticText> | |||
| <staticText> | |||
| <textFieldExpression><![CDATA[$P{hMay}]]></textFieldExpression></textField> | |||
| <textField> | |||
| <reportElement x="605" y="0" width="45" height="20" uuid="a6161aa7-2abd-4bb6-894d-ccdd6f9575c4"> | |||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| @@ -575,9 +587,8 @@ | |||
| <textElement textAlignment="Right" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[十月]]></text> | |||
| </staticText> | |||
| <staticText> | |||
| <textFieldExpression><![CDATA[$P{hOct}]]></textFieldExpression></textField> | |||
| <textField> | |||
| <reportElement x="425" y="0" width="45" height="20" uuid="424446ec-655d-4bff-a30a-61e898dbc7ad"> | |||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| @@ -585,9 +596,8 @@ | |||
| <textElement textAlignment="Right" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[六月]]></text> | |||
| </staticText> | |||
| <staticText> | |||
| <textFieldExpression><![CDATA[$P{hJun}]]></textFieldExpression></textField> | |||
| <textField> | |||
| <reportElement x="515" y="0" width="45" height="20" uuid="2a9e0115-d056-4c4b-b0b2-0800f55435ad"> | |||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| @@ -595,9 +605,8 @@ | |||
| <textElement textAlignment="Right" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10" isBold="true" isItalic="false"/> | |||
| </textElement> | |||
| <text><![CDATA[八月]]></text> | |||
| </staticText> | |||
| <staticText> | |||
| <textFieldExpression><![CDATA[$P{hAug}]]></textFieldExpression></textField> | |||
| <textField> | |||
| <reportElement x="470" y="0" width="45" height="20" uuid="b221e73b-0f35-4036-bffb-9230a495b827"> | |||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| @@ -605,9 +614,8 @@ | |||
| <textElement textAlignment="Right" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[七月]]></text> | |||
| </staticText> | |||
| <staticText> | |||
| <textFieldExpression><![CDATA[$P{hJul}]]></textFieldExpression></textField> | |||
| <textField> | |||
| <reportElement x="335" y="0" width="45" height="20" uuid="8c5260bc-9079-4f62-803d-1c3e3fc9f41c"> | |||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| @@ -615,14 +623,13 @@ | |||
| <textElement textAlignment="Right" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[四月]]></text> | |||
| </staticText> | |||
| <textFieldExpression><![CDATA[$P{hApr}]]></textFieldExpression></textField> | |||
| <line> | |||
| <reportElement x="1" y="23" width="799" height="1" uuid="ad5d85ce-e7fe-46c0-9b37-76b2ed3f4152"> | |||
| <property name="com.jaspersoft.studio.unit.height" value="px"/> | |||
| </reportElement> | |||
| </line> | |||
| <staticText> | |||
| <textField> | |||
| <reportElement x="140" y="0" width="60" height="20" uuid="08d93aec-e4ae-46a6-afcb-3af9de518ca6"> | |||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| @@ -630,9 +637,8 @@ | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[單位]]></text> | |||
| </staticText> | |||
| <staticText> | |||
| <textFieldExpression><![CDATA[$P{hUom}]]></textFieldExpression></textField> | |||
| <textField> | |||
| <reportElement x="10" y="0" width="48" height="20" uuid="b4a1a243-36d7-46f7-90ea-b38dd5641a9c"> | |||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| @@ -640,9 +646,8 @@ | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[貨品編號]]></text> | |||
| </staticText> | |||
| <staticText> | |||
| <textFieldExpression><![CDATA[$P{hItemNo}]]></textFieldExpression></textField> | |||
| <textField> | |||
| <reportElement x="58" y="0" width="82" height="20" uuid="0b6e2d73-ed45-46c8-b330-07d81fe7f2da"> | |||
| <property name="com.jaspersoft.studio.unit.y" value="px"/> | |||
| <property name="com.jaspersoft.studio.unit.width" value="px"/> | |||
| @@ -650,8 +655,7 @@ | |||
| <textElement textAlignment="Left" verticalAlignment="Middle"> | |||
| <font fontName="微軟正黑體" size="10" isBold="true"/> | |||
| </textElement> | |||
| <text><![CDATA[貨品名稱]]></text> | |||
| </staticText> | |||
| <textFieldExpression><![CDATA[$P{hItemName}]]></textFieldExpression></textField> | |||
| </band> | |||
| </columnHeader> | |||
| </jasperReport> | |||
| @@ -0,0 +1,149 @@ | |||
| package com.ffii.fpsms.modules.report.service | |||
| import org.junit.jupiter.api.Assertions.assertEquals | |||
| import org.junit.jupiter.api.Test | |||
| import java.math.BigDecimal | |||
| import java.time.LocalDate | |||
| import java.time.YearMonth | |||
| class SemiFgProductionAnalysisLayoutTest { | |||
| private val today = LocalDate.of(2026, 9, 23) | |||
| @Test | |||
| fun `year view defaults to the current year`() { | |||
| val window = SemiFgProductionAnalysisLayout.resolveWindow( | |||
| SemiFgReportFilter(view = "year"), | |||
| today, | |||
| ) | |||
| assertEquals(SemiFgView.YEAR, window.view) | |||
| assertEquals(LocalDate.of(2026, 1, 1), window.start) | |||
| assertEquals(LocalDate.of(2026, 12, 31), window.endInclusive) | |||
| assertEquals(12, window.periods.size) | |||
| } | |||
| @Test | |||
| fun `day view uses the selected date`() { | |||
| val window = SemiFgProductionAnalysisLayout.resolveWindow( | |||
| SemiFgReportFilter(view = "day", reportDate = "2026-03-15"), | |||
| today, | |||
| ) | |||
| assertEquals(LocalDate.of(2026, 3, 15), window.day) | |||
| assertEquals(window.start, window.endInclusive) | |||
| } | |||
| @Test | |||
| fun `week view is Monday to Sunday of the selected date`() { | |||
| val mid = SemiFgProductionAnalysisLayout.resolveWindow( | |||
| SemiFgReportFilter(view = "week", reportWeek = "2026-09-23"), | |||
| today, | |||
| ) | |||
| assertEquals(LocalDate.of(2026, 9, 21), mid.start) | |||
| assertEquals(LocalDate.of(2026, 9, 27), mid.endInclusive) | |||
| assertEquals(7, mid.dayColumns.size) | |||
| val sunday = SemiFgProductionAnalysisLayout.resolveWindow( | |||
| SemiFgReportFilter(view = "week", reportWeek = "2026-09-27"), | |||
| today, | |||
| ) | |||
| assertEquals(mid.start, sunday.start) | |||
| val crossing = SemiFgProductionAnalysisLayout.resolveWindow( | |||
| SemiFgReportFilter(view = "week", reportWeek = "2026-03-30"), | |||
| today, | |||
| ) | |||
| assertEquals(LocalDate.of(2026, 3, 30), crossing.start) | |||
| assertEquals(LocalDate.of(2026, 4, 5), crossing.endInclusive) | |||
| } | |||
| @Test | |||
| fun `month view covers every day of the month`() { | |||
| val window = SemiFgProductionAnalysisLayout.resolveWindow( | |||
| SemiFgReportFilter(view = "month", reportMonth = "2024-02"), | |||
| today, | |||
| ) | |||
| assertEquals(LocalDate.of(2024, 2, 1), window.start) | |||
| assertEquals(LocalDate.of(2024, 2, 29), window.endInclusive) | |||
| } | |||
| @Test | |||
| fun `custom range of a few days keeps one column per day`() { | |||
| val window = SemiFgProductionAnalysisLayout.resolveWindow( | |||
| SemiFgReportFilter(view = "range", lastOutDateStart = "2026-03-28", lastOutDateEnd = "2026-04-02"), | |||
| today, | |||
| ) | |||
| assertEquals(SemiFgView.RANGE, window.view) | |||
| assertEquals(6, window.dayColumns.size) | |||
| assertEquals(LocalDate.of(2026, 3, 28), window.dayColumns.first()) | |||
| assertEquals(LocalDate.of(2026, 4, 2), window.dayColumns.last()) | |||
| } | |||
| @Test | |||
| fun `custom range of one month keeps one column per day`() { | |||
| val window = SemiFgProductionAnalysisLayout.resolveWindow( | |||
| SemiFgReportFilter(view = "range", lastOutDateStart = "2026-08-01", lastOutDateEnd = "2026-09-01"), | |||
| today, | |||
| ) | |||
| assertEquals(32, window.dayColumns.size) | |||
| assertEquals(LocalDate.of(2026, 8, 1), window.dayColumns.first()) | |||
| assertEquals(LocalDate.of(2026, 9, 1), window.dayColumns.last()) | |||
| assertEquals(emptyList<YearMonth>(), window.periods) | |||
| } | |||
| @Test | |||
| fun `custom range longer than a month uses months and keeps partial months separate`() { | |||
| val window = SemiFgProductionAnalysisLayout.resolveWindow( | |||
| SemiFgReportFilter(view = "range", lastOutDateStart = "2026-03-15", lastOutDateEnd = "2026-06-02"), | |||
| today, | |||
| ) | |||
| assertEquals(emptyList<LocalDate>(), window.dayColumns) | |||
| assertEquals(listOf(YearMonth.of(2026, 3), YearMonth.of(2026, 4), YearMonth.of(2026, 5), YearMonth.of(2026, 6)), window.periods) | |||
| val data = SemiFgReportData( | |||
| window = window, | |||
| lines = listOf( | |||
| line(LocalDate.of(2026, 3, 1), "FG1", "50"), | |||
| line(LocalDate.of(2026, 3, 20), "FG1", "7"), | |||
| line(LocalDate.of(2026, 6, 2), "FG1", "3"), | |||
| line(LocalDate.of(2026, 6, 9), "FG1", "11"), | |||
| ), | |||
| ) | |||
| val rows = SemiFgProductionAnalysisLayout.listRows(data) | |||
| assertEquals(listOf("2026-03", "2026-06"), rows.map { it.period }) | |||
| assertEquals(BigDecimal("7"), rows[0].qty) | |||
| assertEquals(BigDecimal("3"), rows[1].qty) | |||
| } | |||
| @Test | |||
| fun `year pivot does not add January of another year`() { | |||
| val data = SemiFgReportData( | |||
| window = SemiFgProductionAnalysisLayout.resolveWindow( | |||
| SemiFgReportFilter(view = "year", year = "2026"), | |||
| today, | |||
| ), | |||
| lines = listOf( | |||
| line(LocalDate.of(2026, 1, 5), "FG1", "10"), | |||
| line(LocalDate.of(2025, 1, 5), "FG1", "99"), | |||
| line(LocalDate.of(2026, 3, 2), "FG1", "4"), | |||
| ), | |||
| ) | |||
| val row = SemiFgProductionAnalysisLayout.yearPivotRows(data).single() | |||
| assertEquals(BigDecimal("10"), row["qtyJan"]) | |||
| assertEquals(BigDecimal("4"), row["qtyMar"]) | |||
| assertEquals(BigDecimal.ZERO, row["qtyFeb"]) | |||
| } | |||
| @Test | |||
| fun `anchor period is a day for a month view and a year-month for a year view`() { | |||
| val date = LocalDate.of(2026, 3, 15) | |||
| assertEquals("FG1|2026-03-15", SemiFgProductionAnalysisLayout.anchorKey(SemiFgView.MONTH, "FG1", date)) | |||
| assertEquals("FG1|2026-03", SemiFgProductionAnalysisLayout.anchorKey(SemiFgView.YEAR, "FG1", date)) | |||
| } | |||
| private fun line(date: LocalDate, item: String, qty: String) = SemiFgLine( | |||
| prodDate = date, | |||
| itemNo = item, | |||
| itemName = item, | |||
| unitOfMeasure = "箱", | |||
| jobOrderCode = "JO1", | |||
| productLotNo = "LOT", | |||
| qty = BigDecimal(qty), | |||
| qcFailed = false, | |||
| ) | |||
| } | |||
| @@ -0,0 +1,197 @@ | |||
| package com.ffii.fpsms.modules.report.service | |||
| import com.ffii.fpsms.modules.report.web.ReportItemFilterRequest | |||
| import org.apache.poi.ss.usermodel.CellType | |||
| import org.apache.poi.xssf.usermodel.XSSFWorkbook | |||
| import org.junit.jupiter.api.Assertions.assertEquals | |||
| import org.junit.jupiter.api.Assertions.assertThrows | |||
| import org.junit.jupiter.api.Assertions.assertTrue | |||
| import org.junit.jupiter.api.Test | |||
| import java.io.ByteArrayInputStream | |||
| import java.math.BigDecimal | |||
| import java.time.LocalDate | |||
| class SemiFgProductionAnalysisReworkTest { | |||
| private val today = LocalDate.of(2026, 9, 23) | |||
| @Test | |||
| fun `each view resolves a closed date window`() { | |||
| val day = SemiFgProductionAnalysisLayout.resolveWindow( | |||
| SemiFgReportFilter(view = "day", reportDate = "2026/09/19"), | |||
| today, | |||
| ) | |||
| assertEquals(LocalDate.of(2026, 9, 19), day.start) | |||
| assertEquals(day.start, day.endInclusive) | |||
| val leap = SemiFgProductionAnalysisLayout.resolveWindow( | |||
| SemiFgReportFilter(view = "month", reportMonth = "2024-02"), | |||
| today, | |||
| ) | |||
| assertEquals(LocalDate.of(2024, 2, 29), leap.endInclusive) | |||
| val nonLeap = SemiFgProductionAnalysisLayout.resolveWindow( | |||
| SemiFgReportFilter(view = "month", reportMonth = "2026-02"), | |||
| today, | |||
| ) | |||
| assertEquals(LocalDate.of(2026, 2, 28), nonLeap.endInclusive) | |||
| val year = SemiFgProductionAnalysisLayout.resolveWindow( | |||
| SemiFgReportFilter(view = "year", year = "2026", lastOutDateStart = "2026-09-01", lastOutDateEnd = "2026-09-02"), | |||
| today, | |||
| ) | |||
| assertEquals(LocalDate.of(2026, 1, 1), year.start) | |||
| assertEquals(LocalDate.of(2026, 12, 31), year.endInclusive) | |||
| } | |||
| @Test | |||
| fun `custom range uses days inside one month and months after that`() { | |||
| val sameMonth = SemiFgProductionAnalysisLayout.resolveWindow( | |||
| SemiFgReportFilter(view = "range", lastOutDateStart = "2026-09-03", lastOutDateEnd = "2026-09-03"), | |||
| today, | |||
| ) | |||
| assertEquals(1, sameMonth.dayColumns.size) | |||
| val oneMonth = SemiFgProductionAnalysisLayout.resolveWindow( | |||
| SemiFgReportFilter(view = "range", lastOutDateStart = "2026-08-01", lastOutDateEnd = "2026-09-01"), | |||
| today, | |||
| ) | |||
| assertEquals(32, oneMonth.dayColumns.size) | |||
| assertTrue(oneMonth.periods.isEmpty()) | |||
| val overAMonth = SemiFgProductionAnalysisLayout.resolveWindow( | |||
| SemiFgReportFilter(view = "range", lastOutDateStart = "2026-08-01", lastOutDateEnd = "2026-09-02"), | |||
| today, | |||
| ) | |||
| assertTrue(overAMonth.dayColumns.isEmpty()) | |||
| assertEquals(2, overAMonth.periods.size) | |||
| val max = SemiFgProductionAnalysisLayout.resolveWindow( | |||
| SemiFgReportFilter(view = "range", lastOutDateStart = "2024-10-01", lastOutDateEnd = "2026-09-30"), | |||
| today, | |||
| ) | |||
| assertEquals(24, max.periods.size) | |||
| assertThrows(SemiFgReportRequestException::class.java) { | |||
| SemiFgProductionAnalysisLayout.resolveWindow( | |||
| SemiFgReportFilter(view = "range", lastOutDateStart = "2024-09-01", lastOutDateEnd = "2026-09-30"), | |||
| today, | |||
| ) | |||
| } | |||
| assertThrows(SemiFgReportRequestException::class.java) { | |||
| SemiFgProductionAnalysisLayout.resolveWindow( | |||
| SemiFgReportFilter(view = "range", lastOutDateStart = "2026-09-02", lastOutDateEnd = "2026-09-01"), | |||
| today, | |||
| ) | |||
| } | |||
| assertThrows(SemiFgReportRequestException::class.java) { | |||
| SemiFgProductionAnalysisLayout.resolveWindow(SemiFgReportFilter(view = "trend"), today) | |||
| } | |||
| } | |||
| @Test | |||
| fun `pasted item codes merge with the picker and ignore duplicates`() { | |||
| val combined = ReportItemFilterRequest( | |||
| itemCode = "PP1080, pp1175", | |||
| itemCodePaste = "PP1175\nPP2008 PP1080", | |||
| itemCodes = listOf("PP2008", "PP2211"), | |||
| ).combinedItemCode() | |||
| assertEquals("PP1080,pp1175,PP2008,PP2211", combined) | |||
| } | |||
| @Test | |||
| fun `workbooks for every view keep a zero-qty item on the summary`() { | |||
| val lines = listOf( | |||
| line(LocalDate.of(2026, 9, 3), "PP1080", "10", false), | |||
| line(LocalDate.of(2026, 9, 19), "PP1175", "4", false), | |||
| line(LocalDate.of(2026, 8, 2), "PP2008", "0", true), | |||
| ) | |||
| val cases = listOf( | |||
| SemiFgReportFilter(view = "day", reportDate = "2026-09-19"), | |||
| SemiFgReportFilter(view = "week", reportWeek = "2026-09-19"), | |||
| SemiFgReportFilter(view = "month", reportMonth = "2026-09"), | |||
| SemiFgReportFilter(view = "year", year = "2026"), | |||
| SemiFgReportFilter(view = "range", lastOutDateStart = "2026-09-01", lastOutDateEnd = "2026-09-30"), | |||
| SemiFgReportFilter(view = "range", lastOutDateStart = "2026-08-20", lastOutDateEnd = "2026-09-05"), | |||
| SemiFgReportFilter(view = "range", lastOutDateStart = "2026-08-01", lastOutDateEnd = "2026-09-15"), | |||
| ) | |||
| for (filter in cases) { | |||
| val data = SemiFgReportData(SemiFgProductionAnalysisLayout.resolveWindow(filter, today), lines) | |||
| XSSFWorkbook(ByteArrayInputStream(SemiFgProductionAnalysisWorkbook().build(data, "en"))).use { book -> | |||
| assertTrue(book.getSheet("Summary") != null, filter.view) | |||
| assertTrue(book.getSheet("Detail") != null, filter.view) | |||
| assertTrue(book.getSheet("Chart") != null, filter.view) | |||
| val summary = book.getSheet("Summary") | |||
| val codes = (0..summary.lastRowNum).mapNotNull { summary.getRow(it)?.getCell(0)?.stringCellValue } | |||
| assertTrue("PP2008" in codes, filter.view) | |||
| val formulas = book.flatMap { sheet -> sheet.flatMap { row -> row.filter { it.cellType == CellType.FORMULA } } } | |||
| assertTrue(formulas.none { it.cellFormula.contains("FILTER", ignoreCase = true) }) | |||
| } | |||
| } | |||
| } | |||
| @Test | |||
| fun `same-month custom range headers are day numbers and a cross-month range uses day and month`() { | |||
| val lines = listOf(line(LocalDate.of(2026, 9, 3), "PP1080", "10", false)) | |||
| val same = SemiFgReportData( | |||
| SemiFgProductionAnalysisLayout.resolveWindow( | |||
| SemiFgReportFilter(view = "range", lastOutDateStart = "2026-09-01", lastOutDateEnd = "2026-09-05"), | |||
| today, | |||
| ), | |||
| lines, | |||
| ) | |||
| val crossing = SemiFgReportData( | |||
| SemiFgProductionAnalysisLayout.resolveWindow( | |||
| SemiFgReportFilter(view = "range", lastOutDateStart = "2026-08-28", lastOutDateEnd = "2026-09-02"), | |||
| today, | |||
| ), | |||
| lines, | |||
| ) | |||
| headers(same).let { headers -> | |||
| assertTrue(headers.contains("3")) | |||
| assertTrue(headers.none { it.startsWith("2026-") }) | |||
| } | |||
| headers(crossing).let { headers -> | |||
| assertTrue(headers.contains("28/8"), headers.toString()) | |||
| assertTrue(headers.contains("2/9"), headers.toString()) | |||
| assertTrue(headers.none { it.startsWith("2026-") }) | |||
| } | |||
| } | |||
| @Test | |||
| fun `all means finished goods and semi-finished goods and a full code matches exactly`() { | |||
| assertEquals(listOf("FG", "WIP"), resolveSemiFgCategoryTypes(null)) | |||
| assertEquals(listOf("FG", "WIP"), resolveSemiFgCategoryTypes("All")) | |||
| assertEquals(listOf("FG", "WIP"), resolveSemiFgCategoryTypes("")) | |||
| assertEquals(listOf("FG"), resolveSemiFgCategoryTypes("FG")) | |||
| assertEquals(listOf("mat"), resolveSemiFgCategoryTypes("Material")) | |||
| assertEquals(listOf("FG", "WIP", "mat"), resolveSemiFgCategoryTypes("All,mat")) | |||
| val args = mutableMapOf<String, Any>() | |||
| val sql = buildSemiFgItemCodeClause("PP1080, P001, %PP108%", "it.code", "semiItem", args) | |||
| assertTrue(sql.contains("UPPER(it.code) = :semiItem_0")) | |||
| assertTrue(sql.contains("UPPER(it.code) = :semiItem_1")) | |||
| assertTrue(sql.contains("it.code LIKE :semiItem_2")) | |||
| assertEquals("PP1080", args["semiItem_0"]) | |||
| assertEquals("P001", args["semiItem_1"]) | |||
| assertEquals("%PP108%", args["semiItem_2"]) | |||
| } | |||
| private fun headers(data: SemiFgReportData): List<String> = | |||
| XSSFWorkbook(ByteArrayInputStream(SemiFgProductionAnalysisWorkbook().build(data))).use { book -> | |||
| val summary = book.getSheet("彙總") | |||
| (0..summary.lastRowNum).firstNotNullOf { index -> | |||
| val row = summary.getRow(index) ?: return@firstNotNullOf null | |||
| val values = (0 until row.lastCellNum).mapNotNull { row.getCell(it)?.stringCellValue } | |||
| values.takeIf { "貨品編號" in it } | |||
| } | |||
| } | |||
| private fun line(date: LocalDate, item: String, qty: String, failed: Boolean) = SemiFgLine( | |||
| prodDate = date, | |||
| itemNo = item, | |||
| itemName = item, | |||
| unitOfMeasure = "箱", | |||
| jobOrderCode = "JO", | |||
| productLotNo = "LOT", | |||
| qty = BigDecimal(qty), | |||
| qcFailed = failed, | |||
| ) | |||
| } | |||
| @@ -0,0 +1,74 @@ | |||
| package com.ffii.fpsms.modules.report.service | |||
| import org.apache.poi.ss.usermodel.CellType | |||
| import org.apache.poi.xssf.usermodel.XSSFWorkbook | |||
| import org.junit.jupiter.api.Assertions.assertNotNull | |||
| import org.junit.jupiter.api.Assertions.assertTrue | |||
| import org.junit.jupiter.api.Test | |||
| import java.io.ByteArrayInputStream | |||
| import java.math.BigDecimal | |||
| import java.time.LocalDate | |||
| class SemiFgProductionAnalysisWorkbookTest { | |||
| @Test | |||
| fun `summary quantity links to the matching detail section`() { | |||
| val window = SemiFgProductionAnalysisLayout.resolveWindow( | |||
| SemiFgReportFilter(view = "year", year = "2026"), | |||
| LocalDate.of(2026, 9, 23), | |||
| ) | |||
| val data = SemiFgReportData( | |||
| window = window, | |||
| lines = listOf( | |||
| SemiFgLine( | |||
| prodDate = LocalDate.of(2026, 3, 15), | |||
| itemNo = "FG1", | |||
| itemName = "Cake", | |||
| unitOfMeasure = "箱", | |||
| jobOrderCode = "JO-1", | |||
| productLotNo = "LOT-1", | |||
| qty = BigDecimal("12"), | |||
| qcFailed = false, | |||
| ), | |||
| ), | |||
| ) | |||
| XSSFWorkbook(ByteArrayInputStream(SemiFgProductionAnalysisWorkbook().build(data))).use { workbook -> | |||
| assertNotNull(workbook.getSheet("彙總")) | |||
| assertNotNull(workbook.getSheet("明細")) | |||
| assertNotNull(workbook.getSheet("生產圖表")) | |||
| val summary = workbook.getSheet("彙總") | |||
| val linked = (0..summary.lastRowNum).flatMap { rowIndex -> | |||
| val row = summary.getRow(rowIndex) ?: return@flatMap emptyList() | |||
| (0 until row.lastCellNum).mapNotNull { col -> row.getCell(col)?.hyperlink } | |||
| } | |||
| assertTrue(linked.isNotEmpty()) | |||
| assertTrue(linked.any { it.address.contains("明細") && it.address.contains(":H") }) | |||
| val detail = workbook.getSheet("明細") | |||
| val heading = (0..detail.lastRowNum).any { rowIndex -> | |||
| val row = detail.getRow(rowIndex) ?: return@any false | |||
| row.getCell(1)?.stringCellValue == "FG1" && row.getCell(2)?.stringCellValue == "Cake" | |||
| } | |||
| val line = (0..detail.lastRowNum).any { rowIndex -> | |||
| val row = detail.getRow(rowIndex) ?: return@any false | |||
| row.getCell(0)?.stringCellValue == "2026-03-15" && row.getCell(1)?.stringCellValue == "FG1" | |||
| } | |||
| assertTrue(heading) | |||
| assertTrue(line) | |||
| val formulas = buildList { | |||
| for (sheet in workbook) { | |||
| for (row in sheet) { | |||
| for (cell in row) { | |||
| if (cell.cellType == CellType.FORMULA) add(cell.cellFormula) | |||
| } | |||
| } | |||
| } | |||
| } | |||
| assertTrue(formulas.isNotEmpty()) | |||
| assertTrue(formulas.none { it.contains("FILTER", ignoreCase = true) }) | |||
| val cached = workbook.flatMap { sheet -> | |||
| sheet.flatMap { row -> row.filter { it.cellType == CellType.FORMULA } } | |||
| } | |||
| assertTrue(cached.isNotEmpty()) | |||
| assertTrue(cached.all { it.cachedFormulaResultType == CellType.NUMERIC }) | |||
| } | |||
| } | |||
| } | |||