Skip to content

Commit fa4b3c1

Browse files
fix: gate shortage stock updates behind manager decision
Ensure shortage receipts no longer update stock immediately, add a manager rejection path for shortage handling, and make rejected-shortage purchase orders view-only for inventory staff to prevent further actions.
1 parent 112a2c8 commit fa4b3c1

File tree

9 files changed

+228
-20
lines changed

9 files changed

+228
-20
lines changed

backend/src/main/java/com/smalltrend/controller/inventory/purchase/PurchaseOrderController.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,12 @@ public ResponseEntity<PurchaseOrderResponse> requestSupplierSupplement(@PathVari
107107
return ResponseEntity.ok(purchaseOrderService.requestSupplierSupplement(id, note));
108108
}
109109

110+
@PutMapping("/purchase-orders/{id}/shortage/reject")
111+
@PreAuthorize("hasAnyAuthority('ADMIN', 'MANAGER', 'ROLE_ADMIN', 'ROLE_MANAGER')")
112+
public ResponseEntity<PurchaseOrderResponse> rejectShortage(@PathVariable("id") Integer id, @RequestBody Map<String, String> payload) {
113+
return ResponseEntity.ok(purchaseOrderService.rejectShortage(id, payload.get("rejectionReason")));
114+
}
115+
110116
// ─── Cancel & Delete ─────────────────────────────────────
111117

112118
@PutMapping("/purchase-orders/{id}/cancel")

backend/src/main/java/com/smalltrend/service/inventory/purchase/PurchaseOrderService.java

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -382,10 +382,6 @@ public PurchaseOrderResponse receiveGoods(Integer orderId, GoodsReceiptRequest r
382382
order.setNotes(receiptRequest.getNotes());
383383
}
384384

385-
if (!stockItemRequests.isEmpty()) {
386-
updateStock(order, stockItemRequests, false);
387-
}
388-
389385
List<SyncedPurchasePriceItemResponse> syncedPurchasePriceItems = syncPurchasePrices(order);
390386
int syncedPurchasePriceCount = syncedPurchasePriceItems.size();
391387
LocalDateTime syncedPurchasePriceAt = syncedPurchasePriceCount > 0 ? LocalDateTime.now() : null;
@@ -402,14 +398,16 @@ public PurchaseOrderResponse receiveGoods(Integer orderId, GoodsReceiptRequest r
402398
order.setStatus(PurchaseOrderStatus.SHORTAGE_PENDING_APPROVAL);
403399
notifyManagersOnShortage(order);
404400
} else {
401+
if (!stockItemRequests.isEmpty()) {
402+
updateStock(order, stockItemRequests, false);
403+
}
405404
order.setStatus(PurchaseOrderStatus.RECEIVED);
406405
order.setShortageReason(null);
407406
order.setShortageSubmittedAt(null);
408407
order.setManagerDecision(null);
409408
order.setManagerDecisionNote(null);
410409
order.setManagerDecidedAt(null);
411410
}
412-
413411
purchaseOrderRepository.save(order);
414412

