Просмотр исходного кода

[Feat] FG/Semi-FG production analysis supports day, week, month, year, and range windows, bilingual PDF and Excel, and clickable summary item codes that jump to Detail

fg_report_rework
Harry Groves 1 день назад
Родитель
Сommit
6328c404c6
11 измененных файлов: 2244 добавлений и 666 удалений
  1. +152
    -134
      src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt
  2. +475
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisLayout.kt
  3. +719
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisWorkbook.kt
  4. +97
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/SemiFgReportText.kt
  5. +5
    -0
      src/main/java/com/ffii/fpsms/modules/report/web/ReportItemFilterRequest.kt
  6. +163
    -465
      src/main/java/com/ffii/fpsms/modules/report/web/SemiFGProductionAnalysisReportController.kt
  7. +142
    -0
      src/main/resources/jasper/SemiFGProductionAnalysisListReport.jrxml
  8. +71
    -67
      src/main/resources/jasper/SemiFGProductionAnalysisReport.jrxml
  9. +149
    -0
      src/test/kotlin/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisLayoutTest.kt
  10. +197
    -0
      src/test/kotlin/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisReworkTest.kt
  11. +74
    -0
      src/test/kotlin/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisWorkbookTest.kt

+ 152
- 134
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, Any>): String {
val types = resolveSemiFgCategoryTypes(stockCategory)
if (types.isEmpty()) return ""
return buildMultiValueExactClause(types.joinToString(","), "it.type", paramPrefix, args)
}

/**
* Helper function to build SQL clause for comma-separated values with exact match.
* Supports multiple values like "val1, val2, val3" and generates OR conditions with =.
@@ -56,164 +65,134 @@ class SemiFGProductionAnalysisReportService(
}

/**
* Queries the database for Semi FG Production Analysis Report data.
* Aligned with [ReportService.searchStockInTraceabilityReport] totals for the same filters:
* - stock_in_line driven (no stock_ledger gate); INNER JOIN bom so only items that exist as BOM rows appear
* - Include only stock_in_line rows with non-null jobOrderId
* - Exclude stock_in_line rows with status = 'Pending'
* - stockCategory → items.type (exact, comma-separated); itemCode → items.code (LIKE, comma-separated)
* - Date path:
* stock_in_line.productLotNo (must be present) ->
* stock_in_line.inventoryLotId -> inventory_lot.id ->
* inventory_lot.stockInDate (used for month/year/date filters)
* - Quantity source: stock_in_line.acceptedQty
* - QC any fail → line qty 0 (same as traceability stockInQty)
* - One row per stockInLineId per month before pivot; all lines counted (not only job orders)
* Put-away lines behind the FG / Semi-FG production analysis report.
* Same inclusion rules as the previous yearly pivot:
* BOM item, product lot present, job order present, status not Pending,
* QC fail forces the line quantity to 0.
* The date is inventory_lot.stockInDate when the product lot is present.
*/
fun searchSemiFGProductionAnalysisReport(
stockCategory: String?,
stockSubCategory: String?,
itemCode: String?,
year: String?,
lastOutDateStart: String?,
lastOutDateEnd: String?
): List<Map<String, Any>> {
val args = mutableMapOf<String, Any>()

val stockCategorySql = if (!itemCode.isNullOrBlank()) {
""
} else if (!stockCategory.isNullOrBlank() && stockCategory != "All" && !stockCategory.contains("All")) {
buildMultiValueExactClause(stockCategory, "it.type", "semiSc", args)
} else {
""
}
val stockSubCategorySql = buildMultiValueLikeClause(stockSubCategory, "ic.sub", "stockSubCategory", args)
val itemCodeSql = buildMultiValueLikeClause(itemCode, "it.code", "semiItem", args)

val yearSql = if (!year.isNullOrBlank() && year != "All") {
args["year"] = year
"AND YEAR(CASE WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate ELSE si.productionDate END) = :year"
} else {
""
}

val lastOutDateStartSql = if (!lastOutDateStart.isNullOrBlank()) {
val formattedDate = lastOutDateStart.replace("/", "-")
args["lastOutDateStart"] = formattedDate
"AND DATE(CASE WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate ELSE si.productionDate END) >= DATE(:lastOutDateStart)"
} else ""

val lastOutDateEndSql = if (!lastOutDateEnd.isNullOrBlank()) {
val formattedDate = lastOutDateEnd.replace("/", "-")
args["lastOutDateEnd"] = formattedDate
"AND DATE(CASE WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate ELSE si.productionDate END) <= DATE(:lastOutDateEnd)"
} else ""
fun loadReport(filter: SemiFgReportFilter, today: LocalDate = LocalDate.now()): SemiFgReportData {
val window = SemiFgProductionAnalysisLayout.resolveWindow(filter, today)
val args = mutableMapOf<String, Any>(
"rangeStart" to window.start.toString(),
"rangeEnd" to window.endInclusive.toString(),
)
val stockCategorySql = categoryClause(filter.stockCategory, "semiSc", args)
val stockSubCategorySql = buildMultiValueLikeClause(filter.stockSubCategory, "ic.sub", "stockSubCategory", args)
val itemCodeSql = buildSemiFgItemCodeClause(filter.itemCode, "it.code", "semiItem", args)
val prodDateExpr = """
CASE
WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate
ELSE si.productionDate
END
""".trimIndent()

val sql = """
WITH qr_agg AS (
SELECT
qr.stockInLineId,
MAX(CASE WHEN qr.qcPassed = 0 THEN 1 ELSE 0 END) AS qcFailed
FROM qc_result qr
WHERE qr.deleted = 0
GROUP BY qr.stockInLineId
),
base AS (
SELECT
MAX(base.itemNo) AS itemNo,
MAX(base.itemName) AS itemName,
MAX(base.unitOfMeasure) AS unitOfMeasure,
MAX(base.prodDate) AS prodDate,
MAX(base.jobOrderCode) AS jobOrderCode,
MAX(base.productLotNo) AS productLotNo,
base.stockInLineId AS stockInLineId,
MAX(base.linePutAwayQty) AS qty,
MAX(base.qcFailed) AS qcFailed
FROM (
SELECT
COALESCE(it.code, '') AS itemNo,
COALESCE(it.name, '') AS itemName,
COALESCE(ic.sub, '') AS stockSubCategory,
COALESCE(uc.udfudesc, '') AS unitOfMeasure,
MONTH(
CASE
WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate
ELSE si.productionDate
END
) AS mon,
DATE($prodDateExpr) AS prodDate,
COALESCE(jo.code, '') AS jobOrderCode,
COALESCE(si.productLotNo, '') AS productLotNo,
si.id AS stockInLineId,
CASE WHEN COALESCE(qr_agg.qcFailed, 0) = 1 THEN 0
ELSE COALESCE(si.acceptedQty, 0)
END AS linePutAwayQty
END AS linePutAwayQty,
COALESCE(qr_agg.qcFailed, 0) AS qcFailed
FROM stock_in_line si
INNER JOIN items it ON si.itemId = it.id
INNER JOIN bom b ON b.code = it.code AND b.deleted = false
LEFT JOIN inventory_lot il ON il.id = si.inventoryLotId AND il.deleted = false
LEFT JOIN qr_agg ON qr_agg.stockInLineId = si.id
LEFT JOIN (
SELECT
qr.stockInLineId,
MAX(CASE WHEN qr.qcPassed = 0 THEN 1 ELSE 0 END) AS qcFailed
FROM qc_result qr
WHERE qr.deleted = 0
GROUP BY qr.stockInLineId
) qr_agg ON qr_agg.stockInLineId = si.id
LEFT JOIN job_order jo ON jo.id = si.jobOrderId AND jo.deleted = false
LEFT JOIN item_category ic ON it.categoryId = ic.id
LEFT JOIN item_uom iu ON it.id = iu.itemId AND iu.stockUnit = true
LEFT JOIN uom_conversion uc ON iu.uomId = uc.id
WHERE si.deleted = false
AND si.productLotNo IS NOT NULL
AND TRIM(si.productLotNo) <> ''
AND (
CASE
WHEN si.productLotNo IS NOT NULL AND TRIM(si.productLotNo) <> '' THEN il.stockInDate
ELSE si.productionDate
END
) IS NOT NULL
AND ($prodDateExpr) IS NOT NULL
AND si.jobOrderId IS NOT NULL
AND (si.status IS NULL OR si.status <> 'Pending')
AND DATE($prodDateExpr) >= DATE(:rangeStart)
AND DATE($prodDateExpr) <= DATE(:rangeEnd)
$stockCategorySql
$stockSubCategorySql
$itemCodeSql
$yearSql
$lastOutDateStartSql
$lastOutDateEndSql
),
dedup AS (
SELECT
itemNo,
itemName,
stockSubCategory,
unitOfMeasure,
mon,
stockInLineId,
MAX(linePutAwayQty) AS linePutAwayQty
FROM base
GROUP BY itemNo, itemName, stockSubCategory, unitOfMeasure, mon, stockInLineId
)
SELECT
MAX(d.stockSubCategory) AS stockSubCategory,
d.itemNo AS itemNo,
MAX(d.itemName) AS itemName,
MAX(d.unitOfMeasure) AS unitOfMeasure,
CAST(COALESCE(SUM(CASE WHEN d.mon = 1 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyJan,
CAST(COALESCE(SUM(CASE WHEN d.mon = 2 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyFeb,
CAST(COALESCE(SUM(CASE WHEN d.mon = 3 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyMar,
CAST(COALESCE(SUM(CASE WHEN d.mon = 4 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyApr,
CAST(COALESCE(SUM(CASE WHEN d.mon = 5 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyMay,
CAST(COALESCE(SUM(CASE WHEN d.mon = 6 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyJun,
CAST(COALESCE(SUM(CASE WHEN d.mon = 7 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyJul,
CAST(COALESCE(SUM(CASE WHEN d.mon = 8 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyAug,
CAST(COALESCE(SUM(CASE WHEN d.mon = 9 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtySep,
CAST(COALESCE(SUM(CASE WHEN d.mon = 10 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyOct,
CAST(COALESCE(SUM(CASE WHEN d.mon = 11 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyNov,
CAST(COALESCE(SUM(CASE WHEN d.mon = 12 THEN d.linePutAwayQty ELSE 0 END), 0) AS DECIMAL(18,2)) AS qtyDec,
CAST(COALESCE(SUM(d.linePutAwayQty), 0) AS CHAR) AS totalProductionQty
FROM dedup d
GROUP BY d.itemNo
HAVING COALESCE(SUM(d.linePutAwayQty), 0) > 0
ORDER BY d.itemNo
) base
GROUP BY base.stockInLineId
ORDER BY itemNo, prodDate, jobOrderCode, productLotNo
""".trimIndent()

return jdbcDao.queryForList(sql, args)
val lines = jdbcDao.queryForList(sql, args).mapNotNull { row -> toLine(row) }
return SemiFgReportData(window, lines)
}

private fun toLine(row: Map<String, Any?>): SemiFgLine? {
val prodDate = asLocalDate(row["prodDate"]) ?: return null
val itemNo = row["itemNo"]?.toString()?.trim().orEmpty()
if (itemNo.isEmpty()) return null
return SemiFgLine(
prodDate = prodDate,
itemNo = itemNo,
itemName = row["itemName"]?.toString().orEmpty(),
unitOfMeasure = row["unitOfMeasure"]?.toString().orEmpty(),
jobOrderCode = row["jobOrderCode"]?.toString().orEmpty(),
productLotNo = row["productLotNo"]?.toString().orEmpty(),
qty = asBigDecimal(row["qty"]),
qcFailed = asBoolean(row["qcFailed"]),
)
}

private fun asLocalDate(value: Any?): LocalDate? = when (value) {
is LocalDate -> value
is LocalDateTime -> value.toLocalDate()
is java.sql.Date -> value.toLocalDate()
is java.sql.Timestamp -> value.toLocalDateTime().toLocalDate()
is String -> runCatching { LocalDate.parse(value.take(10)) }.getOrNull()
else -> null
}

private fun asBigDecimal(value: Any?): BigDecimal = when (value) {
null -> BigDecimal.ZERO
is BigDecimal -> value
is Number -> BigDecimal(value.toString())
else -> value.toString().replace(",", "").toBigDecimalOrNull() ?: BigDecimal.ZERO
}

private fun asBoolean(value: Any?): Boolean = when (value) {
is Boolean -> value
is Number -> value.toInt() != 0
is String -> value == "1" || value.equals("true", ignoreCase = true)
else -> false
}

/**
* Gets list of item codes (bom.code) with names based on stockCategory filter.
* Supports multiple categories separated by comma (e.g., "FG,WIP").
* If stockCategory is "All" or null, returns all codes.
* If stockCategory is "FG" or "WIP" or "FG,WIP", returns codes matching those descriptions.
* Returns a list of maps with "code" and "name" keys.
* Item codes on a BOM, with names. All (or a blank category) is FG and WIP.
* Material is `mat`. An item code typed later is applied on top of this category.
*/
fun getSemiFGItemCodes(stockCategory: String?): List<Map<String, String>> {
val args = mutableMapOf<String, Any>()
val stockCategorySql = if (!stockCategory.isNullOrBlank() && stockCategory != "All" && !stockCategory.contains("All")) {
buildMultiValueExactClause(stockCategory, "it.type", "semiFgCodesSc", args)
} else {
""
}
val stockCategorySql = categoryClause(stockCategory, "semiFgCodesSc", args)

val sql = """
SELECT DISTINCT b.code, COALESCE(it.name, b.name, '') AS name
@@ -239,18 +218,11 @@ class SemiFGProductionAnalysisReportService(
}

/**
* Gets list of item codes with their category (FG/WIP) and name based on stockCategory filter.
* Supports multiple categories separated by comma (e.g., "FG,WIP").
* Returns a list of maps with "code", "category", and "name" keys.
* Item codes with type and name. All (or a blank category) is FG and WIP.
*/
fun getSemiFGItemCodesWithCategory(stockCategory: String?): List<Map<String, String>> {
val args = mutableMapOf<String, Any>()
val stockCategorySql = if (!stockCategory.isNullOrBlank() && stockCategory != "All" && !stockCategory.contains("All")) {
buildMultiValueExactClause(stockCategory, "it.type", "semiFgCodesCatSc", args)
} else {
""
}
val stockCategorySql = categoryClause(stockCategory, "semiFgCodesCatSc", args)

val sql = """
SELECT DISTINCT b.code, COALESCE(it.type, '') AS category, COALESCE(it.name, b.name, '') AS name
@@ -276,3 +248,49 @@ class SemiFGProductionAnalysisReportService(
}
}
}

