diff --git a/src/main/java/com/goi/erp/controller/TankController.java b/src/main/java/com/goi/erp/controller/TankController.java new file mode 100644 index 0000000..1fffb7e --- /dev/null +++ b/src/main/java/com/goi/erp/controller/TankController.java @@ -0,0 +1,135 @@ +package com.goi.erp.controller; + +import com.goi.erp.common.permission.PermissionChecker; +import com.goi.erp.common.permission.PermissionSet; +import com.goi.erp.dto.TankRequestDto; +import com.goi.erp.dto.TankResponseDto; +import com.goi.erp.service.TankService; +import com.goi.erp.token.PermissionAuthenticationToken; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/tank") +@RequiredArgsConstructor +public class TankController { + + private final TankService tankService; + + /* ============================================================ + CREATE + ============================================================ */ + @PostMapping + public ResponseEntity createTank( + @RequestBody TankRequestDto requestDto + ) { + PermissionAuthenticationToken auth = requireAuth(); + + PermissionSet permissionSet = auth.getPermissionSet(); + if (!PermissionChecker.canCreateOPR(permissionSet)) { + throw new AccessDeniedException("You do not have permission to create tank"); + } + + String actor = auth.getName(); // 네 token 구현에 맞게 바꿔도 됨 (ex: getUsername()) + TankResponseDto response = tankService.createTank(requestDto, actor); + + return new ResponseEntity<>(response, HttpStatus.CREATED); + } + + /* ============================================================ + READ ALL (no page) + ============================================================ */ + @GetMapping + public ResponseEntity> getAllTanks() { + PermissionAuthenticationToken auth = requireAuth(); + + PermissionSet permissionSet = auth.getPermissionSet(); + if (!PermissionChecker.canReadOPRAll(permissionSet)) { + throw new AccessDeniedException("You do not have permission to read tank data"); + } + + return ResponseEntity.ok( + tankService.getAllTanks() + ); + } + + /* ============================================================ + READ ONE (UUID) + ============================================================ */ + @GetMapping("/uuid/{uuid}") + public ResponseEntity getTankByUuid( + @PathVariable UUID uuid + ) { + PermissionAuthenticationToken auth = requireAuth(); + + PermissionSet permissionSet = auth.getPermissionSet(); + if (!PermissionChecker.canReadOPR(permissionSet)) { + throw new AccessDeniedException("You do not have permission to read tank data"); + } + + return ResponseEntity.ok( + tankService.getTank(uuid) + ); + } + + /* ============================================================ + UPDATE (UUID) + ============================================================ */ + @PatchMapping("/uuid/{uuid}") + public ResponseEntity updateTankByUuid( + @PathVariable UUID uuid, + @RequestBody TankRequestDto requestDto + ) { + PermissionAuthenticationToken auth = requireAuth(); + + PermissionSet permissionSet = auth.getPermissionSet(); + if (!PermissionChecker.canUpdateOPR(permissionSet)) { + throw new AccessDeniedException("You do not have permission to update tank"); + } + + String actor = auth.getName(); + return ResponseEntity.ok( + tankService.updateTank(uuid, requestDto, actor) + ); + } + + /* ============================================================ + DEACTIVATE (UUID) + ============================================================ */ + @DeleteMapping("/uuid/{uuid}") + public ResponseEntity deactivateTank( + @PathVariable UUID uuid + ) { + PermissionAuthenticationToken auth = requireAuth(); + + PermissionSet permissionSet = auth.getPermissionSet(); + if (!PermissionChecker.canUpdateOPR(permissionSet)) { + throw new AccessDeniedException("You do not have permission to deactivate tank"); + } + + String actor = auth.getName(); + tankService.deactivateTank(uuid, actor); + + return ResponseEntity.noContent().build(); + } + + /* ============================================================ + AUTH HELPER + ============================================================ */ + private PermissionAuthenticationToken requireAuth() { + PermissionAuthenticationToken auth = + (PermissionAuthenticationToken) SecurityContextHolder.getContext().getAuthentication(); + + if (auth == null || auth.getPermissionSet() == null) { + throw new AccessDeniedException("Permission information is missing"); + } + return auth; + } +} diff --git a/src/main/java/com/goi/erp/controller/TankInventoryCompositionController.java b/src/main/java/com/goi/erp/controller/TankInventoryCompositionController.java new file mode 100644 index 0000000..7daa6d4 --- /dev/null +++ b/src/main/java/com/goi/erp/controller/TankInventoryCompositionController.java @@ -0,0 +1,106 @@ +package com.goi.erp.controller; + +import com.goi.erp.entity.Tank; +import com.goi.erp.entity.TankInventoryComposition; +import com.goi.erp.repository.TankInventoryCompositionRepository; +import com.goi.erp.repository.TankRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/tank/inventory/composition") +public class TankInventoryCompositionController { + + private final TankRepository tankRepository; + private final TankInventoryCompositionRepository compositionRepository; + + // ------------------------- + // READ (no pagination) + // ------------------------- + + /** + * 특정 탱크의 OIL composition 조회 (HT/DT) + * 예: /api/tank-inventory-compositions/by-tank?tnkUuid=... + */ + @GetMapping("/by-tank") + public ResponseEntity> getByTank( + @RequestParam UUID tnkUuid, + Authentication auth + ) { + requireAdminOrManager(auth); + + Tank tank = tankRepository.findByTnkUuid(tnkUuid) + .orElseThrow(() -> new IllegalArgumentException("Tank not found: " + tnkUuid)); + + List rows = + compositionRepository.findByTicTankIdAndTicMaterialKindOrderByTicOriginTypeAsc( + tank.getTnkId(), + "OIL" + ); + + return ResponseEntity.ok(rows); + } + + // ------------------------- + // UPDATE (manual adjust) + // ------------------------- + + /** + * 수동 보정(관리자/매니저) + * - ticQty를 원하는 값으로 세팅 + * - updated_by 자동 반영 + * + * 예: PATCH /api/tank-inventory-compositions/{ticId}/qty?qty=12.5 + */ + @PatchMapping("/{ticId}/qty") + public ResponseEntity updateQty( + @PathVariable Long ticId, + @RequestParam java.math.BigDecimal qty, + Authentication auth + ) { + requireAdminOrManager(auth); + + TankInventoryComposition comp = compositionRepository.findById(ticId) + .orElseThrow(() -> new IllegalArgumentException("Composition not found: " + ticId)); + + if (qty == null || qty.compareTo(java.math.BigDecimal.ZERO) < 0) { + throw new IllegalArgumentException("qty must be >= 0"); + } + + comp.setTicQty(qty); + comp.setTicUpdatedAt(LocalDateTime.now()); + comp.setTicUpdatedBy(actor(auth)); + + TankInventoryComposition saved = compositionRepository.save(comp); + return ResponseEntity.ok(saved); + } + + // ------------------------- + // auth utils + // ------------------------- + + private void requireAdminOrManager(Authentication auth) { + if (auth == null || auth.getAuthorities() == null) { + throw new SecurityException("Unauthenticated"); + } + + boolean ok = auth.getAuthorities().stream().anyMatch(a -> + "ROLE_ADMIN".equals(a.getAuthority()) || "ROLE_MANAGER".equals(a.getAuthority()) + ); + + if (!ok) { + throw new SecurityException("Forbidden"); + } + } + + private String actor(Authentication auth) { + return auth != null ? auth.getName() : "SYSTEM"; + } +} diff --git a/src/main/java/com/goi/erp/controller/TankInventoryController.java b/src/main/java/com/goi/erp/controller/TankInventoryController.java new file mode 100644 index 0000000..2f3276f --- /dev/null +++ b/src/main/java/com/goi/erp/controller/TankInventoryController.java @@ -0,0 +1,69 @@ +package com.goi.erp.controller; + +import com.goi.erp.entity.TankInventory; +import com.goi.erp.service.TankInventoryService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.math.BigDecimal; +import java.security.Principal; +import java.util.UUID; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/tank/inventory") +public class TankInventoryController { + + private final TankInventoryService tankInventoryService; + + /** + * 단일 탱크 inventory 조회 + */ + @GetMapping("/{tankUuid}") + @PreAuthorize("hasAuthority('TANK_READ')") + public TankInventory getInventory(@PathVariable UUID tankUuid) { + return tankInventoryService.findByTankUuid(tankUuid) + .orElseThrow(() -> + new IllegalStateException("Inventory not found. tankUuid=" + tankUuid) + ); + } + + /** + * inventory upsert (절대값) + */ + @PostMapping("/{tankUuid}") + @PreAuthorize("hasAuthority('TANK_WRITE')") + public TankInventory upsertInventory( + @PathVariable UUID tankUuid, + @RequestParam String materialKind, + @RequestParam BigDecimal qtyTotal, + Principal principal + ) { + return tankInventoryService.upsertInventory( + tankUuid, + materialKind, + qtyTotal, + principal.getName() // ✅ updatedBy + ); + } + + /** + * inventory delta 적용 + */ + @PostMapping("/{tankUuid}/delta") + @PreAuthorize("hasAuthority('TANK_WRITE')") + public TankInventory applyDelta( + @PathVariable UUID tankUuid, + @RequestParam String materialKind, + @RequestParam BigDecimal deltaQty, + Principal principal + ) { + return tankInventoryService.applyDelta( + tankUuid, + materialKind, + deltaQty, + principal.getName() // ✅ updatedBy + ); + } +} diff --git a/src/main/java/com/goi/erp/dto/TankInventoryCompositionRequestDto.java b/src/main/java/com/goi/erp/dto/TankInventoryCompositionRequestDto.java new file mode 100644 index 0000000..b303c01 --- /dev/null +++ b/src/main/java/com/goi/erp/dto/TankInventoryCompositionRequestDto.java @@ -0,0 +1,28 @@ +package com.goi.erp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TankInventoryCompositionRequestDto { + + private UUID ticUuid; + + private UUID tnkUuid; // tnk_id 대용 + + private String ticMaterialKind; // OIL / WATER / SLUDGE + private String ticOriginType; // HT / DT (OIL일 때만) + + private BigDecimal ticQty; // 현재 잔량 + + private LocalDateTime ticLastInAt; // 마지막 유입 시각 +} diff --git a/src/main/java/com/goi/erp/dto/TankInventoryCompositionResponseDto.java b/src/main/java/com/goi/erp/dto/TankInventoryCompositionResponseDto.java new file mode 100644 index 0000000..6aefa15 --- /dev/null +++ b/src/main/java/com/goi/erp/dto/TankInventoryCompositionResponseDto.java @@ -0,0 +1,34 @@ +package com.goi.erp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TankInventoryCompositionResponseDto { + + private UUID ticUuid; + + private UUID tnkUuid; + private String tnkCode; + private String tnkName; + + private String ticMaterialKind; // OIL / WATER / SLUDGE + private String ticOriginType; // HT / DT + + private BigDecimal ticQty; + private LocalDateTime ticLastInAt; + + private LocalDateTime ticCreatedAt; + private String ticCreatedBy; + private LocalDateTime ticUpdatedAt; + private String ticUpdatedBy; +} diff --git a/src/main/java/com/goi/erp/dto/TankInventoryRequestDto.java b/src/main/java/com/goi/erp/dto/TankInventoryRequestDto.java new file mode 100644 index 0000000..1019413 --- /dev/null +++ b/src/main/java/com/goi/erp/dto/TankInventoryRequestDto.java @@ -0,0 +1,26 @@ +package com.goi.erp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TankInventoryRequestDto { + + private UUID tniUuid; + + private UUID tnkUuid; // tnk_id 대용 + private String tniMaterialKind; // OIL / WATER / SLUDGE / MIXED + + private BigDecimal tniQtyTotal; // 현재 총량 + + private LocalDateTime tniLastUpdatedAt; +} diff --git a/src/main/java/com/goi/erp/dto/TankInventoryResponseDto.java b/src/main/java/com/goi/erp/dto/TankInventoryResponseDto.java new file mode 100644 index 0000000..b8a4619 --- /dev/null +++ b/src/main/java/com/goi/erp/dto/TankInventoryResponseDto.java @@ -0,0 +1,36 @@ +package com.goi.erp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TankInventoryResponseDto { + + /* tank */ + private UUID tnkUuid; + private String tnkCode; + private String tnkName; + + private UUID tniUuid; + private String tniMaterialKind; // OIL / WATER / SLUDGE / MIXED + + private BigDecimal tniQtyTotal; + private BigDecimal tniSpaceLeft; + private BigDecimal tniStatusPercent; + + private LocalDateTime tniLastUpdatedAt; + + private LocalDateTime tniCreatedAt; + private String tniCreatedBy; + private LocalDateTime tniUpdatedAt; + private String tniUpdatedBy; +} diff --git a/src/main/java/com/goi/erp/dto/TankInventorySnapshotRequestDto.java b/src/main/java/com/goi/erp/dto/TankInventorySnapshotRequestDto.java new file mode 100644 index 0000000..7d98102 --- /dev/null +++ b/src/main/java/com/goi/erp/dto/TankInventorySnapshotRequestDto.java @@ -0,0 +1,24 @@ +package com.goi.erp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TankInventorySnapshotRequestDto { + + private UUID tnkUuid; + + private String tisMaterialKind; // OIL / WATER / SLUDGE / MIXED + private BigDecimal tisQtyTotal; + + private LocalDateTime tisSnapshotAt; +} diff --git a/src/main/java/com/goi/erp/dto/TankInventorySnapshotResponseDto.java b/src/main/java/com/goi/erp/dto/TankInventorySnapshotResponseDto.java new file mode 100644 index 0000000..7595994 --- /dev/null +++ b/src/main/java/com/goi/erp/dto/TankInventorySnapshotResponseDto.java @@ -0,0 +1,32 @@ +package com.goi.erp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TankInventorySnapshotResponseDto { + + private UUID tisUuid; + + private UUID tnkUuid; + private String tnkCode; + private String tnkName; + private String tnkType; + + private String tisMaterialKind; + private BigDecimal tisQtyTotal; + + private LocalDateTime tisSnapshotAt; + + private LocalDateTime tisCreatedAt; + private String tisCreatedBy; +} diff --git a/src/main/java/com/goi/erp/dto/TankRequestDto.java b/src/main/java/com/goi/erp/dto/TankRequestDto.java new file mode 100644 index 0000000..c99aa02 --- /dev/null +++ b/src/main/java/com/goi/erp/dto/TankRequestDto.java @@ -0,0 +1,24 @@ +package com.goi.erp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TankRequestDto { + + private String tnkCode; // T-01, HT-N, W-01 + private UUID tnkUuid; + private String tnkName; // UCO 1, Heating Tank (New) + private String tnkType; // GT / RT / HT / WT / DT / DRUM / SLUDGE + private BigDecimal tnkCapacity; + private String tnkUnit; // TON (default) + private Boolean tnkActive; // true / false +} \ No newline at end of file diff --git a/src/main/java/com/goi/erp/dto/TankResponseDto.java b/src/main/java/com/goi/erp/dto/TankResponseDto.java new file mode 100644 index 0000000..2cc695d --- /dev/null +++ b/src/main/java/com/goi/erp/dto/TankResponseDto.java @@ -0,0 +1,31 @@ +package com.goi.erp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TankResponseDto { + + private UUID tnkUuid; + private String tnkCode; + private String tnkName; + private String tnkType; + + private BigDecimal tnkCapacity; + private String tnkUnit; + private Boolean tnkActive; + + private LocalDateTime tnkCreatedAt; + private String tnkCreatedBy; + private LocalDateTime tnkUpdatedAt; + private String tnkUpdatedBy; +} \ No newline at end of file diff --git a/src/main/java/com/goi/erp/dto/TankTransactionLineRequestDto.java b/src/main/java/com/goi/erp/dto/TankTransactionLineRequestDto.java new file mode 100644 index 0000000..9af4098 --- /dev/null +++ b/src/main/java/com/goi/erp/dto/TankTransactionLineRequestDto.java @@ -0,0 +1,35 @@ +package com.goi.erp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TankTransactionLineRequestDto { + + private UUID fromTnkUuid; // nullable (외부 유입) + private UUID toTnkUuid; // nullable (외부 반출) + + private UUID ttlUuid; // nullable (외부 유입) + private String ttlTxType; // TRANSFER / DRAIN / LOAD / DUMP / ADJUST + + private String ttlMaterialKind; // OIL / WATER / SLUDGE / MIXED + private String ttlOriginType; // HT / DT (OIL일 때) + + private BigDecimal ttlQty; + + private LocalDateTime ttlTxAt; // 발생 시각 + + // 시스템이 자동 세팅 (보통 null 로 요청) + private String ttlProcessRule; + + private String ttlNote; +} diff --git a/src/main/java/com/goi/erp/dto/TankTransactionLineResponseDto.java b/src/main/java/com/goi/erp/dto/TankTransactionLineResponseDto.java new file mode 100644 index 0000000..61b42c6 --- /dev/null +++ b/src/main/java/com/goi/erp/dto/TankTransactionLineResponseDto.java @@ -0,0 +1,42 @@ +package com.goi.erp.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TankTransactionLineResponseDto { + + private UUID ttlUuid; + + private String ttlTxType; + + private UUID fromTnkUuid; + private String fromTnkCode; + private String fromTnkName; + + private UUID toTnkUuid; + private String toTnkCode; + private String toTnkName; + + private String ttlMaterialKind; + private String ttlOriginType; + + private BigDecimal ttlQty; + + private LocalDateTime ttlTxAt; + + private String ttlProcessRule; + private String ttlNote; + + private LocalDateTime ttlCreatedAt; + private String ttlCreatedBy; +} diff --git a/src/main/java/com/goi/erp/entity/Tank.java b/src/main/java/com/goi/erp/entity/Tank.java new file mode 100644 index 0000000..f223267 --- /dev/null +++ b/src/main/java/com/goi/erp/entity/Tank.java @@ -0,0 +1,73 @@ +package com.goi.erp.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table( + name = "tank", + uniqueConstraints = { + @UniqueConstraint(name = "uk_tank_code", columnNames = {"tnk_code"}) + } +) +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +public class Tank { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "tnk_id") + private Long tnkId; + + @Column(name = "tnk_uuid", unique = true) + private UUID tnkUuid; + + @Column(name = "tnk_code", nullable = false, length = 20, unique = true) + private String tnkCode; // T-01, HT-N, W-01 + + @Column(name = "tnk_name", nullable = false, length = 100) + private String tnkName; + + @Column(name = "tnk_type", nullable = false, length = 10) + private String tnkType; // GT / RT / HT / WT / DT + + @Column(name = "tnk_capacity", nullable = false, precision = 12, scale = 3) + private BigDecimal tnkCapacity; + + @Column(name = "tnk_unit", length = 10) + private String tnkUnit; // TON + + @Column(name = "tnk_active") + private Boolean tnkActive; + + @Column(name = "tnk_created_at") + private LocalDateTime tnkCreatedAt; + + @Column(name = "tnk_created_by", length = 50) + private String tnkCreatedBy; + + @Column(name = "tnk_updated_at") + private LocalDateTime tnkUpdatedAt; + + @Column(name = "tnk_updated_by", length = 50) + private String tnkUpdatedBy; +} \ No newline at end of file diff --git a/src/main/java/com/goi/erp/entity/TankInventory.java b/src/main/java/com/goi/erp/entity/TankInventory.java new file mode 100644 index 0000000..2758e16 --- /dev/null +++ b/src/main/java/com/goi/erp/entity/TankInventory.java @@ -0,0 +1,73 @@ +package com.goi.erp.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table( + name = "tank_inventory", + uniqueConstraints = { + @UniqueConstraint(name = "uk_tank_inventory_tank", columnNames = {"tni_tank_id"}) + } +) +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +public class TankInventory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "tni_id") + private Long tniId; + + @Column(name = "tni_uuid", unique = true) + private UUID tniUuid; + + @Column(name = "tni_tank_id", nullable = false) + private Long tniTankId; + + @Column(name = "tni_material_kind", nullable = false, length = 10) + private String tniMaterialKind; // OIL / WATER / SLUDGE / MIXED + + @Column(name = "tni_qty_total", nullable = false, precision = 12, scale = 3) + private BigDecimal tniQtyTotal; + + @Column(name = "tni_space_left", nullable = false, precision = 12, scale = 3) + private BigDecimal tniSpaceLeft; + + @Column(name = "tni_status_percent", nullable = false, precision = 5, scale = 2) + private BigDecimal tniStatusPercent; + + @Column(name = "tni_last_updated_at", nullable = false) + private LocalDateTime tniLastUpdatedAt; + + @Column(name = "tni_created_at") + private LocalDateTime tniCreatedAt; + + @Column(name = "tni_created_by", length = 50) + private String tniCreatedBy; + + @Column(name = "tni_updated_at") + private LocalDateTime tniUpdatedAt; + + @Column(name = "tni_updated_by", length = 50) + private String tniUpdatedBy; +} \ No newline at end of file diff --git a/src/main/java/com/goi/erp/entity/TankInventoryComposition.java b/src/main/java/com/goi/erp/entity/TankInventoryComposition.java new file mode 100644 index 0000000..919081a --- /dev/null +++ b/src/main/java/com/goi/erp/entity/TankInventoryComposition.java @@ -0,0 +1,73 @@ +package com.goi.erp.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table( + name = "tank_inventory_composition", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_tank_inventory_composition", + columnNames = {"tic_tank_id", "tic_material_kind", "tic_origin_type"} + ) + } +) +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +public class TankInventoryComposition { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "tic_id") + private Long ticId; + + @Column(name = "tic_uuid", unique = true) + private UUID ticUuid; + + @Column(name = "tic_tank_id", nullable = false) + private Long ticTankId; + + @Column(name = "tic_material_kind", nullable = false, length = 10) + private String ticMaterialKind; // OIL / WATER / SLUDGE + + @Column(name = "tic_origin_type", length = 10) + private String ticOriginType; // HT / DT (OIL일 때만) + + @Column(name = "tic_qty", nullable = false, precision = 12, scale = 3) + private BigDecimal ticQty; + + @Column(name = "tic_last_in_at", nullable = false) + private LocalDateTime ticLastInAt; + + @Column(name = "tic_created_at") + private LocalDateTime ticCreatedAt; + + @Column(name = "tic_created_by", length = 50) + private String ticCreatedBy; + + @Column(name = "tic_updated_at") + private LocalDateTime ticUpdatedAt; + + @Column(name = "tic_updated_by", length = 50) + private String ticUpdatedBy; +} \ No newline at end of file diff --git a/src/main/java/com/goi/erp/entity/TankInventorySnapshot.java b/src/main/java/com/goi/erp/entity/TankInventorySnapshot.java new file mode 100644 index 0000000..e0afe48 --- /dev/null +++ b/src/main/java/com/goi/erp/entity/TankInventorySnapshot.java @@ -0,0 +1,64 @@ +package com.goi.erp.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table( + name = "tank_inventory_snapshot", + uniqueConstraints = { + @UniqueConstraint( + name = "uk_tank_inventory_snapshot", + columnNames = {"tis_tank_id", "tis_snapshot_at"} + ) + } +) +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +public class TankInventorySnapshot { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "tis_id") + private Long tisId; + + @Column(name = "tis_uuid", unique = true) + private UUID tisUuid; + + @Column(name = "tis_tank_id", nullable = false) + private Long tisTankId; + + @Column(name = "tis_snapshot_at", nullable = false) + private LocalDateTime tisSnapshotAt; + + @Column(name = "tis_material_kind", nullable = false, length = 10) + private String tisMaterialKind; // OIL / WATER / SLUDGE / MIXED + + @Column(name = "tis_qty_total", nullable = false, precision = 12, scale = 3) + private BigDecimal tisQtyTotal; + + @Column(name = "tis_created_at") + private LocalDateTime tisCreatedAt; + + @Column(name = "tis_created_by", length = 50) + private String tisCreatedBy; +} \ No newline at end of file diff --git a/src/main/java/com/goi/erp/entity/TankTransactionLine.java b/src/main/java/com/goi/erp/entity/TankTransactionLine.java new file mode 100644 index 0000000..77ea8ed --- /dev/null +++ b/src/main/java/com/goi/erp/entity/TankTransactionLine.java @@ -0,0 +1,76 @@ +package com.goi.erp.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "tank_transaction_line") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) +public class TankTransactionLine { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "ttl_id") + private Long ttlId; + + @Column(name = "ttl_uuid", unique = true) + private UUID ttlUuid; + + @Column(name = "ttl_tx_type", nullable = false, length = 20) + private String ttlTxType; // TRANSFER / DRAIN / LOAD / DUMP / ADJUST + + @Column(name = "ttl_from_tank_id") + private Long ttlFromTankId; + + @Column(name = "ttl_to_tank_id") + private Long ttlToTankId; + + @Column(name = "ttl_material_kind", nullable = false, length = 10) + private String ttlMaterialKind; // OIL / WATER / SLUDGE / MIXED + + @Column(name = "ttl_origin_type", length = 10) + private String ttlOriginType; // HT / DT (OIL일 때 필수) + + @Column(name = "ttl_qty", nullable = false, precision = 12, scale = 3) + private BigDecimal ttlQty; + + @Column(name = "ttl_tx_at", nullable = false) + private LocalDateTime ttlTxAt; + + /* + * PRIORITY_HT_THEN_DT : GT 출고 시 HT-origin 먼저 차감 + * MANUAL : 작업자 수동 지정 + * CLEAN_DT_ONLY : RT -> DT -> GT (클린 오일) + * PROPORTIONAL : 비율 배분 (구 방식) + */ + @Column(name = "ttl_process_rule", length = 50) + private String ttlProcessRule; + + @Column(name = "ttl_note") + private String ttlNote; + + @Column(name = "ttl_created_at") + private LocalDateTime ttlCreatedAt; + + @Column(name = "ttl_created_by", length = 50) + private String ttlCreatedBy; +} \ No newline at end of file diff --git a/src/main/java/com/goi/erp/repository/TankInventoryCompositionRepository.java b/src/main/java/com/goi/erp/repository/TankInventoryCompositionRepository.java new file mode 100644 index 0000000..77dcc06 --- /dev/null +++ b/src/main/java/com/goi/erp/repository/TankInventoryCompositionRepository.java @@ -0,0 +1,83 @@ +package com.goi.erp.repository; + +import com.goi.erp.entity.TankInventoryComposition; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface TankInventoryCompositionRepository extends JpaRepository { + + /** + * 탱크별 현재 composition 전체 조회 + * (주로 GT, WT 대시보드용) + */ + List findByTicTankId(Long ticTankId); + List findByTicTankIdAndTicMaterialKindOrderByTicOriginTypeAsc(Long ticTankId, String ticMaterialKind); + + + /** + * 특정 탱크 + material + origin 단건 조회 + * (HT/DT oil 잔량 찾기용) + */ + Optional findByTicTankIdAndTicMaterialKindAndTicOriginType( + Long ticTankId, + String ticMaterialKind, + String ticOriginType + ); + + /** + * 특정 탱크의 OIL composition 전체 조회 + * (GT 출고 시 HT → DT 차감 계산용) + */ + List findByTicTankIdAndTicMaterialKind( + Long ticTankId, + String ticMaterialKind + ); + + /** + * 수량 증가 (유입) + */ + @Modifying + @Query(""" + UPDATE TankInventoryComposition t + SET t.ticQty = t.ticQty + :qty, + t.ticLastInAt = :txAt, + t.ticUpdatedAt = CURRENT_TIMESTAMP + WHERE t.ticTankId = :tankId + AND t.ticMaterialKind = :materialKind + AND t.ticOriginType = :originType + """) + int increaseQty( + @Param("tankId") Long tankId, + @Param("materialKind") String materialKind, + @Param("originType") String originType, + @Param("qty") BigDecimal qty, + @Param("txAt") LocalDateTime txAt + ); + + /** + * 수량 감소 (출고 / 이동) + */ + @Modifying + @Query(""" + UPDATE TankInventoryComposition t + SET t.ticQty = t.ticQty - :qty, + t.ticUpdatedAt = CURRENT_TIMESTAMP + WHERE t.ticTankId = :tankId + AND t.ticMaterialKind = :materialKind + AND t.ticOriginType = :originType + AND t.ticQty >= :qty + """) + int decreaseQty( + @Param("tankId") Long tankId, + @Param("materialKind") String materialKind, + @Param("originType") String originType, + @Param("qty") BigDecimal qty + ); +} diff --git a/src/main/java/com/goi/erp/repository/TankInventoryRepository.java b/src/main/java/com/goi/erp/repository/TankInventoryRepository.java new file mode 100644 index 0000000..d2014df --- /dev/null +++ b/src/main/java/com/goi/erp/repository/TankInventoryRepository.java @@ -0,0 +1,28 @@ +package com.goi.erp.repository; + +import com.goi.erp.entity.TankInventory; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface TankInventoryRepository extends JpaRepository { + + /** + * tank_id 기준 현재 inventory 조회 + * (1 tank = 1 row 보장) + */ + Optional findByTniTankId(Long tniTankId); + + /** + * tank_id 기준 inventory 존재 여부 + */ + boolean existsByTniTankId(Long tniTankId); + + /** + * material kind 기준 전체 조회 + * 예: OIL 탱크 전체 집계용 + */ + Iterable findAllByTniMaterialKind(String tniMaterialKind); +} diff --git a/src/main/java/com/goi/erp/repository/TankInventorySnapshotRepository.java b/src/main/java/com/goi/erp/repository/TankInventorySnapshotRepository.java new file mode 100644 index 0000000..d418357 --- /dev/null +++ b/src/main/java/com/goi/erp/repository/TankInventorySnapshotRepository.java @@ -0,0 +1,60 @@ +package com.goi.erp.repository; + +import com.goi.erp.entity.TankInventorySnapshot; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface TankInventorySnapshotRepository extends JpaRepository { + + /** + * 특정 탱크의 특정 시점 기준 가장 최근 스냅샷 (as-of query) + * 예: "어제 오전 9시 재고" + */ + Optional + findTopByTisTankIdAndTisSnapshotAtLessThanEqualOrderByTisSnapshotAtDesc( + Long tisTankId, + LocalDateTime snapshotAt + ); + + /** + * 특정 탱크의 최신 스냅샷 + */ + Optional + findTopByTisTankIdOrderByTisSnapshotAtDesc(Long tisTankId); + + /** + * 특정 기간 동안의 스냅샷 (차트용) + */ + List + findByTisTankIdAndTisSnapshotAtBetweenOrderByTisSnapshotAtAsc( + Long tisTankId, + LocalDateTime from, + LocalDateTime to + ); + + /** + * 특정 시각에 찍힌 전체 탱크 스냅샷 (예: 오전 9시 전체 현황) + */ + List + findByTisSnapshotAt(LocalDateTime snapshotAt); + + /** + * 특정 시각 이전의 전체 탱크 최신 스냅샷 + * (공장장: "그 시점 전체 재고") + */ + @Query(""" + select tis + from TankInventorySnapshot tis + where tis.tisSnapshotAt = ( + select max(t2.tisSnapshotAt) + from TankInventorySnapshot t2 + where t2.tisTankId = tis.tisTankId + and t2.tisSnapshotAt <= :snapshotAt + ) + """) + List findLatestSnapshotsAsOf(LocalDateTime snapshotAt); +} diff --git a/src/main/java/com/goi/erp/repository/TankRepository.java b/src/main/java/com/goi/erp/repository/TankRepository.java new file mode 100644 index 0000000..d092c20 --- /dev/null +++ b/src/main/java/com/goi/erp/repository/TankRepository.java @@ -0,0 +1,18 @@ +package com.goi.erp.repository; + +import com.goi.erp.entity.Tank; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +public interface TankRepository extends JpaRepository { + + Optional findByTnkUuid(UUID tnkUuid); + + Optional findByTnkCode(String tnkCode); + + boolean existsByTnkUuid(UUID tnkUuid); + + boolean existsByTnkCode(String tnkCode); +} diff --git a/src/main/java/com/goi/erp/repository/TankTransactionLineRepository.java b/src/main/java/com/goi/erp/repository/TankTransactionLineRepository.java new file mode 100644 index 0000000..8ef8e26 --- /dev/null +++ b/src/main/java/com/goi/erp/repository/TankTransactionLineRepository.java @@ -0,0 +1,68 @@ +package com.goi.erp.repository; + +import com.goi.erp.entity.TankTransactionLine; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface TankTransactionLineRepository extends JpaRepository, JpaSpecificationExecutor { + + /** + * UUID 단건 조회 (API 응답용) + */ + Optional findByTtlUuid(UUID ttlUuid); + + /** + * 특정 탱크 기준 전체 트랜잭션 조회 + * (from / to 어느 쪽이든 포함) + */ + List findByTtlFromTankIdOrTtlToTankId( + Long fromTankId, + Long toTankId + ); + + /** + * 특정 탱크 + 기간 조회 + * (감사 / 리포트 / replay 용) + */ + List findByTtlFromTankIdAndTtlTxAtBetween( + Long tankId, + LocalDateTime from, + LocalDateTime to + ); + + List findByTtlToTankIdAndTtlTxAtBetween( + Long tankId, + LocalDateTime from, + LocalDateTime to + ); + + /** + * 탱크 기준 전체 히스토리 (시간순) + */ + List findByTtlFromTankIdOrTtlToTankIdOrderByTtlTxAtAsc( + Long fromTankId, + Long toTankId + ); + + /** + * 특정 탱크 + material_kind + * (예: GT의 OIL만 replay) + */ + List findByTtlToTankIdAndTtlMaterialKind( + Long tankId, + String ttlMaterialKind + ); + + /** + * 특정 탱크에서 빠져나간 트랜잭션 + * (출고/드레인 분석용) + */ + List findByTtlFromTankId( + Long tankId + ); +} diff --git a/src/main/java/com/goi/erp/service/TankInventoryCompositionService.java b/src/main/java/com/goi/erp/service/TankInventoryCompositionService.java new file mode 100644 index 0000000..c334668 --- /dev/null +++ b/src/main/java/com/goi/erp/service/TankInventoryCompositionService.java @@ -0,0 +1,135 @@ +package com.goi.erp.service; + +import com.goi.erp.entity.TankInventoryComposition; +import com.goi.erp.entity.TankTransactionLine; +import com.goi.erp.repository.TankInventoryCompositionRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@Service +@RequiredArgsConstructor +public class TankInventoryCompositionService { + + private final TankInventoryCompositionRepository compositionRepository; + + /** + * 트랜잭션 라인 1건을 composition에 반영 + * - OIL만 처리 + * - origin 판단은 tx 생성 시 이미 끝났다는 전제 + * - from/to 둘 다 있으면: from(-), to(+) + */ + @Transactional + public void applyTransactionLine(TankTransactionLine tx, String actor) { + + if (!"OIL".equalsIgnoreCase(tx.getTtlMaterialKind())) { + return; + } + + String originType = tx.getTtlOriginType(); // HT / DT + if (originType == null || originType.isBlank()) { + throw new IllegalStateException("OIL transaction must have originType"); + } + + BigDecimal qty = tx.getTtlQty(); + if (qty == null || qty.compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalStateException("ttlQty must be > 0"); + } + + LocalDateTime txAt = tx.getTtlTxAt() != null ? tx.getTtlTxAt() : LocalDateTime.now(); + + // OUT: from tank + if (tx.getTtlFromTankId() != null) { + applyDelta(tx.getTtlFromTankId(), originType, qty.negate(), txAt, actor); + } + + // IN: to tank + if (tx.getTtlToTankId() != null) { + applyDelta(tx.getTtlToTankId(), originType, qty, txAt, actor); + } + } + + /** + * 수동 보정(컨트롤러에서 사용) + * - 특정 탱크의 OIL origin(HT/DT) 잔량을 원하는 값으로 세팅 + */ + @Transactional + public TankInventoryComposition upsertManual( + Long tankId, + String originType, + BigDecimal newQty, + LocalDateTime lastInAt, + String actor + ) { + if (tankId == null) throw new IllegalArgumentException("tankId is required"); + if (originType == null || originType.isBlank()) throw new IllegalArgumentException("originType is required"); + if (newQty == null || newQty.compareTo(BigDecimal.ZERO) < 0) throw new IllegalArgumentException("newQty must be >= 0"); + + String origin = originType.toUpperCase(); + + TankInventoryComposition comp = compositionRepository + .findByTicTankIdAndTicMaterialKindAndTicOriginType(tankId, "OIL", origin) + .orElseGet(() -> TankInventoryComposition.builder() + .ticTankId(tankId) + .ticMaterialKind("OIL") + .ticOriginType(origin) + .ticQty(BigDecimal.ZERO) + .ticLastInAt(lastInAt != null ? lastInAt : LocalDateTime.now()) + .ticCreatedAt(LocalDateTime.now()) + .ticCreatedBy(actor) + .build()); + + comp.setTicQty(newQty); + + if (lastInAt != null) { + comp.setTicLastInAt(lastInAt); + } + + comp.setTicUpdatedAt(LocalDateTime.now()); + comp.setTicUpdatedBy(actor); + + return compositionRepository.save(comp); + } + + private void applyDelta( + Long tankId, + String originType, + BigDecimal delta, + LocalDateTime txAt, + String actor + ) { + String origin = originType.toUpperCase(); + + TankInventoryComposition comp = compositionRepository + .findByTicTankIdAndTicMaterialKindAndTicOriginType(tankId, "OIL", origin) + .orElseGet(() -> TankInventoryComposition.builder() + .ticTankId(tankId) + .ticMaterialKind("OIL") + .ticOriginType(origin) + .ticQty(BigDecimal.ZERO) + .ticLastInAt(txAt) + .ticCreatedAt(LocalDateTime.now()) + .ticCreatedBy(actor) + .build()); + + BigDecimal next = comp.getTicQty().add(delta); + if (next.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalStateException("composition would go negative. tankId=" + tankId + ", origin=" + origin); + } + + comp.setTicQty(next); + + // last_in_at은 유입(+delta)일 때만 갱신 + if (delta.compareTo(BigDecimal.ZERO) > 0) { + comp.setTicLastInAt(txAt); + } + + comp.setTicUpdatedAt(LocalDateTime.now()); + comp.setTicUpdatedBy(actor); + + compositionRepository.save(comp); + } +} diff --git a/src/main/java/com/goi/erp/service/TankInventoryService.java b/src/main/java/com/goi/erp/service/TankInventoryService.java new file mode 100644 index 0000000..7864670 --- /dev/null +++ b/src/main/java/com/goi/erp/service/TankInventoryService.java @@ -0,0 +1,102 @@ +package com.goi.erp.service; + +import com.goi.erp.entity.Tank; +import com.goi.erp.entity.TankInventory; +import com.goi.erp.repository.TankInventoryRepository; +import com.goi.erp.repository.TankRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class TankInventoryService { + + private final TankInventoryRepository tankInventoryRepository; + private final TankRepository tankRepository; + + @Transactional(readOnly = true) + public Optional findByTankUuid(UUID tankUuid) { + Long tankId = getTankIdByUuid(tankUuid); + return tankInventoryRepository.findByTniTankId(tankId); + } + + /** + * inventory 생성 or 갱신 (UPSERT) + * 계산 필드는 DB trigger 처리 + */ + @Transactional + public TankInventory upsertInventory( + UUID tankUuid, + String materialKind, + BigDecimal qtyTotal, + String updatedBy + ) { + if (materialKind == null) { + throw new IllegalArgumentException("materialKind is required"); + } + + Long tankId = getTankIdByUuid(tankUuid); + + TankInventory inventory = tankInventoryRepository + .findByTniTankId(tankId) + .orElseGet(() -> TankInventory.builder() + .tniUuid(UUID.randomUUID()) // ✅ 추가 + .tniTankId(tankId) + .tniCreatedAt(LocalDateTime.now()) + .tniCreatedBy(updatedBy) + .build() + ); + + inventory.setTniMaterialKind(materialKind); + inventory.setTniQtyTotal(qtyTotal); + inventory.setTniUpdatedAt(LocalDateTime.now()); + inventory.setTniUpdatedBy(updatedBy); + + return tankInventoryRepository.save(inventory); + } + + /** + * 수량 증감 (delta) + */ + @Transactional + public TankInventory applyDelta( + UUID tankUuid, + String materialKind, + BigDecimal deltaQty, + String updatedBy + ) { + if (materialKind == null) { + throw new IllegalArgumentException("materialKind is required"); + } + + Long tankId = getTankIdByUuid(tankUuid); + + TankInventory inventory = tankInventoryRepository + .findByTniTankId(tankId) + .orElseThrow(() -> + new IllegalStateException("Tank inventory not found. tankUuid=" + tankUuid) + ); + + inventory.setTniMaterialKind(materialKind); + inventory.setTniQtyTotal(inventory.getTniQtyTotal().add(deltaQty)); + inventory.setTniUpdatedAt(LocalDateTime.now()); + inventory.setTniUpdatedBy(updatedBy); + + return tankInventoryRepository.save(inventory); + } + + private Long getTankIdByUuid(UUID tankUuid) { + Tank tank = tankRepository.findByTnkUuid(tankUuid) + .orElseThrow(() -> + new IllegalArgumentException("Tank not found. uuid=" + tankUuid) + ); + return tank.getTnkId(); + } +} + diff --git a/src/main/java/com/goi/erp/service/TankService.java b/src/main/java/com/goi/erp/service/TankService.java new file mode 100644 index 0000000..4d8dffc --- /dev/null +++ b/src/main/java/com/goi/erp/service/TankService.java @@ -0,0 +1,118 @@ +package com.goi.erp.service; + +import com.goi.erp.dto.TankRequestDto; +import com.goi.erp.dto.TankResponseDto; +import com.goi.erp.entity.Tank; +import com.goi.erp.repository.TankRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class TankService { + + private final TankRepository tankRepository; + + @Transactional + public TankResponseDto createTank(TankRequestDto requestDto, String actor) { + + Tank tank = Tank.builder() + .tnkUuid(requestDto.getTnkUuid() != null ? requestDto.getTnkUuid() : UUID.randomUUID()) + .tnkCode(requestDto.getTnkCode()) + .tnkName(requestDto.getTnkName()) + .tnkType(requestDto.getTnkType()) + .tnkCapacity(requestDto.getTnkCapacity()) + .tnkUnit(requestDto.getTnkUnit() != null ? requestDto.getTnkUnit() : "TON") + .tnkActive(requestDto.getTnkActive() != null ? requestDto.getTnkActive() : Boolean.TRUE) + .tnkCreatedAt(LocalDateTime.now()) + .tnkCreatedBy(actor) + .tnkUpdatedAt(LocalDateTime.now()) + .tnkUpdatedBy(actor) + .build(); + + Tank saved = tankRepository.save(tank); + return toResponseDto(saved); + } + + @Transactional(readOnly = true) + public TankResponseDto getTank(UUID tankUuid) { + Tank tank = tankRepository.findByTnkUuid(tankUuid) + .orElseThrow(() -> new IllegalArgumentException("Tank not found: " + tankUuid)); + + return toResponseDto(tank); + } + + @Transactional(readOnly = true) + public List getAllTanks() { + return tankRepository.findAll().stream() + .map(this::toResponseDto) + .collect(Collectors.toList()); + } + + @Transactional + public TankResponseDto updateTank(UUID tankUuid, TankRequestDto requestDto, String actor) { + + Tank tank = tankRepository.findByTnkUuid(tankUuid) + .orElseThrow(() -> new IllegalArgumentException("Tank not found: " + tankUuid)); + + if (requestDto.getTnkCode() != null) { + tank.setTnkCode(requestDto.getTnkCode()); + } + if (requestDto.getTnkName() != null) { + tank.setTnkName(requestDto.getTnkName()); + } + if (requestDto.getTnkType() != null) { + tank.setTnkType(requestDto.getTnkType()); + } + if (requestDto.getTnkCapacity() != null) { + tank.setTnkCapacity(requestDto.getTnkCapacity()); + } + if (requestDto.getTnkUnit() != null) { + tank.setTnkUnit(requestDto.getTnkUnit()); + } + if (requestDto.getTnkActive() != null) { + tank.setTnkActive(requestDto.getTnkActive()); + } + + tank.setTnkUpdatedAt(LocalDateTime.now()); + tank.setTnkUpdatedBy(actor); + + Tank saved = tankRepository.save(tank); + return toResponseDto(saved); + } + + @Transactional + public void deactivateTank(UUID tankUuid, String actor) { + + Tank tank = tankRepository.findByTnkUuid(tankUuid) + .orElseThrow(() -> new IllegalArgumentException("Tank not found: " + tankUuid)); + + tank.setTnkActive(false); + tank.setTnkUpdatedAt(LocalDateTime.now()); + tank.setTnkUpdatedBy(actor); + + tankRepository.save(tank); + } + + private TankResponseDto toResponseDto(Tank tank) { + return TankResponseDto.builder() + .tnkUuid(tank.getTnkUuid()) + .tnkCode(tank.getTnkCode()) + .tnkName(tank.getTnkName()) + .tnkType(tank.getTnkType()) + .tnkCapacity(tank.getTnkCapacity()) + .tnkUnit(tank.getTnkUnit()) + .tnkActive(tank.getTnkActive()) + .tnkCreatedAt(tank.getTnkCreatedAt()) + .tnkCreatedBy(tank.getTnkCreatedBy()) + .tnkUpdatedAt(tank.getTnkUpdatedAt()) + .tnkUpdatedBy(tank.getTnkUpdatedBy()) + .build(); + } +} diff --git a/src/main/java/com/goi/erp/service/TankTransactionLineService.java b/src/main/java/com/goi/erp/service/TankTransactionLineService.java new file mode 100644 index 0000000..01b104a --- /dev/null +++ b/src/main/java/com/goi/erp/service/TankTransactionLineService.java @@ -0,0 +1,262 @@ +package com.goi.erp.service; + +import com.goi.erp.dto.TankTransactionLineRequestDto; +import com.goi.erp.dto.TankTransactionLineResponseDto; +import com.goi.erp.entity.Tank; +import com.goi.erp.entity.TankInventory; +import com.goi.erp.entity.TankInventoryComposition; +import com.goi.erp.entity.TankTransactionLine; +import com.goi.erp.repository.TankInventoryCompositionRepository; +import com.goi.erp.repository.TankInventoryRepository; +import com.goi.erp.repository.TankRepository; +import com.goi.erp.repository.TankTransactionLineRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class TankTransactionLineService { + + private final TankRepository tankRepository; + private final TankTransactionLineRepository tankTransactionLineRepository; + private final TankInventoryRepository tankInventoryRepository; + private final TankInventoryCompositionRepository tankInventoryCompositionRepository; + + /** + * 트랜잭션 라인 1건 기록 + 현재 재고/컴포지션 반영 + * - 기본적으로 시스템이 ttlProcessRule 자동 세팅 가능 + * - from/to 는 UUID로 받고 내부에서 tankId로 resolve + */ + @Transactional + public TankTransactionLineResponseDto createLine(TankTransactionLineRequestDto req, String actor) { + validate(req); + + Tank fromTank = resolveTankNullable(req.getFromTnkUuid()); + Tank toTank = resolveTankNullable(req.getToTnkUuid()); + + Long fromTankId = (fromTank == null) ? null : fromTank.getTnkId(); + Long toTankId = (toTank == null) ? null : toTank.getTnkId(); + + // process rule 자동 기본값(원하면 더 엄격히) + String processRule = defaultProcessRule(req); + + TankTransactionLine line = TankTransactionLine.builder() + .ttlUuid(Optional.ofNullable(req.getTtlUuid()).orElse(UUID.randomUUID())) // dto에 ttlUuid 없으면 제거해도 됨 + .ttlTxType(req.getTtlTxType()) + .ttlFromTankId(fromTankId) + .ttlToTankId(toTankId) + .ttlMaterialKind(req.getTtlMaterialKind()) + .ttlOriginType(req.getTtlOriginType()) + .ttlQty(req.getTtlQty()) + .ttlTxAt(req.getTtlTxAt() != null ? req.getTtlTxAt() : LocalDateTime.now()) + .ttlProcessRule(processRule) + .ttlNote(req.getTtlNote()) + .ttlCreatedAt(LocalDateTime.now()) + .ttlCreatedBy(actor) + .build(); + + TankTransactionLine saved = tankTransactionLineRepository.save(line); + + // 1) inventory 반영 (from -= qty, to += qty) + applyInventoryDelta(fromTank, saved.getTtlFromTankId(), saved.getTtlMaterialKind(), saved.getTtlQty().negate(), actor); + applyInventoryDelta(toTank, saved.getTtlToTankId(), saved.getTtlMaterialKind(), saved.getTtlQty(), actor); + + // 2) composition 반영 (OIL만, origin HT/DT 유지) + // - to 쪽은 +, from 쪽은 - + if ("OIL".equalsIgnoreCase(saved.getTtlMaterialKind())) { + applyCompositionDelta(saved.getTtlFromTankId(), saved.getTtlOriginType(), saved.getTtlQty().negate(), saved.getTtlTxAt(), actor); + applyCompositionDelta(saved.getTtlToTankId(), saved.getTtlOriginType(), saved.getTtlQty(), saved.getTtlTxAt(), actor); + } + + return toResponse(saved, fromTank, toTank); + } + + // ----------------------- + // validation / resolve + // ----------------------- + + private void validate(TankTransactionLineRequestDto req) { + if (req.getTtlTxType() == null || req.getTtlTxType().isBlank()) { + throw new IllegalArgumentException("ttlTxType is required"); + } + if (req.getTtlMaterialKind() == null || req.getTtlMaterialKind().isBlank()) { + throw new IllegalArgumentException("ttlMaterialKind is required"); + } + if (req.getTtlQty() == null || req.getTtlQty().compareTo(BigDecimal.ZERO) <= 0) { + throw new IllegalArgumentException("ttlQty must be > 0"); + } + // OIL이면 origin 필수 + if ("OIL".equalsIgnoreCase(req.getTtlMaterialKind())) { + if (req.getTtlOriginType() == null || req.getTtlOriginType().isBlank()) { + throw new IllegalArgumentException("ttlOriginType is required when ttlMaterialKind=OIL"); + } + String o = req.getTtlOriginType().toUpperCase(); + if (!("HT".equals(o) || "DT".equals(o))) { + throw new IllegalArgumentException("ttlOriginType must be HT or DT"); + } + } + // from/to 둘 다 null이면 의미 없음 + if (req.getFromTnkUuid() == null && req.getToTnkUuid() == null) { + throw new IllegalArgumentException("at least one of fromTankUuid/toTankUuid is required"); + } + } + + private Tank resolveTankNullable(UUID uuid) { + if (uuid == null) return null; + return tankRepository.findByTnkUuid(uuid) + .orElseThrow(() -> new IllegalArgumentException("Tank not found: " + uuid)); + } + + private String defaultProcessRule(TankTransactionLineRequestDto req) { + if (req.getTtlProcessRule() != null && !req.getTtlProcessRule().isBlank()) { + return req.getTtlProcessRule(); + } + + // 자동 기본값: OIL이면 보통 PRIORITY_HT_THEN_DT 또는 CLEAN_DT_ONLY가 될 수 있으나 + // 여기서는 사용자가 originType을 준 그대로를 존중하고, rule은 최소 표기만 자동. + if ("OIL".equalsIgnoreCase(req.getTtlMaterialKind())) { + // 출고/드레인/이동 모두 "원칙상 HT 우선 차감"이 작동할 수 있으니 기본값으로 둠 + return "PRIORITY_HT_THEN_DT"; + } + return null; + } + + // ----------------------- + // inventory / composition updates + // ----------------------- + + private void applyInventoryDelta( + Tank tankOrNull, + Long tankId, + String materialKind, + BigDecimal delta, + String actor + ) { + if (tankId == null) return; + + Tank resolvedTank = tankOrNull; + if (resolvedTank == null) { + // 호출 경로에 따라 tankOrNull이 null일 수 있으니 필요 시 resolve + resolvedTank = tankRepository.findById(tankId).orElse(null); + } + if (resolvedTank == null) return; + + // + final Tank tank = resolvedTank; + TankInventory inv = tankInventoryRepository.findByTniTankId(tankId) + .orElseGet(() -> TankInventory.builder() + .tniUuid(UUID.randomUUID()) + .tniTankId(tankId) + .tniMaterialKind(materialKind.toUpperCase()) + .tniQtyTotal(BigDecimal.ZERO) + .tniSpaceLeft(tank.getTnkCapacity()) // 초기: full space + .tniStatusPercent(BigDecimal.ZERO) + .tniLastUpdatedAt(LocalDateTime.now()) + .tniCreatedAt(LocalDateTime.now()) + .tniCreatedBy(actor) + .build()); + + // material kind 단일 상태 가정: 바뀌는 경우는 "WT가 WATER였다가 OIL로 쓴다" 같은 전환이므로 + // delta 적용 전에 0인지 체크하고 필요 시 전환 허용(정책은 너가 정하면 됨) + inv.setTniMaterialKind(materialKind.toUpperCase()); + + BigDecimal newQty = inv.getTniQtyTotal().add(delta); + if (newQty.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalStateException("Inventory would go negative. tankId=" + tankId); + } + inv.setTniQtyTotal(newQty); + + BigDecimal spaceLeft = tank.getTnkCapacity().subtract(newQty); + inv.setTniSpaceLeft(spaceLeft); + + BigDecimal percent = BigDecimal.ZERO; + if (tank.getTnkCapacity().compareTo(BigDecimal.ZERO) > 0) { + percent = newQty.multiply(BigDecimal.valueOf(100)) + .divide(tank.getTnkCapacity(), 2, java.math.RoundingMode.HALF_UP); + } + inv.setTniStatusPercent(percent); + + inv.setTniLastUpdatedAt(LocalDateTime.now()); + inv.setTniUpdatedAt(LocalDateTime.now()); + inv.setTniUpdatedBy(actor); + + tankInventoryRepository.save(inv); + } + + private void applyCompositionDelta( + Long tankId, + String originType, + BigDecimal delta, + LocalDateTime txAt, + String actor + ) { + if (tankId == null) return; + if (originType == null || originType.isBlank()) return; + + String origin = originType.toUpperCase(); + + TankInventoryComposition comp = tankInventoryCompositionRepository + .findByTicTankIdAndTicMaterialKindAndTicOriginType(tankId, "OIL", origin) + .orElseGet(() -> TankInventoryComposition.builder() + .ticUuid(UUID.randomUUID()) + .ticTankId(tankId) + .ticMaterialKind("OIL") + .ticOriginType(origin) + .ticQty(BigDecimal.ZERO) + .ticLastInAt(txAt != null ? txAt : LocalDateTime.now()) + .ticCreatedAt(LocalDateTime.now()) + .ticCreatedBy(actor) + .build()); + + BigDecimal newQty = comp.getTicQty().add(delta); + if (newQty.compareTo(BigDecimal.ZERO) < 0) { + throw new IllegalStateException("Composition would go negative. tankId=" + tankId + ", origin=" + origin); + } + + comp.setTicQty(newQty); + + // last_in_at은 +delta(유입)일 때만 갱신 + if (delta.compareTo(BigDecimal.ZERO) > 0) { + comp.setTicLastInAt(txAt != null ? txAt : LocalDateTime.now()); + } + + comp.setTicUpdatedAt(LocalDateTime.now()); + comp.setTicUpdatedBy(actor); + + tankInventoryCompositionRepository.save(comp); + } + + // ----------------------- + // mapping + // ----------------------- + + private TankTransactionLineResponseDto toResponse(TankTransactionLine saved, Tank fromTank, Tank toTank) { + return TankTransactionLineResponseDto.builder() + .ttlUuid(saved.getTtlUuid()) + .ttlTxType(saved.getTtlTxType()) + + .fromTnkUuid(fromTank == null ? null : fromTank.getTnkUuid()) + .fromTnkCode(fromTank == null ? null : fromTank.getTnkCode()) + .fromTnkName(fromTank == null ? null : fromTank.getTnkName()) + + .toTnkUuid(toTank == null ? null : toTank.getTnkUuid()) + .toTnkCode(toTank == null ? null : toTank.getTnkCode()) + .toTnkName(toTank == null ? null : toTank.getTnkName()) + + .ttlMaterialKind(saved.getTtlMaterialKind()) + .ttlOriginType(saved.getTtlOriginType()) + .ttlQty(saved.getTtlQty()) + .ttlTxAt(saved.getTtlTxAt()) + .ttlProcessRule(saved.getTtlProcessRule()) + .ttlNote(saved.getTtlNote()) + .ttlCreatedAt(saved.getTtlCreatedAt()) + .ttlCreatedBy(saved.getTtlCreatedBy()) + .build(); + } +}