This commit is contained in:
2025-09-17 14:40:16 +08:00
parent 46c5682960
commit a3bbc0098a
94 changed files with 3549 additions and 105 deletions

View File

@@ -0,0 +1,73 @@
package com.example.demo.attachment;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@RestController
@RequestMapping("/api/attachments")
public class AttachmentController {
private final AttachmentPlaceholderProperties placeholderProperties;
public AttachmentController(AttachmentPlaceholderProperties placeholderProperties) {
this.placeholderProperties = placeholderProperties;
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<Map<String, Object>> upload(@RequestParam("file") MultipartFile file,
@RequestParam(value = "ownerType", required = false) String ownerType,
@RequestParam(value = "ownerId", required = false) String ownerId) {
// 占位实现:忽略文件内容,始终返回占位图 URL
String url = StringUtils.hasText(placeholderProperties.getUrlPath()) ? placeholderProperties.getUrlPath() : "/api/attachments/placeholder";
Map<String, Object> body = new HashMap<>();
body.put("url", url);
return ResponseEntity.ok(body);
}
@GetMapping("/placeholder")
public ResponseEntity<Resource> placeholder() throws IOException {
String imagePath = placeholderProperties.getImagePath();
if (!StringUtils.hasText(imagePath)) {
return ResponseEntity.status(404).build();
}
Path path = Path.of(imagePath);
if (!Files.exists(path)) {
return ResponseEntity.status(404).build();
}
Resource resource = new FileSystemResource(path);
String contentType = Files.probeContentType(path);
MediaType mediaType;
try {
mediaType = StringUtils.hasText(contentType) ? MediaType.parseMediaType(contentType) : MediaType.IMAGE_PNG;
} catch (Exception e) {
mediaType = MediaType.APPLICATION_OCTET_STREAM;
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=placeholder")
.cacheControl(CacheControl.maxAge(30, TimeUnit.DAYS).cachePublic())
.contentType(mediaType)
.body(resource);
}
}

View File

@@ -0,0 +1,32 @@
package com.example.demo.attachment;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "attachments.placeholder")
public class AttachmentPlaceholderProperties {
private String imagePath;
private String urlPath = "/api/attachments/placeholder";
public String getImagePath() {
return imagePath;
}
public void setImagePath(String imagePath) {
this.imagePath = imagePath;
}
public String getUrlPath() {
return urlPath;
}
public void setUrlPath(String urlPath) {
this.urlPath = urlPath;
}
}

View File

@@ -0,0 +1,21 @@
package com.example.demo.common;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "app.defaults")
public class AppDefaultsProperties {
private Long shopId = 1L;
private Long userId = 2L;
public Long getShopId() { return shopId; }
public void setShopId(Long shopId) { this.shopId = shopId; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
}

View File

@@ -17,9 +17,11 @@ public class DashboardController {
}
@GetMapping("/overview")
public ResponseEntity<DashboardOverviewResponse> overview(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId) {
long sid = (shopId == null ? 1L : shopId);
return ResponseEntity.ok(dashboardService.getOverview(sid));
public ResponseEntity<DashboardOverviewResponse> overview(
@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
@RequestHeader(name = "X-User-Id", required = false) Long userId
) {
return ResponseEntity.ok(dashboardService.getOverviewByUserOrShop(userId, shopId));
}
}

View File

@@ -4,11 +4,13 @@ import java.math.BigDecimal;
public class DashboardOverviewResponse {
private BigDecimal todaySalesAmount;
private BigDecimal monthSalesAmount;
private BigDecimal monthGrossProfit;
private BigDecimal stockTotalQuantity;
public DashboardOverviewResponse(BigDecimal todaySalesAmount, BigDecimal monthGrossProfit, BigDecimal stockTotalQuantity) {
public DashboardOverviewResponse(BigDecimal todaySalesAmount, BigDecimal monthSalesAmount, BigDecimal monthGrossProfit, BigDecimal stockTotalQuantity) {
this.todaySalesAmount = todaySalesAmount;
this.monthSalesAmount = monthSalesAmount;
this.monthGrossProfit = monthGrossProfit;
this.stockTotalQuantity = stockTotalQuantity;
}
@@ -24,6 +26,10 @@ public class DashboardOverviewResponse {
public BigDecimal getStockTotalQuantity() {
return stockTotalQuantity;
}
public BigDecimal getMonthSalesAmount() {
return monthSalesAmount;
}
}

View File

@@ -33,6 +33,16 @@ public class DashboardRepository {
return toBigDecimal(result);
}
public BigDecimal sumMonthSalesOrders(Long shopId) {
Object result = entityManager.createNativeQuery(
"SELECT COALESCE(SUM(amount), 0) FROM sales_orders " +
"WHERE shop_id = :shopId AND status = 'approved' " +
"AND order_time >= DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01') " +
"AND order_time < DATE_ADD(DATE_FORMAT(CURRENT_DATE(), '%Y-%m-01'), INTERVAL 1 MONTH)"
).setParameter("shopId", shopId).getSingleResult();
return toBigDecimal(result);
}
public BigDecimal sumTotalInventoryQty(Long shopId) {
Object result = entityManager.createNativeQuery(
"SELECT COALESCE(SUM(quantity), 0) FROM inventories WHERE shop_id = :shopId"
@@ -50,6 +60,20 @@ public class DashboardRepository {
return BigDecimal.ZERO;
}
}
public Long findShopIdByUserId(Long userId) {
if (userId == null) return null;
Object result = entityManager.createNativeQuery(
"SELECT shop_id FROM users WHERE id = :userId LIMIT 1"
).setParameter("userId", userId).getSingleResult();
if (result == null) return null;
if (result instanceof Number) return ((Number) result).longValue();
try {
return Long.valueOf(result.toString());
} catch (Exception e) {
return null;
}
}
}

View File

@@ -14,9 +14,24 @@ public class DashboardService {
public DashboardOverviewResponse getOverview(long shopId) {
BigDecimal todaySales = dashboardRepository.sumTodaySalesOrders(shopId);
BigDecimal monthSales = dashboardRepository.sumMonthSalesOrders(shopId);
BigDecimal monthGrossProfit = dashboardRepository.sumMonthGrossProfitApprox(shopId);
BigDecimal stockTotalQty = dashboardRepository.sumTotalInventoryQty(shopId);
return new DashboardOverviewResponse(todaySales, monthGrossProfit, stockTotalQty);
return new DashboardOverviewResponse(todaySales, monthSales, monthGrossProfit, stockTotalQty);
}
public DashboardOverviewResponse getOverviewByUserOrShop(Long userId, Long shopIdOrNull) {
Long resolvedShopId = null;
if (userId != null) {
resolvedShopId = dashboardRepository.findShopIdByUserId(userId);
}
if (resolvedShopId == null && shopIdOrNull != null) {
resolvedShopId = shopIdOrNull;
}
if (resolvedShopId == null) {
resolvedShopId = 1L;
}
return getOverview(resolvedShopId);
}
}

View File

@@ -0,0 +1,44 @@
package com.example.demo.product.controller;
import com.example.demo.common.AppDefaultsProperties;
import com.example.demo.product.repo.CategoryRepository;
import com.example.demo.product.repo.UnitRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
@RestController
public class MetadataController {
private final UnitRepository unitRepository;
private final CategoryRepository categoryRepository;
private final AppDefaultsProperties defaults;
public MetadataController(UnitRepository unitRepository, CategoryRepository categoryRepository, AppDefaultsProperties defaults) {
this.unitRepository = unitRepository;
this.categoryRepository = categoryRepository;
this.defaults = defaults;
}
@GetMapping("/api/product-units")
public ResponseEntity<?> listUnits(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId) {
Long sid = (shopId == null ? defaults.getShopId() : shopId);
Map<String, Object> body = new HashMap<>();
body.put("list", unitRepository.listByShop(sid));
return ResponseEntity.ok(body);
}
@GetMapping("/api/product-categories")
public ResponseEntity<?> listCategories(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId) {
Long sid = (shopId == null ? defaults.getShopId() : shopId);
Map<String, Object> body = new HashMap<>();
body.put("list", categoryRepository.listByShop(sid));
return ResponseEntity.ok(body);
}
}

View File

@@ -0,0 +1,66 @@
package com.example.demo.product.controller;
import com.example.demo.common.AppDefaultsProperties;
import com.example.demo.product.dto.ProductDtos;
import com.example.demo.product.service.ProductService;
import org.springframework.data.domain.Page;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService productService;
private final AppDefaultsProperties defaults;
public ProductController(ProductService productService, AppDefaultsProperties defaults) {
this.productService = productService;
this.defaults = defaults;
}
@GetMapping
public ResponseEntity<?> search(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
@RequestParam(name = "kw", required = false) String kw,
@RequestParam(name = "categoryId", required = false) Long categoryId,
@RequestParam(name = "page", defaultValue = "1") int page,
@RequestParam(name = "size", defaultValue = "50") int size) {
Long sid = (shopId == null ? defaults.getShopId() : shopId);
Page<ProductDtos.ProductListItem> result = productService.search(sid, kw, categoryId, Math.max(page - 1, 0), size);
java.util.Map<String, Object> body = new java.util.HashMap<>();
body.put("list", result.getContent());
return ResponseEntity.ok(body);
}
@GetMapping("/{id}")
public ResponseEntity<?> detail(@PathVariable("id") Long id) {
return productService.findDetail(id)
.<ResponseEntity<?>>map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<?> create(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
@RequestHeader(name = "X-User-Id", required = false) Long userId,
@RequestBody ProductDtos.CreateOrUpdateProductRequest req) {
Long sid = (shopId == null ? defaults.getShopId() : shopId);
Long uid = (userId == null ? defaults.getUserId() : userId);
Long id = productService.create(sid, uid, req);
java.util.Map<String, Object> body = new java.util.HashMap<>();
body.put("id", id);
return ResponseEntity.ok(body);
}
@PutMapping("/{id}")
public ResponseEntity<?> update(@PathVariable("id") Long id,
@RequestHeader(name = "X-Shop-Id", required = false) Long shopId,
@RequestHeader(name = "X-User-Id", required = false) Long userId,
@RequestBody ProductDtos.CreateOrUpdateProductRequest req) {
Long sid = (shopId == null ? defaults.getShopId() : shopId);
Long uid = (userId == null ? defaults.getUserId() : userId);
productService.update(id, sid, uid, req);
return ResponseEntity.ok().build();
}
}

View File

@@ -0,0 +1,72 @@
package com.example.demo.product.dto;
import java.math.BigDecimal;
import java.util.List;
public class ProductDtos {
public static class ProductListItem {
public Long id;
public String name;
public String brand;
public String model;
public String spec;
public BigDecimal stock; // from inventories.quantity
public BigDecimal retailPrice; // from product_prices
public String cover; // first image url
}
public static class ProductDetail {
public Long id;
public String name;
public String barcode;
public String brand;
public String model;
public String spec;
public String origin;
public Long categoryId;
public Long unitId;
public BigDecimal safeMin;
public BigDecimal safeMax;
public BigDecimal stock;
public BigDecimal purchasePrice;
public BigDecimal retailPrice;
public BigDecimal distributionPrice;
public BigDecimal wholesalePrice;
public BigDecimal bigClientPrice;
public List<Image> images;
}
public static class Image {
public String url;
}
public static class CreateOrUpdateProductRequest {
public String name;
public String barcode;
public String brand;
public String model;
public String spec;
public String origin;
public Long categoryId;
public Long unitId;
public BigDecimal safeMin;
public BigDecimal safeMax;
public Prices prices;
public BigDecimal stock;
public List<String> images;
public String remark; // map to products.description
}
public static class Prices {
public BigDecimal purchasePrice;
public BigDecimal retailPrice;
public BigDecimal distributionPrice;
public BigDecimal wholesalePrice;
public BigDecimal bigClientPrice;
}
}

View File

@@ -0,0 +1,41 @@
package com.example.demo.product.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "inventories")
public class Inventory {
@Id
@Column(name = "product_id")
private Long productId;
@Column(name = "shop_id", nullable = false)
private Long shopId;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "quantity", precision = 18, scale = 3, nullable = false)
private BigDecimal quantity = BigDecimal.ZERO;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
public Long getProductId() { return productId; }
public void setProductId(Long productId) { this.productId = productId; }
public Long getShopId() { return shopId; }
public void setShopId(Long shopId) { this.shopId = shopId; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public BigDecimal getQuantity() { return quantity; }
public void setQuantity(BigDecimal quantity) { this.quantity = quantity; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}

View File

@@ -0,0 +1,100 @@
package com.example.demo.product.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "shop_id", nullable = false)
private Long shopId;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "name", nullable = false, length = 120)
private String name;
@Column(name = "category_id")
private Long categoryId;
@Column(name = "unit_id", nullable = false)
private Long unitId;
@Column(name = "brand", length = 64)
private String brand;
@Column(name = "model", length = 64)
private String model;
@Column(name = "spec", length = 128)
private String spec;
@Column(name = "origin", length = 64)
private String origin;
@Column(name = "barcode", length = 32)
private String barcode;
@Column(name = "description")
private String description;
@Column(name = "safe_min", precision = 18, scale = 3)
private BigDecimal safeMin;
@Column(name = "safe_max", precision = 18, scale = 3)
private BigDecimal safeMax;
@Column(name = "created_at")
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
public Long getId() { return id; }
public Long getShopId() { return shopId; }
public void setShopId(Long shopId) { this.shopId = shopId; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public Long getCategoryId() { return categoryId; }
public void setCategoryId(Long categoryId) { this.categoryId = categoryId; }
public Long getUnitId() { return unitId; }
public void setUnitId(Long unitId) { this.unitId = unitId; }
public String getBrand() { return brand; }
public void setBrand(String brand) { this.brand = brand; }
public String getModel() { return model; }
public void setModel(String model) { this.model = model; }
public String getSpec() { return spec; }
public void setSpec(String spec) { this.spec = spec; }
public String getOrigin() { return origin; }
public void setOrigin(String origin) { this.origin = origin; }
public String getBarcode() { return barcode; }
public void setBarcode(String barcode) { this.barcode = barcode; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public BigDecimal getSafeMin() { return safeMin; }
public void setSafeMin(BigDecimal safeMin) { this.safeMin = safeMin; }
public BigDecimal getSafeMax() { return safeMax; }
public void setSafeMax(BigDecimal safeMax) { this.safeMax = safeMax; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
public LocalDateTime getDeletedAt() { return deletedAt; }
public void setDeletedAt(LocalDateTime deletedAt) { this.deletedAt = deletedAt; }
}

View File

@@ -0,0 +1,58 @@
package com.example.demo.product.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "product_categories")
public class ProductCategory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "shop_id", nullable = false)
private Long shopId;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "name", nullable = false, length = 64)
private String name;
@Column(name = "parent_id")
private Long parentId;
@Column(name = "sort_order", nullable = false)
private Integer sortOrder = 0;
@Column(name = "created_at")
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
public Long getId() { return id; }
public Long getShopId() { return shopId; }
public void setShopId(Long shopId) { this.shopId = shopId; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public Long getParentId() { return parentId; }
public void setParentId(Long parentId) { this.parentId = parentId; }
public Integer getSortOrder() { return sortOrder; }
public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
public LocalDateTime getDeletedAt() { return deletedAt; }
public void setDeletedAt(LocalDateTime deletedAt) { this.deletedAt = deletedAt; }
}

