在「物料用量總結」加入「需採購/生產數量」=下週用量 − 庫存(不扣 PO,可為負) 欄寬加寬;不放在每日分頁 工單 列表顯示 BOM 工序標準工時(預估分鐘) 「BOM材料」→「材料」 耗材「不適用」→「不用提料」 BOM 工藝 新增 bom.details(含 Liquibase) 「工藝明細」改為可儲存的自由文字;「工藝流程」仍用名稱 看板 / 分頁 工單狀態看板經手人:無 handler 時回退顯示 operator 隱藏飲料生產數量分頁 排產規則文案 「一般日」→「一般規則」 「多備日」等「多備」相關 →「特別規則」 「多備星期」→「特別規則適用星期」(前後端同步)debrand
| @@ -68,7 +68,9 @@ data class JobOrderInfoWithTypeName( | |||||
| val status: String, | val status: String, | ||||
| val jobTypeId: Long?, | val jobTypeId: Long?, | ||||
| val jobTypeName: String?, | val jobTypeName: String?, | ||||
| val lotNo: String? | |||||
| val lotNo: String?, | |||||
| /** Standard BOM process minutes (prep + duration + post). Null if unknown. */ | |||||
| val estimatedMinutes: Int? = null, | |||||
| ) | ) | ||||
| // Job Order | // Job Order | ||||
| interface JobOrderDetailWithJsonString { | interface JobOrderDetailWithJsonString { | ||||
| @@ -69,6 +69,7 @@ import java.math.RoundingMode | |||||
| import java.time.LocalDate | import java.time.LocalDate | ||||
| import java.time.LocalDateTime | import java.time.LocalDateTime | ||||
| import com.ffii.fpsms.modules.master.entity.BomMaterialRepository | import com.ffii.fpsms.modules.master.entity.BomMaterialRepository | ||||
| import com.ffii.fpsms.modules.master.entity.BomProcessRepository | |||||
| import com.ffii.fpsms.modules.master.service.ItemUomService | import com.ffii.fpsms.modules.master.service.ItemUomService | ||||
| import com.ffii.fpsms.modules.master.web.models.ConvertUomByItemRequest | import com.ffii.fpsms.modules.master.web.models.ConvertUomByItemRequest | ||||
| import com.ffii.fpsms.modules.stock.service.StockInLineService | import com.ffii.fpsms.modules.stock.service.StockInLineService | ||||
| @@ -96,6 +97,7 @@ open class JobOrderService( | |||||
| val productProcessRepository: ProductProcessRepository, | val productProcessRepository: ProductProcessRepository, | ||||
| val jobOrderBomMaterialRepository: JobOrderBomMaterialRepository, | val jobOrderBomMaterialRepository: JobOrderBomMaterialRepository, | ||||
| val bomMaterialRepository: BomMaterialRepository, | val bomMaterialRepository: BomMaterialRepository, | ||||
| val bomProcessRepository: BomProcessRepository, | |||||
| val itemUomService: ItemUomService | val itemUomService: ItemUomService | ||||
| ) { | ) { | ||||
| @@ -171,6 +173,15 @@ open class JobOrderService( | |||||
| } else { | } else { | ||||
| emptyMap() | emptyMap() | ||||
| } | } | ||||
| val bomIds = jobOrdersMap.values.mapNotNull { it.bom?.id }.distinct() | |||||
| val estimatedMinutesByBomId: Map<Long, Int> = if (bomIds.isNotEmpty()) { | |||||
| bomProcessRepository.sumStandardMinutesByBomIds(bomIds).associate { agg -> | |||||
| agg.bomId to agg.totalMinutes.toInt().coerceAtLeast(0) | |||||
| } | |||||
| } else { | |||||
| emptyMap() | |||||
| } | |||||
| // 移除内存中的日期过滤,因为已经在数据库层面过滤了 | // 移除内存中的日期过滤,因为已经在数据库层面过滤了 | ||||
| val records = response.content | val records = response.content | ||||
| @@ -184,6 +195,9 @@ open class JobOrderService( | |||||
| Pair(null, null) | Pair(null, null) | ||||
| } | } | ||||
| val bomId = jobOrder?.bom?.id | |||||
| val estimatedMinutes = bomId?.let { estimatedMinutesByBomId[it] } | |||||
| JobOrderInfoWithTypeName( | JobOrderInfoWithTypeName( | ||||
| id = info.id, | id = info.id, | ||||
| @@ -210,7 +224,8 @@ open class JobOrderService( | |||||
| }, | }, | ||||
| jobTypeId = info.jobTypeId, | jobTypeId = info.jobTypeId, | ||||
| jobTypeName = info.jobTypeId?.let { jobTypes[it]?.name }, | jobTypeName = info.jobTypeId?.let { jobTypes[it]?.name }, | ||||
| lotNo = info.lotNo | |||||
| lotNo = info.lotNo, | |||||
| estimatedMinutes = estimatedMinutes, | |||||
| ) | ) | ||||
| } | } | ||||
| .filter { info -> | .filter { info -> | ||||
| @@ -134,12 +134,12 @@ open class PsScheduleRuleService( | |||||
| } | } | ||||
| fun savePlantRules(request: SavePsPlantRulesRequest) { | fun savePlantRules(request: SavePsPlantRulesRequest) { | ||||
| requireDays(request.redLineDays, request.safetyStockDays, UiText.t("一般日", "Normal days")) | |||||
| requireDays(request.prepareMoreRedLineDays, request.prepareMoreSafetyStockDays, UiText.t("多備日", "Extra-prep days")) | |||||
| requireDays(request.redLineDays, request.safetyStockDays, UiText.t("一般規則", "Normal rules")) | |||||
| requireDays(request.prepareMoreRedLineDays, request.prepareMoreSafetyStockDays, UiText.t("特別規則", "Special rules")) | |||||
| val coffee = requireProduceDays(request.coffeeDays, UiText.t("類別 A", "Category A")) | val coffee = requireProduceDays(request.coffeeDays, UiText.t("類別 A", "Category A")) | ||||
| val tea = requireProduceDays(request.teaDays, UiText.t("類別 B", "Category B")) | val tea = requireProduceDays(request.teaDays, UiText.t("類別 B", "Category B")) | ||||
| val lemon = requireProduceDays(request.lemonDays, UiText.t("類別 C", "Category C")) | val lemon = requireProduceDays(request.lemonDays, UiText.t("類別 C", "Category C")) | ||||
| val prepareMore = parseDaysStrict(request.prepareMoreDays, UiText.t("多備星期", "Extra-prep weekdays")) | |||||
| val prepareMore = parseDaysStrict(request.prepareMoreDays, UiText.t("特別規則適用星期", "Special-rule weekdays")) | |||||
| val noProduction = parseDaysStrict(request.noProductionDays, UiText.t("不生產星期", "Non-production weekdays")) | val noProduction = parseDaysStrict(request.noProductionDays, UiText.t("不生產星期", "Non-production weekdays")) | ||||
| upsert(RED_LINE, formatNumber(request.redLineDays), "float") | upsert(RED_LINE, formatNumber(request.redLineDays), "float") | ||||
| @@ -56,6 +56,10 @@ open class Bom : BaseEntity<Long>() { | |||||
| @Column(name = "description", nullable = false, length = 100) | @Column(name = "description", nullable = false, length = 100) | ||||
| open var description: String? = null | open var description: String? = null | ||||
| /** Free-text process / craft details shown on job order 工藝明細. */ | |||||
| @Column(name = "details", columnDefinition = "TEXT") | |||||
| open var details: String? = null | |||||
| @Column(name = "outputQty", precision = 14, scale = 2) | @Column(name = "outputQty", precision = 14, scale = 2) | ||||
| open var outputQty: BigDecimal? = null | open var outputQty: BigDecimal? = null | ||||
| @@ -30,4 +30,22 @@ interface BomProcessRepository : AbstractRepository<BomProcess, Long> { | |||||
| """ | """ | ||||
| ) | ) | ||||
| fun sumDurationByBomIds(@Param("bomIds") bomIds: Collection<Long>): List<BomDurationAggregate> | fun sumDurationByBomIds(@Param("bomIds") bomIds: Collection<Long>): List<BomDurationAggregate> | ||||
| /** prep + duration + post for JO list estimated time */ | |||||
| @Query( | |||||
| """ | |||||
| SELECT | |||||
| bp.bom.id AS bomId, | |||||
| COALESCE(SUM( | |||||
| COALESCE(bp.prepTimeInMinute, 0) + | |||||
| COALESCE(bp.durationInMinute, 0) + | |||||
| COALESCE(bp.postProdTimeInMinute, 0) | |||||
| ), 0) AS totalMinutes | |||||
| FROM BomProcess bp | |||||
| WHERE bp.deleted = false | |||||
| AND bp.bom.id IN :bomIds | |||||
| GROUP BY bp.bom.id | |||||
| """ | |||||
| ) | |||||
| fun sumStandardMinutesByBomIds(@Param("bomIds") bomIds: Collection<Long>): List<BomDurationAggregate> | |||||
| } | } | ||||
| @@ -212,6 +212,19 @@ open class BomService( | |||||
| return response | return response | ||||
| } | } | ||||
| /** Update free-text craft details (工藝明細) on BOM. */ | |||||
| @Transactional | |||||
| open fun updateBomDetails(id: Long, details: String?): Map<String, Any?> { | |||||
| val bom = bomRepository.findByIdAndDeletedIsFalse(id) | |||||
| ?: throw NotFoundException() | |||||
| bom.details = details | |||||
| bomRepository.saveAndFlush(bom) | |||||
| return mapOf( | |||||
| "id" to bom.id, | |||||
| "details" to bom.details, | |||||
| ) | |||||
| } | |||||
| /** | /** | ||||
| * Edit BOM (basic fields + BOM materials + BOM process lines). | * Edit BOM (basic fields + BOM materials + BOM process lines). | ||||
| * | * | ||||
| @@ -1564,7 +1564,8 @@ open class ProductionScheduleService( | |||||
| val matSheet = workbook.createSheet(UiText.t("物料用量總結", "Material usage summary")) | val matSheet = workbook.createSheet(UiText.t("物料用量總結", "Material usage summary")) | ||||
| val matHeaders = listOf( | val matHeaders = listOf( | ||||
| UiText.t("物料編號", "Material code"), UiText.t("物料名稱", "Material name"), UiText.t("需求量", "Required qty"), UiText.t("總用量", "Total usage"), | |||||
| UiText.t("物料編號", "Material code"), UiText.t("物料名稱", "Material name"), | |||||
| UiText.t("需採購/生產數量", "Qty to purchase/produce"), UiText.t("總用量", "Total usage"), | |||||
| UiText.t("單位", "UOM"), UiText.t("數量(採購單位)", "Qty (purchase UOM)"), UiText.t("採購單位", "Purchase UOM"), | UiText.t("單位", "UOM"), UiText.t("數量(採購單位)", "Qty (purchase UOM)"), UiText.t("採購單位", "Purchase UOM"), | ||||
| UiText.t("已採購數量", "Purchased qty"), UiText.t("庫存數", "On hand"), UiText.t("庫存單位", "Stock UOM"), | UiText.t("已採購數量", "Purchased qty"), UiText.t("庫存數", "On hand"), UiText.t("庫存單位", "Stock UOM"), | ||||
| UiText.t("相關成品編號", "Related finished-good code"), UiText.t("相關成品名稱", "Related finished-good name") | UiText.t("相關成品編號", "Related finished-good code"), UiText.t("相關成品名稱", "Related finished-good name") | ||||
| @@ -1586,12 +1587,13 @@ open class ProductionScheduleService( | |||||
| val purchaseQtyNeed = asDouble(rowData["purchaseQtyNeed"]) | val purchaseQtyNeed = asDouble(rowData["purchaseQtyNeed"]) | ||||
| val purchased = asDouble(rowData["purchasedQty"]) | val purchased = asDouble(rowData["purchasedQty"]) | ||||
| val onHand = asDouble(rowData["onHandQty"]) | val onHand = asDouble(rowData["onHandQty"]) | ||||
| val requiredQty = (totalNeed - purchased - onHand).coerceAtLeast(0.0) | |||||
| // 總用量 − 庫存;不扣在途採購。負數代表有餘量。 | |||||
| val qtyToPurchaseOrProduce = totalNeed - onHand | |||||
| val values = listOf<Any>( | val values = listOf<Any>( | ||||
| rowData["matCode"]?.toString() ?: "", | rowData["matCode"]?.toString() ?: "", | ||||
| rowData["matName"]?.toString() ?: "", | rowData["matName"]?.toString() ?: "", | ||||
| requiredQty, | |||||
| qtyToPurchaseOrProduce, | |||||
| totalNeed, | totalNeed, | ||||
| rowData["uomName"]?.toString() ?: "", | rowData["uomName"]?.toString() ?: "", | ||||
| purchaseQtyNeed, | purchaseQtyNeed, | ||||
| @@ -1628,7 +1630,7 @@ open class ProductionScheduleService( | |||||
| // Column widths for material summary | // Column widths for material summary | ||||
| matSheet.setColumnWidth(0, 20 * 256) | matSheet.setColumnWidth(0, 20 * 256) | ||||
| matSheet.setColumnWidth(1, 50 * 256) // 物料名稱 | matSheet.setColumnWidth(1, 50 * 256) // 物料名稱 | ||||
| matSheet.setColumnWidth(2, 16 * 256) | |||||
| matSheet.setColumnWidth(2, 22 * 256) // 需採購/生產數量 | |||||
| matSheet.setColumnWidth(3, 16 * 256) | matSheet.setColumnWidth(3, 16 * 256) | ||||
| matSheet.setColumnWidth(4, 12 * 256) | matSheet.setColumnWidth(4, 12 * 256) | ||||
| matSheet.setColumnWidth(5, 16 * 256) | matSheet.setColumnWidth(5, 16 * 256) | ||||
| @@ -29,6 +29,7 @@ import com.ffii.fpsms.modules.master.web.models.BomFormatCheckRequest | |||||
| import com.ffii.fpsms.modules.master.web.models.ImportBomRequestPayload | import com.ffii.fpsms.modules.master.web.models.ImportBomRequestPayload | ||||
| import com.ffii.fpsms.modules.master.web.models.BomDetailResponse | import com.ffii.fpsms.modules.master.web.models.BomDetailResponse | ||||
| import com.ffii.fpsms.modules.master.web.models.EditBomRequest | import com.ffii.fpsms.modules.master.web.models.EditBomRequest | ||||
| import com.ffii.fpsms.modules.master.web.models.UpdateBomDetailsRequest | |||||
| import com.ffii.fpsms.modules.master.web.models.BomExcelCheckProgress | import com.ffii.fpsms.modules.master.web.models.BomExcelCheckProgress | ||||
| import com.ffii.fpsms.modules.master.web.models.BomIdByItemCodeResponse | import com.ffii.fpsms.modules.master.web.models.BomIdByItemCodeResponse | ||||
| import com.ffii.fpsms.modules.master.web.models.MasterDataIssueResponse | import com.ffii.fpsms.modules.master.web.models.MasterDataIssueResponse | ||||
| @@ -169,4 +170,13 @@ fun getBomDetail(@PathVariable id: Long): BomDetailResponse { | |||||
| ): BomDetailResponse { | ): BomDetailResponse { | ||||
| return bomService.editBom(id, request) | return bomService.editBom(id, request) | ||||
| } | } | ||||
| /** Update free-text craft details (工藝明細) only. */ | |||||
| @PutMapping("/{id}/details") | |||||
| fun updateBomDetails( | |||||
| @PathVariable id: Long, | |||||
| @RequestBody request: UpdateBomDetailsRequest, | |||||
| ): Map<String, Any?> { | |||||
| return bomService.updateBomDetails(id, request.details) | |||||
| } | |||||
| } | } | ||||
| @@ -24,6 +24,8 @@ data class ProductProcessInfo( | |||||
| val jobOrderStatus: String?, | val jobOrderStatus: String?, | ||||
| val jobType: String?, | val jobType: String?, | ||||
| val bomDescription: String?, | val bomDescription: String?, | ||||
| /** Free-text craft details from bom.details */ | |||||
| val bomDetails: String? = null, | |||||
| val isDark: String?, | val isDark: String?, | ||||
| val isDense: Int?, | val isDense: Int?, | ||||
| val isFloat: String?, | val isFloat: String?, | ||||
| @@ -674,6 +674,7 @@ open class ProductProcessService( | |||||
| jobOrderStatus = jobOrder?.status?.value ?: "", | jobOrderStatus = jobOrder?.status?.value ?: "", | ||||
| jobType = jobType?.name ?: "", | jobType = jobType?.name ?: "", | ||||
| bomDescription = bom?.description ?: "", | bomDescription = bom?.description ?: "", | ||||
| bomDetails = bom?.details, | |||||
| itemId = bom?.item?.id ?: 0, | itemId = bom?.item?.id ?: 0, | ||||
| itemCode = bom?.item?.code ?: "", | itemCode = bom?.item?.code ?: "", | ||||
| itemName = bom?.item?.name ?: "", | itemName = bom?.item?.name ?: "", | ||||
| @@ -2602,7 +2603,9 @@ open class ProductProcessService( | |||||
| processName = line.name, | processName = line.name, | ||||
| equipmentName = equipmentName, | equipmentName = equipmentName, | ||||
| equipmentDetailName = equipmentDetailName, | equipmentDetailName = equipmentDetailName, | ||||
| handlerName = line.handler?.name, | |||||
| // Scan staff writes operator; startLine writes handler. Prefer handler, fall back to operator. | |||||
| handlerName = line.handler?.name?.takeIf { it.isNotBlank() } | |||||
| ?: line.operator?.name, | |||||
| startTime = line.startTime, | startTime = line.startTime, | ||||
| endTime = line.endTime, | endTime = line.endTime, | ||||
| processingTime = line.processingTime, | processingTime = line.processingTime, | ||||