[Tank] Added Tank

This commit is contained in:
Hyojin Ahn 2026-01-23 14:38:51 -05:00
parent 9eb9f21950
commit 8c5b1ce437
27 changed files with 1855 additions and 0 deletions

View File

@ -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<TankResponseDto> 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<List<TankResponseDto>> 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<TankResponseDto> 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<TankResponseDto> 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<Void> 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;
}
}

View File

@ -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<List<TankInventoryComposition>> getByTank(
@RequestParam UUID tnkUuid,
Authentication auth
) {
requireAdminOrManager(auth);
Tank tank = tankRepository.findByTnkUuid(tnkUuid)
.orElseThrow(() -> new IllegalArgumentException("Tank not found: " + tnkUuid));
List<TankInventoryComposition> 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<TankInventoryComposition> 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";
}
}

View File

@ -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
);
}
}

View File

@ -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; // 마지막 유입 시각
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<TankInventoryComposition, Long> {
/**
* 탱크별 현재 composition 전체 조회
* (주로 GT, WT 대시보드용)
*/
List<TankInventoryComposition> findByTicTankId(Long ticTankId);
List<TankInventoryComposition> findByTicTankIdAndTicMaterialKindOrderByTicOriginTypeAsc(Long ticTankId, String ticMaterialKind);
/**
* 특정 탱크 + material + origin 단건 조회
* (HT/DT oil 잔량 찾기용)
*/
Optional<TankInventoryComposition> findByTicTankIdAndTicMaterialKindAndTicOriginType(
Long ticTankId,
String ticMaterialKind,
String ticOriginType
);
/**
* 특정 탱크의 OIL composition 전체 조회
* (GT 출고 HT DT 차감 계산용)
*/
List<TankInventoryComposition> 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
);
}

View File

@ -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<TankInventory, Long> {
/**
* tank_id 기준 현재 inventory 조회
* (1 tank = 1 row 보장)
*/
Optional<TankInventory> findByTniTankId(Long tniTankId);
/**
* tank_id 기준 inventory 존재 여부
*/
boolean existsByTniTankId(Long tniTankId);
/**
* material kind 기준 전체 조회
* : OIL 탱크 전체 집계용
*/
Iterable<TankInventory> findAllByTniMaterialKind(String tniMaterialKind);
}

View File

@ -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<TankInventorySnapshot, Long> {
/**
* 특정 탱크의 특정 시점 기준 가장 최근 스냅샷 (as-of query)
* : "어제 오전 9시 재고"
*/
Optional<TankInventorySnapshot>
findTopByTisTankIdAndTisSnapshotAtLessThanEqualOrderByTisSnapshotAtDesc(
Long tisTankId,
LocalDateTime snapshotAt
);
/**
* 특정 탱크의 최신 스냅샷
*/
Optional<TankInventorySnapshot>
findTopByTisTankIdOrderByTisSnapshotAtDesc(Long tisTankId);
/**
* 특정 기간 동안의 스냅샷 (차트용)
*/
List<TankInventorySnapshot>
findByTisTankIdAndTisSnapshotAtBetweenOrderByTisSnapshotAtAsc(
Long tisTankId,
LocalDateTime from,
LocalDateTime to
);
/**
* 특정 시각에 찍힌 전체 탱크 스냅샷 (: 오전 9시 전체 현황)
*/
List<TankInventorySnapshot>
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<TankInventorySnapshot> findLatestSnapshotsAsOf(LocalDateTime snapshotAt);
}

View File

@ -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<Tank, Long> {
Optional<Tank> findByTnkUuid(UUID tnkUuid);
Optional<Tank> findByTnkCode(String tnkCode);
boolean existsByTnkUuid(UUID tnkUuid);
boolean existsByTnkCode(String tnkCode);
}

View File

@ -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<TankTransactionLine, Long>, JpaSpecificationExecutor<TankTransactionLine> {
/**
* UUID 단건 조회 (API 응답용)
*/
Optional<TankTransactionLine> findByTtlUuid(UUID ttlUuid);
/**
* 특정 탱크 기준 전체 트랜잭션 조회
* (from / to 어느 쪽이든 포함)
*/
List<TankTransactionLine> findByTtlFromTankIdOrTtlToTankId(
Long fromTankId,
Long toTankId
);
/**
* 특정 탱크 + 기간 조회
* (감사 / 리포트 / replay )
*/
List<TankTransactionLine> findByTtlFromTankIdAndTtlTxAtBetween(
Long tankId,
LocalDateTime from,
LocalDateTime to
);
List<TankTransactionLine> findByTtlToTankIdAndTtlTxAtBetween(
Long tankId,
LocalDateTime from,
LocalDateTime to
);
/**
* 탱크 기준 전체 히스토리 (시간순)
*/
List<TankTransactionLine> findByTtlFromTankIdOrTtlToTankIdOrderByTtlTxAtAsc(
Long fromTankId,
Long toTankId
);
/**
* 특정 탱크 + material_kind
* (: GT의 OIL만 replay)
*/
List<TankTransactionLine> findByTtlToTankIdAndTtlMaterialKind(
Long tankId,
String ttlMaterialKind
);
/**
* 특정 탱크에서 빠져나간 트랜잭션
* (출고/드레인 분석용)
*/
List<TankTransactionLine> findByTtlFromTankId(
Long tankId
);
}

View File

@ -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);
}
}

View File

@ -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<TankInventory> 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();
}
}

View File

@ -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<TankResponseDto> 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();
}
}

View File

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