/** All, blank, or no category means finished goods and semi-finished goods. Material is `mat`. */
internal fun resolveSemiFgCategoryTypes(stockCategory: String?): List<String> {
val selected = ReportMultiValueTokens.split(stockCategory)
val explicit = selected
.filter { !it.equals("All", ignoreCase = true) }
.map { normalizeSemiFgType(it) }
.distinct()
val wantsAll = selected.isEmpty() || selected.any { it.equals("All", ignoreCase = true) }
return when {
wantsAll && explicit.isEmpty() -> listOf("FG", "WIP")
wantsAll -> (listOf("FG", "WIP") + explicit).distinct()
else -> explicit
}
}

private fun normalizeSemiFgType(token: String): String = when {
token.equals("mat", ignoreCase = true) || token.equals("material", ignoreCase = true) || token == "材料" -> "mat"
token.equals("fg", ignoreCase = true) -> "FG"
token.equals("wip", ignoreCase = true) -> "WIP"
else -> token
}

/**
* A full item code matches that code only. Tokens that already contain % or _ stay as LIKE.
*/
internal fun buildSemiFgItemCodeClause(
itemCode: String?,
columnName: String,
paramPrefix: String,
args: MutableMap<String, Any>,
): String {
val tokens = ReportMultiValueTokens.split(itemCode)
if (tokens.isEmpty()) return ""
val conditions = tokens.mapIndexed { index, value ->
val name = "${paramPrefix}_$index"
if ('%' in value || '_' in value) {
args[name] = value
"$columnName LIKE :$name"
} else {
args[name] = value.uppercase()
"UPPER($columnName) = :$name"
}
}
return "AND (${conditions.joinToString(" OR ")})"
}

+ 475
- 0
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<YearMonth> = emptyList(),
/** Set for a custom range of 31 days or fewer. Each entry is one summary column. */
val dayColumns: List<LocalDate> = emptyList(),
)

data class SemiFgReportData(
val window: SemiFgWindow,
val lines: List<SemiFgLine>,
) {
val view: SemiFgView get() = window.view
}

data class SemiFgListRow(
val period: String,
val itemNo: String,
val itemName: String,
val unitOfMeasure: String,
val qty: BigDecimal,
)

object SemiFgProductionAnalysisLayout {
const val MAX_RANGE_MONTHS = 24

val MONTH_LABELS: List<String> = listOf(
"一月", "二月", "三月", "四月", "五月", "六月",
"七月", "八月", "九月", "十月", "十一月", "十二月",
)

private val isoDate: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE
private val isoMonth: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM")

fun resolveWindow(filter: SemiFgReportFilter, today: LocalDate = LocalDate.now()): SemiFgWindow {
val explicit = filter.view?.trim()?.lowercase()?.takeIf { it.isNotEmpty() }
val view = when (explicit) {
null, "year" -> SemiFgView.YEAR
"day" -> SemiFgView.DAY
"week" -> SemiFgView.WEEK
"month" -> SemiFgView.MONTH
"range" -> SemiFgView.RANGE
else -> throw SemiFgReportRequestException("未知的檢視")
}
return when (view) {
SemiFgView.DAY -> {
val day = parseDate(filter.reportDate)
?: parseDate(filter.lastOutDateStart)?.takeIf { it == parseDate(filter.lastOutDateEnd) }
?: throw SemiFgReportRequestException("請選擇日期")
SemiFgWindow(
view = view,
start = day,
endInclusive = day,
periodLabel = "單日 $day",
day = day,
)
}
SemiFgView.WEEK -> {
val anchor = parseDate(filter.reportWeek)
?: throw SemiFgReportRequestException("請選擇日期")
val start = anchor.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
val end = start.plusDays(6)
SemiFgWindow(
view = view,
start = start,
endInclusive = end,
periodLabel = "單週 $start 至 $end",
dayColumns = datesBetween(start, end),
)
}
SemiFgView.MONTH -> {
val month = parseMonth(filter.reportMonth)
?: throw SemiFgReportRequestException("請選擇月份(YYYY-MM)")
SemiFgWindow(
view = view,
start = month.atDay(1),
endInclusive = month.atEndOfMonth(),
periodLabel = "單月 $month",
month = month,
)
}
SemiFgView.YEAR -> {
val year = parseYear(filter.year) ?: today.year
var start = LocalDate.of(year, 1, 1)
var end = LocalDate.of(year, 12, 31)
if (explicit == null) {
parseDate(filter.lastOutDateStart)?.let { if (it.isAfter(start)) start = it }
parseDate(filter.lastOutDateEnd)?.let { if (it.isBefore(end)) end = it }
}
if (end.isBefore(start)) {
end = start
}
SemiFgWindow(
view = view,
start = start,
endInclusive = end,
periodLabel = "全年 $year",
year = year,
periods = (1..12).map { YearMonth.of(year, it) },
)
}
SemiFgView.RANGE -> {
val start = parseDate(filter.lastOutDateStart)
?: throw SemiFgReportRequestException("請選擇完成生產日期(由)")
val end = parseDate(filter.lastOutDateEnd)
?: throw SemiFgReportRequestException("請選擇完成生產日期(至)")
if (start.isAfter(end)) {
throw SemiFgReportRequestException("開始日期不可晚於結束日期")
}
val months = monthsBetween(YearMonth.from(start), YearMonth.from(end))
if (months.size > MAX_RANGE_MONTHS) {
throw SemiFgReportRequestException("自訂日期最多 $MAX_RANGE_MONTHS 個月")
}
val dayColumns = if (!end.isAfter(start.plusMonths(1))) datesBetween(start, end) else emptyList()
SemiFgWindow(
view = view,
start = start,
endInclusive = end,
periodLabel = "自訂 $start 至 $end",
periods = if (dayColumns.isEmpty()) months else emptyList(),
dayColumns = dayColumns,
)
}
}
}

private fun monthsBetween(from: YearMonth, to: YearMonth): List<YearMonth> =
generateSequence(from) { prev -> prev.plusMonths(1).takeIf { !it.isAfter(to) } }.toList()

private fun datesBetween(start: LocalDate, end: LocalDate): List<LocalDate> =
generateSequence(start) { prev -> prev.plusDays(1).takeIf { !it.isAfter(end) } }.toList()

fun summaryItems(lines: List<SemiFgLine>, qtyDesc: Boolean = false): List<SemiFgItem> {
val grouped = linkedMapOf<String, SemiFgItem>()
val totals = linkedMapOf<String, BigDecimal>()
for (line in lines) {
if (line.itemNo.isBlank()) continue
val existing = grouped[line.itemNo]
if (existing == null) {
grouped[line.itemNo] = SemiFgItem(line.itemNo, line.itemName, line.unitOfMeasure)
} else if (existing.itemName.isBlank() && line.itemName.isNotBlank()) {
grouped[line.itemNo] = existing.copy(itemName = line.itemName, unitOfMeasure = line.unitOfMeasure.ifBlank { existing.unitOfMeasure })
}
totals[line.itemNo] = (totals[line.itemNo] ?: BigDecimal.ZERO) + line.qty
}
val items = grouped.values.toList()
return if (qtyDesc) {
items.sortedWith(compareByDescending<SemiFgItem> { totals[it.itemNo] }.thenBy { it.itemNo })
} else {
items.sortedBy { it.itemNo }
}
}

fun dailyQty(lines: List<SemiFgLine>): Map<String, Map<LocalDate, BigDecimal>> {
val out = linkedMapOf<String, MutableMap<LocalDate, BigDecimal>>()
for (line in lines) {
val byDate = out.getOrPut(line.itemNo) { linkedMapOf() }
byDate[line.prodDate] = (byDate[line.prodDate] ?: BigDecimal.ZERO) + line.qty
}
return out
}

fun anchorKey(view: SemiFgView, itemNo: String, date: LocalDate): String =
"$itemNo|${filterPeriod(view, date)}"

fun anchorKey(window: SemiFgWindow, itemNo: String, date: LocalDate): String =
"$itemNo|${filterPeriod(window, date)}"

fun filterPeriod(view: SemiFgView, date: LocalDate): String = when (view) {
SemiFgView.DAY, SemiFgView.WEEK, SemiFgView.MONTH -> date.format(isoDate)
SemiFgView.YEAR, SemiFgView.RANGE -> YearMonth.from(date).format(isoMonth)
}

fun filterPeriod(window: SemiFgWindow, date: LocalDate): String =
if (window.view == SemiFgView.RANGE && window.dayColumns.isNotEmpty()) {
date.format(isoDate)
} else {
filterPeriod(window.view, date)
}

fun qtyInMonthWithin(
daily: Map<String, Map<LocalDate, BigDecimal>>,
itemNo: String,
month: YearMonth,
start: LocalDate,
endInclusive: LocalDate,
): BigDecimal =
daily[itemNo].orEmpty()
.filterKeys { date ->
YearMonth.from(date) == month && !date.isBefore(start) && !date.isAfter(endInclusive)
}
.values
.fold(BigDecimal.ZERO, BigDecimal::add)

fun roundQty(value: BigDecimal): Long =
value.setScale(0, RoundingMode.HALF_UP).longValueExact()

fun jobOrderCount(lines: List<SemiFgLine>, itemNo: String): Int {
val codes = lines.asSequence()
.filter { it.itemNo == itemNo && it.qty > BigDecimal.ZERO }
.map { it.jobOrderCode.trim() }
.filter { it.isNotEmpty() }
.distinct()
.toList()
if (codes.isNotEmpty()) return codes.size
return lines.count { it.itemNo == itemNo && it.qty > BigDecimal.ZERO }
}

fun qtyOnDay(daily: Map<String, Map<LocalDate, BigDecimal>>, itemNo: String, day: LocalDate): BigDecimal =
daily[itemNo]?.get(day) ?: BigDecimal.ZERO

fun qtyInMonth(daily: Map<String, Map<LocalDate, BigDecimal>>, itemNo: String, month: YearMonth): BigDecimal =
daily[itemNo].orEmpty()
.filterKeys { YearMonth.from(it) == month }
.values
.fold(BigDecimal.ZERO, BigDecimal::add)

fun yearPivotRows(data: SemiFgReportData): List<Map<String, Any>> {
val year = data.window.year ?: return emptyList()
val daily = dailyQty(data.lines)
return summaryItems(data.lines).map { item ->
val months = (1..12).map { month ->
qtyInMonth(daily, item.itemNo, YearMonth.of(year, month))
}
val total = months.fold(BigDecimal.ZERO, BigDecimal::add)
mapOf(
"itemNo" to item.itemNo,
"itemName" to item.itemName,
"unitOfMeasure" to item.unitOfMeasure,
"qtyJan" to months[0],
"qtyFeb" to months[1],
"qtyMar" to months[2],
"qtyApr" to months[3],
"qtyMay" to months[4],
"qtyJun" to months[5],
"qtyJul" to months[6],
"qtyAug" to months[7],
"qtySep" to months[8],
"qtyOct" to months[9],
"qtyNov" to months[10],
"qtyDec" to months[11],
"totalProductionQty" to total.stripTrailingZeros().toPlainString(),
)
}
}

fun listRows(data: SemiFgReportData): List<SemiFgListRow> {
val daily = dailyQty(data.lines)
return when (data.view) {
SemiFgView.DAY -> {
val day = data.window.day ?: return emptyList()
summaryItems(data.lines, qtyDesc = true).map { item ->
SemiFgListRow(
period = day.toString(),
itemNo = item.itemNo,
itemName = item.itemName,
unitOfMeasure = item.unitOfMeasure,
qty = qtyOnDay(daily, item.itemNo, day),
)
}
}
SemiFgView.MONTH -> {
val month = data.window.month ?: return emptyList()
val rows = mutableListOf<SemiFgListRow>()
var cursor = month.atDay(1)
val end = month.atEndOfMonth()
while (!cursor.isAfter(end)) {
for (item in summaryItems(data.lines)) {
val qty = qtyOnDay(daily, item.itemNo, cursor)
if (qty > BigDecimal.ZERO) {
rows += SemiFgListRow(cursor.toString(), item.itemNo, item.itemName, item.unitOfMeasure, qty)
}
}
cursor = cursor.plusDays(1)
}
appendItemsWithNoQty(rows, summaryItems(data.lines), month.toString())
rows
}
SemiFgView.WEEK, SemiFgView.RANGE -> rangeListRows(data, daily)
SemiFgView.YEAR -> emptyList()
}
}

private fun rangeListRows(
data: SemiFgReportData,
daily: Map<String, Map<LocalDate, BigDecimal>>,
): List<SemiFgListRow> {
val rows = mutableListOf<SemiFgListRow>()
val items = summaryItems(data.lines)
if (data.window.dayColumns.isNotEmpty()) {
for (date in data.window.dayColumns) {
for (item in items) {
val qty = qtyOnDay(daily, item.itemNo, date)
if (qty > BigDecimal.ZERO) {
rows += SemiFgListRow(date.toString(), item.itemNo, item.itemName, item.unitOfMeasure, qty)
}
}
}
appendItemsWithNoQty(rows, items, data.window.start.toString())
return rows
}
for (period in data.window.periods) {
for (item in items) {
val qty = qtyInMonthWithin(daily, item.itemNo, period, data.window.start, data.window.endInclusive)
if (qty > BigDecimal.ZERO) {
rows += SemiFgListRow(period.toString(), item.itemNo, item.itemName, item.unitOfMeasure, qty)
}
}
}
appendItemsWithNoQty(rows, items, data.window.periods.firstOrNull()?.toString() ?: data.window.start.toString())
return rows
}

private fun appendItemsWithNoQty(rows: MutableList<SemiFgListRow>, items: List<SemiFgItem>, period: String) {
val seen = rows.map { it.itemNo }.toSet()
for (item in items) {
if (item.itemNo in seen) continue
rows += SemiFgListRow(period, item.itemNo, item.itemName, item.unitOfMeasure, BigDecimal.ZERO)
}
}

fun chartCategories(data: SemiFgReportData, items: List<SemiFgItem>): List<String> {
return when (data.view) {
SemiFgView.DAY -> items.map { it.itemNo }
SemiFgView.MONTH -> {
val month = data.window.month ?: return emptyList()
(1..month.lengthOfMonth()).map { it.toString() }
}
SemiFgView.YEAR -> MONTH_LABELS
SemiFgView.WEEK -> data.window.dayColumns.map { it.toString() }
SemiFgView.RANGE -> if (data.window.dayColumns.isNotEmpty()) {
data.window.dayColumns.map { it.toString() }
} else {
data.window.periods.map { it.toString() }
}
}
}

fun periodOptions(data: SemiFgReportData): List<String> {
return when (data.view) {
SemiFgView.DAY -> listOfNotNull(data.window.day?.toString())
SemiFgView.MONTH -> {
val month = data.window.month ?: return emptyList()
(1..month.lengthOfMonth()).map { month.atDay(it).toString() }
}
SemiFgView.YEAR -> data.window.periods.map { it.toString() }
SemiFgView.WEEK -> data.window.dayColumns.map { it.toString() }
SemiFgView.RANGE -> chartCategories(data, emptyList())
}
}

fun chartQty(
data: SemiFgReportData,
daily: Map<String, Map<LocalDate, BigDecimal>>,
itemNo: String,
category: String,
): BigDecimal {
return when (data.view) {
SemiFgView.DAY -> if (itemNo == category) {
daily[itemNo].orEmpty().values.fold(BigDecimal.ZERO, BigDecimal::add)
} else {
BigDecimal.ZERO
}
SemiFgView.MONTH -> {
val month = data.window.month ?: return BigDecimal.ZERO
val day = category.toIntOrNull() ?: return BigDecimal.ZERO
if (day !in 1..month.lengthOfMonth()) BigDecimal.ZERO
else qtyOnDay(daily, itemNo, month.atDay(day))
}
SemiFgView.YEAR -> {
val index = MONTH_LABELS.indexOf(category)
val year = data.window.year ?: return BigDecimal.ZERO
if (index < 0) BigDecimal.ZERO else qtyInMonth(daily, itemNo, YearMonth.of(year, index + 1))
}
SemiFgView.WEEK -> {
val day = runCatching { LocalDate.parse(category) }.getOrNull() ?: return BigDecimal.ZERO
qtyOnDay(daily, itemNo, day)
}
SemiFgView.RANGE -> {
if (data.window.dayColumns.isNotEmpty()) {
val day = runCatching { LocalDate.parse(category) }.getOrNull() ?: return BigDecimal.ZERO
qtyOnDay(daily, itemNo, day)
} else {
val period = runCatching { YearMonth.parse(category) }.getOrNull() ?: return BigDecimal.ZERO
qtyInMonthWithin(daily, itemNo, period, data.window.start, data.window.endInclusive)
}
}
}
}

private fun parseDate(raw: String?): LocalDate? {
val text = raw?.trim()?.replace("/", "-")?.takeIf { it.isNotEmpty() && !it.equals("All", true) } ?: return null
return try {
LocalDate.parse(text.take(10), isoDate)
} catch (_: DateTimeParseException) {
throw SemiFgReportRequestException("日期格式須為 YYYY-MM-DD")
}
}

private fun parseMonth(raw: String?): YearMonth? {
val text = raw?.trim()?.replace("/", "-")?.takeIf { it.isNotEmpty() && !it.equals("All", true) } ?: return null
val normalized = if (text.length >= 7) text.take(7) else text
return try {
YearMonth.parse(normalized, isoMonth)
} catch (_: DateTimeParseException) {
throw SemiFgReportRequestException("月份格式須為 YYYY-MM")
}
}

private fun parseYear(raw: String?): Int? {
val text = raw?.trim()?.takeIf { it.isNotEmpty() && !it.equals("All", true) } ?: return null
val year = text.toIntOrNull()
if (year == null || year !in 1900..9999) {
throw SemiFgReportRequestException("請輸入四位數年份")
}
return year
}
}