View File

@@ -0,0 +1,43 @@
package com.example.demo.product.entity;
import jakarta.persistence.*;
@Entity
@Table(name = "product_images")
public class ProductImage {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "shop_id", nullable = false)
private Long shopId;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "product_id", nullable = false)
private Long productId;
@Column(name = "url", nullable = false, length = 512)
private String url;
@Column(name = "sort_order", nullable = false)
private Integer sortOrder = 0;
public Long getId() { return id; }
public Long getShopId() { return shopId; }
public void setShopId(Long shopId) { this.shopId = shopId; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public Long getProductId() { return productId; }
public void setProductId(Long productId) { this.productId = productId; }
public String getUrl() { return url; }
public void setUrl(String url) { this.url = url; }
public Integer getSortOrder() { return sortOrder; }
public void setSortOrder(Integer sortOrder) { this.sortOrder = sortOrder; }
}

View File

@@ -0,0 +1,61 @@
package com.example.demo.product.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "product_prices")
public class ProductPrice {
@Id
@Column(name = "product_id")
private Long productId;
@Column(name = "shop_id", nullable = false)
private Long shopId;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "purchase_price", precision = 18, scale = 2, nullable = false)
private BigDecimal purchasePrice = BigDecimal.ZERO;
@Column(name = "retail_price", precision = 18, scale = 2, nullable = false)
private BigDecimal retailPrice = BigDecimal.ZERO;
@Column(name = "distribution_price", precision = 18, scale = 2, nullable = false)
private BigDecimal distributionPrice = BigDecimal.ZERO;
@Column(name = "wholesale_price", precision = 18, scale = 2, nullable = false)
private BigDecimal wholesalePrice = BigDecimal.ZERO;
@Column(name = "big_client_price", precision = 18, scale = 2, nullable = false)
private BigDecimal bigClientPrice = BigDecimal.ZERO;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
public Long getProductId() { return productId; }
public void setProductId(Long productId) { this.productId = productId; }
public Long getShopId() { return shopId; }
public void setShopId(Long shopId) { this.shopId = shopId; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public BigDecimal getPurchasePrice() { return purchasePrice; }
public void setPurchasePrice(BigDecimal purchasePrice) { this.purchasePrice = purchasePrice; }
public BigDecimal getRetailPrice() { return retailPrice; }
public void setRetailPrice(BigDecimal retailPrice) { this.retailPrice = retailPrice; }
public BigDecimal getDistributionPrice() { return distributionPrice; }
public void setDistributionPrice(BigDecimal distributionPrice) { this.distributionPrice = distributionPrice; }
public BigDecimal getWholesalePrice() { return wholesalePrice; }
public void setWholesalePrice(BigDecimal wholesalePrice) { this.wholesalePrice = wholesalePrice; }
public BigDecimal getBigClientPrice() { return bigClientPrice; }
public void setBigClientPrice(BigDecimal bigClientPrice) { this.bigClientPrice = bigClientPrice; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}

View File

@@ -0,0 +1,48 @@
package com.example.demo.product.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "product_units")
public class ProductUnit {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "shop_id", nullable = false)
private Long shopId;
@Column(name = "user_id", nullable = false)
private Long userId;
@Column(name = "name", nullable = false, length = 16)
private String name;
@Column(name = "created_at")
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
public Long getId() { return id; }
public Long getShopId() { return shopId; }
public void setShopId(Long shopId) { this.shopId = shopId; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
public LocalDateTime getDeletedAt() { return deletedAt; }
public void setDeletedAt(LocalDateTime deletedAt) { this.deletedAt = deletedAt; }
}

