Переглянути джерело

1. 員工發貨績效圖表

2.  新報表:送貨訂單與倉存單位不符
3 店鋪補貨報表 加「原單提料人」「實際補貨提料人」;Excel 欄位改純中文表頭
4  包裝 autoPass
5. JO 提料 re-suggest JO 類型 storeId 保持 null(避免被預設成 2/F 濾掉 3F)  前端對齊 DO:用後端 stockout qty、同 POL 依 status 排序
6. 使用者建立/驗證
7.  BOM 設備
8「不合用/不適用」統一不掛 equipment data
9 hilde some useless console log
fix負數倉
CANCERYS\kw093 1 місяць тому
джерело
коміт
781caff39f
15 змінених файлів з 413 додано та 74 видалено
  1. +57
    -22
      src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt
  2. +6
    -2
      src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt
  3. +2
    -0
      src/main/java/com/ffii/fpsms/modules/common/ErrorCodes.java
  4. +9
    -4
      src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt
  5. +1
    -0
      src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchPickConstants.kt
  6. +10
    -2
      src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt
  7. +2
    -1
      src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessLineRepository.kt
  8. +28
    -34
      src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt
  9. +1
    -1
      src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt
  10. +1
    -1
      src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt
  11. +128
    -0
      src/main/java/com/ffii/fpsms/modules/report/service/DoInventoryUomMismatchReportService.kt
  12. +25
    -7
      src/main/java/com/ffii/fpsms/modules/report/service/ShopOrderReplenishmentReportService.kt
  13. +116
    -0
      src/main/java/com/ffii/fpsms/modules/report/web/DoInventoryUomMismatchReportController.kt
  14. +4
    -0
      src/main/java/com/ffii/fpsms/modules/user/entity/UserRepository.java
  15. +23
    -0
      src/main/java/com/ffii/fpsms/modules/user/service/UserService.java

+ 57
- 22
src/main/java/com/ffii/fpsms/modules/chart/service/ChartService.kt Переглянути файл

@@ -717,9 +717,13 @@ open class ChartService(
}