+ 719
- 0
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<String, DetailSpan>,
text: SemiFgReportText,
) {
val items = when (data.view) {
SemiFgView.DAY -> SemiFgProductionAnalysisLayout.summaryItems(data.lines, qtyDesc = true)
else -> SemiFgProductionAnalysisLayout.summaryItems(data.lines)
}
val daily = SemiFgProductionAnalysisLayout.dailyQty(data.lines)
val columns = summaryColumnCount(data)
var rowIndex = 0

val titleRow = sheet.createRow(rowIndex++)
titleRow.heightInPoints = 28f
val titleCell = titleRow.createCell(0)
titleCell.setCellValue(text.title)
titleCell.cellStyle = styles.title
if (columns > 1) {
sheet.addMergedRegion(CellRangeAddress(0, 0, 0, columns - 1))
}

val infoRow = sheet.createRow(rowIndex++)
infoRow.createCell(0).apply {
setCellValue(text.viewLine(data.window))
cellStyle = styles.info
}
infoRow.createCell(minOf(3, columns - 1)).apply {
setCellValue(text.reportDateLine())
cellStyle = styles.info
}
if (columns > 6) {
infoRow.createCell(6).apply {
setCellValue(text.reportTimeLine())
cellStyle = styles.info
}
}
val hintRow = sheet.createRow(rowIndex++)
hintRow.createCell(0).apply {
setCellValue(text.detailHint())
cellStyle = styles.info
}
rowIndex++

val headerRowIndex = rowIndex
when (data.view) {
SemiFgView.DAY -> writeDaySummary(sheet, rowIndex, data, items, daily, styles, detailSheetName, anchors, text)
SemiFgView.WEEK -> writeRangeSummary(sheet, rowIndex, data, items, daily, styles, detailSheetName, anchors, text)
SemiFgView.MONTH -> writeMonthSummary(sheet, rowIndex, data, items, daily, styles, detailSheetName, anchors, text)
SemiFgView.YEAR -> writeYearSummary(sheet, rowIndex, data, items, daily, styles, detailSheetName, anchors, text)
SemiFgView.RANGE -> writeRangeSummary(sheet, rowIndex, data, items, daily, styles, detailSheetName, anchors, text)
}
sheet.createFreezePane(0, headerRowIndex + 1)
applySummaryWidths(sheet, data)
}

private fun summaryColumnCount(data: SemiFgReportData): Int = when (data.view) {
SemiFgView.DAY -> 5
SemiFgView.WEEK -> 3 + data.window.dayColumns.size + 1
SemiFgView.MONTH -> 3 + (data.window.month?.lengthOfMonth() ?: 31) + 1
SemiFgView.YEAR -> 16
SemiFgView.RANGE -> if (data.window.dayColumns.isNotEmpty()) {
3 + data.window.dayColumns.size + 1
} else {
3 + data.window.periods.size + 1
}
}

private fun writeDaySummary(
sheet: XSSFSheet,
startRow: Int,
data: SemiFgReportData,
items: List<SemiFgItem>,
daily: Map<String, Map<LocalDate, BigDecimal>>,
styles: Styles,
detailSheetName: String,
anchors: Map<String, DetailSpan>,
text: SemiFgReportText,
): Int {
var rowIndex = startRow
val headers = listOf(text.itemNo, text.itemName, text.uom, text.jobOrders, text.putAwayQty)
writeHeader(sheet, rowIndex++, headers, styles)
val day = data.window.day
if (items.isEmpty() || day == null) {
writeEmptyRow(sheet, rowIndex++, headers.size, styles)
return rowIndex
}
var total = BigDecimal.ZERO
for (item in items) {
val qty = SemiFgProductionAnalysisLayout.qtyOnDay(daily, item.itemNo, day)
total += qty
val anchor = anchors[SemiFgProductionAnalysisLayout.anchorKey(SemiFgView.DAY, item.itemNo, day)]
val row = sheet.createRow(rowIndex++)
setText(row, 0, item.itemNo, styles, detailSheetName, anchor, link = true)
setText(row, 1, item.itemName.ifBlank { "-" }, styles)
setText(row, 2, item.unitOfMeasure.ifBlank { "-" }, styles)
setWholeNumber(row, 3, SemiFgProductionAnalysisLayout.jobOrderCount(data.lines, item.itemNo).toBigDecimal(), styles)
setWholeNumber(row, 4, qty, styles)
}
writeTotalRow(sheet, rowIndex++, text.total, 4, total, styles, leadingBlanks = 3)
return rowIndex
}

private fun writeMonthSummary(
sheet: XSSFSheet,
startRow: Int,
data: SemiFgReportData,
items: List<SemiFgItem>,
daily: Map<String, Map<LocalDate, BigDecimal>>,
styles: Styles,
detailSheetName: String,
anchors: Map<String, DetailSpan>,
text: SemiFgReportText,
): Int {
var rowIndex = startRow
val month = data.window.month
val days = month?.lengthOfMonth() ?: 0
val headers = listOf(text.itemNo, text.itemName, text.uom) + (1..days).map { it.toString() } + text.monthTotal
writeHeader(sheet, rowIndex++, headers, styles)
if (items.isEmpty() || month == null) {
writeEmptyRow(sheet, rowIndex++, headers.size, styles)
return rowIndex
}
val dayTotals = Array(days) { BigDecimal.ZERO }
var grand = BigDecimal.ZERO
for (item in items) {
val row = sheet.createRow(rowIndex++)
val firstAnchor = firstAnchor(anchors, item.itemNo)
setText(row, 0, item.itemNo, styles, detailSheetName, firstAnchor, link = true)
setText(row, 1, item.itemName.ifBlank { "-" }, styles)
setText(row, 2, item.unitOfMeasure.ifBlank { "-" }, styles)
var rowTotal = BigDecimal.ZERO
for (day in 1..days) {
val date = month.atDay(day)
val qty = SemiFgProductionAnalysisLayout.qtyOnDay(daily, item.itemNo, date)
rowTotal += qty
dayTotals[day - 1] += qty
setWholeNumber(row, 2 + day, qty, styles)
}
grand += rowTotal
setWholeNumber(row, 3 + days, rowTotal, styles)
}
val totalRow = sheet.createRow(rowIndex++)
setText(totalRow, 0, text.total, styles)
setText(totalRow, 1, "", styles)
setText(totalRow, 2, "", styles)
dayTotals.forEachIndexed { index, qty ->
setWholeNumber(totalRow, 3 + index, qty, styles)
}
setWholeNumber(totalRow, 3 + days, grand, styles)
return rowIndex
}

private fun writeRangeSummary(
sheet: XSSFSheet,
startRow: Int,
data: SemiFgReportData,
items: List<SemiFgItem>,
daily: Map<String, Map<LocalDate, BigDecimal>>,
styles: Styles,
detailSheetName: String,
anchors: Map<String, DetailSpan>,
text: SemiFgReportText,
): Int {
val columns: List<Pair<String, (String) -> BigDecimal>> = if (data.window.dayColumns.isNotEmpty()) {
val sameMonth = data.window.dayColumns.map { YearMonth.from(it) }.distinct().size == 1
data.window.dayColumns.map { date ->
val heading = if (sameMonth) date.dayOfMonth.toString() else "%d/%d".format(date.dayOfMonth, date.monthValue)
heading to { itemNo: String ->
SemiFgProductionAnalysisLayout.qtyOnDay(daily, itemNo, date)
}
}
} else {
data.window.periods.map { period ->
period.toString() to { itemNo: String ->
SemiFgProductionAnalysisLayout.qtyInMonthWithin(
daily, itemNo, period, data.window.start, data.window.endInclusive,
)
}
}
}
var rowIndex = startRow
val headers = listOf(text.itemNo, text.itemName, text.uom) + columns.map { it.first } + text.total
writeHeader(sheet, rowIndex++, headers, styles)
if (items.isEmpty() || columns.isEmpty()) {
writeEmptyRow(sheet, rowIndex++, headers.size, styles)
return rowIndex
}
val columnTotals = Array(columns.size) { BigDecimal.ZERO }
var grand = BigDecimal.ZERO
for (item in items) {
val row = sheet.createRow(rowIndex++)
val firstAnchor = firstAnchor(anchors, item.itemNo)
setText(row, 0, item.itemNo, styles, detailSheetName, firstAnchor, link = true)
setText(row, 1, item.itemName.ifBlank { "-" }, styles)
setText(row, 2, item.unitOfMeasure.ifBlank { "-" }, styles)
var rowTotal = BigDecimal.ZERO
columns.forEachIndexed { index, (_, qtyOf) ->
val qty = qtyOf(item.itemNo)
rowTotal += qty
columnTotals[index] += qty
setWholeNumber(row, 3 + index, qty, styles)
}
grand += rowTotal
setWholeNumber(row, 3 + columns.size, rowTotal, styles)
}
val totalRow = sheet.createRow(rowIndex++)
setText(totalRow, 0, text.total, styles)
setText(totalRow, 1, "", styles)
setText(totalRow, 2, "", styles)
columnTotals.forEachIndexed { index, qty ->
setWholeNumber(totalRow, 3 + index, qty, styles)
}
setWholeNumber(totalRow, 3 + columns.size, grand, styles)
return rowIndex
}