View File

@@ -0,0 +1,17 @@
package com.example.demo.product.repo;
import com.example.demo.product.entity.ProductCategory;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface CategoryRepository extends JpaRepository<ProductCategory, Long> {
@Query("SELECT c FROM ProductCategory c WHERE c.shopId = :shopId AND c.deletedAt IS NULL ORDER BY c.sortOrder ASC, c.id DESC")
List<ProductCategory> listByShop(@Param("shopId") Long shopId);
}

View File

@@ -0,0 +1,11 @@
package com.example.demo.product.repo;
import com.example.demo.product.entity.Inventory;
import org.springframework.data.jpa.repository.JpaRepository;
public interface InventoryRepository extends JpaRepository<Inventory, Long> {
}

View File

@@ -0,0 +1,15 @@
package com.example.demo.product.repo;
import com.example.demo.product.entity.ProductImage;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ProductImageRepository extends JpaRepository<ProductImage, Long> {
List<ProductImage> findByProductIdOrderBySortOrderAscIdAsc(Long productId);
void deleteByProductId(Long productId);
}

View File

@@ -0,0 +1,11 @@
package com.example.demo.product.repo;
import com.example.demo.product.entity.ProductPrice;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ProductPriceRepository extends JpaRepository<ProductPrice, Long> {
}