/**
* Staff delivery performance: daily pick ticket count and total time per staff.
* FP-MTMS Version Checklist | Functions Ref. No. 61 | v1.0.0 | 2026-08-10
* Staff delivery performance: daily pick ticket count, duration, item kinds, and actual picked qty.
* Uses delivery_order_pick_order (handler = handledBy); time = sum of
* (ticketCompleteDateTime - ticketReleaseTime) per completed ticket.
* itemKindCount = sum of per-ticket COUNT(DISTINCT pol.itemId);
* itemQtyPicked = sum of stock_out_line.qty via pick_order_line (actual picked, not pol.qty).
* Scoped CTE first (date/store/staff) then lineAgg — avoids full-history pick/sol scan.
* staffNos: when non-empty, filter to these staff by user.staffNo (multi-select).
* storeIdNull: when true, only rows with dop.storeId IS NULL (takes precedence over storeId).
* storeId: when non-blank and storeIdNull is not true, filter dop.storeId equality (trimmed).
@@ -742,12 +746,13 @@ open class ChartService(
args["endExclusive"] = endDate.plusDays(1).atStartOfDay()
"AND dop.ticketCompleteDateTime < :endExclusive"
} else ""
val staffSql = if (!staffNos.isNullOrEmpty()) {
val nos = staffNos.map { it.trim() }.filter { it.isNotBlank() }
if (nos.isEmpty()) "" else {
args["staffNos"] = nos
"AND u.staffNo IN (:staffNos)"
}
val staffNosFiltered = staffNos?.map { it.trim() }?.filter { it.isNotBlank() }.orEmpty()
val staffSql = if (staffNosFiltered.isNotEmpty()) {
args["staffNos"] = staffNosFiltered
"AND u_scope.staffNo IN (:staffNos)"
} else ""
val scopeUserJoin = if (staffSql.isNotEmpty()) {
"LEFT JOIN user u_scope ON dop.handledBy = u_scope.id AND u_scope.deleted = 0"
} else ""
val storeSql = when {
storeIdNull == true -> "AND dop.storeId IS NULL"
@@ -758,31 +763,61 @@ open class ChartService(
else -> ""
}
val useStoreFilter = storeIdNull == true || !storeId.isNullOrBlank()
val fromClause = if (useStoreFilter) {
val dopFromClause = if (useStoreFilter) {
"FROM delivery_order_pick_order dop"
} else {
"FROM delivery_order_pick_order dop FORCE INDEX (idx_dopo_staff_perf_complete)"
}
val sql = """
WITH dop_scoped AS (
SELECT
dop.id,
dop.ticketCompleteDateTime,
dop.ticketReleaseTime,
dop.handledBy,
dop.handlerName
$dopFromClause
$scopeUserJoin
WHERE dop.deleted = 0
AND dop.ticketStatus = 'completed'
AND dop.ticketCompleteDateTime IS NOT NULL
$startSql $endSql $storeSql $staffSql
),
lineAgg AS (
SELECT
po.deliveryOrderPickOrderId AS dopId,
COUNT(DISTINCT pol.itemId) AS itemKindCount,
COALESCE(SUM(sol.qty), 0) AS itemQtyPicked
FROM dop_scoped d
INNER JOIN pick_order po
ON po.deliveryOrderPickOrderId = d.id
AND po.deleted = 0
INNER JOIN pick_order_line pol
ON pol.poId = po.id
AND pol.deleted = 0
LEFT JOIN stock_out_line sol FORCE INDEX (idx_sol_polid_deleted_status_qty)
ON sol.pickOrderLineId = pol.id
AND sol.deleted = 0
GROUP BY po.deliveryOrderPickOrderId
)
SELECT
DATE_FORMAT(dop.ticketCompleteDateTime, '%Y-%m-%d') AS date,
COALESCE(NULLIF(TRIM(COALESCE(u.name, '')), ''), dop.handlerName, 'Unknown') AS staffName,
COUNT(dop.id) AS orderCount,
DATE_FORMAT(d.ticketCompleteDateTime, '%Y-%m-%d') AS date,
COALESCE(NULLIF(TRIM(COALESCE(u.name, '')), ''), d.handlerName, 'Unknown') AS staffName,
COUNT(d.id) AS orderCount,
COALESCE(SUM(
CASE
WHEN dop.ticketReleaseTime IS NOT NULL AND dop.ticketCompleteDateTime IS NOT NULL
THEN GREATEST(0, TIMESTAMPDIFF(MINUTE, dop.ticketReleaseTime, dop.ticketCompleteDateTime))
WHEN d.ticketReleaseTime IS NOT NULL AND d.ticketCompleteDateTime IS NOT NULL
THEN GREATEST(0, TIMESTAMPDIFF(MINUTE, d.ticketReleaseTime, d.ticketCompleteDateTime))
ELSE 0
END
), 0) AS totalMinutes
$fromClause
LEFT JOIN user u ON dop.handledBy = u.id AND u.deleted = 0
WHERE dop.deleted = 0
AND dop.ticketStatus = 'completed'
AND dop.ticketCompleteDateTime IS NOT NULL
$startSql $endSql $staffSql $storeSql
GROUP BY DATE_FORMAT(dop.ticketCompleteDateTime, '%Y-%m-%d'),
dop.handledBy, u.name, dop.handlerName
), 0) AS totalMinutes,
COALESCE(SUM(la.itemKindCount), 0) AS itemKindCount,
COALESCE(SUM(la.itemQtyPicked), 0) AS itemQtyPicked
FROM dop_scoped d
LEFT JOIN user u ON d.handledBy = u.id AND u.deleted = 0
LEFT JOIN lineAgg la ON la.dopId = d.id
GROUP BY DATE_FORMAT(d.ticketCompleteDateTime, '%Y-%m-%d'),
d.handledBy, u.name, d.handlerName
ORDER BY date, orderCount DESC
""".trimIndent()
return jdbcDao.queryForList(sql, args)


+ 6
- 2
src/main/java/com/ffii/fpsms/modules/chart/web/ChartController.kt Переглянути файл

@@ -194,9 +194,13 @@ class ChartController(
chartService.getStaffDeliveryPerformanceHandlers()

/**
* FP-MTMS Version Checklist | Functions Ref. No. 61 | v1.0.0 | 2026-08-10
* GET /chart/staff-delivery-performance?startDate=&endDate=&staffNo=A001&staffNo=A002&storeId=2/F&storeIdNull=true
* Returns [{ date, staffName, orderCount, totalMinutes }]. Data from delivery_order_pick_order
* (handledBy), orderCount = completed pick tickets, totalMinutes = sum(ticketCompleteDateTime - ticketReleaseTime).
* Returns [{ date, staffName, orderCount, totalMinutes, itemKindCount, itemQtyPicked }].
* Data from delivery_order_pick_order (handledBy); orderCount = completed pick tickets;
* totalMinutes = sum(ticketCompleteDateTime - ticketReleaseTime);
* itemKindCount = sum of per-ticket COUNT(DISTINCT pick_order_line.itemId);
* itemQtyPicked = sum of stock_out_line.qty via pick_order_line.
* Optional storeId filters delivery_order_pick_order.storeId; storeIdNull=true means IS NULL (overrides storeId).
*/
@GetMapping("/staff-delivery-performance")


+ 2
- 0
src/main/java/com/ffii/fpsms/modules/common/ErrorCodes.java Переглянути файл