private fun writeYearSummary(
sheet: XSSFSheet,
startRow: Int,
data: SemiFgReportData,
items: List<SemiFgItem>,
daily: Map<String, Map<LocalDate, BigDecimal>>,
styles: Styles,
detailSheetName: String,
anchors: Map<String, DetailSpan>,
text: SemiFgReportText,
): Int {
var rowIndex = startRow
val headers = listOf(text.itemNo, text.itemName, text.uom) + text.monthLabels + text.putAwayTotal
writeHeader(sheet, rowIndex++, headers, styles)
val year = data.window.year
if (items.isEmpty() || year == null) {
writeEmptyRow(sheet, rowIndex++, headers.size, styles)
return rowIndex
}
val monthTotals = Array(12) { BigDecimal.ZERO }
var grand = BigDecimal.ZERO
for (item in items) {
val row = sheet.createRow(rowIndex++)
val firstAnchor = firstAnchor(anchors, item.itemNo)
setText(row, 0, item.itemNo, styles, detailSheetName, firstAnchor, link = true)
setText(row, 1, item.itemName.ifBlank { "-" }, styles)
setText(row, 2, item.unitOfMeasure.ifBlank { "-" }, styles)
var rowTotal = BigDecimal.ZERO
for (month in 1..12) {
val period = YearMonth.of(year, month)
val qty = SemiFgProductionAnalysisLayout.qtyInMonth(daily, item.itemNo, period)
rowTotal += qty
monthTotals[month - 1] += qty
setWholeNumber(row, 2 + month, qty, styles)
}
grand += rowTotal
setWholeNumber(row, 15, rowTotal, styles)
}
val totalRow = sheet.createRow(rowIndex++)
setText(totalRow, 0, text.total, styles)
setText(totalRow, 1, "", styles)
setText(totalRow, 2, "", styles)
monthTotals.forEachIndexed { index, qty ->
setWholeNumber(totalRow, 3 + index, qty, styles)
}
setWholeNumber(totalRow, 15, grand, styles)
return rowIndex
}

private fun writeDetail(sheet: XSSFSheet, data: SemiFgReportData, styles: Styles, text: SemiFgReportText): Map<String, DetailSpan> {
val headers = listOf(text.date, text.itemNo, text.itemName, text.uom, text.jobOrderNo, text.lotNo, text.putAwayQty, text.qc)
writeHeader(sheet, 0, headers, styles)
val anchors = linkedMapOf<String, DetailSpan>()
val grouped = data.lines
.sortedWith(compareBy({ it.itemNo }, { it.prodDate }, { it.jobOrderCode }, { it.productLotNo }))
.groupBy { it.itemNo }
var rowIndex = 1
if (grouped.isEmpty()) {
writeEmptyRow(sheet, rowIndex, headers.size, styles)
return anchors
}
for ((itemNo, lines) in grouped) {
val firstRow = rowIndex
val header = sheet.createRow(rowIndex)
header.heightInPoints = 22f
header.createCell(0).cellStyle = styles.sectionEdge
header.createCell(1).apply {
setCellValue(itemNo)
cellStyle = styles.sectionCode
}
header.createCell(2).apply {
setCellValue(lines.first().itemName.ifBlank { "" })
cellStyle = styles.sectionName
}
for (col in 3 until headers.size) {
header.createCell(col).cellStyle = styles.sectionEdge
}
rowIndex++
for (line in lines) {
val row = sheet.createRow(rowIndex++)
setText(row, 0, line.prodDate.toString(), styles)
setText(row, 1, line.itemNo, styles)
setText(row, 2, line.itemName.ifBlank { "-" }, styles)
setText(row, 3, line.unitOfMeasure.ifBlank { "-" }, styles)
setText(row, 4, line.jobOrderCode.ifBlank { "-" }, styles)
setText(row, 5, line.productLotNo.ifBlank { "-" }, styles)
setWholeNumber(row, 6, line.qty, styles)
setText(row, 7, if (line.qcFailed) text.qcFail else text.qcPass, styles)
}
val span = DetailSpan(firstRow, rowIndex - 1)
anchors[itemNo] = span
for (line in lines) {
anchors[SemiFgProductionAnalysisLayout.anchorKey(data.window, line.itemNo, line.prodDate)] = span
}
}
val widths = intArrayOf(22, 18, 28, 12, 18, 18, 12, 10)
widths.forEachIndexed { idx, width -> sheet.setColumnWidth(idx, width * 256) }
return anchors
}

private fun writeChart(
chartSheet: XSSFSheet,
chartData: XSSFSheet,
data: SemiFgReportData,
chartDataName: String,
text: SemiFgReportText,
) {
val items = when (data.view) {
SemiFgView.DAY -> SemiFgProductionAnalysisLayout.summaryItems(data.lines, qtyDesc = true)
else -> SemiFgProductionAnalysisLayout.summaryItems(data.lines)
}
val rawCategories = SemiFgProductionAnalysisLayout.chartCategories(data, items)
val categories = if (data.view == SemiFgView.YEAR) text.monthLabels else rawCategories
val daily = SemiFgProductionAnalysisLayout.dailyQty(data.lines)

val dataHeader = chartData.createRow(0)
dataHeader.createCell(0).setCellValue(text.itemNo)
dataHeader.createCell(1).setCellValue(text.period)
dataHeader.createCell(2).setCellValue(text.qty)
var dataRow = 1
for (item in items) {
categories.forEachIndexed { index, category ->
val qtyCategory = if (data.view == SemiFgView.YEAR) {
SemiFgProductionAnalysisLayout.MONTH_LABELS[index]
} else {
category
}
val row = chartData.createRow(dataRow++)
row.createCell(0).setCellValue(item.itemNo)
row.createCell(1).setCellValue(category)
row.createCell(2).setCellValue(
SemiFgProductionAnalysisLayout.roundQty(
SemiFgProductionAnalysisLayout.chartQty(data, daily, item.itemNo, qtyCategory),
).toDouble(),
)
}
}
val itemList = listOf(text.all) + items.map { it.itemNo }
val listCol = 40
itemList.forEachIndexed { index, value ->
val row = chartData.getRow(index) ?: chartData.createRow(index)
row.createCell(listCol).setCellValue(value)
}

chartSheet.setDisplayGridlines(false)
val labelStyle = chartSheet.workbook.createCellStyle() as XSSFCellStyle
labelStyle.alignment = HorizontalAlignment.LEFT
labelStyle.verticalAlignment = VerticalAlignment.CENTER
labelStyle.setFont(chartSheet.workbook.createFont().apply { bold = true })
val choiceStyle = chartSheet.workbook.createCellStyle() as XSSFCellStyle
choiceStyle.alignment = HorizontalAlignment.LEFT
choiceStyle.verticalAlignment = VerticalAlignment.CENTER
choiceStyle.borderBottom = BorderStyle.THIN
choiceStyle.borderTop = BorderStyle.THIN
choiceStyle.borderLeft = BorderStyle.THIN
choiceStyle.borderRight = BorderStyle.THIN
choiceStyle.fillForegroundColor = IndexedColors.GREY_25_PERCENT.index
choiceStyle.fillPattern = FillPatternType.SOLID_FOREGROUND

val labelRow = chartSheet.createRow(0)
labelRow.heightInPoints = 22f
labelRow.createCell(0).apply {
setCellValue(text.selectItem)
cellStyle = labelStyle
}
labelRow.createCell(1).apply {
setCellValue(text.all)
cellStyle = choiceStyle
}
addListValidation(
chartSheet,
"'$chartDataName'!\$${CellReference.convertNumToColString(listCol)}\$1:\$${CellReference.convertNumToColString(listCol)}\$${itemList.size}",
0,
1,
text.pickItemError,
text.invalidChoice,
)

if (categories.isEmpty()) {
chartSheet.createRow(2).createCell(0).setCellValue(text.noData)
} else {
val labelRowIndex = 0
val keyRowIndex = 1
val valueRowIndex = 2
val firstCol = 4
val labels = chartLabels(data, categories)
val sheetRef = chartSheet.sheetName.replace("'", "''")
labels.forEachIndexed { index, label ->
val col = firstCol + index
val labelCellRow = chartData.getRow(labelRowIndex) ?: chartData.createRow(labelRowIndex)
val keyCellRow = chartData.getRow(keyRowIndex) ?: chartData.createRow(keyRowIndex)
val valueCellRow = chartData.getRow(valueRowIndex) ?: chartData.createRow(valueRowIndex)
labelCellRow.createCell(col).setCellValue(label)
keyCellRow.createCell(col).setCellValue(categories[index])
val keyRef = "${CellReference.convertNumToColString(col)}${keyRowIndex + 1}"
valueCellRow.createCell(col).cellFormula =
"""IF('$sheetRef'!${'$'}B${'$'}1="${text.all}",SUMIF('$chartDataName'!B:B,$keyRef,'$chartDataName'!C:C),SUMIFS('$chartDataName'!C:C,'$chartDataName'!A:A,'$sheetRef'!${'$'}B${'$'}1,'$chartDataName'!B:B,$keyRef))"""
}
chartSheet.workbook.creationHelper.createFormulaEvaluator().evaluateAll()
val lastCol = firstCol + categories.size - 1
val drawing = chartSheet.createDrawingPatriarch()
val anchor = XSSFClientAnchor(0, 0, 0, 0, 0, 2, 12, 18)
val chart = drawing.createChart(anchor)
chart.setTitleText(text.chartTitle(data.window))
chart.setTitleOverlay(false)
val bottomAxis = chart.createCategoryAxis(AxisPosition.BOTTOM)
bottomAxis.setTitle(chartAxisTitle(data, text))
val leftAxis = chart.createValueAxis(AxisPosition.LEFT)
leftAxis.setTitle(text.producedQty)
val categoriesSource = XDDFDataSourcesFactory.fromStringCellRange(
chartData,
CellRangeAddress(labelRowIndex, labelRowIndex, firstCol, lastCol),
)
val valuesSource = XDDFDataSourcesFactory.fromNumericCellRange(
chartData,
CellRangeAddress(valueRowIndex, valueRowIndex, firstCol, lastCol),
)
val barData = chart.createData(ChartTypes.BAR, bottomAxis, leftAxis) as XDDFBarChartData
barData.barDirection = BarDirection.COL
val series = barData.addSeries(categoriesSource, valuesSource)
series.setTitle(text.producedQty, null)
chart.plot(barData)
}

val note = chartSheet.createRow(19)
note.heightInPoints = 32f
note.createCell(0).setCellValue(text.chartNote())
chartSheet.addMergedRegion(CellRangeAddress(19, 19, 0, 6))
chartSheet.setColumnWidth(0, 24 * 256)
chartSheet.setColumnWidth(1, 22 * 256)
}

private fun chartLabels(data: SemiFgReportData, keys: List<String>): List<String> {
val days = data.window.dayColumns
if ((data.view != SemiFgView.RANGE && data.view != SemiFgView.WEEK) || days.isEmpty()) return keys
val sameMonth = days.map { YearMonth.from(it) }.distinct().size == 1
return if (sameMonth) {
days.map { it.dayOfMonth.toString() }
} else {
days.map { "%d/%d".format(it.dayOfMonth, it.monthValue) }
}
}

private fun chartAxisTitle(data: SemiFgReportData, text: SemiFgReportText): String = when (data.view) {
SemiFgView.DAY -> text.axisItem
SemiFgView.WEEK -> text.axisDay
SemiFgView.MONTH -> text.axisDay
SemiFgView.YEAR -> text.axisMonth
SemiFgView.RANGE -> if (data.window.dayColumns.isNotEmpty()) text.axisDate else text.axisPeriod
}

private fun addListValidation(
sheet: XSSFSheet,
formula: String,
row: Int,
col: Int,
error: String,
errorTitle: String,
) {
if (formula.isBlank()) return
val helper: DataValidationHelper = sheet.dataValidationHelper
val constraint: DataValidationConstraint = helper.createFormulaListConstraint(formula)
val validation = helper.createValidation(constraint, CellRangeAddressList(row, row, col, col))
validation.showErrorBox = true
validation.createErrorBox(errorTitle, error)
sheet.addValidationData(validation)
}

private fun applySummaryWidths(sheet: XSSFSheet, data: SemiFgReportData) {
sheet.setColumnWidth(0, 18 * 256)
sheet.setColumnWidth(1, 24 * 256)
sheet.setColumnWidth(2, 12 * 256)
val narrow = when {
data.view == SemiFgView.MONTH || data.view == SemiFgView.WEEK -> 11 * 256
data.view == SemiFgView.RANGE && data.window.dayColumns.isNotEmpty() -> 11 * 256
else -> 12 * 256
}
val last = summaryColumnCount(data)
for (col in 3 until last) {
sheet.setColumnWidth(col, if (col == last - 1) 16 * 256 else narrow)
}
}

private fun writeHeader(sheet: XSSFSheet, rowIndex: Int, headers: List<String>, styles: Styles) {
val row = sheet.createRow(rowIndex)
headers.forEachIndexed { index, name ->
row.createCell(index).apply {
setCellValue(name)
cellStyle = styles.header
}
}
}

private fun writeEmptyRow(sheet: XSSFSheet, rowIndex: Int, columns: Int, styles: Styles) {
val row = sheet.createRow(rowIndex)
for (col in 0 until columns) {
row.createCell(col).apply {
setCellValue("-")
cellStyle = styles.dash
}
}
}

