diff --git a/backend/doc/同步文档.md b/backend/doc/同步文档.md index 01723bf..8d14206 100644 --- a/backend/doc/同步文档.md +++ b/backend/doc/同步文档.md @@ -1,6 +1,6 @@ ## 前后端数据库状态说明 -**更新日期**: 2025-09-16 +**更新日期**: 2025-09-17 ### 概要 - 数据库已落地:已在远程 MySQL `mysql.tonaspace.com` 的 `partsinquiry` 库完成初始化(表结构与触发器已创建)。 @@ -27,12 +27,15 @@ - 关闭:不设置/置为 `false` 即可停用(生产环境默认关闭)。 - 完全移除:删除 `frontend/common/config.js` 中默认用户配置与 `frontend/common/http.js` 中注入逻辑。 -### 后端(Spring Boot)数据库状态 -- 依赖:`pom.xml` 未包含 `spring-boot-starter-web`、`spring-boot-starter-data-jpa`、`mysql-connector-j` 等数据库相关依赖。 -- 配置:`src/main/resources/application.properties` 仅有 `spring.application.name=demo`;未配置 `spring.datasource.*`、`spring.jpa.*`。 -- 数据模型:`src/main/java` 未发现 `@Entity`、Repository、Service;存在 `backend/db/db.sql` 脚本,已执行至远程库。 -- 迁移:未发现 Flyway/Liquibase 配置与脚本(当前通过 MysqlMCP 手工执行)。 -- 结论:数据库已初始化,但后端未配置运行时数据源与接口,暂不可用。 +### 后端(Spring Boot)状态 +- 依赖:`pom.xml` 已包含 `spring-boot-starter-web`、`spring-boot-starter-data-jpa`、`mysql-connector-j`。 +- 配置:`application.properties` 使用环境变量注入数据源,已补充 Hikari/JPA;新增附件占位图配置: + - `attachments.placeholder.image-path`(env: `ATTACHMENTS_PLACEHOLDER_IMAGE`) + - `attachments.placeholder.url-path`(env: `ATTACHMENTS_PLACEHOLDER_URL`,默认 `/api/attachments/placeholder`) +- 接口:新增附件相关接口(占位方案): + - POST `/api/attachments`:忽略内容,返回 `{ url: "/api/attachments/placeholder" }` + - GET `/api/attachments/placeholder`:返回本地占位图二进制 +- 迁移:仍建议引入 Flyway/Liquibase;结构变更继续通过 MysqlMCP 并同步 `/doc/database_documentation.md`。 ### 前端(uni-app)数据库状态 - 数据持久化:未见 IndexedDB/WebSQL/SQLite/云数据库使用;页面数据为内置静态数据。 @@ -49,5 +52,26 @@ - 引入迁移工具(Flyway/Liquibase)管理 DDL;后续所有变更继续通过 MysqlMCP 执行,并同步 `/doc/database_documentation.md`。 - 增加健康检查与基础 CRUD 接口;在 `/doc/openapi.yaml` 按规范登记并标注实现状态(❌/✅)。 +### 前端默认连接策略 +- 默认后端地址:`http://192.168.31.193:8080`(可被环境变量/Storage 覆盖) +- 多地址重试:按顺序尝试(去重处理):`[ENV, Storage, 192.168.31.193:8080, 127.0.0.1:8080, localhost:8080]` +- 默认用户:开启(可被环境变量/Storage 关闭),请求自动附带 `X-User-Id`(默认 `2`)。 +- 如需关闭:在 Storage 或构建环境中设置 `ENABLE_DEFAULT_USER=false`。 + + +### 占位图策略(当前阶段) +- 说明:所有图片上传与展示均统一使用占位图,实际文件存储暂不开发。 +- 本地占位图:`C:\Users\21826\Desktop\Wj\PartsInquiry\backend\picture\屏幕截图 2025-08-14 134657.png` +- 配置方式: + - PowerShell(当前用户持久化) + ```powershell + setx ATTACHMENTS_PLACEHOLDER_IMAGE "C:\\Users\\21826\\Desktop\\Wj\\PartsInquiry\\backend\\picture\\屏幕截图 2025-08-14 134657.png" + setx ATTACHMENTS_PLACEHOLDER_URL "/api/attachments/placeholder" + ``` + - 应用重启后生效;也可在运行环境变量中注入。 +- 前端影响: + - `components/ImageUploader.vue` 上传始终得到 `{ url: '/api/attachments/placeholder' }` + - 商品列表/详情展示该占位图地址 + diff --git a/backend/picture/屏幕截图 2025-08-14 134657.png b/backend/picture/屏幕截图 2025-08-14 134657.png new file mode 100644 index 0000000..181db18 Binary files /dev/null and b/backend/picture/屏幕截图 2025-08-14 134657.png differ diff --git a/backend/src/main/java/com/example/demo/attachment/AttachmentController.java b/backend/src/main/java/com/example/demo/attachment/AttachmentController.java new file mode 100644 index 0000000..aeb74a8 --- /dev/null +++ b/backend/src/main/java/com/example/demo/attachment/AttachmentController.java @@ -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> 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 body = new HashMap<>(); + body.put("url", url); + return ResponseEntity.ok(body); + } + + @GetMapping("/placeholder") + public ResponseEntity 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); + } +} + + + + diff --git a/backend/src/main/java/com/example/demo/attachment/AttachmentPlaceholderProperties.java b/backend/src/main/java/com/example/demo/attachment/AttachmentPlaceholderProperties.java new file mode 100644 index 0000000..8eb576b --- /dev/null +++ b/backend/src/main/java/com/example/demo/attachment/AttachmentPlaceholderProperties.java @@ -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; + } +} + + + + diff --git a/backend/src/main/java/com/example/demo/common/AppDefaultsProperties.java b/backend/src/main/java/com/example/demo/common/AppDefaultsProperties.java new file mode 100644 index 0000000..80d3e69 --- /dev/null +++ b/backend/src/main/java/com/example/demo/common/AppDefaultsProperties.java @@ -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; } +} + + + + diff --git a/backend/src/main/java/com/example/demo/dashboard/DashboardController.java b/backend/src/main/java/com/example/demo/dashboard/DashboardController.java index 4b27279..757747a 100644 --- a/backend/src/main/java/com/example/demo/dashboard/DashboardController.java +++ b/backend/src/main/java/com/example/demo/dashboard/DashboardController.java @@ -17,9 +17,11 @@ public class DashboardController { } @GetMapping("/overview") - public ResponseEntity overview(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId) { - long sid = (shopId == null ? 1L : shopId); - return ResponseEntity.ok(dashboardService.getOverview(sid)); + public ResponseEntity 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)); } } diff --git a/backend/src/main/java/com/example/demo/dashboard/DashboardOverviewResponse.java b/backend/src/main/java/com/example/demo/dashboard/DashboardOverviewResponse.java index 577d511..37c4f7f 100644 --- a/backend/src/main/java/com/example/demo/dashboard/DashboardOverviewResponse.java +++ b/backend/src/main/java/com/example/demo/dashboard/DashboardOverviewResponse.java @@ -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; + } } diff --git a/backend/src/main/java/com/example/demo/dashboard/DashboardRepository.java b/backend/src/main/java/com/example/demo/dashboard/DashboardRepository.java index a2a97ed..6c6862c 100644 --- a/backend/src/main/java/com/example/demo/dashboard/DashboardRepository.java +++ b/backend/src/main/java/com/example/demo/dashboard/DashboardRepository.java @@ -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; + } + } } diff --git a/backend/src/main/java/com/example/demo/dashboard/DashboardService.java b/backend/src/main/java/com/example/demo/dashboard/DashboardService.java index 205c58b..4ed60bb 100644 --- a/backend/src/main/java/com/example/demo/dashboard/DashboardService.java +++ b/backend/src/main/java/com/example/demo/dashboard/DashboardService.java @@ -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); } } diff --git a/backend/src/main/java/com/example/demo/product/controller/MetadataController.java b/backend/src/main/java/com/example/demo/product/controller/MetadataController.java new file mode 100644 index 0000000..29cbd9d --- /dev/null +++ b/backend/src/main/java/com/example/demo/product/controller/MetadataController.java @@ -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 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 body = new HashMap<>(); + body.put("list", categoryRepository.listByShop(sid)); + return ResponseEntity.ok(body); + } +} + + diff --git a/backend/src/main/java/com/example/demo/product/controller/ProductController.java b/backend/src/main/java/com/example/demo/product/controller/ProductController.java new file mode 100644 index 0000000..c03952b --- /dev/null +++ b/backend/src/main/java/com/example/demo/product/controller/ProductController.java @@ -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 result = productService.search(sid, kw, categoryId, Math.max(page - 1, 0), size); + java.util.Map 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) + .>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 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(); + } +} + + diff --git a/backend/src/main/java/com/example/demo/product/dto/ProductDtos.java b/backend/src/main/java/com/example/demo/product/dto/ProductDtos.java new file mode 100644 index 0000000..7a6656e --- /dev/null +++ b/backend/src/main/java/com/example/demo/product/dto/ProductDtos.java @@ -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 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 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; + } +} + + + + diff --git a/backend/src/main/java/com/example/demo/product/entity/Inventory.java b/backend/src/main/java/com/example/demo/product/entity/Inventory.java new file mode 100644 index 0000000..16b7910 --- /dev/null +++ b/backend/src/main/java/com/example/demo/product/entity/Inventory.java @@ -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; } +} + + + + diff --git a/backend/src/main/java/com/example/demo/product/entity/Product.java b/backend/src/main/java/com/example/demo/product/entity/Product.java new file mode 100644 index 0000000..1075a67 --- /dev/null +++ b/backend/src/main/java/com/example/demo/product/entity/Product.java @@ -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; } +} + + + + diff --git a/backend/src/main/java/com/example/demo/product/entity/ProductCategory.java b/backend/src/main/java/com/example/demo/product/entity/ProductCategory.java new file mode 100644 index 0000000..bcdc277 --- /dev/null +++ b/backend/src/main/java/com/example/demo/product/entity/ProductCategory.java @@ -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; } +} + + + + diff --git a/backend/src/main/java/com/example/demo/product/entity/ProductImage.java b/backend/src/main/java/com/example/demo/product/entity/ProductImage.java new file mode 100644 index 0000000..0a8029c --- /dev/null +++ b/backend/src/main/java/com/example/demo/product/entity/ProductImage.java @@ -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; } +} + + + + diff --git a/backend/src/main/java/com/example/demo/product/entity/ProductPrice.java b/backend/src/main/java/com/example/demo/product/entity/ProductPrice.java new file mode 100644 index 0000000..1c07c03 --- /dev/null +++ b/backend/src/main/java/com/example/demo/product/entity/ProductPrice.java @@ -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; } +} + + + + diff --git a/backend/src/main/java/com/example/demo/product/entity/ProductUnit.java b/backend/src/main/java/com/example/demo/product/entity/ProductUnit.java new file mode 100644 index 0000000..28b5f76 --- /dev/null +++ b/backend/src/main/java/com/example/demo/product/entity/ProductUnit.java @@ -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; } +} + + + + diff --git a/backend/src/main/java/com/example/demo/product/repo/CategoryRepository.java b/backend/src/main/java/com/example/demo/product/repo/CategoryRepository.java new file mode 100644 index 0000000..be8228d --- /dev/null +++ b/backend/src/main/java/com/example/demo/product/repo/CategoryRepository.java @@ -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 { + @Query("SELECT c FROM ProductCategory c WHERE c.shopId = :shopId AND c.deletedAt IS NULL ORDER BY c.sortOrder ASC, c.id DESC") + List listByShop(@Param("shopId") Long shopId); +} + + + + diff --git a/backend/src/main/java/com/example/demo/product/repo/InventoryRepository.java b/backend/src/main/java/com/example/demo/product/repo/InventoryRepository.java new file mode 100644 index 0000000..95c46c5 --- /dev/null +++ b/backend/src/main/java/com/example/demo/product/repo/InventoryRepository.java @@ -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 { +} + + + + diff --git a/backend/src/main/java/com/example/demo/product/repo/ProductImageRepository.java b/backend/src/main/java/com/example/demo/product/repo/ProductImageRepository.java new file mode 100644 index 0000000..4073ce1 --- /dev/null +++ b/backend/src/main/java/com/example/demo/product/repo/ProductImageRepository.java @@ -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 { + List findByProductIdOrderBySortOrderAscIdAsc(Long productId); + void deleteByProductId(Long productId); +} + + + + diff --git a/backend/src/main/java/com/example/demo/product/repo/ProductPriceRepository.java b/backend/src/main/java/com/example/demo/product/repo/ProductPriceRepository.java new file mode 100644 index 0000000..f42b904 --- /dev/null +++ b/backend/src/main/java/com/example/demo/product/repo/ProductPriceRepository.java @@ -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 { +} + + + + diff --git a/backend/src/main/java/com/example/demo/product/repo/ProductRepository.java b/backend/src/main/java/com/example/demo/product/repo/ProductRepository.java new file mode 100644 index 0000000..c6f49c5 --- /dev/null +++ b/backend/src/main/java/com/example/demo/product/repo/ProductRepository.java @@ -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 { + + @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 search(@Param("shopId") Long shopId, + @Param("kw") String kw, + @Param("categoryId") Long categoryId, + Pageable pageable); + + boolean existsByShopIdAndBarcode(Long shopId, String barcode); +} + + + + diff --git a/backend/src/main/java/com/example/demo/product/repo/UnitRepository.java b/backend/src/main/java/com/example/demo/product/repo/UnitRepository.java new file mode 100644 index 0000000..680f7c4 --- /dev/null +++ b/backend/src/main/java/com/example/demo/product/repo/UnitRepository.java @@ -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 { + @Query("SELECT u FROM ProductUnit u WHERE u.shopId = :shopId AND u.deletedAt IS NULL ORDER BY u.id DESC") + List listByShop(@Param("shopId") Long shopId); +} + + + + diff --git a/backend/src/main/java/com/example/demo/product/service/ProductService.java b/backend/src/main/java/com/example/demo/product/service/ProductService.java new file mode 100644 index 0000000..7b9111f --- /dev/null +++ b/backend/src/main/java/com/example/demo/product/service/ProductService.java @@ -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 search(Long shopId, String kw, Long categoryId, int page, int size) { + Page 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 imgs = imageRepository.findByProductIdOrderBySortOrderAscIdAsc(prod.getId()); + it.cover = imgs.isEmpty() ? null : imgs.get(0).getUrl(); + return it; + }); + } + + public Optional findDetail(Long id) { + Optional 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 imgs = imageRepository.findByProductIdOrderBySortOrderAscIdAsc(p.getId()); + List 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 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 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 nvl(T v, T def) { return v != null ? v : def; } + private static String emptyToNull(String s) { return (s == null || s.isBlank()) ? null : s; } +} + + diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index c98859e..15752d8 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -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} diff --git a/doc/openapi.yaml b/doc/openapi.yaml index 04564ca..b9d1c34 100644 --- a/doc/openapi.yaml +++ b/doc/openapi.yaml @@ -7,6 +7,30 @@ info: servers: - url: / paths: + /api/dashboard/overview: + get: + summary: 首页概览(✅ Fully Implemented) + description: 订单口径的今日销售额(approved)、近似本月毛利(按当前进价近似)与库存总量。支持 X-Shop-Id 或 X-User-Id(优先从用户解析店铺)。后端与前端均已接入。 + parameters: + - in: header + name: X-Shop-Id + required: false + schema: + type: integer + description: 店铺ID,缺省为 1 + - in: header + name: X-User-Id + required: false + schema: + type: integer + description: 用户ID;当未提供 X-Shop-Id 时将用其所属店铺进行统计 + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/DashboardOverview' /api/notices: get: summary: 公告列表(✅ Fully Implemented) @@ -104,8 +128,8 @@ paths: format: int64 /api/products: get: - summary: 商品搜索(❌ Partially Implemented) - description: 前端已接入查询参数 kw/page/size,后端待实现或对齐。 + summary: 商品搜索(✅ Fully Implemented) + description: 支持 kw/page/size/categoryId;返回 {list:[]} 以兼容前端。 parameters: - in: query name: kw @@ -131,6 +155,178 @@ paths: - type: array items: $ref: '#/components/schemas/Product' + post: + summary: 新建商品(✅ Fully Implemented) + description: 保存商品、价格、库存与图片(当前图片统一占位图 URL)。 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProductRequest' + responses: + '200': + description: 成功 + content: + application/json: + schema: + type: object + properties: + id: + type: integer + format: int64 + /api/products/{id}: + get: + summary: 商品详情(✅ Fully Implemented) + parameters: + - in: path + name: id + required: true + schema: { type: integer } + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/ProductDetail' + put: + summary: 更新商品(✅ Fully Implemented) + parameters: + - in: path + name: id + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateProductRequest' + responses: + '200': + description: 成功 + /api/product-categories: + get: + summary: 类别列表(✅ Fully Implemented) + responses: + '200': + description: 成功 + content: + application/json: + schema: + oneOf: + - type: array + items: { $ref: '#/components/schemas/Category' } + - type: object + properties: + list: + type: array + items: { $ref: '#/components/schemas/Category' } + post: + summary: 新增类别(❌ Partially Implemented) + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: { type: string } + /api/product-categories/{id}: + put: + summary: 更新类别(❌ Partially Implemented) + parameters: + - in: path + name: id + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: { type: string } + delete: + summary: 删除类别(❌ Partially Implemented) + parameters: + - in: path + name: id + required: true + schema: { type: integer } + responses: { '200': { description: 成功 } } + /api/product-units: + get: + summary: 单位列表(✅ Fully Implemented) + responses: + '200': + description: 成功 + content: + application/json: + schema: + oneOf: + - type: array + items: { $ref: '#/components/schemas/Unit' } + - type: object + properties: + list: + type: array + items: { $ref: '#/components/schemas/Unit' } + post: + summary: 新增单位(❌ Partially Implemented) + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: { type: string } + /api/product-units/{id}: + put: + summary: 更新单位(❌ Partially Implemented) + parameters: + - in: path + name: id + required: true + schema: { type: integer } + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: { type: string } + delete: + summary: 删除单位(❌ Partially Implemented) + parameters: + - in: path + name: id + required: true + schema: { type: integer } + responses: { '200': { description: 成功 } } + /api/product-settings: + get: + summary: 货品设置读取(❌ Partially Implemented) + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/ProductSettings' + put: + summary: 货品设置保存(❌ Partially Implemented) + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ProductSettings' + responses: { '200': { description: 成功 } } - type: object properties: list: @@ -195,8 +391,63 @@ paths: format: int64 orderNo: type: string + /api/attachments: + post: + summary: 上传附件(✅ Fully Implemented,占位图方案) + description: 接收 multipart 上传但忽略文件内容,始终返回占位图 URL(后端配置项 `attachments.placeholder.image-path` 指向本地占位图片;URL 固定 `/api/attachments/placeholder` 可通过 `attachments.placeholder.url-path` 覆盖)。 + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + ownerType: + type: string + ownerId: + type: string + responses: + '200': + description: 成功 + content: + application/json: + schema: + type: object + properties: + url: + type: string + /api/attachments/placeholder: + get: + summary: 附件占位图读取(✅ Fully Implemented) + description: 返回后端配置的本地占位图内容,路径由 `attachments.placeholder.image-path` 指定。 + responses: + '200': + description: 图片二进制 + content: + image/png: + schema: + type: string + format: binary components: schemas: + DashboardOverview: + type: object + properties: + todaySalesAmount: + type: number + example: 1250.00 + monthSalesAmount: + type: number + example: 26500.00 + monthGrossProfit: + type: number + example: 3560.25 + stockTotalQuantity: + type: number + example: 1300 Notice: type: object properties: @@ -306,6 +557,67 @@ components: type: number stock: type: number + ProductDetail: + allOf: + - $ref: '#/components/schemas/Product' + - type: object + properties: + brand: { type: string } + model: { type: string } + spec: { type: string } + categoryId: { type: integer, format: int64, nullable: true } + unitId: { type: integer, format: int64 } + safeMin: { type: number, nullable: true } + safeMax: { type: number, nullable: true } + purchasePrice: { type: number } + retailPrice: { type: number } + distributionPrice: { type: number } + wholesalePrice: { type: number } + bigClientPrice: { type: number } + images: + type: array + items: + type: object + properties: { url: { type: string } } + CreateProductRequest: + type: object + properties: + name: { type: string } + barcode: { type: string, nullable: true } + brand: { type: string, nullable: true } + model: { type: string, nullable: true } + spec: { type: string, nullable: true } + categoryId: { type: integer, format: int64, nullable: true } + unitId: { type: integer, format: int64 } + safeMin: { type: number, nullable: true } + safeMax: { type: number, nullable: true } + prices: + type: object + properties: + purchasePrice: { type: number } + retailPrice: { type: number } + distributionPrice: { type: number } + wholesalePrice: { type: number } + bigClientPrice: { type: number } + stock: { type: number, nullable: true } + images: + type: array + items: { type: string, description: '图片URL' } + Category: + type: object + properties: + id: { type: integer, format: int64 } + name: { type: string } + Unit: + type: object + properties: + id: { type: integer, format: int64 } + name: { type: string } + ProductSettings: + type: object + properties: + hideZeroStock: { type: boolean } + hidePurchasePrice: { type: boolean } Customer: type: object properties: diff --git a/frontend/common/config.js b/frontend/common/config.js index 784272d..b13c96b 100644 --- a/frontend/common/config.js +++ b/frontend/common/config.js @@ -3,10 +3,14 @@ const envBaseUrl = (typeof process !== 'undefined' && process.env && (process.env.VITE_APP_API_BASE_URL || process.env.API_BASE_URL)) || ''; const storageBaseUrl = typeof uni !== 'undefined' ? (uni.getStorageSync('API_BASE_URL') || '') : ''; -const fallbackBaseUrl = 'http://localhost:8080'; +const fallbackBaseUrl = 'http://192.168.31.193:8080'; export const API_BASE_URL = (envBaseUrl || storageBaseUrl || fallbackBaseUrl).replace(/\/$/, ''); +// 多地址候选(按优先级顺序,自动去重与去尾斜杠) +const candidateBases = [envBaseUrl, storageBaseUrl, fallbackBaseUrl, 'http://127.0.0.1:8080', 'http://localhost:8080']; +export const API_BASE_URL_CANDIDATES = Array.from(new Set(candidateBases.filter(Boolean))).map(u => String(u).replace(/\/$/, '')); + const envShopId = (typeof process !== 'undefined' && process.env && (process.env.VITE_APP_SHOP_ID || process.env.SHOP_ID)) || ''; const storageShopId = typeof uni !== 'undefined' ? (uni.getStorageSync('SHOP_ID') || '') : ''; export const SHOP_ID = Number(envShopId || storageShopId || 1); @@ -18,7 +22,7 @@ export const SHOP_ID = Number(envShopId || storageShopId || 1); // - 生产默认关闭(false);开发可通过本地存储或环境变量开启 const envEnableDefaultUser = (typeof process !== 'undefined' && process.env && (process.env.VITE_APP_ENABLE_DEFAULT_USER || process.env.ENABLE_DEFAULT_USER)) || ''; const storageEnableDefaultUser = typeof uni !== 'undefined' ? (uni.getStorageSync('ENABLE_DEFAULT_USER') || '') : ''; -export const ENABLE_DEFAULT_USER = String(envEnableDefaultUser || storageEnableDefaultUser || 'false').toLowerCase() === 'true'; +export const ENABLE_DEFAULT_USER = String(envEnableDefaultUser || storageEnableDefaultUser || 'true').toLowerCase() === 'true'; const envDefaultUserId = (typeof process !== 'undefined' && process.env && (process.env.VITE_APP_DEFAULT_USER_ID || process.env.DEFAULT_USER_ID)) || ''; const storageDefaultUserId = typeof uni !== 'undefined' ? (uni.getStorageSync('DEFAULT_USER_ID') || '') : ''; diff --git a/frontend/common/constants.js b/frontend/common/constants.js index 9c3a3e7..205cc10 100644 --- a/frontend/common/constants.js +++ b/frontend/common/constants.js @@ -16,3 +16,4 @@ export const EXPENSE_CATEGORIES = [ ] + diff --git a/frontend/common/http.js b/frontend/common/http.js index 1cee3aa..65d5bdc 100644 --- a/frontend/common/http.js +++ b/frontend/common/http.js @@ -1,4 +1,4 @@ -import { API_BASE_URL, SHOP_ID, ENABLE_DEFAULT_USER, DEFAULT_USER_ID } from './config.js' +import { API_BASE_URL, API_BASE_URL_CANDIDATES, SHOP_ID, ENABLE_DEFAULT_USER, DEFAULT_USER_ID } from './config.js' function buildUrl(path) { if (!path) return API_BASE_URL @@ -6,22 +6,26 @@ function buildUrl(path) { return API_BASE_URL + (path.startsWith('/') ? path : '/' + path) } +function requestWithFallback(options, candidates, idx, resolve, reject) { + const base = candidates[idx] || API_BASE_URL + const url = options.url.replace(/^https?:\/\/[^/]+/, base) + uni.request({ ...options, url, success: (res) => { + const { statusCode, data } = res + if (statusCode >= 200 && statusCode < 300) return resolve(data) + if (idx + 1 < candidates.length) return requestWithFallback(options, candidates, idx + 1, resolve, reject) + reject(new Error('HTTP ' + statusCode)) + }, fail: (err) => { + if (idx + 1 < candidates.length) return requestWithFallback(options, candidates, idx + 1, resolve, reject) + reject(err) + } }) +} + export function get(path, params = {}) { return new Promise((resolve, reject) => { const headers = { 'X-Shop-Id': SHOP_ID } if (ENABLE_DEFAULT_USER && DEFAULT_USER_ID) headers['X-User-Id'] = DEFAULT_USER_ID - uni.request({ - url: buildUrl(path), - method: 'GET', - data: params, - header: headers, - success: (res) => { - const { statusCode, data } = res - if (statusCode >= 200 && statusCode < 300) return resolve(data) - reject(new Error('HTTP ' + statusCode)) - }, - fail: (err) => reject(err) - }) + const options = { url: buildUrl(path), method: 'GET', data: params, header: headers } + requestWithFallback(options, API_BASE_URL_CANDIDATES, 0, resolve, reject) }) } @@ -30,19 +34,63 @@ export function post(path, body = {}) { return new Promise((resolve, reject) => { const headers = { 'Content-Type': 'application/json', 'X-Shop-Id': SHOP_ID } if (ENABLE_DEFAULT_USER && DEFAULT_USER_ID) headers['X-User-Id'] = DEFAULT_USER_ID - uni.request({ - url: buildUrl(path), - method: 'POST', - data: body, - header: headers, - success: (res) => { - const { statusCode, data } = res - if (statusCode >= 200 && statusCode < 300) return resolve(data) - reject(new Error('HTTP ' + statusCode)) - }, - fail: (err) => reject(err) - }) + const options = { url: buildUrl(path), method: 'POST', data: body, header: headers } + requestWithFallback(options, API_BASE_URL_CANDIDATES, 0, resolve, reject) }) } +export function put(path, body = {}) { + return new Promise((resolve, reject) => { + const headers = { 'Content-Type': 'application/json', 'X-Shop-Id': SHOP_ID } + if (ENABLE_DEFAULT_USER && DEFAULT_USER_ID) headers['X-User-Id'] = DEFAULT_USER_ID + const options = { url: buildUrl(path), method: 'PUT', data: body, header: headers } + requestWithFallback(options, API_BASE_URL_CANDIDATES, 0, resolve, reject) + }) +} + +export function del(path, body = {}) { + return new Promise((resolve, reject) => { + const headers = { 'Content-Type': 'application/json', 'X-Shop-Id': SHOP_ID } + if (ENABLE_DEFAULT_USER && DEFAULT_USER_ID) headers['X-User-Id'] = DEFAULT_USER_ID + const options = { url: buildUrl(path), method: 'DELETE', data: body, header: headers } + requestWithFallback(options, API_BASE_URL_CANDIDATES, 0, resolve, reject) + }) +} + +function uploadWithFallback(options, candidates, idx, resolve, reject) { + const base = candidates[idx] || API_BASE_URL + const url = options.url.replace(/^https?:\/\/[^/]+/, base) + const uploadOptions = { ...options, url } + uni.uploadFile({ + ...uploadOptions, + success: (res) => { + const statusCode = res.statusCode || 0 + if (statusCode >= 200 && statusCode < 300) { + try { + const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data + return resolve(data) + } catch (e) { + return resolve(res.data) + } + } + if (idx + 1 < candidates.length) return uploadWithFallback(options, candidates, idx + 1, resolve, reject) + reject(new Error('HTTP ' + statusCode)) + }, + fail: (err) => { + if (idx + 1 < candidates.length) return uploadWithFallback(options, candidates, idx + 1, resolve, reject) + reject(err) + } + }) +} + +// 文件上传封装:自动注入租户/用户头并进行多地址回退 +export function upload(path, filePath, formData = {}, name = 'file') { + return new Promise((resolve, reject) => { + const header = { 'X-Shop-Id': SHOP_ID } + if (ENABLE_DEFAULT_USER && DEFAULT_USER_ID) header['X-User-Id'] = DEFAULT_USER_ID + const options = { url: buildUrl(path), filePath, name, formData, header } + uploadWithFallback(options, API_BASE_URL_CANDIDATES, 0, resolve, reject) + }) +} + diff --git a/frontend/components/ImageUploader.vue b/frontend/components/ImageUploader.vue new file mode 100644 index 0000000..f0361e4 --- /dev/null +++ b/frontend/components/ImageUploader.vue @@ -0,0 +1,164 @@ + + + + + + + + + diff --git a/frontend/pages.json b/frontend/pages.json index bea0ba3..b402ec8 100644 --- a/frontend/pages.json +++ b/frontend/pages.json @@ -18,6 +18,36 @@ "navigationBarTitleText": "选择商品" } }, + { + "path": "pages/product/list", + "style": { + "navigationBarTitleText": "货品列表" + } + }, + { + "path": "pages/product/form", + "style": { + "navigationBarTitleText": "编辑货品" + } + }, + { + "path": "pages/product/categories", + "style": { + "navigationBarTitleText": "类别管理" + } + }, + { + "path": "pages/product/units", + "style": { + "navigationBarTitleText": "单位管理" + } + }, + { + "path": "pages/product/settings", + "style": { + "navigationBarTitleText": "货品设置" + } + }, { "path": "pages/customer/select", "style": { diff --git a/frontend/pages/account/select.vue b/frontend/pages/account/select.vue index 414c79a..8271fec 100644 --- a/frontend/pages/account/select.vue +++ b/frontend/pages/account/select.vue @@ -43,3 +43,4 @@ + diff --git a/frontend/pages/customer/select.vue b/frontend/pages/customer/select.vue index 3d3d7c9..3928c88 100644 --- a/frontend/pages/customer/select.vue +++ b/frontend/pages/customer/select.vue @@ -48,3 +48,4 @@ + diff --git a/frontend/pages/index/index.vue b/frontend/pages/index/index.vue index 0254ec3..a189bfc 100644 --- a/frontend/pages/index/index.vue +++ b/frontend/pages/index/index.vue @@ -69,7 +69,7 @@ 首页 - + 货品 @@ -99,6 +99,7 @@ loadingNotices: false, noticeError: '', features: [ + { key: 'product', title: '货品', img: '/static/icons/product.png', emoji: '📦' }, { key: 'customer', title: '客户', img: '/static/icons/customer.png', emoji: '👥' }, { key: 'sale', title: '销售', img: '/static/icons/sale.png', emoji: '💰' }, { key: 'account', title: '账户', img: '/static/icons/account.png', emoji: '💳' }, @@ -118,12 +119,14 @@ methods: { async fetchMetrics() { try { - const d = await get('/api/metrics/overview') + const d = await get('/api/dashboard/overview') + const toNum = v => (typeof v === 'number' ? v : Number(v || 0)) this.kpi = { - todaySales: (d && d.todaySales) || '0.00', - monthSales: (d && d.monthSales) || '0.00', - monthProfit: (d && d.monthProfit) || '0.00', - stockCount: (d && d.stockCount) || '0' + ...this.kpi, + todaySales: toNum(d && d.todaySalesAmount).toFixed(2), + monthSales: toNum(d && d.monthSalesAmount).toFixed(2), + monthProfit: toNum(d && d.monthGrossProfit).toFixed(2), + stockCount: String((d && d.stockTotalQuantity) != null ? d.stockTotalQuantity : 0) } } catch (e) { // 忽略错误,保留默认值 @@ -145,8 +148,16 @@ } }, onFeatureTap(item) { + if (item.key === 'product') { + uni.navigateTo({ url: '/pages/product/list' }) + return + } uni.showToast({ title: item.title + '(开发中)', icon: 'none' }) }, + goProduct() { + this.activeTab = 'product' + uni.navigateTo({ url: '/pages/product/list' }) + }, onCreateOrder() { uni.navigateTo({ url: '/pages/order/create' }) }, diff --git a/frontend/pages/product/categories.vue b/frontend/pages/product/categories.vue new file mode 100644 index 0000000..981671c --- /dev/null +++ b/frontend/pages/product/categories.vue @@ -0,0 +1,67 @@ + + + + + + + + + diff --git a/frontend/pages/product/form.vue b/frontend/pages/product/form.vue new file mode 100644 index 0000000..1d08c41 --- /dev/null +++ b/frontend/pages/product/form.vue @@ -0,0 +1,213 @@ +