diff --git a/build.gradle b/build.gradle index db3461c..5d96016 100644 --- a/build.gradle +++ b/build.gradle @@ -5,7 +5,7 @@ plugins { } group = 'com.sampoom' -version = '0.0.1-SNAPSHOT' +version = '1.0.0' description = 'Demo project for Spring Boot' java { diff --git a/src/main/java/com/sampoom/purchase/api/purchase/controller/PurchaseController.java b/src/main/java/com/sampoom/purchase/api/purchase/controller/PurchaseController.java new file mode 100644 index 0000000..52735a3 --- /dev/null +++ b/src/main/java/com/sampoom/purchase/api/purchase/controller/PurchaseController.java @@ -0,0 +1,66 @@ +package com.sampoom.purchase.api.purchase.controller; + +import com.sampoom.purchase.api.purchase.dto.PurchaseOrderRequestDto; +import com.sampoom.purchase.api.purchase.dto.PurchaseOrderResponseDto; +import com.sampoom.purchase.api.purchase.entity.OrderStatus; +import com.sampoom.purchase.api.purchase.entity.UrgencyLevel; +import com.sampoom.purchase.api.purchase.service.PurchaseService; +import com.sampoom.purchase.common.response.ApiResponse; +import com.sampoom.purchase.common.response.PageResponseDto; +import com.sampoom.purchase.common.response.SuccessStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Purchase", description = "Purchase 관련 API 입니다.") +@RestController +@RequiredArgsConstructor +public class PurchaseController { + + private final PurchaseService purchaseService; + + @Operation(summary = "자재 주문 생성", description = "공장에 필요한 자재 주문을 생성합니다.") + @PostMapping() + public ResponseEntity> createMaterialOrder( + @RequestBody PurchaseOrderRequestDto requestDto) { + return ApiResponse.success(SuccessStatus.CREATED, + purchaseService.createMaterialOrder(requestDto)); + } + + + + @Operation(summary = "자재 주문 취소", description = "주문을 취소 처리합니다.") + @PatchMapping("/{orderId}/cancel") + public ResponseEntity> cancelOrder( + @PathVariable Long orderId) { + return ApiResponse.success(SuccessStatus.OK, purchaseService.cancelOrder(orderId)); + } + + @Operation(summary = "자재 주문 삭제", description = "주문을 삭제합니다(소프트 삭제).") + @DeleteMapping("/{orderId}") + public ResponseEntity> deleteOrder( + @PathVariable Long orderId) { + purchaseService.deleteOrder(orderId); + return ApiResponse.success_only(SuccessStatus.OK); + } + + @Operation(summary = "자재 주문 단건 조회", description = "특정 자재 주문의 상세를 조회합니다.") + @GetMapping("/{orderId}") + public ResponseEntity> getOrder( + @PathVariable Long orderId) { + return ApiResponse.success(SuccessStatus.OK, purchaseService.getOrder(orderId)); + } + + @Operation(summary = "자재 주문 목록 조회", description = "주문 상태 필터와 검색(자재명/자재코드/주문코드), 긴급도 필터로 목록을 조회합니다.") + @GetMapping() + public ResponseEntity>> getOrders( + @RequestParam(required = false) OrderStatus status, + @RequestParam(required = false) UrgencyLevel urgency, + @RequestParam(required = false) String query, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size) { + return ApiResponse.success(SuccessStatus.OK, purchaseService.getOrders(status, urgency, query, page, size)); + } +} diff --git a/src/main/java/com/sampoom/purchase/api/purchase/dto/PurchaseOrderItemDto.java b/src/main/java/com/sampoom/purchase/api/purchase/dto/PurchaseOrderItemDto.java new file mode 100644 index 0000000..afb30c0 --- /dev/null +++ b/src/main/java/com/sampoom/purchase/api/purchase/dto/PurchaseOrderItemDto.java @@ -0,0 +1,31 @@ +package com.sampoom.purchase.api.purchase.dto; + +import com.sampoom.purchase.api.purchase.entity.PurchaseOrderItem; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class PurchaseOrderItemDto { + private String materialCode; + private String materialName; + private String unit; + private Long quantity; + private BigDecimal unitPrice; + + public static PurchaseOrderItemDto from(PurchaseOrderItem item) { + return PurchaseOrderItemDto.builder() + .materialCode(item.getMaterialCode()) + .materialName(item.getMaterialName()) + .unit(item.getUnit()) + .quantity(item.getQuantity()) + .unitPrice(item.getUnitPrice()) + .build(); + } +} diff --git a/src/main/java/com/sampoom/purchase/api/purchase/dto/PurchaseOrderRequestDto.java b/src/main/java/com/sampoom/purchase/api/purchase/dto/PurchaseOrderRequestDto.java new file mode 100644 index 0000000..727d004 --- /dev/null +++ b/src/main/java/com/sampoom/purchase/api/purchase/dto/PurchaseOrderRequestDto.java @@ -0,0 +1,27 @@ +package com.sampoom.purchase.api.purchase.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDate; +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class PurchaseOrderRequestDto { + private Long factoryId; + private String factoryName; + + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate requiredAt; + + private String requesterName; // 요청자 이름 추가 + + private List items; +} diff --git a/src/main/java/com/sampoom/purchase/api/purchase/dto/PurchaseOrderResponseDto.java b/src/main/java/com/sampoom/purchase/api/purchase/dto/PurchaseOrderResponseDto.java new file mode 100644 index 0000000..753730f --- /dev/null +++ b/src/main/java/com/sampoom/purchase/api/purchase/dto/PurchaseOrderResponseDto.java @@ -0,0 +1,54 @@ +package com.sampoom.purchase.api.purchase.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.sampoom.purchase.api.purchase.entity.OrderStatus; +import com.sampoom.purchase.api.purchase.entity.PurchaseOrder; +import com.sampoom.purchase.api.purchase.entity.PurchaseOrderItem; +import com.sampoom.purchase.api.purchase.entity.UrgencyLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class PurchaseOrderResponseDto { + private Long id; + private String orderCode; + private LocalDateTime orderAt; + + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate requiredAt; // 날짜만 + + private Long factoryId; + private String factoryName; + private String requesterName; + private UrgencyLevel urgency; + private BigDecimal expectedAmount; + private OrderStatus status; + private List items; + + public static PurchaseOrderResponseDto from(PurchaseOrder order, List orderItems) { + return PurchaseOrderResponseDto.builder() + .id(order.getId()) + .status(order.getStatus()) + .orderCode(order.getCode()) + .orderAt(order.getOrderAt()) + .requiredAt(order.getRequiredAt()) + .factoryId(order.getFactoryId()) + .factoryName(order.getFactoryName()) + .requesterName(order.getRequesterName()) + .urgency(order.getUrgency()) + .expectedAmount(order.getExpectedAmount()) + .items(orderItems.stream().map(PurchaseOrderItemDto::from).collect(Collectors.toList())) + .build(); + } +} diff --git a/src/main/java/com/sampoom/purchase/api/purchase/entity/OrderStatus.java b/src/main/java/com/sampoom/purchase/api/purchase/entity/OrderStatus.java new file mode 100644 index 0000000..be6d6fb --- /dev/null +++ b/src/main/java/com/sampoom/purchase/api/purchase/entity/OrderStatus.java @@ -0,0 +1,8 @@ +package com.sampoom.purchase.api.purchase.entity; + + +public enum OrderStatus { + ORDERED, // 주문됨 + RECEIVED, // 입고됨 + CANCELED // 발주 취소됨 +} diff --git a/src/main/java/com/sampoom/purchase/api/purchase/entity/PurchaseOrder.java b/src/main/java/com/sampoom/purchase/api/purchase/entity/PurchaseOrder.java new file mode 100644 index 0000000..a964576 --- /dev/null +++ b/src/main/java/com/sampoom/purchase/api/purchase/entity/PurchaseOrder.java @@ -0,0 +1,69 @@ +package com.sampoom.purchase.api.purchase.entity; + +import com.sampoom.purchase.common.entitiy.SoftDeleteEntity; +import com.sampoom.purchase.common.exception.BadRequestException; +import com.sampoom.purchase.common.response.ErrorStatus; +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +@Entity +@Getter +@Table(name = "purchase_order") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@SQLDelete(sql = "UPDATE purchase_order SET deleted = true, deleted_at = now() WHERE purchase_order_id = ?") +@SQLRestriction("deleted = false") +public class PurchaseOrder extends SoftDeleteEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "purchase_order_id") + private Long id; + + private String code; + private LocalDateTime orderAt; + private LocalDateTime receivedAt; + private LocalDateTime canceledAt; + private LocalDate requiredAt; + + + @Enumerated(EnumType.STRING) + private OrderStatus status; + + + private Long factoryId; + + private String factoryName; + + @Enumerated(EnumType.STRING) + private UrgencyLevel urgency; // 긴급도 + + private String requesterName; // 요청자 이름 + + @Column(precision = 19, scale = 2) + private BigDecimal expectedAmount; // 예상 금액 + + public void receive() { + if (this.status != OrderStatus.ORDERED) { + throw new BadRequestException(ErrorStatus.ORDER_ALREADY_PROCESSED); + } + this.status = OrderStatus.RECEIVED; + this.receivedAt = LocalDateTime.now(); + } + + public void cancel() { + + if (this.status != OrderStatus.ORDERED) { + throw new BadRequestException(ErrorStatus.ORDER_ALREADY_PROCESSED); + } + this.status = OrderStatus.CANCELED; + this.canceledAt = LocalDateTime.now(); + } +} diff --git a/src/main/java/com/sampoom/purchase/api/purchase/entity/PurchaseOrderItem.java b/src/main/java/com/sampoom/purchase/api/purchase/entity/PurchaseOrderItem.java new file mode 100644 index 0000000..e81be9f --- /dev/null +++ b/src/main/java/com/sampoom/purchase/api/purchase/entity/PurchaseOrderItem.java @@ -0,0 +1,34 @@ +package com.sampoom.purchase.api.purchase.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.math.BigDecimal; + +@Entity +@Getter +@Table(name = "purchase_order_item") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class PurchaseOrderItem { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "purchase_order_item_id") + private Long id; + + private Long quantity; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "purchase_order_id") + private PurchaseOrder purchaseOrder; + + private String materialCode; + + private String materialName; + + private String unit; + + @Column(precision = 19, scale = 2) + private BigDecimal unitPrice; +} diff --git a/src/main/java/com/sampoom/purchase/api/purchase/entity/UrgencyLevel.java b/src/main/java/com/sampoom/purchase/api/purchase/entity/UrgencyLevel.java new file mode 100644 index 0000000..ba25359 --- /dev/null +++ b/src/main/java/com/sampoom/purchase/api/purchase/entity/UrgencyLevel.java @@ -0,0 +1,8 @@ +package com.sampoom.purchase.api.purchase.entity; + +public enum UrgencyLevel { + HIGH, + MEDIUM, + LOW +} + diff --git a/src/main/java/com/sampoom/purchase/api/purchase/repository/PurchaseOrderItemRepository.java b/src/main/java/com/sampoom/purchase/api/purchase/repository/PurchaseOrderItemRepository.java new file mode 100644 index 0000000..b2dc173 --- /dev/null +++ b/src/main/java/com/sampoom/purchase/api/purchase/repository/PurchaseOrderItemRepository.java @@ -0,0 +1,11 @@ +package com.sampoom.purchase.api.purchase.repository; + +import com.sampoom.purchase.api.purchase.entity.PurchaseOrderItem; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface PurchaseOrderItemRepository extends JpaRepository { + List findByPurchaseOrderId(Long purchaseOrderId); + List findByPurchaseOrderIdIn(List orderIds); +} diff --git a/src/main/java/com/sampoom/purchase/api/purchase/repository/PurchaseOrderRepository.java b/src/main/java/com/sampoom/purchase/api/purchase/repository/PurchaseOrderRepository.java new file mode 100644 index 0000000..fde536b --- /dev/null +++ b/src/main/java/com/sampoom/purchase/api/purchase/repository/PurchaseOrderRepository.java @@ -0,0 +1,28 @@ +package com.sampoom.purchase.api.purchase.repository; + +import com.sampoom.purchase.api.purchase.entity.OrderStatus; +import com.sampoom.purchase.api.purchase.entity.PurchaseOrder; +import com.sampoom.purchase.api.purchase.entity.UrgencyLevel; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface PurchaseOrderRepository extends JpaRepository { + + @Query("select distinct po from PurchaseOrder po left join PurchaseOrderItem i on i.purchaseOrder = po " + + "where (:status is null or po.status = :status) " + + "and (:urgency is null or po.urgency = :urgency) " + + "and (:query is null or :query = '' or lower(po.code) like lower(concat('%', :query, '%')) " + + "or lower(i.materialCode) like lower(concat('%', :query, '%')) " + + "or lower(i.materialName) like lower(concat('%', :query, '%')))" ) + Page search(@Param("status") OrderStatus status, + @Param("urgency") UrgencyLevel urgency, + @Param("query") String query, + Pageable pageable); + + Optional findTopByCodeStartingWithOrderByCodeDesc(String prefix); +} diff --git a/src/main/java/com/sampoom/purchase/api/purchase/service/PurchaseService.java b/src/main/java/com/sampoom/purchase/api/purchase/service/PurchaseService.java new file mode 100644 index 0000000..d32f263 --- /dev/null +++ b/src/main/java/com/sampoom/purchase/api/purchase/service/PurchaseService.java @@ -0,0 +1,154 @@ +package com.sampoom.purchase.api.purchase.service; + +import com.sampoom.purchase.api.purchase.dto.PurchaseOrderRequestDto; +import com.sampoom.purchase.api.purchase.dto.PurchaseOrderResponseDto; +import com.sampoom.purchase.api.purchase.entity.*; +import com.sampoom.purchase.api.purchase.repository.PurchaseOrderItemRepository; +import com.sampoom.purchase.api.purchase.repository.PurchaseOrderRepository; +import com.sampoom.purchase.common.exception.NotFoundException; +import com.sampoom.purchase.common.response.ErrorStatus; +import com.sampoom.purchase.common.response.PageResponseDto; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class PurchaseService { + + private final PurchaseOrderRepository orderRepository; + private final PurchaseOrderItemRepository orderItemRepository; + + @Transactional + public PurchaseOrderResponseDto createMaterialOrder(PurchaseOrderRequestDto requestDto) { + // 예상 금액 합산: Σ(unitPrice * quantity) + BigDecimal expectedAmount = requestDto.getItems() == null ? BigDecimal.ZERO : + requestDto.getItems().stream() + .map(i -> (i.getUnitPrice() == null ? BigDecimal.ZERO : i.getUnitPrice()) + .multiply(BigDecimal.valueOf(i.getQuantity() == null ? 0L : i.getQuantity()))) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + // 긴급도 계산: 오늘과 필요일 차이 기준 + UrgencyLevel urgency = calculateUrgency(requestDto.getRequiredAt()); + + PurchaseOrder order = PurchaseOrder.builder() + .code(generateOrderCode()) + .factoryId(requestDto.getFactoryId()) + .status(OrderStatus.ORDERED) + .orderAt(LocalDateTime.now()) + .factoryName(requestDto.getFactoryName()) + .requiredAt(requestDto.getRequiredAt()) + .requesterName(requestDto.getRequesterName()) + .expectedAmount(expectedAmount) + .urgency(urgency) + .build(); + + orderRepository.save(order); + + List orderItems = requestDto.getItems().stream() + .map(itemDto -> PurchaseOrderItem.builder() + .purchaseOrder(order) + .materialCode(itemDto.getMaterialCode()) + .materialName(itemDto.getMaterialName()) + .unit(itemDto.getUnit()) + .quantity(itemDto.getQuantity()) + .unitPrice(itemDto.getUnitPrice()) + .build()) + .collect(Collectors.toList()); + + orderItemRepository.saveAll(orderItems); + + return PurchaseOrderResponseDto.from(order, orderItems); + } + + private UrgencyLevel calculateUrgency(LocalDate requiredAt) { + if (requiredAt == null) return UrgencyLevel.LOW; + long days = ChronoUnit.DAYS.between(LocalDate.now(), requiredAt); + if (days <= 1) return UrgencyLevel.HIGH; + if (days <= 3) return UrgencyLevel.MEDIUM; + return UrgencyLevel.LOW; + } + + @Transactional(readOnly = true) + public PurchaseOrderResponseDto getOrder(Long orderId) { + PurchaseOrder order = orderRepository.findById(orderId) + .orElseThrow(() -> new NotFoundException(ErrorStatus.ORDER_NOT_FOUND)); + List items = orderItemRepository.findByPurchaseOrderId(orderId); + return PurchaseOrderResponseDto.from(order, items); + } + + @Transactional(readOnly = true) + public PageResponseDto getOrders(OrderStatus status, UrgencyLevel urgency, String query, int page, int size) { + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "orderAt")); + Page pageResult = orderRepository.search(status, urgency, query, pageable); + List orderIds = pageResult.getContent().stream() + .map(PurchaseOrder::getId).toList(); + List allItems = + orderItemRepository.findByPurchaseOrderIdIn(orderIds); + Map> itemsByOrderId = allItems.stream() + .collect(Collectors.groupingBy(i -> i.getPurchaseOrder().getId())); + List content = pageResult.getContent().stream() + .map(order -> PurchaseOrderResponseDto.from( + order, + itemsByOrderId.getOrDefault(order.getId(), java.util.Collections.emptyList()))) + .collect(Collectors.toList()); + return PageResponseDto.builder() + .content(content) + .totalElements(pageResult.getTotalElements()) + .totalPages(pageResult.getTotalPages()) + .build(); + } + + + + @Transactional + public PurchaseOrderResponseDto cancelOrder(Long orderId) { + PurchaseOrder order = orderRepository.findById(orderId) + .orElseThrow(() -> new NotFoundException(ErrorStatus.ORDER_NOT_FOUND)); + order.cancel(); + orderRepository.save(order); + List items = orderItemRepository.findByPurchaseOrderId(orderId); + return PurchaseOrderResponseDto.from(order, items); + } + + @Transactional + public void deleteOrder(Long orderId) { + PurchaseOrder order = orderRepository.findById(orderId) + .orElseThrow(() -> new NotFoundException(ErrorStatus.ORDER_NOT_FOUND)); + orderRepository.delete(order); + } + + private String generateOrderCode() { + String datePart = java.time.LocalDate.now() + .format(java.time.format.DateTimeFormatter.BASIC_ISO_DATE); // YYYYMMDD + String prefix = "ORD-" + datePart + "-"; + + String lastCode = orderRepository + .findTopByCodeStartingWithOrderByCodeDesc(prefix) + .map(PurchaseOrder::getCode) + .orElse(null); + + int nextSeq = 1; + if (lastCode != null && lastCode.startsWith(prefix)) { + String seqStr = lastCode.substring(prefix.length()); + try { + nextSeq = Integer.parseInt(seqStr) + 1; + } catch (NumberFormatException ignored) { + // keep as 1 + } + } + return prefix + String.format("%03d", nextSeq); + } +} diff --git a/src/main/java/com/sampoom/purchase/common/config/swagger/WebConfig.java b/src/main/java/com/sampoom/purchase/common/config/swagger/WebConfig.java deleted file mode 100644 index b735573..0000000 --- a/src/main/java/com/sampoom/purchase/common/config/swagger/WebConfig.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.sampoom.purchase.common.config.swagger; - - -import org.springframework.context.annotation.Configuration; -import org.springframework.web.servlet.config.annotation.CorsRegistry; -import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; - -@Configuration -public class WebConfig implements WebMvcConfigurer { - - @Override - public void addCorsMappings(CorsRegistry registry) { - registry.addMapping("/**") // 모든 경로 허용 - .allowedOrigins("http://localhost:3000") - .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") - .allowedHeaders("*") - .allowCredentials(true) // 쿠키 허용 시 필요 - .maxAge(3600); // preflight 캐싱 시간 (초) - } - -}