private fun writeTotalRow(
sheet: XSSFSheet,
rowIndex: Int,
label: String,
qtyColumn: Int,
qty: BigDecimal,
styles: Styles,
leadingBlanks: Int,
) {
val row = sheet.createRow(rowIndex)
setText(row, 0, label, styles)
for (col in 1..leadingBlanks) {
setText(row, col, "", styles)
}
setWholeNumber(row, qtyColumn, qty, styles)
}

private fun firstAnchor(anchors: Map<String, DetailSpan>, itemNo: String): DetailSpan? =
anchors.keys.filter { it.startsWith("$itemNo|") }.minOrNull()?.let { anchors[it] }

private fun setText(
row: org.apache.poi.ss.usermodel.Row,
col: Int,
value: String,
styles: Styles,
detailSheetName: String = "",
anchor: DetailSpan? = null,
link: Boolean = false,
) {
val cell = row.createCell(col)
cell.setCellValue(value)
if (link && anchor != null && detailSheetName.isNotEmpty()) {
cell.cellStyle = styles.linkText
linkTo(cell, detailSheetName, anchor)
} else {
cell.cellStyle = styles.text
}
}

private fun setWholeNumber(
row: org.apache.poi.ss.usermodel.Row,
col: Int,
value: BigDecimal,
styles: Styles,
) {
val cell = row.createCell(col)
val rounded = SemiFgProductionAnalysisLayout.roundQty(value)
if (rounded == 0L) {
cell.setCellValue("-")
cell.cellStyle = styles.dash
return
}
cell.setCellValue(rounded.toDouble())
cell.cellStyle = styles.number
}

/** Clicking the summary code selects this item's rows on Detail. Excel drops the highlight when another cell is selected. */
private fun linkTo(cell: Cell, detailSheetName: String, span: DetailSpan) {
val link = cell.sheet.workbook.creationHelper.createHyperlink(HyperlinkType.DOCUMENT)
val start = span.firstRow + 1
val end = span.lastRow + 1
val lastCol = CellReference.convertNumToColString(DETAIL_LAST_COL)
link.address = "'$detailSheetName'!A$start:$lastCol$end"
cell.hyperlink = link
}

private data class DetailSpan(val firstRow: Int, val lastRow: Int)

private class Styles(workbook: XSSFWorkbook) {
private val format = workbook.creationHelper.createDataFormat().getFormat("#,##0")
private val linkFont = workbook.createFont().apply {
color = IndexedColors.BLUE.index
underline = org.apache.poi.ss.usermodel.Font.U_SINGLE
}
val title: XSSFCellStyle = newStyle(workbook).apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
setFont(workbook.createFont().apply {
bold = true
fontHeightInPoints = 16
})
}
val info: XSSFCellStyle = newStyle(workbook).apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
}
val header: XSSFCellStyle = bordered(workbook).apply {
alignment = HorizontalAlignment.CENTER
fillForegroundColor = IndexedColors.GREY_25_PERCENT.index
fillPattern = FillPatternType.SOLID_FOREGROUND
setFont(workbook.createFont().apply { bold = true })
}
val text: XSSFCellStyle = bordered(workbook).apply { alignment = HorizontalAlignment.LEFT }
val linkText: XSSFCellStyle = bordered(workbook).apply {
alignment = HorizontalAlignment.LEFT
setFont(linkFont)
}
val number: XSSFCellStyle = bordered(workbook).apply {
alignment = HorizontalAlignment.RIGHT
dataFormat = format
}
val dash: XSSFCellStyle = bordered(workbook).apply { alignment = HorizontalAlignment.RIGHT }
val sectionCode: XSSFCellStyle = bordered(workbook).apply {
alignment = HorizontalAlignment.LEFT
borderBottom = BorderStyle.MEDIUM
setFont(workbook.createFont().apply {
bold = true
fontHeightInPoints = 14
})
}
val sectionName: XSSFCellStyle = bordered(workbook).apply {
alignment = HorizontalAlignment.LEFT
borderBottom = BorderStyle.MEDIUM
setFont(workbook.createFont().apply { bold = true })
}
val sectionEdge: XSSFCellStyle = bordered(workbook).apply {
borderBottom = BorderStyle.MEDIUM
}

private fun newStyle(workbook: XSSFWorkbook): XSSFCellStyle =
workbook.createCellStyle() as XSSFCellStyle

private fun bordered(workbook: XSSFWorkbook): XSSFCellStyle = newStyle(workbook).apply {
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THIN
borderBottom = BorderStyle.THIN
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
}
}
}

+ 97
- 0
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<String> = if (english) {
listOf("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
} else {
SemiFgProductionAnalysisLayout.MONTH_LABELS
}

fun viewLine(window: SemiFgWindow): String =
t("檢視:", "View: ") + periodHeading(window)

fun reportDateLine(today: LocalDate = LocalDate.now()): String =
reportDateLabel + today.format(DateTimeFormatter.ISO_LOCAL_DATE)

fun reportTimeLine(time: LocalTime = LocalTime.now()): String =
reportTimeLabel + time.format(DateTimeFormatter.ofPattern("HH:mm:ss"))

fun detailHint(): String = t(
"點選貨品編號會在「明細」選取該貨品的所有列。",
"Click an item code to select that item's rows on Detail.",
)

fun chartNote(): String = t(
"點選「彙總」的貨品編號,會在「明細」選取該貨品的所有列。",
"On Summary, click an item code to select that item's rows on Detail.",
)

fun chartTitle(window: SemiFgWindow): String =
t("生產數量(", "Quantity (") + periodHeading(window) + t(")", ")")

private fun t(zh: String, en: String): String = if (english) en else zh

companion object {
fun of(lang: String?): SemiFgReportText =
SemiFgReportText(lang?.trim()?.lowercase()?.startsWith("en") == true)
}
}

+ 5
- 0
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)


+ 163
- 465
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<ByteArray> =
buildSemiFgProductionAnalysisPdf(
stockCategory, stockSubCategory, itemCode, year, lastOutDateStart, lastOutDateEnd,
)
@RequestParam(required = false) lastOutDateEnd: String?,
@RequestParam(required = false) view: String?,
@RequestParam(required = false) reportDate: String?,
@RequestParam(required = false) reportMonth: String?,
@RequestParam(required = false) reportWeek: String?,
): ResponseEntity<ByteArray> = buildPdf(
SemiFgReportFilter(
view, stockCategory, stockSubCategory, itemCode, year,
lastOutDateStart, lastOutDateEnd, reportDate, reportMonth,
reportWeek = reportWeek,
),
)

@PostMapping("/print-semi-fg-production-analysis")
fun generateSemiFGProductionAnalysisReportPost(
@RequestBody(required = false) req: ReportItemFilterRequest?,
): ResponseEntity<ByteArray> {
val r = req ?: ReportItemFilterRequest()
return buildSemiFgProductionAnalysisPdf(
r.stockCategory,
r.stockSubCategory,
r.combinedItemCode(),
r.year,
r.lastOutDateStart,
r.lastOutDateEnd,
)
}

private fun buildSemiFgProductionAnalysisPdf(
stockCategory: String?,
stockSubCategory: String?,
itemCode: String?,
year: String?,
lastOutDateStart: String?,
lastOutDateEnd: String?,
): ResponseEntity<ByteArray> {
val parameters = mutableMapOf<String, Any>()
// Set report header parameters
parameters["stockCategory"] = stockCategory ?: "All"
parameters["stockSubCategory"] = stockSubCategory ?: "All"
parameters["itemNo"] = itemCode ?: "All"
parameters["year"] = year ?: LocalDate.now().year.toString()
parameters["reportDate"] = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
parameters["reportTime"] = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))
parameters["lastOutDateStart"] = lastOutDateStart ?: ""
parameters["lastOutDateEnd"] = lastOutDateEnd ?: ""
parameters["deliveryPeriodStart"] = ""
parameters["deliveryPeriodEnd"] = ""

// Query the DB to get a list of data
val dbData = semiFGProductionAnalysisReportService.searchSemiFGProductionAnalysisReport(
stockCategory,
stockSubCategory,
itemCode,
year,
lastOutDateStart,
lastOutDateEnd
)

val pdfBytes = reportService.createPdfResponse(
"/jasper/SemiFGProductionAnalysisReport.jrxml",
parameters,
dbData
)

val headers = HttpHeaders().apply {
contentType = MediaType.APPLICATION_PDF
setContentDispositionFormData("attachment", "SemiFGProductionAnalysisReport.pdf")
set("filename", "SemiFGProductionAnalysisReport.pdf")
}

return ResponseEntity(pdfBytes, headers, HttpStatus.OK)
}
): ResponseEntity<ByteArray> = buildPdf(filterOf(req))

@GetMapping("/print-semi-fg-production-analysis-excel")
fun exportSemiFGProductionAnalysisReportExcel(
@@ -120,401 +63,156 @@ class SemiFGProductionAnalysisReportController(
@RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) year: String?,
@RequestParam(required = false) lastOutDateStart: String?,
@RequestParam(required = false) lastOutDateEnd: String?
): ResponseEntity<ByteArray> =
buildSemiFgProductionAnalysisExcel(
stockCategory, stockSubCategory, itemCode, year, lastOutDateStart, lastOutDateEnd,
)
@RequestParam(required = false) lastOutDateEnd: String?,
@RequestParam(required = false) view: String?,
@RequestParam(required = false) reportDate: String?,
@RequestParam(required = false) reportMonth: String?,
@RequestParam(required = false) reportWeek: String?,
): ResponseEntity<ByteArray> = buildExcel(
SemiFgReportFilter(
view, stockCategory, stockSubCategory, itemCode, year,
lastOutDateStart, lastOutDateEnd, reportDate, reportMonth,
reportWeek = reportWeek,
),
)

@PostMapping("/print-semi-fg-production-analysis-excel")
fun exportSemiFGProductionAnalysisReportExcelPost(
@RequestBody(required = false) req: ReportItemFilterRequest?,
): ResponseEntity<ByteArray> {
val r = req ?: ReportItemFilterRequest()
return buildSemiFgProductionAnalysisExcel(
r.stockCategory,
r.stockSubCategory,
r.combinedItemCode(),
r.year,
r.lastOutDateStart,
r.lastOutDateEnd,
)
}

private fun buildSemiFgProductionAnalysisExcel(
stockCategory: String?,
stockSubCategory: String?,
itemCode: String?,
year: String?,
lastOutDateStart: String?,
lastOutDateEnd: String?,
): ResponseEntity<ByteArray> {
val dbData = semiFGProductionAnalysisReportService.searchSemiFGProductionAnalysisReport(
stockCategory,
stockSubCategory,
itemCode,
year,
lastOutDateStart,
lastOutDateEnd
)

val excelBytes = createSemiFGProductionAnalysisExcel(
dbData = dbData,
reportTitle = "成品/半成品生產分析報告",
year = year
)
): ResponseEntity<ByteArray> = buildExcel(filterOf(req))

val headers = HttpHeaders().apply {
contentType = MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
setContentDispositionFormData("attachment", "SemiFGProductionAnalysisReport.xlsx")
set("filename", "SemiFGProductionAnalysisReport.xlsx")
}

return ResponseEntity(excelBytes, headers, HttpStatus.OK)
@GetMapping("/semi-fg-item-codes")
fun getSemiFGItemCodes(
@RequestParam(required = false) stockCategory: String?,
): ResponseEntity<List<Map<String, String>>> {
val itemCodes = semiFGProductionAnalysisReportService.getSemiFGItemCodes(stockCategory)
return ResponseEntity(itemCodes, HttpStatus.OK)
}

private fun createSemiFGProductionAnalysisExcel(
dbData: List<Map<String, Any>>,
reportTitle: String,
year: String?
): ByteArray {
val workbook = XSSFWorkbook()
val safeSheetName = WorkbookUtil.createSafeSheetName(reportTitle)
val sheet = workbook.createSheet(safeSheetName)
val totalColumns = 16
var rowIndex = 0

val titleStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
}
val titleFont = workbook.createFont().apply {
bold = true
fontHeightInPoints = 16
}
titleStyle.setFont(titleFont)

val infoStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
}

val headerStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
fillForegroundColor = IndexedColors.GREY_25_PERCENT.index
fillPattern = FillPatternType.SOLID_FOREGROUND
borderTop = BorderStyle.THIN
borderBottom = BorderStyle.THIN
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
}
val headerFont = workbook.createFont().apply { bold = true }
headerStyle.setFont(headerFont)

val textStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.LEFT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THIN
borderBottom = BorderStyle.THIN
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
}

val numberStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.RIGHT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THIN
borderBottom = BorderStyle.THIN
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
dataFormat = workbook.creationHelper.createDataFormat().getFormat("#,##0")
}

val dashStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.RIGHT
verticalAlignment = VerticalAlignment.CENTER
borderTop = BorderStyle.THIN
borderBottom = BorderStyle.THIN
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
}

val titleRow = sheet.createRow(rowIndex++)
titleRow.heightInPoints = 28f
val titleCell = titleRow.createCell(0)
titleCell.setCellValue(reportTitle)
titleCell.cellStyle = titleStyle
sheet.addMergedRegion(CellRangeAddress(0, 0, 0, totalColumns - 1))

val infoRow = sheet.createRow(rowIndex++)
val reportDateCell = infoRow.createCell(0)
reportDateCell.setCellValue("報告日期:${LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))}")
reportDateCell.cellStyle = infoStyle
val reportTimeCell = infoRow.createCell(6)
reportTimeCell.setCellValue("報告時間:${LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))}")
reportTimeCell.cellStyle = infoStyle
val reportYearCell = infoRow.createCell(11)
reportYearCell.setCellValue("年份:${year?.takeIf { it.isNotBlank() && it != "All" } ?: "All"}")
reportYearCell.cellStyle = infoStyle
sheet.addMergedRegion(CellRangeAddress(1, 1, 0, 5))
sheet.addMergedRegion(CellRangeAddress(1, 1, 6, 10))
sheet.addMergedRegion(CellRangeAddress(1, 1, 11, 15))

