Преглед изворни кода

[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 дан
родитељ
комит
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 org.springframework.stereotype.Service
import com.ffii.core.support.JdbcDao import com.ffii.core.support.JdbcDao
import java.math.BigDecimal
import java.time.LocalDate
import java.time.LocalDateTime


@Service @Service
class SemiFGProductionAnalysisReportService( class SemiFGProductionAnalysisReportService(
@@ -31,6 +34,12 @@ class SemiFGProductionAnalysisReportService(
return "AND (${conditions.joinToString(" OR ")})" 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. * 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 =. * 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 = """ 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 SELECT
COALESCE(it.code, '') AS itemNo, COALESCE(it.code, '') AS itemNo,
COALESCE(it.name, '') AS itemName, COALESCE(it.name, '') AS itemName,
COALESCE(ic.sub, '') AS stockSubCategory,
COALESCE(uc.udfudesc, '') AS unitOfMeasure, 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, si.id AS stockInLineId,
CASE WHEN COALESCE(qr_agg.qcFailed, 0) = 1 THEN 0 CASE WHEN COALESCE(qr_agg.qcFailed, 0) = 1 THEN 0
ELSE COALESCE(si.acceptedQty, 0) ELSE COALESCE(si.acceptedQty, 0)
END AS linePutAwayQty
END AS linePutAwayQty,
COALESCE(qr_agg.qcFailed, 0) AS qcFailed
FROM stock_in_line si FROM stock_in_line si
INNER JOIN items it ON si.itemId = it.id INNER JOIN items it ON si.itemId = it.id
INNER JOIN bom b ON b.code = it.code AND b.deleted = false 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 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_category ic ON it.categoryId = ic.id
LEFT JOIN item_uom iu ON it.id = iu.itemId AND iu.stockUnit = true LEFT JOIN item_uom iu ON it.id = iu.itemId AND iu.stockUnit = true
LEFT JOIN uom_conversion uc ON iu.uomId = uc.id LEFT JOIN uom_conversion uc ON iu.uomId = uc.id
WHERE si.deleted = false WHERE si.deleted = false
AND si.productLotNo IS NOT NULL AND si.productLotNo IS NOT NULL
AND TRIM(si.productLotNo) <> '' 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.jobOrderId IS NOT NULL
AND (si.status IS NULL OR si.status <> 'Pending') AND (si.status IS NULL OR si.status <> 'Pending')
AND DATE($prodDateExpr) >= DATE(:rangeStart)
AND DATE($prodDateExpr) <= DATE(:rangeEnd)
$stockCategorySql $stockCategorySql
$stockSubCategorySql $stockSubCategorySql
$itemCodeSql $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() """.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>> { fun getSemiFGItemCodes(stockCategory: String?): List<Map<String, String>> {
val args = mutableMapOf<String, Any>() 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 = """ val sql = """
SELECT DISTINCT b.code, COALESCE(it.name, b.name, '') AS name 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>> { fun getSemiFGItemCodesWithCategory(stockCategory: String?): List<Map<String, String>> {
val args = mutableMapOf<String, Any>() 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 = """ val sql = """
SELECT DISTINCT b.code, COALESCE(it.type, '') AS category, COALESCE(it.name, b.name, '') AS name 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 supplier: String? = null,
val poCode: String? = null, val poCode: String? = null,
val grnCode: 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? = fun combinedItemCode(): String? =
ReportMultiValueTokens.combine(itemCode, itemCodePaste, itemCodes) 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 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.LocalDate
import java.time.LocalTime import java.time.LocalTime
import java.time.format.DateTimeFormatter 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 @RestController
@RequestMapping("/report") @RequestMapping("/report")
@@ -37,6 +29,7 @@ class SemiFGProductionAnalysisReportController(
private val semiFGProductionAnalysisReportService: SemiFGProductionAnalysisReportService, private val semiFGProductionAnalysisReportService: SemiFGProductionAnalysisReportService,
private val reportService: ReportService, private val reportService: ReportService,
) { ) {
private val workbook = SemiFgProductionAnalysisWorkbook()


@GetMapping("/print-semi-fg-production-analysis") @GetMapping("/print-semi-fg-production-analysis")
fun generateSemiFGProductionAnalysisReport( fun generateSemiFGProductionAnalysisReport(
@@ -45,73 +38,23 @@ class SemiFGProductionAnalysisReportController(
@RequestParam(required = false) itemCode: String?, @RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) year: String?, @RequestParam(required = false) year: String?,
@RequestParam(required = false) lastOutDateStart: 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") @PostMapping("/print-semi-fg-production-analysis")
fun generateSemiFGProductionAnalysisReportPost( fun generateSemiFGProductionAnalysisReportPost(
@RequestBody(required = false) req: ReportItemFilterRequest?, @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") @GetMapping("/print-semi-fg-production-analysis-excel")
fun exportSemiFGProductionAnalysisReportExcel( fun exportSemiFGProductionAnalysisReportExcel(
@@ -120,401 +63,156 @@ class SemiFGProductionAnalysisReportController(
@RequestParam(required = false) itemCode: String?, @RequestParam(required = false) itemCode: String?,
@RequestParam(required = false) year: String?, @RequestParam(required = false) year: String?,
@RequestParam(required = false) lastOutDateStart: 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") @PostMapping("/print-semi-fg-production-analysis-excel")
fun exportSemiFGProductionAnalysisReportExcelPost( fun exportSemiFGProductionAnalysisReportExcelPost(
@RequestBody(required = false) req: ReportItemFilterRequest?, @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"> <parameter name="lastOutDateEnd" class="java.lang.String">
<parameterDescription><![CDATA["lastOutDateStart"]]></parameterDescription> <parameterDescription><![CDATA["lastOutDateStart"]]></parameterDescription>
</parameter> </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> <queryString>
<![CDATA[select * from fpsmsdb.items , fpsmsdb.stock_out_line]]> <![CDATA[select * from fpsmsdb.items , fpsmsdb.stock_out_line]]>
</queryString> </queryString>
@@ -366,7 +392,7 @@
<textElement textAlignment="Center" verticalAlignment="Middle"/> <textElement textAlignment="Center" verticalAlignment="Middle"/>
<textFieldExpression><![CDATA[$V{PAGE_NUMBER}]]></textFieldExpression> <textFieldExpression><![CDATA[$V{PAGE_NUMBER}]]></textFieldExpression>
</textField> </textField>
<staticText>
<textField>
<reportElement x="677" y="0" width="40" height="23" uuid="5a9e0fa9-418d-4838-9f82-e2f336e5bce7"> <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.y" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/> <property name="com.jaspersoft.studio.unit.width" value="px"/>
@@ -374,8 +400,7 @@
<textElement textAlignment="Center" verticalAlignment="Middle"> <textElement textAlignment="Center" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="12"/> <font fontName="微軟正黑體" size="12"/>
</textElement> </textElement>
<text><![CDATA[頁數]]></text>
</staticText>
<textFieldExpression><![CDATA[$P{hPage}]]></textFieldExpression></textField>
<staticText> <staticText>
<reportElement x="747" y="0" width="21" height="23" uuid="26d3d09c-48aa-4870-822a-c403e7faddfa"> <reportElement x="747" y="0" width="21" height="23" uuid="26d3d09c-48aa-4870-822a-c403e7faddfa">
<property name="com.jaspersoft.studio.unit.y" value="px"/> <property name="com.jaspersoft.studio.unit.y" value="px"/>
@@ -393,7 +418,7 @@
<textElement textAlignment="Center" verticalAlignment="Middle"/> <textElement textAlignment="Center" verticalAlignment="Middle"/>
<textFieldExpression><![CDATA[$V{PAGE_NUMBER}]]></textFieldExpression> <textFieldExpression><![CDATA[$V{PAGE_NUMBER}]]></textFieldExpression>
</textField> </textField>
<staticText>
<textField>
<reportElement x="10" y="30" width="90" height="23" uuid="0daddd8b-ca61-42af-9c2b-cbf11b7d7dac"> <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.y" value="px"/>
<property name="com.jaspersoft.studio.unit.height" value="px"/> <property name="com.jaspersoft.studio.unit.height" value="px"/>
@@ -401,8 +426,7 @@
<textElement textAlignment="Left" verticalAlignment="Middle"> <textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="12" isBold="true"/> <font fontName="微軟正黑體" size="12" isBold="true"/>
</textElement> </textElement>
<text><![CDATA[報告日期:]]></text>
</staticText>
<textFieldExpression><![CDATA[$P{hReportDate}]]></textFieldExpression></textField>
<textField> <textField>
<reportElement x="708" y="52" width="90" height="23" uuid="53b66bc3-4925-4340-add0-b4f2069a76c1"/> <reportElement x="708" y="52" width="90" height="23" uuid="53b66bc3-4925-4340-add0-b4f2069a76c1"/>
<textElement textAlignment="Left" verticalAlignment="Middle"> <textElement textAlignment="Left" verticalAlignment="Middle">
@@ -410,7 +434,7 @@
</textElement> </textElement>
<textFieldExpression><![CDATA[$P{year}]]></textFieldExpression> <textFieldExpression><![CDATA[$P{year}]]></textFieldExpression>
</textField> </textField>
<staticText>
<textField>
<reportElement x="640" y="29" width="68" height="23" uuid="e2be43a3-3570-4a4e-a671-d96f872a5ad7"> <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.y" value="px"/>
<property name="com.jaspersoft.studio.unit.height" value="px"/> <property name="com.jaspersoft.studio.unit.height" value="px"/>
@@ -418,9 +442,8 @@
<textElement textAlignment="Left" verticalAlignment="Middle"> <textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="12" isBold="true"/> <font fontName="微軟正黑體" size="12" isBold="true"/>
</textElement> </textElement>
<text><![CDATA[報告時間:]]></text>
</staticText>
<staticText>
<textFieldExpression><![CDATA[$P{hReportTime}]]></textFieldExpression></textField>
<textField>
<reportElement x="640" y="52" width="68" height="23" uuid="29ed871d-3417-4978-9799-964389143451"> <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.y" value="px"/>
<property name="com.jaspersoft.studio.unit.height" value="px"/> <property name="com.jaspersoft.studio.unit.height" value="px"/>
@@ -428,8 +451,7 @@
<textElement textAlignment="Left" verticalAlignment="Middle"> <textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="12" isBold="true"/> <font fontName="微軟正黑體" size="12" isBold="true"/>
</textElement> </textElement>
<text><![CDATA[年份:]]></text>
</staticText>
<textFieldExpression><![CDATA[$P{hYear}]]></textFieldExpression></textField>
<textField> <textField>
<reportElement x="708" y="29" width="90" height="23" uuid="cb1fbb06-e953-40e5-bdbe-5fc219f7c884"/> <reportElement x="708" y="29" width="90" height="23" uuid="cb1fbb06-e953-40e5-bdbe-5fc219f7c884"/>
<textElement textAlignment="Left" verticalAlignment="Middle"> <textElement textAlignment="Left" verticalAlignment="Middle">
@@ -444,7 +466,7 @@
</textElement> </textElement>
<textFieldExpression><![CDATA[$P{reportDate}]]></textFieldExpression> <textFieldExpression><![CDATA[$P{reportDate}]]></textFieldExpression>
</textField> </textField>
<staticText>
<textField>
<reportElement x="300" y="0" width="210" height="23" uuid="7451001b-6d5a-438c-82db-2b9e687d9a27"> <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.y" value="px"/>
<property name="com.jaspersoft.studio.unit.height" value="px"/> <property name="com.jaspersoft.studio.unit.height" value="px"/>
@@ -452,8 +474,7 @@
<textElement textAlignment="Center" verticalAlignment="Middle"> <textElement textAlignment="Center" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="16" isBold="true"/> <font fontName="微軟正黑體" size="16" isBold="true"/>
</textElement> </textElement>
<text><![CDATA[成品/半成品生產分析報告]]></text>
</staticText>
<textFieldExpression><![CDATA[$P{hTitle}]]></textFieldExpression></textField>
<textField> <textField>
<reportElement x="100" y="53" width="336" height="23" uuid="b51949e1-c40a-4db0-a799-4243555893fd"> <reportElement x="100" y="53" width="336" height="23" uuid="b51949e1-c40a-4db0-a799-4243555893fd">
<property name="com.jaspersoft.studio.unit.height" value="px"/> <property name="com.jaspersoft.studio.unit.height" value="px"/>
@@ -461,9 +482,9 @@
<textElement textAlignment="Left" verticalAlignment="Middle"> <textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="12" isBold="true"/> <font fontName="微軟正黑體" size="12" isBold="true"/>
</textElement> </textElement>
<textFieldExpression><![CDATA[$P{lastOutDateStart} + " 至 " + $P{lastOutDateEnd}]]></textFieldExpression>
<textFieldExpression><![CDATA[$P{lastOutDateStart} + " " + $P{hTo} + " " + $P{lastOutDateEnd}]]></textFieldExpression>
</textField> </textField>
<staticText>
<textField>
<reportElement x="10" y="53" width="90" height="23" uuid="fb09a559-f5fa-4e56-a891-87c600a2745a"> <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.y" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/> <property name="com.jaspersoft.studio.unit.width" value="px"/>
@@ -472,8 +493,7 @@
<textElement textAlignment="Left" verticalAlignment="Middle"> <textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="12" isBold="true"/> <font fontName="微軟正黑體" size="12" isBold="true"/>
</textElement> </textElement>
<text><![CDATA[完成生產日期:]]></text>
</staticText>
<textFieldExpression><![CDATA[$P{hProdDate}]]></textFieldExpression></textField>
<line> <line>
<reportElement x="0" y="75" width="799" height="1" uuid="f04b0470-72e7-42b6-80fd-f1dc4ea5fa66"> <reportElement x="0" y="75" width="799" height="1" uuid="f04b0470-72e7-42b6-80fd-f1dc4ea5fa66">
<property name="com.jaspersoft.studio.unit.height" value="px"/> <property name="com.jaspersoft.studio.unit.height" value="px"/>
@@ -488,16 +508,15 @@
</pageHeader> </pageHeader>
<columnHeader> <columnHeader>
<band height="28"> <band height="28">
<staticText>
<textField>
<reportElement x="200" y="0" width="45" height="20" uuid="e4267f38-4a00-4a22-965d-9ab531262592"> <reportElement x="200" y="0" width="45" height="20" uuid="e4267f38-4a00-4a22-965d-9ab531262592">
<property name="com.jaspersoft.studio.unit.y" value="px"/> <property name="com.jaspersoft.studio.unit.y" value="px"/>
</reportElement> </reportElement>
<textElement textAlignment="Right" verticalAlignment="Middle"> <textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/> <font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement> </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"> <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.y" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/> <property name="com.jaspersoft.studio.unit.width" value="px"/>
@@ -505,9 +524,8 @@
<textElement textAlignment="Right" verticalAlignment="Middle"> <textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/> <font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement> </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"> <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.y" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/> <property name="com.jaspersoft.studio.unit.width" value="px"/>
@@ -515,9 +533,8 @@
<textElement textAlignment="Right" verticalAlignment="Middle"> <textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/> <font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement> </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"> <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.y" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/> <property name="com.jaspersoft.studio.unit.width" value="px"/>
@@ -525,9 +542,8 @@
<textElement textAlignment="Right" verticalAlignment="Middle"> <textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/> <font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement> </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"> <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.y" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/> <property name="com.jaspersoft.studio.unit.width" value="px"/>
@@ -535,9 +551,8 @@
<textElement textAlignment="Right" verticalAlignment="Middle"> <textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/> <font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement> </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"> <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.y" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/> <property name="com.jaspersoft.studio.unit.width" value="px"/>
@@ -545,9 +560,8 @@
<textElement textAlignment="Right" verticalAlignment="Middle"> <textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/> <font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement> </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"> <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.y" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/> <property name="com.jaspersoft.studio.unit.width" value="px"/>
@@ -555,9 +569,8 @@
<textElement textAlignment="Right" verticalAlignment="Middle" markup="html"> <textElement textAlignment="Right" verticalAlignment="Middle" markup="html">
<font fontName="微軟正黑體" size="10" isBold="true"/> <font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement> </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"> <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.y" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/> <property name="com.jaspersoft.studio.unit.width" value="px"/>
@@ -565,9 +578,8 @@
<textElement textAlignment="Right" verticalAlignment="Middle"> <textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/> <font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement> </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"> <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.y" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/> <property name="com.jaspersoft.studio.unit.width" value="px"/>
@@ -575,9 +587,8 @@
<textElement textAlignment="Right" verticalAlignment="Middle"> <textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/> <font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement> </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"> <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.y" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/> <property name="com.jaspersoft.studio.unit.width" value="px"/>
@@ -585,9 +596,8 @@
<textElement textAlignment="Right" verticalAlignment="Middle"> <textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/> <font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement> </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"> <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.y" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/> <property name="com.jaspersoft.studio.unit.width" value="px"/>
@@ -595,9 +605,8 @@
<textElement textAlignment="Right" verticalAlignment="Middle"> <textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true" isItalic="false"/> <font fontName="微軟正黑體" size="10" isBold="true" isItalic="false"/>
</textElement> </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"> <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.y" value="px"/>
<property name="com.jaspersoft.studio.unit.height" value="px"/> <property name="com.jaspersoft.studio.unit.height" value="px"/>
@@ -605,9 +614,8 @@
<textElement textAlignment="Right" verticalAlignment="Middle"> <textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/> <font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement> </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"> <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.y" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/> <property name="com.jaspersoft.studio.unit.width" value="px"/>
@@ -615,14 +623,13 @@
<textElement textAlignment="Right" verticalAlignment="Middle"> <textElement textAlignment="Right" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/> <font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement> </textElement>
<text><![CDATA[四月]]></text>
</staticText>
<textFieldExpression><![CDATA[$P{hApr}]]></textFieldExpression></textField>
<line> <line>
<reportElement x="1" y="23" width="799" height="1" uuid="ad5d85ce-e7fe-46c0-9b37-76b2ed3f4152"> <reportElement x="1" y="23" width="799" height="1" uuid="ad5d85ce-e7fe-46c0-9b37-76b2ed3f4152">
<property name="com.jaspersoft.studio.unit.height" value="px"/> <property name="com.jaspersoft.studio.unit.height" value="px"/>
</reportElement> </reportElement>
</line> </line>
<staticText>
<textField>
<reportElement x="140" y="0" width="60" height="20" uuid="08d93aec-e4ae-46a6-afcb-3af9de518ca6"> <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.y" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/> <property name="com.jaspersoft.studio.unit.width" value="px"/>
@@ -630,9 +637,8 @@
<textElement textAlignment="Left" verticalAlignment="Middle"> <textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/> <font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement> </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"> <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.y" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/> <property name="com.jaspersoft.studio.unit.width" value="px"/>
@@ -640,9 +646,8 @@
<textElement textAlignment="Left" verticalAlignment="Middle"> <textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/> <font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement> </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"> <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.y" value="px"/>
<property name="com.jaspersoft.studio.unit.width" value="px"/> <property name="com.jaspersoft.studio.unit.width" value="px"/>
@@ -650,8 +655,7 @@
<textElement textAlignment="Left" verticalAlignment="Middle"> <textElement textAlignment="Left" verticalAlignment="Middle">
<font fontName="微軟正黑體" size="10" isBold="true"/> <font fontName="微軟正黑體" size="10" isBold="true"/>
</textElement> </textElement>
<text><![CDATA[貨品名稱]]></text>
</staticText>
<textFieldExpression><![CDATA[$P{hItemName}]]></textFieldExpression></textField>
</band> </band>
</columnHeader> </columnHeader>
</jasperReport> </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 })
}
}
}

Loading…
Откажи
Сачувај