From a3bbc0098a28136b20b53ed90d7031f22e2f9d04 Mon Sep 17 00:00:00 2001 From: Wdp-ab <2182606194@qq.com> Date: Wed, 17 Sep 2025 14:40:16 +0800 Subject: [PATCH] 9.17/1 --- backend/doc/同步文档.md | 38 ++- .../picture/屏幕截图 2025-08-14 134657.png | Bin 0 -> 20056 bytes .../demo/attachment/AttachmentController.java | 73 ++++ .../AttachmentPlaceholderProperties.java | 32 ++ .../demo/common/AppDefaultsProperties.java | 21 ++ .../demo/dashboard/DashboardController.java | 8 +- .../dashboard/DashboardOverviewResponse.java | 8 +- .../demo/dashboard/DashboardRepository.java | 24 ++ .../demo/dashboard/DashboardService.java | 17 +- .../controller/MetadataController.java | 44 +++ .../product/controller/ProductController.java | 66 ++++ .../example/demo/product/dto/ProductDtos.java | 72 ++++ .../demo/product/entity/Inventory.java | 41 +++ .../example/demo/product/entity/Product.java | 100 ++++++ .../demo/product/entity/ProductCategory.java | 58 ++++ .../demo/product/entity/ProductImage.java | 43 +++ .../demo/product/entity/ProductPrice.java | 61 ++++ .../demo/product/entity/ProductUnit.java | 48 +++ .../demo/product/repo/CategoryRepository.java | 17 + .../product/repo/InventoryRepository.java | 11 + .../product/repo/ProductImageRepository.java | 15 + .../product/repo/ProductPriceRepository.java | 11 + .../demo/product/repo/ProductRepository.java | 25 ++ .../demo/product/repo/UnitRepository.java | 16 + .../demo/product/service/ProductService.java | 216 ++++++++++++ .../src/main/resources/application.properties | 10 + doc/openapi.yaml | 316 +++++++++++++++++- frontend/common/config.js | 8 +- frontend/common/constants.js | 1 + frontend/common/http.js | 98 ++++-- frontend/components/ImageUploader.vue | 164 +++++++++ frontend/pages.json | 30 ++ frontend/pages/account/select.vue | 1 + frontend/pages/customer/select.vue | 1 + frontend/pages/index/index.vue | 23 +- frontend/pages/product/categories.vue | 67 ++++ frontend/pages/product/form.vue | 213 ++++++++++++ frontend/pages/product/list.vue | 133 ++++++++ frontend/pages/product/select.vue | 1 + frontend/pages/product/settings.vue | 45 +++ frontend/pages/product/units.vue | 67 ++++ frontend/pages/supplier/select.vue | 1 + .../dist/dev/.sourcemap/mp-weixin/app.js.map | 2 +- .../.sourcemap/mp-weixin/common/config.js.map | 2 +- .../mp-weixin/common/constants.js.map | 2 +- .../.sourcemap/mp-weixin/common/http.js.map | 2 +- .../.sourcemap/mp-weixin/common/vendor.js.map | 2 +- .../mp-weixin/components/ImageUploader.js.map | 1 + .../mp-weixin/pages/account/select.js.map | 2 +- .../mp-weixin/pages/customer/select.js.map | 2 +- .../mp-weixin/pages/index/index.js.map | 2 +- .../mp-weixin/pages/product/categories.js.map | 1 + .../mp-weixin/pages/product/edit.js.map | 1 + .../mp-weixin/pages/product/form.js.map | 1 + .../mp-weixin/pages/product/list.js.map | 1 + .../mp-weixin/pages/product/select.js.map | 2 +- .../mp-weixin/pages/product/settings.js.map | 1 + .../mp-weixin/pages/product/units.js.map | 1 + .../mp-weixin/pages/supplier/select.js.map | 2 +- frontend/unpackage/dist/dev/mp-weixin/app.js | 5 + .../unpackage/dist/dev/mp-weixin/app.json | 5 + .../dist/dev/mp-weixin/common/config.js | 7 +- .../dist/dev/mp-weixin/common/http.js | 103 ++++-- .../dist/dev/mp-weixin/common/vendor.js | 134 +++++++- .../dev/mp-weixin/components/ImageUploader.js | 144 ++++++++ .../mp-weixin/components/ImageUploader.json | 4 + .../mp-weixin/components/ImageUploader.wxml | 1 + .../mp-weixin/components/ImageUploader.wxss | 15 + .../dist/dev/mp-weixin/pages/index/index.js | 23 +- .../dev/mp-weixin/pages/product/categories.js | 62 ++++ .../mp-weixin/pages/product/categories.json | 4 + .../mp-weixin/pages/product/categories.wxml | 1 + .../mp-weixin/pages/product/categories.wxss | 15 + .../dist/dev/mp-weixin/pages/product/edit.js | 251 ++++++++++++++ .../dev/mp-weixin/pages/product/edit.json | 4 + .../dev/mp-weixin/pages/product/edit.wxml | 1 + .../dev/mp-weixin/pages/product/edit.wxss | 35 ++ .../dist/dev/mp-weixin/pages/product/form.js | 249 ++++++++++++++ .../dev/mp-weixin/pages/product/form.json | 6 + .../dev/mp-weixin/pages/product/form.wxml | 1 + .../dev/mp-weixin/pages/product/form.wxss | 17 + .../dist/dev/mp-weixin/pages/product/list.js | 122 +++++++ .../dev/mp-weixin/pages/product/list.json | 4 + .../dev/mp-weixin/pages/product/list.wxml | 1 + .../dev/mp-weixin/pages/product/list.wxss | 33 ++ .../dev/mp-weixin/pages/product/settings.js | 39 +++ .../dev/mp-weixin/pages/product/settings.json | 4 + .../dev/mp-weixin/pages/product/settings.wxml | 1 + .../dev/mp-weixin/pages/product/settings.wxss | 5 + .../dist/dev/mp-weixin/pages/product/units.js | 62 ++++ .../dev/mp-weixin/pages/product/units.json | 4 + .../dev/mp-weixin/pages/product/units.wxml | 1 + .../dev/mp-weixin/pages/product/units.wxss | 15 + .../dev/mp-weixin/project.private.config.json | 5 + 94 files changed, 3549 insertions(+), 105 deletions(-) create mode 100644 backend/picture/屏幕截图 2025-08-14 134657.png create mode 100644 backend/src/main/java/com/example/demo/attachment/AttachmentController.java create mode 100644 backend/src/main/java/com/example/demo/attachment/AttachmentPlaceholderProperties.java create mode 100644 backend/src/main/java/com/example/demo/common/AppDefaultsProperties.java create mode 100644 backend/src/main/java/com/example/demo/product/controller/MetadataController.java create mode 100644 backend/src/main/java/com/example/demo/product/controller/ProductController.java create mode 100644 backend/src/main/java/com/example/demo/product/dto/ProductDtos.java create mode 100644 backend/src/main/java/com/example/demo/product/entity/Inventory.java create mode 100644 backend/src/main/java/com/example/demo/product/entity/Product.java create mode 100644 backend/src/main/java/com/example/demo/product/entity/ProductCategory.java create mode 100644 backend/src/main/java/com/example/demo/product/entity/ProductImage.java create mode 100644 backend/src/main/java/com/example/demo/product/entity/ProductPrice.java create mode 100644 backend/src/main/java/com/example/demo/product/entity/ProductUnit.java create mode 100644 backend/src/main/java/com/example/demo/product/repo/CategoryRepository.java create mode 100644 backend/src/main/java/com/example/demo/product/repo/InventoryRepository.java create mode 100644 backend/src/main/java/com/example/demo/product/repo/ProductImageRepository.java create mode 100644 backend/src/main/java/com/example/demo/product/repo/ProductPriceRepository.java create mode 100644 backend/src/main/java/com/example/demo/product/repo/ProductRepository.java create mode 100644 backend/src/main/java/com/example/demo/product/repo/UnitRepository.java create mode 100644 backend/src/main/java/com/example/demo/product/service/ProductService.java create mode 100644 frontend/components/ImageUploader.vue create mode 100644 frontend/pages/product/categories.vue create mode 100644 frontend/pages/product/form.vue create mode 100644 frontend/pages/product/list.vue create mode 100644 frontend/pages/product/settings.vue create mode 100644 frontend/pages/product/units.vue create mode 100644 frontend/unpackage/dist/dev/.sourcemap/mp-weixin/components/ImageUploader.js.map create mode 100644 frontend/unpackage/dist/dev/.sourcemap/mp-weixin/pages/product/categories.js.map create mode 100644 frontend/unpackage/dist/dev/.sourcemap/mp-weixin/pages/product/edit.js.map create mode 100644 frontend/unpackage/dist/dev/.sourcemap/mp-weixin/pages/product/form.js.map create mode 100644 frontend/unpackage/dist/dev/.sourcemap/mp-weixin/pages/product/list.js.map create mode 100644 frontend/unpackage/dist/dev/.sourcemap/mp-weixin/pages/product/settings.js.map create mode 100644 frontend/unpackage/dist/dev/.sourcemap/mp-weixin/pages/product/units.js.map create mode 100644 frontend/unpackage/dist/dev/mp-weixin/components/ImageUploader.js create mode 100644 frontend/unpackage/dist/dev/mp-weixin/components/ImageUploader.json create mode 100644 frontend/unpackage/dist/dev/mp-weixin/components/ImageUploader.wxml create mode 100644 frontend/unpackage/dist/dev/mp-weixin/components/ImageUploader.wxss create mode 100644 frontend/unpackage/dist/dev/mp-weixin/pages/product/categories.js create mode 100644 frontend/unpackage/dist/dev/mp-weixin/pages/product/categories.json create mode 100644 frontend/unpackage/dist/dev/mp-weixin/pages/product/categories.wxml create mode 100644 frontend/unpackage/dist/dev/mp-weixin/pages/product/categories.wxss create mode 100644 frontend/unpackage/dist/dev/mp-weixin/pages/product/edit.js create mode 100644 frontend/unpackage/dist/dev/mp-weixin/pages/product/edit.json create mode 100644 frontend/unpackage/dist/dev/mp-weixin/pages/product/edit.wxml create mode 100644 frontend/unpackage/dist/dev/mp-weixin/pages/product/edit.wxss create mode 100644 frontend/unpackage/dist/dev/mp-weixin/pages/product/form.js create mode 100644 frontend/unpackage/dist/dev/mp-weixin/pages/product/form.json create mode 100644 frontend/unpackage/dist/dev/mp-weixin/pages/product/form.wxml create mode 100644 frontend/unpackage/dist/dev/mp-weixin/pages/product/form.wxss create mode 100644 frontend/unpackage/dist/dev/mp-weixin/pages/product/list.js create mode 100644 frontend/unpackage/dist/dev/mp-weixin/pages/product/list.json create mode 100644 frontend/unpackage/dist/dev/mp-weixin/pages/product/list.wxml create mode 100644 frontend/unpackage/dist/dev/mp-weixin/pages/product/list.wxss create mode 100644 frontend/unpackage/dist/dev/mp-weixin/pages/product/settings.js create mode 100644 frontend/unpackage/dist/dev/mp-weixin/pages/product/settings.json create mode 100644 frontend/unpackage/dist/dev/mp-weixin/pages/product/settings.wxml create mode 100644 frontend/unpackage/dist/dev/mp-weixin/pages/product/settings.wxss create mode 100644 frontend/unpackage/dist/dev/mp-weixin/pages/product/units.js create mode 100644 frontend/unpackage/dist/dev/mp-weixin/pages/product/units.json create mode 100644 frontend/unpackage/dist/dev/mp-weixin/pages/product/units.wxml create mode 100644 frontend/unpackage/dist/dev/mp-weixin/pages/product/units.wxss create mode 100644 frontend/unpackage/dist/dev/mp-weixin/project.private.config.json 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 0000000000000000000000000000000000000000..181db18d2406eae18e618235203101b532509a3e GIT binary patch literal 20056 zcmYg%1yCGK7cIdxSa5fT0Kwf^Y=Z>1AR$46yL)gCE&&!oa9A9I%VI$mcV}^T-hO|* zdiAPCYO1EXr|<2)bNbwKqd#gW;b4+uA|N2(0F>pm;P-3zIfsr4zxK?M=)-SsT(y*B z5vs;158w~THZtlm2ne+aSWll(;LjM}l?_}G5Z?CwcfJ{PDzidBP(KF9%jkHS9(Nr0 zEQwACVB3D5YI%Gae|@)R)3oA1Xt#cggn$u5%Os+aRcz4ll>o_xQ?pK6tdEqToo-{u z#r__A#FgD=+NFvwTfFpG0zl#*V(P)#UNmD%4dH+dn6!X2^pV<>GhgU{eO?Q z5d?t}<+mDLJ9_n0v%L}O7S%MCJOIN0fhA1|`+3HjhYq@}YNqSeZ*x!a`w~>f1y0@2 zmN4u_QKhTAx7?FPN7uf+X3B>h)0Ow$4|cGiE4ncHHz>{67lwZ!V9Qv*Zk<}Ap!jp6 z`{aM`K4zcmCHYL~*h%U-Sp#i$vfi@lw^eD2^r7=|(^n!mkBP`5L?M)`FL?ZYhlrf! zi6@JO=Yw7t`rjoI9Rb!V8_T;xEv{vdn15;a8jdmrWN; zI%#8@C>XX~pLmrkJ^vr&eNqy7enO+fObFX*IbPy&*5pqc7wtn-^c4h?zm&4Lu-4K( z%M7nFKY5$l!9AzG3YKf=mG~pIi7HqSfmOM^$|eee3=@?xW?}rW!#j>QhjQ8c(Hlh> zj-N;T0jJ7@l>Ca9* z6^z%=RPnaiMs%b4e$v8IU-j)BNftR<#tSm2JE}g5{#>TUG1FSaAWD!^+ZmJuDgQ%8ma^D&LH`OEfTNb)OY`p8bMcoPfTW4Oh1D^Z9>_;^?ID;8reDXbKGXb z7g-MF9u+cIs+mTF!P-3$^Z34#H+Rr%jiY$rVIJ^^5Lw}?ntvdcNgz^TSGapz4X3Ih z#lXKhmN_>9qno*SJvq1y^?g-WjdlrJPJJ1V{m>vTk@6aS%v#mS^Mvw}wR2<`rg+W4 za$&o5In_$`lE>$z)U=!Yo(X-sDdZRdNG2>%610VnFXqp}aDm}M zE_94Q2JA^!juJg=dpOTWAI6Uv$ z^n2wPgIy)(B_1N&PX22QnIN;&S4M&q@z)Ud2OKO3)vaRzx?&PE0e^o-GSi@#8=Z9* zgN7yg#+sy0%_n3=is-qbCACj^L+an2;GMjoUH&UR@$I-aAbYa0jztyj4iugrGnIvg z*-FL+RXhG`y~6Q#oZ`=FR;FLL`G?ONOn2AjzyBbj_@LYQ*6ll{OWPKnohxtor$WTQ zD&-^3NhaAXz)Av*IcefNy!rITQ0s2s8_sfvRd(MxdB_d#Nl{PTrdXLHFjik#QMR6##jXx`@AFhzOS=kg4#*K zWWwoZQYl%ffMTKPGpRIvdS6B`td)u1_F*oFMCNc2uX2^4igWJbGpRW#s?Q}%O20J! z{pCT<&=|{2^-R@3r}-k17!OANq>e_>ToWVrNThNHMl~?PQ7I3q{6(@-s^@(`P{0#1 zzT(e4vcGgSV@B4%FEdv>R1f;na0ls z{&DS^+UZ1;PE0f7ZaPg$n{AtURtXZ3T+e<};Ps$<_I&>H=9V z|28ev^oTzP-H%#9`%Ft;$pd3dun5X;hfC>plYhLl`=|QP_#gt<=ryN`8yRxRVkna` zTeP=Bgqk?5h|<1d(_KZ+c)2jNL{8g~YUH1L`292M(t+5C{?8XU6Y+3g=yxzxf>ONa z+~3DD^j%|L-up6N+)F8(Jy3l97}1D{$j!gNS`Zc)mKkT%V2WD)HX z)huU^^RJUE6nBAocvm0+&E3nEq5N^0g>(=Y4WGMY=}~J#767};tnm~wfoIj8dU|&U zR$V6m?4aoKDfZNsA(+=+kEQYTh*J|a25KXVHK8+wXX&X@_514&MCg#DZ6Fg^!5*zV zTQ`tkCtSAEP-~Es&W!<^MY2Sy`#pVmWcI}Syw1OvLSuVJU77O}5pd_g$5TCP;Oi40 z`5NT?5I-^Lr$v~#cZrKX$(0&h7c*%ZTXv!o#gU7Psl~8FALKB=05$;{6bhm)4O4%$ zs36Pk+|iGi-g_!t7$c#-Cn0s}L~x^7-m_2WgU8$dM)9|v*4+VJ*9pVU9V{Ntfb9=t ze9=HA-g!LT9D5&#O>Uhy15o#wN?Q-|aML}Oj_qUR@Gn8V#mm0-UB5=tc&_B#Ha0V- zzk2p1b(E9~e8B!id;R^ANcI-L)SG+gC~1D$o9|a?;K|)qah1E%wGxKEXl?pn&$h-t z#eSB$45(xf{L_g(W>SdT#lwtI79FQ;GUi(2c}n;yZ|Gd#Ou1>*&oDvg^{*GCZ@{zv z^^3(Tuhv{^WzyXAf{V=lqf_*d9~O7$(PHtq*`S@hagooYWYz0 zWF{8PRWu1H?a~|9Kp$NAo1M=G{K@ybSUW#k&#}}lclxUEn@a=Z?-<}FKSxu198bCivSj<5#TDZ#-bptNJqZwn9GVF?K;PZFO2ne53ni+Xs>ddOFx9E4`6>hIs z&8IT6>3G(OJ(~YaGBA8rqqkPIXMD%#?@INM@bW8l1F z+!CJ3AlvWS*{utSBMZGqon{S54i!c*{+O%t?BEi@59t5-V?^vTY~jJMDSQ3xa6L^^ zwrdA-@s^%ou%`^6^=y>`;aL*w#EkLgS?K3UE#xdF zzNF3{AdBa|O}mf^`zi&nUt&Hiz3jSEJa~Tn_sG6xE)p18cllJ~BMnhJj_iJeMRixN6>HLU#1ydn%j; z3L>nB%c5hY0%1w*e_|Hnt^u8BPnc^LJz^`bSkw0u0XYEB4WEDL=l$iMn{}hN>rtoL zoBsgEGTad+YT2$LKk_>=Szfe4Y3>}D7)39Zas5o&D0x$R7E*W9= zh4Aagi8*WvSHY&8y?7<2#wI*pVij(75FTO<+@zTFYHGVXPe{-mo$GHLdF zUdi#W;pOQPat)Y{mV?9Ri5y#>Fe=qjv&S{+5Dtmq-`r!`_XGwlIZN*Yeyd+--yC&u z+b~`+z97JtspuU}@XMc1$-W%K4lN~*(_SC6Xfppa)wp!OLC^pZz93NfC+Le-PDt%9 zso2+?p01&x(WiZ)t>xZ(Nd1z}Xo9htvnVsC$Q(-WX=)pLQUIfBSp9luFTpm)#0|PIhCmkE2RXP_<9lYP6QC@nF(=54&=nUkAKP7mCHmVe^Jcm&Nbt2At#@C{>_Tw#$- zD~SPI3~K>R}y(pY%-3<<#eM~so;u~2ZH(j zADd#W(>xkK^1Hhesu?v!3OZ5?VbY&@y5D@cj-tnd^;?J=N!Xz~efl@buYxw}+-Zn* z1HsSwmBcv0_{QmlyS(Pl@SuP=%@l{PWWDRoa^CN~_%|g__^fN!&p~*nv{9bDWE0^9 z8SB4`Z-{%0ShHOXM&SOO82(LDZYj(2E{f0Gj`g|S8Qd*n37PB5;|0(d$}ufXH(#wy z$9c-ySrxsLXY66pB7*gco;nVJ2OWGsf{%%vD)u+M6V>;r8Z?m_QU#}O_VdkGE=GNP2rDj<<0h^V#nNDr$H3N!uohagn=<`3tgH&Sl5Kw+5UZ`=^9+U4G z0*t82a@%=G-46*B5SN=6k~sk_sPJ3*^L>DxxQc4A3GB zh2BcI(XBh&#!6j`Z)==-VDU|7tTY4C=grU+)*StOTR94_FSY7%#F|_G?AbmyROx3> zm95pO%3|blEVV1x`*Lvm7u!4azJa~+3E9Xr|18wkH%V%vN4{tT(9%@oR6dmq-YFka6xJ_ zKK}XVNX8U_F?Q-&1The?tg;fg!ySZvs81QVp%hL!ccnl0E%$uEP zY9H7QJo=3kRe#UyPF&(uQFhuvIORhU7h*CgR(S^}EQNhmy9N2+H}le9ae(r0eQx%bdx`uFfpJA8k?ApNKN!Qh_F*(w0-V`j4F zgJY;)2rGp_fN|@n)Mt}`mI-Wfy>eXJP>;~NaU;k(sXeFFqLHz?@L!XgL(wM&d=;;yn+DN> zjJ=!)b!fGCo2;>a=Ui^*^GV#meTBsB8?zDu!rw?{PZ*E%i?j!*X-gP zrO|#?*I}^U2Q$_~%X4EBuVB62^k;&hU?{4?weBj$Fhj<7-A{*0r@Re$6%wD6RV3ww zQ8NyN+-iQ4Ua$9ijoq?2^Op0FwKqxAA;bAO=>6ODdu}Q13D0-ZnXydK`Yb7E2Rv?o z4?g4NX0x{BF_Bp>SvCK@S>t*edpr{@U!VQQA)yPR!-`5XYK}|0ln8yERO_GaZm}%- zoRpK!Ha&5<%P;Ya)eeb|h(VF(l?fKWwpeM`U^AW4|DftW zkMVNP%GF?rDt0%@Zd8)Zzpn0BZO~s444v2_w(PJ`{Hm}S=E@4?q_>2fM#g(BAm#Zl54tqkOA2__IW0j82EYeE(7Hf?-b(o(QeZc z@-p8XHKT;e<^RiDu{+@{oXqrX698N!u0fY%RzKYK{zyZUzf@3%ex# zz#|@xI5HiIF~(}kL4ToAaK6Pq+d11GZv3NZtFkZ9E2a=DGk%Z5Pto>gDTbjFw_l;y zF&M2&AX?e%9@4bx<=#ixMW&O;KKDCSq9r1I?Ph+s_csj!nIE650r0aUFjq%iieaSA zp*K>9q~oUYI;e;HL2Cu7F+4!(;Qt*eQ}`ryIhF61b7+^dPe|JCYP=_gUUQW9n!cvJ z!Ct(Y72DCndscrS*2+G`f6e%6XYEj{9G~{n?X!?x!D0lpQg1EdYwE@1P5m47Rz{1| zFBMUX9=*Pl2;{!=il(kvZ$5Orqa!s-n_FLy$PL>EG9$Fw70Z2T3B@}--aLG&_H9Ui#cA=iJXG-|?ARn2cO0O4oMZg9V9XBv{LT;JC^Nqi`4ryN6Z}2FPPF zwYVq6)pgE@eRSoz{Z3Kj0I&dp1}tCEbbWu4o904u%B8}kd6jtqo=GNp0YKs2k^e9h z`rDLN6zT>_KL`4!DmX*Vw$YR|Ur}iP>t@+TXo#RP%*mZX=*$j!jYz2)Z>Out_SHc` zIdhr5T*L6XjCJFH{A|u(e=3~C>M&aL8moxk6j!BV!)QNL8G*M7rS`EVOB9`F-0u7( z)$2VxztD5py>}p{pYH23cm8(wzR6YKPnum#HnY1a9tkGR8?$6jgQ2<$Cf^^~l+txW zvhePLXxU&%=>6rDehpRXUwt9Il-}Yhu&cqobP^hBntLsgxZw}5@$iefc>6$_Gk+@j z8y8&ZS0?SAm~Sx>k_o|baEz}EgIL8KLLYO89ZgPXxo_o-GqteTRm+Y(wVV@h<=R{( zKD$Y8s$y~%PCL}whZ4Il7~Q>N9Ji{wO!H9>xWH(wwo>(p;B-p)MTr~L$)BX1|H%SG z@=p<|Dl@Ilzx)Iw+u#Ci)og4Q>cYpDk0h$ zmeK%1u}3C5>in5`!IT7vN;}J{gL~p9Y}k+dzCRE$(es@!0@L=n&6n~sTCG#E_Px-i zRpmovy&m_@&?f~nzQ!T@+Qtnn7Kl%1%!9&rWHXHb0u6c&BnW5_C`8J1aygfxu5oNz_X==T8j960xseRUBGU zE2u2s)0r5>ZSAZI*#?<3YcS!aUO2JnB^80=|+mQ)pRaPvhng(!Ndw5Hm6 zMraf(mVBrxB$NnDr6v+IpNCW?S!%zQ-&g6b;s(hbVodI!LbLA$8-dxh$+=2rIwucQ zqpL9rpo2Hr+_EF-z8Vgs1w811gTu3p-Jq*hjhZS#hrMr_J zpvC0x{cr+IBkV|5^OsRq-_~(kM7PrG1G|#g9_pC|$rFL9Or0{dTRw@%DC&=fnZh$? zA{_=`qOCykHsubR8ehLNsBfu44~o$of1fZGkE;FDtjd7rgs?bk-EZ0vnUdq5vM!?m zf~49l=x*8bpZ4X=N&-RoY1x3gA1U=uAfI=`wIj96{y5@q?Ky_chnH-T!d7;1!=qTZ z2Qu?2&mCP%SST-0Z~E4$^HpnOX1KgUFWnL~4ls&Tk|$%yYc&n<+T0`&Y%)S7S?WDi zT0Q{-`~U$m zU}hLhvf-=!oNQnIe)rFQ@u)7OTZ<==b$YGS+^`(CVzc^G7PnY0yDhmZO*fk%yQBWQ z9}i23!8>R;2jS(^e@%V)0Gi2FyRXIsaWyIy4lHce@t``p4lWa<%^tJpq~>*uIF@T{ zgnL+McT6MZ&4URNNm6S>W$@NS_~z!>acn>y_qu}kg|q!}aRUynB6iEY&yz#(zFjJIea zs6o6u8`Kc4e;AS|n6Y&>uyt@l>$H0VCPxma6=-x!8S<->Qq;0^eyA#ykd{)2l3wgk ze^++=i;yL};#w2GbjQsmdb=uVF~%QwJMV>8Zg<8^Ap4h4G#Y+*o;81_& z7HeITrXMr<@fKfLz+46l&t1+R+R-vln45LlLUP2YwY# zB}Ol_uUNUA-qcja`2Z04hZn;9p#(xpGWob6RyRLwXuZnpB`WVbZ73Ll?Lu|t!1s$= z`(xrjUPGg2E1?Xe_<+m7*nVzqxaUwJAFH-yTX|?mVQNEiMEZN@v6i_37Pz)tGu-R567()0i5~_at=I!7#U`SiF+v~k zg0frlRTU0m1nhB00Ud~Ev)tSW_#`a9Rurj<^1k(~*}vX4s1P|-g-?>~>QdpnF2uSk zzek`qrRROz?rS;`73uRJV;Q1ySz3TR&?tzUBG(HHPX3Pd#cNS+^;7x9*TJrIAjh`( z=G`2Mxd~mf*=|y8fB4|Qta3*<9wJw~#$3p9sbFz}=OQdF!^0Pr{@7P6$iJ!juN{jy zPO`8h=Xpr0onj1}qf(^mVpx})qw@`Uv6Cb%dMRi^tEA`cK9-PUY?shBiMI`7VfYGd zDN+a$w^EtJ21F#*WRiehh<;$E|!F>L*)S4Da^eUEY*Kx`NaI3qR z8(sSiDuKi=`KQ@XyS$mlg25~%r?<@u-_`)2XdWa+G_=Rxf`1b!k1(|5;-o{GFYbkz zrT|j^(wT1rRZg{13P{E>tM_`(+)zJdG)&2Yn^y2W8Lv-c)$c6)18C4)gZ!P_GAQO0 zQ)ZOV8V}485lBFPY^-q~+%Wbu3z zweB!Urh;A44xL*rv`r4D9{HAyBYa%s7JrU?}o=s_$M}s4jrV$`;xcwp3a`m53go?5M@4@H)|_t03No zS*_V?_jxG8nx^Rm7hUi)SCIdizR9Z)vWYmF*iZT4S8UL&e;AJlRu80}O@i1x$n;&w z)@8LVV>$V~2$SuIz8Ju%Q6_4^Z?A3vq=iQ7wOQ(D^Gfy#it4*QwM?ld($U_~S zNxR@xXJ%a+NIIaBwC>FBmi~5UaQ?FASK?x}FeMhT2-&^Ar;~AyOxsMn` zWon&_hG-$^Zl9D=84HaqEEgV1w4s-5QK!q}74pwS0e>ElE^`GBI{)L;Ui$3=;EO?>_f1A_JEYBv^HI*Ik_va9=d%owj{}6XbT$b6< zHc|u51w|XYbD&KTOyYuRi9}Cjq@qQ|1xzV5?BIzl)k@_4iMxmr@n&p^Pj^83Az?Mi z4f0P7!?bwi8q(dD*G-h@?GpP=jgEv6Ch5Q8QqX+8b>hALPuYQm%CfkBeFj8m6f8*C z=v^C!lx}Y_W2k^+T0ow=Hx;HA?1mPd>wol9++uHfp80p`Wt2B{WP=of2M5(=vEHJM zwcWiod(=qXZx-wF6u@Ew`z@d@N)Me%e!7z;GVpDJqAiwqxo_*?MjDTsA4PA>CTG0X zaJYwG$3wO`y|4#6l58CQx*SHWsp3Fx5vJn7W8h&2uXFMB zoc2y1D=`0HBb~XzmZ45E)3Cp}h>8nqxLHm})wXOkOIy0ooP5ZZ;E1w>3~RJGZZDf^ z3@a^=8m3!S-*3B&D`1g=sHhbSVKFIL8-qL3TNYY0xd?eP_kyKZNJ)lHcq*n#c_&$4 zk==S5KC8_G?Qcr@jICM#N6S?zwX$FT*-R15Z}ryrZsQHE*yP$-U;$^v7wc9#`73i2 zjI!1hC|P(3vw{0=Skw+n4)6TM`Lc>7MSrGCtp!BF^>{c4CXpgR^>4g?g#|G+;Q~D| z2trj5qk_hf)9?c1X)D)M?UUa*53_XK_347Z??IvpYq7@hha$@|d;-wri#{f!@0_KnAn=NXSB5v3(#Sv}_5*YZ;v7yZ@XAYG|u;HSo6e3eh( z;50etlq&=Tmy>aAn>)%SkLfQLG=T~mb}j-eC^q>N6rSxt9$NX#{mZy>{p{B%221je z%g>8k`UJkBmK@2ANjl3!Iu^x6P;@p^Iiby5YheSBO(clvx^;>uVH%wd+b5iZ!pQU* zPSWxrTd%ABB;+M>2#Tp=G@P0nVw7^UkdllW9%OK&56r$>PFE=nL}09hF33YIRs}Q8 z+_-*9@$o)ZJN1?<_(l*r|9X*D7*sJ~w`8Z1EsU(9KtPn@lQe_#dQ1l@oRAQ!-3l|H zNYE-?Xq^Lx8IovX5~ZvgVhg$lps%<@%L*x{8YGJQ`>AhN(Cs*1vqpim;{~RSu?TPzaO=#) zfiVlAK=m#(qhM5PbH!1%2KDzwT8O!^oJ~U}c3Z5qJAjO`$N% zSHgIz8QT~>5TPgsBuSX|^9(a2`F$dK(PMqUJgQckBKLvO!!{)3DZ{7eH|xQR!ET)b zG=rpr+&BWMc+V!2@v5?2=Tyd9rbPVe9`8^o=zB^=PXX~|AR;b(qYyrjrIr&bjGCnU zS3l-6fTwhX%k}|c?xZe)kpSnZHMa28*1*ApDtl2HZhA{CDA+S3DSitOT5{xS*4 zSZjlk6vNdJG2V};5ot{yA2POQSwqv|aoTpoe6N+P)E-8WU&MV+va<~lR9}U59pR#E zxEsyWT&F)9l=99ua=I^r!_x8*h0(IoL&aCo6E*fHb7|ceycKa0@!|MSm}N*-g0sVA$tq2 z5;N63F-h2VKFq*HZz)yOE&$<2DJBX-f|wbp2;?zK!}iCl`%3(&1f`d~vHW2Nl1-Pi z6EpHd;4B_0`nPV!0+_~C_5Kxt+_6W+kUVHRGFT+Olldj6QOfXj_FH32ovQta zHjdFJnJVpjO*K=c>^WL7ySP_jKNoFoDBbOsN2%mH0fB5xVN`r1D~ONQaEDCz8N>y; z&{=DW9{e_YDYY8yrEQ0r`{=yoU7NB$a$`5@C1>5AAS~wSmE3(B*=uHI7s3UBZW;UW zJe^RROSiYFONzNcJl<*Y<+Kf)qOh#i-`C&vI@A2_`cN0Kpw=#@00P4~Va*uRj||od z>oac=s0n^nc$DB4!kh|DoenfE1Xp~PNWpNq4>?JS&jhkz%=-!c)kSROqr7~R?KH9v z(bvQ36vfJpg7zfb<*pb<*~y3?+$q6@QWx zp!9Ljcd|KGM3oKWpcwEs;tcOZ7^JNZqTWof^d@Rn0r+?`k6WX~t-6*&_^FjXZB7>B zN`u*;tr%_ib<}!-aX+VpO^olZ(g7=9Dn=aJh*!?uK5P&_yxAgXP1v=74p4J9*w6iS z)S?Nd`mk0y|6ziki`Ik)Zh;7+*56YJe$!v}D>#aAW+-&Ej27Qfeo|!4fu^~pApc28 zSo*~{ic%Q*LW4Cc*dl^E>+K*De>qXqeq6euoICS$a25!_y&zgb4C?gF5 z-Io+N(Q>O+yu&}ikK8&2*gkPAgbk>v%N>cfM6Wkbx5O{jRgP7ne85*53m;+Ue^nBW zx_OtOc}ie7bpPqfPM*Z*2GN(?U%ptP*LWo}y5q_{HhKTg#F+1SO0d&Bedgl^aqETz zXPgtaZNH#p!bBrHKdE}QzQcH<_BD^h2OJQICA#T~ZB}^Bj$_N9>avHkJvQd6=&qHp z^M$^>cGvtCV@&h{tB5mcDDJgzjWis0q)6{LUH)I*am9};Gxh3?Tn$GPrCIq!qs)4J zI;&!;74Z^>qnV)4lL$|^_PCH{RZgfql62K*5YeFlNjr1-PYQ)za$9GmhiMk4H@9rHGV8JZ7c(`P%rQ1@mw&S3TGj?i zMWvAum8BDjk2*bvSC;)HW%*OXa*3ak^~?|&UIk_M-xZ!i+jur-jc_@dzW3c~QPMX? zN`%_q|AAq0U^f>q;QRjVAM3M{-L0r!K5}ruN^<|}{B1DZx+9pLMfO5!TM5k3BkB}? zaQ#(0g(dvTiS9Nj1626i~+Z_{U9O*W|+_F3s8Z zLej7khCg;47XJQxH+SNw)M~aEd0{!DdC|EOHlmeqnGg-Y{9XgJSl$k=2QlPv&m&Gk zs%ETbMH?OFAGX)KRNt;z@en(9HYztUIxU<$c1R)Z6aIs1HW_Aor)}l?Pju*?HGdm@q^R|3fP~I zRiEMIGM1a{#p&zA_Ub=KA_xQ8uc;r-8Im56u;pD6vza_7g~qz5cz|{o-Xn>M_*pJ* zEqq0>*4n!%%k;-zdIm(7L@FdYD#YUX@H(6mxrZ1{>etgoZb%n&FIl@E)va$+)9Yri zgnwg;mpl>A$)CA}V=>0WhcUE+ zJv$Ph`kYAcG%<&)-KOhOYc)Zhqtw$i@mAtiI!2N`S_Xe9{FhqnGL+IvwFaBEOCcT#LJb8mn1{Tr&7T5kNTvn{C zH?DTl{tcTGGRMb52WHH$pF|tgK69R|%Q=jCSEGo4KN-%`#IRUZWL?pVC0Q3G$hG06 zmR=X}cRf+R5hgRE>Vl&z4bai4A3q5UZx|O?LmD9NUp%AkIm1tHj8(;g!#L|edpPDM z5NFF)ZJXsiYj{C_zKgy{cpsa8#v(sEE9~UA*cPYGNjE6H6U6i3g0Y@AA@`ZLS^F$o zn#Yez=aJx%MH^RIBK45+yBDy^7=gpFTlC4h_9u&=7a(qwmsEzadR^#+eNj2ac+ z^7#%Q8mGN@*bb-tEws$|Fr1u8?X&mDWN#%2A5uv=!{%LprD#v7fRxe-moi+he)2NyBu@se!V?*aMw4&wnXcOL*= zr72yg*CQ#Lcx@#meL*6)6d#}sNkONQ#+P|a=DNpv=BCW8APbBc6+iiKa|9wZ$buu# zJMrU4@A2!cJB#isZ2cqz4-BKbuWF~6EU=P}-N)2s_Hv_nerMCa)&}O` z*$w;OkOqJ|%xS(~)~_2&AS^0{z)ac(^cQiVDU6o=#*Yo(-?a2oe*YlO{1q?r!zV=5 zHQkY8$_PfQFV)`DqXxK4>302>q>g7N3EDz_!Joucedh_gCQ$x^<|0lPdGQeIN!pXs z|H?ypzdvBZOCWs42XRN7XueX$kM~UfuEi%qtl=M705X1_;PR5t37P3Pm5OK)8$tr! z=(ybWjF$gyx|}*@&voZo9|ID!#)FCAHA`FdR|kYE0f8ak{+Ugg#i&*v9WaWoSZ>J_ zOa73+`pY(ezDJfg1Da^Y(0QBtT!P$|X1&x|g#tSr_K@UMm4)a{M?ippGvbAMjX-vU z-+>~CuWsY8ylz1yl0Gi~q;^rI_2+Ko`$WoV)j|jQu)Ixl8d$_Mi+-k#GfCv|~@a2_YsIMXupXD*&@@ zQ=^RT&Npd+?X&V|D?NtxzX6S?gH#gs5KY*vUc0C_XYYY)>lTFuO(8{BPkXB6wMCt- z@KEf=`6W`^AYrJ|=F_+vpl%_6G~ce~_Z%J;PGHQzNqDEy!=!y1GnZF=6a zqIgj@t~YGGvr2F6Nuaa0#i0!JyyLR)fu}#-#!8>M1ePkb0^!vnpG^npS8aKRt?hi{ zn=#8fhL2~14TNLhQ^OREo}VBKa!vEc=FY)UZtbGw6g;AvF^1il0 zQXQ43aSzE#$~Zh5+_AI6lW5NM{x#&XKX64Ecfc?GN(LQi%4*pBH+?!QK~yDs$Tg2K z!jW%NfAI1`UGN%I+ZQ)uhHg^x_ zKb4JT!A`wSlJJR#*?XY6Sg^5M|GOT8LhsNrSuOo)O^i$2M0R@rXMNFoCND{-(FoTP zVPWflv|@I#1D16T(^o|#gyJ@_%u=F2oG%=^#p4jTIM-JN9-}gPxPzL?q}LK%@(^Q% z%(TcIJO6q;Y)YB6trz_BKEJm~wH{rO`d+ZTnrHN3bkS-lL-fGzbz~$Fv}iV4P&hEf63w2zt;34p7mT`&vc>k1z$Q4;S>e@mJexQ+?nq#)>LiUA`bulhJd|(OS z+LbN|aVs*e(7saCdS|^v+?b&h&bG3n!;_jWf5&V(SsIIgCmUc%B!&8nxN=7{t_`vP ze5y#iXWBf33ru^BIC+`^bO#^W9x)yW*FpmOpRP~_mI9~XNhkr7PhoEG61(L2i}a<{ zhzeT!=TlxsV9zv_T!#befabn>3s^UinaxUQq4WJMwAg;SHytTyNg+eyE8~qGDcm*f z7eG;KFJ9&y!~X`E|92Ju-#hhRSd|a%*#&J}5e;Nlr@qSR?eYYCj4DW}wtk=W+Qij$ z_A%HUTDr5Ganzemjrp%sZt{w}p7Ho&dB#YsOy36qiLSbi3vHC+Y1ueVHOF|I`#dbO zBdK13^Xh>D0-zR;b!Hc%IDAcopAnDtJb3Yeyz@SrDv6dmBk>M$xJICZ-}$L&{}_Vn zavXqUf7nB7Jq$t2^1dqfXp`i6+!^wzc-!f0BwAG`9@2jZn+nY-8ayIs__g9r9WS>? z^PrP~4gu%EEWZCw@l4(xe(nOZA&GV0yCl=~iUeJaNxfeV2;bPH5p9&pTrU#u$432# z3ATcCX3m=uT+3^@mr2|>S~`846g3bCBF$i}P{WMp@#+KrBDt(+D70G8bK&g-+Y(to zcTX)Nra^8pg9){r(xk7!-y8=MS>{}f0;>L}RQGc@7hgDpM|rvkTsM{16b(DZn?1~0 zftGtz3WoxX6hC&D(3q`H+%k)|6K#~0=5z*Y6aY!hRV|b96SvZ}00cVGQvq5Ueclyp zFzax~;;A~T?oXU-e_c6v4ZYMEh#`^FO~0r9ziRbDJ_DyWT-!Y(3K?~Od_4nkCj;rV zUXivAXh@xs)+KApjT4O?_6w#(?WTQ*YKr`s976!30HYh3agiolz4s$p}ZrRLkSw-E$FFab1VR?oN$Nx$WSgw=$ zYhu{or8V)X2A-atsHBqJUYA{dwrdvfP@|)Pj-Mzf+#wtzjjWhoG?rz$Gl(bu`xg|P zV+P&HjA^-0V3%ncMotIlyHfK`=>E$-3Y}BAKSp{P#BN{tV&S>|}2SM3Mn7o=7=3y1wE%6aF}Nig*VgNrBi=HxSQK~C9fS7-pfuJ9sWyS=$Fa&1NG zxO_$b(|Kd%g~2o0T$DdinqHs-7c0-m@BJdqZvx%4UU|}hO|QjW;+MlX;}{l+78y_W z6rVa+#n4j^&2cAG58H1H20<=Ro20$`z0z*c%rGvCyv1byGOXBZ_f{7?*?c&N!t8Yj zh`7=C%C;vsGQH6A(Dfm#jNw#s!`<4Qv&jW8kcEx#-PadU|0VSP#q8(FYxw`E;=IF} z%DO!q7+RzS7#$HpM^wmIm{0-{B(w}lMw~&K5s*j+jX_F;NRc840ulrzfFg(x2sIKw zstN`LrAw2Z00yK=4Bsjw_J87H17g^iU2jFn)8A**P zA;{%{I74opV%|vK{7s9IqqcxA9j@>p-6ce9k{5fFtw1JvgfK%sy4OcBv z!qcT@73Kccdb3V?@R;($px%w0x2&=km`{iR;h7Yt-i1U{y0-|sK7Q$70Y z;Sz}d3l#$IaYdiygLZcXq**#v)@f*jUz*snMeq6e+wvNnxjC&R)^^bV!}10%c48gp zr~s%lx}sw(X}O$%Yz@O{{{l*y7DENc8s-d4(2T>a@VQO1uJ%If9X4F@dsfxGk!LQi zMK_NBV_B)z`j2cSRl@S<1;gT-Ny4U(RGSf@F||M98%5&R*KEGSD%-#$>NAt@sQsr~ z8uBtVUuY3=S@d_Z@SPj2>FJ013cW)-$hZ_5cK96#z@yvP)vfpxfWZ?7D3q34d?|o6 zD_xYhua|6hd*2yY=rw#o{SM=( zN+{FgB%fi+ie0yPz|%d;ON8~}#eEBE@YcS#a-B7M$0voP006&>tsd5N@~CAr;>akm zYsB6*1PK#E;WV`whevPoewkYfb|}1D9aN`5w^#z z&HQy(bZ;v4#a|D1E(m4(utWg$99L!#J1fZ%MLczhHc=MFdLiJu(7Yk(I8?62a@5Ij zpq#Da!<~;evGlQiG(Lci3IpsPofxO6l$x+(0OlW!%q2CS+PQu3w|yLu;%7+HPnz{= zH*2H!$;#_wkIoc#65=4Ll>^MQcHoG&F?I6omp{P34K{Z%5DkE{cF1OR!j-Z zd?$SD#@i|lxIviBpCu(3&ps^Fmqqs;RQY{3;R4sFYkt=?iys}zl_s;bN*DhgwVb|s zVW0Wwd}d4Y>9glUedb+$T(FHWEXAV_j663abHhMHE`CA}48140vPi6-gpOZLSr)D^ zG1I>l8-U2?lT2)Vu8DQg{%akXqcbu3I8Qn_YRG&6;Jq;> zZds&O#Ew;qJm}YA)zrF(N3flTg~SeO_{^ePfe$tuLqDFkp_G76Y9`zSLlG=)u(|u; zHKN*0e+6AOKjVQv!`C`cuC3KG(gfAV@EyeB=iDzOQ3YDj- z`Cw*}*5;`lyIkMt!VCLA&Fz-zbFYif)vXt;Dv^pap2_0J6q^e0er07M^?@VG&Kp23 zjvEYUE#xZ$4;A=%>3!rD&Op)4t7FD0b&Yeh00%Q~d!l?!m}dYET4OG%U!!(yv;S!A zIrshS`+~xe6`ZA=dL2n&b$Dbpt@M7B1VJ&-2$WnSIxOn7?!9!QI-|NY^R% zEcOewGWp{bOVojdG3ccYYGaICR~g!;WLM0t)arEwhn)OSMeo3!v3OMa&bO2UAD9wMHSk{zJ zNHjl;-$JIzGlEaNmD8~~ofSEUOK|A@+UY<1liF^t(u5lbT?OcHHyh8dSPmO>+m<6d zwW=^+?!}PICAFPWFV38mn!-yj;zducVzlu+9rIq~D#_?Xk7LBZLdO`$-Js#bI1z#v zG=`(maVwfWy5o{(cY3bGvrmO3{*1k@EJ-sSf0= z8OjG{oZIbQh-w+P;Em^!QCe!>B3_z_7p2*1w#a#-txjdX)HyEbf~me9wB)Bx=m}CM zAk(*Fv?^8f zpbE*qvL!WF7yWvBI@NcMTKO~9lYcw&^JV5{>ulI>_3SBhyj4ONpj=YBG>f}wfM~_T z4rCjHqWZfoWhz6z^A=vWp_wr7=Zl78!h$h9)4F(lQx*nr2&pS>SCq~L;!q^7{?5Sw zS8Vf+4b683KBlx6d?F18f#7oh`>t8*FrDj)N@gpKC6E$GQ_(G&&K9tDur`ziHY65L zfz2?GLGTo#`4Av|BS`xIal2#gu|M8Qu=3Sa`LkaDfW2W+L@nrWl|~SRKHk$9?JelQ z0zb8AcH*#oLQ9r9WdYHV=0r8P!Au5CFNILEIzHMPs%K4~mod|CK}>uE-z!LF0%A8& zK^A7^ObN(OQ6LwUQ+3mKcdzJJ#$V6hOfbAId^?`RnS762xe)>q4t7Xof4$LCm&rGy}Yuc+OKQE(Xe9jT4F;{~jt^Y-|^N+HQWSe@u)9G^14w7Tn$7SWrHmZN-h8*`a%&=RAPA+c_$kTz z&XPIL-=_nILYa6^u#DM5)pjuWLyTvU>*OqGm4s4_mx63pOR0vdx+9(e z#TNmAl7!S5=nos#R_+hgbKabR z2&NjFifPBtOszezAlbT$pu7u4Q{Lxbc8?0j)?=Ly2VJ+5E55QMz>pajI^!Zh4yp-W z7e48uQ8nW?43K3FR%EYXtTxsd*eDWWJV$I^9&p@RaGHf(8l1dw&1%9xQP4DA+cf@Y z+(A{L7rCFo5R_27fP2_eNri+|S)q8}0y%@-tipD3KnV|$qvh;;4q(Hvx>(@MFp~iL z26kG#1C(fLD{Iizeo3%gWaQYYcj}9)YwsulfuSjWK$i1;2)rN`D!>`0mRY{^&DiRI zUlJeK_4a zeD|)9#Ic&iZdN^mOZH8fk260T*A-R`P5)vPJ+@ZCAoC9DC1g`7aJ!FIe9-TQvMHVu zPklSB{NT_()(^m&8Y8i5YwJYtaAoMK%uoF!Ky(WJM-;YoPsFNL=VRq1Iheb{NX@J| z0zg4X>im6nx|?Tf=B{kmZxZYyo^wu4%FNjPe-W(x#giaMp9l*7Gs24ntL1IV=FQEs z%c;?^1X6G=_UeMybIW!un?M8r$ei74s~^vd9Z;?GuK#o)3e>144yv1nqoWH@=j zt?h8dU`j>iudt@qT-qpcJC6Q8O(H5m-%df% zF&Q>NZp5VzEe0RmOe>lltZb30ZEg63ITE72 zAso_?hAjAf`jcMSnS!|LZ}%C2Ln-=pCXhjgSH9KX4!#rtmw$YtkUV*>?_zsR=IZCK z{nz}bF4Z^cE4C>VD}zZRm9J#Bx4+!$!^8~Mb_HrgyaGm5ZnbgV)R%2iY)IwGPuBUp zR*1Z;H*USty3+GCOnqtbtwQm{O6> 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 @@ +