rowIndex++ // spacer row

val headers = listOf(
"貨品編號",
"貨品名稱",
"單位",
"一月",
"二月",
"三月",
"四月",
"五月",
"六月",
"七月",
"八月",
"九月",
"十月",
"十一月",
"十二月",
"上架總計"
)

val headerRow = sheet.createRow(rowIndex++)
headers.forEachIndexed { index, name ->
val cell = headerRow.createCell(index)
cell.setCellValue(name)
cell.cellStyle = headerStyle
}

if (dbData.isEmpty()) {
val dataRow = sheet.createRow(rowIndex++)
setTextCell(dataRow, 0, "-", textStyle)
setTextCell(dataRow, 1, "-", textStyle)
setTextCell(dataRow, 2, "-", textStyle)
for (col in 3 until totalColumns) {
setDashCell(dataRow, col, dashStyle)
}
} else {
dbData.forEach { row ->
val dataRow = sheet.createRow(rowIndex++)
setTextCell(dataRow, 0, row["itemNo"]?.toString()?.ifBlank { "-" } ?: "-", textStyle)
setTextCell(dataRow, 1, row["itemName"]?.toString()?.ifBlank { "-" } ?: "-", textStyle)
setTextCell(dataRow, 2, row["unitOfMeasure"]?.toString()?.ifBlank { "-" } ?: "-", textStyle)
setNumberCell(dataRow, 3, row["qtyJan"], numberStyle, dashStyle)
setNumberCell(dataRow, 4, row["qtyFeb"], numberStyle, dashStyle)
setNumberCell(dataRow, 5, row["qtyMar"], numberStyle, dashStyle)
setNumberCell(dataRow, 6, row["qtyApr"], numberStyle, dashStyle)
setNumberCell(dataRow, 7, row["qtyMay"], numberStyle, dashStyle)
setNumberCell(dataRow, 8, row["qtyJun"], numberStyle, dashStyle)
setNumberCell(dataRow, 9, row["qtyJul"], numberStyle, dashStyle)
setNumberCell(dataRow, 10, row["qtyAug"], numberStyle, dashStyle)
setNumberCell(dataRow, 11, row["qtySep"], numberStyle, dashStyle)
setNumberCell(dataRow, 12, row["qtyOct"], numberStyle, dashStyle)
setNumberCell(dataRow, 13, row["qtyNov"], numberStyle, dashStyle)
setNumberCell(dataRow, 14, row["qtyDec"], numberStyle, dashStyle)
setNumberCell(dataRow, 15, row["totalProductionQty"], numberStyle, dashStyle)
}
}

val widths = intArrayOf(18, 24, 12, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 12)
widths.forEachIndexed { idx, width -> sheet.setColumnWidth(idx, width * 256) }

addSemiFGProductionChartSheets(workbook, dbData)

val output = ByteArrayOutputStream()
workbook.use { it.write(output) }
return output.toByteArray()
@GetMapping("/semi-fg-item-codes-with-category")
fun getSemiFGItemCodesWithCategory(
@RequestParam(required = false) stockCategory: String?,
): ResponseEntity<List<Map<String, String>>> {
val itemCodesWithCategory = semiFGProductionAnalysisReportService.getSemiFGItemCodesWithCategory(stockCategory)
return ResponseEntity(itemCodesWithCategory, HttpStatus.OK)
}

/**
* Adds a visible chart sheet with:
* - Cell B1 dropdown to choose 貨品編號 or 「全部」(sum of all items)
* - Formulas that pull monthly qty from a very-hidden data sheet
* - Column bar chart bound to those formula cells (updates when selection changes)
*/
private fun addSemiFGProductionChartSheets(workbook: XSSFWorkbook, dbData: List<Map<String, Any>>) {
val monthKeys = listOf(
"qtyJan", "qtyFeb", "qtyMar", "qtyApr", "qtyMay", "qtyJun",
"qtyJul", "qtyAug", "qtySep", "qtyOct", "qtyNov", "qtyDec",
)
val monthLabels = listOf(
"一月", "二月", "三月", "四月", "五月", "六月",
"七月", "八月", "九月", "十月", "十一月", "十二月",
private fun filterOf(req: ReportItemFilterRequest?): SemiFgReportFilter {
val r = req ?: ReportItemFilterRequest()
return SemiFgReportFilter(
view = r.view,
stockCategory = r.stockCategory,
stockSubCategory = r.stockSubCategory,
itemCode = r.combinedItemCode(),
year = r.year,
lastOutDateStart = r.lastOutDateStart,
lastOutDateEnd = r.lastOutDateEnd,
reportDate = r.reportDate,
reportMonth = r.reportMonth,
lang = r.lang,
reportWeek = r.reportWeek,
)

val dataSheetName = WorkbookUtil.createSafeSheetName("圖表數據")
val dataSheet = workbook.createSheet(dataSheetName)

val headerRow = dataSheet.createRow(0)
headerRow.createCell(0).setCellValue("貨品編號")
monthLabels.forEachIndexed { i, label ->
headerRow.createCell(1 + i).setCellValue(label)
}

dbData.forEachIndexed { idx, row ->
val r = dataSheet.createRow(1 + idx)
r.createCell(0).setCellValue(row["itemNo"]?.toString() ?: "")
monthKeys.forEachIndexed { mi, key ->
r.createCell(1 + mi).setCellValue(parseNumericForExcelChart(row[key]))
}
}

val zColIndex = CellReference.convertColStringToIndex("Z")
val zHeader = dataSheet.getRow(0) ?: dataSheet.createRow(0)
zHeader.createCell(zColIndex).setCellValue("全部")
dbData.forEachIndexed { idx, row ->
val r = dataSheet.getRow(1 + idx) ?: dataSheet.createRow(1 + idx)
r.createCell(zColIndex).setCellValue(row["itemNo"]?.toString() ?: "")
}

val lastDataRow1Based = 1 + dbData.size
val zListEnd1Based = 1 + dbData.size

val chartSheetName = WorkbookUtil.createSafeSheetName("生產圖表")
val chartSheet = workbook.createSheet(chartSheetName)

val labelR = chartSheet.createRow(0)
labelR.createCell(0).setCellValue("選擇貨品編號")
val filterCell = labelR.createCell(1)
val defaultItemNo = dbData.firstOrNull()?.get("itemNo")?.toString()?.trim()
filterCell.setCellValue(defaultItemNo?.takeIf { it.isNotBlank() } ?: "全部")

val dvHelper: DataValidationHelper = chartSheet.dataValidationHelper
val listFormula = "'$dataSheetName'!\$Z\$1:\$Z\$$zListEnd1Based"
val constraint: DataValidationConstraint = dvHelper.createFormulaListConstraint(listFormula)
val regions = CellRangeAddressList(0, 0, 1, 1)
val validation = dvHelper.createValidation(constraint, regions)
validation.showErrorBox = true
validation.createErrorBox("無效選擇", "請從清單中選擇貨品編號或「全部」。")
validation.showPromptBox = true
validation.createPromptBox("篩選", "選擇貨品編號檢視該品項各月數量;選擇「全部」顯示所有品項加總。")
chartSheet.addValidationData(validation)

val catRowIndex = 3
val valRowIndex = 4
val firstMonthCol = 1
val lastMonthCol = 12

val catRow = chartSheet.createRow(catRowIndex)
val valRow = chartSheet.createRow(valRowIndex)
for (i in 0 until 12) {
catRow.createCell(firstMonthCol + i).setCellValue(monthLabels[i])
val colLetter = CellReference.convertNumToColString(firstMonthCol + i)
val formula = if (lastDataRow1Based < 2) {
"0"
} else {
"IF(TRIM(\$B\$1)=\"全部\",SUM('$dataSheetName'!$colLetter\$2:$colLetter\$$lastDataRow1Based),IFERROR(INDEX('$dataSheetName'!$colLetter\$2:$colLetter\$$lastDataRow1Based,MATCH(TRIM(\$B\$1),'$dataSheetName'!\$A\$2:\$A\$$lastDataRow1Based,0)),0))"
}
valRow.createCell(firstMonthCol + i).setCellFormula(formula)
}
catRow.createCell(lastMonthCol + 1).setCellValue("總和")
valRow.createCell(lastMonthCol + 1).setCellFormula("SUM(B5:M5)")

val drawing = chartSheet.createDrawingPatriarch()
val anchor = XSSFClientAnchor(0, 0, 0, 0, 1, 6, 14, 28)
val chart = drawing.createChart(anchor)
chart.setTitleText("各月生產數量(依選擇之貨品編號)")
chart.setTitleOverlay(false)

val legend = chart.getOrAddLegend()
legend.position = LegendPosition.BOTTOM

val bottomAxis = chart.createCategoryAxis(AxisPosition.BOTTOM)
bottomAxis.setTitle("月份")
val leftAxis = chart.createValueAxis(AxisPosition.LEFT)
leftAxis.setTitle("生產數量")

val catRangeAddr = CellRangeAddress(catRowIndex, catRowIndex, firstMonthCol, lastMonthCol)
val valRangeAddr = CellRangeAddress(valRowIndex, valRowIndex, firstMonthCol, lastMonthCol)
val categories = XDDFDataSourcesFactory.fromStringCellRange(chartSheet, catRangeAddr)
val values = XDDFDataSourcesFactory.fromNumericCellRange(chartSheet, valRangeAddr)

val barData = chart.createData(ChartTypes.BAR, bottomAxis, leftAxis) as XDDFBarChartData
barData.barDirection = BarDirection.COL
val series = barData.addSeries(categories, values)
series.setTitle("生產數量", null)
chart.plot(barData)

chartSheet.setColumnWidth(0, 18 * 256)
chartSheet.setColumnWidth(1, 10 * 256)
chartSheet.setColumnWidth(lastMonthCol + 1, 12 * 256)

workbook.setSheetVisibility(workbook.getSheetIndex(dataSheet), SheetVisibility.VERY_HIDDEN)
workbook.setSheetOrder(chartSheetName, 1)
}

private fun parseNumericForExcelChart(value: Any?): Double {
if (value == null) return 0.0
return when (value) {
is Number -> value.toDouble()
is String -> {
val s = value.replace(",", "").trim()
if (s.isEmpty() || s == "-") return 0.0
s.toDoubleOrNull() ?: 0.0
private fun buildPdf(filter: SemiFgReportFilter): ResponseEntity<ByteArray> {
val data = load(filter)
val text = SemiFgReportText.of(filter.lang)
val today = LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE)
val time = LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"))
val pdfBytes = if (data.view == SemiFgView.YEAR) {
val parameters = mutableMapOf<String, Any>(
"stockCategory" to (filter.stockCategory ?: "All"),
"stockSubCategory" to (filter.stockSubCategory ?: "All"),
"itemNo" to (filter.itemCode ?: "All"),
"year" to (data.window.year?.toString() ?: ""),
"reportDate" to today,
"reportTime" to time,
"lastOutDateStart" to data.window.start.toString(),
"lastOutDateEnd" to data.window.endInclusive.toString(),
"deliveryPeriodStart" to "",
"deliveryPeriodEnd" to "",
)
parameters.putAll(pdfHeaderParameters(text))
reportService.createPdfResponse(
"/jasper/SemiFGProductionAnalysisReport.jrxml",
parameters,
SemiFgProductionAnalysisLayout.yearPivotRows(data),
)
} else {
val rows = SemiFgProductionAnalysisLayout.listRows(data).map {
mapOf(
"period" to it.period,
"itemNo" to it.itemNo,
"itemName" to it.itemName,
"unitOfMeasure" to it.unitOfMeasure,
"qtyText" to SemiFgProductionAnalysisLayout.roundQty(it.qty).toString(),
)
}.ifEmpty {
listOf(
mapOf(
"period" to "-",
"itemNo" to "-",
"itemName" to "-",
"unitOfMeasure" to "-",
"qtyText" to "-",
),
)
}
else -> value.toString().replace(",", "").trim().toDoubleOrNull() ?: 0.0
}
}

private fun setTextCell(
row: org.apache.poi.ss.usermodel.Row,
col: Int,
value: Any?,
style: org.apache.poi.ss.usermodel.CellStyle
) {
val cell = row.createCell(col)
cell.setCellValue(value?.toString() ?: "")
cell.cellStyle = style
}

private fun setNumberCell(
row: org.apache.poi.ss.usermodel.Row,
col: Int,
value: Any?,
style: org.apache.poi.ss.usermodel.CellStyle,
dashStyle: org.apache.poi.ss.usermodel.CellStyle
) {
val cell = row.createCell(col)
val parsed = when (value) {
is Number -> value.toDouble()
is String -> value.replace(",", "").toDoubleOrNull() ?: 0.0
else -> value?.toString()?.replace(",", "")?.toDoubleOrNull() ?: 0.0
val parameters = mutableMapOf<String, Any>(
"reportTitle" to text.title,
"periodLabel" to text.periodHeading(data.window),
"reportDate" to today,
"reportTime" to time,
)
parameters.putAll(pdfHeaderParameters(text))
reportService.createPdfResponse(
"/jasper/SemiFGProductionAnalysisListReport.jrxml",
parameters,
rows,
)
}
if (parsed == 0.0) {
cell.setCellValue("-")
cell.cellStyle = dashStyle
} else {
cell.setCellValue(parsed.roundToLong().toDouble())
cell.cellStyle = style
val headers = HttpHeaders().apply {
contentType = MediaType.APPLICATION_PDF
setContentDispositionFormData("attachment", "SemiFGProductionAnalysisReport.pdf")
set("filename", "SemiFGProductionAnalysisReport.pdf")
}
return ResponseEntity(pdfBytes, headers, HttpStatus.OK)
}

private fun setDashCell(
row: org.apache.poi.ss.usermodel.Row,
col: Int,
style: org.apache.poi.ss.usermodel.CellStyle
) {
val cell = row.createCell(col)
cell.setCellValue("-")
cell.cellStyle = style
private fun buildExcel(filter: SemiFgReportFilter): ResponseEntity<ByteArray> {
val bytes = workbook.build(load(filter), filter.lang)
val headers = HttpHeaders().apply {
contentType = MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
setContentDispositionFormData("attachment", "SemiFGProductionAnalysisReport.xlsx")
set("filename", "SemiFGProductionAnalysisReport.xlsx")
}
return ResponseEntity(bytes, headers, HttpStatus.OK)
}

@GetMapping("/semi-fg-item-codes")
fun getSemiFGItemCodes(
@RequestParam(required = false) stockCategory: String?
): ResponseEntity<List<Map<String, String>>> {
val itemCodes = semiFGProductionAnalysisReportService.getSemiFGItemCodes(stockCategory)
return ResponseEntity(itemCodes, HttpStatus.OK)
private fun pdfHeaderParameters(text: SemiFgReportText): Map<String, Any> {
val months = listOf("hJan", "hFeb", "hMar", "hApr", "hMay", "hJun", "hJul", "hAug", "hSep", "hOct", "hNov", "hDec")
val headers = mutableMapOf<String, Any>(
"hTitle" to text.title,
"hPage" to text.pageLabel,
"hReportDate" to text.reportDateLabel,
"hReportTime" to text.reportTimeLabel,
"hYear" to text.yearLabel,
"hProdDate" to text.productionDateLabel,
"hTo" to text.toLabel,
"hItemNo" to text.itemNo,
"hItemName" to text.itemName,
"hUom" to text.uom,
"hTotal" to text.putAwayTotal,
"hPeriod" to text.period,
"hQty" to text.putAwayQty,
)
months.forEachIndexed { index, name -> headers[name] = text.monthLabels[index] }
return headers
}

@GetMapping("/semi-fg-item-codes-with-category")
fun getSemiFGItemCodesWithCategory(
@RequestParam(required = false) stockCategory: String?
): ResponseEntity<List<Map<String, String>>> {
val itemCodesWithCategory = semiFGProductionAnalysisReportService.getSemiFGItemCodesWithCategory(stockCategory)
return ResponseEntity(itemCodesWithCategory, HttpStatus.OK)
private fun load(filter: SemiFgReportFilter) = try {
semiFGProductionAnalysisReportService.loadReport(filter)
} catch (ex: SemiFgReportRequestException) {
throw ResponseStatusException(HttpStatus.BAD_REQUEST, ex.message)
}
}

+ 142
- 0
src/main/resources/jasper/SemiFGProductionAnalysisListReport.jrxml Просмотреть файл

@@ -0,0 +1,142 @@
<?xml version="1.0" encoding="UTF-8"?>
<jasperReport xmlns="http://jasperreports.sourceforge.net/jasperreports" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://jasperreports.sourceforge.net/jasperreports http://jasperreports.sourceforge.net/xsd/jasperreport.xsd" name="SemiFGProductionAnalysisListReport" pageWidth="842" pageHeight="595" orientation="Landscape" columnWidth="802" leftMargin="20" rightMargin="20" topMargin="20" bottomMargin="20" uuid="7c1a0e2b-4f6d-4a1e-9b3c-2d8e5f0a1b44">
<parameter name="reportTitle" class="java.lang.String"/>
<parameter name="periodLabel" class="java.lang.String"/>
<parameter name="reportDate" class="java.lang.String"/>
<parameter name="reportTime" class="java.lang.String"/>
<parameter name="hTitle" class="java.lang.String"/>
<parameter name="hPage" class="java.lang.String"/>
<parameter name="hReportDate" class="java.lang.String"/>
<parameter name="hReportTime" class="java.lang.String"/>
<parameter name="hYear" class="java.lang.String"/>
<parameter name="hProdDate" class="java.lang.String"/>
<parameter name="hTo" class="java.lang.String"/>
<parameter name="hItemNo" class="java.lang.String"/>
<parameter name="hItemName" class="java.lang.String"/>
<parameter name="hUom" class="java.lang.String"/>
<parameter name="hTotal" class="java.lang.String"/>
<parameter name="hPeriod" class="java.lang.String"/>
<parameter name="hQty" class="java.lang.String"/>
<parameter name="hJan" class="java.lang.String"/>
<parameter name="hFeb" class="java.lang.String"/>
<parameter name="hMar" class="java.lang.String"/>
<parameter name="hApr" class="java.lang.String"/>
<parameter name="hMay" class="java.lang.String"/>
<parameter name="hJun" class="java.lang.String"/>
<parameter name="hJul" class="java.lang.String"/>
<parameter name="hAug" class="java.lang.String"/>
<parameter name="hSep" class="java.lang.String"/>
<parameter name="hOct" class="java.lang.String"/>
<parameter name="hNov" class="java.lang.String"/>
<parameter name="hDec" class="java.lang.String"/>
<field name="period" class="java.lang.String"/>
<field name="itemNo" class="java.lang.String"/>
<field name="itemName" class="java.lang.String"/>
<field name="unitOfMeasure" class="java.lang.String"/>
<field name="qtyText" class="java.lang.String"/>
<pageHeader>
<band height="70">
<textField>
<reportElement x="0" y="0" width="802" height="28" uuid="a1b2c3d4-e5f6-7890-abcd-ef1234567890"/>
<textElement textAlignment="Center" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="16" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$P{hTitle}]]></textFieldExpression></textField>
<textField>
<reportElement x="0" y="30" width="400" height="18" uuid="b2c3d4e5-f6a7-8901-bcde-f12345678901"/>
<textElement verticalAlignment="Middle">
<font fontName="微軟正黑體" size="11" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$P{periodLabel}]]></textFieldExpression>
</textField>
<textField>
<reportElement x="420" y="30" width="180" height="18" uuid="c3d4e5f6-a7b8-9012-cdef-123456789012"/>
<textElement verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10"/>
</textElement>
<textFieldExpression><![CDATA[$P{hReportDate} + $P{reportDate}]]></textFieldExpression>
</textField>
<textField>
<reportElement x="610" y="30" width="180" height="18" uuid="d4e5f6a7-b8c9-0123-def0-234567890123"/>
<textElement verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10"/>
</textElement>
<textFieldExpression><![CDATA[$P{hReportTime} + $P{reportTime}]]></textFieldExpression>
</textField>
</band>
</pageHeader>
<columnHeader>
<band height="24">
<textField>
<reportElement x="0" y="0" width="120" height="20" uuid="e5f6a7b8-c9d0-1234-ef01-345678901234"/>
<textElement verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$P{hPeriod}]]></textFieldExpression></textField>
<textField>
<reportElement x="120" y="0" width="120" height="20" uuid="f6a7b8c9-d0e1-2345-f012-456789012345"/>
<textElement verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$P{hItemNo}]]></textFieldExpression></textField>
<textField>
<reportElement x="240" y="0" width="320" height="20" uuid="a7b8c9d0-e1f2-3456-0123-567890123456"/>
<textElement verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$P{hItemName}]]></textFieldExpression></textField>
<textField>
<reportElement x="560" y="0" width="100" height="20" uuid="b8c9d0e1-f2a3-4567-1234-678901234567"/>
<textElement verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$P{hUom}]]></textFieldExpression></textField>
<textField>
<reportElement x="660" y="0" width="120" height="20" uuid="c9d0e1f2-a3b4-5678-2345-789012345678"/>
<textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$P{hQty}]]></textFieldExpression></textField>
</band>
</columnHeader>
<detail>
<band height="20">
<textField>
<reportElement x="0" y="0" width="120" height="18" uuid="d0e1f2a3-b4c5-6789-3456-890123456789"/>
<textElement verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10"/>
</textElement>
<textFieldExpression><![CDATA[$F{period}]]></textFieldExpression>
</textField>
<textField>
<reportElement x="120" y="0" width="120" height="18" uuid="e1f2a3b4-c5d6-7890-4567-901234567890"/>
<textElement verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10"/>
</textElement>
<textFieldExpression><![CDATA[$F{itemNo}]]></textFieldExpression>
</textField>
<textField>
<reportElement x="240" y="0" width="320" height="18" uuid="f2a3b4c5-d6e7-8901-5678-012345678901"/>
<textElement verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10"/>
</textElement>
<textFieldExpression><![CDATA[$F{itemName}]]></textFieldExpression>
</textField>
<textField>
<reportElement x="560" y="0" width="100" height="18" uuid="a3b4c5d6-e7f8-9012-6789-123456789012"/>
<textElement verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10"/>
</textElement>
<textFieldExpression><![CDATA[$F{unitOfMeasure}]]></textFieldExpression>
</textField>
<textField>
<reportElement x="660" y="0" width="120" height="18" uuid="b4c5d6e7-f8a9-0123-7890-234567890123"/>
<textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10"/>
</textElement>
<textFieldExpression><![CDATA[$F{qtyText}]]></textFieldExpression>
</textField>
</band>
</detail>
</jasperReport>