415413
log.info("Purchase Order {} receive processed. Status={}, stock delta updated.",
@@ -432,6 +430,26 @@ public PurchaseOrderResponse closeShortage(Integer orderId, String managerDecisi
432430
throw new RuntimeException("Chỉ có thể chốt thiếu khi phiếu đang chờ quản lý xử lý thiếu hàng.");
433431
}
434432

433+
List<PurchaseOrderItemRequest> stockItemRequests = (order.getItems() == null ? List.<PurchaseOrderItemRequest>of() : order.getItems().stream()
434+
.filter(Objects::nonNull)
435+
.map(item -> PurchaseOrderItemRequest.builder()
436+
.variantId(item.getVariant() != null ? item.getVariant().getId().intValue() : null)
437+
.productId(item.getVariant() != null && item.getVariant().getProduct() != null
438+
? item.getVariant().getProduct().getId().intValue() : null)
439+
.quantity(item.getReceivedQuantity() != null ? item.getReceivedQuantity() : 0)
440+
.unitCost(item.getUnitCost())
441+
.totalCost(item.getTotalCost())
442+
.expiryDate(item.getExpiryDate())
443+
.build())
444+
.filter(req -> req.getQuantity() != null && req.getQuantity() > 0)
445+
.toList());
446+
447+
if (stockItemRequests.isEmpty()) {
448+
throw new RuntimeException("Không có số lượng thực nhận để chốt thiếu.");
449+
}
450+
451+
updateStock(order, stockItemRequests, false);
452+
435453
order.setManagerDecision("CLOSE_SHORTAGE");
436454
order.setManagerDecisionNote(managerDecisionNote);
437455
order.setManagerDecidedAt(LocalDateTime.now());
@@ -469,6 +487,30 @@ public PurchaseOrderResponse requestSupplierSupplement(Integer orderId, String m
469487
return toDetailResponse(order);
470488
}
471489

490+
@Transactional
491+
public PurchaseOrderResponse rejectShortage(Integer orderId, String reason) {
492+
PurchaseOrder order = purchaseOrderRepository.findById(orderId)
493+
.orElseThrow(() -> new RuntimeException("Không tìm thấy phiếu nhập với ID: " + orderId));
494+
495+
if (order.getStatus() != PurchaseOrderStatus.SHORTAGE_PENDING_APPROVAL) {
496+
throw new RuntimeException("Chỉ có thể từ chối nhập hàng khi phiếu đang chờ quản lý xử lý thiếu hàng.");
497+
}
498+
499+
if (reason == null || reason.isBlank()) {
500+
throw new RuntimeException("Lý do từ chối nhập hàng là bắt buộc.");
501+
}
502+
503+
order.setManagerDecision("REJECT_SHORTAGE");
504+
order.setManagerDecisionNote(reason.trim());
505+
order.setManagerDecidedAt(LocalDateTime.now());
506+
order.setStatus(PurchaseOrderStatus.REJECTED);
507+
order.setRejectionReason(reason.trim());
508+
purchaseOrderRepository.save(order);
509+
510+
log.info("Purchase Order {} shortage rejected by manager. Reason: {}", order.getOrderNumber(), reason);
511+
return toDetailResponse(order);
512+
}
513+
472514
// ─── Confirm Existing Draft ──────────────────────────────
473515
@Transactional
474516
public PurchaseOrderResponse confirmExistingOrder(Integer orderId) {

backend/src/test/java/com/smalltrend/controller/inventory/purchaseorder/PurchaseOrderControllerTest.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,18 @@ void requestSupplierSupplement_shouldPassNullWhenPayloadIsNull() {
232232
verify(purchaseOrderService).requestSupplierSupplement(1, null);
233233
}
234234

235+
@Test
236+
void rejectShortage_shouldPassReasonWhenPayloadProvided() {
237+
PurchaseOrderResponse expected = new PurchaseOrderResponse();
238+
when(purchaseOrderService.rejectShortage(1, "Từ chối nhập hàng thiếu")).thenReturn(expected);
239+
240+
ResponseEntity<PurchaseOrderResponse> response = controller.rejectShortage(1, Map.of("rejectionReason", "Từ chối nhập hàng thiếu"));
241+
242+
assertEquals(HttpStatus.OK, response.getStatusCode());
243+
assertEquals(expected, response.getBody());
244+
verify(purchaseOrderService).rejectShortage(1, "Từ chối nhập hàng thiếu");
245+
}
246+
235247
@Test
236248
void cancelOrder_shouldReturnOk() {
237249
PurchaseOrderResponse expected = new PurchaseOrderResponse();

backend/src/test/java/com/smalltrend/service/inventory/purchaseorder/PurchaseOrderServiceTest.java

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,8 @@ void receiveGoods_shouldAutoNotifyManagers_whenShortageDetected() {
374374

375375
assertEquals("SHORTAGE_PENDING_APPROVAL", response.getStatus());
376376
verify(inventoryManagerNotificationService, timeout(1000).times(1)).notifyManagers(any(), any());
377+
verify(inventoryStockRepository, never()).save(any());
378+
verify(stockMovementRepository, never()).save(any());
377379
}
378380

379381
@Test
@@ -463,7 +465,7 @@ void updateStock_shouldHandleExistingStock() {
463465
}
464466

465467
@Test
466-
void updateStock_shouldHandleUnitConversion() {
468+
void receiveGoods_shouldNotUpdateStockImmediately_whenUnitConversionOrderHasShortage() {
467469
Unit fromUnit = Unit.builder().id(2).name("Box").build();
468470
ProductVariant otherVariant = ProductVariant.builder().id(2).unit(fromUnit).product(product).build();
469471

@@ -489,9 +491,11 @@ void updateStock_shouldHandleUnitConversion() {
489491
.build()
490492
));
491493

492-
purchaseOrderService.receiveGoods(1, req);
493-
verify(inventoryStockRepository).save(stockCaptor.capture());
494-
assertEquals(5, stockCaptor.getValue().getQuantity());
494+
PurchaseOrderResponse response = purchaseOrderService.receiveGoods(1, req);
495+
496+
assertEquals("SHORTAGE_PENDING_APPROVAL", response.getStatus());
497+
verify(inventoryStockRepository, never()).save(any());
498+
verify(stockMovementRepository, never()).save(any());
495499
}
496500

497501
@Test
@@ -511,6 +515,49 @@ void rejectOrder_shouldWork() {
511515
assertEquals("REJECTED", purchaseOrderService.rejectOrder(1, "reason").getStatus());
512516
}
513517

518+
@Test
519+
void closeShortage_shouldUpdateStockAndSetReceived() {
520+
order.setStatus(PurchaseOrderStatus.SHORTAGE_PENDING_APPROVAL);
521+
order.setItems(new ArrayList<>());
522+
order.getItems().add(PurchaseOrderItem.builder()
523+
.id(1)
524+
.variant(variant)
525+
.quantity(10)
526+
.receivedQuantity(7)
527+
.unitCost(BigDecimal.ONE)
528+
.totalCost(new BigDecimal("7"))
529+
.purchaseOrder(order)
530+
.build());
531+
532+
when(purchaseOrderRepository.findById(1)).thenReturn(Optional.of(order));
533+
534+
PurchaseOrderResponse response = purchaseOrderService.closeShortage(1, "close note");
535+
536+
assertEquals("RECEIVED", response.getStatus());
537+
verify(inventoryStockRepository, atLeastOnce()).save(any());
538+
verify(stockMovementRepository, atLeastOnce()).save(any());
539+
}
540+
541+
@Test
542+
void rejectShortage_shouldRequireReason() {
543+
order.setStatus(PurchaseOrderStatus.SHORTAGE_PENDING_APPROVAL);
544+
when(purchaseOrderRepository.findById(1)).thenReturn(Optional.of(order));
545+
546+
assertThrows(RuntimeException.class, () -> purchaseOrderService.rejectShortage(1, " "));
547+
}
548+
549+
@Test
550+
void rejectShortage_shouldSetRejectedWithoutStockUpdate() {
551+
order.setStatus(PurchaseOrderStatus.SHORTAGE_PENDING_APPROVAL);
552+
when(purchaseOrderRepository.findById(1)).thenReturn(Optional.of(order));
553+
554+
PurchaseOrderResponse response = purchaseOrderService.rejectShortage(1, "missing too much");
555+
556+
assertEquals("REJECTED", response.getStatus());
557+
verify(inventoryStockRepository, never()).save(any());
558+
verify(stockMovementRepository, never()).save(any());
559+
}
560+
514561
@Test
515562
void cancelOrder_shouldWorkOnlyDraft() {
516563
order.setStatus(PurchaseOrderStatus.DRAFT);

frontend/src/components/inventory/purchase/ActionButtons.jsx

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export default function ActionButtons({
2525
onReceiveGoods,
2626
onCloseShortage,
2727
onRequestSupplement,
28+
onRejectShortage,
29+
forceViewOnly = false,
2830
layout = "panel",
2931
footerHint = "",
3032
}) {
@@ -36,9 +38,9 @@ export default function ActionButtons({
3638
const isInventoryStaff = ["INVENTORY_STAFF", "ROLE_INVENTORY_STAFF"].includes(
3739
userRole,
3840
);
39-
const canCheckAndReceive = isAdmin || isInventoryStaff;
40-
const canStartChecking = isInventoryStaff;
41-
const canCreatePurchaseRequest = isAdmin || isInventoryStaff;
41+
const canCheckAndReceive = !forceViewOnly && (isAdmin || isInventoryStaff);
42+
const canStartChecking = !forceViewOnly && isInventoryStaff;
43+
const canCreatePurchaseRequest = !forceViewOnly && (isAdmin || isInventoryStaff);
4244

4345
const isDraft = status === PO_STATUS.DRAFT || status === PO_STATUS.REJECTED;
4446
const isPending = status === PO_STATUS.PENDING;
@@ -144,6 +146,14 @@ export default function ActionButtons({
144146
if (isManagerActionableShortage) {
145147
return (
146148
<>
149+
<button
150+
onClick={onRejectShortage}
151+
disabled={saving}
152+
className="inline-flex items-center justify-center gap-2 px-4 py-2 text-xs font-semibold text-red-600 bg-white border border-red-200 rounded-xl hover:bg-red-50 disabled:opacity-50 disabled:cursor-not-allowed transition"
153+
>
154+
<Eye size={16} />
155+
Từ chối nhập hàng
156+
</button>
147157
<button
148158
onClick={onRequestSupplement}
149159
disabled={saving}

frontend/src/components/inventory/purchase/SummaryPanel.jsx

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,19 @@ export default function SummaryPanel({
3030
order.status === PO_STATUS.SHORTAGE_PENDING_APPROVAL;
3131
const isSupplierSupplementPending =
3232
order.status === PO_STATUS.SUPPLIER_SUPPLEMENT_PENDING;
33+
const isRejected = order.status === PO_STATUS.REJECTED;
3334
const showMetaInfo =
3435
allowMetaEdit ||
3536
isReceived ||
3637
isShortagePendingApproval ||
37-
isSupplierSupplementPending;
38+
isSupplierSupplementPending ||
39+
isRejected;
3840
const showPaymentInfo =
39-
isChecking || isReceived || isShortagePendingApproval || isSupplierSupplementPending;
41+
isChecking ||
42+
isReceived ||
43+
isShortagePendingApproval ||
44+
isSupplierSupplementPending ||
45+
isRejected;
4046
const shortageReason =
4147
order.shortage_reason || order.shortageReason || "";
4248
const managerDecisionNote =
@@ -45,7 +51,7 @@ export default function SummaryPanel({
4551
(isShortagePendingApproval || isSupplierSupplementPending) &&
4652
String(shortageReason).trim() !== "";
4753
const showManagerDecisionNote =
48-
(isSupplierSupplementPending || isReceived) &&
54+
(isSupplierSupplementPending || isReceived || isRejected) &&
4955
String(managerDecisionNote).trim() !== "";
5056

5157
const selectedSupplier = suppliers.find(

frontend/src/hooks/inventory/purchase/usePurchaseOrder.js

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
rejectPurchaseOrder,
1515
closeShortageOrder,
1616
requestSupplierSupplementOrder,
17+
rejectShortageOrder,
1718
} from "../../../services/inventory/inventoryService";
1819
import {
1920
PO_STATUS,
@@ -876,7 +877,7 @@ export function usePurchaseOrder(initialId = null) {
876877
}
877878

878879
if (response?.status === PO_STATUS.SHORTAGE_PENDING_APPROVAL) {
879-
toast.success("Đã nhập kho phần hàng nhận được và chuyển sang chờ quản lý xử lý thiếu.");
880+
toast.success("Đã ghi nhận kiểm kê thiếu và chuyển phiếu sang chờ quản lý xử lý.");
880881
} else {
881882
toast.success("Đã xác nhận nhập kho và cập nhật tồn kho thành công!");
882883
}
@@ -1010,6 +1011,48 @@ export function usePurchaseOrder(initialId = null) {
10101011
[initialId, order.status],
10111012
);
10121013

1014+
const rejectShortage = useCallback(async (rejectionReason) => {
1015+
if (!initialId) return false;
1016+
if (order.status !== PO_STATUS.SHORTAGE_PENDING_APPROVAL) {
1017+
toast.warning("Chỉ có thể từ chối khi phiếu đang chờ quản lý xử lý thiếu hàng.");
1018+
return false;
1019+
}
1020+
if (!rejectionReason || rejectionReason.trim() === "") {
1021+
toast.warning("Bạn phải nhập lý do từ chối nhập hàng.");
1022+
return false;
1023+
}
1024+
1025+
setSaving(true);
1026+
try {
1027+
const response = await rejectShortageOrder(initialId, rejectionReason.trim());
1028+
if (response) {
1029+
const { mappedOrder, mappedItems, mappedReceiptItems } =
1030+
mapOrderStateFromResponse(response, products);
1031+
setOrder(mappedOrder);
1032+
setItems(mappedItems);
1033+
setReceiptItems(mappedReceiptItems);
1034+
}
1035+
toast.success("Đã từ chối nhập hàng thiếu và chuyển phiếu về trạng thái từ chối.");
1036+
return true;
1037+
} catch (err) {
1038+
console.error("Reject shortage error", err);
1039+
toast.error("Lỗi khi từ chối nhập hàng thiếu: " + err.message);
1040+
return false;
1041+
} finally {
1042+
setSaving(false);
1043+
}
1044+
}, [initialId, order.status, products]);
1045+
1046+
const rejectOrderUnified = useCallback(
1047+
async (navigate, rejectionReason) => {
1048+
if (order.status === PO_STATUS.SHORTAGE_PENDING_APPROVAL) {
1049+
return rejectShortage(rejectionReason);
1050+
}
1051+
return rejectOrder(navigate, rejectionReason);
1052+
},
1053+
[order.status, rejectOrder, rejectShortage],
1054+
);
1055+
10131056
const deleteOrder = useCallback(
10141057
async (navigate) => {
10151058
if (!initialId) return false;
@@ -1070,7 +1113,7 @@ export function usePurchaseOrder(initialId = null) {
10701113
receiveGoods,
10711114
closeShortage,
10721115
requestSupplierSupplement,
1073-
rejectOrder,
1116+
rejectOrder: rejectOrderUnified,
10741117
deleteOrder,
10751118
};
10761119
}

0 commit comments

Comments
 (0)