diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt b/src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt index ef20f79e..32280a7b 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt +++ b/src/main/java/com/ffii/fpsms/modules/report/service/SemiFGProductionAnalysisReportService.kt @@ -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) } diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisLayout.kt b/src/main/java/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisLayout.kt index 6757dd74..cde5973b 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisLayout.kt +++ b/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 = 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, qtyDesc: Boolean = false): List { val grouped = linkedMapOf() val totals = linkedMapOf() 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 { totals[it.itemNo] }.thenBy { it.itemNo }) + items.sortedWith( + compareByDescending { 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): Map> { val out = linkedMapOf>() 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, itemNo: String): Int { + fun jobOrderCount(lines: List, 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>, 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, items: List, 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): List { 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): 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>, 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) } } } diff --git a/src/main/java/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisWorkbook.kt b/src/main/java/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisWorkbook.kt index 475f42c2..d339c2d9 100644 --- a/src/main/java/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisWorkbook.kt +++ b/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 { - 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() 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? = 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, itemNo: String): DetailSpan? = - anchors.keys.filter { it.startsWith("$itemNo|") }.minOrNull()?.let { anchors[it] } + private fun firstAnchor(anchors: Map, 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, diff --git a/src/test/kotlin/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisLayoutTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisLayoutTest.kt index c4a6170f..317ce360 100644 --- a/src/test/kotlin/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisLayoutTest.kt +++ b/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( diff --git a/src/test/kotlin/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisWorkbookTest.kt b/src/test/kotlin/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisWorkbookTest.kt index 5d6c2d37..a5565c71 100644 --- a/src/test/kotlin/com/ffii/fpsms/modules/report/service/SemiFgProductionAnalysisWorkbookTest.kt +++ b/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 -> "" + } }