+ 71
- 67
src/main/resources/jasper/SemiFGProductionAnalysisReport.jrxml Просмотреть файл

@@ -32,6 +32,32 @@
<parameter name="lastOutDateEnd" class="java.lang.String">
<parameterDescription><![CDATA["lastOutDateStart"]]></parameterDescription>
</parameter>
<parameter name="hTitle" class="java.lang.String"/>
<parameter name="hPage" class="java.lang.String"/>
<parameter name="hReportDate" class="java.lang.String"/>
<parameter name="hReportTime" class="java.lang.String"/>
<parameter name="hYear" class="java.lang.String"/>
<parameter name="hProdDate" class="java.lang.String"/>
<parameter name="hTo" class="java.lang.String"/>
<parameter name="hItemNo" class="java.lang.String"/>
<parameter name="hItemName" class="java.lang.String"/>
<parameter name="hUom" class="java.lang.String"/>
<parameter name="hTotal" class="java.lang.String"/>
<parameter name="hPeriod" class="java.lang.String"/>
<parameter name="hQty" class="java.lang.String"/>
<parameter name="hJan" class="java.lang.String"/>
<parameter name="hFeb" class="java.lang.String"/>
<parameter name="hMar" class="java.lang.String"/>
<parameter name="hApr" class="java.lang.String"/>
<parameter name="hMay" class="java.lang.String"/>
<parameter name="hJun" class="java.lang.String"/>
<parameter name="hJul" class="java.lang.String"/>
<parameter name="hAug" class="java.lang.String"/>
<parameter name="hSep" class="java.lang.String"/>
<parameter name="hOct" class="java.lang.String"/>
<parameter name="hNov" class="java.lang.String"/>
<parameter name="hDec" class="java.lang.String"/>
<queryString>
<![CDATA[select * from fpsmsdb.items , fpsmsdb.stock_out_line]]>
</queryString>
@@ -366,7 +392,7 @@
<textElement textAlignment="Center" verticalAlignment="Middle"/>
<textFieldExpression><![CDATA[$V{PAGE_NUMBER}]]></textFieldExpression>
</textField>
<staticText>
<textField>
<reportElement x="677" y="0" width="40" height="23" uuid="5a9e0fa9-418d-4838-9f82-e2f336e5bce7">
<property name="com.jaspersoft.studio.unit.y" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/>
@@ -374,8 +400,7 @@
<textElement textAlignment="Center" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="12"/>
</textElement>
<text><![CDATA[頁數]]></text>
</staticText>
<textFieldExpression><![CDATA[$P{hPage}]]></textFieldExpression></textField>
<staticText>
<reportElement x="747" y="0" width="21" height="23" uuid="26d3d09c-48aa-4870-822a-c403e7faddfa">
<property name="com.jaspersoft.studio.unit.y" value="px"/>
@@ -393,7 +418,7 @@
<textElement textAlignment="Center" verticalAlignment="Middle"/>
<textFieldExpression><![CDATA[$V{PAGE_NUMBER}]]></textFieldExpression>
</textField>
<staticText>
<textField>
<reportElement x="10" y="30" width="90" height="23" uuid="0daddd8b-ca61-42af-9c2b-cbf11b7d7dac">
<property name="com.jaspersoft.studio.unit.y" value="px"/>
<property name="com.jaspersoft.studio.unit.height" value="px"/>
@@ -401,8 +426,7 @@
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="12" isBold="true"/>
</textElement>
<text><![CDATA[報告日期:]]></text>
</staticText>
<textFieldExpression><![CDATA[$P{hReportDate}]]></textFieldExpression></textField>
<textField>
<reportElement x="708" y="52" width="90" height="23" uuid="53b66bc3-4925-4340-add0-b4f2069a76c1"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
@@ -410,7 +434,7 @@
</textElement>
<textFieldExpression><![CDATA[$P{year}]]></textFieldExpression>
</textField>
<staticText>
<textField>
<reportElement x="640" y="29" width="68" height="23" uuid="e2be43a3-3570-4a4e-a671-d96f872a5ad7">
<property name="com.jaspersoft.studio.unit.y" value="px"/>
<property name="com.jaspersoft.studio.unit.height" value="px"/>
@@ -418,9 +442,8 @@
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="12" isBold="true"/>
</textElement>
<text><![CDATA[報告時間:]]></text>
</staticText>
<staticText>
<textFieldExpression><![CDATA[$P{hReportTime}]]></textFieldExpression></textField>
<textField>
<reportElement x="640" y="52" width="68" height="23" uuid="29ed871d-3417-4978-9799-964389143451">
<property name="com.jaspersoft.studio.unit.y" value="px"/>
<property name="com.jaspersoft.studio.unit.height" value="px"/>
@@ -428,8 +451,7 @@
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="12" isBold="true"/>
</textElement>
<text><![CDATA[年份:]]></text>
</staticText>
<textFieldExpression><![CDATA[$P{hYear}]]></textFieldExpression></textField>
<textField>
<reportElement x="708" y="29" width="90" height="23" uuid="cb1fbb06-e953-40e5-bdbe-5fc219f7c884"/>
<textElement textAlignment="Left" verticalAlignment="Middle">
@@ -444,7 +466,7 @@
</textElement>
<textFieldExpression><![CDATA[$P{reportDate}]]></textFieldExpression>
</textField>
<staticText>
<textField>
<reportElement x="300" y="0" width="210" height="23" uuid="7451001b-6d5a-438c-82db-2b9e687d9a27">
<property name="com.jaspersoft.studio.unit.y" value="px"/>
<property name="com.jaspersoft.studio.unit.height" value="px"/>
@@ -452,8 +474,7 @@
<textElement textAlignment="Center" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="16" isBold="true"/>
</textElement>
<text><![CDATA[成品/半成品生產分析報告]]></text>
</staticText>
<textFieldExpression><![CDATA[$P{hTitle}]]></textFieldExpression></textField>
<textField>
<reportElement x="100" y="53" width="336" height="23" uuid="b51949e1-c40a-4db0-a799-4243555893fd">
<property name="com.jaspersoft.studio.unit.height" value="px"/>
@@ -461,9 +482,9 @@
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="12" isBold="true"/>
</textElement>
<textFieldExpression><![CDATA[$P{lastOutDateStart} + " 至 " + $P{lastOutDateEnd}]]></textFieldExpression>
<textFieldExpression><![CDATA[$P{lastOutDateStart} + " " + $P{hTo} + " " + $P{lastOutDateEnd}]]></textFieldExpression>
</textField>
<staticText>
<textField>
<reportElement x="10" y="53" width="90" height="23" uuid="fb09a559-f5fa-4e56-a891-87c600a2745a">
<property name="com.jaspersoft.studio.unit.y" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/>
@@ -472,8 +493,7 @@
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="12" isBold="true"/>
</textElement>
<text><![CDATA[完成生產日期:]]></text>
</staticText>
<textFieldExpression><![CDATA[$P{hProdDate}]]></textFieldExpression></textField>
<line>
<reportElement x="0" y="75" width="799" height="1" uuid="f04b0470-72e7-42b6-80fd-f1dc4ea5fa66">
<property name="com.jaspersoft.studio.unit.height" value="px"/>
@@ -488,16 +508,15 @@
</pageHeader>
<columnHeader>
<band height="28">
<staticText>
<textField>
<reportElement x="200" y="0" width="45" height="20" uuid="e4267f38-4a00-4a22-965d-9ab531262592">
<property name="com.jaspersoft.studio.unit.y" value="px"/>
</reportElement>
<textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement>
<text><![CDATA[一月]]></text>
</staticText>
<staticText>
<textFieldExpression><![CDATA[$P{hJan}]]></textFieldExpression></textField>
<textField>
<reportElement x="650" y="0" width="45" height="20" uuid="e51758d1-2a7e-4fef-8b41-206e9aa6c611">
<property name="com.jaspersoft.studio.unit.y" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/>
@@ -505,9 +524,8 @@
<textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement>
<text><![CDATA[十一月]]></text>
</staticText>
<staticText>
<textFieldExpression><![CDATA[$P{hNov}]]></textFieldExpression></textField>
<textField>
<reportElement x="695" y="0" width="45" height="20" uuid="d7c79995-7b3c-4622-8d08-6b4a87345cbe">
<property name="com.jaspersoft.studio.unit.y" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/>
@@ -515,9 +533,8 @@
<textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement>
<text><![CDATA[十二月]]></text>
</staticText>
<staticText>
<textFieldExpression><![CDATA[$P{hDec}]]></textFieldExpression></textField>
<textField>
<reportElement x="290" y="0" width="45" height="20" uuid="ac124afb-3aa8-4ec6-b4a8-8de161e8bbc5">
<property name="com.jaspersoft.studio.unit.y" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/>
@@ -525,9 +542,8 @@
<textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement>
<text><![CDATA[三月]]></text>
</staticText>
<staticText>
<textFieldExpression><![CDATA[$P{hMar}]]></textFieldExpression></textField>
<textField>
<reportElement x="740" y="0" width="50" height="20" uuid="f092d4db-d5c5-4515-a826-8fec621a8992">
<property name="com.jaspersoft.studio.unit.y" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/>
@@ -535,9 +551,8 @@
<textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement>
<text><![CDATA[上架總計]]></text>
</staticText>
<staticText>
<textFieldExpression><![CDATA[$P{hTotal}]]></textFieldExpression></textField>
<textField>
<reportElement x="560" y="0" width="45" height="20" uuid="85c77a9b-c044-4bc2-8cd9-3b0058e4b74e">
<property name="com.jaspersoft.studio.unit.y" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/>
@@ -545,9 +560,8 @@
<textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement>
<text><![CDATA[九月]]></text>
</staticText>
<staticText>
<textFieldExpression><![CDATA[$P{hSep}]]></textFieldExpression></textField>
<textField>
<reportElement x="245" y="0" width="45" height="20" uuid="ec7698ae-c92b-4b44-9f7a-28f10ba5f385">
<property name="com.jaspersoft.studio.unit.y" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/>
@@ -555,9 +569,8 @@
<textElement textAlignment="Right" verticalAlignment="Middle" markup="html">
<font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement>
<text><![CDATA[二月]]></text>
</staticText>
<staticText>
<textFieldExpression><![CDATA[$P{hFeb}]]></textFieldExpression></textField>
<textField>
<reportElement x="380" y="0" width="45" height="20" uuid="5204319c-5b4e-421b-9493-4dd988caca52">
<property name="com.jaspersoft.studio.unit.y" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/>
@@ -565,9 +578,8 @@
<textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement>
<text><![CDATA[五月]]></text>
</staticText>
<staticText>
<textFieldExpression><![CDATA[$P{hMay}]]></textFieldExpression></textField>
<textField>
<reportElement x="605" y="0" width="45" height="20" uuid="a6161aa7-2abd-4bb6-894d-ccdd6f9575c4">
<property name="com.jaspersoft.studio.unit.y" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/>
@@ -575,9 +587,8 @@
<textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement>
<text><![CDATA[十月]]></text>
</staticText>
<staticText>
<textFieldExpression><![CDATA[$P{hOct}]]></textFieldExpression></textField>
<textField>
<reportElement x="425" y="0" width="45" height="20" uuid="424446ec-655d-4bff-a30a-61e898dbc7ad">
<property name="com.jaspersoft.studio.unit.y" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/>
@@ -585,9 +596,8 @@
<textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement>
<text><![CDATA[六月]]></text>
</staticText>
<staticText>
<textFieldExpression><![CDATA[$P{hJun}]]></textFieldExpression></textField>
<textField>
<reportElement x="515" y="0" width="45" height="20" uuid="2a9e0115-d056-4c4b-b0b2-0800f55435ad">
<property name="com.jaspersoft.studio.unit.y" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/>
@@ -595,9 +605,8 @@
<textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true" isItalic="false"/>
</textElement>
<text><![CDATA[八月]]></text>
</staticText>
<staticText>
<textFieldExpression><![CDATA[$P{hAug}]]></textFieldExpression></textField>
<textField>
<reportElement x="470" y="0" width="45" height="20" uuid="b221e73b-0f35-4036-bffb-9230a495b827">
<property name="com.jaspersoft.studio.unit.y" value="px"/>
<property name="com.jaspersoft.studio.unit.height" value="px"/>
@@ -605,9 +614,8 @@
<textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement>
<text><![CDATA[七月]]></text>
</staticText>
<staticText>
<textFieldExpression><![CDATA[$P{hJul}]]></textFieldExpression></textField>
<textField>
<reportElement x="335" y="0" width="45" height="20" uuid="8c5260bc-9079-4f62-803d-1c3e3fc9f41c">
<property name="com.jaspersoft.studio.unit.y" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/>
@@ -615,14 +623,13 @@
<textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement>
<text><![CDATA[四月]]></text>
</staticText>
<textFieldExpression><![CDATA[$P{hApr}]]></textFieldExpression></textField>
<line>
<reportElement x="1" y="23" width="799" height="1" uuid="ad5d85ce-e7fe-46c0-9b37-76b2ed3f4152">
<property name="com.jaspersoft.studio.unit.height" value="px"/>
</reportElement>
</line>
<staticText>
<textField>
<reportElement x="140" y="0" width="60" height="20" uuid="08d93aec-e4ae-46a6-afcb-3af9de518ca6">
<property name="com.jaspersoft.studio.unit.y" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/>
@@ -630,9 +637,8 @@
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement>
<text><![CDATA[單位]]></text>
</staticText>
<staticText>
<textFieldExpression><![CDATA[$P{hUom}]]></textFieldExpression></textField>
<textField>
<reportElement x="10" y="0" width="48" height="20" uuid="b4a1a243-36d7-46f7-90ea-b38dd5641a9c">
<property name="com.jaspersoft.studio.unit.y" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/>
@@ -640,9 +646,8 @@
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement>
<text><![CDATA[貨品編號]]></text>
</staticText>
<staticText>
<textFieldExpression><![CDATA[$P{hItemNo}]]></textFieldExpression></textField>
<textField>
<reportElement x="58" y="0" width="82" height="20" uuid="0b6e2d73-ed45-46c8-b330-07d81fe7f2da">
<property name="com.jaspersoft.studio.unit.y" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/>
@@ -650,8 +655,7 @@
<textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement>
<text><![CDATA[貨品名稱]]></text>
</staticText>
<textFieldExpression><![CDATA[$P{hItemName}]]></textFieldExpression></textField>
</band>
</columnHeader>
</jasperReport>

