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

[Feat] Restyle excel doc

fg_report_rework
Harry Groves 7 часов назад
Родитель
Сommit
768efee770
5 измененных файлов: 231 добавлений и 76 удалений
  1. +29
    -5
      src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt
  2. +52
    -29
      src/main/java/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisLayout.kt
  3. +63
    -37
      src/main/java/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisWorkbook.kt
  4. +2
    -2
      src/test/kotlin/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisLayoutTest.kt
  5. +85
    -3
      src/test/kotlin/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisWorkbookTest.kt

+ 29
- 5
src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt Просмотреть файл

@@ -102,12 +102,13 @@ class SemiFGProductionAnalysisReportService(
SELECT
COALESCE(it.code, '') AS itemNo,
COALESCE(it.name, '') AS itemName,
COALESCE(uc.udfudesc, '') AS unitOfMeasure,
COALESCE(NULLIF(lot_uom.unitOfMeasure, ''), item_stock.unitOfMeasure, '') AS unitOfMeasure,
DATE($prodDateExpr) AS prodDate,
COALESCE(jo.code, '') AS jobOrderCode,
COALESCE(si.productLotNo, '') AS productLotNo,
si.id AS stockInLineId,
CASE WHEN COALESCE(qr_agg.qcFailed, 0) = 1 THEN 0
WHEN COALESCE(lot_uom.uomCount, 0) > 1 THEN COALESCE(lot_uom.inQty, 0)
ELSE COALESCE(si.acceptedQty, 0)
END AS linePutAwayQty,
COALESCE(qr_agg.qcFailed, 0) AS qcFailed
@@ -125,8 +126,31 @@ class SemiFGProductionAnalysisReportService(
) qr_agg ON qr_agg.stockInLineId = si.id
LEFT JOIN job_order jo ON jo.id = si.jobOrderId AND jo.deleted = false
LEFT JOIN item_category ic ON it.categoryId = ic.id
LEFT JOIN item_uom iu ON it.id = iu.itemId AND iu.stockUnit = true
LEFT JOIN uom_conversion uc ON iu.uomId = uc.id
LEFT JOIN (
SELECT
grouped.inventoryLotId AS inventoryLotId,
grouped.unitOfMeasure AS unitOfMeasure,
grouped.inQty AS inQty,
COUNT(*) OVER (PARTITION BY grouped.inventoryLotId) AS uomCount
FROM (
SELECT
ill.inventoryLotId AS inventoryLotId,
COALESCE(uc.udfudesc, '') AS unitOfMeasure,
SUM(COALESCE(ill.inQty, 0)) AS inQty
FROM inventory_lot_line ill
LEFT JOIN item_uom iu ON iu.id = ill.stockItemUomId AND iu.deleted = 0
LEFT JOIN uom_conversion uc ON uc.id = iu.uomId
WHERE ill.deleted = 0
GROUP BY ill.inventoryLotId, COALESCE(uc.udfudesc, '')
) grouped
) lot_uom ON lot_uom.inventoryLotId = il.id
LEFT JOIN (
SELECT iu.itemId AS itemId, MAX(uc.udfudesc) AS unitOfMeasure
FROM item_uom iu
INNER JOIN uom_conversion uc ON uc.id = iu.uomId
WHERE iu.stockUnit = 1 AND iu.deleted = 0
GROUP BY iu.itemId
) item_stock ON item_stock.itemId = it.id
WHERE si.deleted = false
AND si.productLotNo IS NOT NULL
AND TRIM(si.productLotNo) <> ''
@@ -139,8 +163,8 @@ class SemiFGProductionAnalysisReportService(
$stockSubCategorySql
$itemCodeSql
) base
GROUP BY base.stockInLineId
ORDER BY itemNo, prodDate, jobOrderCode, productLotNo
GROUP BY base.stockInLineId, base.unitOfMeasure
ORDER BY itemNo, unitOfMeasure, prodDate, jobOrderCode, productLotNo
""".trimIndent()

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


+ 52
- 29
src/main/java/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisLayout.kt Просмотреть файл

@@ -187,41 +187,50 @@ object SemiFgProductionAnalysisLayout {
private fun datesBetween(start: LocalDate, end: LocalDate): List<LocalDate> =
generateSequence(start) { prev -> prev.plusDays(1).takeIf { !it.isAfter(end) } }.toList()

/** Same item code with a different stock unit is a separate group. */
fun itemKey(itemNo: String, unitOfMeasure: String): String =
"${itemNo.trim()}\u001F${unitOfMeasure.trim()}"

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]
val key = itemKey(line.itemNo, line.unitOfMeasure)
val existing = grouped[key]
if (existing == null) {
grouped[line.itemNo] = SemiFgItem(line.itemNo, line.itemName, line.unitOfMeasure)
grouped[key] = SemiFgItem(line.itemNo, line.itemName, line.unitOfMeasure.trim())
} else if (existing.itemName.isBlank() && line.itemName.isNotBlank()) {
grouped[line.itemNo] = existing.copy(itemName = line.itemName, unitOfMeasure = line.unitOfMeasure.ifBlank { existing.unitOfMeasure })
grouped[key] = existing.copy(itemName = line.itemName)
}
totals[line.itemNo] = (totals[line.itemNo] ?: BigDecimal.ZERO) + line.qty
totals[key] = (totals[key] ?: BigDecimal.ZERO) + line.qty
}
val items = grouped.values.toList()
return if (qtyDesc) {
items.sortedWith(compareByDescending<SemiFgItem> { totals[it.itemNo] }.thenBy { it.itemNo })
items.sortedWith(
compareByDescending<SemiFgItem> { totals[itemKey(it.itemNo, it.unitOfMeasure)] }
.thenBy { it.itemNo }
.thenBy { it.unitOfMeasure },
)
} else {
items.sortedBy { it.itemNo }
items.sortedWith(compareBy({ it.itemNo }, { it.unitOfMeasure }))
}
}

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() }
val byDate = out.getOrPut(itemKey(line.itemNo, line.unitOfMeasure)) { 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(view: SemiFgView, itemNo: String, unitOfMeasure: String, date: LocalDate): String =
"$itemNo|${unitOfMeasure.trim()}|${filterPeriod(view, date)}"

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

fun filterPeriod(view: SemiFgView, date: LocalDate): String = when (view) {
SemiFgView.DAY, SemiFgView.WEEK, SemiFgView.MONTH -> date.format(isoDate)
@@ -252,15 +261,19 @@ object SemiFgProductionAnalysisLayout {
fun roundQty(value: BigDecimal): Long =
value.setScale(0, RoundingMode.HALF_UP).longValueExact()

fun jobOrderCount(lines: List<SemiFgLine>, itemNo: String): Int {
fun jobOrderCount(lines: List<SemiFgLine>, itemNo: String, unitOfMeasure: String): Int {
val uom = unitOfMeasure.trim()
val matching = { line: SemiFgLine ->
line.itemNo == itemNo && line.unitOfMeasure.trim() == uom && line.qty > BigDecimal.ZERO
}
val codes = lines.asSequence()
.filter { it.itemNo == itemNo && it.qty > BigDecimal.ZERO }
.filter(matching)
.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 }
return lines.count(matching)
}

fun qtyOnDay(daily: Map<String, Map<LocalDate, BigDecimal>>, itemNo: String, day: LocalDate): BigDecimal =
@@ -277,7 +290,7 @@ object SemiFgProductionAnalysisLayout {
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))
qtyInMonth(daily, itemKey(item.itemNo, item.unitOfMeasure), YearMonth.of(year, month))
}
val total = months.fold(BigDecimal.ZERO, BigDecimal::add)
mapOf(
@@ -312,7 +325,7 @@ object SemiFgProductionAnalysisLayout {
itemNo = item.itemNo,
itemName = item.itemName,
unitOfMeasure = item.unitOfMeasure,
qty = qtyOnDay(daily, item.itemNo, day),
qty = qtyOnDay(daily, itemKey(item.itemNo, item.unitOfMeasure), day),
)
}
}
@@ -323,7 +336,7 @@ object SemiFgProductionAnalysisLayout {
val end = month.atEndOfMonth()
while (!cursor.isAfter(end)) {
for (item in summaryItems(data.lines)) {
val qty = qtyOnDay(daily, item.itemNo, cursor)
val qty = qtyOnDay(daily, itemKey(item.itemNo, item.unitOfMeasure), cursor)
if (qty > BigDecimal.ZERO) {
rows += SemiFgListRow(cursor.toString(), item.itemNo, item.itemName, item.unitOfMeasure, qty)
}
@@ -347,7 +360,7 @@ object SemiFgProductionAnalysisLayout {
if (data.window.dayColumns.isNotEmpty()) {
for (date in data.window.dayColumns) {
for (item in items) {
val qty = qtyOnDay(daily, item.itemNo, date)
val qty = qtyOnDay(daily, itemKey(item.itemNo, item.unitOfMeasure), date)
if (qty > BigDecimal.ZERO) {
rows += SemiFgListRow(date.toString(), item.itemNo, item.itemName, item.unitOfMeasure, qty)
}
@@ -358,7 +371,7 @@ object SemiFgProductionAnalysisLayout {
}
for (period in data.window.periods) {
for (item in items) {
val qty = qtyInMonthWithin(daily, item.itemNo, period, data.window.start, data.window.endInclusive)
val qty = qtyInMonthWithin(daily, itemKey(item.itemNo, item.unitOfMeasure), period, data.window.start, data.window.endInclusive)
if (qty > BigDecimal.ZERO) {
rows += SemiFgListRow(period.toString(), item.itemNo, item.itemName, item.unitOfMeasure, qty)
}
@@ -369,16 +382,16 @@ object SemiFgProductionAnalysisLayout {
}

private fun appendItemsWithNoQty(rows: MutableList<SemiFgListRow>, items: List<SemiFgItem>, period: String) {
val seen = rows.map { it.itemNo }.toSet()
val seen = rows.map { itemKey(it.itemNo, it.unitOfMeasure) }.toSet()
for (item in items) {
if (item.itemNo in seen) continue
if (itemKey(item.itemNo, item.unitOfMeasure) 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.DAY -> items.map { chartItemLabel(it, items) }
SemiFgView.MONTH -> {
val month = data.window.month ?: return emptyList()
(1..month.lengthOfMonth()).map { it.toString() }
@@ -406,15 +419,25 @@ object SemiFgProductionAnalysisLayout {
}
}

fun chartItemLabel(item: SemiFgItem, items: List<SemiFgItem>): String {
val uom = item.unitOfMeasure.trim()
val sharedCode = items.count { it.itemNo == item.itemNo } > 1
return if (sharedCode && uom.isNotEmpty()) "${item.itemNo} ($uom)" else item.itemNo
}

fun chartQty(
data: SemiFgReportData,
daily: Map<String, Map<LocalDate, BigDecimal>>,
itemNo: String,
unitOfMeasure: String,
category: String,
): BigDecimal {
val key = itemKey(itemNo, unitOfMeasure)
val uom = unitOfMeasure.trim()
val withUom = if (uom.isEmpty()) itemNo else "$itemNo ($uom)"
return when (data.view) {
SemiFgView.DAY -> if (itemNo == category) {
daily[itemNo].orEmpty().values.fold(BigDecimal.ZERO, BigDecimal::add)
SemiFgView.DAY -> if (category == itemNo || category == withUom) {
daily[key].orEmpty().values.fold(BigDecimal.ZERO, BigDecimal::add)
} else {
BigDecimal.ZERO
}
@@ -422,24 +445,24 @@ object SemiFgProductionAnalysisLayout {
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))
else qtyOnDay(daily, key, 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))
if (index < 0) BigDecimal.ZERO else qtyInMonth(daily, key, YearMonth.of(year, index + 1))
}
SemiFgView.WEEK -> {
val day = runCatching { LocalDate.parse(category) }.getOrNull() ?: return BigDecimal.ZERO
qtyOnDay(daily, itemNo, day)
qtyOnDay(daily, key, day)
}
SemiFgView.RANGE -> {
if (data.window.dayColumns.isNotEmpty()) {
val day = runCatching { LocalDate.parse(category) }.getOrNull() ?: return BigDecimal.ZERO
qtyOnDay(daily, itemNo, day)
qtyOnDay(daily, key, day)
} else {
val period = runCatching { YearMonth.parse(category) }.getOrNull() ?: return BigDecimal.ZERO
qtyInMonthWithin(daily, itemNo, period, data.window.start, data.window.endInclusive)
qtyInMonthWithin(daily, key, period, data.window.start, data.window.endInclusive)
}
}
}


+ 63
- 37
src/main/java/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisWorkbook.kt Просмотреть файл

@@ -14,6 +14,7 @@ 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.AxisCrossBetween
import org.apache.poi.xddf.usermodel.chart.AxisPosition
import org.apache.poi.xddf.usermodel.chart.BarDirection
import org.apache.poi.xddf.usermodel.chart.ChartTypes
@@ -36,7 +37,7 @@ import java.time.format.DateTimeFormatter
*/
class SemiFgProductionAnalysisWorkbook {
private companion object {
const val DETAIL_LAST_COL = 7
const val DETAIL_LAST_COL = 4
}
fun build(data: SemiFgReportData, lang: String? = null): ByteArray {
val text = SemiFgReportText.of(lang)
@@ -155,14 +156,15 @@ class SemiFgProductionAnalysisWorkbook {
}
var total = BigDecimal.ZERO
for (item in items) {
val qty = SemiFgProductionAnalysisLayout.qtyOnDay(daily, item.itemNo, day)
val key = SemiFgProductionAnalysisLayout.itemKey(item.itemNo, item.unitOfMeasure)
val qty = SemiFgProductionAnalysisLayout.qtyOnDay(daily, key, day)
total += qty
val anchor = anchors[SemiFgProductionAnalysisLayout.anchorKey(SemiFgView.DAY, item.itemNo, day)]
val anchor = anchors[SemiFgProductionAnalysisLayout.anchorKey(SemiFgView.DAY, item.itemNo, item.unitOfMeasure, 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, 3, SemiFgProductionAnalysisLayout.jobOrderCount(data.lines, item.itemNo, item.unitOfMeasure).toBigDecimal(), styles)
setWholeNumber(row, 4, qty, styles)
}
writeTotalRow(sheet, rowIndex++, text.total, 4, total, styles, leadingBlanks = 3)
@@ -193,14 +195,15 @@ class SemiFgProductionAnalysisWorkbook {
var grand = BigDecimal.ZERO
for (item in items) {
val row = sheet.createRow(rowIndex++)
val firstAnchor = firstAnchor(anchors, item.itemNo)
val firstAnchor = firstAnchor(anchors, item.itemNo, item.unitOfMeasure)
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
val key = SemiFgProductionAnalysisLayout.itemKey(item.itemNo, item.unitOfMeasure)
for (day in 1..days) {
val date = month.atDay(day)
val qty = SemiFgProductionAnalysisLayout.qtyOnDay(daily, item.itemNo, date)
val qty = SemiFgProductionAnalysisLayout.qtyOnDay(daily, key, date)
rowTotal += qty
dayTotals[day - 1] += qty
setWholeNumber(row, 2 + day, qty, styles)
@@ -234,15 +237,15 @@ class SemiFgProductionAnalysisWorkbook {
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)
heading to { itemKey: String ->
SemiFgProductionAnalysisLayout.qtyOnDay(daily, itemKey, date)
}
}
} else {
data.window.periods.map { period ->
period.toString() to { itemNo: String ->
period.toString() to { itemKey: String ->
SemiFgProductionAnalysisLayout.qtyInMonthWithin(
daily, itemNo, period, data.window.start, data.window.endInclusive,
daily, itemKey, period, data.window.start, data.window.endInclusive,
)
}
}
@@ -258,13 +261,14 @@ class SemiFgProductionAnalysisWorkbook {
var grand = BigDecimal.ZERO
for (item in items) {
val row = sheet.createRow(rowIndex++)
val firstAnchor = firstAnchor(anchors, item.itemNo)
val firstAnchor = firstAnchor(anchors, item.itemNo, item.unitOfMeasure)
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
val key = SemiFgProductionAnalysisLayout.itemKey(item.itemNo, item.unitOfMeasure)
columns.forEachIndexed { index, (_, qtyOf) ->
val qty = qtyOf(item.itemNo)
val qty = qtyOf(key)
rowTotal += qty
columnTotals[index] += qty
setWholeNumber(row, 3 + index, qty, styles)
@@ -306,14 +310,15 @@ class SemiFgProductionAnalysisWorkbook {
var grand = BigDecimal.ZERO
for (item in items) {
val row = sheet.createRow(rowIndex++)
val firstAnchor = firstAnchor(anchors, item.itemNo)
val firstAnchor = firstAnchor(anchors, item.itemNo, item.unitOfMeasure)
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
val key = SemiFgProductionAnalysisLayout.itemKey(item.itemNo, item.unitOfMeasure)
for (month in 1..12) {
val period = YearMonth.of(year, month)
val qty = SemiFgProductionAnalysisLayout.qtyInMonth(daily, item.itemNo, period)
val qty = SemiFgProductionAnalysisLayout.qtyInMonth(daily, key, period)
rowTotal += qty
monthTotals[month - 1] += qty
setWholeNumber(row, 2 + month, qty, styles)
@@ -333,52 +338,63 @@ class SemiFgProductionAnalysisWorkbook {
}

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)
val headers = listOf(text.date, text.jobOrderNo, text.lotNo, text.putAwayQty, text.qc)
writeHeader(sheet, 0, headers, styles)
sheet.createFreezePane(0, 1)
val anchors = linkedMapOf<String, DetailSpan>()
val grouped = data.lines
.sortedWith(compareBy({ it.itemNo }, { it.prodDate }, { it.jobOrderCode }, { it.productLotNo }))
.groupBy { it.itemNo }
.sortedWith(compareBy({ it.itemNo }, { it.unitOfMeasure }, { it.prodDate }, { it.jobOrderCode }, { it.productLotNo }))
.groupBy { SemiFgProductionAnalysisLayout.itemKey(it.itemNo, it.unitOfMeasure) }
var rowIndex = 1
if (grouped.isEmpty()) {
writeEmptyRow(sheet, rowIndex, headers.size, styles)
return anchors
}
for ((itemNo, lines) in grouped) {
for ((_, lines) in grouped) {
val itemNo = lines.first().itemNo
val firstRow = rowIndex
val header = sheet.createRow(rowIndex)
header.heightInPoints = 22f
header.createCell(0).cellStyle = styles.sectionEdge
header.createCell(1).apply {
header.createCell(0).apply {
setCellValue(itemNo)
cellStyle = styles.sectionCode
}
header.createCell(2).apply {
header.createCell(1).apply {
setCellValue(lines.first().itemName.ifBlank { "" })
cellStyle = styles.sectionName
}
header.createCell(2).apply {
setCellValue(lines.first().unitOfMeasure.ifBlank { "-" })
cellStyle = styles.sectionName
}
for (col in 3 until headers.size) {
header.createCell(col).cellStyle = styles.sectionEdge
}
rowIndex++
var previous: List<String>? = null
for (line in lines) {
val fields = listOf(
line.prodDate.toString(),
line.jobOrderCode.ifBlank { "-" },
line.productLotNo.ifBlank { "-" },
)
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 prior = previous
fields.forEachIndexed { index, value ->
val shown = if (prior != null && prior[index] == value) "" else value
setText(row, index, shown, styles)
}
setWholeNumber(row, 3, line.qty, styles)
setText(row, 4, if (line.qcFailed) text.qcFail else text.qcPass, styles)
previous = fields
}
val span = DetailSpan(firstRow, rowIndex - 1)
anchors[itemNo] = span
anchors[SemiFgProductionAnalysisLayout.itemKey(itemNo, lines.first().unitOfMeasure)] = span
for (line in lines) {
anchors[SemiFgProductionAnalysisLayout.anchorKey(data.window, line.itemNo, line.prodDate)] = span
anchors[SemiFgProductionAnalysisLayout.anchorKey(data.window, line.itemNo, line.unitOfMeasure, line.prodDate)] = span
}
}
val widths = intArrayOf(22, 18, 28, 12, 18, 18, 12, 10)
val widths = intArrayOf(22, 28, 18, 12, 10)
widths.forEachIndexed { idx, width -> sheet.setColumnWidth(idx, width * 256) }
return anchors
}
@@ -411,16 +427,22 @@ class SemiFgProductionAnalysisWorkbook {
category
}
val row = chartData.createRow(dataRow++)
row.createCell(0).setCellValue(item.itemNo)
row.createCell(0).setCellValue(SemiFgProductionAnalysisLayout.chartItemLabel(item, items))
row.createCell(1).setCellValue(category)
row.createCell(2).setCellValue(
SemiFgProductionAnalysisLayout.roundQty(
SemiFgProductionAnalysisLayout.chartQty(data, daily, item.itemNo, qtyCategory),
SemiFgProductionAnalysisLayout.chartQty(
data,
daily,
item.itemNo,
item.unitOfMeasure,
qtyCategory,
),
).toDouble(),
)
}
}
val itemList = listOf(text.all) + items.map { it.itemNo }
val itemList = listOf(text.all) + items.map { SemiFgProductionAnalysisLayout.chartItemLabel(it, items) }
val listCol = 40
itemList.forEachIndexed { index, value ->
val row = chartData.getRow(index) ?: chartData.createRow(index)
@@ -505,6 +527,8 @@ class SemiFgProductionAnalysisWorkbook {
val series = barData.addSeries(categoriesSource, valuesSource)
series.setTitle(text.producedQty, null)
chart.plot(barData)
// midCat draws the value axis through the middle of the first and last categories, so those bars are clipped to half width.
leftAxis.crossBetween = AxisCrossBetween.BETWEEN
}

val note = chartSheet.createRow(19)
@@ -603,8 +627,10 @@ class SemiFgProductionAnalysisWorkbook {
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 firstAnchor(anchors: Map<String, DetailSpan>, itemNo: String, unitOfMeasure: String): DetailSpan? {
val prefix = "$itemNo|${unitOfMeasure.trim()}|"
return anchors.keys.filter { it.startsWith(prefix) }.minOrNull()?.let { anchors[it] }
}

private fun setText(
row: org.apache.poi.ss.usermodel.Row,


+ 2
- 2
src/test/kotlin/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisLayoutTest.kt Просмотреть файл

@@ -132,8 +132,8 @@ class SemiFgProductionAnalysisLayoutTest {
@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))
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(


+ 85
- 3
src/test/kotlin/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisWorkbookTest.kt Просмотреть файл

@@ -2,6 +2,7 @@ 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.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
@@ -41,15 +42,16 @@ class SemiFgProductionAnalysisWorkbookTest {
(0 until row.lastCellNum).mapNotNull { col -> row.getCell(col)?.hyperlink }
}
assertTrue(linked.isNotEmpty())
assertTrue(linked.any { it.address.contains("明細") && it.address.contains(":H") })
assertTrue(linked.any { it.address.contains("明細") && it.address.contains(":E") })
val detail = workbook.getSheet("明細")
assertEquals(1, detail.paneInformation.horizontalSplitPosition)
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"
row.getCell(0)?.stringCellValue == "FG1" && row.getCell(1)?.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"
row.getCell(0)?.stringCellValue == "2026-03-15" && row.getCell(4)?.stringCellValue == "合格"
}
assertTrue(heading)
assertTrue(line)
@@ -71,4 +73,84 @@ class SemiFgProductionAnalysisWorkbookTest {
assertTrue(cached.all { it.cachedFormulaResultType == CellType.NUMERIC })
}
}

@Test
fun `detail splits the same item code by stock unit and blanks repeated values`() {
val window = SemiFgProductionAnalysisLayout.resolveWindow(
SemiFgReportFilter(view = "month", reportMonth = "2026-03"),
LocalDate.of(2026, 9, 23),
)
val data = SemiFgReportData(
window = window,
lines = listOf(
line(LocalDate.of(2026, 3, 15), "FG1", "箱", "JO-1", "LOT-1", "12"),
line(LocalDate.of(2026, 3, 16), "FG1", "箱", "JO-1", "LOT-2", "4"),
line(LocalDate.of(2026, 3, 15), "FG1", "包", "JO-2", "LOT-3", "3"),
),
)
XSSFWorkbook(ByteArrayInputStream(SemiFgProductionAnalysisWorkbook().build(data))).use { workbook ->
val detail = workbook.getSheet("明細")
val rows = (0..detail.lastRowNum).map { index ->
val row = detail.getRow(index)
(0..7).map { col -> cellText(row?.getCell(col)) }
}
val headers = rows.filter { it[0] == "FG1" }
assertEquals(setOf("箱", "包"), headers.map { it[2] }.toSet())
val boxHeader = rows.indexOfFirst { it[0] == "FG1" && it[2] == "箱" }
val boxLines = listOf(rows[boxHeader + 1], rows[boxHeader + 2])
assertEquals("2026-03-15", boxLines[0][0])
assertEquals("JO-1", boxLines[0][1])
assertEquals("LOT-1", boxLines[0][2])
assertEquals("2026-03-16", boxLines[1][0])
assertEquals("", boxLines[1][1])
assertEquals("LOT-2", boxLines[1][2])
assertEquals("合格", boxLines[0][4])
assertEquals("合格", boxLines[1][4])
val summary = workbook.getSheet("彙總")
val summaryCodes = (0..summary.lastRowNum).mapNotNull { summary.getRow(it)?.getCell(0)?.stringCellValue }
assertEquals(2, summaryCodes.count { it == "FG1" })
}
}

@Test
fun `chart value axis crosses between categories so end bars keep full width`() {
val window = SemiFgProductionAnalysisLayout.resolveWindow(
SemiFgReportFilter(view = "month", reportMonth = "2026-03"),
LocalDate.of(2026, 9, 23),
)
val bytes = SemiFgProductionAnalysisWorkbook().build(
SemiFgReportData(window, listOf(line(LocalDate.of(2026, 3, 15), "FG1", "箱", "JO-1", "LOT-1", "12"))),
)
val chartXml = java.util.zip.ZipInputStream(ByteArrayInputStream(bytes)).use { zip ->
generateSequence { zip.nextEntry }.first { it.name.startsWith("xl/charts/") }.let { zip.readBytes().decodeToString() }
}
assertTrue(chartXml.contains("val=\"between\""), chartXml)
assertTrue(!chartXml.contains("midCat"), chartXml)
}

private fun line(
date: LocalDate,
item: String,
uom: String,
job: String,
lot: String,
qty: String,
) = SemiFgLine(
prodDate = date,
itemNo = item,
itemName = "Cake",
unitOfMeasure = uom,
jobOrderCode = job,
productLotNo = lot,
qty = BigDecimal(qty),
qcFailed = false,
)

private fun cellText(cell: org.apache.poi.ss.usermodel.Cell?): String = when {
cell == null -> ""
cell.cellType == CellType.STRING -> cell.stringCellValue
cell.cellType == CellType.NUMERIC -> cell.numericCellValue.toString()
cell.cellType == CellType.BLANK -> ""
else -> ""
}
}

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