@@ -10,6 +10,8 @@ public class ErrorCodes {

public static final String SEND_EMAIL_ERROR = "SEND_EMAIL_ERROR";
public static final String USERNAME_NOT_AVAILABLE = "USERNAME_NOT_AVAILABLE";
public static final String NAME_NOT_AVAILABLE = "NAME_NOT_AVAILABLE";
public static final String STAFF_NO_NOT_AVAILABLE = "STAFF_NO_NOT_AVAILABLE";

public static final String INIT_EXCEL_ERROR = "INIT_EXCEL_ERROR";



+ 9
- 4
src/main/java/com/ffii/fpsms/modules/deliveryOrder/service/DoWorkbenchMainService.kt Переглянути файл

@@ -267,7 +267,7 @@ open class DoWorkbenchMainService(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.0 | 2026-08-03
* FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.1 | 2026-08-10
* Workbench scan-pick (DO FG):
* 1) Post outbound on scanned inventory lot line first; on failure return a clear message.
* 2) If the lot runs out before this stock-out line’s chunk is filled and the user did not pass a short [qty],
@@ -2386,10 +2386,15 @@ return MessageResponse(
var postMs = 0L
try {
if (pickOrderId != null) {
val suggestionStoreId = resolveWorkbenchSuggestionStoreId(pickOrderId, requestStoreId)
// JO assign primes with storeId=null; re-suggest must match or 3F lots are
// filtered out after resolveWorkbenchSuggestionStoreId defaults to "2/F".
val resolvedPoType = pickOrderRepository.findById(pickOrderId).orElse(null)?.type
val suggestionStoreId =
if (resolvedPoType == PickOrderType.JOB_ORDER) null
else resolveWorkbenchSuggestionStoreId(pickOrderId, requestStoreId)
val resuggestExcludeWarehouseCodes = workbenchResuggestExcludeWarehouseCodes(
pickOrderId = pickOrderId,
poType = null,
poType = resolvedPoType,
userId = userId,
requestExcludeWarehouseCodes = effectiveExcludeWarehouseCodes,
)
@@ -2620,7 +2625,7 @@ return MessageResponse(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.0 | 2026-08-03
* FP-MTMS Version Checklist | Functions Ref. No. 38 | v1.0.1 | 2026-08-10
* DO user-audit: Just Complete is reportable only when the item still has pickable stock
* at complete time (AVAILABLE, not expired, in−out > 0).
*


+ 1
- 0
src/main/java/com/ffii/fpsms/modules/jobOrder/service/JoWorkbenchPickConstants.kt Переглянути файл

@@ -5,6 +5,7 @@ package com.ffii.fpsms.modules.jobOrder.service
*
* [DEFAULT_EXCLUDE_WAREHOUSE_CODES] applies on **assign / first prime** ([JoWorkbenchMainService])
* and on **scan-pick re-suggest** ([com.ffii.fpsms.modules.deliveryOrder.service.DoWorkbenchMainService]).
* JO re-suggest keeps [storeId] null (same as assign); it does not use DO floor store resolution.
*/
object JoWorkbenchPickConstants {
val DEFAULT_EXCLUDE_WAREHOUSE_CODES: Set<String> = setOf(


+ 10
- 2
src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt Переглянути файл

@@ -1430,7 +1430,8 @@ open class BomService(
}
3 -> {
val equipmentName = tempCell.stringCellValue.trim()
if (equipmentName != "不適用") {
// 不合用 / 不適用:不掛 equipment(與格式檢查同等處理)
if (!isNotApplicableEquipment(equipmentName)) {
val equipment = bomGetOrCreateEquipment(equipmentName)
// println("equipment created")
bomProcessRequest.equipment = equipment
@@ -1977,10 +1978,17 @@ open class BomService(
issueLogFileId = issueLogId
)
}
/** FP-MTMS Version Checklist | Functions Ref. No. 63 | v1.0.0 | 2026-08-10 */
/** BOM 工序「使用設備」為不合用/不適用時,不掛 equipment FK */
private fun isNotApplicableEquipment(value: String): Boolean {
val trimmed = value.trim()
return trimmed == "不合用" || trimmed == "不適用"
}

private fun isValidEquipmentType(value: String): Boolean {
val trimmed = value.trim()
if (trimmed.isEmpty()) return false
if (trimmed == "不合用" || trimmed == "不適用") return true
if (isNotApplicableEquipment(trimmed)) return true
if (trimmed.contains(",")) return false // 新增:不允許逗號
val regex = Regex("^[^-/]+-[^-/]+$") // 例:工具類-切絲機
return regex.matches(trimmed)


+ 2
- 1
src/main/java/com/ffii/fpsms/modules/productProcess/entity/ProductProcessLineRepository.kt Переглянути файл

@@ -29,12 +29,13 @@ interface ProductProcessLineRepository : JpaRepository<ProductProcessLine, Long>
""")
fun findByProductProcess_IdInWithOperatorAndEquipment(@Param("ids") ids: List<Long>): List<ProductProcessLine>

/** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.3 | 2026-08-10 */
@Query(
"""
SELECT
p.jobOrder.id AS jobOrderId,
COUNT(l.id) AS totalLines,
SUM(CASE WHEN l.status IN ('Completed', 'Pass') THEN 1 ELSE 0 END) AS doneLines
SUM(CASE WHEN l.status IN ('Completed', 'Pass', 'autoPass') THEN 1 ELSE 0 END) AS doneLines
FROM ProductProcessLine l
JOIN l.productProcess p
WHERE l.deleted = false


+ 28
- 34
src/main/java/com/ffii/fpsms/modules/productProcess/service/ProductProcessService.kt Переглянути файл

@@ -1250,7 +1250,7 @@ open class ProductProcessService(
)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.0 | 2026-08-09 */
/** FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.1 | 2026-08-10 */
open fun updateProductProcessLineStatus(productProcessLineId: Long, status: String): MessageResponse {
println(" Service: Updating ProductProcessLine Status: $productProcessLineId")
val productProcessLine = productProcessLineRepository.findById(productProcessLineId).orElse(null)
@@ -1260,8 +1260,8 @@ open class ProductProcessService(
productProcessLineRepository.save(productProcessLine)
println(" Service: ProductProcessLine Status Updated: ${productProcessLine.status}")

// One packaging Complete/Pass → auto-Pass remaining packaging lines on the same JO.
if (isLineDone(status) && isPackagingLine(productProcessLine)) {
// One packaging Complete/Pass → autoPass remaining packaging lines on the same JO.
if (isManualLineDone(status) && isPackagingLine(productProcessLine)) {
autoPassSiblingPackagingLines(productProcessLineId)
}

@@ -1319,7 +1319,7 @@ open class ProductProcessService(
val productProcessLines = productProcessLineRepository.findByProductProcess_Id(productProcessId)

println(" Service: ProductProcessLines: $productProcessLines")
if (productProcessLines.all { it.status == "Completed" || it.status == "Pass" }) {
if (productProcessLines.all { isLineDone(it.status) }) {
productProcess.status = ProductProcessStatus.COMPLETED
if (productProcess.endTime == null) {
productProcess.endTime = LocalDateTime.now()
@@ -1511,9 +1511,9 @@ open class ProductProcessService(
}

/**
* FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.2 | 2026-08-09
* QC 列表:按 jobOrder 粒度分页,qcReady 由该 jobOrder 下所有 productProcessLine 是否全部 Completed/Pass 决定。
* (包裝 Complete/Pass 時會自動 Pass 同 JO 其餘包裝 line,因此不再需要「任一包裝即可」特例。)
* FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.3 | 2026-08-10
* QC 列表:按 jobOrder 粒度分页,qcReady 由该 jobOrder 下所有 productProcessLine 是否全部 Completed/Pass/autoPass 决定。
* (包裝 Complete/Pass 時會自動 autoPass 同 JO 其餘包裝 line,因此不再需要「任一包裝即可」特例。)
*
* 注意:date/itemCode/jobOrderCode/bomIds 用来筛选“候选 jobOrder”,但 qcReady 判断会用该 jobOrder 下全部 productProcessLine。
*/
@@ -1663,10 +1663,6 @@ open class ProductProcessService(
val linesByJobOrder = candidateLines.groupBy { it.productProcess.jobOrder?.id ?: 0L }
val done = { s: String? ->
val x = s?.trim()?.lowercase()
x == "completed" || x == "pass"
}
val qcKeys = candidateAggregates.mapNotNull { agg ->
val jobOrderId = agg.jobOrderId
val stockInLine = stockInLineByJobOrderId[jobOrderId]
@@ -1688,15 +1684,10 @@ open class ProductProcessService(

val lineAggregate = lineStatusByJobOrderId[jobOrderId]
val totalLines = lineAggregate?.totalLines ?: 0L
val done = { s: String? ->
val x = s?.trim()?.lowercase()
x == "completed" || x == "pass"
}
// After packaging Complete/Pass auto-passes sibling 包裝 lines, qcReady is simply all lines done.

// After packaging Complete/Pass autoPasses sibling 包裝 lines, qcReady is simply all lines done.
val jobLines = linesByJobOrder[jobOrderId].orEmpty()
val allLinesDone = jobLines.isNotEmpty() && jobLines.all { done(it.status) }
val allLinesDone = jobLines.isNotEmpty() && jobLines.all { isLineDone(it.status) }

val ready = includedInList && stockInLine != null && allLinesDone
@@ -1886,10 +1877,7 @@ open class ProductProcessService(
val joPickOrdersList = if (pickOrderId != null) joPickOrdersByPickOrderId[pickOrderId].orEmpty() else emptyList()

val productProcessLines = linesByProcessId[productProcess.id ?: 0L].orEmpty()
val finishedCount = productProcessLines.count {
val s = it.status?.trim()?.lowercase()
s == "completed" || s == "pass"
}
val finishedCount = productProcessLines.count { isLineDone(it.status) }

val bomIsDrink = productProcess.bom?.isDrink
val matchStatus = if (joPickOrdersList.isNotEmpty() &&
@@ -1963,7 +1951,7 @@ open class ProductProcessService(
}

/**
* FG QC / 上架 reminders: same eligibility as 完成QC工單 (qcReady): all lines Completed/Pass, stock-in exists and not completed/rejected;
* FG QC / 上架 reminders: same eligibility as 完成QC工單 (qcReady): all lines Completed/Pass/autoPass, stock-in exists and not completed/rejected;
* only job orders that have a product process dated **today or yesterday** (server local date).
*/
open fun findJobOrderFgQcAndPutAwayAlertsForTodayYesterday(): JobOrderFgAlertsResponse {
@@ -2017,7 +2005,7 @@ open class ProductProcessService(

val jobOrderLines = processes.flatMap { p -> linesByProcessId[p.id ?: 0L].orEmpty() }
val allLinesDone = jobOrderLines.isNotEmpty() &&
jobOrderLines.all { it.status == "Completed" || it.status == "Pass" }
jobOrderLines.all { isLineDone(it.status) }
if (!allLinesDone) continue

val maxDate = processes.mapNotNull { it.date }.maxOrNull()
@@ -2119,6 +2107,12 @@ open class ProductProcessService(
status?.trim()?.lowercase()?.replace(" ", "") ?: ""

private fun isLineDone(status: String?): Boolean {
val n = normalizeLineStatus(status)
return n == "completed" || n == "pass" || n == "autopass"
}

/** Manual Complete/Pass only — does not include autoPass (avoids cascade loops). */
private fun isManualLineDone(status: String?): Boolean {
val n = normalizeLineStatus(status)
return n == "completed" || n == "pass"
}
@@ -2128,14 +2122,14 @@ open class ProductProcessService(
return n == "inprogress" || n == "paused"
}

/** FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.0 | 2026-08-09 */
/** FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.1 | 2026-08-10 */
private fun isPackagingLine(line: ProductProcessLine): Boolean =
(line.name ?: "").trim() == PACKAGING_PROCESS_NAME

/**
* FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.0 | 2026-08-09
* When one 包裝 line is Completed/Pass, auto-Pass other unfinished 包裝 lines on the same job order,
* then sync each affected ProductProcess.status (Pass counts as done → COMPLETED).
* FP-MTMS Version Checklist | Functions Ref. No. 58 | v1.0.1 | 2026-08-10
* When one 包裝 line is Completed/Pass, set other unfinished 包裝 lines on the same job order to autoPass,
* then sync each affected ProductProcess.status (autoPass counts as done → COMPLETED).
*/
private fun autoPassSiblingPackagingLines(triggerLineId: Long) {
val trigger = productProcessLineRepository.findById(triggerLineId).orElse(null) ?: return
@@ -2163,7 +2157,7 @@ open class ProductProcessService(
line.startTime = now
}
line.endTime = now
line.status = "Pass"
line.status = "autoPass"
productProcessLineRepository.save(line)
line.productProcess?.id?.let { affectedProcessIds.add(it) }
}
@@ -2175,7 +2169,7 @@ open class ProductProcessService(

/**
* Align parent [ProductProcess.status] with all line states.
* - All Completed/Pass -> completed (via [ifAllLinesCompletedOrPassed])
* - All Completed/Pass/autoPass -> completed (via [ifAllLinesCompletedOrPassed])
* - Any line started but not all done -> in_progress
* - Does not override STOPPED or CANCELLED on the parent.
*/
@@ -2255,8 +2249,8 @@ open class ProductProcessService(
// 获取所有 product process lines
val allproductProcessLines = productProcessLineRepository.findByProductProcess_Id(productProcessId)

// 检查是否所有 lines 都是 "Completed" 或 "Pass"
if (allproductProcessLines.all { it.status == "Completed" || it.status == "Pass" }) {
// 检查是否所有 lines 都是 Completed / Pass / autoPass
if (allproductProcessLines.all { isLineDone(it.status) }) {
// 更新 product process 的 endTime 和状态
updateProductProcessEndTime(productProcessId)
updateProductProcessStatus(productProcessId, ProductProcessStatus.COMPLETED)
@@ -3097,7 +3091,7 @@ open class ProductProcessService(
}

val currentProcesses = operatorLines
.filter { it.endTime == null || (it.status != null && it.status != "Completed" && it.status != "Pass") }
.filter { it.endTime == null || (it.status != null && !isLineDone(it.status)) }
.map { line ->
val productProcess = line.productProcess
val jobOrder = productProcess.jobOrder


+ 1
- 1
src/main/java/com/ffii/fpsms/modules/productProcess/web/ProductProcessController.kt Переглянути файл

@@ -200,7 +200,7 @@ class ProductProcessController(
return productProcessService.getAllJoborderProductProcessInfo(bomType)
}

/** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.1 | 2026-08-06 */
/** FP-MTMS Version Checklist | Functions Ref. No. 41 | v1.0.3 | 2026-08-10 */
@GetMapping("/Demo/Process/search")
fun demoprocesssearch(
@RequestParam(required = false) date: String?,


+ 1
- 1
src/main/java/com/ffii/fpsms/modules/productProcess/web/model/SaveProductProcessRequest.kt Переглянути файл

@@ -218,7 +218,7 @@ data class JobOrderProductProcessPageResponse(
)

/**
* Nav alerts aligned with 完成QC工單 list: all product process lines Completed/Pass, stock-in not completed/rejected;
* Nav alerts aligned with 完成QC工單 list: all product process lines Completed/Pass/autoPass, stock-in not completed/rejected;
* job order has at least one process dated today or yesterday. [qc] = before received; [putAway] = received / partially_completed.
*/
data class JobOrderFgAlertRowResponse(


+ 128
- 0
src/main/java/com/ffii/fpsms/modules/report/service/DoInventoryUomMismatchReportService.kt Переглянути файл

@@ -0,0 +1,128 @@
package com.ffii.fpsms.modules.report.service

import com.ffii.core.support.JdbcDao
import com.ffii.fpsms.modules.deliveryOrder.service.DoFloorSupplierSettingsService
import org.springframework.stereotype.Service
import java.time.LocalDate
import java.time.format.DateTimeParseException

@Service
class DoInventoryUomMismatchReportService(
private val jdbcDao: JdbcDao,
private val doFloorSupplierSettingsService: DoFloorSupplierSettingsService,
) {
/**
* FP-MTMS Version Checklist | Functions Ref. No. 60 | v1.0.0 | 2026-08-10
* Pending DO lines by estimatedArrivalDate whose current inventory lot UOM
* does not match the DO line UOM. Excel-only report.
* Supplier scope follows DoSearch floor lists (2F / 4F / All = both).
*/
fun searchDoInventoryUomMismatch(
deliveryDate: String?,
storeId: String? = null,
): List<Map<String, Any?>> {
val raw = deliveryDate?.trim()?.replace("/", "-").orEmpty()
if (raw.isBlank()) {
throw IllegalArgumentException("deliveryDate is required")
}

val date = try {
LocalDate.parse(raw)
} catch (_: DateTimeParseException) {
throw IllegalArgumentException("deliveryDate must be yyyy-MM-dd")
}

val today = LocalDate.now()
if (date.isBefore(today)) {
throw IllegalArgumentException("deliveryDate must not be before today")
}

val floor = storeId?.trim().orEmpty().let { f ->
when {
f.isEmpty() || f.equals("All", ignoreCase = true) -> "ALL"
else -> f
}
}
val allowedSupplierCodes = doFloorSupplierSettingsService.allowedSupplierCodesForFloor(floor)
if (allowedSupplierCodes.isEmpty()) {
return emptyList()
}

val args = mutableMapOf<String, Any>(
"deliveryDate" to date.toString(),
"allowedSupplierCodes" to allowedSupplierCodes,
)

// Drive from pending DO by estimatedArrivalDate (not orderDate / pick requiredDeliveryDate).
// STRAIGHT_JOIN keeps DO CTE as driver (avoids full inventory_lot scan).
// Supplier filter matches DoSearch: d.supplier.code IN floor settings (2F/4F/All).
val sql = """
WITH do_lines AS (
SELECT DISTINCT
do.code AS doCode,
DATE(do.estimatedArrivalDate) AS deliveryDate,
s.code AS supplierCode,
s.name AS supplierName,
dol.id AS doLineId,
dol.itemId AS itemId,
dol.itemNo AS itemNo,
dol.qty AS doQty,
dol.uomId AS doUomId,
uc_do.udfudesc AS doUom
FROM delivery_order do
INNER JOIN shop s
ON s.id = do.supplierId
AND s.deleted = 0
AND s.code IN (:allowedSupplierCodes)
INNER JOIN delivery_order_line dol
ON dol.deliveryOrderId = do.id
AND dol.deleted = 0
LEFT JOIN uom_conversion uc_do
ON uc_do.id = dol.uomId
AND uc_do.deleted = 0
WHERE do.deleted = 0
AND do.status = 'pending'
AND do.supplierId IS NOT NULL
AND do.estimatedArrivalDate IS NOT NULL
AND DATE(do.estimatedArrivalDate) = :deliveryDate
)
SELECT
DATE_FORMAT(d.deliveryDate, '%Y-%m-%d') AS deliveryDate,
d.doCode AS doCode,
d.supplierCode AS supplierCode,
d.supplierName AS supplierName,
d.itemNo AS itemCode,
it.name AS itemName,
d.doQty AS doQty,
d.doUom AS doUom,
d.doUomId AS doUomId,
il.lotNo AS mismatchLotNo,
(IFNULL(ill.inQty, 0) - IFNULL(ill.outQty, 0)) AS mismatchLotQty,
uc_ill.udfudesc AS mismatchLotUom,
iu.uomId AS inventoryUomId
FROM do_lines d
STRAIGHT_JOIN inventory_lot il
ON il.itemId = d.itemId
AND il.deleted = 0
STRAIGHT_JOIN inventory_lot_line ill
ON ill.inventoryLotId = il.id
AND ill.deleted = 0
AND (IFNULL(ill.inQty, 0) - IFNULL(ill.outQty, 0)) > 0
STRAIGHT_JOIN item_uom iu
ON iu.id = ill.stockItemUomId
AND iu.deleted = 0
LEFT JOIN uom_conversion uc_ill
ON uc_ill.id = iu.uomId
AND uc_ill.deleted = 0
LEFT JOIN items it
ON it.id = d.itemId
AND it.deleted = 0
WHERE d.doUomId IS NULL
OR iu.uomId IS NULL
OR d.doUomId <> iu.uomId
ORDER BY d.doCode, d.itemNo, il.lotNo
""".trimIndent()

return jdbcDao.queryForList(sql, args)
}
}

+ 25
- 7
src/main/java/com/ffii/fpsms/modules/report/service/ShopOrderReplenishmentReportService.kt Переглянути файл

@@ -15,6 +15,7 @@ import java.math.BigDecimal
class ShopOrderReplenishmentReportService(
private val jdbcDao: JdbcDao,
) {
/** FP-MTMS Version Checklist | Functions Ref. No. 49 | v1.0.1 | 2026-08-10 */
fun searchShopOrderReplenishmentReport(
reorderDateStart: String?,
reorderDateEnd: String?,
@@ -87,6 +88,7 @@ class ShopOrderReplenishmentReportService(
DATE_FORMAT(dr.created, '%Y-%m-%d') AS reorderDate,
dr.reason AS reason,
DATE_FORMAT(dop.requiredDeliveryDate, '%Y-%m-%d') AS deliveredDate,
TRIM(IFNULL(dop.handlerName, '')) AS actualDeliveredHandler,
dr.sourceDoId AS sourceDoId,
dr.itemId AS itemId,
dr.pickOrderLineId AS pickOrderLineId
@@ -130,7 +132,7 @@ class ShopOrderReplenishmentReportService(
val itemIds = baseRows.mapNotNull { longVal(it["itemId"]) }.distinct()

val actualDeliveredByPolId = loadActualDeliveredQtyByPickOrderLineId(pickOrderLineIds)
val firstOrderPickBySourceKey = loadFirstOrderActualPickQty(sourceDoIds, itemIds, pickOrderLineIds)
val firstOrderPickBySourceKey = loadFirstOrderPickInfo(sourceDoIds, itemIds, pickOrderLineIds)

val rows = baseRows.map { row ->
val polId = longVal(row["pickOrderLineId"])
@@ -138,6 +140,7 @@ class ShopOrderReplenishmentReportService(
val itemId = longVal(row["itemId"])
val sourceKey =
if (sourceDoId != null && itemId != null) sourceDoId to itemId else null
val firstOrder = sourceKey?.let { firstOrderPickBySourceKey[it] }

linkedMapOf<String, Any?>(
"shopNo" to row["shopNo"],
@@ -147,11 +150,13 @@ class ShopOrderReplenishmentReportService(
"itemNo" to row["itemNo"],
"itemName" to row["itemName"],
"firstOrderQty" to row["firstOrderQty"],
"firstOrderActualPickQty" to (sourceKey?.let { firstOrderPickBySourceKey[it] } ?: BigDecimal.ZERO),
"firstOrderActualPickQty" to (firstOrder?.qty ?: BigDecimal.ZERO),
"firstOrderPickerHandler" to (firstOrder?.handler ?: ""),
"reorderQty" to row["reorderQty"],
"reorderDate" to row["reorderDate"],
"reason" to row["reason"],
"actualDeliveredQty" to (polId?.let { actualDeliveredByPolId[it] } ?: BigDecimal.ZERO),
"actualDeliveredHandler" to (row["actualDeliveredHandler"] ?: ""),
"deliveredDate" to row["deliveredDate"],
)
}
@@ -185,14 +190,15 @@ class ShopOrderReplenishmentReportService(
}

/**
* Sum stock_out_line.qty for source DO pick order lines of the same item,
* First-order pick qty + handler for source DO + item,
* excluding any POL that is itself a replenishment line (incl. current report POLs).
* Same ticket has at most one handler; MAX() is for GROUP BY only.
*/
private fun loadFirstOrderActualPickQty(
private fun loadFirstOrderPickInfo(
sourceDoIds: List<Long>,
itemIds: List<Long>,
excludePickOrderLineIds: List<Long>,
): Map<Pair<Long, Long>, BigDecimal> {
): Map<Pair<Long, Long>, FirstOrderPickInfo> {
if (sourceDoIds.isEmpty() || itemIds.isEmpty()) return emptyMap()

val args = mutableMapOf<String, Any>(
@@ -210,7 +216,8 @@ class ShopOrderReplenishmentReportService(
SELECT
po.doId AS sourceDoId,
pol.itemId AS itemId,
SUM(IFNULL(sol.qty, 0)) AS firstOrderActualPickQty
SUM(IFNULL(sol.qty, 0)) AS firstOrderActualPickQty,
MAX(TRIM(IFNULL(dop.handlerName, ''))) AS firstOrderPickerHandler
FROM pick_order po
INNER JOIN pick_order_line pol
ON pol.poId = po.id
@@ -218,6 +225,9 @@ class ShopOrderReplenishmentReportService(
LEFT JOIN stock_out_line sol
ON sol.pickOrderLineId = pol.id
AND IFNULL(sol.deleted, 0) = 0
LEFT JOIN delivery_order_pick_order dop
ON dop.id = po.deliveryOrderPickOrderId
AND IFNULL(dop.deleted, 0) = 0
WHERE IFNULL(po.deleted, 0) = 0
AND po.doId IN (:sourceDoIds)
AND pol.itemId IN (:itemIds)
@@ -236,10 +246,18 @@ class ShopOrderReplenishmentReportService(
return rows.mapNotNull { row ->
val sourceDoId = longVal(row["sourceDoId"]) ?: return@mapNotNull null
val itemId = longVal(row["itemId"]) ?: return@mapNotNull null
(sourceDoId to itemId) to decimalVal(row["firstOrderActualPickQty"])
(sourceDoId to itemId) to FirstOrderPickInfo(
qty = decimalVal(row["firstOrderActualPickQty"]),
handler = row["firstOrderPickerHandler"]?.toString()?.trim().orEmpty(),
)
}.toMap()
}

private data class FirstOrderPickInfo(
val qty: BigDecimal,
val handler: String,
)

private fun normalizeDate(raw: String): String = raw.trim().replace("/", "-")

private fun dateStartClause(


+ 116
- 0
src/main/java/com/ffii/fpsms/modules/report/web/DoInventoryUomMismatchReportController.kt Переглянути файл

@@ -0,0 +1,116 @@
package com.ffii.fpsms.modules.report.web

import com.ffii.fpsms.modules.report.service.DoInventoryUomMismatchReportService
import org.apache.poi.ss.usermodel.BorderStyle
import org.apache.poi.ss.usermodel.FillPatternType
import org.apache.poi.ss.usermodel.HorizontalAlignment
import org.apache.poi.ss.usermodel.IndexedColors
import org.apache.poi.ss.usermodel.VerticalAlignment
import org.apache.poi.ss.util.WorkbookUtil
import org.apache.poi.xssf.usermodel.XSSFWorkbook
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import java.io.ByteArrayOutputStream

@RestController
@RequestMapping("/report")
class DoInventoryUomMismatchReportController(
private val doInventoryUomMismatchReportService: DoInventoryUomMismatchReportService,
) {
/** FP-MTMS Version Checklist | Functions Ref. No. 60 | v1.0.0 | 2026-08-10 */
@GetMapping("/print-do-inventory-uom-mismatch-excel")
fun exportExcel(
@RequestParam(required = false) deliveryDate: String?,
@RequestParam(required = false) storeId: String?,
): ResponseEntity<ByteArray> {
val rows = try {
doInventoryUomMismatchReportService.searchDoInventoryUomMismatch(deliveryDate, storeId)
} catch (ex: IllegalArgumentException) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build()
}

val bytes = buildExcel(rows)
val headers = HttpHeaders().apply {
contentType = MediaType.parseMediaType(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
)
// ASCII only — Tomcat rejects non-Latin-1 in Content-Disposition
setContentDispositionFormData("attachment", "DoInventoryUomMismatchReport.xlsx")
}
return ResponseEntity(bytes, headers, HttpStatus.OK)
}

private fun buildExcel(rows: List<Map<String, Any?>>): ByteArray {
val workbook = XSSFWorkbook()
val sheet = workbook.createSheet(WorkbookUtil.createSafeSheetName("DO_UOM_mismatch"))
val headerStyle = workbook.createCellStyle().apply {
alignment = HorizontalAlignment.CENTER
verticalAlignment = VerticalAlignment.CENTER
fillForegroundColor = IndexedColors.GREY_25_PERCENT.index
fillPattern = FillPatternType.SOLID_FOREGROUND
borderTop = BorderStyle.THIN
borderBottom = BorderStyle.THIN
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
val font = workbook.createFont().apply { bold = true }
setFont(font)
}
val textStyle = workbook.createCellStyle().apply {
borderTop = BorderStyle.THIN
borderBottom = BorderStyle.THIN
borderLeft = BorderStyle.THIN
borderRight = BorderStyle.THIN
}

val columns = listOf(
"deliveryDate" to "預計到貨日期",
"doCode" to "送貨單號",
"supplierCode" to "供應商編號",
"supplierName" to "供應商名稱",
"itemCode" to "貨品編號",
"itemName" to "貨品名稱",
"doQty" to "送貨單數量",
"doUom" to "送貨單單位",
"mismatchLotNo" to "不符批號",
"mismatchLotQty" to "不符批數量",
"mismatchLotUom" to "不符批單位",
)

var r = 0
val headerRow = sheet.createRow(r++)
columns.forEachIndexed { i, (_, label) ->
headerRow.createCell(i).apply {
setCellValue(label)
cellStyle = headerStyle
}
}

for (row in rows) {
val excelRow = sheet.createRow(r++)
columns.forEachIndexed { i, (key, _) ->
val v = row[key]
val cell = excelRow.createCell(i)
cell.cellStyle = textStyle
when (v) {
null -> cell.setCellValue("")
is Number -> cell.setCellValue(v.toDouble())
else -> cell.setCellValue(v.toString())
}
}
}

val widths = intArrayOf(14, 16, 12, 18, 14, 28, 12, 14, 18, 12, 14)
widths.forEachIndexed { i, w -> sheet.setColumnWidth(i, w * 256) }

val out = ByteArrayOutputStream()
workbook.write(out)
workbook.close()
return out.toByteArray()
}
}

+ 4
- 0
src/main/java/com/ffii/fpsms/modules/user/entity/UserRepository.java Переглянути файл

@@ -13,6 +13,8 @@ import com.ffii.fpsms.modules.user.entity.projections.UserCombo;
public interface UserRepository extends AbstractRepository<User, Long> {

List<User> findByName(@Param("name") String name);

List<User> findByNameAndDeletedFalse(String name);
Optional<User> findByUsernameAndDeletedFalse(String username);

@@ -20,6 +22,8 @@ public interface UserRepository extends AbstractRepository<User, Long> {

Optional<User> findByStaffNo(@Param("staffNo") String staffNo);

Optional<User> findByStaffNoAndDeletedFalse(String staffNo);

@Modifying
@Query(value = """
INSERT INTO user_authority (userID, authId)


+ 23
- 0
src/main/java/com/ffii/fpsms/modules/user/service/UserService.java Переглянути файл

@@ -92,6 +92,22 @@ public class UserService extends AbstractBaseEntityService<User, Long, UserRepos
return userRepository.findByUsernameAndDeletedFalse(username);
}

/** FP-MTMS Version Checklist | Functions Ref. No. 62 | v1.0.0 | 2026-08-10 */
public boolean isNameTaken(String name) {
if (StringUtils.isBlank(name)) {
return false;
}
return !userRepository.findByNameAndDeletedFalse(name).isEmpty();
}

/** FP-MTMS Version Checklist | Functions Ref. No. 62 | v1.0.0 | 2026-08-10 */
public boolean isStaffNoTaken(String staffNo) {
if (StringUtils.isBlank(staffNo)) {
return false;
}
return userRepository.findByStaffNoAndDeletedFalse(staffNo).isPresent();
}

// @Transactional(rollbackFor = Exception.class)
public List<UserRecord> search(SearchUserReq req) {
StringBuilder sql = new StringBuilder("SELECT"
@@ -199,11 +215,18 @@ public class UserService extends AbstractBaseEntityService<User, Long, UserRepos
return instance;
}

/** FP-MTMS Version Checklist | Functions Ref. No. 62 | v1.0.0 | 2026-08-10 */
@Transactional(rollbackFor = Exception.class)
public User newRecord(NewUserReq req) throws UnsupportedEncodingException {
if (findByUsername(req.getUsername()).isPresent()) {
throw new UnprocessableEntityException(ErrorCodes.USERNAME_NOT_AVAILABLE);
}
if (isNameTaken(req.getName())) {
throw new UnprocessableEntityException(ErrorCodes.NAME_NOT_AVAILABLE);
}
if (isStaffNoTaken(req.getStaffNo())) {
throw new UnprocessableEntityException(ErrorCodes.STAFF_NO_NOT_AVAILABLE);
}

System.out.println("Start Save");



Завантаження…
Відмінити
Зберегти