+ 149
- 0
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<YearMonth>(), window.periods)
}

@Test
fun `custom range longer than a month uses months and keeps partial months separate`() {
val window = SemiFgProductionAnalysisLayout.resolveWindow(
SemiFgReportFilter(view = "range", lastOutDateStart = "2026-03-15", lastOutDateEnd = "2026-06-02"),
today,
)
assertEquals(emptyList<LocalDate>(), window.dayColumns)
assertEquals(listOf(YearMonth.of(2026, 3), YearMonth.of(2026, 4), YearMonth.of(2026, 5), YearMonth.of(2026, 6)), window.periods)
val data = SemiFgReportData(
window = window,
lines = listOf(
line(LocalDate.of(2026, 3, 1), "FG1", "50"),
line(LocalDate.of(2026, 3, 20), "FG1", "7"),
line(LocalDate.of(2026, 6, 2), "FG1", "3"),
line(LocalDate.of(2026, 6, 9), "FG1", "11"),
),
)
val rows = SemiFgProductionAnalysisLayout.listRows(data)
assertEquals(listOf("2026-03", "2026-06"), rows.map { it.period })
assertEquals(BigDecimal("7"), rows[0].qty)
assertEquals(BigDecimal("3"), rows[1].qty)
}

@Test
fun `year pivot does not add January of another year`() {
val data = SemiFgReportData(
window = SemiFgProductionAnalysisLayout.resolveWindow(
SemiFgReportFilter(view = "year", year = "2026"),
today,
),
lines = listOf(
line(LocalDate.of(2026, 1, 5), "FG1", "10"),
line(LocalDate.of(2025, 1, 5), "FG1", "99"),
line(LocalDate.of(2026, 3, 2), "FG1", "4"),
),
)
val row = SemiFgProductionAnalysisLayout.yearPivotRows(data).single()
assertEquals(BigDecimal("10"), row["qtyJan"])
assertEquals(BigDecimal("4"), row["qtyMar"])
assertEquals(BigDecimal.ZERO, row["qtyFeb"])
}

@Test
fun `anchor period is a day for a month view and a year-month for a year view`() {
val date = LocalDate.of(2026, 3, 15)
assertEquals("FG1|2026-03-15", SemiFgProductionAnalysisLayout.anchorKey(SemiFgView.MONTH, "FG1", date))
assertEquals("FG1|2026-03", SemiFgProductionAnalysisLayout.anchorKey(SemiFgView.YEAR, "FG1", date))
}

private fun line(date: LocalDate, item: String, qty: String) = SemiFgLine(
prodDate = date,
itemNo = item,
itemName = item,
unitOfMeasure = "箱",
jobOrderCode = "JO1",
productLotNo = "LOT",
qty = BigDecimal(qty),
qcFailed = false,
)
}

+ 197
- 0
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<String, Any>()
val sql = buildSemiFgItemCodeClause("PP1080, P001, %PP108%", "it.code", "semiItem", args)
assertTrue(sql.contains("UPPER(it.code) = :semiItem_0"))
assertTrue(sql.contains("UPPER(it.code) = :semiItem_1"))
assertTrue(sql.contains("it.code LIKE :semiItem_2"))
assertEquals("PP1080", args["semiItem_0"])
assertEquals("P001", args["semiItem_1"])
assertEquals("%PP108%", args["semiItem_2"])
}

private fun headers(data: SemiFgReportData): List<String> =
XSSFWorkbook(ByteArrayInputStream(SemiFgProductionAnalysisWorkbook().build(data))).use { book ->
val summary = book.getSheet("彙總")
(0..summary.lastRowNum).firstNotNullOf { index ->
val row = summary.getRow(index) ?: return@firstNotNullOf null
val values = (0 until row.lastCellNum).mapNotNull { row.getCell(it)?.stringCellValue }
values.takeIf { "貨品編號" in it }
}
}

private fun line(date: LocalDate, item: String, qty: String, failed: Boolean) = SemiFgLine(
prodDate = date,
itemNo = item,
itemName = item,
unitOfMeasure = "箱",
jobOrderCode = "JO",
productLotNo = "LOT",
qty = BigDecimal(qty),
qcFailed = failed,
)
}

+ 74
- 0
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 })
}
}
}

Загрузка…
Отмена
Сохранить