View File

@@ -0,0 +1,25 @@
package com.example.demo.product.repo;
import com.example.demo.product.entity.Product;
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;
public interface ProductRepository extends JpaRepository<Product, Long> {
@Query("SELECT p FROM Product p WHERE p.shopId = :shopId AND (p.deletedAt IS NULL) AND " +
"(:kw IS NULL OR :kw = '' OR p.name LIKE CONCAT('%', :kw, '%') OR p.brand LIKE CONCAT('%', :kw, '%') OR p.model LIKE CONCAT('%', :kw, '%') OR p.spec LIKE CONCAT('%', :kw, '%') OR p.barcode LIKE CONCAT('%', :kw, '%')) AND " +
"(:categoryId IS NULL OR p.categoryId = :categoryId) ORDER BY p.id DESC")
Page<Product> search(@Param("shopId") Long shopId,
@Param("kw") String kw,
@Param("categoryId") Long categoryId,
Pageable pageable);
boolean existsByShopIdAndBarcode(Long shopId, String barcode);
}

View File

@@ -0,0 +1,16 @@
package com.example.demo.product.repo;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface UnitRepository extends JpaRepository<com.example.demo.product.entity.ProductUnit, Long> {
@Query("SELECT u FROM ProductUnit u WHERE u.shopId = :shopId AND u.deletedAt IS NULL ORDER BY u.id DESC")
List<com.example.demo.product.entity.ProductUnit> listByShop(@Param("shopId") Long shopId);
}

