Compare commits

...

19 Commits

Author SHA1 Message Date
wangjia 68fce1970c chore: release client-v1.0.83
Deploy Client / build-client-web (push) Successful in 46s
Deploy Client / build-windows (push) Successful in 1m54s
Deploy Client / build-macos (push) Successful in 2m22s
Deploy Client / build-android (push) Successful in 1m28s
Deploy Client / build-ios (push) Successful in 3m19s
Deploy Client / release-deploy-client (push) Successful in 1m53s
2026-06-26 18:11:40 +08:00
wangjia 2ad893fb40 docs: 出入库单打印排版方案(6 种黑白样式 HTML + PDF)
参考真实发票/送货单产出 6 种黑白排版供选型,已登记 docs 索引。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 18:10:19 +08:00
wangjia 1558ce1e94 refactor(client): 出入库单打印改用「紧凑报表」版式
- 左侧竖线点缀 + 抬头左对齐内联(公司名 + 单据类型同行)
- 抬头信息改为灰底信息条;明细表收紧字号/行距、灰底表头
- 数量/单价/金额/总计改等宽数字(PDF 内置 Courier,Web monospace)
- 单据总计改为右对齐灰底条
- 仅排版调整,字段/数据/售价口径/温馨提示逻辑均不变

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 18:10:12 +08:00
wangjia a355a3a2a2 feat(client): 出入库单打印按新版式重做 + 单价口径修正
- 版式参照单据样板:公司抬头 + 两行三列表头 + 统一明细列
  (商品编号/名称/系列/规格/批次号/生产日期/数量/单价/金额/备注)
- 单据总计独立成行(仅数量+金额,右对齐高亮)
- 出库单保留温馨提示,入库单去掉
- 签字行:制单人=当前登录用户(OrderPrintMeta),采购员/销售员=经办人;
  公司名取门店(shopInfoProvider)
- 出库单单价=售价(sale_price),金额=售价×数量;历史单 sale_price=0
  时回退 unit_price/total_price。入库单单价=进价(unit_price)
- 桌面 PDF 与 Web HTML 两端同构

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:03:23 +08:00
wangjia 5187702fc5 chore: release client-v1.0.82
Deploy Client / build-client-web (push) Successful in 47s
Deploy Client / build-windows (push) Successful in 1m56s
Deploy Client / build-macos (push) Successful in 2m15s
Deploy Client / build-android (push) Successful in 1m20s
Deploy Client / build-ios (push) Successful in 2m47s
Deploy Client / release-deploy-client (push) Successful in 1m51s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 01:52:04 +08:00
wangjia 3d83f94f24 chore: release client-v1.0.81
Deploy Client / build-client-web (push) Successful in 42s
Deploy Client / build-windows (push) Successful in 1m55s
Deploy Client / build-macos (push) Successful in 2m19s
Deploy Client / build-android (push) Successful in 1m7s
Deploy Client / build-ios (push) Successful in 2m37s
Deploy Client / release-deploy-client (push) Successful in 1m53s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:40:23 +08:00
wangjia 5861b9210c chore: release client-v1.0.80
Deploy Client / build-client-web (push) Successful in 43s
Deploy Client / build-windows (push) Successful in 1m56s
Deploy Client / build-macos (push) Successful in 2m11s
Deploy Client / build-android (push) Successful in 1m29s
Deploy Client / build-ios (push) Successful in 2m47s
Deploy Client / release-deploy-client (push) Successful in 1m49s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 18:02:32 +08:00
wangjia a678c3806e chore: release client-v1.0.79
Deploy Client / build-client-web (push) Successful in 44s
Deploy Client / build-macos (push) Successful in 2m29s
Deploy Client / build-android (push) Successful in 1m23s
Deploy Client / build-ios (push) Successful in 2m59s
Deploy Client / build-windows (push) Successful in 1m52s
Deploy Client / release-deploy-client (push) Successful in 2m40s
入库/出库单搜索框升级:搜索后关键词转为框内 chip 标签 + 一键清除(✕),仅保留单个搜索词;新增 SearchChip 组件 + provider currentKeyword getter 进屏同步。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-24 12:08:55 +08:00
wangjia 5ff41cc505 chore: release server-v1.0.81
Deploy Server / release-deploy-server (push) Successful in 1m54s
修复入库单/出库单按商品名搜不到历史导入单:搜索酒名条件由 COALESCE(被占位名劫持) 改为 p.name OR it.product_name 任一命中,并加回归测试。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-24 11:56:21 +08:00
wangjia 33b0f6c30c chore: release client-v1.0.78
Deploy Client / build-windows (push) Successful in 1m52s
Deploy Client / build-client-web (push) Successful in 41s
Deploy Client / build-macos (push) Successful in 2m36s
Deploy Client / build-android (push) Successful in 1m16s
Deploy Client / build-ios (push) Successful in 3m0s
Deploy Client / release-deploy-client (push) Successful in 3m11s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-24 01:26:39 +08:00
wangjia 6f25972277 chore: release server-v1.0.80
Deploy Server / release-deploy-server (push) Successful in 1m57s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-24 01:25:51 +08:00
wangjia a06c1c0709 fix(deploy): setup_ssh 给私钥补结尾换行,修复阿里 ed25519 部署 key "invalid format"
Forgejo 存 secret 时会去掉结尾换行,而 OpenSSH 格式私钥(如阿里 ed25519
部署 key)缺结尾换行会被 openssh 判为 "invalid format" 拒绝加载,退化成
无密钥 → Permission denied,导致迁移期 Deploy → Ali 影子轨每次都失败
(因 continue-on-error 不阻塞 EC2、流水线仍显示成功,故未被察觉)。
EC2 那把是 RSA/PEM 对结尾换行宽容,未暴露此问题。

改 printf '%s' → printf '%s\n' 始终补一个结尾换行;对已含换行的 PEM 无害。
已本机端到端复现并验证:去掉结尾换行的 key 经修复后 deploy-server.sh
阿里分支跑通,阿里二进制已与 EC2 一致(sha 7e77ec46)、jiu.service 保持 stopped。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-24 01:23:13 +08:00
wangjia 052a541084 chore: release client-v1.0.77
Deploy Client / build-client-web (push) Successful in 42s
Deploy Client / build-windows (push) Successful in 1m57s
Deploy Client / build-macos (push) Successful in 2m51s
Deploy Client / build-android (push) Successful in 1m32s
Deploy Client / build-ios (push) Successful in 3m42s
Deploy Client / release-deploy-client (push) Successful in 3m10s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-24 00:33:22 +08:00
wangjia b260939563 chore: release server-v1.0.79
Deploy Server / release-deploy-server (push) Successful in 1m46s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-24 00:32:24 +08:00
wangjia 84f99ef459 test: 补酒名搜索拼音/多租户与前端搜索限流用例
- backend: 入库酒名全拼(feitian)+首字母(ftmt)命中、不相关拼音不命中
- backend: 入库酒名反查的多租户隔离(店A搜茅台不串到店B)
- client: setKeyword 防抖(350ms内连发取最后一次)+去重(同词不重查)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:19:34 +08:00
wangjia 102ace42ac feat(client): 入库/出库搜索框支持酒名,并对搜索限流
- 搜索框 hint 改为「单号/往来单位/酒名」
- setKeyword 加去重+350ms 防抖:同词不重查、连发回车/狂点合并取最后一次,
  避免酒名模糊查询(单次约 55ms 明细扫描)被刷
