| @@ -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) } | |||
| @@ -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) | |||
| } | |||
| } | |||
| } | |||
| @@ -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, | |||
| @@ -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( | |||
| @@ -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 -> "" | |||
| } | |||
| } | |||