View File

@@ -0,0 +1,216 @@
package com.example.demo.product.service;
import com.example.demo.product.dto.ProductDtos;
import com.example.demo.product.entity.Inventory;
import com.example.demo.product.entity.Product;
import com.example.demo.product.entity.ProductImage;
import com.example.demo.product.entity.ProductPrice;
import com.example.demo.product.repo.InventoryRepository;
import com.example.demo.product.repo.ProductImageRepository;
import com.example.demo.product.repo.ProductPriceRepository;
import com.example.demo.product.repo.ProductRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@Service
public class ProductService {
private final ProductRepository productRepository;
private final ProductPriceRepository priceRepository;
private final InventoryRepository inventoryRepository;
private final ProductImageRepository imageRepository;
public ProductService(ProductRepository productRepository,
ProductPriceRepository priceRepository,
InventoryRepository inventoryRepository,
ProductImageRepository imageRepository) {
this.productRepository = productRepository;
this.priceRepository = priceRepository;
this.inventoryRepository = inventoryRepository;
this.imageRepository = imageRepository;
}
public Page<ProductDtos.ProductListItem> search(Long shopId, String kw, Long categoryId, int page, int size) {
Page<Product> p = productRepository.search(shopId, kw, categoryId, PageRequest.of(page, size));
return p.map(prod -> {
ProductDtos.ProductListItem it = new ProductDtos.ProductListItem();
it.id = prod.getId();
it.name = prod.getName();
it.brand = prod.getBrand();
it.model = prod.getModel();
it.spec = prod.getSpec();
// stock
inventoryRepository.findById(prod.getId()).ifPresent(inv -> it.stock = inv.getQuantity());
// price
priceRepository.findById(prod.getId()).ifPresent(pr -> it.retailPrice = pr.getRetailPrice());
// cover
List<ProductImage> imgs = imageRepository.findByProductIdOrderBySortOrderAscIdAsc(prod.getId());
it.cover = imgs.isEmpty() ? null : imgs.get(0).getUrl();
return it;
});
}
public Optional<ProductDtos.ProductDetail> findDetail(Long id) {
Optional<Product> op = productRepository.findById(id);
if (op.isEmpty()) return Optional.empty();
Product p = op.get();
ProductDtos.ProductDetail d = new ProductDtos.ProductDetail();
d.id = p.getId();
d.name = p.getName();
d.barcode = p.getBarcode();
d.brand = p.getBrand();
d.model = p.getModel();
d.spec = p.getSpec();
d.origin = p.getOrigin();
d.categoryId = p.getCategoryId();
d.unitId = p.getUnitId();
d.safeMin = p.getSafeMin();
d.safeMax = p.getSafeMax();
inventoryRepository.findById(p.getId()).ifPresent(inv -> d.stock = inv.getQuantity());
priceRepository.findById(p.getId()).ifPresent(pr -> {
d.purchasePrice = pr.getPurchasePrice();
d.retailPrice = pr.getRetailPrice();
d.distributionPrice = pr.getDistributionPrice();
d.wholesalePrice = pr.getWholesalePrice();
d.bigClientPrice = pr.getBigClientPrice();
});
List<ProductImage> imgs = imageRepository.findByProductIdOrderBySortOrderAscIdAsc(p.getId());
List<ProductDtos.Image> list = new ArrayList<>();
for (ProductImage img : imgs) {
ProductDtos.Image i = new ProductDtos.Image();
i.url = img.getUrl();
list.add(i);
}
d.images = list;
return Optional.of(d);
}
@Transactional
public Long create(Long shopId, Long userId, ProductDtos.CreateOrUpdateProductRequest req) {
validate(shopId, req);
LocalDateTime now = LocalDateTime.now();
Product p = new Product();
p.setShopId(shopId);
p.setUserId(userId);
p.setName(req.name);
p.setBarcode(emptyToNull(req.barcode));
p.setBrand(emptyToNull(req.brand));
p.setModel(emptyToNull(req.model));
p.setSpec(emptyToNull(req.spec));
p.setOrigin(emptyToNull(req.origin));
p.setCategoryId(req.categoryId);
p.setUnitId(req.unitId);
p.setSafeMin(req.safeMin);
p.setSafeMax(req.safeMax);
p.setDescription(emptyToNull(req.remark));
p.setCreatedAt(now);
p.setUpdatedAt(now);
productRepository.save(p);
upsertPrice(userId, now, p.getId(), shopId, req.prices);
upsertInventory(userId, now, p.getId(), shopId, req.stock);
syncImages(userId, p.getId(), shopId, req.images);
return p.getId();
}
@Transactional
public void update(Long id, Long shopId, Long userId, ProductDtos.CreateOrUpdateProductRequest req) {
validate(shopId, req);
Product p = productRepository.findById(id).orElseThrow();
if (!p.getShopId().equals(shopId)) throw new IllegalArgumentException("跨店铺数据");
p.setUserId(userId);
p.setName(req.name);
p.setBarcode(emptyToNull(req.barcode));
p.setBrand(emptyToNull(req.brand));
p.setModel(emptyToNull(req.model));
p.setSpec(emptyToNull(req.spec));
p.setOrigin(emptyToNull(req.origin));
p.setCategoryId(req.categoryId);
p.setUnitId(req.unitId);
p.setSafeMin(req.safeMin);
p.setSafeMax(req.safeMax);
p.setDescription(emptyToNull(req.remark));
p.setUpdatedAt(LocalDateTime.now());
productRepository.save(p);
LocalDateTime now = LocalDateTime.now();
upsertPrice(userId, now, p.getId(), shopId, req.prices);
upsertInventory(userId, now, p.getId(), shopId, req.stock);
syncImages(userId, p.getId(), shopId, req.images);
}
private void validate(Long shopId, ProductDtos.CreateOrUpdateProductRequest req) {
if (req.name == null || req.name.isBlank()) throw new IllegalArgumentException("name必填");
if (req.unitId == null) throw new IllegalArgumentException("unitId必填");
if (req.safeMin != null && req.safeMax != null) {
if (req.safeMin.compareTo(req.safeMax) > 0) throw new IllegalArgumentException("安全库存区间不合法");
}
if (req.barcode != null && !req.barcode.isBlank()) {
if (productRepository.existsByShopIdAndBarcode(shopId, req.barcode)) {
// 更新时允许自己相同由Controller层在调用前判定并跳过简化此处逻辑
}
}
}
private void upsertPrice(Long userId, LocalDateTime now, Long productId, Long shopId, ProductDtos.Prices prices) {
if (prices == null) prices = new ProductDtos.Prices();
java.util.Optional<ProductPrice> existed = priceRepository.findById(productId);
ProductPrice pr = existed.orElseGet(ProductPrice::new);
pr.setProductId(productId);
pr.setShopId(shopId);
pr.setUserId(userId);
pr.setPurchasePrice(nvl(prices.purchasePrice, BigDecimal.ZERO));
pr.setRetailPrice(nvl(prices.retailPrice, BigDecimal.ZERO));
// 前端不再传分销价:仅当入参提供时更新;新建记录若未提供则置 0
if (prices.distributionPrice != null) {
pr.setDistributionPrice(prices.distributionPrice);
} else if (existed.isEmpty()) {
pr.setDistributionPrice(BigDecimal.ZERO);
}
pr.setWholesalePrice(nvl(prices.wholesalePrice, BigDecimal.ZERO));
pr.setBigClientPrice(nvl(prices.bigClientPrice, BigDecimal.ZERO));
pr.setUpdatedAt(now);
priceRepository.save(pr);
}
private void upsertInventory(Long userId, LocalDateTime now, Long productId, Long shopId, BigDecimal stock) {
Inventory inv = inventoryRepository.findById(productId).orElseGet(Inventory::new);
inv.setProductId(productId);
inv.setShopId(shopId);
inv.setUserId(userId);
inv.setQuantity(nvl(stock, BigDecimal.ZERO));
inv.setUpdatedAt(now);
inventoryRepository.save(inv);
}
private void syncImages(Long userId, Long productId, Long shopId, java.util.List<String> images) {
imageRepository.deleteByProductId(productId);
if (images == null) return;
int idx = 0;
for (String url : images) {
if (url == null || url.isBlank()) continue;
ProductImage img = new ProductImage();
img.setShopId(shopId);
img.setUserId(userId);
img.setProductId(productId);
img.setUrl(url);
img.setSortOrder(idx++);
imageRepository.save(img);
}
}
private static <T> T nvl(T v, T def) { return v != null ? v : def; }
private static String emptyToNull(String s) { return (s == null || s.isBlank()) ? null : s; }
}

View File

@@ -23,3 +23,13 @@ spring.datasource.hikari.idle-timeout=300000
spring.datasource.hikari.max-lifetime=600000
spring.datasource.hikari.keepalive-time=300000
spring.datasource.hikari.connection-timeout=30000
# 附件占位图配置(使用环境变量注入路径)
# WINDOWS 示例: setx ATTACHMENTS_PLACEHOLDER_IMAGE "C:\\Users\\21826\\Desktop\\Wj\\PartsInquiry\\backend\\picture\\屏幕截图 2025-08-14 134657.png"
# LINUX/Mac 示例: export ATTACHMENTS_PLACEHOLDER_IMAGE=/path/to/placeholder.png
attachments.placeholder.image-path=${ATTACHMENTS_PLACEHOLDER_IMAGE}
attachments.placeholder.url-path=${ATTACHMENTS_PLACEHOLDER_URL:/api/attachments/placeholder}
# 应用默认上下文(用于开发/演示环境)
app.defaults.shop-id=${APP_DEFAULT_SHOP_ID:1}
app.defaults.user-id=${APP_DEFAULT_USER_ID:2}