- Timer 在 provider dispose 时取消

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:19:34 +08:00
wangjia e8f416ddea feat(backend): 入库/出库列表 keyword 支持酒名模糊反查
- keyword 在单号/往来单位基础上,新增按酒名反查含该酒明细的单据
- 酒名匹配复用拼音基建:name(优先 product 回退快照) + name_pinyin + name_initials
- 子查询带 shop_id,多租户隔离不破
- 补 stock_in/stock_out List 按酒名+拼音命中的测试用例

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:19:34 +08:00
wangjia 08b41a8f63 feat(client): 入库单价允许留空/0(待定价),移除 >0 校验
- 删除新建入库商品明细单价的「不能为空 + >0」校验,留空或填 0 视为「待定价」
- 待定价明细经审核后在入库单详情「确认进价」补填真实进价
- 清理未使用的 productCode 局部变量与 _productCodeOf 辅助方法

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 00:19:34 +08:00
wangjia 2eb4275e6f chore(deploy): CI 双轨——发版同时部署 EC2 + 阿里(EC2→Ali 迁移期)
deploy-{server,site,client}.sh 目标主机参数化(DEPLOY_HOST/USER/SSH_KEY),
不传时行为与现状完全一致(只发 EC2)。workflow 的 Deploy 拆成 EC2(前)+
Ali(后,if:always + continue-on-error,影子目标失败不挡线上)。deploy-server
的 ali 分支只换二进制 + reload 独立 nginx(listen 443),不启动 jiu(阿里 DB
为只读从库,切流提升为主后才起)。新增 deploy/nginx-jiu-ali.conf 并打进
configs.tar.gz。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZ4DskSRKsSiheQonFtQvx
2026-06-23 22:57:41 +08:00
32 changed files with 1902 additions and 412 deletions
+14 -1
View File
@@ -180,7 +180,7 @@ jobs:
GITEA_REPOSITORY: ${{ gitea.repository }} GITEA_REPOSITORY: ${{ gitea.repository }}
run: sh scripts/ci/release-client.sh "${{ gitea.ref_name }}" run: sh scripts/ci/release-client.sh "${{ gitea.ref_name }}"
- name: Deploy - name: Deploy → EC2
env: env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
FORGEJO_URL: ${{ secrets.FORGEJO_URL }} FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
@@ -190,6 +190,19 @@ jobs:
EC2_USER: ${{ secrets.EC2_USER }} EC2_USER: ${{ secrets.EC2_USER }}
run: sh scripts/ci/deploy-client.sh "${{ gitea.ref_name }}" run: sh scripts/ci/deploy-client.sh "${{ gitea.ref_name }}"
# 迁移期影子目标:同步 Flutter web + version.yaml + 安装包到阿里机。失败不挡 EC2。
- name: Deploy → Ali (migration shadow)
if: always()
continue-on-error: true
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
GITEA_REPOSITORY: ${{ gitea.repository }}
DEPLOY_SSH_KEY: ${{ secrets.ALI_SSH_KEY }}
DEPLOY_HOST: ${{ secrets.ALI_HOST }}
DEPLOY_USER: ${{ secrets.ALI_USER }}
run: sh scripts/ci/deploy-client.sh "${{ gitea.ref_name }}"
- name: Notify - name: Notify
if: always() if: always()
env: env:
+17 -1
View File
@@ -36,16 +36,32 @@ jobs:
GITEA_REPOSITORY: ${{ gitea.repository }} GITEA_REPOSITORY: ${{ gitea.repository }}
run: sh scripts/ci/release-server.sh "${{ gitea.ref_name }}" run: sh scripts/ci/release-server.sh "${{ gitea.ref_name }}"
- name: Deploy - name: Deploy → EC2
env: env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
FORGEJO_URL: ${{ secrets.FORGEJO_URL }} FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
GITEA_REPOSITORY: ${{ gitea.repository }} GITEA_REPOSITORY: ${{ gitea.repository }}
DEPLOY_TARGET: ec2
EC2_SSH_KEY: ${{ secrets.EC2_SSH_KEY }} EC2_SSH_KEY: ${{ secrets.EC2_SSH_KEY }}
EC2_HOST: ${{ secrets.EC2_HOST }} EC2_HOST: ${{ secrets.EC2_HOST }}
EC2_USER: ${{ secrets.EC2_USER }} EC2_USER: ${{ secrets.EC2_USER }}
run: sh scripts/ci/deploy-server.sh "${{ gitea.ref_name }}" run: sh scripts/ci/deploy-server.sh "${{ gitea.ref_name }}"
# 迁移期影子目标:部署到阿里机(只换二进制 + reload 独立 nginxjiu 保持 stopped)。
# if: always() → EC2 失败也照样尝试;continue-on-error → 阿里失败不让流水线变红。
- name: Deploy → Ali (migration shadow)
if: always()
continue-on-error: true
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
GITEA_REPOSITORY: ${{ gitea.repository }}
DEPLOY_TARGET: ali
DEPLOY_SSH_KEY: ${{ secrets.ALI_SSH_KEY }}
DEPLOY_HOST: ${{ secrets.ALI_HOST }}
DEPLOY_USER: ${{ secrets.ALI_USER }}
run: sh scripts/ci/deploy-server.sh "${{ gitea.ref_name }}"
- name: Notify - name: Notify
if: always() if: always()
env: env:
+14 -1
View File
@@ -32,7 +32,7 @@ jobs:
GITEA_REPOSITORY: ${{ gitea.repository }} GITEA_REPOSITORY: ${{ gitea.repository }}
run: sh scripts/ci/release-site.sh "${{ gitea.ref_name }}" run: sh scripts/ci/release-site.sh "${{ gitea.ref_name }}"
- name: Deploy - name: Deploy → EC2
env: env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }} FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
FORGEJO_URL: ${{ secrets.FORGEJO_URL }} FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
@@ -42,6 +42,19 @@ jobs:
EC2_USER: ${{ secrets.EC2_USER }} EC2_USER: ${{ secrets.EC2_USER }}
run: sh scripts/ci/deploy-site.sh "${{ gitea.ref_name }}" run: sh scripts/ci/deploy-site.sh "${{ gitea.ref_name }}"
# 迁移期影子目标:同步官网到阿里机。失败不挡 EC2。
- name: Deploy → Ali (migration shadow)
if: always()
continue-on-error: true
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
FORGEJO_URL: ${{ secrets.FORGEJO_URL }}
GITEA_REPOSITORY: ${{ gitea.repository }}
DEPLOY_SSH_KEY: ${{ secrets.ALI_SSH_KEY }}
DEPLOY_HOST: ${{ secrets.ALI_HOST }}
DEPLOY_USER: ${{ secrets.ALI_USER }}
run: sh scripts/ci/deploy-site.sh "${{ gitea.ref_name }}"
- name: Notify - name: Notify
if: always() if: always()
env: env:
+46
View File
@@ -5,6 +5,52 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.83] - 2026-06-26
### 改进
- 出库单 / 入库单打印版式全面优化:公司抬头、批次号与生产日期、数字等宽对齐、单据总计独立高亮条,整体更紧凑规整,打印更清晰。
### 修复
- 修正出库单打印的「单价 / 金额」:现按售价显示(金额 = 售价 × 数量),不再误用成本价;历史单据自动兼容。
## [1.0.82] - 2026-06-26
### 新功能
- 出库单 / 入库单打印新增「打印预览」:点击打印先弹出整页预览(所见即所得),确认无误后再调用系统打印,解决部分 Windows 电脑打印对话框无法预览的问题。
## [1.0.81] - 2026-06-24
### 改进
- 出库 / 入库单详情全屏弹窗只覆盖内容区,不再遮挡左侧菜单栏、顶栏与状态栏。
- 出库 / 入库单详情标题栏改为浅色配色(浅蓝底 + 深色文字),观感更清爽。
## [1.0.80] - 2026-06-24
### 新功能
- 出库单详情新增「售价」与「利润」列,利润 = 售价 − 成本,一眼看清每笔出库的盈亏。
### 改进
- 出库 / 入库单详情改为全屏展示,内容更大更清晰,左上角返回箭头关闭。
- 出库单详情自动带出商品的生产日期与批次号(明细未单独填写时回退商品资料),去掉冗余的「数量」列。
## [1.0.79] - 2026-06-24
### 改进
- 入库单 / 出库单搜索框升级:搜索后关键词以标签形式显示在搜索框内,带一键清除(✕)按钮,输入框自动清空便于继续输入;当前仅支持单个搜索词。
## [1.0.78] - 2026-06-24
### 改进
- 维护性发布:发布与部署流程修复,应用功能无变更。
## [1.0.77] - 2026-06-24
### 新功能
- 入库 / 出库列表的搜索框支持按「酒名(商品名)」搜索(支持汉字、全拼、首字母),输入时带防抖限流,输入更顺畅。
### 改进
- 入库录入单价允许留空或填 0(待定价):不再强制单价大于 0,便于先入库、后用「确认进价」补填真实进价。
## [1.0.76] - 2026-06-23 ## [1.0.76] - 2026-06-23
### 新功能 ### 新功能
+15
View File
@@ -5,6 +5,21 @@
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.81] - 2026-06-24
### 修复
- 入库单 / 出库单按商品名搜索:历史导入的单据现在也能按商品名搜到(此前明细统一挂占位商品,搜索被占位名顶替,只能搜到新建单据)。
## [1.0.80] - 2026-06-24
### 改进
- 运维(不影响线上功能):修复迁移期 CI 影子部署链路——部署私钥写入时补齐结尾换行,解决阿里机 ed25519 部署密钥被判「invalid format」导致 Deploy → Ali 步骤每次失败的问题。
## [1.0.79] - 2026-06-24
### 改进
- 入库单 / 出库单列表的关键词搜索支持按「酒名(商品名)」模糊反查:除单号、往来单位外,输入商品名(含拼音/首字母)即可筛出含该商品的单据。
## [1.0.78] - 2026-06-23 ## [1.0.78] - 2026-06-23
### 新功能 ### 新功能
+12 -2
View File
@@ -52,9 +52,19 @@ func (h *StockInHandler) List(c *gin.Context) {
} }
if kw := strings.TrimSpace(c.Query("keyword")); kw != "" { if kw := strings.TrimSpace(c.Query("keyword")); kw != "" {
like := "%" + kw + "%" like := "%" + kw + "%"
// keyword 同时命中:单号 / 往来单位 / 酒名(含全拼+首字母,反查含该酒明细的单据)。
// 酒名「product 主数据名 OR 明细快照名」任一命中——不能用 COALESCE 二选一:历史导入单的
// 明细 product_id 统一指向占位商品「历史导入占位」(p.name 非空),COALESCE 会被占位名劫持,
// 永远取不到真实快照 product_name,导致导入单按真实酒名搜不到。拼音只在 products 上(快照无拼音列)。
query = query.Where( query = query.Where(
"order_no LIKE ? OR partner_id IN (SELECT id FROM partners WHERE shop_id = ? AND name LIKE ?)", "order_no LIKE ?"+
like, shopID, like, " OR partner_id IN (SELECT id FROM partners WHERE shop_id = ? AND name LIKE ?)"+
" OR id IN (SELECT it.order_id FROM stock_in_items it"+
" LEFT JOIN products p ON p.id = it.product_id AND p.shop_id = it.shop_id"+
" WHERE it.shop_id = ? AND it.deleted_at IS NULL"+
" AND (p.name LIKE ? OR it.product_name LIKE ?"+
" OR p.name_pinyin LIKE ? OR p.name_initials LIKE ?))",
like, shopID, like, shopID, like, like, like, like,
) )
} }
+144 -1
View File
@@ -263,7 +263,7 @@ func TestStockInHandler_TotalAmount(t *testing.T) {
"warehouse_id": warehouse.ID, "warehouse_id": warehouse.ID,
"order_date": time.Now().Format(time.RFC3339), "order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{ "items": []map[string]interface{}{
{"product_id": product1.ID, "quantity": 10.0, "unit_price": 5.0}, // 50 {"product_id": product1.ID, "quantity": 10.0, "unit_price": 5.0}, // 50
{"product_id": product2.ID, "quantity": 3.0, "unit_price": 20.0}, // 60 {"product_id": product2.ID, "quantity": 3.0, "unit_price": 20.0}, // 60
}, },
}) })
@@ -451,6 +451,149 @@ func TestStockInHandler_List_FilterByKeyword(t *testing.T) {
assert.Equal(t, float64(2), parseResponse(w)["total"].(float64)) assert.Equal(t, float64(2), parseResponse(w)["total"].(float64))
} }
func TestStockInHandler_List_FilterByProductName(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SI012")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
// 单据1:明细酒名「飞天茅台」(入库按 product_name 新建独立 product
w := makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{
"warehouse_id": warehouse.ID,
"order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{{"product_name": "飞天茅台", "quantity": 5.0}},
})
require.Equal(t, http.StatusCreated, w.Code)
// 单据2:明细酒名「拉菲红酒」
w = makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{
"warehouse_id": warehouse.ID,
"order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{{"product_name": "拉菲红酒", "quantity": 3.0}},
})
require.Equal(t, http.StatusCreated, w.Code)
// 按酒名模糊反查 → 各命中 1 单
w = makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword=茅台", token, nil)
assert.Equal(t, float64(1), parseResponse(w)["total"].(float64))
w = makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword=红酒", token, nil)
assert.Equal(t, float64(1), parseResponse(w)["total"].(float64))
// 无关键词 → 两单都在
w = makeRequest(r, "GET", "/api/v1/stock-in/orders", token, nil)
assert.Equal(t, float64(2), parseResponse(w)["total"].(float64))
}
// 回归:历史导入单的明细 product_id 统一指向占位商品「历史导入占位」(p.name 非空),
// 真实酒名只在快照列 product_name。修复前搜索用 COALESCE(NULLIF(p.name,”), product_name)
// 被占位名劫持,导入单按真实酒名搜不到;修复后「p.name OR product_name」任一命中。
func TestStockInHandler_List_FilterByImportedSnapshotName(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SI012B")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
// 占位商品:名「历史导入占位」,编码 HIST-PLACEHOLDER(模拟 import-history
placeholder := &model.Product{
TenantBase: model.TenantBase{ShopID: shop.ID},
Name: "历史导入占位",
Code: "HIST-PLACEHOLDER",
}
require.NoError(t, db.Create(placeholder).Error)
// 导入单:明细 product_id 指向占位商品,真实酒名「飞天茅台」仅在快照 product_name
order := &model.StockInOrder{
TenantBase: model.TenantBase{ShopID: shop.ID},
OrderNo: "RK-IMPORT-001",
WarehouseID: warehouse.ID,
OperatorID: user.ID,
Status: "approved",
OrderDate: model.Date{Time: time.Now()},
}
require.NoError(t, db.Create(order).Error)
require.NoError(t, db.Create(&model.StockInItem{
OrderID: order.ID,
ShopID: shop.ID,
ProductID: placeholder.ID,
ProductCode: "ZXZ000010",
ProductName: "飞天茅台",
Quantity: 5.0,
}).Error)
// 按真实酒名搜 → 命中导入单(修复前为 0)
w := makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword=茅台", token, nil)
assert.Equal(t, float64(1), parseResponse(w)["total"].(float64))
}
func TestStockInHandler_List_FilterByProductPinyin(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SI013")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
// 入库按 product_name 新建独立 product,会自动写入拼音列(name_pinyin/name_initials
w := makeRequest(r, "POST", "/api/v1/stock-in/orders", token, map[string]interface{}{
"warehouse_id": warehouse.ID,
"order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{{"product_name": "飞天茅台", "quantity": 5.0}},
})
require.Equal(t, http.StatusCreated, w.Code)
// 全拼命中(feitianmaotai
w = makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword=feitian", token, nil)
assert.Equal(t, float64(1), parseResponse(w)["total"].(float64))
// 首字母命中(ftmt
w = makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword=ftmt", token, nil)
assert.Equal(t, float64(1), parseResponse(w)["total"].(float64))
// 不相关拼音不命中
w = makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword=wuliangye", token, nil)
assert.Equal(t, float64(0), parseResponse(w)["total"].(float64))
}
func TestStockInHandler_List_FilterByProductName_TenantIsolation(t *testing.T) {
db := testutil.SetupTestDB()
r := setupProtectedRouter(db)
// 店A、店B 各建一张含「茅台」明细的入库单
shopA := testutil.CreateTestShop(db, "SI014A")
userA := testutil.CreateTestUser(db, shopA.ID, "adminA", "pass", "admin")
whA := testutil.CreateTestWarehouse(db, shopA.ID, "WA")
tokenA := getAuthToken(userA.ID, shopA.ID, "admin")
shopB := testutil.CreateTestShop(db, "SI014B")
userB := testutil.CreateTestUser(db, shopB.ID, "adminB", "pass", "admin")
whB := testutil.CreateTestWarehouse(db, shopB.ID, "WB")
tokenB := getAuthToken(userB.ID, shopB.ID, "admin")
for _, tc := range []struct {
token string
wh uint64
}{{tokenA, whA.ID}, {tokenB, whB.ID}} {
w := makeRequest(r, "POST", "/api/v1/stock-in/orders", tc.token, map[string]interface{}{
"warehouse_id": tc.wh,
"order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{{"product_name": "飞天茅台", "quantity": 1.0}},
})
require.Equal(t, http.StatusCreated, w.Code)
}
// 店A 搜茅台只看到自己的 1 单,绝不串到店B
w := makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword=茅台", tokenA, nil)
assert.Equal(t, float64(1), parseResponse(w)["total"].(float64))
w = makeRequest(r, "GET", "/api/v1/stock-in/orders?keyword=茅台", tokenB, nil)
assert.Equal(t, float64(1), parseResponse(w)["total"].(float64))
}
func TestStockInHandler_List_FilterByStatus(t *testing.T) { func TestStockInHandler_List_FilterByStatus(t *testing.T) {
db := testutil.SetupTestDB() db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SI010") shop := testutil.CreateTestShop(db, "SI010")
+12 -2
View File
@@ -45,9 +45,19 @@ func (h *StockOutHandler) List(c *gin.Context) {
} }
if kw := strings.TrimSpace(c.Query("keyword")); kw != "" { if kw := strings.TrimSpace(c.Query("keyword")); kw != "" {
like := "%" + kw + "%" like := "%" + kw + "%"
// keyword 同时命中:单号 / 往来单位 / 酒名(含全拼+首字母,反查含该酒明细的单据)。
// 酒名「product 主数据名 OR 明细快照名」任一命中——不能用 COALESCE 二选一:历史导入单的
// 明细 product_id 统一指向占位商品「历史导入占位」(p.name 非空),COALESCE 会被占位名劫持,
// 永远取不到真实快照 product_name,导致导入单按真实酒名搜不到。拼音只在 products 上(快照无拼音列)。
query = query.Where( query = query.Where(
"order_no LIKE ? OR partner_id IN (SELECT id FROM partners WHERE shop_id = ? AND name LIKE ?)", "order_no LIKE ?"+
like, shopID, like, " OR partner_id IN (SELECT id FROM partners WHERE shop_id = ? AND name LIKE ?)"+
" OR id IN (SELECT it.order_id FROM stock_out_items it"+
" LEFT JOIN products p ON p.id = it.product_id AND p.shop_id = it.shop_id"+
" WHERE it.shop_id = ? AND it.deleted_at IS NULL"+
" AND (p.name LIKE ? OR it.product_name LIKE ?"+
" OR p.name_pinyin LIKE ? OR p.name_initials LIKE ?))",
like, shopID, like, shopID, like, like, like, like,
) )
} }
// 按商品编码反查单据:精确匹配,走 idx_so_items_shop_code(shop_id, product_code) 索引 // 按商品编码反查单据:精确匹配,走 idx_so_items_shop_code(shop_id, product_code) 索引
@@ -212,6 +212,82 @@ func TestStockOutHandler_List_FilterByKeyword(t *testing.T) {
assert.Equal(t, float64(2), parseResponse(w)["total"].(float64)) assert.Equal(t, float64(2), parseResponse(w)["total"].(float64))
} }
func TestStockOutHandler_List_FilterByProductName(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SO012")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
maotai := testutil.CreateTestProduct(db, shop.ID, "飞天茅台")
lafei := testutil.CreateTestProduct(db, shop.ID, "拉菲红酒")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
w := makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{
"warehouse_id": warehouse.ID,
"order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{{"product_id": maotai.ID, "quantity": 1.0}},
})
require.Equal(t, http.StatusCreated, w.Code)
w = makeRequest(r, "POST", "/api/v1/stock-out/orders", token, map[string]interface{}{
"warehouse_id": warehouse.ID,
"order_date": time.Now().Format(time.RFC3339),
"items": []map[string]interface{}{{"product_id": lafei.ID, "quantity": 1.0}},
})
require.Equal(t, http.StatusCreated, w.Code)
// 按酒名模糊反查 → 各命中 1 单
w = makeRequest(r, "GET", "/api/v1/stock-out/orders?keyword=茅台", token, nil)
assert.Equal(t, float64(1), parseResponse(w)["total"].(float64))
w = makeRequest(r, "GET", "/api/v1/stock-out/orders?keyword=红酒", token, nil)
assert.Equal(t, float64(1), parseResponse(w)["total"].(float64))
// 无关键词 → 两单都在
w = makeRequest(r, "GET", "/api/v1/stock-out/orders", token, nil)
assert.Equal(t, float64(2), parseResponse(w)["total"].(float64))
}
// 回归:历史导入出库单的明细 product_id 指向占位商品「历史导入占位」,真实酒名只在快照列。
// 修复前 COALESCE 被占位名劫持搜不到,修复后「p.name OR product_name」任一命中。
func TestStockOutHandler_List_FilterByImportedSnapshotName(t *testing.T) {
db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SO012B")
user := testutil.CreateTestUser(db, shop.ID, "admin", "pass", "admin")
warehouse := testutil.CreateTestWarehouse(db, shop.ID, "Warehouse")
token := getAuthToken(user.ID, shop.ID, "admin")
r := setupProtectedRouter(db)
placeholder := &model.Product{
TenantBase: model.TenantBase{ShopID: shop.ID},
Name: "历史导入占位",
Code: "HIST-PLACEHOLDER",
}
require.NoError(t, db.Create(placeholder).Error)
order := &model.StockOutOrder{
TenantBase: model.TenantBase{ShopID: shop.ID},
OrderNo: "CK-IMPORT-001",
WarehouseID: warehouse.ID,
OperatorID: user.ID,
Status: "approved",
OrderDate: model.Date{Time: time.Now()},
}
require.NoError(t, db.Create(order).Error)
require.NoError(t, db.Create(&model.StockOutItem{
OrderID: order.ID,
ShopID: shop.ID,
ProductID: placeholder.ID,
ProductCode: "ZXZ000010",
ProductName: "飞天茅台",
Quantity: 5.0,
}).Error)
// 按真实酒名搜 → 命中导入单(修复前为 0)
w := makeRequest(r, "GET", "/api/v1/stock-out/orders?keyword=茅台", token, nil)
assert.Equal(t, float64(1), parseResponse(w)["total"].(float64))
}
func TestStockOutHandler_Reject(t *testing.T) { func TestStockOutHandler_Reject(t *testing.T) {
db := testutil.SetupTestDB() db := testutil.SetupTestDB()
shop := testutil.CreateTestShop(db, "SO004") shop := testutil.CreateTestShop(db, "SO004")
@@ -0,0 +1,20 @@
/// 单据打印的「门店抬头 + 制单人」元信息。
///
/// - 门店信息(公司名称/地址/电话)来自 `shopInfoProvider`,作为单据顶部抬头。
/// - [makerName] = 制单人 = **当前登录、点击打印的人**(非单据经办人/审核人)。
/// 入库单的「采购员」「出库单的「销售员」才是单据经办人(operator)。
class OrderPrintMeta {
final String shopName;
final String shopAddress;
final String shopPhone;
/// 制单人 = 当前登录用户(打印此单的人)。
final String makerName;
const OrderPrintMeta({
this.shopName = '',
this.shopAddress = '',
this.shopPhone = '',
this.makerName = '',
});
}
+15 -4
View File
@@ -5,6 +5,8 @@ import '../../models/stock_out.dart';
import '../errors/error_reporter.dart'; import '../errors/error_reporter.dart';
import 'label_data.dart'; import 'label_data.dart';
export 'label_data.dart'; export 'label_data.dart';
import 'order_print_meta.dart';
export 'order_print_meta.dart';
import 'print_util_stub.dart' import 'print_util_stub.dart'
if (dart.library.js_interop) 'print_util_web.dart'; if (dart.library.js_interop) 'print_util_web.dart';
@@ -47,11 +49,20 @@ Future<List<String>> listLabelPrinters() => listLabelPrintersImpl();
/// 自动检测默认热敏打印机(名字含 deli/dl-888);找不到或 Web 返回 null。 /// 自动检测默认热敏打印机(名字含 deli/dl-888);找不到或 Web 返回 null。
Future<String?> detectDefaultPrinter() => detectDefaultPrinterImpl(); Future<String?> detectDefaultPrinter() => detectDefaultPrinterImpl();
Future<void> printStockInOrder(StockInOrder order) => Future<void> printStockInOrder(StockInOrder order, OrderPrintMeta meta) =>
printStockInOrderImpl(order); printStockInOrderImpl(order, meta);
Future<void> printStockOutOrder(StockOutOrder order) => Future<void> printStockOutOrder(StockOutOrder order, OrderPrintMeta meta) =>
printStockOutOrderImpl(order); printStockOutOrderImpl(order, meta);
/// 生成入库单 PDF 字节(桌面端真实排版,供打印预览光栅化用);Web 端不支持。
Future<Uint8List> buildStockInOrderPdf(StockInOrder order, OrderPrintMeta meta) =>
buildStockInOrderPdfImpl(order, meta);
/// 生成出库单 PDF 字节(桌面端真实排版,供打印预览光栅化用);Web 端不支持。
Future<Uint8List> buildStockOutOrderPdf(
StockOutOrder order, OrderPrintMeta meta) =>
buildStockOutOrderPdfImpl(order, meta);
/// 统一打印入口:catch 任意异常,上报并弹 SnackBar 给用户。 /// 统一打印入口:catch 任意异常,上报并弹 SnackBar 给用户。
/// 用法:await safePrint(context, () => printStockInOrder(order)); /// 用法:await safePrint(context, () => printStockInOrder(order));
+278 -158
View File
@@ -25,6 +25,7 @@ import 'package:printing/printing.dart';
import '../../models/stock_in.dart'; import '../../models/stock_in.dart';
import '../../models/stock_out.dart'; import '../../models/stock_out.dart';
import 'label_data.dart'; import 'label_data.dart';
import 'order_print_meta.dart';
Future<pw.Font> _loadFont() async { Future<pw.Font> _loadFont() async {
final data = await rootBundle.load('assets/fonts/NotoSansSC-Regular.ttf'); final data = await rootBundle.load('assets/fonts/NotoSansSC-Regular.ttf');
@@ -644,184 +645,275 @@ Future<List<String>> listLabelPrintersImpl() async {
/// 自动检测默认热敏打印机(名字含 deli/dl-888);找不到返回 null。 /// 自动检测默认热敏打印机(名字含 deli/dl-888);找不到返回 null。
Future<String?> detectDefaultPrinterImpl() => _findThermalPrinter(); Future<String?> detectDefaultPrinterImpl() => _findThermalPrinter();
/// 截取日期前 10 位(yyyy-MM-dd),空值返回空串。
String _d10(String? s) {
if (s == null || s.isEmpty) return '';
return s.length > 10 ? s.substring(0, 10) : s;
}
/// 数量格式化:整数不带小数,否则保留 3 位。
String _qtyStr(double q) =>
q % 1 == 0 ? q.toStringAsFixed(0) : q.toStringAsFixed(3);
/// 单据明细列(入库/出库统一):商品编号→备注。
const _orderHeaders = [
'商品编号', '商品名称', '系列', '规格', '批次号',
'生产日期', '数量', '单价', '金额', '备注',
];
const _orderColFlex = <double>[1.5, 2.0, 1.1, 1.3, 1.6, 1.3, 0.7, 1.1, 1.2, 0.9];
/// 温馨提示文案(入库/出库统一)。
const _kTipsText =
'温馨提示:签收时,请务必核对好酒品数量、年份和日期、批次及物流码,'
'如有问题及时反馈,酒品无质量问题一经售出概不退换,谢谢合作。';
/// 各明细列对齐:系列/规格/批次号/生产日期居中,数量/单价/金额右对齐,其余左对齐。
pw.TextAlign _colAlign(int i) {
switch (i) {
case 2:
case 3:
case 4:
case 5:
return pw.TextAlign.center;
case 6:
case 7:
case 8:
return pw.TextAlign.right;
default:
return pw.TextAlign.left;
}
}
/// 构建入库单/出库单 A4 版式(参照「锐浪报表」单据:公司抬头 + 两行三列表头
/// + 明细表 + 单据总计 + 温馨提示 + 签字行)。入库/出库仅称谓不同。
pw.Widget _buildOrderDoc({ pw.Widget _buildOrderDoc({
required pw.Font font, required pw.Font font,
required pw.Font bold, required pw.Font bold,
required String title, required String shopName, // 公司名称(顶部抬头)
required String title, // 入 库 单 / 出 库 单
required String orderNo, required String orderNo,
required String? orderDate, required String noPrefix, // 'NO' / 'NO.'
required String? partnerLabel, required String partnerLabel, // 单位名称 / 客户名称
required String? partnerName, required String? partnerName,
required String dateLabel, // 入库日期 / 出库日期
required String? orderDate,
required String addressLabel, // 单位地址 / 送货地址
required String? warehouseName, required String? warehouseName,
required String signerLabel, // 出货人(签字)/ 收货人(签字)
required String operatorRoleLabel, // 采购员 / 销售员(= 经办人)
required String? operatorName, required String? operatorName,
required String? reviewerName, required String? makerName, // 制单人 = 打印人
required String? remark, required String? remark,
required List<String> headers, required bool showTips,
required List<List<String>> rows, required List<List<String>> rows,
required double totalQty, required double totalQty,
required double totalAmt, required double totalAmt,
String? tipsText,
}) { }) {
final headerStyle = pw.TextStyle(font: bold, fontSize: 10, fontWeight: pw.FontWeight.bold); // 样式三「紧凑报表」:左侧竖线点缀 + 抬头左对齐内联 + 灰底信息条 + 密集明细表
final cellStyle = pw.TextStyle(font: font, fontSize: 9.5); // + 数字等宽(内置 Courier,ASCII 数字适用)+ 灰底总计条。
final metaStyle = pw.TextStyle(font: font, fontSize: 10); final mono = pw.Font.courier();
final boldStyle = pw.TextStyle(font: bold, fontSize: 10, fontWeight: pw.FontWeight.bold); final monoBold = pw.Font.courierBold();
final cellStyle = pw.TextStyle(font: font, fontSize: 8.5);
final numStyle = pw.TextStyle(font: mono, fontSize: 8.5);
final headStyle =
pw.TextStyle(font: bold, fontSize: 8.5, fontWeight: pw.FontWeight.bold);
final metaStyle = pw.TextStyle(font: font, fontSize: 9);
final metaBold =
pw.TextStyle(font: bold, fontSize: 9, fontWeight: pw.FontWeight.bold);
final colWidths = headers.map((h) { pw.Widget metaText(String label, String value, pw.TextAlign align) =>
if (h == '商品名称') return const pw.FlexColumnWidth(2.2); pw.Text('$label$value', style: metaStyle, textAlign: align);
if (h == '系列' || h == '规格') return const pw.FlexColumnWidth(1.2);
if (h == '商品编号') return const pw.FlexColumnWidth(1.2);
return const pw.FlexColumnWidth(0.9);
}).toList();
return pw.Column( return pw.Container(
crossAxisAlignment: pw.CrossAxisAlignment.start, // 左侧竖线点缀
children: [ decoration: const pw.BoxDecoration(
pw.Center( border: pw.Border(left: pw.BorderSide(color: PdfColors.black, width: 3)),
child: pw.Text(title, ),
style: pw.TextStyle(font: bold, fontSize: 20, fontWeight: pw.FontWeight.bold)), padding: const pw.EdgeInsets.only(left: 14),
), child: pw.Column(
pw.SizedBox(height: 8), crossAxisAlignment: pw.CrossAxisAlignment.stretch,
pw.Container( children: [
decoration: const pw.BoxDecoration( // ── 抬头:公司名 + 单据类型,左对齐内联 ──────────────────────────
border: pw.Border(bottom: pw.BorderSide(color: PdfColors.grey600)), pw.Row(
), crossAxisAlignment: pw.CrossAxisAlignment.end,
padding: const pw.EdgeInsets.only(bottom: 5),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [ children: [
pw.Text('$partnerLabel${partnerName ?? ''}', style: metaStyle), pw.Text(shopName,
pw.Text('日期:${orderDate ?? ''}', style: metaStyle), style: pw.TextStyle(
pw.Text('NO: $orderNo', style: boldStyle), font: bold, fontSize: 16, fontWeight: pw.FontWeight.bold)),
pw.SizedBox(width: 12),
pw.Padding(
padding: const pw.EdgeInsets.only(bottom: 1.5),
child: pw.Text(title,
style: pw.TextStyle(
font: font,
fontSize: 11,
letterSpacing: 3,
color: PdfColors.grey700)),
),
], ],
), ),
), pw.SizedBox(height: 8),
pw.SizedBox(height: 4), // ── 抬头信息:灰底信息条(两行三列)────────────────────────────
pw.Row(
children: [
pw.Text('仓库:${warehouseName ?? ''}', style: metaStyle),
pw.SizedBox(width: 24),
pw.Text('经办人:${operatorName ?? ''}', style: metaStyle),
],
),
pw.SizedBox(height: 8),
pw.Table(
border: pw.TableBorder.all(color: PdfColors.grey500),
columnWidths: { for (var i = 0; i < colWidths.length; i++) i: colWidths[i] },
children: [
pw.TableRow(
decoration: const pw.BoxDecoration(color: PdfColors.grey200),
children: headers.map((h) => pw.Padding(
padding: const pw.EdgeInsets.symmetric(horizontal: 3, vertical: 3),
child: pw.Text(h, textAlign: pw.TextAlign.center, style: headerStyle),
)).toList(),
),
...rows.map((row) => pw.TableRow(
children: row.map((cell) => pw.Padding(
padding: const pw.EdgeInsets.symmetric(horizontal: 3, vertical: 2),
child: pw.Text(cell, style: cellStyle),
)).toList(),
)),
pw.TableRow(
decoration: const pw.BoxDecoration(color: PdfColors.grey100),
children: List.generate(headers.length, (i) {
if (i == headers.length - 3) {
return pw.Padding(
padding: const pw.EdgeInsets.symmetric(horizontal: 3, vertical: 2),
child: pw.Text('单据总计', textAlign: pw.TextAlign.right, style: boldStyle),
);
}
if (i == headers.length - 2) {
return pw.Padding(
padding: const pw.EdgeInsets.symmetric(horizontal: 3, vertical: 2),
child: pw.Text(
totalQty % 1 == 0 ? totalQty.toStringAsFixed(0) : totalQty.toStringAsFixed(3),
textAlign: pw.TextAlign.right, style: boldStyle,
),
);
}
if (i == headers.length - 1) {
return pw.Padding(
padding: const pw.EdgeInsets.symmetric(horizontal: 3, vertical: 2),
child: pw.Text(totalAmt.toStringAsFixed(2),
textAlign: pw.TextAlign.right, style: boldStyle),
);
}
return pw.SizedBox();
}),
),
],
),
if ((remark ?? '').isNotEmpty) ...[
pw.SizedBox(height: 6),
pw.Text('备注:$remark', style: metaStyle),
],
if (tipsText != null) ...[
pw.SizedBox(height: 6),
pw.Container( pw.Container(
decoration: pw.BoxDecoration( decoration: pw.BoxDecoration(
border: pw.Border.all(color: PdfColors.grey400), color: PdfColors.grey100,
color: PdfColors.grey50, border: pw.Border.all(color: PdfColors.grey400, width: 0.5),
),
padding: const pw.EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: pw.Column(
children: [
pw.Row(
children: [
pw.Expanded(
flex: 5,
child: metaText(
partnerLabel, partnerName ?? '', pw.TextAlign.left)),
pw.Expanded(
flex: 4,
child: metaText(
dateLabel, _d10(orderDate), pw.TextAlign.left)),
pw.Expanded(
flex: 3,
child: pw.Text('$noPrefix$orderNo',
style: metaBold, textAlign: pw.TextAlign.right)),
],
),
pw.SizedBox(height: 3),
pw.Row(
children: [
pw.Expanded(
flex: 5,
child: metaText('联系电话', '', pw.TextAlign.left)),
pw.Expanded(
flex: 4,
child: metaText(addressLabel, '', pw.TextAlign.left)),
pw.Expanded(
flex: 3,
child: metaText(
'仓库', warehouseName ?? '', pw.TextAlign.right)),
],
),
],
), ),
padding: const pw.EdgeInsets.all(5),
child: pw.Text(tipsText,
style: pw.TextStyle(font: font, fontSize: 9, color: PdfColors.grey700)),
), ),
], pw.SizedBox(height: 8),
pw.SizedBox(height: 36), // ── 明细表(密集、灰底表头、数字等宽)──────────────────────────
pw.Row( pw.Table(
mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, border: pw.TableBorder.all(color: PdfColors.grey500, width: 0.5),
children: [ columnWidths: {
_sig(font, bold, '收/出货人(签字)'), for (var i = 0; i < _orderColFlex.length; i++)
_sig(font, bold, '经办人:${operatorName ?? ''}'), i: pw.FlexColumnWidth(_orderColFlex[i])
_sig(font, bold, '制单人:${reviewerName ?? operatorName ?? ''}'), },
children: [
pw.TableRow(
decoration: const pw.BoxDecoration(color: PdfColors.grey300),
children: [
for (final h in _orderHeaders)
pw.Padding(
padding: const pw.EdgeInsets.symmetric(
horizontal: 2, vertical: 2.5),
child: pw.Text(h,
textAlign: pw.TextAlign.center, style: headStyle),
),
],
),
for (final row in rows)
pw.TableRow(
children: [
for (var i = 0; i < row.length; i++)
pw.Padding(
padding: const pw.EdgeInsets.symmetric(
horizontal: 3, vertical: 2),
child: pw.Text(row[i],
textAlign: _colAlign(i),
style: (i >= 6 && i <= 8) ? numStyle : cellStyle),
),
],
),
],
),
// ── 单据总计:灰底条,右对齐,仅数量 + 金额(等宽数字)──────────
pw.SizedBox(height: 6),
pw.Container(
width: double.infinity,
decoration: pw.BoxDecoration(
color: PdfColors.grey100,
border: pw.Border.all(color: PdfColors.grey400, width: 0.5),
),
padding: const pw.EdgeInsets.symmetric(horizontal: 10, vertical: 5),
child: pw.Row(
mainAxisAlignment: pw.MainAxisAlignment.end,
crossAxisAlignment: pw.CrossAxisAlignment.center,
children: [
pw.Text('单据总计', style: metaBold),
pw.SizedBox(width: 26),
pw.Text('数量 ', style: metaStyle),
pw.Text(_qtyStr(totalQty),
style: pw.TextStyle(font: monoBold, fontSize: 11)),
pw.SizedBox(width: 26),
pw.Text('金额 ', style: metaStyle),
pw.Text(totalAmt.toStringAsFixed(2),
style: pw.TextStyle(font: monoBold, fontSize: 12)),
],
),
),
// ── 温馨提示(仅出库单)──────────────────────────────────────────
if (showTips) ...[
pw.SizedBox(height: 8),
pw.Container(
width: double.infinity,
decoration: pw.BoxDecoration(
border: pw.Border.all(color: PdfColors.grey400, width: 0.5),
color: PdfColors.grey50,
),
padding: const pw.EdgeInsets.all(5),
child: pw.Text(_kTipsText,
style: pw.TextStyle(
font: font, fontSize: 8.5, color: PdfColors.grey800)),
),
], ],
), pw.SizedBox(height: 24),
], // ── 签字行:签收人 / 经办人 / 制单人 ──────────────────────────────
); pw.Row(
} mainAxisAlignment: pw.MainAxisAlignment.spaceBetween,
children: [
pw.Widget _sig(pw.Font font, pw.Font bold, String label) { pw.Text('$signerLabel', style: metaStyle),
return pw.Column( pw.Text('$operatorRoleLabel${operatorName ?? ''}',
children: [ style: metaStyle),
pw.Container(width: 120, height: 28), pw.Text('制单人:${makerName ?? ''}', style: metaStyle),
pw.Container( ],
width: 120,
decoration: const pw.BoxDecoration(
border: pw.Border(top: pw.BorderSide(color: PdfColors.grey700)),
), ),
padding: const pw.EdgeInsets.only(top: 3), if ((remark ?? '').isNotEmpty) ...[
child: pw.Text(label, pw.SizedBox(height: 10),
textAlign: pw.TextAlign.center, pw.Text('备注:$remark', style: metaStyle),
style: pw.TextStyle(font: font, fontSize: 10)), ],
), ],
], ),
); );
} }
Future<void> printStockInOrderImpl(StockInOrder order) async { /// 生成入库单 A4 PDF 字节(与实际打印同一套排版,供预览光栅化 + 打印共用)。
try { Future<Uint8List> buildStockInOrderPdfImpl(
StockInOrder order, OrderPrintMeta meta) async {
final font = await _loadFont(); final font = await _loadFont();
final bold = await _loadBoldFont(); final bold = await _loadBoldFont();
double totalQty = 0, totalAmt = 0; double totalQty = 0, totalAmt = 0;
final rows = <List<String>>[]; final rows = <List<String>>[];
for (int i = 0; i < order.items.length; i++) { for (final it in order.items) {
final it = order.items[i];
totalQty += it.quantity; totalQty += it.quantity;
totalAmt += it.totalPrice; totalAmt += it.totalPrice;
rows.add([ rows.add([
'${i + 1}',
it.productCode ?? '', it.productCode ?? '',
it.productName ?? '', it.productName ?? '',
it.productSeries ?? '', it.productSeries ?? '',
it.productSpec ?? '', it.productSpec ?? '',
it.quantity % 1 == 0 ? it.quantity.toStringAsFixed(0) : it.quantity.toStringAsFixed(3), it.batchNo ?? '',
it.productUnit ?? '', _d10(it.productionDate),
_qtyStr(it.quantity),
it.unitPrice.toStringAsFixed(2), it.unitPrice.toStringAsFixed(2),
it.totalPrice.toStringAsFixed(2), it.totalPrice.toStringAsFixed(2),
it.productionDate ?? '', '',
it.batchNo ?? '',
]); ]);
} }
@@ -832,23 +924,35 @@ Future<void> printStockInOrderImpl(StockInOrder order) async {
build: (_) => [ build: (_) => [
_buildOrderDoc( _buildOrderDoc(
font: font, bold: bold, font: font, bold: bold,
shopName: meta.shopName,
title: '入 库 单', title: '入 库 单',
orderNo: order.orderNo, orderNo: order.orderNo,
orderDate: order.orderDate, noPrefix: 'NO',
partnerLabel: '供应商', partnerLabel: '单位名称',
partnerName: order.partnerName, partnerName: order.partnerName,
dateLabel: '入库日期',
orderDate: order.orderDate,
addressLabel: '单位地址',
warehouseName: order.warehouseName, warehouseName: order.warehouseName,
signerLabel: '出货人(签字)',
operatorRoleLabel: '采购员',
operatorName: order.operatorName, operatorName: order.operatorName,
reviewerName: order.reviewerName, makerName: meta.makerName,
remark: order.remark, remark: order.remark,
headers: ['序号', '商品编号', '商品名称', '系列', '规格', '数量', '单位', '单价', '金额', '生产日期', '批次'], showTips: false,
rows: rows, rows: rows,
totalQty: totalQty, totalQty: totalQty,
totalAmt: totalAmt, totalAmt: totalAmt,
), ),
], ],
)); ));
final bytes = await doc.save(); return doc.save();
}
Future<void> printStockInOrderImpl(
StockInOrder order, OrderPrintMeta meta) async {
try {
final bytes = await buildStockInOrderPdfImpl(order, meta);
if (!kIsWeb && Platform.isMacOS) { if (!kIsWeb && Platform.isMacOS) {
await Future<void>.delayed(Duration.zero); await Future<void>.delayed(Duration.zero);
} }
@@ -862,26 +966,32 @@ Future<void> printStockInOrderImpl(StockInOrder order) async {
} }
} }
Future<void> printStockOutOrderImpl(StockOutOrder order) async { /// 生成出库单 A4 PDF 字节(与实际打印同一套排版,供预览光栅化 + 打印共用)。
Future<Uint8List> buildStockOutOrderPdfImpl(
StockOutOrder order, OrderPrintMeta meta) async {
final font = await _loadFont(); final font = await _loadFont();
final bold = await _loadBoldFont(); final bold = await _loadBoldFont();
double totalQty = 0, totalAmt = 0; double totalQty = 0, totalAmt = 0;
final rows = <List<String>>[]; final rows = <List<String>>[];
for (int i = 0; i < order.items.length; i++) { for (final it in order.items) {
final it = order.items[i]; // 出库单「单价」= 售价:App 单据存 sale_price;历史导入单 sale_price=0、
// 实际售价存在 unit_price/total_price,故 sale_price 为空时回退到 unit_price。
final unit = it.salePrice > 0 ? it.salePrice : it.unitPrice;
final amt = it.salePrice > 0 ? it.salePrice * it.quantity : it.totalPrice;
totalQty += it.quantity; totalQty += it.quantity;
totalAmt += it.totalPrice; totalAmt += amt;
rows.add([ rows.add([
'${i + 1}',
it.productCode ?? '', it.productCode ?? '',
it.productName ?? '', it.productName ?? '',
it.productSeries ?? '', it.productSeries ?? '',
it.productSpec ?? '', it.productSpec ?? '',
it.productUnit ?? '', it.batchNo ?? '',
it.quantity % 1 == 0 ? it.quantity.toStringAsFixed(0) : it.quantity.toStringAsFixed(3), _d10(it.productionDate),
it.unitPrice.toStringAsFixed(2), _qtyStr(it.quantity),
it.totalPrice.toStringAsFixed(2), unit.toStringAsFixed(2),
amt.toStringAsFixed(2),
'',
]); ]);
} }
@@ -892,25 +1002,35 @@ Future<void> printStockOutOrderImpl(StockOutOrder order) async {
build: (_) => [ build: (_) => [
_buildOrderDoc( _buildOrderDoc(
font: font, bold: bold, font: font, bold: bold,
shopName: meta.shopName,
title: '出 库 单', title: '出 库 单',
orderNo: order.orderNo, orderNo: order.orderNo,
orderDate: order.orderDate, noPrefix: 'NO.',
partnerLabel: '客户', partnerLabel: '客户名称',
partnerName: order.partnerName, partnerName: order.partnerName,
dateLabel: '出库日期',
orderDate: order.orderDate,
addressLabel: '送货地址',
warehouseName: order.warehouseName, warehouseName: order.warehouseName,
signerLabel: '收货人(签字)',
operatorRoleLabel: '销售员',
operatorName: order.operatorName, operatorName: order.operatorName,
reviewerName: order.reviewerName, makerName: meta.makerName,
remark: order.remark, remark: order.remark,
headers: ['序号', '商品编号', '商品名称', '系列', '规格', '单位', '数量', '单价', '金额'], showTips: true,
rows: rows, rows: rows,
totalQty: totalQty, totalQty: totalQty,
totalAmt: totalAmt, totalAmt: totalAmt,
tipsText: '温馨提示:签收时,请务必核对好酒品数量、年份和日期、批次及物流码,如有问题及时反馈,酒品无质量问题一经售出概不退换,谢谢合作。',
), ),
], ],
)); ));
return doc.save();
}
Future<void> printStockOutOrderImpl(
StockOutOrder order, OrderPrintMeta meta) async {
try { try {
final bytes = await doc.save(); final bytes = await buildStockOutOrderPdfImpl(order, meta);
if (!kIsWeb && Platform.isMacOS) { if (!kIsWeb && Platform.isMacOS) {
await Future<void>.delayed(Duration.zero); await Future<void>.delayed(Duration.zero);
} }
+130 -132
View File
@@ -6,6 +6,7 @@ import '../../models/stock_in.dart';
import '../../models/stock_out.dart'; import '../../models/stock_out.dart';
import '../errors/error_reporter.dart'; import '../errors/error_reporter.dart';
import 'label_data.dart'; import 'label_data.dart';
import 'order_print_meta.dart';
void _openPrintWindow(String html) { void _openPrintWindow(String html) {
final win = web.window.open('', '_blank'); final win = web.window.open('', '_blank');
@@ -170,28 +171,85 @@ body { width: 38mm; height: 20mm; overflow: hidden; background: #fff; }
} }
} }
// ── 单据 PDF 字节(Web 走 HTML 打印路径,不生成 PDF;预览弹窗仅桌面端调用)──────
Future<Uint8List> buildStockInOrderPdfImpl(
StockInOrder order, OrderPrintMeta meta) async =>
throw UnsupportedError('Web 端入库单走 HTML 打印,不生成 PDF');
Future<Uint8List> buildStockOutOrderPdfImpl(
StockOutOrder order, OrderPrintMeta meta) async =>
throw UnsupportedError('Web 端出库单走 HTML 打印,不生成 PDF');
/// 单据打印共用样式(样式三「紧凑报表」:左侧竖线 + 抬头左对齐内联 +
/// 灰底信息条 + 密集明细表 + 数字等宽 + 灰底总计条;入库/出库一致)。
const String _orderPrintCss = '''
@page { size: A4; margin: 12mm 14mm; }
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: "PingFang SC", "Microsoft YaHei", "SimSun", sans-serif; color: #111; border-left: 3px solid #111; padding-left: 14px; }
.shop { display: inline; font-size: 19px; font-weight: 700; }
.title { display: inline; margin-left: 12px; font-size: 13px; letter-spacing: 4px; color: #444; }
.head-block { background: #f3f3f3; border: 1px solid #ccc; padding: 7px 10px; margin: 10px 0; }
.meta-row { display: flex; font-size: 11.5px; line-height: 1.9; }
.meta-row + .meta-row { margin-top: 2px; }
.meta-row .c1 { flex: 5; } .meta-row .c2 { flex: 4; } .meta-row .c3 { flex: 3; text-align: right; }
.meta-row .no { font-weight: 700; }
table { width: 100%; border-collapse: collapse; border: 1px solid #bbb; }
th { background: #e5e5e5; border: 1px solid #bbb; padding: 5px 5px; font-size: 11px; font-weight: 700; text-align: center; white-space: nowrap; }
td { border: 1px solid #ccc; padding: 4px 6px; font-size: 11px; }
.c { text-align: center; } .r { text-align: right; }
td.r { font-family: "SF Mono", "Roboto Mono", Menlo, Consolas, monospace; }
.subtotal-bar { display: flex; justify-content: flex-end; align-items: baseline; gap: 28px; border: 1px solid #bbb; background: #f3f3f3; padding: 7px 12px; margin-top: 8px; font-size: 12px; font-weight: 700; }
.subtotal-bar .lbl-total { margin-right: auto; }
.subtotal-bar b { font-size: 14px; font-family: "SF Mono", Menlo, Consolas, monospace; }
.tips { border: 1px solid #ddd; background: #fafafa; padding: 6px 8px; margin: 8px 0; font-size: 10px; color: #555; line-height: 1.6; }
.sigs { display: flex; justify-content: space-between; margin-top: 30px; font-size: 12px; }
.remark { margin-top: 10px; font-size: 11px; color: #333; }
''';
const String _orderTipsHtml =
'<div class="tips">温馨提示:签收时,请务必核对好酒品数量、年份和日期、批次及物流码,'
'如有问题及时反馈,酒品无质量问题一经售出概不退换,谢谢合作。</div>';
/// 截取日期前 10 位(yyyy-MM-dd)。
String _d10(String? s) =>
(s == null || s.isEmpty) ? '' : (s.length > 10 ? s.substring(0, 10) : s);
/// 单据明细表头(入库/出库一致)。
const String _orderTableHead = '''<thead><tr>
<th style="width:12%">商品编号</th>
<th style="width:16%">商品名称</th>
<th style="width:9%">系列</th>
<th style="width:10%">规格</th>
<th style="width:14%">批次号</th>
<th style="width:11%">生产日期</th>
<th style="width:5%">数量</th>
<th style="width:8%">单价</th>
<th style="width:9%">金额</th>
<th style="width:6%">备注</th>
</tr></thead>''';
// ── 入库单 ────────────────────────────────────────────────────────────────── // ── 入库单 ──────────────────────────────────────────────────────────────────
Future<void> printStockInOrderImpl(StockInOrder order) async { Future<void> printStockInOrderImpl(
StockInOrder order, OrderPrintMeta meta) async {
final rows = StringBuffer(); final rows = StringBuffer();
double totalQty = 0; double totalQty = 0;
double totalAmt = 0; double totalAmt = 0;
for (int i = 0; i < order.items.length; i++) { for (final item in order.items) {
final item = order.items[i];
totalQty += item.quantity; totalQty += item.quantity;
totalAmt += item.totalPrice; totalAmt += item.totalPrice;
rows.write('''<tr> rows.write('''<tr>
<td class="c">${i + 1}</td>
<td>${item.productCode ?? ''}</td> <td>${item.productCode ?? ''}</td>
<td>${item.productName ?? ''}</td> <td>${item.productName ?? ''}</td>
<td class="c">${item.productSeries ?? ''}</td> <td class="c">${item.productSeries ?? ''}</td>
<td class="c">${item.productSpec ?? ''}</td> <td class="c">${item.productSpec ?? ''}</td>
<td class="c">${item.batchNo ?? ''}</td>
<td class="c">${_d10(item.productionDate)}</td>
<td class="r">${item.quantity % 1 == 0 ? item.quantity.toStringAsFixed(0) : item.quantity.toStringAsFixed(3)}</td> <td class="r">${item.quantity % 1 == 0 ? item.quantity.toStringAsFixed(0) : item.quantity.toStringAsFixed(3)}</td>
<td class="c">${item.productUnit ?? ''}</td>
<td class="r">${item.unitPrice.toStringAsFixed(2)}</td> <td class="r">${item.unitPrice.toStringAsFixed(2)}</td>
<td class="r">${item.totalPrice.toStringAsFixed(2)}</td> <td class="r">${item.totalPrice.toStringAsFixed(2)}</td>
<td class="c">${item.productionDate ?? ''}</td> <td></td>
<td class="c">${item.batchNo ?? ''}</td>
</tr>'''); </tr>''');
} }
@@ -203,72 +261,38 @@ Future<void> printStockInOrderImpl(StockInOrder order) async {
<html> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<style> <style>$_orderPrintCss</style>
@page { size: A4; margin: 12mm 15mm; }
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: "PingFang SC", "Microsoft YaHei", "SimSun", sans-serif; font-size: 11px; color: #000; }
.hdr { text-align: center; margin-bottom: 10px; }
.hdr .title { font-size: 22px; font-weight: bold; margin-bottom: 4px; }
.meta { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 6px; border-bottom: 1px solid #666; padding-bottom: 5px; }
.meta .left, .meta .center, .meta .right { flex: 1; }
.meta .center { text-align: center; }
.meta .right { text-align: right; font-weight: bold; font-size: 12px; }
.meta2 { display: flex; gap: 24px; margin-bottom: 8px; font-size: 11px; }
table { width: 100%; border-collapse: collapse; margin-bottom: 4px; }
th, td { border: 1px solid #999; padding: 4px 5px; }
th { background: #f0f0f0; font-weight: 600; text-align: center; font-size: 11px; }
td { font-size: 10.5px; }
.c { text-align: center; }
.r { text-align: right; }
.subtotal { font-weight: bold; background: #f7f7f7; }
.remark { margin: 6px 0; font-size: 11px; color: #333; }
.sigs { display: flex; justify-content: space-between; margin-top: 28px; }
.sig { text-align: center; min-width: 120px; }
.sig-line { border-top: 1px solid #555; padding-top: 4px; margin-top: 28px; font-size: 11px; }
.footer-note { text-align: right; font-size: 9px; color: #aaa; margin-top: 6px; }
</style>
</head> </head>
<body> <body>
<div class="hdr"><div class="title">入 库 单</div></div> <div class="shop">${meta.shopName}</div>
<div class="meta"> <div class="title">入 库 单</div>
<div class="left">供应商:${order.partnerName ?? ''}</div> <div class="head-block">
<div class="center">入库日期:${order.orderDate ?? ''}</div> <div class="meta-row">
<div class="right">NO: ${order.orderNo}</div> <div class="c1">单位名称:${order.partnerName ?? ''}</div>
</div> <div class="c2">入库日期:${_d10(order.orderDate)}</div>
<div class="meta2"> <div class="c3 no">NO${order.orderNo}</div>
<span>仓库:${order.warehouseName ?? ''}</span> </div>
<span>采购人:${order.operatorName ?? ''}</span> <div class="meta-row">
<div class="c1">联系电话:</div>
<div class="c2">单位地址:</div>
<div class="c3">仓库:${order.warehouseName ?? ''}</div>
</div>
</div> </div>
<table> <table>
<thead><tr> $_orderTableHead
<th style="width:3%">序号</th>
<th style="width:9%">商品编号</th>
<th style="width:18%">商品名称</th>
<th style="width:10%">系列</th>
<th style="width:9%">规格</th>
<th style="width:5%">数量</th>
<th style="width:5%">单位</th>
<th style="width:8%">单价</th>
<th style="width:8%">金额</th>
<th style="width:10%">生产日期</th>
<th style="width:10%">批次号</th>
</tr></thead>
<tbody>$rows</tbody> <tbody>$rows</tbody>
<tfoot><tr class="subtotal">
<td colspan="5" class="r">单据总计</td>
<td class="r">${totalQty % 1 == 0 ? totalQty.toStringAsFixed(0) : totalQty.toStringAsFixed(3)}</td>
<td></td><td></td>
<td class="r">${totalAmt.toStringAsFixed(2)}</td>
<td colspan="2"></td>
</tr></tfoot>
</table> </table>
$remarkLine <div class="subtotal-bar">
<div class="sigs"> <span class="lbl-total">单据总计</span>
<div class="sig"><div class="sig-line">出货人(签字)</div></div> <span>数量 <b>${totalQty % 1 == 0 ? totalQty.toStringAsFixed(0) : totalQty.toStringAsFixed(3)}</b></span>
<div class="sig"><div class="sig-line">采购员:${order.operatorName ?? ''}</div></div> <span>金额 <b>${totalAmt.toStringAsFixed(2)}</b></span>
<div class="sig"><div class="sig-line">制单人:${order.reviewerName ?? order.operatorName ?? ''}</div></div>
</div> </div>
<div class="footer-note">酒库管理系统</div> <div class="sigs">
<span>出货人(签字):</span>
<span>采购员:${order.operatorName ?? ''}</span>
<span>制单人:${meta.makerName}</span>
</div>
$remarkLine
<script>window.onload = function(){ window.print(); };</script> <script>window.onload = function(){ window.print(); };</script>
</body> </body>
</html>'''; </html>''';
@@ -291,24 +315,30 @@ Future<String?> detectDefaultPrinterImpl() async => null;
// ── 出库单 ────────────────────────────────────────────────────────────────── // ── 出库单 ──────────────────────────────────────────────────────────────────
Future<void> printStockOutOrderImpl(StockOutOrder order) async { Future<void> printStockOutOrderImpl(
StockOutOrder order, OrderPrintMeta meta) async {
final rows = StringBuffer(); final rows = StringBuffer();
double totalQty = 0; double totalQty = 0;
double totalAmt = 0; double totalAmt = 0;
for (int i = 0; i < order.items.length; i++) { for (final item in order.items) {
final item = order.items[i]; // 出库单「单价」= 售价:App 单据存 sale_price;历史导入单 sale_price=0、
// 实际售价存在 unit_price/total_price,故 sale_price 为空时回退到 unit_price。
final unit = item.salePrice > 0 ? item.salePrice : item.unitPrice;
final amt =
item.salePrice > 0 ? item.salePrice * item.quantity : item.totalPrice;
totalQty += item.quantity; totalQty += item.quantity;
totalAmt += item.totalPrice; totalAmt += amt;
rows.write('''<tr> rows.write('''<tr>
<td class="c">${i + 1}</td>
<td>${item.productCode ?? ''}</td> <td>${item.productCode ?? ''}</td>
<td>${item.productName ?? ''}</td> <td>${item.productName ?? ''}</td>
<td class="c">${item.productSeries ?? ''}</td> <td class="c">${item.productSeries ?? ''}</td>
<td class="c">${item.productSpec ?? ''}</td> <td class="c">${item.productSpec ?? ''}</td>
<td class="c">${item.productUnit ?? ''}</td> <td class="c">${item.batchNo ?? ''}</td>
<td class="c">${_d10(item.productionDate)}</td>
<td class="r">${item.quantity % 1 == 0 ? item.quantity.toStringAsFixed(0) : item.quantity.toStringAsFixed(3)}</td> <td class="r">${item.quantity % 1 == 0 ? item.quantity.toStringAsFixed(0) : item.quantity.toStringAsFixed(3)}</td>
<td class="r">${item.unitPrice.toStringAsFixed(2)}</td> <td class="r">${unit.toStringAsFixed(2)}</td>
<td class="r">${item.totalPrice.toStringAsFixed(2)}</td> <td class="r">${amt.toStringAsFixed(2)}</td>
<td></td>
</tr>'''); </tr>''');
} }
@@ -320,71 +350,39 @@ Future<void> printStockOutOrderImpl(StockOutOrder order) async {
<html> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<style> <style>$_orderPrintCss</style>
@page { size: A4; margin: 12mm 15mm; }
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: "PingFang SC", "Microsoft YaHei", "SimSun", sans-serif; font-size: 11px; color: #000; }
.hdr { text-align: center; margin-bottom: 10px; }
.hdr .title { font-size: 22px; font-weight: bold; margin-bottom: 4px; }
.meta { display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 6px; border-bottom: 1px solid #666; padding-bottom: 5px; }
.meta .left, .meta .center, .meta .right { flex: 1; }
.meta .center { text-align: center; }
.meta .right { text-align: right; font-weight: bold; font-size: 12px; }
.meta2 { display: flex; gap: 24px; margin-bottom: 8px; font-size: 11px; }
table { width: 100%; border-collapse: collapse; margin-bottom: 4px; }
th, td { border: 1px solid #999; padding: 4px 5px; }
th { background: #f0f0f0; font-weight: 600; text-align: center; font-size: 11px; }
td { font-size: 10.5px; }
.c { text-align: center; }
.r { text-align: right; }
.subtotal { font-weight: bold; background: #f7f7f7; }
.remark { margin: 6px 0; font-size: 11px; color: #333; }
.tips { border: 1px solid #ddd; background: #fafafa; padding: 6px 8px; margin: 8px 0; font-size: 10px; color: #555; line-height: 1.6; }
.sigs { display: flex; justify-content: space-between; margin-top: 28px; }
.sig { text-align: center; min-width: 120px; }
.sig-line { border-top: 1px solid #555; padding-top: 4px; margin-top: 28px; font-size: 11px; }
.footer-note { text-align: right; font-size: 9px; color: #aaa; margin-top: 6px; }
</style>
</head> </head>
<body> <body>
<div class="hdr"><div class="title">出 库 单</div></div> <div class="shop">${meta.shopName}</div>
<div class="meta"> <div class="title">出 库 单</div>
<div class="left">客户:${order.partnerName ?? ''}</div> <div class="head-block">
<div class="center">出库日期:${order.orderDate ?? ''}</div> <div class="meta-row">
<div class="right">NO.${order.orderNo}</div> <div class="c1">客户名称:${order.partnerName ?? ''}</div>
</div> <div class="c2">出库日期:${_d10(order.orderDate)}</div>
<div class="meta2"> <div class="c3 no">NO.${order.orderNo}</div>
<span>仓库:${order.warehouseName ?? ''}</span> </div>
<span>经办人:${order.operatorName ?? ''}</span> <div class="meta-row">
<div class="c1">联系电话:</div>
<div class="c2">送货地址:</div>
<div class="c3">仓库:${order.warehouseName ?? ''}</div>
</div>
</div> </div>
<table> <table>
<thead><tr> $_orderTableHead
<th style="width:3%">序号</th>
<th style="width:9%">商品编号</th>
<th style="width:20%">商品名称</th>
<th style="width:11%">系列</th>
<th style="width:10%">规格</th>
<th style="width:5%">单位</th>
<th style="width:5%">数量</th>
<th style="width:9%">单价</th>
<th style="width:9%">金额</th>
</tr></thead>
<tbody>$rows</tbody> <tbody>$rows</tbody>
<tfoot><tr class="subtotal">
<td colspan="6" class="r">单据总计</td>
<td class="r">${totalQty % 1 == 0 ? totalQty.toStringAsFixed(0) : totalQty.toStringAsFixed(3)}</td>
<td></td>
<td class="r">${totalAmt.toStringAsFixed(2)}</td>
</tr></tfoot>
</table> </table>
$remarkLine <div class="subtotal-bar">
<div class="tips">温馨提示:签收时,请务必核对好酒品数量、年份和日期、批次及物流码,如有问题及时反馈,酒品无质量问题一经售出概不退换,谢谢合作。</div> <span class="lbl-total">单据总计</span>
<div class="sigs"> <span>数量 <b>${totalQty % 1 == 0 ? totalQty.toStringAsFixed(0) : totalQty.toStringAsFixed(3)}</b></span>
<div class="sig"><div class="sig-line">收货人(签字)</div></div> <span>金额 <b>${totalAmt.toStringAsFixed(2)}</b></span>
<div class="sig"><div class="sig-line">销售员:${order.operatorName ?? ''}</div></div>
<div class="sig"><div class="sig-line">制单人:${order.reviewerName ?? order.operatorName ?? ''}</div></div>
</div> </div>
<div class="footer-note">酒库管理系统</div> $_orderTipsHtml
<div class="sigs">
<span>收货人(签字):</span>
<span>销售员:${order.operatorName ?? ''}</span>
<span>制单人:${meta.makerName}</span>
</div>
$remarkLine
<script>window.onload = function(){ window.print(); };</script> <script>window.onload = function(){ window.print(); };</script>
</body> </body>
</html>'''; </html>''';
+3 -2
View File
@@ -61,8 +61,9 @@ class StockOutItem {
productSeries: lineOrProduct('series', 'series'), productSeries: lineOrProduct('series', 'series'),
productSpec: lineOrProduct('spec', 'spec'), productSpec: lineOrProduct('spec', 'spec'),
productUnit: (json['product'] as Map<String, dynamic>?)?['unit'] as String?, productUnit: (json['product'] as Map<String, dynamic>?)?['unit'] as String?,
batchNo: json['batch_no'] as String?, // 批次号/生产日期:优先明细行快照,回退 product 关联(App 新建单据快照列常为空)
productionDate: json['production_date'] as String?, batchNo: lineOrProduct('batch_no', 'batch_no'),
productionDate: lineOrProduct('production_date', 'production_date'),
); );
} }
} }
+16 -3
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/api/api_client.dart'; import '../core/api/api_client.dart';
import '../core/auth/auth_state.dart'; import '../core/auth/auth_state.dart';
@@ -25,9 +27,11 @@ class StockInListNotifier extends AsyncNotifier<PageResult<StockInOrder>> {
String? _endDate; String? _endDate;
String _keyword = ''; String _keyword = '';
PageResult<StockInOrder>? _cache; PageResult<StockInOrder>? _cache;
Timer? _searchDebounce;
@override @override
Future<PageResult<StockInOrder>> build() async { Future<PageResult<StockInOrder>> build() async {
ref.onDispose(() => _searchDebounce?.cancel());
ref.watch(authStateProvider.select((s) => s.user?.shopId)); ref.watch(authStateProvider.select((s) => s.user?.shopId));
ref.watch(networkRecoveryCountProvider); ref.watch(networkRecoveryCountProvider);
try { try {
@@ -65,6 +69,8 @@ class StockInListNotifier extends AsyncNotifier<PageResult<StockInOrder>> {
/// 当前服务端状态过滤值(供页面进入时对齐标签/下拉,避免重复拉取) /// 当前服务端状态过滤值(供页面进入时对齐标签/下拉,避免重复拉取)
String get currentStatus => _status; String get currentStatus => _status;
String get currentKeyword => _keyword;
void setStatus(String status) { void setStatus(String status) {
_status = status; _status = status;
_page = 1; _page = 1;
@@ -78,10 +84,17 @@ class StockInListNotifier extends AsyncNotifier<PageResult<StockInOrder>> {
reload(); reload();
} }
/// 关键词搜索(含酒名模糊反查,单次约 55ms 的明细扫描)。
/// 去重 + 防抖限流:同词不重查;连发回车/狂点合并取最后一次,避免昂贵查询被刷。
void setKeyword(String keyword) { void setKeyword(String keyword) {
_keyword = keyword; final kw = keyword.trim();
_page = 1; if (kw == _keyword) return;
reload(); _searchDebounce?.cancel();
_searchDebounce = Timer(const Duration(milliseconds: 350), () {
_keyword = kw;
_page = 1;
reload();
});
} }
void reload() { void reload() {
+16 -3
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/api/api_client.dart'; import '../core/api/api_client.dart';
import '../core/auth/auth_state.dart'; import '../core/auth/auth_state.dart';
@@ -26,9 +28,11 @@ class StockOutListNotifier extends AsyncNotifier<PageResult<StockOutOrder>> {
String _keyword = ''; String _keyword = '';
String _productCode = ''; String _productCode = '';
PageResult<StockOutOrder>? _cache; PageResult<StockOutOrder>? _cache;
Timer? _searchDebounce;
@override @override
Future<PageResult<StockOutOrder>> build() async { Future<PageResult<StockOutOrder>> build() async {
ref.onDispose(() => _searchDebounce?.cancel());
ref.watch(authStateProvider.select((s) => s.user?.shopId)); ref.watch(authStateProvider.select((s) => s.user?.shopId));
ref.watch(networkRecoveryCountProvider); ref.watch(networkRecoveryCountProvider);
try { try {
@@ -67,6 +71,8 @@ class StockOutListNotifier extends AsyncNotifier<PageResult<StockOutOrder>> {
/// 当前服务端状态过滤值(供页面进入时对齐标签/下拉,避免重复拉取) /// 当前服务端状态过滤值(供页面进入时对齐标签/下拉,避免重复拉取)
String get currentStatus => _status; String get currentStatus => _status;
String get currentKeyword => _keyword;
void setStatus(String status) { void setStatus(String status) {
_status = status; _status = status;
_page = 1; _page = 1;
@@ -80,10 +86,17 @@ class StockOutListNotifier extends AsyncNotifier<PageResult<StockOutOrder>> {
reload(); reload();
} }
/// 关键词搜索(含酒名模糊反查,单次约 55ms 的明细扫描)。
/// 去重 + 防抖限流:同词不重查;连发回车/狂点合并取最后一次,避免昂贵查询被刷。
void setKeyword(String keyword) { void setKeyword(String keyword) {
_keyword = keyword; final kw = keyword.trim();
_page = 1; if (kw == _keyword) return;
reload(); _searchDebounce?.cancel();
_searchDebounce = Timer(const Duration(milliseconds: 350), () {
_keyword = kw;
_page = 1;
reload();
});
} }
/// 按商品编码精确反查(独立于 keyword) /// 按商品编码精确反查(独立于 keyword)
@@ -5,8 +5,8 @@ import '../../core/responsive/responsive.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../core/auth/auth_state.dart'; import '../../core/auth/auth_state.dart';
import '../../core/utils/print_util.dart';
import '../../core/utils/date_util.dart'; import '../../core/utils/date_util.dart';
import '../../widgets/order_print_preview_dialog.dart';
import '../../models/stock_in.dart'; import '../../models/stock_in.dart';
import '../../core/config/app_constants.dart'; import '../../core/config/app_constants.dart';
import '../../providers/partner_provider.dart'; import '../../providers/partner_provider.dart';
@@ -227,7 +227,7 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
Future<void> _printOrder() async { Future<void> _printOrder() async {
if (_loadedOrder == null) return; if (_loadedOrder == null) return;
await safePrint(context, () => printStockInOrder(_loadedOrder!)); await showStockInOrderPrint(context, ref, _loadedOrder!);
} }
Future<void> _submit(bool asDraft) async { Future<void> _submit(bool asDraft) async {
@@ -868,11 +868,8 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}')) FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))
], ],
onChanged: (_) => setState(() {}), onChanged: (_) => setState(() {}),
validator: (v) { // 单价允许为空或 0:表示「待定价」,入库审核后可在单据详情「确认进价」补填真实进价
if (v == null || v.isEmpty) return '不能为空'; validator: (_) => null,
if ((double.tryParse(v) ?? 0) <= 0) return '>0';
return null;
},
); );
} }
@@ -976,15 +973,6 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
); );
} }
String _productCodeOf(_ItemRow item) =>
ref
.read(productNameListProvider)
.valueOrNull
?.where((o) => o.id == item.selectedNameId)
.firstOrNull
?.code ??
'';
// ── 桌面表格:表头行 ────────────────────────────────────────────────────── // ── 桌面表格:表头行 ──────────────────────────────────────────────────────
Widget _buildItemTableHeader() { Widget _buildItemTableHeader() {
Widget th(String s) => Padding( Widget th(String s) => Padding(
@@ -1019,7 +1007,6 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
final qty = double.tryParse(item.qtyCtrl.text) ?? 0; final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
final price = double.tryParse(item.priceCtrl.text) ?? 0; final price = double.tryParse(item.priceCtrl.text) ?? 0;
final amount = qty * price; final amount = qty * price;
final productCode = _productCodeOf(item);
Widget tc(String s, {Color? color, FontWeight? weight}) => Padding( Widget tc(String s, {Color? color, FontWeight? weight}) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
@@ -1166,7 +1153,6 @@ class _StockInFormScreenState extends ConsumerState<StockInFormScreen> {
final qty = double.tryParse(item.qtyCtrl.text) ?? 0; final qty = double.tryParse(item.qtyCtrl.text) ?? 0;
final price = double.tryParse(item.priceCtrl.text) ?? 0; final price = double.tryParse(item.priceCtrl.text) ?? 0;
final amount = qty * price; final amount = qty * price;
final productCode = _productCodeOf(item);
return MobileListCard( return MobileListCard(
title: Text('商品 ${index + 1}'), title: Text('商品 ${index + 1}'),
@@ -1,8 +1,9 @@
import '../../core/utils/dialog_util.dart'; import '../../core/utils/dialog_util.dart';
import '../../core/errors/error_reporter.dart'; import '../../core/errors/error_reporter.dart';
import '../../core/exceptions.dart'; import '../../core/exceptions.dart';
import '../../core/utils/print_util.dart' show safePrint, printStockInOrder, LabelData; import '../../core/utils/print_util.dart' show LabelData;
import '../../widgets/label_preview_dialog.dart'; import '../../widgets/label_preview_dialog.dart';
import '../../widgets/order_print_preview_dialog.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -17,6 +18,7 @@ import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButto
import '../../core/storage/column_prefs.dart'; import '../../core/storage/column_prefs.dart';
import '../../widgets/page_scaffold.dart'; import '../../widgets/page_scaffold.dart';
import '../../widgets/status_badge.dart'; import '../../widgets/status_badge.dart';
import '../../widgets/search_chip.dart';
import '../../core/utils/export_util.dart'; import '../../core/utils/export_util.dart';
import '../../providers/inventory_provider.dart'; import '../../providers/inventory_provider.dart';
import '../../providers/tab_state_provider.dart'; import '../../providers/tab_state_provider.dart';
@@ -44,6 +46,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
Set<String> _filterSupplier = {}; Set<String> _filterSupplier = {};
Set<String>? _hiddenCols; // null = 尚未载入本地存档(回退到 minWidth 首次默认) Set<String>? _hiddenCols; // null = 尚未载入本地存档(回退到 minWidth 首次默认)
final _searchCtrl = TextEditingController(); final _searchCtrl = TextEditingController();
String _appliedKw = ''; // 已生效的搜索词(框内 chip 标签展示)
static const _screenId = 'stock_in_list'; static const _screenId = 'stock_in_list';
@@ -72,6 +75,10 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
final want = tab == 1 ? 'pending,draft' : _statusFilter; final want = tab == 1 ? 'pending,draft' : _statusFilter;
final notifier = ref.read(stockInListProvider.notifier); final notifier = ref.read(stockInListProvider.notifier);
if (notifier.currentStatus != want) notifier.setStatus(want); if (notifier.currentStatus != want) notifier.setStatus(want);
// 同步已生效的搜索词到框内 chip(provider 持久化,切走再回来不丢)
if (notifier.currentKeyword != _appliedKw) {
setState(() => _appliedKw = notifier.currentKeyword);
}
}); });
} }
@@ -81,8 +88,19 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
super.dispose(); super.dispose();
} }
void _triggerSearch() => void _triggerSearch() {
ref.read(stockInListProvider.notifier).setKeyword(_searchCtrl.text.trim()); final kw = _searchCtrl.text.trim();
ref.read(stockInListProvider.notifier).setKeyword(kw);
// 仅支持单个搜索词:生效后输入框清空,搜索词转为框内 chip 标签
setState(() => _appliedKw = kw);
if (kw.isNotEmpty) _searchCtrl.clear();
}
void _clearSearch() {
_searchCtrl.clear();
setState(() => _appliedKw = '');
ref.read(stockInListProvider.notifier).setKeyword('');
}
String? get _startDate => _dateRange != null String? get _startDate => _dateRange != null
? '${_dateRange!.start.year}-${_dateRange!.start.month.toString().padLeft(2, '0')}-${_dateRange!.start.day.toString().padLeft(2, '0')}' ? '${_dateRange!.start.year}-${_dateRange!.start.month.toString().padLeft(2, '0')}-${_dateRange!.start.day.toString().padLeft(2, '0')}'
@@ -363,12 +381,29 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
) )
: null; : null;
final hasKw = _appliedKw.isNotEmpty;
final searchField = TextField( final searchField = TextField(
controller: _searchCtrl, controller: _searchCtrl,
decoration: InputDecoration( decoration: InputDecoration(
hintText: '单号/往来单位,回车搜索', hintText: hasKw ? '继续输入新关键词…' : '单号/往来单位/酒名,回车搜索',
prefixIcon: const Icon(Icons.search, size: 16),
hintStyle: const TextStyle(fontSize: 12), hintStyle: const TextStyle(fontSize: 12),
// 搜索图标 +(已生效时)框内 chip 标签,仅一个词;✕ 删除即清除搜索。
// 用 prefixIcon 而非 prefix:后者仅在聚焦/有文字时才显示,搜索后输入框为空会被隐藏。
prefixIconConstraints:
const BoxConstraints(minWidth: 0, minHeight: 0),
prefixIcon: Padding(
padding: const EdgeInsets.only(left: 8, right: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.search, size: 16),
if (hasKw) ...[
const SizedBox(width: 6),
SearchChip(label: _appliedKw, onDeleted: _clearSearch),
],
],
),
),
suffixIcon: IconButton( suffixIcon: IconButton(
icon: const Icon(Icons.search, size: 16), icon: const Icon(Icons.search, size: 16),
tooltip: '搜索', tooltip: '搜索',
@@ -495,7 +530,7 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
onPrint: () async { onPrint: () async {
final order = await ref.read(stockInRepositoryProvider).get(o.id); final order = await ref.read(stockInRepositoryProvider).get(o.id);
if (context.mounted) { if (context.mounted) {
await safePrint(context, () => printStockInOrder(order)); await showStockInOrderPrint(context, ref, order);
} }
}, },
afterPrint: [ afterPrint: [
@@ -573,6 +608,8 @@ class _StockInListScreenState extends ConsumerState<StockInListScreen> {
Future<void> _showDetail(BuildContext context, int orderId) async { Future<void> _showDetail(BuildContext context, int orderId) async {
showAppDialog( showAppDialog(
context: context, context: context,
// 用分支 Navigator 承载,全屏弹窗只覆盖内容区,不盖住左侧栏/顶栏/状态栏
useRootNavigator: false,
builder: (ctx) => _StockInDetailDialog( builder: (ctx) => _StockInDetailDialog(
orderId: orderId, orderId: orderId,
repository: ref.read(stockInRepositoryProvider), repository: ref.read(stockInRepositoryProvider),
@@ -1010,40 +1047,36 @@ class _StockInDetailDialogState extends ConsumerState<_StockInDetailDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Dialog( return Dialog.fullscreen(
child: Container( child: SafeArea(
width: context.dialogWidth(720),
constraints: const BoxConstraints(maxHeight: 600),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: const BoxDecoration( decoration: const BoxDecoration(
color: AppTheme.primary, color: AppTheme.brand50,
borderRadius: BorderRadius.only( border: Border(bottom: BorderSide(color: AppTheme.border)),
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
), ),
child: Row( child: Row(
children: [ children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: AppTheme.primaryDark),
tooltip: '返回',
onPressed: () => Navigator.of(context).pop(),
),
const Text('入库单详情', const Text('入库单详情',
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Colors.white)), color: AppTheme.primaryDark)),
const Spacer(), const Spacer(),
if (_loadedOrder != null) if (_loadedOrder != null)
IconButton( IconButton(
icon: const Icon(Icons.print_outlined, color: Colors.white), icon: const Icon(Icons.print_outlined, color: AppTheme.primaryDark),
tooltip: '打印', tooltip: '打印',
onPressed: () => safePrint(context, () => printStockInOrder(_loadedOrder!)), onPressed: () => showStockInOrderPrint(context, ref, _loadedOrder!),
), ),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
], ],
), ),
), ),
@@ -7,8 +7,8 @@ import 'package:go_router/go_router.dart';
import '../../core/responsive/responsive.dart'; import '../../core/responsive/responsive.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../core/auth/auth_state.dart'; import '../../core/auth/auth_state.dart';
import '../../core/utils/print_util.dart';
import '../../core/utils/date_util.dart'; import '../../core/utils/date_util.dart';
import '../../widgets/order_print_preview_dialog.dart';
import '../../models/inventory.dart'; import '../../models/inventory.dart';
import '../../models/stock_out.dart'; import '../../models/stock_out.dart';
import '../../widgets/date_picker_field.dart'; import '../../widgets/date_picker_field.dart';
@@ -258,7 +258,7 @@ class _StockOutFormScreenState extends ConsumerState<StockOutFormScreen> {
Future<void> _printOrder() async { Future<void> _printOrder() async {
if (_loadedOrder == null) return; if (_loadedOrder == null) return;
await safePrint(context, () => printStockOutOrder(_loadedOrder!)); await showStockOutOrderPrint(context, ref, _loadedOrder!);
} }
Future<void> _submit(bool asDraft) async { Future<void> _submit(bool asDraft) async {
@@ -1,6 +1,6 @@
import '../../core/utils/dialog_util.dart'; import '../../core/utils/dialog_util.dart';
import '../../core/errors/error_reporter.dart'; import '../../core/errors/error_reporter.dart';
import '../../core/utils/print_util.dart' show safePrint, printStockOutOrder; import '../../widgets/order_print_preview_dialog.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -15,8 +15,8 @@ import '../../widgets/multi_select_dropdown.dart' show ColDef, ColumnToggleButto
import '../../core/storage/column_prefs.dart'; import '../../core/storage/column_prefs.dart';
import '../../widgets/page_scaffold.dart'; import '../../widgets/page_scaffold.dart';
import '../../widgets/status_badge.dart'; import '../../widgets/status_badge.dart';
import '../../widgets/search_chip.dart';
import '../../core/utils/export_util.dart'; import '../../core/utils/export_util.dart';
import '../../core/utils/print_util.dart';
import '../../providers/inventory_provider.dart'; import '../../providers/inventory_provider.dart';
import '../../providers/tab_state_provider.dart'; import '../../providers/tab_state_provider.dart';
import '../../providers/product_provider.dart'; import '../../providers/product_provider.dart';
@@ -43,6 +43,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
Set<String>? _hiddenCols; // null = 尚未载入本地存档(回退到 minWidth 首次默认) Set<String>? _hiddenCols; // null = 尚未载入本地存档(回退到 minWidth 首次默认)
final _searchCtrl = TextEditingController(); final _searchCtrl = TextEditingController();
final _codeSearchCtrl = TextEditingController(); final _codeSearchCtrl = TextEditingController();
String _appliedKw = ''; // 已生效的搜索词(框内 chip 标签展示)
static const _screenId = 'stock_out_list'; static const _screenId = 'stock_out_list';
@@ -59,6 +60,10 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
final want = tab == 1 ? 'pending,draft' : _statusFilter; final want = tab == 1 ? 'pending,draft' : _statusFilter;
final notifier = ref.read(stockOutListProvider.notifier); final notifier = ref.read(stockOutListProvider.notifier);
if (notifier.currentStatus != want) notifier.setStatus(want); if (notifier.currentStatus != want) notifier.setStatus(want);
// 同步已生效的搜索词到框内 chip(provider 持久化,切走再回来不丢)
if (notifier.currentKeyword != _appliedKw) {
setState(() => _appliedKw = notifier.currentKeyword);
}
}); });
} }
@@ -82,9 +87,19 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
super.dispose(); super.dispose();
} }
void _triggerSearch() => ref void _triggerSearch() {
.read(stockOutListProvider.notifier) final kw = _searchCtrl.text.trim();
.setKeyword(_searchCtrl.text.trim()); ref.read(stockOutListProvider.notifier).setKeyword(kw);
// 仅支持单个搜索词:生效后输入框清空,搜索词转为框内 chip 标签
setState(() => _appliedKw = kw);
if (kw.isNotEmpty) _searchCtrl.clear();
}
void _clearSearch() {
_searchCtrl.clear();
setState(() => _appliedKw = '');
ref.read(stockOutListProvider.notifier).setKeyword('');
}
void _triggerCodeSearch() => ref void _triggerCodeSearch() => ref
.read(stockOutListProvider.notifier) .read(stockOutListProvider.notifier)
@@ -375,12 +390,29 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
) )
: null; : null;
final hasKw = _appliedKw.isNotEmpty;
final searchField = TextField( final searchField = TextField(
controller: _searchCtrl, controller: _searchCtrl,
decoration: InputDecoration( decoration: InputDecoration(
hintText: '单号/往来单位,回车搜索', hintText: hasKw ? '继续输入新关键词…' : '单号/往来单位/酒名,回车搜索',
prefixIcon: const Icon(Icons.search, size: 16),
hintStyle: const TextStyle(fontSize: 12), hintStyle: const TextStyle(fontSize: 12),
// 搜索图标 +(已生效时)框内 chip 标签,仅一个词;✕ 删除即清除搜索。
// 用 prefixIcon 而非 prefix:后者仅在聚焦/有文字时才显示,搜索后输入框为空会被隐藏。
prefixIconConstraints:
const BoxConstraints(minWidth: 0, minHeight: 0),
prefixIcon: Padding(
padding: const EdgeInsets.only(left: 8, right: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.search, size: 16),
if (hasKw) ...[
const SizedBox(width: 6),
SearchChip(label: _appliedKw, onDeleted: _clearSearch),
],
],
),
),
suffixIcon: IconButton( suffixIcon: IconButton(
icon: const Icon(Icons.search, size: 16), icon: const Icon(Icons.search, size: 16),
tooltip: '搜索', tooltip: '搜索',
@@ -526,7 +558,7 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
onPrint: () async { onPrint: () async {
final order = await ref.read(stockOutRepositoryProvider).get(o.id); final order = await ref.read(stockOutRepositoryProvider).get(o.id);
if (context.mounted) { if (context.mounted) {
await safePrint(context, () => printStockOutOrder(order)); await showStockOutOrderPrint(context, ref, order);
} }
}, },
onSettle: () => _confirmSettle(context, o.id, 'stock_out'), onSettle: () => _confirmSettle(context, o.id, 'stock_out'),
@@ -574,6 +606,8 @@ class _StockOutListScreenState extends ConsumerState<StockOutListScreen> {
Future<void> _showDetail(BuildContext context, int orderId) async { Future<void> _showDetail(BuildContext context, int orderId) async {
showAppDialog( showAppDialog(
context: context, context: context,
// 用分支 Navigator 承载,全屏弹窗只覆盖内容区,不盖住左侧栏/顶栏/状态栏
useRootNavigator: false,
builder: (ctx) => _StockOutDetailDialog( builder: (ctx) => _StockOutDetailDialog(
orderId: orderId, orderId: orderId,
repository: ref.read(stockOutRepositoryProvider), repository: ref.read(stockOutRepositoryProvider),
@@ -924,40 +958,36 @@ class _StockOutDetailDialogState extends ConsumerState<_StockOutDetailDialog> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Dialog( return Dialog.fullscreen(
child: Container( child: SafeArea(
width: context.dialogWidth(720),
constraints: const BoxConstraints(maxHeight: 600),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 14), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
decoration: const BoxDecoration( decoration: const BoxDecoration(
color: AppTheme.primary, color: AppTheme.brand50,
borderRadius: BorderRadius.only( border: Border(bottom: BorderSide(color: AppTheme.border)),
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
), ),
child: Row( child: Row(
children: [ children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: AppTheme.primaryDark),
tooltip: '返回',
onPressed: () => Navigator.of(context).pop(),
),
const Text('出库单详情', const Text('出库单详情',
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Colors.white)), color: AppTheme.primaryDark)),
const Spacer(), const Spacer(),
if (_loadedOrder != null) if (_loadedOrder != null)
IconButton( IconButton(
icon: const Icon(Icons.print_outlined, color: Colors.white), icon: const Icon(Icons.print_outlined, color: AppTheme.primaryDark),
tooltip: '打印', tooltip: '打印',
onPressed: () => safePrint(context, () => printStockOutOrder(_loadedOrder!)), onPressed: () => showStockOutOrderPrint(context, ref, _loadedOrder!),
), ),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
], ],
), ),
), ),
@@ -1040,7 +1070,7 @@ class _StockOutDetailDialogState extends ConsumerState<_StockOutDetailDialog> {
children: [ children: [
TableRow( TableRow(
decoration: const BoxDecoration(color: Color(0xFFF0F4FF)), decoration: const BoxDecoration(color: Color(0xFFF0F4FF)),
children: ['序号', '商品编码', '名称', '系列', '规格', '生产日期', '批次号', '数量', '', '金额'] children: ['序号', '商品编码', '名称', '系列', '规格', '生产日期', '批次号', '成本价', '', '利润']
.map((h) => Padding( .map((h) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 10),
child: Text(h, child: Text(h,
@@ -1069,9 +1099,9 @@ class _StockOutDetailDialogState extends ConsumerState<_StockOutDetailDialog> {
: (item.productionDate ?? '-')), : (item.productionDate ?? '-')),
_TableCell( _TableCell(
(item.batchNo?.isNotEmpty ?? false) ? item.batchNo! : '-'), (item.batchNo?.isNotEmpty ?? false) ? item.batchNo! : '-'),
_TableCell(item.quantity.toStringAsFixed(3)),
_TableCell('¥${item.unitPrice.toStringAsFixed(2)}'), _TableCell('¥${item.unitPrice.toStringAsFixed(2)}'),
_TableCell('¥${item.totalPrice.toStringAsFixed(2)}'), _TableCell('¥${item.salePrice.toStringAsFixed(2)}'),
_TableCell('¥${(item.salePrice - item.unitPrice).toStringAsFixed(2)}'),
], ],
); );
}), }),
@@ -0,0 +1,282 @@
import 'dart:typed_data';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:printing/printing.dart';
import '../core/auth/auth_state.dart';
import '../core/errors/error_reporter.dart';
import '../core/theme/app_theme.dart';
import '../core/responsive/responsive.dart';
import '../core/utils/print_util.dart';
import '../models/shop.dart';
import '../models/stock_in.dart';
import '../models/stock_out.dart';
import '../providers/shop_provider.dart';
/// 单据打印预览弹窗:把待打印的 A4 PDF 光栅化成图片(所见即所得)逐页展示,
/// 页内点「打印」再调系统打印。解决部分 Windows 系统打印对话框无预览的问题。
///
/// 仅桌面端使用;Web 端走浏览器自带的打印预览(见 [showStockInOrderPrint])。
class OrderPrintPreviewDialog extends StatefulWidget {
/// 标题,如「入库单打印预览」。
final String title;
/// 系统打印任务名(也作另存默认文件名前缀)。
final String printName;
/// 惰性生成 PDF 字节。
final Future<Uint8List> Function() pdfBuilder;
const OrderPrintPreviewDialog({
super.key,
required this.title,
required this.printName,
required this.pdfBuilder,
});
@override
State<OrderPrintPreviewDialog> createState() =>
_OrderPrintPreviewDialogState();
}
class _OrderPrintPreviewDialogState extends State<OrderPrintPreviewDialog> {
Uint8List? _pdfBytes;
final List<Uint8List> _pages = [];
bool _rendering = true;
bool _printing = false;
String _error = '';
@override
void initState() {
super.initState();
_render();
}
Future<void> _render() async {
try {
final bytes = await widget.pdfBuilder();
if (!mounted) return;
setState(() => _pdfBytes = bytes);
// 逐页光栅化(progressive),DPI 取 110 兼顾清晰与速度。
await for (final raster in Printing.raster(bytes, dpi: 110)) {
final png = await raster.toPng();
if (!mounted) return;
setState(() => _pages.add(png));
}
} catch (e, st) {
reportError(e, st);
if (mounted) setState(() => _error = '预览生成失败:$e');
} finally {
if (mounted) setState(() => _rendering = false);
}
}
Future<void> _doPrint() async {
final bytes = _pdfBytes;
if (bytes == null || _printing) return;
setState(() => _printing = true);
try {
await Printing.layoutPdf(
name: '${widget.printName}_${_ts()}',
dynamicLayout: false,
onLayout: (_) async => bytes,
);
if (mounted) Navigator.of(context).pop();
} catch (e, st) {
reportError(e, st);
if (mounted) {
setState(() {
_printing = false;
_error = '打印失败,请检查打印机连接和驱动是否正常。\n详情:$e';
});
}
}
}
String _ts() {
final n = DateTime.now();
String p(int v) => v.toString().padLeft(2, '0');
return '${n.year}${p(n.month)}${p(n.day)}_${p(n.hour)}${p(n.minute)}${p(n.second)}';
}
@override
Widget build(BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
child: Container(
width: context.dialogWidth(720),
constraints: const BoxConstraints(maxHeight: 720),
child: Column(
children: [
// ── 顶栏 ──────────────────────────────────────────────────────────
Container(
padding:
const EdgeInsets.symmetric(horizontal: 20, vertical: 14),
decoration: const BoxDecoration(
color: AppTheme.primary,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
),
),
child: Row(
children: [
Text(widget.title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.white)),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed:
_printing ? null : () => Navigator.of(context).pop(),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
),
// ── 预览区 ─────────────────────────────────────────────────────────
Expanded(
child: Container(
width: double.infinity,
color: const Color(0xFFE9ECF1),
child: _buildBody(),
),
),
// ── 底栏 ──────────────────────────────────────────────────────────
Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 14),
child: Row(
children: [
Expanded(
child: Text(
_error.isNotEmpty
? _error
: (_rendering
? '正在生成预览…'
: '${_pages.length}'),
style: TextStyle(
fontSize: 12,
color: _error.isNotEmpty
? Colors.red
: AppTheme.textSecondary),
),
),
TextButton(
onPressed:
_printing ? null : () => Navigator.of(context).pop(),
child: const Text('取消'),
),
const SizedBox(width: 8),
FilledButton.icon(
icon: const Icon(Icons.print, size: 18),
onPressed: (_pdfBytes == null || _printing)
? null
: _doPrint,
label: Text(_printing ? '打印中…' : '打印'),
),
],
),
),
],
),
),
);
}
Widget _buildBody() {
if (_pages.isEmpty) {
if (_error.isNotEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(_error,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.red, fontSize: 13)),
),
);
}
return const Center(child: CircularProgressIndicator());
}
return ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
itemCount: _pages.length,
separatorBuilder: (_, __) => const SizedBox(height: 16),
itemBuilder: (_, i) => Center(
child: Container(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.18),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Image.memory(_pages[i], fit: BoxFit.contain),
),
),
);
}
}
/// 组装单据打印抬头信息:公司名称(门店)+ 制单人(当前登录、点击打印的人)。
Future<OrderPrintMeta> _buildPrintMeta(WidgetRef ref) async {
ShopInfo? shop;
try {
shop = await ref.read(shopInfoProvider.future);
} catch (_) {
// 门店信息拉取失败(离线等)时抬头留空,不阻塞打印。
}
return OrderPrintMeta(
shopName: shop?.name ?? '',
shopAddress: shop?.address ?? '',
shopPhone: shop?.phone ?? '',
makerName: ref.read(authStateProvider).user?.realName ?? '',
);
}
/// 打印入库单:桌面端先弹应用内预览,Web 端走浏览器自带打印预览。
Future<void> showStockInOrderPrint(
BuildContext context, WidgetRef ref, StockInOrder order) async {
final meta = await _buildPrintMeta(ref);
if (!context.mounted) return;
if (kIsWeb) {
await safePrint(context, () => printStockInOrder(order, meta));
return;
}
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => OrderPrintPreviewDialog(
title: '入库单打印预览',
printName: '入库单',
pdfBuilder: () => buildStockInOrderPdf(order, meta),
),
);
}
/// 打印出库单:桌面端先弹应用内预览,Web 端走浏览器自带打印预览。
Future<void> showStockOutOrderPrint(
BuildContext context, WidgetRef ref, StockOutOrder order) async {
final meta = await _buildPrintMeta(ref);
if (!context.mounted) return;
if (kIsWeb) {
await safePrint(context, () => printStockOutOrder(order, meta));
return;
}
await showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => OrderPrintPreviewDialog(
title: '出库单打印预览',
printName: '出库单',
pdfBuilder: () => buildStockOutOrderPdf(order, meta),
),
);
}
+52
View File
@@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
/// 搜索框内嵌的「已生效搜索词」标签:紧凑圆角 chip + ✕ 删除按钮。
///
/// 用于列表屏搜索框 [InputDecoration.prefix],仅展示单个搜索词;
/// 点 ✕ 触发 [onDeleted] 清除搜索。
class SearchChip extends StatelessWidget {
const SearchChip({super.key, required this.label, required this.onDeleted});
final String label;
final VoidCallback onDeleted;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bg = theme.colorScheme.primary.withValues(alpha: 0.12);
final fg = theme.colorScheme.primary;
return Container(
padding: const EdgeInsets.only(left: 8, right: 2, top: 2, bottom: 2),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 120),
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
color: fg,
fontWeight: FontWeight.w500,
),
),
),
InkResponse(
onTap: onDeleted,
radius: 14,
child: Padding(
padding: const EdgeInsets.all(2),
child: Icon(Icons.close, size: 14, color: fg),
),
),
],
),
);
}
}
@@ -0,0 +1,82 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jiu_client/core/api/api_client.dart';
import 'package:jiu_client/core/models/page_result.dart';
import 'package:jiu_client/models/stock_in.dart';
import 'package:jiu_client/providers/stock_in_provider.dart';
import 'package:jiu_client/repositories/stock_in_repository.dart';
/// 统计 list() 调用次数与最后一次关键词的假仓库,用于验证搜索限流。
class _CountingStockInRepo extends StockInRepository {
int listCalls = 0;
String? lastKeyword;
_CountingStockInRepo() : super(ApiClient(token: 't'));
@override
Future<PageResult<StockInOrder>> list({
String? status,
String? startDate,
String? endDate,
String? keyword,
int page = 1,
int pageSize = 20,
}) async {
listCalls++;
lastKeyword = keyword;
return const PageResult<StockInOrder>(
data: [], total: 0, page: 1, pageSize: 20);
}
}
void main() {
test('setKeyword 防抖:350ms 内连发只触发最后一次', () async {
final repo = _CountingStockInRepo();
final container = ProviderContainer(
overrides: [stockInRepositoryProvider.overrideWithValue(repo)],
);
addTearDown(container.dispose);
// 初次 build 自动拉一次列表
await container.read(stockInListProvider.future);
expect(repo.listCalls, 1);
final notifier = container.read(stockInListProvider.notifier);
// 350ms 内连发三个不同关键词
notifier.setKeyword('');
notifier.setKeyword('茅台');
notifier.setKeyword('飞天茅台');
// 防抖窗口未到 → 不应有新请求
await Future<void>.delayed(const Duration(milliseconds: 120));
expect(repo.listCalls, 1);
// 过了防抖窗口 → 合并成 1 次,关键词取最后一个
await Future<void>.delayed(const Duration(milliseconds: 350));
expect(repo.listCalls, 2);
expect(repo.lastKeyword, '飞天茅台');
});
test('setKeyword 去重:相同关键词不重复查询', () async {
final repo = _CountingStockInRepo();
final container = ProviderContainer(
overrides: [stockInRepositoryProvider.overrideWithValue(repo)],
);
addTearDown(container.dispose);
await container.read(stockInListProvider.future);
final notifier = container.read(stockInListProvider.notifier);
// 首次搜索「茅台」
notifier.setKeyword('茅台');
await Future<void>.delayed(const Duration(milliseconds: 400));
expect(repo.listCalls, 2);
expect(repo.lastKeyword, '茅台');
// 再次搜索完全相同的关键词 → 直接 return,不发请求
notifier.setKeyword('茅台');
await Future<void>.delayed(const Duration(milliseconds: 400));
expect(repo.listCalls, 2);
});
}
+103
View File
@@ -0,0 +1,103 @@
# 阿里云独立 nginx 版(宿主机直接终结 TLS,不经 pangolin)。
# 与 nginx-jiu.confEC2 pangolin 容器版,listen 127.0.0.1:8445)等价,
# 仅入口监听改为对公网 443 直连。CI deploy-server.sh 的 ali 分支落到
# /etc/nginx/conf.d/jiu.conf。
limit_req_zone $binary_remote_addr zone=jiu_pub:10m rate=10r/s;
limit_req_status 429;
server {
listen 443 ssl http2;
server_name jiu.51yanmei.com;
ssl_certificate /etc/letsencrypt/live/jiu.51yanmei.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/jiu.51yanmei.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
client_max_body_size 20m;
# 商品图片静态文件
location ^~ /images/ {
alias /opt/jiu/images/;
expires 30d;
add_header Cache-Control "public, immutable";
}
# 文件导入接口(超时更长)
location ~ ^/api/v1/import/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 300s;
}
# 未鉴权公开/登录接口:加最外层 per-IP 限流
location ~ ^/api/v1/(public|auth)/ {
limit_req zone=jiu_pub burst=20 nodelay;
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 30s;
}
# API 反向代理
location ~ ^/(api|health|version) {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 30s;
}
# Flutter 管理端(仅 /app/ 前缀)
location /app/ {
alias /opt/jiu/web/;
index index.html;
try_files $uri $uri/ /app/index.html;
}
location = /app/index.html {
alias /opt/jiu/web/index.html;
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
expires 0;
}
# 公开商品详情页(扫码跳转)→ 后端注入 OG 标签
location ~ ^/product/ {
limit_req zone=jiu_pub burst=20 nodelay;
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 30s;
}
# Flutter base-href=/app/ 会把 /product/:id 重写为 /app/product/:id
location ~ ^/app/product/ {
limit_req zone=jiu_pub burst=20 nodelay;
rewrite ^/app(/product/.+)$ $1 break;
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 30s;
}
# 桌面客户端安装包下载
location /downloads/ {
alias /opt/jiu/downloads/;
add_header Content-Disposition "attachment";
add_header Cache-Control "no-cache";
autoindex off;
}
# 营销站点兜底
location / {
root /opt/jiu/marketing;
try_files $uri $uri.html $uri/index.html /index.html;
}
}
server {
listen 80;
server_name jiu.51yanmei.com;
return 301 https://$host$request_uri;
}
+281
View File
@@ -0,0 +1,281 @@
<!DOCTYPE html><html lang="zh"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>出入库单排版方案 · 6 种黑白样式</title>
<style>
* { box-sizing:border-box; margin:0; padding:0; }
body { background:#e9ebee; font-family:"PingFang SC","Microsoft YaHei",sans-serif; color:#111; padding-bottom:60px; }
.page-head { position:sticky; top:0; z-index:10; background:#fff; border-bottom:1px solid #ccc; padding:14px 24px; display:flex; align-items:center; gap:24px; flex-wrap:wrap; }
.page-head h1 { font-size:17px; font-weight:700; }
.page-head nav a { color:#111; text-decoration:none; font-size:13px; margin-right:14px; border-bottom:1px dashed #999; padding-bottom:1px; }
.style-block { max-width:900px; margin:34px auto; }
.style-block > h2 { font-size:16px; margin:0 12px 4px; }
.style-block > .desc { font-size:12.5px; color:#555; margin:0 12px 16px; line-height:1.6; }
.sheet { background:#fff; box-shadow:0 2px 12px rgba(0,0,0,.16); padding:46px 40px; margin-bottom:26px; }
.doc table { width:100%; border-collapse:collapse; }
.doc th { text-align:center; white-space:nowrap; }
.doc td.num { text-align:right; }
.doc td.ctr { text-align:center; }
.doc .meta-row { display:flex; }
.doc .total { display:flex; }
.doc .sign { display:flex; justify-content:space-between; margin-top:40px; font-size:13px; }
.doc .tips { font-size:11px; color:#555; line-height:1.6; border:1px solid #ddd; background:#fafafa; padding:7px 10px; margin-top:12px; }
/* 一 极简线框 */
.s1 .doc { color:#1a1a1a; }
.s1 .hd { display:flex; align-items:baseline; gap:14px; border-bottom:2px solid #111; padding-bottom:12px; margin-bottom:16px; }
.s1 .co { font-size:23px; font-weight:700; letter-spacing:1px; }
.s1 .ty { font-size:14px; color:#666; letter-spacing:6px; }
.s1 .meta { margin-bottom:16px; }
.s1 .meta-row { justify-content:flex-start; gap:52px; font-size:12.5px; color:#333; line-height:2.1; }
.s1 .m-no { font-weight:700; color:#111; }
.s1 table { border:none; }
.s1 th { border:none; border-bottom:2px solid #111; padding:8px 6px; font-size:12px; font-weight:700; letter-spacing:.5px; }
.s1 td { border:none; border-bottom:1px solid #e2e2e2; padding:9px 6px; font-size:12px; }
.s1 .total { border-top:2px solid #111; margin-top:-1px; justify-content:flex-end; gap:44px; padding:12px 4px; font-size:13px; }
.s1 .t-label { margin-right:auto; color:#666; font-weight:700; }
.s1 .total b { font-size:15px; font-weight:700; }
.s1 .sign { margin-top:48px; }
.s1 .tips { border:none; border-left:3px solid #111; background:none; padding:2px 0 2px 10px; }
/* 二 现代分区 */
.s2 .hd { text-align:center; margin-bottom:14px; }
.s2 .co { font-size:24px; font-weight:800; }
.s2 .ty { display:inline-block; margin-top:8px; font-size:14px; font-weight:700; letter-spacing:6px; border:1.5px solid #111; border-radius:2px; padding:3px 18px; }
.s2 .meta { display:grid; grid-template-columns:repeat(3,1fr); border:1px solid #bbb; margin-bottom:14px; }
.s2 .meta-row { display:contents; }
.s2 .m { border:.5px solid #ccc; padding:8px 10px; font-size:12.5px; }
.s2 table { border:1px solid #ccc; }
.s2 th { background:#ececec; border:1px solid #ccc; padding:8px 6px; font-size:12px; font-weight:700; }
.s2 td { border:1px solid #ddd; padding:7px 8px; font-size:12px; }
.s2 tbody tr:nth-child(even) { background:#f7f7f7; }
.s2 .total { margin-top:12px; justify-content:flex-end; align-items:center; gap:36px; background:#efefef; border:1px solid #bbb; border-radius:2px; padding:12px 18px; }
.s2 .t-label { margin-right:auto; font-weight:800; font-size:14px; }
.s2 .total b { font-size:17px; font-weight:800; }
/* 三 紧凑报表 */
.s3 .doc { border-left:4px solid #111; padding-left:18px; }
.s3 .hd { display:flex; align-items:baseline; gap:12px; margin-bottom:10px; }
.s3 .co { font-size:19px; font-weight:700; }
.s3 .ty { font-size:13px; letter-spacing:4px; color:#444; }
.s3 .meta { background:#f3f3f3; border:1px solid #ccc; padding:7px 10px; margin-bottom:10px; }
.s3 .meta-row { justify-content:space-between; font-size:11.5px; line-height:1.9; }
.s3 .m-no { font-weight:700; }
.s3 table { border:1px solid #bbb; }
.s3 th { background:#e5e5e5; border:1px solid #bbb; padding:5px 5px; font-size:11px; font-weight:700; }
.s3 td { border:1px solid #ccc; padding:4px 6px; font-size:11px; }
.s3 td.num { font-family:"SF Mono","Roboto Mono",Menlo,Consolas,monospace; }
.s3 .total { justify-content:flex-end; gap:28px; margin-top:8px; border:1px solid #bbb; background:#f3f3f3; padding:7px 12px; font-size:12px; font-weight:700; }
.s3 .t-label { margin-right:auto; }
.s3 .total b { font-size:14px; font-family:"SF Mono",Menlo,Consolas,monospace; }
.s3 .sign { margin-top:30px; font-size:12px; }
/* 四 西式发票 */
.s4 .topbar { display:flex; justify-content:space-between; align-items:flex-start; border-bottom:3px solid #111; padding-bottom:14px; margin-bottom:16px; }
.s4 .co { font-size:30px; font-weight:800; letter-spacing:1px; }
.s4 .docmeta { text-align:right; }
.s4 .doctype { font-size:18px; font-weight:700; letter-spacing:6px; margin-bottom:8px; }
.s4 .mbox { font-size:12px; line-height:1.8; color:#333; }
.s4 .party { margin-bottom:14px; }
.s4 .plabel { font-size:10.5px; letter-spacing:3px; color:#888; text-transform:uppercase; margin-bottom:5px; }
.s4 .pgrid { display:flex; gap:40px; font-size:13px; }
.s4 .seclabel { font-size:11px; letter-spacing:3px; color:#111; font-weight:700; border-bottom:1px solid #111; padding-bottom:5px; text-transform:uppercase; }
.s4 th { border-bottom:1.5px solid #111; padding:8px 6px; font-size:11.5px; font-weight:700; }
.s4 td { border-bottom:1px solid #e0e0e0; padding:8px 6px; font-size:12px; }
.s4 .grand { display:flex; flex-direction:column; align-items:flex-end; gap:4px; margin-top:14px; }
.s4 .grand > div { display:flex; gap:18px; min-width:230px; justify-content:space-between; font-size:13px; }
.s4 .grand .g1 { justify-content:flex-end; color:#888; letter-spacing:2px; }
.s4 .grand .g3 { border-top:2px solid #111; padding-top:6px; margin-top:2px; font-weight:800; }
.s4 .grand b { font-size:15px; } .s4 .grand .g3 b { font-size:19px; }
.s4 .sign { border-top:1px solid #ccc; padding-top:14px; }
/* 五 瑞士网格 */
.s5 .kicker { font-size:12px; letter-spacing:4px; text-transform:uppercase; color:#888; }
.s5 .headline { font-size:46px; font-weight:800; letter-spacing:4px; line-height:1; margin:4px 0 16px; }
.s5 .metaline { display:flex; gap:42px; flex-wrap:wrap; border-top:1px solid #111; border-bottom:1px solid #111; padding:10px 0; font-size:12.5px; margin-bottom:22px; }
.s5 .metaline i { display:block; font-style:normal; font-size:10px; letter-spacing:2px; text-transform:uppercase; color:#999; margin-bottom:3px; }
.s5 .sec { font-size:10.5px; letter-spacing:3px; text-transform:uppercase; color:#666; margin-bottom:6px; }
.s5 th { border-top:2px solid #111; border-bottom:2px solid #111; padding:8px 6px; font-size:11px; letter-spacing:.5px; font-weight:700; }
.s5 td { border-bottom:1px solid #e4e4e4; padding:9px 6px; font-size:12px; }
.s5 td.num { font-variant-numeric:tabular-nums; font-feature-settings:"tnum"; }
.s5 .total8 { display:flex; justify-content:flex-end; align-items:baseline; gap:14px; border-bottom:3px double #111; padding:12px 2px; margin-top:-1px; }
.s5 .total8 > span { margin-right:auto; font-size:11px; letter-spacing:3px; text-transform:uppercase; color:#666; }
.s5 .total8 em { font-style:normal; font-size:11px; color:#999; letter-spacing:2px; }
.s5 .total8 b { font-size:18px; font-weight:800; }
.s5 .sign { margin-top:46px; }
/* 六 填空表单 */
.s6 .frame { border:1px solid #111; outline:3px solid #111; outline-offset:3px; padding:18px 18px 16px; }
.s6 .co9 { text-align:center; font-size:14px; letter-spacing:2px; color:#333; }
.s6 .title9 { text-align:center; font-size:22px; font-weight:800; letter-spacing:10px; margin:2px 0 14px; }
.s6 .meta9 { display:grid; grid-template-columns:repeat(3,1fr); gap:6px 24px; margin-bottom:12px; }
.s6 .f { display:flex; align-items:baseline; font-size:12.5px; }
.s6 .f label { color:#333; white-space:nowrap; } .s6 .f label::after { content:""; }
.s6 .f .fill { flex:1; border-bottom:1px solid #111; min-height:18px; padding:0 4px; font-weight:600; }
.s6 table, .s6 th, .s6 td { border:1px solid #111; }
.s6 th { background:#eee; padding:6px; font-size:11.5px; font-weight:700; }
.s6 td { padding:6px 7px; font-size:11.5px; }
.s6 .total9 { display:flex; border:1px solid #111; border-top:none; padding:8px 12px; font-size:13px; font-weight:700; }
.s6 .total9 span:first-child { margin-right:auto; }
.s6 .total9 span+span { margin-left:36px; }
.s6 .sign9 { display:flex; margin-top:16px; gap:10px; }
.s6 .sign9 div { flex:1; border:1px solid #111; padding:14px 10px 8px; font-size:12.5px; }
@media print {
@page { size:A4; margin:10mm; }
body { background:#fff; padding:0; }
.page-head { display:none; }
.style-block { max-width:none; margin:0; break-before:page; }
.style-block:first-of-type { break-before:auto; }
.style-block > h2 { font-size:13px; margin:0 0 2px; }
.style-block > .desc { font-size:10px; color:#555; margin:0 0 8px; }
.sheet { box-shadow:none; border:1px solid #e3e3e3; padding:18px 20px; margin-bottom:10px; break-inside:avoid; }
}
</style></head><body>
<header class="page-head"><h1>出入库单排版方案 · 6 种黑白样式</h1><nav><a href="#s1">极简线框</a><a href="#s2">现代分区</a><a href="#s3">紧凑报表</a><a href="#s4">西式发票</a><a href="#s5">瑞士网格</a><a href="#s6">填空表单</a></nav></header>
<section id="s1" class="style-block">
<h2>样式一 · 极简线框</h2><p class="desc">无竖线、仅细横线分隔,大留白、左对齐抬头 —— 清爽现代,激光打印省墨。</p>
<div class="sheet s1"><div class="doc">
<div class="hd"><div class="co">鼎晟酒行</div><div class="ty">入 库 单</div></div>
<div class="meta">
<div class="meta-row r1"><span class="m m-partner">单位名称:丁小峰</span><span class="m m-date">入库日期:2026-06-23</span><span class="m m-no">NORK20260623000013</span></div>
<div class="meta-row r2"><span class="m m-phone">联系电话:</span><span class="m m-addr">单位地址:</span><span class="m m-wh">仓库:鼎晟酒行</span></div>
</div>
<table><thead><tr><th>商品编号</th><th>商品名称</th><th>系列</th><th>规格</th><th>批次号</th><th>生产日期</th><th>数量</th><th>单价</th><th>金额</th><th>备注</th></tr></thead><tbody><tr><td class="">P1328</td><td class="">茅台1935原箱2026</td><td class="ctr">普通/53度</td><td class="ctr">500ml*6/件</td><td class="ctr">8719005920</td><td class="ctr">2026-02-28</td><td class="num">6</td><td class="num">600.00</td><td class="num">3600.00</td><td class=""></td></tr><tr><td class="">P1329</td><td class="">茅台1935原箱2026</td><td class="ctr">普通/53度</td><td class="ctr">500ml*6/件</td><td class="ctr">8719007000</td><td class="ctr">2026-02-28</td><td class="num">6</td><td class="num">600.00</td><td class="num">3600.00</td><td class=""></td></tr><tr><td class="">P1330</td><td class="">茅台公斤2026原箱</td><td class="ctr">普通/53度</td><td class="ctr">1.00L*6/件</td><td class="ctr">2025-184/248896472708</td><td class="ctr">2026-06-01</td><td class="num">6</td><td class="num">3080.00</td><td class="num">18480.00</td><td class=""></td></tr><tr><td class="">P1331</td><td class="">茅台公斤2026原箱</td><td class="ctr">普通/53度</td><td class="ctr">1.00L*6/件</td><td class="ctr">2025-126/009192582386</td><td class="ctr">2026-03-13</td><td class="num">6</td><td class="num">3080.00</td><td class="num">18480.00</td><td class=""></td></tr></tbody></table>
<div class="total"><span class="t-label">单据总计</span><span class="t-qty">数量 <b>24</b></span><span class="t-amt">金额 <b>44160.00</b></span></div>
<div class="sign"><span>出货人(签字):</span><span>采购员:汤宇飞</span><span>制单人:汤宇飞</span></div></div></div>
<div class="sheet s1"><div class="doc">
<div class="hd"><div class="co">鼎晟酒行</div><div class="ty">出 库 单</div></div>
<div class="meta">
<div class="meta-row r1"><span class="m m-partner">客户名称:刘总</span><span class="m m-date">出库日期:2026-06-23</span><span class="m m-no">NO.CK20260623000020</span></div>
<div class="meta-row r2"><span class="m m-phone">联系电话:</span><span class="m m-addr">送货地址:</span><span class="m m-wh">仓库:鼎晟酒行</span></div>
</div>
<table><thead><tr><th>商品编号</th><th>商品名称</th><th>系列</th><th>规格</th><th>批次号</th><th>生产日期</th><th>数量</th><th>单价</th><th>金额</th><th>备注</th></tr></thead><tbody><tr><td class="">P1331</td><td class="">茅台公斤2026原箱</td><td class="ctr">普通/53度</td><td class="ctr">1.00L*6/件</td><td class="ctr">2025-126/009192582386</td><td class="ctr">2026-03-13</td><td class="num">1</td><td class="num">3080.00</td><td class="num">3080.00</td><td class=""></td></tr><tr><td class="">P1330</td><td class="">茅台公斤2026原箱</td><td class="ctr">普通/53度</td><td class="ctr">1.00L*6/件</td><td class="ctr">2025-184/248896472708</td><td class="ctr">2026-06-01</td><td class="num">1</td><td class="num">3080.00</td><td class="num">3080.00</td><td class=""></td></tr></tbody></table>
<div class="total"><span class="t-label">单据总计</span><span class="t-qty">数量 <b>2</b></span><span class="t-amt">金额 <b>6160.00</b></span></div>
<div class="tips">温馨提示:签收时,请务必核对好酒品数量、年份和日期、批次及物流码,如有问题及时反馈,酒品无质量问题一经售出概不退换,谢谢合作。</div><div class="sign"><span>收货人(签字):</span><span>销售员:汤宇飞</span><span>制单人:汤宇飞</span></div></div></div>
</section>
<section id="s2" class="style-block">
<h2>样式二 · 现代分区</h2><p class="desc">抬头字段做成带框表单格、明细斑马纹、总计高亮条 —— 信息分区清晰。</p>
<div class="sheet s2"><div class="doc">
<div class="hd"><div class="co">鼎晟酒行</div><div class="ty">入 库 单</div></div>
<div class="meta">
<div class="meta-row r1"><span class="m m-partner">单位名称:丁小峰</span><span class="m m-date">入库日期:2026-06-23</span><span class="m m-no">NORK20260623000013</span></div>
<div class="meta-row r2"><span class="m m-phone">联系电话:</span><span class="m m-addr">单位地址:</span><span class="m m-wh">仓库:鼎晟酒行</span></div>
</div>
<table><thead><tr><th>商品编号</th><th>商品名称</th><th>系列</th><th>规格</th><th>批次号</th><th>生产日期</th><th>数量</th><th>单价</th><th>金额</th><th>备注</th></tr></thead><tbody><tr><td class="">P1328</td><td class="">茅台1935原箱2026</td><td class="ctr">普通/53度</td><td class="ctr">500ml*6/件</td><td class="ctr">8719005920</td><td class="ctr">2026-02-28</td><td class="num">6</td><td class="num">600.00</td><td class="num">3600.00</td><td class=""></td></tr><tr><td class="">P1329</td><td class="">茅台1935原箱2026</td><td class="ctr">普通/53度</td><td class="ctr">500ml*6/件</td><td class="ctr">8719007000</td><td class="ctr">2026-02-28</td><td class="num">6</td><td class="num">600.00</td><td class="num">3600.00</td><td class=""></td></tr><tr><td class="">P1330</td><td class="">茅台公斤2026原箱</td><td class="ctr">普通/53度</td><td class="ctr">1.00L*6/件</td><td class="ctr">2025-184/248896472708</td><td class="ctr">2026-06-01</td><td class="num">6</td><td class="num">3080.00</td><td class="num">18480.00</td><td class=""></td></tr><tr><td class="">P1331</td><td class="">茅台公斤2026原箱</td><td class="ctr">普通/53度</td><td class="ctr">1.00L*6/件</td><td class="ctr">2025-126/009192582386</td><td class="ctr">2026-03-13</td><td class="num">6</td><td class="num">3080.00</td><td class="num">18480.00</td><td class=""></td></tr></tbody></table>
<div class="total"><span class="t-label">单据总计</span><span class="t-qty">数量 <b>24</b></span><span class="t-amt">金额 <b>44160.00</b></span></div>
<div class="sign"><span>出货人(签字):</span><span>采购员:汤宇飞</span><span>制单人:汤宇飞</span></div></div></div>
<div class="sheet s2"><div class="doc">
<div class="hd"><div class="co">鼎晟酒行</div><div class="ty">出 库 单</div></div>
<div class="meta">
<div class="meta-row r1"><span class="m m-partner">客户名称:刘总</span><span class="m m-date">出库日期:2026-06-23</span><span class="m m-no">NO.CK20260623000020</span></div>
<div class="meta-row r2"><span class="m m-phone">联系电话:</span><span class="m m-addr">送货地址:</span><span class="m m-wh">仓库:鼎晟酒行</span></div>
</div>
<table><thead><tr><th>商品编号</th><th>商品名称</th><th>系列</th><th>规格</th><th>批次号</th><th>生产日期</th><th>数量</th><th>单价</th><th>金额</th><th>备注</th></tr></thead><tbody><tr><td class="">P1331</td><td class="">茅台公斤2026原箱</td><td class="ctr">普通/53度</td><td class="ctr">1.00L*6/件</td><td class="ctr">2025-126/009192582386</td><td class="ctr">2026-03-13</td><td class="num">1</td><td class="num">3080.00</td><td class="num">3080.00</td><td class=""></td></tr><tr><td class="">P1330</td><td class="">茅台公斤2026原箱</td><td class="ctr">普通/53度</td><td class="ctr">1.00L*6/件</td><td class="ctr">2025-184/248896472708</td><td class="ctr">2026-06-01</td><td class="num">1</td><td class="num">3080.00</td><td class="num">3080.00</td><td class=""></td></tr></tbody></table>
<div class="total"><span class="t-label">单据总计</span><span class="t-qty">数量 <b>2</b></span><span class="t-amt">金额 <b>6160.00</b></span></div>
<div class="tips">温馨提示:签收时,请务必核对好酒品数量、年份和日期、批次及物流码,如有问题及时反馈,酒品无质量问题一经售出概不退换,谢谢合作。</div><div class="sign"><span>收货人(签字):</span><span>销售员:汤宇飞</span><span>制单人:汤宇飞</span></div></div></div>
</section>
<section id="s3" class="style-block">
<h2>样式三 · 紧凑报表</h2><p class="desc">小字号、数字等宽对齐、左侧竖线点缀 —— 单页能塞更多行,密集报表风。</p>
<div class="sheet s3"><div class="doc">
<div class="hd"><div class="co">鼎晟酒行</div><div class="ty">入 库 单</div></div>
<div class="meta">
<div class="meta-row r1"><span class="m m-partner">单位名称:丁小峰</span><span class="m m-date">入库日期:2026-06-23</span><span class="m m-no">NORK20260623000013</span></div>
<div class="meta-row r2"><span class="m m-phone">联系电话:</span><span class="m m-addr">单位地址:</span><span class="m m-wh">仓库:鼎晟酒行</span></div>
</div>
<table><thead><tr><th>商品编号</th><th>商品名称</th><th>系列</th><th>规格</th><th>批次号</th><th>生产日期</th><th>数量</th><th>单价</th><th>金额</th><th>备注</th></tr></thead><tbody><tr><td class="">P1328</td><td class="">茅台1935原箱2026</td><td class="ctr">普通/53度</td><td class="ctr">500ml*6/件</td><td class="ctr">8719005920</td><td class="ctr">2026-02-28</td><td class="num">6</td><td class="num">600.00</td><td class="num">3600.00</td><td class=""></td></tr><tr><td class="">P1329</td><td class="">茅台1935原箱2026</td><td class="ctr">普通/53度</td><td class="ctr">500ml*6/件</td><td class="ctr">8719007000</td><td class="ctr">2026-02-28</td><td class="num">6</td><td class="num">600.00</td><td class="num">3600.00</td><td class=""></td></tr><tr><td class="">P1330</td><td class="">茅台公斤2026原箱</td><td class="ctr">普通/53度</td><td class="ctr">1.00L*6/件</td><td class="ctr">2025-184/248896472708</td><td class="ctr">2026-06-01</td><td class="num">6</td><td class="num">3080.00</td><td class="num">18480.00</td><td class=""></td></tr><tr><td class="">P1331</td><td class="">茅台公斤2026原箱</td><td class="ctr">普通/53度</td><td class="ctr">1.00L*6/件</td><td class="ctr">2025-126/009192582386</td><td class="ctr">2026-03-13</td><td class="num">6</td><td class="num">3080.00</td><td class="num">18480.00</td><td class=""></td></tr></tbody></table>
<div class="total"><span class="t-label">单据总计</span><span class="t-qty">数量 <b>24</b></span><span class="t-amt">金额 <b>44160.00</b></span></div>
<div class="sign"><span>出货人(签字):</span><span>采购员:汤宇飞</span><span>制单人:汤宇飞</span></div></div></div>
<div class="sheet s3"><div class="doc">
<div class="hd"><div class="co">鼎晟酒行</div><div class="ty">出 库 单</div></div>
<div class="meta">
<div class="meta-row r1"><span class="m m-partner">客户名称:刘总</span><span class="m m-date">出库日期:2026-06-23</span><span class="m m-no">NO.CK20260623000020</span></div>
<div class="meta-row r2"><span class="m m-phone">联系电话:</span><span class="m m-addr">送货地址:</span><span class="m m-wh">仓库:鼎晟酒行</span></div>
</div>
<table><thead><tr><th>商品编号</th><th>商品名称</th><th>系列</th><th>规格</th><th>批次号</th><th>生产日期</th><th>数量</th><th>单价</th><th>金额</th><th>备注</th></tr></thead><tbody><tr><td class="">P1331</td><td class="">茅台公斤2026原箱</td><td class="ctr">普通/53度</td><td class="ctr">1.00L*6/件</td><td class="ctr">2025-126/009192582386</td><td class="ctr">2026-03-13</td><td class="num">1</td><td class="num">3080.00</td><td class="num">3080.00</td><td class=""></td></tr><tr><td class="">P1330</td><td class="">茅台公斤2026原箱</td><td class="ctr">普通/53度</td><td class="ctr">1.00L*6/件</td><td class="ctr">2025-184/248896472708</td><td class="ctr">2026-06-01</td><td class="num">1</td><td class="num">3080.00</td><td class="num">3080.00</td><td class=""></td></tr></tbody></table>
<div class="total"><span class="t-label">单据总计</span><span class="t-qty">数量 <b>2</b></span><span class="t-amt">金额 <b>6160.00</b></span></div>
<div class="tips">温馨提示:签收时,请务必核对好酒品数量、年份和日期、批次及物流码,如有问题及时反馈,酒品无质量问题一经售出概不退换,谢谢合作。</div><div class="sign"><span>收货人(签字):</span><span>销售员:汤宇飞</span><span>制单人:汤宇飞</span></div></div></div>
</section>
<section id="s4" class="style-block">
<h2>样式四 · 西式发票</h2><p class="desc">左上大字号商号 + 右上单号块、底部「单据总计」金额栈、字母大写小标题 —— 借鉴欧美 INVOICE。</p>
<div class="sheet s4"><div class="doc">
<div class="topbar"><div class="co">鼎晟酒行</div>
<div class="docmeta"><div class="doctype">入 库 单</div>
<div class="mbox"><div>NORK20260623000013</div><div>入库日期:2026-06-23</div><div>仓库:鼎晟酒行</div></div></div></div>
<div class="party"><div class="plabel">往来信息</div>
<div class="pgrid"><span>单位名称:丁小峰</span><span>联系电话:</span><span>单位地址:</span></div></div>
<div class="seclabel">商品明细 / ITEMS</div>
<table><thead><tr><th>商品编号</th><th>商品名称</th><th>系列</th><th>规格</th><th>批次号</th><th>生产日期</th><th>数量</th><th>单价</th><th>金额</th><th>备注</th></tr></thead><tbody><tr><td class="">P1328</td><td class="">茅台1935原箱2026</td><td class="ctr">普通/53度</td><td class="ctr">500ml*6/件</td><td class="ctr">8719005920</td><td class="ctr">2026-02-28</td><td class="num">6</td><td class="num">600.00</td><td class="num">3600.00</td><td class=""></td></tr><tr><td class="">P1329</td><td class="">茅台1935原箱2026</td><td class="ctr">普通/53度</td><td class="ctr">500ml*6/件</td><td class="ctr">8719007000</td><td class="ctr">2026-02-28</td><td class="num">6</td><td class="num">600.00</td><td class="num">3600.00</td><td class=""></td></tr><tr><td class="">P1330</td><td class="">茅台公斤2026原箱</td><td class="ctr">普通/53度</td><td class="ctr">1.00L*6/件</td><td class="ctr">2025-184/248896472708</td><td class="ctr">2026-06-01</td><td class="num">6</td><td class="num">3080.00</td><td class="num">18480.00</td><td class=""></td></tr><tr><td class="">P1331</td><td class="">茅台公斤2026原箱</td><td class="ctr">普通/53度</td><td class="ctr">1.00L*6/件</td><td class="ctr">2025-126/009192582386</td><td class="ctr">2026-03-13</td><td class="num">6</td><td class="num">3080.00</td><td class="num">18480.00</td><td class=""></td></tr></tbody></table>
<div class="grand"><div class="g1">单据总计</div>
<div class="g2"><span>数量</span><b>24</b></div>
<div class="g3"><span>金额</span><b>44160.00</b></div></div>
<div class="sign"><span>出货人(签字):</span><span>采购员:汤宇飞</span><span>制单人:汤宇飞</span></div></div></div>
<div class="sheet s4"><div class="doc">
<div class="topbar"><div class="co">鼎晟酒行</div>
<div class="docmeta"><div class="doctype">出 库 单</div>
<div class="mbox"><div>NO.CK20260623000020</div><div>出库日期:2026-06-23</div><div>仓库:鼎晟酒行</div></div></div></div>
<div class="party"><div class="plabel">往来信息</div>
<div class="pgrid"><span>客户名称:刘总</span><span>联系电话:</span><span>送货地址:</span></div></div>
<div class="seclabel">商品明细 / ITEMS</div>
<table><thead><tr><th>商品编号</th><th>商品名称</th><th>系列</th><th>规格</th><th>批次号</th><th>生产日期</th><th>数量</th><th>单价</th><th>金额</th><th>备注</th></tr></thead><tbody><tr><td class="">P1331</td><td class="">茅台公斤2026原箱</td><td class="ctr">普通/53度</td><td class="ctr">1.00L*6/件</td><td class="ctr">2025-126/009192582386</td><td class="ctr">2026-03-13</td><td class="num">1</td><td class="num">3080.00</td><td class="num">3080.00</td><td class=""></td></tr><tr><td class="">P1330</td><td class="">茅台公斤2026原箱</td><td class="ctr">普通/53度</td><td class="ctr">1.00L*6/件</td><td class="ctr">2025-184/248896472708</td><td class="ctr">2026-06-01</td><td class="num">1</td><td class="num">3080.00</td><td class="num">3080.00</td><td class=""></td></tr></tbody></table>
<div class="grand"><div class="g1">单据总计</div>
<div class="g2"><span>数量</span><b>2</b></div>
<div class="g3"><span>金额</span><b>6160.00</b></div></div>
<div class="tips">温馨提示:签收时,请务必核对好酒品数量、年份和日期、批次及物流码,如有问题及时反馈,酒品无质量问题一经售出概不退换,谢谢合作。</div><div class="sign"><span>收货人(签字):</span><span>销售员:汤宇飞</span><span>制单人:汤宇飞</span></div></div></div>
</section>
<section id="s5" class="style-block">
<h2>样式五 · 瑞士网格</h2><p class="desc">超大标题 + 细基线网格、小标签等宽数字、表格仅上下粗线 —— 杂志/瑞士排版。</p>
<div class="sheet s5"><div class="doc">
<div class="kicker">鼎晟酒行</div>
<h1 class="headline">入 库 单</h1>
<div class="metaline"><span><i>单号</i>RK20260623000013</span><span><i>入库日期</i>2026-06-23</span><span><i>单位名称</i>丁小峰</span><span><i>仓库</i>鼎晟酒行</span></div>
<div class="sec">商品明细</div>
<table><thead><tr><th>商品编号</th><th>商品名称</th><th>系列</th><th>规格</th><th>批次号</th><th>生产日期</th><th>数量</th><th>单价</th><th>金额</th><th>备注</th></tr></thead><tbody><tr><td class="">P1328</td><td class="">茅台1935原箱2026</td><td class="ctr">普通/53度</td><td class="ctr">500ml*6/件</td><td class="ctr">8719005920</td><td class="ctr">2026-02-28</td><td class="num">6</td><td class="num">600.00</td><td class="num">3600.00</td><td class=""></td></tr><tr><td class="">P1329</td><td class="">茅台1935原箱2026</td><td class="ctr">普通/53度</td><td class="ctr">500ml*6/件</td><td class="ctr">8719007000</td><td class="ctr">2026-02-28</td><td class="num">6</td><td class="num">600.00</td><td class="num">3600.00</td><td class=""></td></tr><tr><td class="">P1330</td><td class="">茅台公斤2026原箱</td><td class="ctr">普通/53度</td><td class="ctr">1.00L*6/件</td><td class="ctr">2025-184/248896472708</td><td class="ctr">2026-06-01</td><td class="num">6</td><td class="num">3080.00</td><td class="num">18480.00</td><td class=""></td></tr><tr><td class="">P1331</td><td class="">茅台公斤2026原箱</td><td class="ctr">普通/53度</td><td class="ctr">1.00L*6/件</td><td class="ctr">2025-126/009192582386</td><td class="ctr">2026-03-13</td><td class="num">6</td><td class="num">3080.00</td><td class="num">18480.00</td><td class=""></td></tr></tbody></table>
<div class="total8"><span>单据总计</span><em>数量</em><b>24</b><em>金额</em><b>44160.00</b></div>
<div class="sign"><span>出货人(签字):</span><span>采购员:汤宇飞</span><span>制单人:汤宇飞</span></div></div></div>
<div class="sheet s5"><div class="doc">
<div class="kicker">鼎晟酒行</div>
<h1 class="headline">出 库 单</h1>
<div class="metaline"><span><i>单号</i>CK20260623000020</span><span><i>出库日期</i>2026-06-23</span><span><i>客户名称</i>刘总</span><span><i>仓库</i>鼎晟酒行</span></div>
<div class="sec">商品明细</div>
<table><thead><tr><th>商品编号</th><th>商品名称</th><th>系列</th><th>规格</th><th>批次号</th><th>生产日期</th><th>数量</th><th>单价</th><th>金额</th><th>备注</th></tr></thead><tbody><tr><td class="">P1331</td><td class="">茅台公斤2026原箱</td><td class="ctr">普通/53度</td><td class="ctr">1.00L*6/件</td><td class="ctr">2025-126/009192582386</td><td class="ctr">2026-03-13</td><td class="num">1</td><td class="num">3080.00</td><td class="num">3080.00</td><td class=""></td></tr><tr><td class="">P1330</td><td class="">茅台公斤2026原箱</td><td class="ctr">普通/53度</td><td class="ctr">1.00L*6/件</td><td class="ctr">2025-184/248896472708</td><td class="ctr">2026-06-01</td><td class="num">1</td><td class="num">3080.00</td><td class="num">3080.00</td><td class=""></td></tr></tbody></table>
<div class="total8"><span>单据总计</span><em>数量</em><b>2</b><em>金额</em><b>6160.00</b></div>
<div class="tips">温馨提示:签收时,请务必核对好酒品数量、年份和日期、批次及物流码,如有问题及时反馈,酒品无质量问题一经售出概不退换,谢谢合作。</div><div class="sign"><span>收货人(签字):</span><span>销售员:汤宇飞</span><span>制单人:汤宇飞</span></div></div></div>
</section>
<section id="s6" class="style-block">
<h2>样式六 · 填空表单</h2><p class="desc">双线外框、字段下划线填空、底部多签字格 —— 仿手工三联单/预印单据,正式可手填。</p>
<div class="sheet s6"><div class="doc form9"><div class="frame">
<div class="co9">鼎晟酒行</div>
<div class="title9">入 库 单</div>
<div class="meta9">
<div class="f"><label>单位名称</label><span class="fill">丁小峰</span></div>
<div class="f"><label>入库日期</label><span class="fill">2026-06-23</span></div>
<div class="f"><label>单据编号</label><span class="fill">RK20260623000013</span></div>
<div class="f"><label>联系电话</label><span class="fill"></span></div>
<div class="f"><label>单位地址</label><span class="fill"></span></div>
<div class="f"><label>仓库</label><span class="fill">鼎晟酒行</span></div>
</div>
<table><thead><tr><th>商品编号</th><th>商品名称</th><th>系列</th><th>规格</th><th>批次号</th><th>生产日期</th><th>数量</th><th>单价</th><th>金额</th><th>备注</th></tr></thead><tbody><tr><td class="">P1328</td><td class="">茅台1935原箱2026</td><td class="ctr">普通/53度</td><td class="ctr">500ml*6/件</td><td class="ctr">8719005920</td><td class="ctr">2026-02-28</td><td class="num">6</td><td class="num">600.00</td><td class="num">3600.00</td><td class=""></td></tr><tr><td class="">P1329</td><td class="">茅台1935原箱2026</td><td class="ctr">普通/53度</td><td class="ctr">500ml*6/件</td><td class="ctr">8719007000</td><td class="ctr">2026-02-28</td><td class="num">6</td><td class="num">600.00</td><td class="num">3600.00</td><td class=""></td></tr><tr><td class="">P1330</td><td class="">茅台公斤2026原箱</td><td class="ctr">普通/53度</td><td class="ctr">1.00L*6/件</td><td class="ctr">2025-184/248896472708</td><td class="ctr">2026-06-01</td><td class="num">6</td><td class="num">3080.00</td><td class="num">18480.00</td><td class=""></td></tr><tr><td class="">P1331</td><td class="">茅台公斤2026原箱</td><td class="ctr">普通/53度</td><td class="ctr">1.00L*6/件</td><td class="ctr">2025-126/009192582386</td><td class="ctr">2026-03-13</td><td class="num">6</td><td class="num">3080.00</td><td class="num">18480.00</td><td class=""></td></tr></tbody></table>
<div class="total9"><span>单据总计</span><span>数量 24</span><span>金额 44160.00</span></div>
<div class="sign9"><div>出货人(签字):</div><div>采购员:汤宇飞</div><div>制单人:汤宇飞</div></div>
</div></div></div>
<div class="sheet s6"><div class="doc form9"><div class="frame">
<div class="co9">鼎晟酒行</div>
<div class="title9">出 库 单</div>
<div class="meta9">
<div class="f"><label>客户名称</label><span class="fill">刘总</span></div>
<div class="f"><label>出库日期</label><span class="fill">2026-06-23</span></div>
<div class="f"><label>单据编号</label><span class="fill">CK20260623000020</span></div>
<div class="f"><label>联系电话</label><span class="fill"></span></div>
<div class="f"><label>送货地址</label><span class="fill"></span></div>
<div class="f"><label>仓库</label><span class="fill">鼎晟酒行</span></div>
</div>
<table><thead><tr><th>商品编号</th><th>商品名称</th><th>系列</th><th>规格</th><th>批次号</th><th>生产日期</th><th>数量</th><th>单价</th><th>金额</th><th>备注</th></tr></thead><tbody><tr><td class="">P1331</td><td class="">茅台公斤2026原箱</td><td class="ctr">普通/53度</td><td class="ctr">1.00L*6/件</td><td class="ctr">2025-126/009192582386</td><td class="ctr">2026-03-13</td><td class="num">1</td><td class="num">3080.00</td><td class="num">3080.00</td><td class=""></td></tr><tr><td class="">P1330</td><td class="">茅台公斤2026原箱</td><td class="ctr">普通/53度</td><td class="ctr">1.00L*6/件</td><td class="ctr">2025-184/248896472708</td><td class="ctr">2026-06-01</td><td class="num">1</td><td class="num">3080.00</td><td class="num">3080.00</td><td class=""></td></tr></tbody></table>
<div class="total9"><span>单据总计</span><span>数量 2</span><span>金额 6160.00</span></div>
<div class="tips">温馨提示:签收时,请务必核对好酒品数量、年份和日期、批次及物流码,如有问题及时反馈,酒品无质量问题一经售出概不退换,谢谢合作。</div>
<div class="sign9"><div>收货人(签字):</div><div>销售员:汤宇飞</div><div>制单人:汤宇飞</div></div>
</div></div></div>
</section>
</body></html>
Binary file not shown.
+60
View File
@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>文档索引 — 酒库管理系统</title>
<style>
:root{
--primary:#2563AC; --primary-dark:#154072; --accent:#8B2331;
--ink:#232934; --muted:#6E7888; --border:#DCE2EB; --paper:#F5F7FA; --head:#F0F4FF;
}
*{box-sizing:border-box;font-family:-apple-system,"PingFang SC","Microsoft YaHei",sans-serif;}
body{margin:0;background:var(--paper);color:var(--ink);padding:28px;line-height:1.6;}
h1{font-size:20px;margin:0 0 4px;}
.sub{color:var(--muted);font-size:13px;margin-bottom:20px;}
h2{font-size:15px;margin:24px 0 8px;color:var(--primary-dark);border-bottom:2px solid var(--head);padding-bottom:6px;}
ul{margin:6px 0;padding-left:20px;}
li{margin:5px 0;font-size:13.5px;}
a{color:var(--primary);text-decoration:none;}
a:hover{text-decoration:underline;}
.hint{color:var(--muted);font-size:12px;}
.tag{display:inline-block;font-size:10px;padding:1px 7px;border-radius:9px;background:#EEF2F8;color:var(--muted);margin-left:6px;}
.tag.html{background:#E5EEF9;color:var(--primary);}
</style>
</head>
<body>
<h1>酒库管理系统 · 文档索引</h1>
<div class="sub">项目所有文档的单一入口。新增文档须同时登记到此处。</div>
<h2>设计方案 / 原型</h2>
<ul>
<li><a href="design/order-return-prototype.html">退单原型(已审核单据)</a><span class="tag html">HTML</span></li>
<li><a href="design/order-print-layouts.html">出入库单打印排版方案(6 种黑白样式精选)</a><span class="tag html">HTML</span> · <a href="design/order-print-layouts.pdf">PDF</a></li>
</ul>
<h2>实现计划</h2>
<ul>
<li class="hint">(暂无)</li>
</ul>
<h2>排障 / 运维 Runbook</h2>
<ul>
<li><a href="runbooks/ec2-to-ali-migration.html">EC2 → 阿里云 迁移计划</a><span class="tag html">HTML</span> <span class="hint">— jiu 服务/数据/DB/文件/域名/CI 整体迁移</span></li>
</ul>
<h2>知识库 / 调研 / 测试</h2>
<ul>
<li><a href="testing/license-test-flow.html">授权兑换券测试流程</a><span class="tag html">HTML</span></li>
</ul>
<h2>历史 Markdown 文档(逐步迁移)</h2>
<ul>
<li><a href="deployment-nas-gitea.md">部署:NAS Gitea/Forgejo</a><span class="tag">MD</span></li>
<li><a href="dev-setup.md">开发环境搭建</a><span class="tag">MD</span></li>
<li><a href="android-signing.md">Android 签名</a> · <a href="ios-signing.md">iOS 签名</a> · <a href="macos-signing.md">macOS 签名</a><span class="tag">MD</span></li>
<li><a href="user-manual.md">用户手册</a><span class="tag">MD</span></li>
<li class="hint">另有 api/ architecture/ requirements/ review/ security/ runbooks/ context/ 等目录下的过程文档</li>
</ul>
</body>
</html>
+1
View File
@@ -26,6 +26,7 @@ mv backend/jiu-gencode dist/jiu-gencode
# it belongs to the client pipeline. # it belongs to the client pipeline.
tar -czf dist/configs.tar.gz \ tar -czf dist/configs.tar.gz \
deploy/nginx-jiu.conf \ deploy/nginx-jiu.conf \
deploy/nginx-jiu-ali.conf \
deploy/jiu.service \ deploy/jiu.service \
deploy/production.env.template \ deploy/production.env.template \
deploy/setup-ec2.sh \ deploy/setup-ec2.sh \
+13 -10
View File
@@ -1,14 +1,17 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# deploy-client.sh <tag> — deploy the Flutter web app, desktop/mobile installers, # deploy-client.sh <tag> — deploy the Flutter web app, desktop/mobile installers,
# and version.yaml to EC2. Does NOT restart the backend (version.yaml is read # and version.yaml. Does NOT restart the backend (version.yaml is read
# per-request) nor touch nginx / the marketing site. # per-request) nor touch nginx / the marketing site. Target is EC2 by default;
# set DEPLOY_HOST/DEPLOY_USER/DEPLOY_SSH_KEY to retarget the Ali box (migration).
set -euo pipefail set -euo pipefail
# shellcheck source=scripts/ci/lib-forgejo.sh # shellcheck source=scripts/ci/lib-forgejo.sh
. "$(dirname "$0")/lib-forgejo.sh" . "$(dirname "$0")/lib-forgejo.sh"
TAG="$1" TAG="$1"
echo "==> deploy-client: tag=${TAG}" TARGET_HOST="${DEPLOY_HOST:-$EC2_HOST}"
TARGET_USER="${DEPLOY_USER:-$EC2_USER}"
echo "==> deploy-client: tag=${TAG} host=${TARGET_HOST}"
if [ ! -f dist/web.tar.gz ] || [ ! -f dist/version.yaml ]; then if [ ! -f dist/web.tar.gz ] || [ ! -f dist/version.yaml ]; then
download_release_assets "$TAG" download_release_assets "$TAG"
@@ -27,18 +30,18 @@ mkdir -p /tmp/jiu-web-new
tar -xzf dist/web.tar.gz -C /tmp/jiu-web-new --strip-components=1 tar -xzf dist/web.tar.gz -C /tmp/jiu-web-new --strip-components=1
setup_ssh setup_ssh
echo "==> deploy-client: uploading files to EC2" echo "==> deploy-client: uploading files to ${TARGET_HOST}"
${SCP} "$VERSION_YAML" "${EC2_USER}@${EC2_HOST}:/tmp/version.yaml" ${SCP} "$VERSION_YAML" "${TARGET_USER}@${TARGET_HOST}:/tmp/version.yaml"
rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no" \ rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no" \
/tmp/jiu-web-new/ "${EC2_USER}@${EC2_HOST}:/tmp/jiu-web-new/" /tmp/jiu-web-new/ "${TARGET_USER}@${TARGET_HOST}:/tmp/jiu-web-new/"
# Desktop / mobile installers (served from /downloads/ by nginx). Guarded — # Desktop / mobile installers (served from /downloads/ by nginx). Guarded —
# may be absent in a partial manual deploy. # may be absent in a partial manual deploy.
[ -f dist/jiu-windows-x64-setup.exe ] && ${SCP} dist/jiu-windows-x64-setup.exe "${EC2_USER}@${EC2_HOST}:/tmp/jiu-windows-x64-setup.exe" || true [ -f dist/jiu-windows-x64-setup.exe ] && ${SCP} dist/jiu-windows-x64-setup.exe "${TARGET_USER}@${TARGET_HOST}:/tmp/jiu-windows-x64-setup.exe" || true
[ -f dist/jiu-macos-x64.zip ] && ${SCP} dist/jiu-macos-x64.zip "${EC2_USER}@${EC2_HOST}:/tmp/jiu-macos-x64.zip" || true [ -f dist/jiu-macos-x64.zip ] && ${SCP} dist/jiu-macos-x64.zip "${TARGET_USER}@${TARGET_HOST}:/tmp/jiu-macos-x64.zip" || true
[ -f dist/jiu-android.apk ] && ${SCP} dist/jiu-android.apk "${EC2_USER}@${EC2_HOST}:/tmp/jiu-android.apk" || true [ -f dist/jiu-android.apk ] && ${SCP} dist/jiu-android.apk "${TARGET_USER}@${TARGET_HOST}:/tmp/jiu-android.apk" || true
${SSH} "${EC2_USER}@${EC2_HOST}" << 'ENDSSH' ${SSH} "${TARGET_USER}@${TARGET_HOST}" << 'ENDSSH'
set -e set -e
# Update version config (WorkingDirectory=/opt/jiu reads config/version.yaml). # Update version config (WorkingDirectory=/opt/jiu reads config/version.yaml).
+59 -12
View File
@@ -1,15 +1,26 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# deploy-server.sh <tag> — deploy the backend binary + nginx config to EC2. # deploy-server.sh <tag> — deploy the backend binary + nginx config.
# Two modes: uses dist/ built in the same job, or downloads the release assets # Two source modes: uses dist/ built in the same job, or downloads the release
# by tag (manual rollback). Does NOT touch the web app, marketing site, or # assets by tag (manual rollback). Does NOT touch the web app, marketing site,
# version.yaml. # or version.yaml.
#
# Target is EC2 by default. During the EC2→Ali migration, set DEPLOY_TARGET=ali
# plus DEPLOY_HOST/DEPLOY_USER/DEPLOY_SSH_KEY to deploy to the Alibaba Cloud box.
# - ec2 (default): stop jiu → swap binary → start jiu → health check →
# reload nginx inside the pangolin-edge container.
# - ali: swap binary only (jiu.service stays stopped — the Ali DB is a
# read-only replica until cutover; backend is started at cutover) →
# reload the host nginx with the standalone (listen 443) config.
set -euo pipefail set -euo pipefail
# shellcheck source=scripts/ci/lib-forgejo.sh # shellcheck source=scripts/ci/lib-forgejo.sh
. "$(dirname "$0")/lib-forgejo.sh" . "$(dirname "$0")/lib-forgejo.sh"
TAG="$1" TAG="$1"
echo "==> deploy-server: tag=${TAG}" DEPLOY_TARGET="${DEPLOY_TARGET:-ec2}"
TARGET_HOST="${DEPLOY_HOST:-$EC2_HOST}"
TARGET_USER="${DEPLOY_USER:-$EC2_USER}"
echo "==> deploy-server: tag=${TAG} target=${DEPLOY_TARGET} host=${TARGET_HOST}"
if [ ! -f dist/jiu-server ] || [ ! -f dist/configs.tar.gz ]; then if [ ! -f dist/jiu-server ] || [ ! -f dist/configs.tar.gz ]; then
download_release_assets "$TAG" download_release_assets "$TAG"
@@ -22,19 +33,54 @@ rm -rf /tmp/jiu-configs
mkdir -p /tmp/jiu-configs mkdir -p /tmp/jiu-configs
tar -xzf dist/configs.tar.gz -C /tmp/jiu-configs tar -xzf dist/configs.tar.gz -C /tmp/jiu-configs
# Pick the nginx config matching the target's reverse-proxy topology.
if [ "$DEPLOY_TARGET" = "ali" ]; then
NGINX_SRC=/tmp/jiu-configs/deploy/nginx-jiu-ali.conf
else
NGINX_SRC=/tmp/jiu-configs/deploy/nginx-jiu.conf
fi
setup_ssh setup_ssh
echo "==> deploy-server: uploading files to EC2" echo "==> deploy-server: uploading files to ${TARGET_HOST}"
${SCP} dist/jiu-server "${EC2_USER}@${EC2_HOST}:/tmp/jiu-server" ${SCP} dist/jiu-server "${TARGET_USER}@${TARGET_HOST}:/tmp/jiu-server"
${SCP} /tmp/jiu-configs/deploy/nginx-jiu.conf "${EC2_USER}@${EC2_HOST}:/tmp/nginx-jiu.conf" ${SCP} "${NGINX_SRC}" "${TARGET_USER}@${TARGET_HOST}:/tmp/nginx-jiu.conf"
# Platform code-minting CLI + wrapper (absent when rolling back to a pre-gencode # Platform code-minting CLI + wrapper (absent when rolling back to a pre-gencode
# release — upload only when present). # release — upload only when present).
if [ -f dist/jiu-gencode ]; then if [ -f dist/jiu-gencode ]; then
${SCP} dist/jiu-gencode "${EC2_USER}@${EC2_HOST}:/tmp/jiu-gencode" ${SCP} dist/jiu-gencode "${TARGET_USER}@${TARGET_HOST}:/tmp/jiu-gencode"
${SCP} /tmp/jiu-configs/deploy/jiu-gencode.sh "${EC2_USER}@${EC2_HOST}:/tmp/jiu-gencode.sh" ${SCP} /tmp/jiu-configs/deploy/jiu-gencode.sh "${TARGET_USER}@${TARGET_HOST}:/tmp/jiu-gencode.sh"
fi fi
${SSH} "${EC2_USER}@${EC2_HOST}" << 'ENDSSH' if [ "$DEPLOY_TARGET" = "ali" ]; then
# --- Alibaba Cloud (shadow target, pre-cutover) ---
# Swap the binary but DO NOT start jiu: the Ali DB is a read-only replica, so
# the backend's startup AutoMigrate/backfill would fail. jiu.service is started
# only at cutover (after the replica is promoted to primary). Reload the host
# nginx with the standalone listen-443 config.
${SSH} "${TARGET_USER}@${TARGET_HOST}" << 'ENDSSH'
set -e
cp /tmp/jiu-server /opt/jiu/backend/jiu-server
chmod +x /opt/jiu/backend/jiu-server
if [ -f /tmp/jiu-gencode ]; then
cp /tmp/jiu-gencode /opt/jiu/backend/jiu-gencode
chmod +x /opt/jiu/backend/jiu-gencode
if [ -f /tmp/jiu-gencode.sh ]; then
cp /tmp/jiu-gencode.sh /opt/jiu/backend/jiu-gencode.sh
chmod +x /opt/jiu/backend/jiu-gencode.sh
fi
fi
cp /tmp/nginx-jiu.conf /etc/nginx/conf.d/jiu.conf
nginx -t && systemctl reload nginx
echo "Ali server staged (binary swapped, jiu.service left stopped, nginx reloaded)"
ENDSSH
else
# --- EC2 (live primary) ---
${SSH} "${TARGET_USER}@${TARGET_HOST}" << 'ENDSSH'
set -e set -e
# Replace backend binary # Replace backend binary
@@ -72,7 +118,8 @@ docker exec pangolin-edge nginx -t && docker exec pangolin-edge nginx -s reload
echo "Server deploy complete!" echo "Server deploy complete!"
ENDSSH ENDSSH
fi
teardown_ssh teardown_ssh
rm -rf /tmp/jiu-configs rm -rf /tmp/jiu-configs
echo "==> deploy-server: done" echo "==> deploy-server: done (target=${DEPLOY_TARGET})"
+9 -6
View File
@@ -1,13 +1,16 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# deploy-site.sh <tag> — deploy the Eleventy marketing site to EC2 # deploy-site.sh <tag> — deploy the Eleventy marketing site to /opt/jiu/marketing.
# (/opt/jiu/marketing). Does NOT restart the backend or touch nginx. # Does NOT restart the backend or touch nginx. Target is EC2 by default; set
# DEPLOY_HOST/DEPLOY_USER/DEPLOY_SSH_KEY to retarget the Ali box (migration).
set -euo pipefail set -euo pipefail
# shellcheck source=scripts/ci/lib-forgejo.sh # shellcheck source=scripts/ci/lib-forgejo.sh
. "$(dirname "$0")/lib-forgejo.sh" . "$(dirname "$0")/lib-forgejo.sh"
TAG="$1" TAG="$1"
echo "==> deploy-site: tag=${TAG}" TARGET_HOST="${DEPLOY_HOST:-$EC2_HOST}"
TARGET_USER="${DEPLOY_USER:-$EC2_USER}"
echo "==> deploy-site: tag=${TAG} host=${TARGET_HOST}"
if [ ! -f dist/marketing.tar.gz ]; then if [ ! -f dist/marketing.tar.gz ]; then
download_release_assets "$TAG" download_release_assets "$TAG"
@@ -21,11 +24,11 @@ mkdir -p /tmp/jiu-marketing-new
tar -xzf dist/marketing.tar.gz -C /tmp/jiu-marketing-new tar -xzf dist/marketing.tar.gz -C /tmp/jiu-marketing-new
setup_ssh setup_ssh
echo "==> deploy-site: uploading marketing site to EC2" echo "==> deploy-site: uploading marketing site to ${TARGET_HOST}"
rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no" \ rsync -avz --delete -e "ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no" \
/tmp/jiu-marketing-new/ "${EC2_USER}@${EC2_HOST}:/tmp/jiu-marketing-new/" /tmp/jiu-marketing-new/ "${TARGET_USER}@${TARGET_HOST}:/tmp/jiu-marketing-new/"
${SSH} "${EC2_USER}@${EC2_HOST}" << 'ENDSSH' ${SSH} "${TARGET_USER}@${TARGET_HOST}" << 'ENDSSH'
set -e set -e
mkdir -p /opt/jiu/marketing mkdir -p /opt/jiu/marketing
rsync -a --delete /tmp/jiu-marketing-new/ /opt/jiu/marketing/ rsync -a --delete /tmp/jiu-marketing-new/ /opt/jiu/marketing/
+12 -3
View File
@@ -106,12 +106,21 @@ for a in rel.get('assets', []):
PYEOF PYEOF
} }
# setup_ssh — write the EC2 deploy key and export SSH / SCP commands. # setup_ssh — write the deploy key and export SSH / SCP commands.
# Target is the EC2 host by default; set DEPLOY_HOST / DEPLOY_USER / DEPLOY_SSH_KEY
# to retarget another host (e.g. the Alibaba Cloud box during the EC2→Ali migration).
# Backward-compatible: with no DEPLOY_* set, behaves exactly as before (EC2).
setup_ssh() { setup_ssh() {
local host="${DEPLOY_HOST:-$EC2_HOST}"
local key="${DEPLOY_SSH_KEY:-$EC2_SSH_KEY}"
mkdir -p ~/.ssh mkdir -p ~/.ssh
printf '%s' "${EC2_SSH_KEY}" > ~/.ssh/ec2_deploy.pem # `printf '%s\n'` 末尾补一个换行:Forgejo 存 secret 会去掉结尾换行,而
# OpenSSH 格式私钥(-----BEGIN OPENSSH PRIVATE KEY-----,如阿里 ed25519 部署 key
# 缺结尾换行会被判为 "invalid format" 而拒绝加载,退化成无密钥 → Permission denied。
# 多补的换行对已含结尾换行的 PEM(如 EC2 key)无害。
printf '%s\n' "${key}" > ~/.ssh/ec2_deploy.pem
chmod 600 ~/.ssh/ec2_deploy.pem chmod 600 ~/.ssh/ec2_deploy.pem
ssh-keyscan -H "${EC2_HOST}" >> ~/.ssh/known_hosts 2>/dev/null ssh-keyscan -H "${host}" >> ~/.ssh/known_hosts 2>/dev/null
SSH="ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no" SSH="ssh -i ~/.ssh/ec2_deploy.pem -o StrictHostKeyChecking=no"
SCP="scp -O -i ~/.ssh/ec2_deploy.pem" SCP="scp -O -i ~/.ssh/ec2_deploy.pem"
} }