diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt index bdcd3603..ef20f79e 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt @@ -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 { + 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> { - val args = mutableMapOf() - - 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( + "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): 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> { val args = mutableMapOf() - - 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> { val args = mutableMapOf() - - 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 { + 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 { + 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 ")})" +} diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisLayout.kt b/src/main/java/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisLayout.kt new file mode 100644 index 00000000..6757dd74 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisLayout.kt @@ -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 = emptyList(), + /** Set for a custom range of 31 days or fewer. Each entry is one summary column. */ + val dayColumns: List = emptyList(), +) + +data class SemiFgReportData( + val window: SemiFgWindow, + val lines: List, +) { + 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 = 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 = + generateSequence(from) { prev -> prev.plusMonths(1).takeIf { !it.isAfter(to) } }.toList() + + private fun datesBetween(start: LocalDate, end: LocalDate): List = + generateSequence(start) { prev -> prev.plusDays(1).takeIf { !it.isAfter(end) } }.toList() + + fun summaryItems(lines: List, qtyDesc: Boolean = false): List { + val grouped = linkedMapOf() + val totals = linkedMapOf() + 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 { totals[it.itemNo] }.thenBy { it.itemNo }) + } else { + items.sortedBy { it.itemNo } + } + } + + fun dailyQty(lines: List): Map> { + val out = linkedMapOf>() + 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>, + 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, 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>, itemNo: String, day: LocalDate): BigDecimal = + daily[itemNo]?.get(day) ?: BigDecimal.ZERO + + fun qtyInMonth(daily: Map>, itemNo: String, month: YearMonth): BigDecimal = + daily[itemNo].orEmpty() + .filterKeys { YearMonth.from(it) == month } + .values + .fold(BigDecimal.ZERO, BigDecimal::add) + + fun yearPivotRows(data: SemiFgReportData): List> { + 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 { + 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() + 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>, + ): List { + val rows = mutableListOf() + 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, items: List, 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): List { + 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 { + 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>, + 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 + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisWorkbook.kt b/src/main/java/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisWorkbook.kt new file mode 100644 index 00000000..475f42c2 --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisWorkbook.kt @@ -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, + 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, + daily: Map>, + styles: Styles, + detailSheetName: String, + anchors: Map, + 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, + daily: Map>, + styles: Styles, + detailSheetName: String, + anchors: Map, + 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, + daily: Map>, + styles: Styles, + detailSheetName: String, + anchors: Map, + text: SemiFgReportText, + ): Int { + val columns: List 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, + daily: Map>, + styles: Styles, + detailSheetName: String, + anchors: Map, + 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 { + 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() + 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): List { + 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, 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, 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 + } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/SemiFgReportText.kt b/src/main/java/com/ffii/fpsms/modules/report/service/SemiFgReportText.kt new file mode 100644 index 00000000..7a53d0ba --- /dev/null +++ b/src/main/java/com/ffii/fpsms/modules/report/service/SemiFgReportText.kt @@ -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 = 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) + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/ReportItemFilterRequest.kt b/src/main/java/com/ffii/fpsms/modules/report/web/ReportItemFilterRequest.kt index 168731ab..af2e811d 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/ReportItemFilterRequest.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/ReportItemFilterRequest.kt @@ -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) diff --git a/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt b/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt index 4182292e..5755c77d 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt @@ -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 = - 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 = 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 { - 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 { - val parameters = mutableMapOf() - - // 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 = 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 = - 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 = 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 { - 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 { - val dbData = semiFGProductionAnalysisReportService.searchSemiFGProductionAnalysisReport( - stockCategory, - stockSubCategory, - itemCode, - year, - lastOutDateStart, - lastOutDateEnd - ) - - val excelBytes = createSemiFGProductionAnalysisExcel( - dbData = dbData, - reportTitle = "成品/半成品生產分析報告", - year = year - ) + ): ResponseEntity = 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>> { + val itemCodes = semiFGProductionAnalysisReportService.getSemiFGItemCodes(stockCategory) + return ResponseEntity(itemCodes, HttpStatus.OK) } - private fun createSemiFGProductionAnalysisExcel( - dbData: List>, - 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>> { + 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>) { - 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 { + 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( + "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( + "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 { + 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>> { - val itemCodes = semiFGProductionAnalysisReportService.getSemiFGItemCodes(stockCategory) - return ResponseEntity(itemCodes, HttpStatus.OK) + private fun pdfHeaderParameters(text: SemiFgReportText): Map { + val months = listOf("hJan", "hFeb", "hMar", "hApr", "hMay", "hJun", "hJul", "hAug", "hSep", "hOct", "hNov", "hDec") + val headers = mutableMapOf( + "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>> { - 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) } } diff --git a/src/main/resources/jasper/SemiFGProductionAnalysisListReport.jrxml b/src/main/resources/jasper/SemiFGProductionAnalysisListReport.jrxml new file mode 100644 index 00000000..93b295da --- /dev/null +++ b/src/main/resources/jasper/SemiFGProductionAnalysisListReport.jrxml @@ -0,0 +1,142 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/jasper/SemiFGProductionAnalysisReport.jrxml b/src/main/resources/jasper/SemiFGProductionAnalysisReport.jrxml index 67a25fe2..f78a64c0 100644 --- a/src/main/resources/jasper/SemiFGProductionAnalysisReport.jrxml +++ b/src/main/resources/jasper/SemiFGProductionAnalysisReport.jrxml @@ -32,6 +32,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -366,7 +392,7 @@ - + @@ -374,8 +400,7 @@ - - + @@ -393,7 +418,7 @@ - + @@ -401,8 +426,7 @@ - - + @@ -410,7 +434,7 @@ - + @@ -418,9 +442,8 @@ - - - + + @@ -428,8 +451,7 @@ - - + @@ -444,7 +466,7 @@ - + @@ -452,8 +474,7 @@ - - + @@ -461,9 +482,9 @@ - + - + @@ -472,8 +493,7 @@ - - + @@ -488,16 +508,15 @@ - + - - - + + @@ -505,9 +524,8 @@ - - - + + @@ -515,9 +533,8 @@ - - - + + @@ -525,9 +542,8 @@ - - - + + @@ -535,9 +551,8 @@ - - - + + @@ -545,9 +560,8 @@ - - - + + @@ -555,9 +569,8 @@ - - - + + @@ -565,9 +578,8 @@ - - - + + @@ -575,9 +587,8 @@ - - - + + @@ -585,9 +596,8 @@ - - - + + @@ -595,9 +605,8 @@ - - - + + @@ -605,9 +614,8 @@ - - - + + @@ -615,14 +623,13 @@ - - + - + @@ -630,9 +637,8 @@ - - - + + @@ -640,9 +646,8 @@ - - - + + @@ -650,8 +655,7 @@ - - + diff --git a/src/test/kotlin/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisLayoutTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisLayoutTest.kt new file mode 100644 index 00000000..c4a6170f --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisLayoutTest.kt @@ -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(), 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(), 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, + ) +} diff --git a/src/test/kotlin/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisReworkTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisReworkTest.kt new file mode 100644 index 00000000..b6301e67 --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisReworkTest.kt @@ -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() + 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 = + 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, + ) +} diff --git a/src/test/kotlin/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisWorkbookTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisWorkbookTest.kt new file mode 100644 index 00000000..5d6c2d37 --- /dev/null +++ b/src/test/kotlin/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisWorkbookTest.kt @@ -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 }) + } + } +}