Compare commits

..

1 Commits

Author SHA1 Message Date
3ceeacee63 应付款终版导入:支持 20 万行(流式读取 + 分片导入 + 进度显示)
同步导入在 20 万行规模下必然超时/爆内存:新增 xlsx/csv 流式读取器(共享字符串紧凑存放、支持日期单元格、GBK CSV),上传时把工作簿转成行缓存,之后按片处理(每片 2 万行/25 秒预算),前端显示进度并自动续跑;落库改为批量 upsert + 批量审计事件;上限提升到 20 万行,上传限制放宽到 50MB。
2026-10-16 23:53:33 +08:00
589 changed files with 4567 additions and 104865 deletions

View File

@ -10,7 +10,6 @@ runtime
*.md
JetBrainsMono-字体
node_modules
**/__pycache__
vendor
bootstrap/cache/*
.caiwu-build

View File

@ -38,14 +38,8 @@ jobs:
- name: Copy .env
run: cp .env.testing .env
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version-file: '.nvmrc'
cache: 'npm'
- name: Install NPM
run: npm ci --legacy-peer-deps --no-audit --no-fund
run: npm install
- name: Compile assets
run: npm run production

1
.gitignore vendored
View File

@ -113,7 +113,6 @@ modules/*
.workbuddy/
runtime/.env
/.private/kaifa-progress-sync/
__pycache__/
public/net-layer-demo.html
public/perf-diff-plan.html
public/plan-a-changes-202608.csv

View File

@ -173,7 +173,7 @@
"refersTo": "button-primary",
"description": "浅色内容区的主要操作,使用可读深金而不是亮金。",
"html": "<button type=\"button\" class=\"ds-btn-primary\">查询</button>",
"css": ".ds-btn-primary { height: 34px; padding: 0 14px; border: 1px solid #856632; border-radius: 8px; background: #856632; color: #fff; font: 500 13px/1.5 'JetBrains Mono', 'CaiWu CJK', monospace; cursor: pointer; transition: background 150ms cubic-bezier(.22,1,.36,1), border-color 150ms cubic-bezier(.22,1,.36,1); } .ds-btn-primary:hover { background: #6A5129; border-color: #6A5129; } .ds-btn-primary:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(133,102,50,.20); } .ds-btn-primary:active { transform: translateY(1px); } .ds-btn-primary:disabled { opacity: .55; cursor: not-allowed; transform: none; }"
"css": ".ds-btn-primary { height: 34px; padding: 0 14px; border: 1px solid #856632; border-radius: 8px; background: #856632; color: #fff; font: 500 13px/1.5 'JetBrains Mono', 'Microsoft YaHei', monospace; cursor: pointer; transition: background 150ms cubic-bezier(.22,1,.36,1), border-color 150ms cubic-bezier(.22,1,.36,1); } .ds-btn-primary:hover { background: #6A5129; border-color: #6A5129; } .ds-btn-primary:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(133,102,50,.20); } .ds-btn-primary:active { transform: translateY(1px); } .ds-btn-primary:disabled { opacity: .55; cursor: not-allowed; transform: none; }"
},
{
"name": "Secondary Button",
@ -181,7 +181,7 @@
"refersTo": "button-secondary",
"description": "次要操作保持白底、暖灰边界和正文墨色。",
"html": "<button type=\"button\" class=\"ds-btn-secondary\">重置</button>",
"css": ".ds-btn-secondary { height: 34px; padding: 0 14px; border: 1px solid #D8D2C7; border-radius: 8px; background: #fff; color: #171717; font: 500 13px/1.5 'JetBrains Mono', 'CaiWu CJK', monospace; cursor: pointer; transition: background 150ms cubic-bezier(.22,1,.36,1), border-color 150ms cubic-bezier(.22,1,.36,1); } .ds-btn-secondary:hover { border-color: #A17D3E; background: #FBF8F1; } .ds-btn-secondary:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(133,102,50,.20); } .ds-btn-secondary:active { background: #F7F0DE; } .ds-btn-secondary:disabled { opacity: .55; cursor: not-allowed; }"
"css": ".ds-btn-secondary { height: 34px; padding: 0 14px; border: 1px solid #D8D2C7; border-radius: 8px; background: #fff; color: #171717; font: 500 13px/1.5 'JetBrains Mono', 'Microsoft YaHei', monospace; cursor: pointer; transition: background 150ms cubic-bezier(.22,1,.36,1), border-color 150ms cubic-bezier(.22,1,.36,1); } .ds-btn-secondary:hover { border-color: #A17D3E; background: #FBF8F1; } .ds-btn-secondary:focus-visible { outline: none; box-shadow: 0 0 0 3px rgba(133,102,50,.20); } .ds-btn-secondary:active { background: #F7F0DE; } .ds-btn-secondary:disabled { opacity: .55; cursor: not-allowed; }"
},
{
"name": "Text Field",
@ -189,7 +189,7 @@
"refersTo": "input-default",
"description": "紧凑、清晰、具有可见标签和深金焦点状态的业务输入框。",
"html": "<label class=\"ds-field\"><span class=\"ds-field-label\">订单号</span><input class=\"ds-field-input\" type=\"text\" placeholder=\"请输入订单号\"></label>",
"css": ".ds-field { display: inline-flex; min-width: 220px; flex-direction: column; gap: 5px; color: #171717; font-family: 'JetBrains Mono', 'CaiWu CJK', monospace; } .ds-field-label { color: #595959; font-size: 12px; font-weight: 500; line-height: 16px; } .ds-field-input { height: 34px; padding: 0 10px; border: 1px solid #D8D2C7; border-radius: 8px; background: #fff; color: #171717; font: 400 13px/1.5 inherit; outline: none; transition: border-color 150ms cubic-bezier(.22,1,.36,1), box-shadow 150ms cubic-bezier(.22,1,.36,1); } .ds-field-input::placeholder { color: #595959; } .ds-field-input:hover { border-color: #AFA99E; } .ds-field-input:focus { border-color: #856632; box-shadow: 0 0 0 3px rgba(133,102,50,.20); } .ds-field-input:disabled { background: #EEE9DF; color: #595959; cursor: not-allowed; }"
"css": ".ds-field { display: inline-flex; min-width: 220px; flex-direction: column; gap: 5px; color: #171717; font-family: 'JetBrains Mono', 'Microsoft YaHei', monospace; } .ds-field-label { color: #595959; font-size: 12px; font-weight: 500; line-height: 16px; } .ds-field-input { height: 34px; padding: 0 10px; border: 1px solid #D8D2C7; border-radius: 8px; background: #fff; color: #171717; font: 400 13px/1.5 inherit; outline: none; transition: border-color 150ms cubic-bezier(.22,1,.36,1), box-shadow 150ms cubic-bezier(.22,1,.36,1); } .ds-field-input::placeholder { color: #595959; } .ds-field-input:hover { border-color: #AFA99E; } .ds-field-input:focus { border-color: #856632; box-shadow: 0 0 0 3px rgba(133,102,50,.20); } .ds-field-input:disabled { background: #EEE9DF; color: #595959; cursor: not-allowed; }"
},
{
"name": "Sidebar Active Item",
@ -197,7 +197,7 @@
"refersTo": "sidebar-item-active",
"description": "浅黑功能栏上的单一圆形 chevron 活动状态。",
"html": "<div class=\"ds-sidebar\"><a class=\"ds-sidebar-item is-active\" href=\"#\"><svg class=\"ds-sidebar-icon\" viewBox=\"0 0 24 24\" aria-hidden=\"true\"><path d=\"M4 4h16v16H4zM8 9h8M8 13h8M8 17h5\"/></svg><span>店铺收款账户账单</span><span class=\"ds-active-indicator\" aria-hidden=\"true\">›</span></a></div>",
"css": ".ds-sidebar { width: 224px; padding: 8px; background: #1D1D1F; } .ds-sidebar-item { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 4px; color: #F0E4BF; font: 500 12px/16px 'JetBrains Mono', 'CaiWu CJK', monospace; text-decoration: none; transition: background 150ms cubic-bezier(.22,1,.36,1); } .ds-sidebar-item:hover { background: rgba(240,228,191,.10); } .ds-sidebar-item.is-active { background: #2A2927; color: #F2E7C5; } .ds-sidebar-icon { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; } .ds-active-indicator { display: inline-flex; width: 16px; height: 16px; margin-left: auto; align-items: center; justify-content: center; border: 1px solid rgba(246,233,194,.72); border-radius: 999px; background: rgba(246,233,194,.14); color: #F6E9C2; font-size: 14px; } .ds-sidebar-item:focus-visible { outline: 2px solid #F2E7C5; outline-offset: 2px; }"
"css": ".ds-sidebar { width: 224px; padding: 8px; background: #1D1D1F; } .ds-sidebar-item { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: 4px; color: #F0E4BF; font: 500 12px/16px 'JetBrains Mono', 'Microsoft YaHei', monospace; text-decoration: none; transition: background 150ms cubic-bezier(.22,1,.36,1); } .ds-sidebar-item:hover { background: rgba(240,228,191,.10); } .ds-sidebar-item.is-active { background: #2A2927; color: #F2E7C5; } .ds-sidebar-icon { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; } .ds-active-indicator { display: inline-flex; width: 16px; height: 16px; margin-left: auto; align-items: center; justify-content: center; border: 1px solid rgba(246,233,194,.72); border-radius: 999px; background: rgba(246,233,194,.14); color: #F6E9C2; font-size: 14px; } .ds-sidebar-item:focus-visible { outline: 2px solid #F2E7C5; outline-offset: 2px; }"
},
{
"name": "Workspace Tabs",
@ -205,7 +205,7 @@
"refersTo": "tab-active",
"description": "白色工作区内使用深金活动文字和单一金色下划线的局部标签切换。",
"html": "<nav class=\"ds-tabs\" aria-label=\"账户类型\"><a class=\"ds-tab is-active\" aria-current=\"page\" href=\"#\">淘系支付宝账户</a><a class=\"ds-tab\" href=\"#\">淘系微信账户</a></nav>",
"css": ".ds-tabs { display: flex; align-items: center; gap: 36px; min-height: 34px; padding: 0 32px; overflow-x: auto; border: 1px solid #D8D2C7; border-radius: 16px; background: #fff; } .ds-tab { position: relative; display: inline-flex; min-height: 34px; align-items: center; color: #595959; font: 600 14px/1.25 'JetBrains Mono', 'CaiWu CJK', monospace; text-decoration: none; white-space: nowrap; transition: color 150ms cubic-bezier(.22,1,.36,1); } .ds-tab:hover, .ds-tab.is-active { color: #856632; } .ds-tab.is-active::after { position: absolute; right: 0; bottom: 0; left: 0; height: 3px; border-radius: 999px 999px 0 0; background: #B9954F; content: ''; } .ds-tab:focus-visible { outline: 2px solid #856632; outline-offset: 3px; }"
"css": ".ds-tabs { display: flex; align-items: center; gap: 36px; min-height: 34px; padding: 0 32px; overflow-x: auto; border: 1px solid #D8D2C7; border-radius: 16px; background: #fff; } .ds-tab { position: relative; display: inline-flex; min-height: 34px; align-items: center; color: #595959; font: 600 14px/1.25 'JetBrains Mono', 'Microsoft YaHei', monospace; text-decoration: none; white-space: nowrap; transition: color 150ms cubic-bezier(.22,1,.36,1); } .ds-tab:hover, .ds-tab.is-active { color: #856632; } .ds-tab.is-active::after { position: absolute; right: 0; bottom: 0; left: 0; height: 3px; border-radius: 999px 999px 0 0; background: #B9954F; content: ''; } .ds-tab:focus-visible { outline: 2px solid #856632; outline-offset: 3px; }"
},
{
"name": "Business Panel",
@ -213,21 +213,21 @@
"refersTo": "panel",
"description": "使用白色表面、暖灰边界和有限圆角的单层业务容器。",
"html": "<section class=\"ds-panel\"><h2 class=\"ds-panel-title\">账单明细</h2><p class=\"ds-panel-copy\">按账户和日期范围核对交易数据。</p></section>",
"css": ".ds-panel { padding: 16px; border: 1px solid #D8D2C7; border-radius: 12px; background: #fff; color: #171717; font-family: 'JetBrains Mono', 'CaiWu CJK', monospace; } .ds-panel-title { margin: 0; color: #171717; font-size: 14px; font-weight: 600; line-height: 1.25; } .ds-panel-copy { max-width: 70ch; margin: 8px 0 0; color: #595959; font-size: 13px; line-height: 1.5; } .ds-panel:focus-within { border-color: #856632; }"
"css": ".ds-panel { padding: 16px; border: 1px solid #D8D2C7; border-radius: 12px; background: #fff; color: #171717; font-family: 'JetBrains Mono', 'Microsoft YaHei', monospace; } .ds-panel-title { margin: 0; color: #171717; font-size: 14px; font-weight: 600; line-height: 1.25; } .ds-panel-copy { max-width: 70ch; margin: 8px 0 0; color: #595959; font-size: 13px; line-height: 1.5; } .ds-panel:focus-within { border-color: #856632; }"
},
{
"name": "Status Tags",
"kind": "chip",
"description": "语义颜色只表达真实业务状态,并始终带文字。",
"html": "<div class=\"ds-tags\"><span class=\"ds-tag ds-tag-success\">已核对</span><span class=\"ds-tag ds-tag-warning\">待处理</span><span class=\"ds-tag ds-tag-danger\">异常</span></div>",
"css": ".ds-tags { display: flex; gap: 8px; font-family: 'JetBrains Mono', 'CaiWu CJK', monospace; } .ds-tag { display: inline-flex; min-height: 24px; align-items: center; padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 500; } .ds-tag-success { background: #F1F6EE; color: #53793E; } .ds-tag-warning { background: #FEF5E7; color: #935F07; } .ds-tag-danger { background: #FAE6E6; color: #B80000; }"
"css": ".ds-tags { display: flex; gap: 8px; font-family: 'JetBrains Mono', 'Microsoft YaHei', monospace; } .ds-tag { display: inline-flex; min-height: 24px; align-items: center; padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 500; } .ds-tag-success { background: #F1F6EE; color: #53793E; } .ds-tag-warning { background: #FEF5E7; color: #935F07; } .ds-tag-danger { background: #FAE6E6; color: #B80000; }"
},
{
"name": "Finance Data Table",
"kind": "custom",
"description": "紧凑、可扫描、数字右对齐的财务数据表。支持列显示/隐藏、拖动排序、恢复默认及按用户和具体表格保存;冻结多层表头,横向滚动条始终可达。导出明确筛选范围和可见列顺序,保留精度并排除密码;允许导入的业务提供模板、预览、校验及失败反馈,只读镜像不写回。",
"html": "<table class=\"ds-table\"><thead><tr><th>订单号</th><th class=\"ds-num\">收入</th><th class=\"ds-num\">余额</th></tr></thead><tbody><tr><td>20260722001</td><td class=\"ds-num ds-income\">¥1,280.00</td><td class=\"ds-num\">¥8,460.00</td></tr></tbody></table>",
"css": ".ds-table { width: 100%; border-collapse: collapse; overflow: hidden; border: 1px solid #D8D2C7; border-radius: 12px; background: #fff; color: #171717; font: 400 12.5px/1.5 'JetBrains Mono', 'CaiWu CJK', monospace; } .ds-table th { padding: 9px 10px; border-bottom: 1px solid #D8D2C7; background: #FBF8F1; color: #595959; font-size: 12px; font-weight: 600; text-align: left; } .ds-table td { padding: 9px 10px; border-bottom: 1px solid #EEE9DF; } .ds-table tbody tr:hover { background: #F7F0DE; } .ds-table .ds-num { text-align: right; font-variant-numeric: tabular-nums; } .ds-table .ds-income { color: #B80000; font-weight: 600; }"
"css": ".ds-table { width: 100%; border-collapse: collapse; overflow: hidden; border: 1px solid #D8D2C7; border-radius: 12px; background: #fff; color: #171717; font: 400 12.5px/1.5 'JetBrains Mono', 'Microsoft YaHei', monospace; } .ds-table th { padding: 9px 10px; border-bottom: 1px solid #D8D2C7; background: #FBF8F1; color: #595959; font-size: 12px; font-weight: 600; text-align: left; } .ds-table td { padding: 9px 10px; border-bottom: 1px solid #EEE9DF; } .ds-table tbody tr:hover { background: #F7F0DE; } .ds-table .ds-num { text-align: right; font-variant-numeric: tabular-nums; } .ds-table .ds-income { color: #B80000; font-weight: 600; }"
}
],
"narrative": {
@ -236,7 +236,7 @@
"keyCharacteristics": [
"深黑工具轨道、浅黑功能栏、浅色内容区,三层边界清楚。",
"浅金用于深色导航,深金用于浅色内容区的文字与主要操作。",
"全站使用 JetBrains Mono 与仓库内置 CaiWu CJK 字体,标题和数据采用固定字号,不使用流体放大。",
"全站使用 JetBrains Mono,标题和数据采用固定字号,不使用流体放大。",
"页面标题位于内容区右侧,保持 14px;导航使用统一的紧凑字号。",
"一、二级导航统一 12px;同一层级的图标、字号和交互状态必须一致。",
"表格、筛选器和状态反馈优先,装饰性卡片、阴影和动画受到严格限制。",
@ -250,7 +250,6 @@
{ "name": "The Fixed-Scale Rule", "body": "产品界面使用固定 rem 字号,不使用 clamp() 放大标题;桌面与移动端通过布局重排适配,不通过夸张缩放字体适配。", "section": "typography" },
{ "name": "The Stable-First-Paint Rule", "body": "关键字号、行高和容器尺寸必须由首屏 CSS 直接提供,不能等待 JavaScript 或异步样式后再缩小,禁止首次进入时出现巨大字体闪烁。", "section": "typography" },
{ "name": "The One-Family Rule", "body": "按钮、标签、导航、表格和正文都使用同一字体家族;禁止在数据界面引入装饰性展示字体。", "section": "typography" },
{ "name": "The Cross-Platform Control Rule", "body": "业务控件不得依赖 macOS、Windows 或不同浏览器的原生外观。字体使用仓库内置 Web 字体;下拉框、复选框、日期选择、联想输入使用项目自绘控件;桌面筛选区使用明确 CSS 网格。新页面必须在 Safari 与 Chromium 下验证布局、换行和控件尺寸一致。", "section": "typography" },
{ "name": "The Flat-by-Default Rule", "body": "静态业务面板默认无阴影;若已经有 1px 边框,不得再叠加 16px 以上模糊半径的宽阴影。", "section": "elevation" },
{ "name": "The No-Nested-Cards Rule", "body": "卡片只用于真实分组;卡片内部不能再套同样外观的卡片。优先使用标题、间距、分隔线和表格分组表达层级。", "section": "elevation" }
],

1
.nvmrc
View File

@ -1 +0,0 @@
24.22.1

View File

@ -38,15 +38,6 @@
- 数据页面按本次影响验证代表性数据量下的首页、翻页和筛选耗时;明显长时间等待不能以“返回正确”判定通过。低风险改动不增加重复或仅复述实现的测试。
- 明确区分:本地已验证、已提交/推送、已合并、已上线和仍需生产验收。
### 性能与资源验收(强制)
- 涉及查询、报表、导入导出、同步或后台任务时,开发前先确认实际数据规模、单次数据量、文件大小和运行频率,并据此选择实现与验收数据;不能只验证几十行样例。
- Excel/CSV 必须流式读取;表扫描、关联和排序使用合适索引,页面数据使用分页。分批写库只控制写入批次,不能据此声称读取过程内存受控。
- 重型任务复用项目现有独立批处理容器,完成命令登记,并明确内存上限、超时、互斥和失败恢复;不得占用网页服务资源执行全量处理。
- 禁止通过取消 `memory_limit`、无限重试、放大数据库参数或其他绕过资源边界的办法掩盖性能问题。
- 使用代表性数据测量耗时、进程峰值内存和数据库开销,并在交付说明中写明测量范围、数据量及未验证项。生产环境不运行压测,不随意重跑全量任务。
- 性能优化不得改变金额口径、筛选范围或现有业务结果;相关金额和功能回归必须同时通过。
## 5. 前端与视觉
- 只有前端任务需要先阅读根目录 `DESIGN.md`;`.impeccable/design.json` 是同源机器规范。
@ -73,14 +64,12 @@
## 8. 构建与生产部署
- 发布脚本会拒绝覆盖“不在本次版本里”的线上提交(防止互相冲掉已上线的改动)。热修复必须基于线上当前版本构建,发布后立即合入 `main`;遇到拦截先 `git fetch` 并合入最新 `main` 或线上热修复分支,不得随手加 `-AllowRollback`(`--allow-rollback`),撤回线上发布需负责人明确决定。
- 普通代码发布由任务 Codex 在完成验证与合并后直接执行,不要求用户再次说“部署、上线”;用户明确要求仅本地开发、仅审查或暂不上线时遵循其范围。正式发布只使用已合并的最新 `main`。
- Windows 开发机统一使用 `scripts/deploy-from-workstation.ps1`,macOS 开发机使用 `bash scripts/deploy-from-mac.sh`;两者流程一致(本机构建前端与镜像、迁移拦截、增量推送服务器本机 Registry、激活与健康检查、双版本保留)。服务器只运行开发机已构建的 `caiwu_akaunting:<commit>` 镜像,禁止在服务器执行 Composer、npm 或 Docker 构建。
- 新人只需表达部署需求;Codex 使用默认生产入口运行脚本。首次通过本机窗口提供一次服务器密码后,加密保存在本人电脑供后续自动使用,不要求新人准备私钥、执行命令或每次重输密码。已有私钥认证仍兼容。
- 当前生产入口为 `https://caiwu.jiansuntuzhi.cn:8442`;公网仍只使用 `8442`,日常发布不得改成无端口地址。
- 发布脚本检测到新增数据库迁移时,由 Codex 核对全部待执行迁移。仅涉及本任务共享镜像建表、字段、索引和同步登记的迁移,完成备份后自主使用 `-AllowMigrations`(macOS 为 `--allow-migrations`);其他迁移仍按第 6 节确认授权。不能因需要迁移开关就把镜像接入停给负责人。
- 容器内 Laravel 命令使用 `www-data`。发布后只需确认部署 SHA、应用容器健康、受影响的真实页面/接口、迁移是否执行,以及当前和上一版镜像可回滚。
- 生产重型 CLI 导入统一使用 `sh /opt/caiwuxitong/run-finance-batch.sh <命令> <原参数>`,不得在网页容器内直接执行或取消 PHP 内存限制。新增导入登记 `deploy/finance-batch-imports.json`;入口提供独立容器、互斥、资源与时间上限,主应用保留自己的内存边界。
- 程序静态资源随应用镜像同源发布;七牛只存用户上传的业务图片、票据和附件。
- 只有本地初始化、服务器维护、生产发布或数据库迁移任务需要读取 `docs/开发与部署SOP.md` 的对应章节;普通功能开发无需加载整份 SOP。

View File

@ -22,22 +22,22 @@ colors:
info: "#006395"
typography:
title:
fontFamily: "JetBrains Mono, CaiWu CJK, monospace"
fontFamily: "JetBrains Mono, Microsoft YaHei, PingFang SC, monospace"
fontSize: "0.875rem"
fontWeight: 600
lineHeight: 1.25
body:
fontFamily: "JetBrains Mono, CaiWu CJK, monospace"
fontFamily: "JetBrains Mono, Microsoft YaHei, PingFang SC, monospace"
fontSize: "0.8125rem"
fontWeight: 400
lineHeight: 1.5
label:
fontFamily: "JetBrains Mono, CaiWu CJK, monospace"
fontFamily: "JetBrains Mono, Microsoft YaHei, PingFang SC, monospace"
fontSize: "0.75rem"
fontWeight: 500
lineHeight: 1.333
data:
fontFamily: "JetBrains Mono, CaiWu CJK, monospace"
fontFamily: "JetBrains Mono, Microsoft YaHei, PingFang SC, monospace"
fontSize: "0.78125rem"
fontWeight: 400
lineHeight: 1.5
@ -180,9 +180,9 @@ CaiWuXiTong 是一个需要长时间、高频率使用的财务工作台。视
## 3. Typography
**Display Font:** JetBrains Mono (with bundled CaiWu CJK, monospace)
**Display Font:** JetBrains Mono (with Microsoft YaHei, PingFang SC, monospace)
**Body Font:** JetBrains Mono (with bundled CaiWu CJK, monospace)
**Body Font:** JetBrains Mono (with Microsoft YaHei, PingFang SC, monospace)
**Label/Mono Font:** JetBrains Mono
@ -206,8 +206,6 @@ CaiWuXiTong 是一个需要长时间、高频率使用的财务工作台。视
**The One-Family Rule.** 按钮、标签、导航、表格和正文都使用同一字体家族;禁止在数据界面引入装饰性展示字体。
**The Cross-Platform Control Rule.** 业务控件不得依赖 macOS、Windows 或不同浏览器的原生外观。字体使用仓库内置 Web 字体;下拉框、复选框、日期选择、联想输入使用项目自绘控件;桌面筛选区使用明确 CSS 网格。新页面必须在 Safari 与 Chromium 下验证布局、换行和控件尺寸一致。
## 4. Elevation
界面默认采用“色调分层 + 细边界”,不是依赖大阴影堆叠。工具轨道、功能栏和内容区通过背景明度形成第一层深度;内容面板通过白色表面与细边界形成第二层。阴影只用于浮层或短暂交互反馈。
@ -265,7 +263,6 @@ CaiWuXiTong 是一个需要长时间、高频率使用的财务工作台。视
- 标准高度 34px,8px 圆角,白底、1px 边界;标签位于字段上方,12px/500。
- 聚焦使用深金边框和半透明焦点环;占位文字至少使用辅助墨色,不允许低对比浅灰。
- 错误状态使用语义危险色、明确文字和错误图标;不能只把边框染红。
- 不使用原生 `datalist`、系统日期框或未归一的系统下拉外观承载业务交互;关联选项使用项目自绘菜单或勾选面板,避免 macOS 与 Windows 显示不同。
### Cards / Containers

View File

@ -1,169 +0,0 @@
<?php
namespace App\Business;
/**
* 经营报表页面顶部的小格子指标。
*
* 汇总卡片只描述当前表本身,不把其他目录的数据拼成跨表大看板。
*/
class BusinessSummaryCards
{
/**
* @param array<string,mixed> $summary
* @return list<array{label:string,value:mixed,hint:string,format:string,query:array<string,mixed>,tone:string}>
*/
public function forPage(string $key, array $summary = []): array
{
return match ($key) {
'store-management/store-report' => $summary === [] ? [] : $this->storeReportCards($summary),
'company-management/subject-operating-report' => $summary === [] ? [] : $this->subjectOperatingReportCards($summary),
'store-management/internal-flow-report' => $summary === [] ? [] : $this->internalFlowReportCards($summary),
'company-management/company-cost-arrangement' => $this->companyCostArrangementCards(),
'company-management/tax-forecast' => $this->taxForecastCards(),
'company-management/personnel-inventory' => $this->personnelInventoryCards(),
default => [],
};
}
/** @param array<string,mixed> $summary */
private function subjectOperatingReportCards(array $summary): array
{
$ready = (bool) ($summary['company_status_ready'] ?? false);
$previousLabel = (string) ($summary['previous_month_label'] ?? '上月');
$currentLabel = (string) ($summary['current_month_label'] ?? '当月');
$previousStores = (int) ($summary['previous_income_entities'] ?? 0);
$currentStores = (int) ($summary['current_income_entities'] ?? 0);
if (! $ready) {
return [
$this->card('正常存续主体', '—', '公司资料镜像未就绪,暂时无法剔除注销主体', [], 'text', 'warning'),
$this->card('上月收入', '—', '公司资料镜像未就绪', [], 'text', 'warning'),
$this->card('当月收入', '—', '公司资料镜像未就绪', [], 'text', 'warning'),
$this->card('小规模纳税人', '—', '公司资料镜像未就绪', [], 'text'),
$this->card('一般纳税人', '—', '公司资料镜像未就绪', [], 'text'),
];
}
return [
$this->card('正常存续主体', $summary['normal'] ?? 0, '只统计公司台账标签为保留的主体', ['company_status' => '正常'], 'integer', 'accent'),
$this->card($previousLabel . '收入', $summary['previous_income'] ?? 0, $previousStores . ' 个存续主体有收入', [
'company_status' => '正常',
'sort' => $summary['previous_month_key'] ?? null,
'direction' => 'desc',
], 'amount', 'success'),
$this->card($currentLabel . '收入', $summary['current_month_income'] ?? 0, $currentStores . ' 个存续主体有收入', [
'company_status' => '正常',
'sort' => $summary['current_month_key'] ?? null,
'direction' => 'desc',
], 'amount', 'warning'),
$this->card('小规模纳税人', $summary['small_taxpayer'] ?? 0, '正常存续主体中的小规模纳税人', [
'company_status' => '正常',
'tax_type' => '小规模纳税人',
]),
$this->card('一般纳税人', $summary['general_taxpayer'] ?? 0, '正常存续主体中的一般纳税人', [
'company_status' => '正常',
'tax_type' => '一般纳税人',
]),
];
}
/** @param array<string,mixed> $summary */
private function storeReportCards(array $summary): array
{
$previousLabel = (string) ($summary['previous_month_label'] ?? '上月');
$currentLabel = (string) ($summary['current_month_label'] ?? '当月');
$normalStores = (int) ($summary['normal_stores'] ?? 0);
$previousStores = (int) ($summary['previous_month_stores'] ?? 0);
$currentStores = (int) ($summary['current_month_stores'] ?? 0);
return [
$this->card('正常存续店铺', $normalStores, '只统计开店中店铺,不含关店和已注销', ['status' => '0'], 'integer', 'accent'),
$this->card($previousLabel . '销量', $summary['previous_month_sales'] ?? 0, $previousStores . ' 家存续店铺有销量', [
'status' => '0',
'sort' => $summary['previous_month_key'] ?? null,
'direction' => 'desc',
], 'amount', 'success'),
$this->card($currentLabel . '销量', $summary['current_month_sales'] ?? 0, $currentStores . ' 家存续店铺有销量', [
'status' => '0',
'sort' => $summary['current_month_key'] ?? null,
'direction' => 'desc',
], 'amount', 'warning'),
];
}
/** @param array<string,mixed> $summary */
private function internalFlowReportCards(array $summary): array
{
return [
$this->card('公司数', $summary['companies'] ?? 0, '当前筛选内内部流转公司', [], 'integer', 'accent'),
$this->card('预计销量合计', $summary['projected_sales'] ?? 0, '当前筛选内预计销量合计', [
'sort' => 'projected_sales',
'direction' => 'desc',
], 'amount'),
$this->card('已收票合计', $summary['receipt_total'] ?? 0, '供应商、关联公司与内部流转收票合计', [
'sort' => 'receipt_total',
'direction' => 'desc',
], 'amount', 'success'),
$this->card('剩余缺口合计', $summary['remaining_gap'] ?? 0, '金额越大越应优先处理', [
'sort' => 'remaining_gap',
'direction' => 'desc',
], 'amount', 'warning'),
$this->card('平均开票比例', $summary['average_invoicing_ratio'] ?? 0, '按有比例的公司简单平均', [], 'percent'),
];
}
private function companyCostArrangementCards(): array
{
return [
$this->card('数据状态', '待接入', '当前表尚无业务数据', [], 'text', 'warning'),
$this->card('完整表头', '11 列', '包含序号,收入、成本、税金、人工等字段全部保留', [], 'text'),
$this->card('核心结果', '预计结余 / 缺口', '当月收入预测减去成本与费用', [], 'text'),
$this->card('缺口提示', '负值标红', '成本或费用未覆盖收入时提醒', [], 'text', 'danger'),
$this->card('配平提示', '零或正结余标绿', '表示当月预算已覆盖', [], 'text', 'success'),
];
}
private function taxForecastCards(): array
{
return [
$this->card('数据状态', '待取数', '规则已定义,业务数据尚未接入', [], 'text', 'warning'),
$this->card('完整表头', '22 列', '包含序号、征管口径、收入、税额与备注', [], 'text'),
$this->card('计算税种', '4 类', '增值税、附加税、印花税、所得税', [], 'text'),
$this->card('计税口径', '3 项', '公司税务类型、工商类型和征收方式', [], 'text'),
$this->card('当前记录', '0 条', '数据接入后按完整字段展示', [], 'text'),
];
}
private function personnelInventoryCards(): array
{
return [
$this->card('数据状态', '待接入', '当前表尚无人员数据', [], 'text', 'warning'),
$this->card('完整表头', '17 列', '包含序号和全部人员、容量字段', [], 'text'),
$this->card('敏感字段', '4 项', '密码、身份证、实名手机和身份证地址只显示维护状态', [], 'text', 'danger'),
$this->card('容量规则', '3 档', '可担任公司数量按风险档位提示', [], 'text'),
$this->card('当前记录', '0 条', '接入后按正常在职人员汇总', [], 'text'),
];
}
/**
* @return array{label:string,value:mixed,hint:string,format:string,query:array<string,mixed>,tone:string}
*/
private function card(
string $label,
mixed $value,
string $hint,
array $query = [],
string $format = 'integer',
string $tone = 'neutral',
): array {
return [
'label' => $label,
'value' => $value,
'hint' => $hint,
'format' => $format,
'query' => ['page' => null, ...$query],
'tone' => $tone,
];
}
}

View File

@ -1,237 +0,0 @@
<?php
namespace App\Business;
use App\Models\Business\InventoryWarehouseNameMapping;
use DateTimeImmutable;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\IOFactory;
use RuntimeException;
class InventoryInboundDetailImporter
{
private const TABLE = 'inventory_inbound_detail_rows';
/**
* Replace the imported inbound detail snapshot only after the workbook has
* been opened successfully. The delete and inserts share one transaction,
* so a malformed file cannot leave the page with a partially imported table.
*
* @return array{imported:int,mapped:int,skipped:int,total:int}
*/
public function import(string $file, string $forcedMonth = ''): array
{
$path = realpath($file);
if ($path === false || ! is_file($path) || ! is_readable($path)) {
throw new RuntimeException('入库明细文件不存在或不可读取。');
}
$forcedMonth = $this->normalizeForcedMonth($forcedMonth);
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$now = now();
$maps = $this->shortNameMap();
$reader = IOFactory::createReaderForFile($path);
$reader->setReadDataOnly(true);
$spreadsheet = $reader->load($path);
$sheet = $spreadsheet->getSheet(0);
try {
return DB::connection($connection)->transaction(function () use (
$connection,
$sheet,
$maps,
$forcedMonth,
$now,
): array {
DB::connection($connection)->table(self::TABLE)->delete();
$batch = [];
$imported = 0;
$mapped = 0;
$skipped = 0;
foreach ($sheet->getRowIterator(2) as $row) {
$rowNumber = $row->getRowIndex();
$cells = [];
foreach ($row->getCellIterator('A', 'U') as $cell) {
$cells[] = $cell->getFormattedValue();
}
$warehouse = trim((string) ($cells[14] ?? ''));
$createdRaw = trim((string) ($cells[16] ?? ''));
if ($warehouse === '' && $createdRaw === '') {
$skipped++;
continue;
}
$month = $forcedMonth !== '' ? $forcedMonth : $this->monthOf($createdRaw);
if ($month === '') {
$skipped++;
continue;
}
// 代发仓按仓储方匹配;其它仓库按仓库名称做唯一匹配。
$shortName = '';
if ($warehouse !== '') {
$shortName = str_contains($warehouse, '代发仓')
? ($maps['provider'][$warehouse] ?? $maps['warehouse'][$warehouse] ?? '')
: ($maps['warehouse'][$warehouse] ?? '');
}
if ($shortName !== '') {
$mapped++;
}
$batch[] = [
'report_month' => $month,
'row_key' => sha1(substr($month, 0, 7) . '|' . $rowNumber),
'source_no' => $this->text($cells[0] ?? ''),
'document_no' => $this->text($cells[1] ?? ''),
'created_serial' => $this->text($cells[2] ?? ''),
'supplier' => $this->text($cells[3] ?? ''),
'item_no' => $this->text($cells[4] ?? ''),
'barcode' => $this->text($cells[5] ?? ''),
'spec_name' => $this->text($cells[6] ?? ''),
'quantity' => $this->number($cells[7] ?? ''),
'unit_price' => $this->number($cells[8] ?? ''),
'amount' => $this->number($cells[9] ?? ''),
'estimated_unit_price' => $this->number($cells[10] ?? ''),
'estimated_amount' => $this->number($cells[11] ?? ''),
'supplier_barcode' => $this->text($cells[12] ?? ''),
'remark' => $this->text($cells[13] ?? ''),
'warehouse' => $warehouse !== '' ? $warehouse : null,
'warehouse_short_name' => $shortName !== '' ? $shortName : null,
'created_at_raw' => $this->moment($createdRaw),
'express_name' => $this->text($cells[17] ?? ''),
'express_no' => $this->text($cells[18] ?? ''),
'not_sent' => $this->text($cells[19] ?? ''),
'owner_name' => $this->text($cells[20] ?? ''),
'imported_at' => $now,
];
$imported++;
if (count($batch) >= 1000) {
DB::connection($connection)->table(self::TABLE)->insertOrIgnore($batch);
$batch = [];
}
}
if ($batch !== []) {
DB::connection($connection)->table(self::TABLE)->insertOrIgnore($batch);
}
if ($imported === 0) {
throw new RuntimeException('入库明细文件中没有可导入的数据。');
}
return [
'imported' => $imported,
'mapped' => $mapped,
'skipped' => $skipped,
'total' => DB::connection($connection)->table(self::TABLE)->count(),
];
});
} finally {
$spreadsheet->disconnectWorksheets();
}
}
private function normalizeForcedMonth(string $month): string
{
$month = trim($month);
if ($month === '') {
return '';
}
if (preg_match('/^\d{4}-\d{2}$/', $month) === 1) {
return $month . '-01';
}
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $month) === 1) {
return $month;
}
throw new RuntimeException('指定月份格式应为 YYYY-MM。');
}
/** @return array{warehouse:array<string,string>,provider:array<string,string>} */
private function shortNameMap(): array
{
$rows = InventoryWarehouseNameMapping::withoutGlobalScopes()
->get(['warehouse_provider', 'warehouse', 'warehouse_short_name']);
$grouped = [];
foreach ($rows as $row) {
$warehouse = trim((string) $row->warehouse);
if ($warehouse === '') {
continue;
}
$grouped[$warehouse][] = trim((string) $row->warehouse_short_name);
}
$byWarehouse = [];
foreach ($grouped as $warehouse => $names) {
$unique = array_values(array_unique($names));
if (count($unique) === 1 && $unique[0] !== '') {
$byWarehouse[$warehouse] = $unique[0];
}
}
$byProvider = [];
foreach ($rows as $row) {
$provider = trim((string) $row->warehouse_provider);
if ($provider !== '') {
$byProvider[$provider][] = trim((string) $row->warehouse_short_name);
}
}
foreach ($byProvider as $provider => $names) {
$unique = array_values(array_unique($names));
if (count($unique) === 1 && $unique[0] !== '') {
$byProvider[$provider] = $unique[0];
}
}
return ['warehouse' => $byWarehouse, 'provider' => $byProvider];
}
private function monthOf(string $value): string
{
$value = trim($value);
if ($value === '') {
return '';
}
foreach (['Y/m/d H:i:s', 'Y-m-d H:i:s', 'Y/m/d', 'Y-m-d'] as $format) {
$parsed = DateTimeImmutable::createFromFormat($format, $value);
if ($parsed instanceof DateTimeImmutable) {
return $parsed->format('Y-m') . '-01';
}
}
$timestamp = strtotime($value);
return $timestamp === false ? '' : date('Y-m-01', $timestamp);
}
private function moment(string $value): ?string
{
$value = trim($value);
if ($value === '') {
return null;
}
$timestamp = strtotime($value);
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
}
private function text(mixed $value): ?string
{
$text = trim((string) $value);
return $text === '' ? null : $text;
}
private function number(mixed $value): ?float
{
$text = str_replace(',', '', trim((string) $value));
if ($text === '' || ! is_numeric($text)) {
return null;
}
return (float) $text;
}
}

View File

@ -15,16 +15,11 @@ use RuntimeException;
* 两个数据源:
* - 共享镜像 ops_jst_orders(常规,覆盖镜像有数据的区间);
* - 聚水潭「导出订单」Excel(镜像没有的历史区间,例如 2026-03,只能靠财务导出补)。
*
* 卖家备注包含「SSDD」时同时生成 SSDD 标签。卖家备注不是聚水潭系统标签,
* 但种菜判断需要把这类订单纳入标签明细。
*/
class JstOrderLabelImporter
{
public const NO_LABEL = '(无标签)';
public const SELLER_REMARK_LABEL = 'SSDD';
private const READ_CHUNK = 2000;
private const INSERT_CHUNK = 2000;
@ -32,8 +27,6 @@ class JstOrderLabelImporter
/** 聚水潭导出文件里用到的表头。 */
private const EXPORT_COLUMNS = ['线上订单号', '店铺名称', '下单时间', '标签'];
private const EXPORT_OPTIONAL_COLUMNS = ['卖家备注'];
public function __construct(
private readonly DatabaseManager $databases,
private readonly ?XlsxSheetReader $exportReader = null,
@ -70,7 +63,6 @@ class JstOrderLabelImporter
->where('ordered_at', '>=', $start)
->where('ordered_at', '<', $end)
->delete();
app(PlantingOrderLabelLookup::class)->forget($companyId);
}
$now = now();
@ -79,10 +71,9 @@ class JstOrderLabelImporter
$noLabelOrders = 0;
$buffer = [];
$flush = function () use (&$buffer, &$rows, $target, $companyId): void {
$flush = function () use (&$buffer, &$rows, $target): void {
foreach (array_chunk($buffer, self::INSERT_CHUNK) as $chunk) {
$target->table('jst_order_labels')->insert($chunk);
app(PlantingOrderLabelLookup::class)->forget($companyId);
$rows += count($chunk);
}
$buffer = [];
@ -105,10 +96,7 @@ class JstOrderLabelImporter
continue;
}
$labels = $this->labelsForRecord(
(string) ($record->labels ?? ''),
(string) ($record->remark ?? ''),
);
$labels = $this->splitLabels((string) ($record->labels ?? ''));
if ($labels === []) {
$labels = [self::NO_LABEL];
$noLabelOrders++;
@ -203,7 +191,6 @@ class JstOrderLabelImporter
->where('ordered_at', '>=', $start)
->where('ordered_at', '<', $end)
->delete();
app(PlantingOrderLabelLookup::class)->forget($companyId);
};
$cleared = ! $replace;
@ -216,10 +203,9 @@ class JstOrderLabelImporter
$orderSeen = [];
$buffer = [];
$flush = function () use (&$buffer, &$rows, $target, $companyId): void {
$flush = function () use (&$buffer, &$rows, $target): void {
foreach (array_chunk($buffer, self::INSERT_CHUNK) as $chunk) {
$target->table('jst_order_labels')->insert($chunk);
app(PlantingOrderLabelLookup::class)->forget($companyId);
$rows += count($chunk);
}
$buffer = [];
@ -231,12 +217,9 @@ class JstOrderLabelImporter
// 明细行带标签;不按订单判断就会给本来有标签的订单多写一行「(无标签)」。
$labeledOrders = [];
$anyRow = false;
foreach ($reader->rows($path, self::EXPORT_COLUMNS, self::EXPORT_OPTIONAL_COLUMNS) as $line) {
foreach ($reader->rows($path, self::EXPORT_COLUMNS) as $line) {
$anyRow = true;
if ($this->labelsForRecord(
(string) ($line['标签'] ?? ''),
(string) ($line['卖家备注'] ?? ''),
) === []) {
if ($this->splitLabels((string) ($line['标签'] ?? '')) === []) {
continue;
}
$orderNo = trim((string) ($line['线上订单号'] ?? ''));
@ -254,7 +237,7 @@ class JstOrderLabelImporter
}
// 第二遍:按「线上订单号 + 标签」去重后写入。
foreach ($reader->rows($path, self::EXPORT_COLUMNS, self::EXPORT_OPTIONAL_COLUMNS) as $line) {
foreach ($reader->rows($path, self::EXPORT_COLUMNS) as $line) {
$orderNo = trim((string) ($line['线上订单号'] ?? ''));
$orderedAt = $this->normalizeOrderedAt((string) ($line['下单时间'] ?? ''));
if ($orderNo === '' || $orderedAt === null) {
@ -268,10 +251,7 @@ class JstOrderLabelImporter
continue;
}
$labels = $this->labelsForRecord(
(string) ($line['标签'] ?? ''),
(string) ($line['卖家备注'] ?? ''),
);
$labels = $this->splitLabels((string) ($line['标签'] ?? ''));
$withoutLabel = $labels === [];
if ($withoutLabel) {
if (isset($labeledOrders[$orderNo])) {
@ -366,18 +346,6 @@ class JstOrderLabelImporter
return array_values(array_unique($out));
}
/** @return list<string> */
private function labelsForRecord(string $labels, string $sellerRemark): array
{
$labels = $this->splitLabels($labels);
if (stripos($sellerRemark, self::SELLER_REMARK_LABEL) !== false
&& ! in_array(self::SELLER_REMARK_LABEL, $labels, true)) {
$labels[] = self::SELLER_REMARK_LABEL;
}
return $labels;
}
private function normalizeDate(string $value, string $label): string
{
$value = trim($value);

View File

@ -82,7 +82,6 @@ class JstOrderLabelPlantingRules
public function forget(int $companyId): void
{
Cache::forget($this->key($companyId));
app(PlantingOrderLabelLookup::class)->forget($companyId);
}
private function key(int $companyId): string

View File

@ -1,141 +0,0 @@
<?php
namespace App\Business;
use App\Models\Business\LaborExpenseSnapshot;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class LaborExpenseDataStore
{
public const MAX_ROWS = 2000;
/**
* @return array{rows: array<int, array<string, mixed>>, row_count: int, source_name: ?string, saved_by: ?int, saved_at: ?string}
*/
public function load(): array
{
$snapshot = LaborExpenseSnapshot::query()
->where('company_id', company_id())
->first();
return [
'rows' => is_array($snapshot?->rows) ? array_values($snapshot->rows) : [],
'row_count' => (int) ($snapshot?->row_count ?? 0),
'source_name' => $snapshot?->source_name,
'saved_by' => $snapshot?->saved_by,
'saved_at' => $snapshot?->saved_at?->toIso8601String(),
];
}
/**
* @param array<int, mixed> $rows
* @return array{rows: array<int, array<string, mixed>>, row_count: int, source_name: ?string, saved_by: ?int, saved_at: string}
*/
public function save(array $rows, ?string $sourceName, ?int $savedBy): array
{
if (count($rows) > self::MAX_ROWS) {
throw ValidationException::withMessages([
'rows' => '单次最多保存 ' . number_format(self::MAX_ROWS) . ' 行,请拆分后再保存。',
]);
}
$fields = collect(config('labor_expense_table.fields', []))
->filter(static fn (array $field): bool => ($field['kind'] ?? null) !== 'sequence'
&& blank($field['formula'] ?? null))
->values();
if ($fields->isEmpty()) {
throw ValidationException::withMessages([
'rows' => '人工费用表没有可保存的录入列。',
]);
}
$companyId = (int) company_id();
if ($companyId < 1) {
throw ValidationException::withMessages([
'rows' => '当前没有可保存的公司。',
]);
}
$normalizedRows = [];
foreach (array_values($rows) as $index => $row) {
if (! is_array($row)) {
throw ValidationException::withMessages([
'rows' => '第 ' . ($index + 1) . ' 行数据格式无效。',
]);
}
$record = [];
$hasValue = false;
foreach ($fields as $field) {
$value = $this->normalizeCell($row[$field['key']] ?? null, $field, $index + 1);
$record[$field['key']] = $value;
$hasValue = $hasValue || $value !== '';
}
if ($hasValue) {
$normalizedRows[] = $record;
}
}
$sourceName = trim((string) $sourceName);
$sourceName = $sourceName === '' ? null : mb_substr($sourceName, 0, 191);
$savedAt = now();
DB::transaction(function () use ($companyId, $sourceName, $savedBy, $savedAt, $normalizedRows): void {
LaborExpenseSnapshot::query()->updateOrCreate(
['company_id' => $companyId],
[
'source_name' => $sourceName,
'rows' => $normalizedRows,
'row_count' => count($normalizedRows),
'saved_by' => $savedBy,
'saved_at' => $savedAt,
],
);
});
return [
'rows' => $normalizedRows,
'row_count' => count($normalizedRows),
'source_name' => $sourceName,
'saved_by' => $savedBy,
'saved_at' => $savedAt->toIso8601String(),
];
}
private function normalizeCell(mixed $value, array $field, int $line): mixed
{
if ($value === null) {
return '';
}
$text = trim((string) $value);
if ($text === '') {
return '';
}
if (! in_array($field['kind'] ?? 'text', ['number', 'percent'], true)) {
if (mb_strlen($text) > 120) {
throw ValidationException::withMessages([
'rows' => "第 {$line} 行“{$field['label']}”不能超过 120 个字符。",
]);
}
return $text;
}
$isPercentText = ($field['kind'] ?? null) === 'percent' && str_ends_with($text, '%');
$number = str_replace([',', ',', '¥', '¥', '%'], '', $text);
if (! is_numeric($number)) {
throw ValidationException::withMessages([
'rows' => "第 {$line} 行“{$field['label']}”不是有效数字。",
]);
}
$normalized = (float) $number;
return $isPercentText ? $normalized / 100 : $normalized;
}
}

View File

@ -1,177 +0,0 @@
<?php
namespace App\Business;
use Illuminate\Http\UploadedFile;
use Illuminate\Validation\ValidationException;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Throwable;
class LaborExpenseWorkbookImporter
{
private const MAX_DATA_ROWS = 2000;
/**
* @return array{rows: array<int, array<string, mixed>>, row_count: int, blank_count: int}
*/
public function import(UploadedFile $file): array
{
$fields = collect(config('labor_expense_table.fields', []))
->filter(static fn (array $field): bool => ($field['kind'] ?? null) !== 'sequence'
&& blank($field['formula'] ?? null));
if ($fields->isEmpty()) {
throw ValidationException::withMessages([
'import' => '人工费用表没有可导入的录入列。',
]);
}
$lookup = [];
foreach ($fields as $field) {
$lookup[$this->normalizeHeader($field['key'])] = $field['key'];
$lookup[$this->normalizeHeader($field['label'])] = $field['key'];
}
$spreadsheet = null;
try {
$readerType = match (mb_strtolower($file->getClientOriginalExtension())) {
'xlsx' => 'Xlsx',
'xls' => 'Xls',
'csv' => 'Csv',
default => throw new \RuntimeException('Unsupported workbook format.'),
};
$reader = IOFactory::createReader($readerType);
if (method_exists($reader, 'setReadDataOnly')) {
$reader->setReadDataOnly(true);
}
$spreadsheet = $reader->load($file->getRealPath());
[$sheetRows, $headerRow, $columns] = $this->bestSheet($spreadsheet, $lookup);
} catch (Throwable) {
throw ValidationException::withMessages([
'import' => '无法读取导入文件,请确认文件未损坏且格式为 xlsx、xls 或 csv。',
]);
} finally {
if ($spreadsheet) {
$spreadsheet->disconnectWorksheets();
}
}
if ($sheetRows === null || $columns === []) {
throw ValidationException::withMessages([
'import' => '未找到当前人工费用表的可录入列,请使用页面“导出全部字段”生成的文件作为模板。',
]);
}
$fieldMap = $fields->keyBy('key');
$rows = [];
$blankCount = 0;
foreach (array_slice($sheetRows, $headerRow + 1, null, true) as $rowIndex => $row) {
$record = [];
$hasValue = false;
foreach ($columns as $columnIndex => $fieldKey) {
$field = $fieldMap[$fieldKey];
$value = $this->normalizeCell(
$row[$columnIndex] ?? null,
$field['kind'] ?? 'text',
(int) $rowIndex + 1,
$field['label'] ?? $fieldKey,
);
$record[$fieldKey] = $value;
$hasValue = $hasValue || $value !== '';
}
if (! $hasValue) {
$blankCount++;
continue;
}
$rows[] = $record;
if (count($rows) > self::MAX_DATA_ROWS) {
throw ValidationException::withMessages([
'import' => '单次最多导入 ' . number_format(self::MAX_DATA_ROWS) . ' 行,请拆分文件后重试。',
]);
}
}
if ($rows === []) {
throw ValidationException::withMessages([
'import' => '表格中没有可导入的数据行。',
]);
}
return [
'rows' => $rows,
'row_count' => count($rows),
'blank_count' => $blankCount,
];
}
/**
* @return array{0: array<int, array<int, mixed>>|null, 1: int|null, 2: array<int, string>}
*/
private function bestSheet(mixed $spreadsheet, array $lookup): array
{
$best = [null, null, []];
$bestScore = 0;
foreach ($spreadsheet->getWorksheetIterator() as $worksheet) {
$rows = $worksheet->toArray(null, false, false, false);
foreach (array_slice($rows, 0, 30, true) as $rowIndex => $row) {
$columns = [];
foreach ($row as $columnIndex => $heading) {
$key = $this->normalizeHeader($heading);
if ($key !== '' && isset($lookup[$key])) {
$columns[$columnIndex] = $lookup[$key];
}
}
$score = count(array_unique(array_values($columns)));
if ($score > $bestScore) {
$best = [$rows, (int) $rowIndex, $columns];
$bestScore = $score;
}
}
}
return $best;
}
private function normalizeCell(mixed $value, string $kind, int $line, string $label): mixed
{
if ($value === null) {
return '';
}
$text = trim((string) $value);
if ($text === '') {
return '';
}
if (! in_array($kind, ['number', 'percent'], true)) {
return $text;
}
$isPercentText = $kind === 'percent' && str_ends_with($text, '%');
$number = str_replace([',', ',', '¥', '¥', '%'], '', $text);
if (! is_numeric($number)) {
throw ValidationException::withMessages([
'import' => "第 {$line} 行“{$label}”不是有效数字。",
]);
}
$normalized = (float) $number;
return $isPercentText ? $normalized / 100 : $normalized;
}
private function normalizeHeader(mixed $value): string
{
return mb_strtolower(preg_replace('/[\s\x{FEFF}_\-()()*::]+/u', '', trim((string) $value)));
}
}

View File

@ -1,68 +0,0 @@
<?php
namespace App\Business;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/** Reuse label classification across monthly/platform jobs; writers invalidate it. */
class PlantingOrderLabelLookup
{
/** @param list<string> $positiveLabels @return list<string> */
public function orderNumbers(int $companyId, array $positiveLabels): array
{
sort($positiveLabels, SORT_STRING);
$rules = hash('sha256', json_encode($positiveLabels, JSON_THROW_ON_ERROR));
$key = $this->key($companyId);
$read = function () use ($key, $rules, $companyId): ?array {
$cached = Cache::get($key);
return is_array($cached) && ($cached['rules'] ?? null) === $rules
&& ($cached['revision'] ?? null) === $this->revision($companyId)
? $cached['orders'] : null;
};
if (($orders = $read()) !== null) {
return $orders;
}
return Cache::lock($key . ':build', 300)->block(305, function () use ($companyId, $positiveLabels, $key, $rules, $read): array {
if (($orders = $read()) !== null) {
return $orders;
}
$revision = $this->revision($companyId);
$orders = [];
foreach (DB::table('jst_order_labels')->where('company_id', $companyId)
->where(function ($query) use ($positiveLabels): void {
if ($positiveLabels !== []) {
$query->whereIn('label', $positiveLabels);
}
$query->orWhereRaw('FIND_IN_SET(?, item_code) > 0', [JstOrderLabelPlantingRules::PLANTING_ITEM_CODE]);
})->select('order_no')->distinct()->cursor() as $row) {
$orders[] = (string) $row->order_no;
}
// An import/rule change during the scan must not publish an old result.
if ($this->revision($companyId) === $revision) {
Cache::put($key, ['rules' => $rules, 'revision' => $revision, 'orders' => $orders], now()->addHours(24));
}
return $orders;
});
}
public function forget(int $companyId): void
{
$key = $this->key($companyId);
Cache::store('upstream_shared')->forever($key . ':revision', bin2hex(random_bytes(16)));
Cache::forget($key);
}
public function revision(int $companyId): ?string
{
return Cache::store('upstream_shared')->get($this->key($companyId) . ':revision');
}
private function key(int $companyId): string
{
return 'planting-order-labels:v1:' . $companyId;
}
}

View File

@ -1,261 +0,0 @@
<?php
namespace App\Business;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
/**
* 汇总商品月加权平均单价结果,并将入库明细差异按原因分类。
*/
class ProductMonthlyWeightedAverageReporter
{
// 异常阈值未由业务另行指定:取至少 100 行,且不超过总行数的 0.1%。
private const ANOMALY_THRESHOLD_MIN_ROWS = 100;
private const ANOMALY_THRESHOLD_RATE = 0.001;
private const ROW_TABLE = 'product_monthly_weighted_average_rows';
private const STATE_TABLE = 'product_monthly_weighted_average_states';
private const INBOUND_TABLE = 'inventory_inbound_detail_rows';
private const INVENTORY_MONTH_TABLE = 'inventory_report_authoritative_months';
private const INVENTORY_ROW_TABLE = 'inventory_report_authoritative_rows';
/** @return array<string,mixed> */
public function build(string $connection, CarbonImmutable $month): array
{
$month = $month->startOfMonth();
$db = DB::connection($connection);
$baseQuery = static fn () => $db->table(self::ROW_TABLE)
->where('report_month', $month->toDateString());
$totals = $baseQuery()
->selectRaw('count(*) as total_rows')
->selectRaw('coalesce(sum(opening_quantity), 0) as opening_quantity')
->selectRaw('coalesce(sum(opening_amount), 0) as opening_amount')
->selectRaw('coalesce(sum(purchase_quantity), 0) as purchase_quantity')
->selectRaw('coalesce(sum(purchase_amount), 0) as purchase_amount')
->selectRaw('sum(case when weighted_average_unit_price is not null then 1 else 0 end) as weighted_price_rows')
->selectRaw('sum(case when weighted_average_unit_price is null then 1 else 0 end) as missing_weighted_rows')
->selectRaw('sum(case when pricing_status = ? then 1 else 0 end) as manual_review_rows', ['待人工核查'])
->first();
if ($totals === null || (int) $totals->total_rows === 0) {
return [
'status' => 'missing',
'report_month' => $month->toDateString(),
'message' => '该月份尚未生成商品月加权平均单价。',
'generated_at' => now()->toDateTimeString(),
];
}
$weightedKeys = [];
foreach ($baseQuery()->select('product_code', 'warehouse_short_name')->cursor() as $row) {
$weightedKeys[$this->key($row->product_code, $row->warehouse_short_name)] = true;
}
$inbound = [
'source_rows' => 0,
'source_quantity' => 0.0,
'source_amount' => 0.0,
'matched_rows' => 0,
'matched_quantity' => 0.0,
'matched_amount' => 0.0,
'unmatched_rows' => 0,
'unmatched_quantity' => 0.0,
'unmatched_amount' => 0.0,
'unmatched_groups' => [],
];
foreach ($db->table(self::INBOUND_TABLE)
->where('report_month', $month->toDateString())
->selectRaw('upper(barcode) as code, warehouse_short_name, count(*) as rows_count')
->selectRaw('coalesce(sum(quantity), 0) as quantity')
->selectRaw('coalesce(sum(coalesce(amount, 0) + coalesce(estimated_amount, 0)), 0) as amount')
->groupByRaw('upper(barcode), warehouse_short_name')
->cursor() as $row) {
$rows = (int) $row->rows_count;
$quantity = (float) $row->quantity;
$amount = (float) $row->amount;
$inbound['source_rows'] += $rows;
$inbound['source_quantity'] += $quantity;
$inbound['source_amount'] += $amount;
if (isset($weightedKeys[$this->key($row->code, $row->warehouse_short_name)])) {
$inbound['matched_rows'] += $rows;
$inbound['matched_quantity'] += $quantity;
$inbound['matched_amount'] += $amount;
continue;
}
$inbound['unmatched_rows'] += $rows;
$inbound['unmatched_quantity'] += $quantity;
$inbound['unmatched_amount'] += $amount;
$inbound['unmatched_groups'][] = [
'product_code' => trim((string) $row->code),
'warehouse_short_name' => trim((string) $row->warehouse_short_name),
'source_rows' => $rows,
'quantity' => $this->number($quantity),
'amount' => $this->number($amount),
'reason' => '入库明细未匹配到商品编码 + 仓库简称行',
];
}
usort(
$inbound['unmatched_groups'],
static fn (array $left, array $right): int =>
abs((float) $right['amount']) <=> abs((float) $left['amount']),
);
$inbound = $this->roundNestedNumbers($inbound);
$monthState = $db->table(self::INVENTORY_MONTH_TABLE)
->where('dataset_key', 'jiansun.inventory-report.authoritative-month')
->where('source_month', $month->toDateString())
->first();
$generation = $monthState?->active_generation;
$actualSourceRows = $generation === null
? 0
: $db->table(self::INVENTORY_ROW_TABLE)
->where('generation', (string) $generation)
->count();
$expectedSourceRows = (int) ($monthState?->source_row_count ?? 0);
$weightedAnomalyRows = $baseQuery()
->where(function ($query): void {
$query->whereNull('weighted_average_unit_price')
->orWhere('pricing_status', '待人工核查');
})
->count();
$reasons = [];
if ((int) $totals->missing_weighted_rows > 0) {
$reasons[] = [
'code' => 'missing_weighted_price',
'label' => '加权平均单价为空',
'rows' => (int) $totals->missing_weighted_rows,
];
}
if ((int) $totals->manual_review_rows > 0) {
$reasons[] = [
'code' => 'manual_review',
'label' => '待人工核查',
'rows' => (int) $totals->manual_review_rows,
];
}
if ((int) $inbound['unmatched_rows'] > 0) {
$reasons[] = [
'code' => 'inbound_without_inventory_dimension',
'label' => '入库明细未匹配到商品编码 + 仓库简称行',
'rows' => (int) $inbound['unmatched_rows'],
'groups' => count($inbound['unmatched_groups']),
];
}
if ($actualSourceRows !== $expectedSourceRows) {
$reasons[] = [
'code' => 'upstream_source_row_mismatch',
'label' => '上游进销存源表行数与已激活明细不一致',
'rows' => abs($actualSourceRows - $expectedSourceRows),
];
}
$anomalyRows = $weightedAnomalyRows + (int) $inbound['unmatched_rows'];
$threshold = max(
self::ANOMALY_THRESHOLD_MIN_ROWS,
(int) ceil((int) $totals->total_rows * self::ANOMALY_THRESHOLD_RATE),
);
return [
'status' => $reasons === [] ? 'computed' : 'review_required',
'report_month' => $month->toDateString(),
'totals' => [
'rows' => (int) $totals->total_rows,
'opening_quantity' => $this->number((float) $totals->opening_quantity),
'opening_amount' => $this->number((float) $totals->opening_amount),
'purchase_quantity' => $this->number((float) $totals->purchase_quantity),
'purchase_amount' => $this->number((float) $totals->purchase_amount),
'weighted_price_rows' => (int) $totals->weighted_price_rows,
'missing_weighted_rows' => (int) $totals->missing_weighted_rows,
'manual_review_rows' => (int) $totals->manual_review_rows,
],
'inbound' => $inbound,
'source' => [
'expected_rows' => $expectedSourceRows,
'active_rows' => $actualSourceRows,
'row_difference' => $actualSourceRows - $expectedSourceRows,
'status' => (string) ($monthState?->status ?? 'missing'),
'active_generation' => $generation,
'source_filename' => $monthState?->source_filename,
],
'anomalies' => [
'affected_rows' => $anomalyRows,
'threshold_rows' => $threshold,
'threshold_exceeded' => $anomalyRows > $threshold,
'reasons' => $reasons,
],
'state' => $db->table(self::STATE_TABLE)
->where('report_month', $month->toDateString())
->first(),
'generated_at' => now()->toDateTimeString(),
];
}
/** @param array<string,mixed> $report */
public function brief(array $report): string
{
if (($report['status'] ?? null) === 'missing') {
return (string) ($report['message'] ?? '商品月加权平均单价尚未生成');
}
$totals = $report['totals'] ?? [];
$inbound = $report['inbound'] ?? [];
$anomalies = $report['anomalies'] ?? [];
$reasonLabels = array_map(
static fn (array $reason): string =>
(string) ($reason['label'] ?? $reason['code'] ?? '未知异常'),
$anomalies['reasons'] ?? [],
);
return sprintf(
'%s:总行数 %s;期初 %s 件 / %s;入库 %s 件 / %s;加权单价 %s 行;异常 %s 行%s',
substr((string) ($report['report_month'] ?? ''), 0, 7),
number_format((int) ($totals['rows'] ?? 0)),
number_format((float) ($totals['opening_quantity'] ?? 0), 2),
number_format((float) ($totals['opening_amount'] ?? 0), 2),
number_format((float) ($inbound['matched_quantity'] ?? 0), 2),
number_format((float) ($inbound['matched_amount'] ?? 0), 2),
number_format((int) ($totals['weighted_price_rows'] ?? 0)),
number_format((int) ($anomalies['affected_rows'] ?? 0)),
$reasonLabels === [] ? '' : '(' . implode('、', $reasonLabels) . ')',
);
}
private function key(mixed $productCode, mixed $warehouseShortName): string
{
return mb_strtoupper(trim((string) $productCode))
. "\0"
. trim((string) $warehouseShortName);
}
private function number(float $value): float
{
return round($value, 6);
}
/**
* @param array<string,mixed> $value
* @return array<string,mixed>
*/
private function roundNestedNumbers(array $value): array
{
foreach ($value as $key => $item) {
if (is_array($item)) {
$value[$key] = $this->roundNestedNumbers($item);
} elseif (is_float($item)) {
$value[$key] = round($item, 6);
}
}
return $value;
}
}

View File

@ -1,95 +0,0 @@
<?php
namespace App\Business;
use Carbon\CarbonInterface;
use Illuminate\Database\DatabaseManager;
use Modules\EcommerceFinance\Repositories\BusinessInformationMirrorRepository;
use RuntimeException;
class StoreInformationDailyArchive
{
public const TABLE = 'store_information_daily_archives';
public function __construct(
private readonly BusinessInformationMirrorRepository $mirror,
private readonly UpstreamBusinessInformation $information,
private readonly DatabaseManager $database,
) {}
/**
* 保存指定日期的完整店铺基础信息快照;同一天重跑时覆盖当天版本。
*/
public function capture(CarbonInterface $snapshotDate): int
{
if (! $this->mirror->ready('store-information.read')) {
throw new RuntimeException('店铺基础信息共享镜像尚未完成首次同步。');
}
$rows = $this->information->storesWithAccountOwnership(
$this->mirror->allStores(),
true,
);
if ($rows === []) {
throw new RuntimeException('店铺基础信息共享镜像没有可存档的数据。');
}
$snapshotDateValue = $snapshotDate->format('Y-m-d');
$capturedAt = now('Asia/Shanghai')->format('Y-m-d H:i:s.u');
$values = [];
foreach ($rows as $row) {
$recordId = trim((string) ($row['id'] ?? ''));
if ($recordId === '') {
continue;
}
$values[] = [
'snapshot_date' => $snapshotDateValue,
'store_record_id' => mb_substr($recordId, 0, 64),
'erp_store_name' => mb_substr(trim((string) ($row['erp_store_name'] ?? '')), 0, 191),
'store_name' => $this->nullable($row['store_name'] ?? null, 191),
'company_name' => $this->nullable($row['company_name'] ?? null, 255),
'store_manager' => $this->nullable($row['store_manager'] ?? null, 191),
'department_manager' => $this->nullable($row['department_manager'] ?? null, 191),
'profit_center' => $this->nullable($row['profit_center'] ?? null, 191),
'store_status' => $this->nullable($row['store_status'] ?? null, 64),
'payload' => json_encode(
$row,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR,
),
'captured_at' => $capturedAt,
'created_at' => $capturedAt,
'updated_at' => $capturedAt,
];
}
if ($values === []) {
throw new RuntimeException('店铺基础信息缺少可用的店铺记录 ID,未写入快照。');
}
$connection = $this->database->connection(
(string) config('upstream_mirrors.connection', 'business_mirror'),
);
$connection->transaction(function () use ($connection, $values): void {
foreach (array_chunk($values, 250) as $chunk) {
$connection->table(self::TABLE)->upsert(
$chunk,
['snapshot_date', 'store_record_id'],
[
'erp_store_name', 'store_name', 'company_name',
'store_manager', 'department_manager', 'profit_center',
'store_status', 'payload', 'captured_at', 'updated_at',
],
);
}
});
return count($values);
}
private function nullable(mixed $value, int $limit): ?string
{
$text = trim((string) $value);
return $text === '' ? null : mb_substr($text, 0, $limit);
}
}

View File

@ -1,217 +0,0 @@
<?php
namespace App\Business;
use App\Http\Controllers\Business\Pages;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* 「业绩明细」差额规则的物化状态。
*
* 备注分类、有效差额、有/无差额原先都在查询时按同一套规则链逐行现算,整月
* 30 多万行需要十几到几十秒。store-receipt:materialize-differences 把结果写进
* store_receipt_order_match_rows 的 difference_reason_index / base_difference /
* has_difference 三列,页面改读列。
*
* 规则表达式始终取自 Pages 的静态方法(唯一口径),这里只负责:
* - 用规则表达式的指纹判断物化结果是否仍与当前代码一致;
* - 记录/读取每个月是否已物化。
*/
class StoreReceiptDifferenceMaterialization
{
public const REASON_INDEX_COLUMN = 'difference_reason_index';
public const BASE_DIFFERENCE_COLUMN = 'base_difference';
public const HAS_DIFFERENCE_COLUMN = 'has_difference';
private const TABLE = 'store_receipt_order_match_rows';
private const STATE_TABLE = 'store_receipt_order_match_materializations';
/** @var array<string,bool> */
private static array $memo = [];
public static function connection(): string
{
return (string) config('upstream_mirrors.connection', 'business_mirror');
}
public static function table(): string
{
return self::TABLE;
}
public static function stateTable(): string
{
return self::STATE_TABLE;
}
/**
* 当前规则表达式的指纹。规则改动后指纹变化,旧的物化结果会自动视为过期。
*/
public static function ruleVersion(): string
{
return md5(implode('|', [
Pages::receiptDefaultDifferenceSql(),
Pages::receiptDifferenceRemarkIndexSql(),
Pages::receiptEffectiveDifferenceSql(),
]));
}
/**
* 物化列与状态表是否已就绪(迁移是否执行过)。
*
* 结果缓存 10 分钟:schema 探测在共享镜像库上每次要 0.3~0.5 秒,
* 不能放在每个页面请求的热路径上。迁移执行后由 markMaterialized() 主动失效。
*/
public static function available(bool $fresh = false): bool
{
static $memo = null;
if (! $fresh) {
if ($memo !== null) {
return $memo;
}
return $memo = (bool) Cache::remember(
self::availabilityCacheKey(),
now()->addMinutes(10),
static fn (): bool => self::detectAvailability(),
);
}
$available = self::detectAvailability();
if ($available) {
Cache::put(self::availabilityCacheKey(), true, now()->addMinutes(10));
}
return $memo = $available;
}
private static function detectAvailability(): bool
{
try {
$schema = Schema::connection(self::connection());
return $schema->hasTable(self::TABLE)
&& $schema->hasTable(self::STATE_TABLE)
&& $schema->hasColumn(self::TABLE, self::REASON_INDEX_COLUMN)
&& $schema->hasColumn(self::TABLE, self::BASE_DIFFERENCE_COLUMN)
&& $schema->hasColumn(self::TABLE, self::HAS_DIFFERENCE_COLUMN);
} catch (\Throwable) {
return false;
}
}
private static function availabilityCacheKey(): string
{
return 'store-receipt-materialization-available:v1';
}
/**
* 该账期是否已经按当前规则物化完成。未物化时页面回退到实时规则表达式。
*/
public static function materialized(string $month): bool
{
$month = substr(trim($month), 0, 10);
if ($month === '' || ! self::available()) {
return false;
}
if (array_key_exists($month, self::$memo)) {
return self::$memo[$month];
}
$cacheKey = 'store-receipt-materialized:' . $month . ':' . self::ruleVersion();
return self::$memo[$month] = (bool) Cache::remember($cacheKey, now()->addMinutes(10), function () use ($month): bool {
$row = DB::connection(self::connection())
->table(self::STATE_TABLE)
->where('report_month', $month)
->first();
if ($row === null || (string) ($row->rule_version ?? '') !== self::ruleVersion()) {
return false;
}
// 过渡期保护:尚未部署新代码的同步任务重刷账期时不会写物化列,
// 一旦出现 NULL 就回退实时口径,避免页面把未物化的行当成 0 差额。
return ! DB::connection(self::connection())
->table(self::TABLE)
->where('report_month', $month)
->whereNull(self::HAS_DIFFERENCE_COLUMN)
->limit(1)
->exists();
});
}
/**
* 该账期的数据版本:物化后自增,页面聚合/联动统计的缓存键带上它即可精确失效。
*/
public static function dataVersion(string $month): string
{
return (string) Cache::remember(
self::dataVersionKey($month),
now()->addDays(30),
static fn (): int => 1,
);
}
public static function bumpDataVersion(string $month): void
{
$key = self::dataVersionKey($month);
$current = (int) Cache::get($key, 1);
Cache::put($key, $current + 1, now()->addDays(30));
}
private static function dataVersionKey(string $month): string
{
return 'store-receipt-data-version:' . substr(trim($month), 0, 10);
}
public static function markMaterialized(string $month, int $rowCount): void
{
$month = substr(trim($month), 0, 10);
$connection = self::connection();
DB::connection($connection)->table(self::STATE_TABLE)->updateOrInsert(
['report_month' => $month],
[
'rule_version' => self::ruleVersion(),
'row_count' => $rowCount,
'materialized_at' => now(),
],
);
Cache::forget('store-receipt-materialized:' . $month . ':' . self::ruleVersion());
Cache::forget(self::availabilityCacheKey());
self::bumpDataVersion($month);
unset(self::$memo[$month]);
}
public static function forget(string $month): void
{
$month = substr(trim($month), 0, 10);
Cache::forget('store-receipt-materialized:' . $month . ':' . self::ruleVersion());
unset(self::$memo[$month]);
}
/**
* 账期相邻月份(物化跨月备注规则时需要一起重算)。
*
* @return list<string>
*/
public static function adjacentMonths(string $month): array
{
$date = \Illuminate\Support\Carbon::parse(substr($month, 0, 10) . ' 00:00:00');
return [
$date->copy()->subMonthNoOverflow()->format('Y-m-01'),
$date->copy()->format('Y-m-01'),
$date->copy()->addMonthNoOverflow()->format('Y-m-01'),
];
}
}

View File

@ -1,84 +0,0 @@
<?php
namespace App\Business;
/**
* 平台数字 SKU → 历史已拆分内部商品编码。
*
* 平台 shop_sku_id 已包含平台规格维度;仅当同一 shop_sku_id + shop_i_id
* 的历史内部编码唯一时才允许替换,多候选保持待核验。
*/
class StoreReceiptPlatformSkuMapper
{
/** @var array<string,array<string,int>> */
private array $internalSkus = [];
/** @var array<string,array<string,int>> */
private array $internalProperties = [];
/** @var array<string,int> */
private array $historyRows = [];
/** @param iterable<object|array<string,mixed>> $historyRows */
public function __construct(iterable $historyRows)
{
foreach ($historyRows as $row) {
$shopSkuId = trim((string) $this->value($row, 'shop_sku_id'));
$shopItemId = trim((string) $this->value($row, 'shop_i_id'));
$skuId = trim((string) $this->value($row, 'sku_id'));
$itemId = trim((string) $this->value($row, 'i_id'));
if ($shopSkuId === '' || $shopItemId === '' || $skuId === '') {
continue;
}
// 平台原始行的 i_id 等于 shop_i_id;内部拆分行的 i_id 是内部款式编码。
if ($itemId === '' || $itemId === $shopItemId) {
continue;
}
$key = $this->key($shopSkuId, $shopItemId);
$this->internalSkus[$key][$skuId] = ($this->internalSkus[$key][$skuId] ?? 0) + 1;
$property = trim((string) $this->value($row, 'properties_value'));
if ($property !== '') {
$this->internalProperties[$key][$property] = ($this->internalProperties[$key][$property] ?? 0) + 1;
}
$this->historyRows[$key] = ($this->historyRows[$key] ?? 0) + 1;
}
}
/**
* @return array{
* status:string,
* internal_sku:?string,
* candidates:list<string>,
* history_rows:int,
* internal_properties:list<string>
* }
*/
public function resolve(string $shopSkuId, string $shopItemId): array
{
$key = $this->key(trim($shopSkuId), trim($shopItemId));
$skus = array_keys($this->internalSkus[$key] ?? []);
sort($skus);
$properties = array_keys($this->internalProperties[$key] ?? []);
sort($properties);
return [
'status' => count($skus) === 1 ? 'unique' : ($skus === [] ? 'missing' : 'ambiguous'),
'internal_sku' => count($skus) === 1 ? $skus[0] : null,
'candidates' => $skus,
'history_rows' => (int) ($this->historyRows[$key] ?? 0),
'internal_properties' => $properties,
];
}
private function key(string $shopSkuId, string $shopItemId): string
{
return $shopSkuId . "\0" . $shopItemId;
}
private function value(object|array $row, string $key): mixed
{
return is_array($row) ? ($row[$key] ?? null) : ($row->{$key} ?? null);
}
}

View File

@ -4,13 +4,11 @@ namespace App\Business;
use App\Models\Business\StoreReceivingAccount;
use Modules\EcommerceFinance\Repositories\BusinessInformationMirrorRepository;
use Modules\EcommerceFinance\Services\StoreReportPeriodBuilder;
class StoreReceivingAccountSynchronizer
{
public function __construct(
private readonly BusinessInformationMirrorRepository $mirror,
private readonly StoreReportPeriodBuilder $storeReportPeriods,
) {
}
@ -37,15 +35,6 @@ class StoreReceivingAccountSynchronizer
$byWallet = [];
$byPlatformAndName = [];
$byName = [];
$sourceStores = $this->mirror->receivingAccountStores();
// 店铺主体跟随店铺维度报表的“存续中”期间,不再直接取店铺档案当前主体。
$currentCompanies = $this->storeReportPeriods->currentCompanies(array_map(
static fn (array $row): array => [
'erp_store_name' => trim((string) ($row['erp_store_name'] ?? '')),
'system_company_name' => trim((string) ($row['company_name'] ?? '')),
],
$sourceStores,
));
foreach ($existing as $account) {
$platform = (string) $account->store_platform;
@ -60,7 +49,7 @@ class StoreReceivingAccountSynchronizer
}
}
foreach ($sourceStores as $row) {
foreach ($this->mirror->receivingAccountStores() as $row) {
$erpStoreName = trim((string) ($row['erp_store_name'] ?? ''));
$platform = $this->classify($erpStoreName, (string) ($row['source_platform'] ?? ''));
@ -86,8 +75,7 @@ class StoreReceivingAccountSynchronizer
'company_id' => $companyId,
'store_platform' => $platform,
'store_name' => $erpStoreName,
'store_entity_name' => $currentCompanies[$erpStoreName]
?? trim((string) ($row['company_name'] ?? '')),
'store_entity_name' => trim((string) ($row['company_name'] ?? '')),
'shop_name' => trim((string) ($row['shop_name'] ?? '')) ?: $erpStoreName,
'store_status' => $this->status($row['store_status'] ?? null),
'wallet_number' => $storeId,

View File

@ -23,7 +23,7 @@ class StoreViolationAlertCalculator
/** @var list<array<string,mixed>>|null */
private ?array $rules = null;
/** @var array<string,array{operation_owner:string,operation_department_head:string,operation_director:string,profit_center:string}>|null */
/** @var array<string,array{operation_owner:string,operation_director:string,profit_center:string}>|null */
private ?array $ownerMap = null;
/** @var array<string,?string> */
@ -236,8 +236,7 @@ class StoreViolationAlertCalculator
if ($this->ownerMap === null) {
$this->ownerMap = $this->storeAccounts->ownershipByStoreName();
}
$key = StoreAccountMirrorRepository::storeNameKey($name);
$owner = trim((string) ($this->ownerMap[$key]['operation_owner'] ?? ''));
$owner = trim((string) ($this->ownerMap[$name]['operation_owner'] ?? ''));
if ($owner !== '') {
return mb_substr($owner, 0, 64);
}

View File

@ -1,939 +0,0 @@
<?php
namespace App\Business;
use App\Models\Business\InventoryWarehouseNameMapping;
use DateTimeImmutable;
use DOMDocument;
use DOMXPath;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use RuntimeException;
use Throwable;
use XMLReader;
use ZipArchive;
/**
* 供应商汇总表月度增量导入。
*
* 仅追加目标月份的新行,不删除其他月份。内容相同的源明细按出现次序分配
* 稳定标识后全部保留;重复执行时只跳过已经写入的同一源行。
* 单行字段异常会进入失败清单,不阻断其他有效行。
*/
class SupplierSummaryInboundImporter
{
private const TABLE = 'inventory_inbound_detail_rows';
private const DETAIL_SHEET = '明细';
/** @var array<string,list<string>> */
private const REQUIRED_HEADERS = [
'source_no' => ['来源单号'],
'document_no' => ['单据编号(采购单号)', '单据编号(采购单号)', '单据编号'],
'supplier' => ['供应商'],
'item_no' => ['货号'],
'barcode' => ['条码'],
'spec_name' => ['规格名'],
'quantity' => ['库存变动量'],
'unit_price' => ['单价'],
'amount' => ['金额'],
'estimated_unit_price' => ['预估单价'],
'estimated_amount' => ['预估金额'],
'supplier_barcode' => ['供应商条码'],
'remark' => ['备注'],
'warehouse' => ['仓库'],
];
/** @var array<string,list<string>> */
private const OPTIONAL_HEADERS = [
'express_name' => ['快递名称'],
'express_no' => ['快递单号'],
'not_sent' => ['未发出'],
'owner_name' => ['负责人'],
];
/**
* @return array{
* status:string,
* report_month:string,
* source_filename:string,
* success:int,
* duplicates:int,
* failed:int,
* mapped:int,
* total_in_table:int,
* failure_reason_count:int,
* failure_reasons:list<array{row:int,reason:string}>
* }
*/
public function import(string $file, string $reportMonth): array
{
$path = realpath($file);
if ($path === false || ! is_file($path) || ! is_readable($path)) {
throw new RuntimeException('供应商汇总文件不存在或不可读取。');
}
$month = $this->normalizeMonth($reportMonth);
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$maps = $this->shortNameMap();
[$reader, $sharedStrings] = $this->streamDetailSheet($path);
try {
$success = 0;
$duplicates = 0;
$mapped = 0;
$failed = 0;
$reasons = [];
$occurrences = [];
$batch = [];
DB::connection($connection)->transaction(function () use (
$connection,
$reader,
$sharedStrings,
$maps,
$month,
&$success,
&$duplicates,
&$mapped,
&$failed,
&$reasons,
&$occurrences,
&$batch,
): void {
$flush = function () use (
$connection,
$month,
&$success,
&$duplicates,
&$batch,
): void {
if ($batch === []) {
return;
}
$keys = array_column($batch, 'row_key');
$existing = DB::connection($connection)
->table(self::TABLE)
->where('report_month', $month)
->whereIn('row_key', $keys)
->pluck('row_key')
->map(static fn (mixed $key): string => (string) $key)
->all();
$existing = array_fill_keys($existing, true);
$insertable = [];
foreach ($batch as $record) {
if (isset($existing[$record['row_key']])) {
$duplicates++;
continue;
}
$insertable[] = $record;
}
if ($insertable !== []) {
$inserted = DB::connection($connection)
->table(self::TABLE)
->insertOrIgnore($insertable);
$success += (int) $inserted;
$duplicates += count($insertable) - (int) $inserted;
}
$batch = [];
};
$headerRow = null;
$columns = [];
$headerCandidates = [];
while ($reader->read()) {
if (
$reader->nodeType !== XMLReader::ELEMENT
|| $reader->name !== 'row'
) {
continue;
}
$rowNumber = (int) $reader->getAttribute('r');
$values = $this->readRow($reader, $sharedStrings);
if ($headerRow === null) {
$headerCandidates[$rowNumber] = $values;
$header = $this->headerColumns($headerCandidates);
if ($header !== null) {
[$headerRow, $columns] = $header;
$headerCandidates = [];
continue;
}
if ($rowNumber >= 20) {
throw new RuntimeException(
'「明细」子表前 20 行未找到供应商汇总标准表头。',
);
}
continue;
}
if ($rowNumber <= $headerRow) {
continue;
}
if ($this->emptyRow($values)) {
continue;
}
try {
$record = $this->record(
$values,
$columns,
$maps,
$month,
$rowNumber,
);
} catch (Throwable $exception) {
$failed++;
$reasons[] = [
'row' => $rowNumber,
'reason' => $exception->getMessage(),
];
continue;
}
$baseKey = (string) $record['row_key'];
$occurrence = ($occurrences[$baseKey] ?? 0) + 1;
$occurrences[$baseKey] = $occurrence;
if ($occurrence > 1) {
$record['row_key'] = hash(
'sha256',
$baseKey . '|occurrence:' . $occurrence,
);
}
$mapped++;
$batch[] = $record;
if (count($batch) >= 500) {
$flush();
}
}
if ($headerRow === null) {
throw new RuntimeException(
'「明细」子表前 20 行未找到供应商汇总标准表头。',
);
}
$flush();
});
$status = $failed === 0
? 'success'
: ($success > 0 ? 'partial' : 'failed');
return [
'status' => $status,
'report_month' => $month,
'source_filename' => basename($path),
'success' => $success,
'duplicates' => $duplicates,
'failed' => $failed,
'mapped' => $mapped,
'total_in_table' => DB::connection($connection)
->table(self::TABLE)
->where('report_month', $month)
->count(),
'failure_reason_count' => count($reasons),
'failure_reasons' => $reasons,
];
} finally {
$reader->close();
}
}
private function normalizeMonth(string $month): string
{
if (preg_match('/^\d{4}-\d{2}$/', $month) !== 1) {
throw new RuntimeException('月份格式应为 YYYY-MM。');
}
return $month . '-01';
}
/**
* @param array<int,array<int,mixed>> $rows
* @return array{0:int,1:array<string,int>}|null
*/
private function headerColumns(array $rows): ?array
{
ksort($rows);
foreach ($rows as $row => $values) {
$positions = [];
foreach ($values as $index => $value) {
$heading = $this->normalizeHeader(
(string) $this->plainValue($value),
);
if ($heading !== '') {
$positions[$heading][] = $index;
}
}
$columns = [];
foreach (self::REQUIRED_HEADERS as $field => $aliases) {
$position = $this->firstHeaderPosition($positions, $aliases);
if ($position !== null) {
$columns[$field] = $position;
}
}
$createdPositions = $positions[$this->normalizeHeader('创建时间')] ?? [];
if (count($createdPositions) >= 2) {
$columns['created_serial'] = $createdPositions[0];
$columns['created_at_raw'] = $createdPositions[1];
}
if (count($columns) < count(self::REQUIRED_HEADERS) + 2) {
continue;
}
foreach (self::OPTIONAL_HEADERS as $field => $aliases) {
$position = $this->firstHeaderPosition($positions, $aliases);
if ($position !== null) {
$columns[$field] = $position;
}
}
foreach (['created_serial', 'created_at_raw'] as $field) {
if (! isset($columns[$field])) {
throw new RuntimeException('「明细」表头缺少第二个「创建时间」列。');
}
}
return [$row, $columns];
}
return null;
}
/** @param array<string,list<int>> $positions @param list<string> $aliases */
private function firstHeaderPosition(array $positions, array $aliases): ?int
{
foreach ($aliases as $alias) {
$heading = $this->normalizeHeader($alias);
if (isset($positions[$heading][0])) {
return $positions[$heading][0];
}
}
return null;
}
private function normalizeHeader(string $heading): string
{
return preg_replace('/[\s\x{3000}]+/u', '', trim($heading)) ?? trim($heading);
}
/**
* @param array<int,mixed> $values
* @param array<string,int> $columns
* @param array{warehouse:array<string,string>,provider:array<string,string>} $maps
* @return array<string,mixed>
*/
private function record(
array $values,
array $columns,
array $maps,
string $month,
int $rowNumber,
): array {
$text = fn (string $field): ?string => $this->text(
$values[$columns[$field] ?? -1] ?? null,
);
$number = function (string $label, string $field) use (
$values,
$columns,
$rowNumber,
): ?float {
$value = $values[$columns[$field] ?? -1] ?? null;
if (is_array($value)) {
$formula = trim((string) ($value['__formula'] ?? ''));
if ($formula !== '') {
return $this->formulaValue(
$label,
$field,
$formula,
$rowNumber,
$values,
$columns,
);
}
$value = $value['__cached'] ?? null;
}
$text = str_replace(
[',', ','],
'',
trim((string) $this->plainValue($value)),
);
if ($text === '') {
return null;
}
if (! is_numeric($text)) {
throw new RuntimeException("字段「{$label}」不是有效数字:" . $text);
}
return (float) $text;
};
$barcode = $text('barcode');
$warehouse = $text('warehouse');
if ($barcode === null) {
throw new RuntimeException('商品编码(条码)为空');
}
if ($warehouse === null) {
throw new RuntimeException('仓库为空');
}
$shortName = $maps['warehouse'][$warehouse]
?? $maps['provider'][$warehouse]
?? null;
if ($shortName === null || trim($shortName) === '') {
throw new RuntimeException('仓库未匹配到唯一仓库简称:' . $warehouse);
}
$createdRaw = $this->moment($values[$columns['created_at_raw']] ?? null);
if ($createdRaw === null) {
throw new RuntimeException('第二个「创建时间」为空或格式无效');
}
$record = [
'report_month' => $month,
'source_no' => $text('source_no'),
'document_no' => $text('document_no'),
'created_serial' => $text('created_serial'),
'supplier' => $text('supplier'),
'item_no' => $text('item_no'),
'barcode' => mb_strtoupper($barcode),
'spec_name' => $text('spec_name'),
'quantity' => $number('库存变动量', 'quantity'),
'unit_price' => $number('单价', 'unit_price'),
'amount' => $number('金额', 'amount'),
'estimated_unit_price' => $number('预估单价', 'estimated_unit_price'),
'estimated_amount' => $number('预估金额', 'estimated_amount'),
'supplier_barcode' => $text('supplier_barcode'),
'remark' => $text('remark'),
'warehouse' => $warehouse,
'warehouse_short_name' => $shortName,
'created_at_raw' => $createdRaw,
'express_name' => $text('express_name'),
'express_no' => $text('express_no'),
'not_sent' => $text('not_sent'),
'owner_name' => $text('owner_name'),
];
unset($record['warehouse_short_name']);
$record['row_key'] = hash(
'sha256',
json_encode($record, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
);
$record['warehouse_short_name'] = $shortName;
$record['imported_at'] = now();
return $record;
}
/**
* 仅接受金额类字段当前行内的乘积公式,避免执行来源文件中的任意公式。
*
* @param array<int,mixed> $values
* @param array<string,int> $columns
*/
private function formulaValue(
string $label,
string $field,
string $formula,
int $rowNumber,
array $values,
array $columns,
): float {
$expectedFields = match ($field) {
'amount' => ['quantity', 'unit_price'],
'estimated_amount' => ['estimated_unit_price', 'quantity'],
default => [],
};
if ($expectedFields === []) {
throw new RuntimeException(
"字段「{$label}」不允许使用公式:" . $formula,
);
}
if (
preg_match(
'/^=?\s*\$?([A-Z]{1,3})\$?(\d+)\s*\*\s*\$?([A-Z]{1,3})\$?(\d+)\s*$/i',
$formula,
$matches,
) !== 1
) {
throw new RuntimeException(
"字段「{$label}」公式无法安全识别:" . $formula,
);
}
if ((int) $matches[2] !== $rowNumber || (int) $matches[4] !== $rowNumber) {
throw new RuntimeException(
"字段「{$label}」公式引用了其他行:" . $formula,
);
}
$actualColumns = [
$this->columnIndex($matches[1]),
$this->columnIndex($matches[3]),
];
$expectedColumns = [
$columns[$expectedFields[0]] + 1,
$columns[$expectedFields[1]] + 1,
];
sort($actualColumns);
sort($expectedColumns);
if ($actualColumns !== $expectedColumns) {
throw new RuntimeException(
"字段「{$label}」公式与取数口径不一致:" . $formula,
);
}
$left = $this->numericReference(
$values[$actualColumns[0] - 1] ?? null,
$label,
);
$right = $this->numericReference(
$values[$actualColumns[1] - 1] ?? null,
$label,
);
return $left * $right;
}
private function numericReference(mixed $value, string $label): float
{
if (is_array($value)) {
if (($value['__formula'] ?? '') !== '') {
throw new RuntimeException(
"字段「{$label}」的公式引用了另一个公式单元格",
);
}
$value = $value['__cached'] ?? null;
}
$text = str_replace(
[',', ','],
'',
trim((string) $this->plainValue($value)),
);
if (! is_numeric($text)) {
throw new RuntimeException(
"字段「{$label}」的公式引用值不是有效数字:" . $text,
);
}
return (float) $text;
}
/** @param array<int,mixed> $values */
private function emptyRow(array $values): bool
{
foreach ($values as $value) {
if (trim((string) $this->plainValue($value)) !== '') {
return false;
}
}
return true;
}
private function text(mixed $value): ?string
{
$text = trim((string) $this->plainValue($value));
return $text === '' ? null : $text;
}
private function plainValue(mixed $value): mixed
{
if (is_array($value)) {
return $value['__cached'] ?? null;
}
return $value;
}
private function moment(mixed $value): ?string
{
$value = $this->plainValue($value);
if ($value === null || trim((string) $value) === '') {
return null;
}
if (is_numeric($value)) {
try {
return ExcelDate::excelToDateTimeObject((float) $value)->format('Y-m-d H:i:s');
} catch (Throwable) {
return null;
}
}
$value = trim((string) $value);
foreach (['Y/m/d H:i:s', 'Y-m-d H:i:s', 'Y/m/d', 'Y-m-d'] as $format) {
$date = DateTimeImmutable::createFromFormat($format, $value);
if ($date instanceof DateTimeImmutable) {
return $date->format('Y-m-d H:i:s');
}
}
$timestamp = strtotime($value);
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
}
/** @return array{warehouse:array<string,string>,provider:array<string,string>} */
private function shortNameMap(): array
{
$rows = InventoryWarehouseNameMapping::withoutGlobalScopes()
->get(['warehouse_provider', 'warehouse', 'warehouse_short_name']);
$grouped = [];
foreach ($rows as $row) {
$warehouse = trim((string) $row->warehouse);
if ($warehouse !== '') {
$grouped[$warehouse][] = trim((string) $row->warehouse_short_name);
}
}
$byWarehouse = [];
foreach ($grouped as $warehouse => $names) {
$unique = array_values(array_unique($names));
if (count($unique) === 1 && $unique[0] !== '') {
$byWarehouse[$warehouse] = $unique[0];
}
}
$providerGrouped = [];
foreach ($rows as $row) {
$provider = trim((string) $row->warehouse_provider);
if ($provider !== '') {
$providerGrouped[$provider][] = [
'warehouse' => trim((string) $row->warehouse),
'short_name' => trim((string) $row->warehouse_short_name),
];
}
}
$byProvider = [];
foreach ($providerGrouped as $provider => $providerRows) {
$unique = array_values(array_unique(array_column(
$providerRows,
'short_name',
)));
if (count($unique) === 1 && $unique[0] !== '') {
$byProvider[$provider] = $unique[0];
continue;
}
$mainShortNames = [];
foreach ($providerRows as $providerRow) {
if (str_contains($providerRow['warehouse'], '主仓')) {
$mainShortNames[] = $providerRow['short_name'];
}
}
$mainShortNames = array_values(array_unique($mainShortNames));
if (count($mainShortNames) === 1 && $mainShortNames[0] !== '') {
$byProvider[$provider] = $mainShortNames[0];
}
}
return ['warehouse' => $byWarehouse, 'provider' => $byProvider];
}
/** @return array{0:XMLReader,1:list<string>} */
private function streamDetailSheet(string $path): array
{
$zip = new ZipArchive();
if ($zip->open($path) !== true) {
throw new RuntimeException('供应商汇总文件不是有效的 XLSX 工作簿。');
}
try {
$sheetPath = $this->detailSheetPath($zip);
$sharedStrings = $zip->locateName('xl/sharedStrings.xml') !== false
? $this->readSharedStrings($path)
: [];
} finally {
$zip->close();
}
return [
$this->openZipEntry($path, $sheetPath),
$sharedStrings,
];
}
private function detailSheetPath(ZipArchive $zip): string
{
$workbookXml = $zip->getFromName('xl/workbook.xml');
$relationshipsXml = $zip->getFromName('xl/_rels/workbook.xml.rels');
if ($workbookXml === false || $relationshipsXml === false) {
throw new RuntimeException('供应商汇总工作簿缺少工作簿结构文件。');
}
$workbook = new DOMDocument();
$relationships = new DOMDocument();
if (
! $workbook->loadXML($workbookXml, LIBXML_NONET)
|| ! $relationships->loadXML($relationshipsXml, LIBXML_NONET)
) {
throw new RuntimeException('供应商汇总工作簿结构无法读取。');
}
$relationshipId = null;
$xpath = new DOMXPath($workbook);
$xpath->registerNamespace(
'main',
'http://schemas.openxmlformats.org/spreadsheetml/2006/main',
);
$xpath->registerNamespace(
'office',
'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
);
foreach ($xpath->query('/main:workbook/main:sheets/main:sheet') ?: [] as $sheet) {
if ($sheet instanceof \DOMElement
&& $sheet->getAttribute('name') === self::DETAIL_SHEET
) {
$relationshipId = $sheet->getAttributeNS(
'http://schemas.openxmlformats.org/officeDocument/2006/relationships',
'id',
);
break;
}
}
if ($relationshipId === null || $relationshipId === '') {
throw new RuntimeException('供应商汇总表缺少「明细」子表。');
}
$target = null;
foreach ($relationships->getElementsByTagName('Relationship') as $relationship) {
if ($relationship->getAttribute('Id') === $relationshipId) {
$target = $relationship->getAttribute('Target');
break;
}
}
if ($target === null || $target === '') {
throw new RuntimeException('「明细」子表的工作簿关系不存在。');
}
return $this->normalizeZipPath(
str_starts_with($target, '/') ? ltrim($target, '/') : 'xl/' . $target,
);
}
private function normalizeZipPath(string $path): string
{
$segments = [];
foreach (explode('/', str_replace('\\', '/', $path)) as $segment) {
if ($segment === '' || $segment === '.') {
continue;
}
if ($segment === '..') {
array_pop($segments);
continue;
}
$segments[] = $segment;
}
return implode('/', $segments);
}
/** @return list<string> */
private function readSharedStrings(string $path): array
{
$reader = $this->openZipEntry($path, 'xl/sharedStrings.xml');
$strings = [];
$current = null;
$stringDepth = null;
$phoneticDepth = null;
try {
while ($reader->read()) {
if (
$reader->nodeType === XMLReader::ELEMENT
&& $reader->name === 'si'
) {
$current = '';
$stringDepth = $reader->depth;
$phoneticDepth = null;
continue;
}
if ($current === null) {
continue;
}
if (
$reader->nodeType === XMLReader::ELEMENT
&& $reader->name === 'rPh'
) {
$phoneticDepth = $reader->depth;
continue;
}
if (
$reader->nodeType === XMLReader::END_ELEMENT
&& $reader->name === 'rPh'
&& $reader->depth === $phoneticDepth
) {
$phoneticDepth = null;
continue;
}
if (
$reader->nodeType === XMLReader::ELEMENT
&& $reader->name === 't'
&& $phoneticDepth === null
) {
$current .= $reader->readString();
continue;
}
if (
$reader->nodeType === XMLReader::END_ELEMENT
&& $reader->name === 'si'
&& $reader->depth === $stringDepth
) {
$strings[] = $current;
$current = null;
}
}
} finally {
$reader->close();
}
return $strings;
}
private function openZipEntry(string $path, string $entry): XMLReader
{
$reader = new XMLReader();
$flags = LIBXML_NONET | LIBXML_COMPACT;
if (! $reader->open("zip://{$path}#{$entry}", null, $flags)) {
throw new RuntimeException("无法读取工作簿内容:{$entry}");
}
return $reader;
}
/**
* @param list<string> $sharedStrings
* @return array<int,mixed>
*/
private function readRow(XMLReader $reader, array $sharedStrings): array
{
$values = [];
$rowDepth = $reader->depth;
while ($reader->read()) {
if (
$reader->nodeType === XMLReader::END_ELEMENT
&& $reader->name === 'row'
&& $reader->depth === $rowDepth
) {
break;
}
if (
$reader->nodeType !== XMLReader::ELEMENT
|| $reader->name !== 'c'
) {
continue;
}
$reference = $reader->getAttribute('r') ?? '';
if (
preg_match('/^([A-Z]{1,3})\d+$/i', $reference, $matches) !== 1
) {
continue;
}
$values[$this->columnIndex($matches[1]) - 1] = $this->readCell(
$reader,
$sharedStrings,
);
}
return $values;
}
/** @param list<string> $sharedStrings */
private function readCell(XMLReader $reader, array $sharedStrings): mixed
{
if ($reader->isEmptyElement) {
return null;
}
$type = $reader->getAttribute('t') ?? '';
$cellDepth = $reader->depth;
$cached = null;
$formula = null;
$hasFormula = false;
$inlineParts = [];
$phoneticDepth = null;
while ($reader->read()) {
if (
$reader->nodeType === XMLReader::END_ELEMENT
&& $reader->name === 'c'
&& $reader->depth === $cellDepth
) {
break;
}
if ($reader->nodeType === XMLReader::ELEMENT) {
if ($reader->name === 'f') {
$hasFormula = true;
$formula = $reader->readString();
continue;
}
if ($reader->name === 'v') {
$cached = $reader->readString();
continue;
}
if ($reader->name === 'rPh') {
$phoneticDepth = $reader->depth;
continue;
}
if (
$reader->name === 't'
&& $phoneticDepth === null
) {
$inlineParts[] = $reader->readString();
}
}
if (
$reader->nodeType === XMLReader::END_ELEMENT
&& $reader->name === 'rPh'
&& $reader->depth === $phoneticDepth
) {
$phoneticDepth = null;
}
}
$value = match ($type) {
's' => is_numeric($cached)
? ($sharedStrings[(int) $cached] ?? null)
: null,
'inlineStr' => implode('', $inlineParts),
'b' => $cached === null ? null : ($cached === '1' ? '1' : '0'),
default => $cached ?? implode('', $inlineParts),
};
if ($hasFormula) {
return [
'__formula' => (string) $formula,
'__cached' => $value,
];
}
return $value;
}
private function columnIndex(string $column): int
{
$index = 0;
foreach (str_split(strtoupper($column)) as $letter) {
$index = ($index * 26) + (ord($letter) - 64);
}
return $index;
}
}

View File

@ -1,303 +0,0 @@
<?php
namespace App\Business;
use App\Models\Auth\User;
use App\Models\Business\SystemizationTodo;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use RuntimeException;
use Throwable;
class SystemizationTodoAuditLogger
{
public const TABLE = 'business_systemization_todo_audit_logs';
public const HISTORY_LIMIT = 500;
public const ACTION_LABELS = [
'create' => '新增',
'update' => '修改',
'delete' => '删除',
'import' => '导入',
];
public const FIELD_LABELS = [
'category' => '类型',
'section' => '内容',
'title' => '待办事项',
'owner' => '总负责人',
'assignee' => '责任人',
'start_date' => '开始时间',
'start_note' => '开始时间备注',
'due_date' => '完成时间',
'due_note' => '完成时间备注',
'status' => '状态',
'completed_at' => '完成日期',
'progress' => '完成情况',
'notes' => '备注',
'attachment_count' => '完成截图',
'sort_order' => '排序位置',
];
/**
* @return array<string, mixed>
*/
public function snapshot(SystemizationTodo $todo, bool $withAttachments = false): array
{
$snapshot = [
'id' => (int) $todo->id,
'category' => (string) ($todo->category ?? ''),
'section' => (string) ($todo->section ?? ''),
'title' => (string) ($todo->title ?? ''),
'owner' => (string) ($todo->owner ?? ''),
'assignee' => (string) ($todo->assignee ?? ''),
'start_date' => $todo->start_date?->format('Y-m-d') ?? '',
'start_note' => (string) ($todo->start_note ?? ''),
'due_date' => $todo->due_date?->format('Y-m-d') ?? '',
'due_note' => (string) ($todo->due_note ?? ''),
'status' => $todo->statusLabel(),
'completed_at' => $todo->completed_at?->format('Y-m-d') ?? '',
'progress' => (string) ($todo->progress ?? ''),
'notes' => (string) ($todo->notes ?? ''),
'sort_order' => (int) $todo->sort_order,
'source_key' => (string) ($todo->source_key ?? ''),
];
if ($withAttachments) {
$snapshot['attachment_count'] = (int) $todo->attachments()->count();
}
return $snapshot;
}
public function record(
string $action,
int $companyId,
?int $todoId,
?array $before,
?array $after,
?int $userId = null,
?string $actorName = null,
array $meta = [],
): void {
$this->recordMany([[
'company_id' => $companyId,
'todo_id' => $todoId,
'action' => $action,
'before' => $before,
'after' => $after,
'user_id' => $userId,
'actor_name' => $actorName,
'meta' => $meta,
]]);
}
/**
* @param array<int, array{
* company_id:int,
* todo_id:?int,
* action:string,
* before:?array,
* after:?array,
* user_id:?int,
* actor_name?:?string,
* meta?:array
* }> $events
*/
public function recordMany(array $events): void
{
if ($events === []) {
return;
}
if (! $this->tableReady()) {
throw new RuntimeException('系统化待办操作记录表尚未就绪,请先完成数据库迁移。');
}
$now = $this->platformNow();
$actorNames = [];
$rows = [];
foreach ($events as $event) {
$action = (string) ($event['action'] ?? '');
if (! array_key_exists($action, self::ACTION_LABELS)) {
throw new RuntimeException('不支持的待办操作记录类型。');
}
$userId = isset($event['user_id']) ? (int) $event['user_id'] : null;
$actorName = trim((string) ($event['actor_name'] ?? ''));
if ($actorName === '' && $userId !== null) {
if (! array_key_exists($userId, $actorNames)) {
$actorNames[$userId] = (string) (User::query()->whereKey($userId)->value('name') ?? '');
}
$actorName = $actorNames[$userId];
}
$meta = is_array($event['meta'] ?? null) ? $event['meta'] : [];
$rows[] = [
'company_id' => max(1, (int) ($event['company_id'] ?? 1)),
'todo_id' => isset($event['todo_id']) ? (int) $event['todo_id'] : null,
'action' => $action,
'before_json' => $this->json($event['before'] ?? null),
'after_json' => $this->json($event['after'] ?? null),
'actor_user_id' => $userId,
'actor_name' => mb_substr($actorName, 0, 191),
'extra_json' => $meta === [] ? null : $this->json($meta),
'at' => $now->toDateTimeString(),
'created_at' => $now->toDateTimeString(),
'updated_at' => $now->toDateTimeString(),
];
}
DB::table(self::TABLE)->insert($rows);
$companyIds = array_values(array_unique(array_column($rows, 'company_id')));
foreach ($companyIds as $companyId) {
$maxId = (int) DB::table(self::TABLE)
->where('company_id', $companyId)
->max('id');
if ($maxId > self::HISTORY_LIMIT) {
DB::table(self::TABLE)
->where('company_id', $companyId)
->where('id', '<=', $maxId - self::HISTORY_LIMIT)
->delete();
}
}
}
/**
* @return array<int, array<string, mixed>>
*/
public function history(
int $companyId,
int $limit = self::HISTORY_LIMIT,
?string $from = null,
): array
{
if (! $this->tableReady()) {
return [];
}
return DB::table(self::TABLE)
->where('company_id', $companyId)
->when($from !== null && $from !== '', fn ($query) => $query->where('at', '>=', $from))
->orderByDesc('id')
->limit(max(1, min($limit, self::HISTORY_LIMIT)))
->get()
->map(fn (object $row): array => $this->serializeEvent($row))
->all();
}
public function platformNow(): Carbon
{
$timezone = trim((string) config('app.platform_timezone', ''));
if ($timezone === '') {
$timezone = (string) config('app.timezone', 'UTC');
}
try {
return Carbon::now($timezone);
} catch (Throwable) {
return Carbon::now((string) config('app.timezone', 'UTC'));
}
}
/**
* @return array<string, mixed>
*/
private function serializeEvent(object $row): array
{
$before = $row->before_json ? json_decode((string) $row->before_json, true) : null;
$after = $row->after_json ? json_decode((string) $row->after_json, true) : null;
$before = is_array($before) ? $before : null;
$after = is_array($after) ? $after : null;
$meta = $row->extra_json ? json_decode((string) $row->extra_json, true) : [];
$meta = is_array($meta) ? $meta : [];
$source = $after ?? $before ?? [];
return [
'id' => (int) $row->id,
'at' => (string) $row->at,
'action' => (string) $row->action,
'action_label' => self::ACTION_LABELS[(string) $row->action] ?? (string) $row->action,
'todo_id' => $row->todo_id === null ? null : (int) $row->todo_id,
'category' => (string) ($source['category'] ?? ''),
'section' => (string) ($source['section'] ?? ''),
'title' => (string) ($source['title'] ?? ''),
'actor' => (string) ($row->actor_name ?? ''),
'before' => $before,
'after' => $after,
'meta' => $meta,
'changes' => $this->changes($before, $after),
];
}
/**
* @return array<int, string>
*/
private function changes(?array $before, ?array $after): array
{
if ($after === null && $before !== null) {
return ['删除前:' . $this->itemLabel($before)];
}
if ($before === null && $after !== null) {
return ['新增:' . $this->itemLabel($after)];
}
if ($before === null && $after === null) {
return ['—'];
}
$changes = [];
foreach (self::FIELD_LABELS as $field => $label) {
$old = $this->displayValue($field, $before[$field] ?? '');
$new = $this->displayValue($field, $after[$field] ?? '');
if ($old !== $new) {
$changes[] = $label . ':' . ($old !== '' ? $old : '空')
. ' → ' . ($new !== '' ? $new : '空');
}
}
return $changes === [] ? ['内容无变化'] : $changes;
}
private function itemLabel(array $row): string
{
$parts = array_values(array_filter([
trim((string) ($row['category'] ?? '')),
trim((string) ($row['section'] ?? '')),
trim((string) ($row['title'] ?? '')),
], static fn (string $value): bool => $value !== ''));
return $parts === [] ? '未命名待办' : implode(' · ', $parts);
}
private function displayValue(string $field, mixed $value): string
{
if ($field === 'attachment_count') {
return max(0, (int) $value) . ' 张';
}
return trim(str_replace(["\r\n", "\r"], "\n", (string) ($value ?? '')));
}
private function json(mixed $value): ?string
{
if ($value === null) {
return null;
}
$json = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) {
throw new RuntimeException('待办操作记录序列化失败。');
}
return $json;
}
private function tableReady(): bool
{
try {
return DB::getSchemaBuilder()->hasTable(self::TABLE);
} catch (Throwable) {
return false;
}
}
}

View File

@ -1,847 +0,0 @@
<?php
namespace App\Business;
use App\Models\Business\SystemizationTodo;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Services\PayablesWorkbookStreamReader;
use PhpOffice\PhpSpreadsheet\Shared\Date as ExcelDate;
use RuntimeException;
class SystemizationTodoWorkbookImporter
{
private const REQUIRED_HEADERS = [
'category' => '类型',
'section' => '内容',
'title' => '待办事项',
'owner' => '总负责人',
'assignee' => '责任人',
'start' => '开始时间',
'finish' => '完成时间',
'progress' => '完成情况',
'notes' => '备注',
];
public function __construct(
private readonly SystemizationTodoAuditLogger $audit,
) {
}
/**
* 导入财务系统化待办。只新增系统中不存在的来源行,不覆盖日常维护后的状态与截图。
*
* @return array{created:int,skipped:int,sheet:string}
*/
public function import(
string $path,
int $companyId,
?int $userId = null,
?string $originalName = null,
): array
{
if (! is_file($path)) {
throw new RuntimeException('找不到待办 Excel 文件。');
}
$extension = $this->extension($path, $originalName);
if ($extension !== 'xlsx') {
throw new RuntimeException('系统化待办仅支持 .xlsx 文件。');
}
$reader = PayablesWorkbookStreamReader::forFile($path, $extension);
foreach ($reader->sheetNames() as $sheetIndex => $sheetName) {
$headerInfo = $this->headerMap($reader, $sheetIndex);
if ($headerInfo === null) {
continue;
}
$header = $headerInfo['columns'];
$headerRow = $headerInfo['row'];
$rows = $this->workbookRows(
$reader,
$sheetIndex,
$sheetName,
$header,
$headerRow,
$companyId,
);
$created = 0;
$skipped = 0;
DB::transaction(function () use (
$rows,
$sheetName,
$companyId,
$userId,
&$created,
&$skipped,
): void {
$auditEvents = [];
foreach ($rows as $row) {
$exists = SystemizationTodo::withoutGlobalScopes()
->where('company_id', $companyId)
->where('source_key', $row['data']['source_key'])
->exists();
if ($exists) {
$skipped++;
continue;
}
$todo = SystemizationTodo::withoutGlobalScopes()->create([
...$row['data'],
'company_id' => $companyId,
'imported_at' => now(),
'created_by' => $userId,
'updated_by' => $userId,
]);
$auditEvents[] = [
'company_id' => $companyId,
'todo_id' => (int) $todo->id,
'action' => 'import',
'before' => null,
'after' => $this->audit->snapshot($todo),
'user_id' => $userId,
'meta' => [
'source' => 'excel',
'mode' => 'import',
'sheet' => $sheetName,
],
];
$created++;
}
$this->audit->recordMany($auditEvents);
});
return ['created' => $created, 'skipped' => $skipped, 'sheet' => $sheetName];
}
throw new RuntimeException(
'未找到待办表头。请确认工作表包含:类型、内容、待办事项、总负责人、责任人、开始时间、完成时间、完成情况、备注。'
);
}
/**
* 将新版工作簿同步到已有来源记录。保留截图和来源中已不在工作簿的事项,只新增和更新。
*
* @return array{created:int,updated:int,retained:int,sheet:string}
*/
public function sync(
string $path,
int $companyId,
?int $userId = null,
bool $dryRun = false,
?string $originalName = null,
): array
{
if (! is_file($path)) {
throw new RuntimeException('找不到待办 Excel 文件。');
}
$extension = $this->extension($path, $originalName);
if ($extension !== 'xlsx') {
throw new RuntimeException('系统化待办仅支持 .xlsx 文件。');
}
$reader = PayablesWorkbookStreamReader::forFile($path, $extension);
foreach ($reader->sheetNames() as $sheetIndex => $sheetName) {
$headerInfo = $this->headerMap($reader, $sheetIndex);
if ($headerInfo === null) {
continue;
}
$rows = $this->workbookRows(
$reader,
$sheetIndex,
$sheetName,
$headerInfo['columns'],
$headerInfo['row'],
$companyId,
);
$activeTodos = SystemizationTodo::withoutGlobalScopes()
->where('company_id', $companyId)
->whereNull('deleted_at')
->orderBy('sort_order')
->orderBy('id')
->get();
$rows = $this->alignIncomingCategories($rows, $activeTodos);
$existing = $activeTodos
->filter(static fn (SystemizationTodo $todo): bool => $todo->imported_at !== null)
->values();
[$matches, $newIndexes] = $this->matchRows($rows, $existing);
$matchedIds = array_values($matches);
$retained = $existing
->filter(static fn (SystemizationTodo $todo): bool => ! in_array($todo->id, $matchedIds, true))
->sortBy(static fn (SystemizationTodo $todo): string =>
sprintf('%010d-%010d', (int) $todo->sort_order, (int) $todo->id)
)
->values();
$retainedSortOrder = max(
(int) $existing->max('sort_order'),
...array_map(
static fn (array $row): int => (int) $row['data']['sort_order'],
$rows,
),
) + 1;
$oldSourceKeys = $existing->pluck('source_key', 'id')->all();
$updated = 0;
foreach ($matches as $rowIndex => $todoId) {
$todo = $existing->firstWhere('id', $todoId);
if ($todo && $this->contentDiffers($todo, $rows[$rowIndex]['data'])) {
$updated++;
}
}
if ($dryRun) {
return [
'created' => count($newIndexes),
'updated' => $updated,
'retained' => $retained->count(),
'sheet' => $sheetName,
];
}
$created = 0;
$updated = 0;
DB::transaction(function () use (
$rows,
$matches,
$newIndexes,
$existing,
$retained,
$oldSourceKeys,
$retainedSortOrder,
$companyId,
$userId,
$sheetName,
&$created,
&$updated,
): void {
$auditEvents = [];
SystemizationTodo::withoutGlobalScopes()
->where('company_id', $companyId)
->whereNull('deleted_at')
->whereNotNull('imported_at')
->update(['source_key' => null]);
foreach ($matches as $rowIndex => $todoId) {
$todo = $existing->firstWhere('id', $todoId);
if (! $todo) {
continue;
}
$before = $this->audit->snapshot($todo);
$todo->source_key = null;
$todo->syncOriginalAttribute('source_key');
$todo->fill($rows[$rowIndex]['data']);
$contentChanged = $todo->isDirty([
'category',
'section',
'title',
'owner',
'assignee',
'start_date',
'start_note',
'due_date',
'due_note',
'status',
'completed_at',
'progress',
'notes',
]);
$todo->updated_by = $userId;
$todo->save();
if ($contentChanged) {
$auditEvents[] = [
'company_id' => $companyId,
'todo_id' => (int) $todo->id,
'action' => 'import',
'before' => $before,
'after' => $this->audit->snapshot($todo),
'user_id' => $userId,
'meta' => [
'source' => 'excel',
'mode' => 'sync',
'sheet' => $sheetName,
],
];
$updated++;
}
}
foreach ($newIndexes as $rowIndex) {
$todo = SystemizationTodo::withoutGlobalScopes()->create([
...$rows[$rowIndex]['data'],
'company_id' => $companyId,
'imported_at' => now(),
'created_by' => $userId,
'updated_by' => $userId,
]);
$auditEvents[] = [
'company_id' => $companyId,
'todo_id' => (int) $todo->id,
'action' => 'import',
'before' => null,
'after' => $this->audit->snapshot($todo),
'user_id' => $userId,
'meta' => [
'source' => 'excel',
'mode' => 'sync',
'sheet' => $sheetName,
],
];
$created++;
}
foreach ($retained as $todo) {
SystemizationTodo::withoutGlobalScopes()
->whereKey($todo->id)
->update([
'sort_order' => $retainedSortOrder++,
'source_key' => $oldSourceKeys[$todo->id] ?? null,
]);
}
$this->audit->recordMany($auditEvents);
});
return [
'created' => $created,
'updated' => $updated,
'retained' => $retained->count(),
'sheet' => $sheetName,
];
}
throw new RuntimeException(
'未找到待办表头。请确认工作表包含:类型、内容、待办事项、总负责人、责任人、开始时间、完成时间、完成情况、备注。'
);
}
/**
* @param array<int,int> $columns
* @return array<int,array{
* row:int,
* title_index:int,
* title_count:int,
* source_title:string,
* category:string,
* section:string,
* title:string,
* data:array<string,mixed>
* }>
*/
private function workbookRows(
PayablesWorkbookStreamReader $reader,
int $sheetIndex,
string $sheetName,
array $columns,
int $headerRow,
int $companyId,
): array {
$rows = [];
$category = '';
$section = '';
foreach ($reader->rows($sheetIndex) as $rowNumber => $cells) {
if ($rowNumber <= $headerRow) {
continue;
}
$categoryValue = $this->cleanImportedLabel(
$this->cell($cells, $columns['category'] ?? null)
);
if ($categoryValue !== '') {
$category = $categoryValue;
}
$sectionValue = $this->cleanImportedLabel(
$this->cell($cells, $columns['section'] ?? null)
);
if ($sectionValue !== '') {
$section = $sectionValue;
}
$sourceTitle = $this->cell($cells, $columns['title'] ?? null);
if ($sourceTitle === '') {
continue;
}
$titles = $this->titleSegments($sourceTitle);
$titleCount = count($titles);
$owner = $this->cell($cells, $columns['owner'] ?? null);
$assignee = $this->cell($cells, $columns['assignee'] ?? null);
$startText = $this->cell($cells, $columns['start'] ?? null);
$finishText = $this->cell($cells, $columns['finish'] ?? null);
[$startDate, $startNote] = $this->dateParts($startText);
[$finishDate, $finishNote] = $this->dateParts($finishText);
$progress = $this->cell($cells, $columns['progress'] ?? null);
$notes = $this->cell($cells, $columns['notes'] ?? null);
$progress = (string) (
SystemizationTodo::mergeNotesIntoProgress($progress, $notes) ?? ''
);
$status = $this->status($progress);
foreach ($titles as $offset => $title) {
$titleIndex = $offset + 1;
$sortOrder = ($rowNumber * 100) + $titleIndex;
$rows[] = [
'row' => $rowNumber,
'title_index' => $titleIndex,
'title_count' => $titleCount,
'source_title' => $sourceTitle,
'category' => $category,
'section' => $section,
'title' => $title,
'data' => [
'category' => $category !== '' ? $category : null,
'section' => $section !== '' ? $section : null,
'title' => $title,
'owner' => $owner !== '' ? $owner : null,
'assignee' => $assignee !== '' ? $assignee : null,
'start_date' => $startDate,
'start_note' => $startNote !== '' ? $startNote : null,
'due_date' => $finishDate,
'due_note' => $finishNote !== '' ? $finishNote : null,
'status' => $status,
'completed_at' => $status === SystemizationTodo::STATUS_COMPLETED
? ($finishDate ?? now()->toDateString())
: null,
'progress' => $progress !== '' ? $progress : null,
'notes' => null,
'sort_order' => $sortOrder,
'source_key' => hash(
'sha256',
implode('|', [
$companyId,
$sheetName,
$rowNumber,
$titleIndex,
$category,
$section,
$title,
$owner,
$assignee,
])
),
],
];
}
}
return $rows;
}
/**
* Split a cell into separate tasks only when it contains multiple numbered lines.
*
* @return array<int,string>
*/
private function titleSegments(string $value): array
{
$value = trim(str_replace(["\r\n", "\r"], "\n", $value));
$marker = '/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、,,..::;;)]\s*/u';
$lines = preg_split('/\n/u', $value) ?: [];
$numberedLines = array_filter(
$lines,
static fn (string $line): bool => preg_match($marker, trim($line)) === 1,
);
if (count($numberedLines) < 2) {
return [$this->cleanImportedLabel($value)];
}
$segments = [];
$current = '';
foreach ($lines as $line) {
$line = trim($line);
if ($line === '') {
continue;
}
if (preg_match($marker, $line) === 1) {
if ($current !== '') {
$segments[] = trim($current);
}
$current = trim((string) preg_replace($marker, '', $line));
continue;
}
$current = $current === '' ? $line : $current . "\n" . $line;
}
if ($current !== '') {
$segments[] = trim($current);
}
return $segments !== []
? $segments
: [$this->cleanImportedLabel($value)];
}
/**
* @param array<int,array{
* row:int,
* title_index:int,
* title_count:int,
* source_title:string,
* category:string,
* section:string,
* title:string,
* data:array<string,mixed>
* }> $rows
* @return array{0:array<int,int>,1:array<int,int>}
*/
private function matchRows(array $rows, \Illuminate\Support\Collection $existing): array
{
$matches = [];
$usedIds = [];
$candidates = [];
foreach ($existing as $todo) {
$candidates[$this->matchTitleKey($todo->title)][] = $todo;
}
foreach ($rows as $rowIndex => $row) {
if ($row['title_count'] < 2 || $row['title_index'] !== 1) {
continue;
}
$sourceKey = $this->matchTitleKey($row['source_title']);
$available = array_values(array_filter(
$candidates[$sourceKey] ?? [],
fn (SystemizationTodo $todo): bool =>
! in_array($todo->id, $usedIds, true)
&& $this->matchGroupKey($todo->category) === $this->matchGroupKey($row['category'])
&& $this->matchGroupKey($todo->section) === $this->matchGroupKey($row['section']),
));
if ($available === []) {
continue;
}
usort($available, static fn (SystemizationTodo $a, SystemizationTodo $b): int =>
abs($a->sort_order - $row['data']['sort_order'])
<=> abs($b->sort_order - $row['data']['sort_order'])
);
$matches[$rowIndex] = $available[0]->id;
$usedIds[] = $available[0]->id;
}
// 同名待办可能分布在多个内容组,优先匹配同一「类型 + 内容」。
foreach ($rows as $rowIndex => $row) {
if (isset($matches[$rowIndex])) {
continue;
}
$key = $this->matchTitleKey($row['title']);
$available = array_values(array_filter(
$candidates[$key] ?? [],
fn (SystemizationTodo $todo): bool =>
! in_array($todo->id, $usedIds, true)
&& $this->matchGroupKey($todo->category) === $this->matchGroupKey($row['category'])
&& $this->matchGroupKey($todo->section) === $this->matchGroupKey($row['section']),
));
if ($available === []) {
continue;
}
usort($available, static fn (SystemizationTodo $a, SystemizationTodo $b): int =>
abs($a->sort_order - $row['data']['sort_order'])
<=> abs($b->sort_order - $row['data']['sort_order'])
);
$matches[$rowIndex] = $available[0]->id;
$usedIds[] = $available[0]->id;
}
foreach ($rows as $rowIndex => $row) {
if (isset($matches[$rowIndex])) {
continue;
}
$key = $this->matchTitleKey($row['title']);
$available = array_values(array_filter(
$candidates[$key] ?? [],
static fn (SystemizationTodo $todo): bool => ! in_array($todo->id, $usedIds, true),
));
if ($available === []) {
continue;
}
usort($available, static fn (SystemizationTodo $a, SystemizationTodo $b): int =>
abs($a->sort_order - $row['data']['sort_order'])
<=> abs($b->sort_order - $row['data']['sort_order'])
);
$todo = $available[0];
$matches[$rowIndex] = $todo->id;
$usedIds[] = $todo->id;
}
$remainingIds = $existing
->pluck('id')
->reject(static fn (int $id): bool => in_array($id, $usedIds, true))
->values();
$todoById = $existing->keyBy('id');
$pairs = [];
foreach ($rows as $rowIndex => $row) {
if (isset($matches[$rowIndex])) {
continue;
}
foreach ($remainingIds as $todoId) {
$todo = $todoById->get($todoId);
if (! $todo
|| $this->matchGroupKey($todo->category) !== $this->matchGroupKey($row['category'])
|| $this->matchGroupKey($todo->section) !== $this->matchGroupKey($row['section'])) {
continue;
}
$similarity = $this->titleSimilarity((string) $todo->title, $row['title']);
if ($similarity >= 0.55) {
$pairs[] = [
'rowIndex' => $rowIndex,
'todoId' => $todoId,
'score' => $similarity - abs($todo->sort_order - $row['data']['sort_order']) / 1000,
];
}
}
}
usort($pairs, static fn (array $a, array $b): int => $b['score'] <=> $a['score']);
foreach ($pairs as $pair) {
if (isset($matches[$pair['rowIndex']]) || in_array($pair['todoId'], $usedIds, true)) {
continue;
}
$matches[$pair['rowIndex']] = $pair['todoId'];
$usedIds[] = $pair['todoId'];
}
return [
$matches,
array_values(array_filter(
array_keys($rows),
static fn (int $rowIndex): bool => ! isset($matches[$rowIndex]),
)),
];
}
/**
* 内容组已唯一归属某个类型时,忽略 Excel 中误填的类型占位值。
*
* @param array<int,array<string,mixed>> $rows
* @param \Illuminate\Support\Collection<int,SystemizationTodo> $activeTodos
* @return array<int,array<string,mixed>>
*/
private function alignIncomingCategories(
array $rows,
\Illuminate\Support\Collection $activeTodos,
): array {
$categoriesBySection = [];
foreach ($activeTodos as $todo) {
$sectionKey = $this->matchGroupKey($todo->section);
$category = trim((string) $todo->category);
if ($sectionKey === '' || $category === '') {
continue;
}
$categoriesBySection[$sectionKey][$this->matchText($category)] = $category;
}
foreach ($rows as &$row) {
$categories = $categoriesBySection[$this->matchGroupKey($row['section'])] ?? [];
if (count($categories) === 1) {
$category = reset($categories);
$row['data']['category'] = $category;
$row['category'] = $category;
}
}
return $rows;
}
private function matchTitleKey(?string $value): string
{
$value = preg_replace(
'/^\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、,,..::;;)]\s*/u',
'',
(string) $value,
) ?? '';
return $this->matchText($value);
}
private function matchGroupKey(?string $value): string
{
return $this->matchTitleKey($this->cleanImportedLabel($value));
}
private function cleanImportedLabel(?string $value): string
{
return trim((string) preg_replace(
'/(^|\R)\s*(?:\d+|[一二三四五六七八九十百]+)\s*[、,,..::;;))]\s*/u',
'$1',
(string) $value,
));
}
private function matchText(?string $value): string
{
return mb_strtolower(
preg_replace('/[\s “”"\',。;、,.!?::;()()【】\[\]—-]+/u', '', (string) $value) ?? '',
'UTF-8',
);
}
/**
* @param array<string,mixed> $data
*/
private function contentDiffers(SystemizationTodo $todo, array $data): bool
{
foreach ([
'category',
'section',
'title',
'owner',
'assignee',
'start_date',
'start_note',
'due_date',
'due_note',
'status',
'completed_at',
'progress',
'notes',
] as $field) {
$current = $todo->{$field};
$incoming = $data[$field] ?? null;
if ($current instanceof \DateTimeInterface && $incoming !== null) {
$current = $current->format('Y-m-d');
}
if ((string) ($current ?? '') !== (string) ($incoming ?? '')) {
return true;
}
}
return false;
}
private function titleSimilarity(string $left, string $right): float
{
$left = $this->matchTitleKey($left);
$right = $this->matchTitleKey($right);
if ($left === '' || $right === '') {
return 0.0;
}
if ($left === $right) {
return 1.0;
}
$leftChars = mb_str_split($left);
$rightChars = mb_str_split($right);
$leftPairs = [];
$rightPairs = [];
for ($index = 0; $index < count($leftChars) - 1; $index++) {
$leftPairs[$leftChars[$index] . $leftChars[$index + 1]] = true;
}
for ($index = 0; $index < count($rightChars) - 1; $index++) {
$rightPairs[$rightChars[$index] . $rightChars[$index + 1]] = true;
}
$leftPairs = array_keys($leftPairs);
$rightPairs = array_keys($rightPairs);
$containment = str_contains($left, $right) || str_contains($right, $left) ? 0.95 : 0.0;
$dice = ($leftPairs === [] || $rightPairs === [])
? 0.0
: 2 * count(array_intersect($leftPairs, $rightPairs)) / (count($leftPairs) + count($rightPairs));
return max($containment, $dice);
}
/**
* @return array{row:int,columns:array<string,int>}|null
*/
private function headerMap(PayablesWorkbookStreamReader $reader, int $sheetIndex): ?array
{
foreach ($reader->rows($sheetIndex, null, 12) as $rowNumber => $cells) {
$normalized = [];
foreach ($cells as $column => $value) {
$value = $this->normalizeHeader($this->text($value));
if ($value !== '') {
$normalized[$value] = $column;
}
}
$mapped = [];
foreach (self::REQUIRED_HEADERS as $field => $label) {
$needle = $this->normalizeHeader($label);
foreach ($normalized as $header => $column) {
if ($header === $needle || str_starts_with($header, $needle)) {
$mapped[$field] = $column;
break;
}
}
}
if (isset($mapped['title'])
&& isset($mapped['owner'])
&& isset($mapped['assignee'])) {
return ['row' => $rowNumber, 'columns' => $mapped];
}
}
return null;
}
private function normalizeHeader(string $value): string
{
return preg_replace('/[\s ()()]+/u', '', $value) ?? '';
}
/**
* @param array<int,mixed> $cells
*/
private function cell(array $cells, ?int $column): string
{
return $column === null ? '' : $this->text($cells[$column] ?? null);
}
private function text(mixed $value): string
{
if ($value === null) {
return '';
}
if (is_bool($value)) {
return $value ? '1' : '0';
}
return trim(str_replace("\u{00A0}", ' ', (string) $value));
}
private function extension(string $path, ?string $originalName): string
{
$name = trim((string) $originalName);
$extension = $name !== ''
? pathinfo($name, PATHINFO_EXTENSION)
: pathinfo($path, PATHINFO_EXTENSION);
return strtolower((string) $extension);
}
/**
* @return array{0:string|null,1:string}
*/
private function dateParts(string $value): array
{
$value = trim($value);
if ($value === '' || $value === '已完成') {
return [null, ''];
}
if (is_numeric($value)) {
$serial = (float) $value;
if ($serial >= 20000 && $serial <= 80000) {
return [ExcelDate::excelToDateTimeObject($serial)->format('Y-m-d'), ''];
}
}
foreach (['Y-m-d', 'Y/m/d', 'Y.m.d', 'Y年n月j日'] as $format) {
try {
$date = Carbon::createFromFormat($format, $value);
if ($date !== false) {
return [$date->format('Y-m-d'), ''];
}
} catch (\Throwable) {
// 不是标准日期,保留原值作为时间备注。
}
}
return [null, mb_substr($value, 0, 100)];
}
private function status(string $progress): string
{
if (str_contains($progress, '已完成')) {
return SystemizationTodo::STATUS_COMPLETED;
}
return SystemizationTodo::STATUS_INCOMPLETE;
}
}

View File

@ -20,10 +20,10 @@ class UpstreamBusinessInformation
public function __construct(
private readonly UpstreamQueryCacheFactory $caches,
private readonly BusinessInformationMirrorRepository $mirror,
private readonly InstallationDetailMirrorRepository $installationMirror,
// 兼容仍按旧构造方式调用的代码;容器解析时会正常注入仓储。
private readonly ?StoreReportRepository $storeReports = null,
private readonly ?StoreAccountMirrorRepository $storeAccounts = null,
private readonly ?InstallationDetailMirrorRepository $installationMirror = null,
) {}
public function paginate(string $section, string $page, int $perPage = 50): LengthAwarePaginator
@ -43,10 +43,6 @@ class UpstreamBusinessInformation
'financial-risk-control/process-statistics' => $this->shopInfoFlowPage($currentPage, $perPage),
default => ['data' => [], 'total' => 0],
};
request()->attributes->set(
'business-page-summary-cards',
app(BusinessSummaryCards::class)->forPage($key, $payload['summary'] ?? []),
);
return new LengthAwarePaginator(
array_map(static fn (array $row) => (object) $row, $payload['data']),
@ -82,8 +78,7 @@ class UpstreamBusinessInformation
private function installationPage(int $page, int $perPage): array
{
$mirror = $this->installationMirror ?? app(InstallationDetailMirrorRepository::class);
$payload = $mirror->paginate($page, $perPage, InstallationDetailMirrorRepository::requestFilters());
$payload = $this->installationMirror->paginate($page, $perPage, InstallationDetailMirrorRepository::requestFilters());
if (! $payload['ready']) {
throw new ServiceUnavailableHttpException(null, '安装明细镜像尚未完成首次同步');
}
@ -162,156 +157,16 @@ SQL);
private function storePage(int $page, int $perPage): array
{
$filters = $this->pageFilters('store-management', 'basic-information');
if ($this->mirror->ready('store-information.read')) {
$storeFilters = array_diff_key(
$filters,
array_flip(['store_manager', 'department_manager', '_sort', '_direction']),
);
return $this->pageStoreInformation(
$page,
$perPage,
$filters,
$this->storesWithAccountOwnership(array_map(
fn (array $row): array => $this->mapStore($row),
$this->mirror->allStores($storeFilters),
)),
);
}
$payload = $this->page(
return $this->page(
'store-information.read',
$page,
$perPage,
array_diff_key($filters, array_flip(['store_manager', 'department_manager'])),
$filters,
fn () => $this->snapshot('store-information.read', [
'page' => $page,
'perPage' => $perPage,
]),
false,
);
$rows = $this->storesWithAccountOwnership($payload['data']);
$rows = array_values(array_filter(
$rows,
fn (array $row): bool => $this->storeOwnershipMatchesFilters($row, $filters),
));
return ['data' => $rows, 'total' => count($rows)];
}
/**
* 店铺基础信息页每次读取时把负责人字段覆盖为店铺账号镜像中的值。
* 账号店铺名与 ERP 店铺名只差下划线/空格,因此统一归一后关联。
*
* @param list<array<string,mixed>> $rows
* @return list<array<string,mixed>>
*/
public function storesWithAccountOwnership(array $rows, bool $requireAccountOwnership = false): array
{
$ownership = $this->storeOwnership($requireAccountOwnership);
foreach ($rows as &$row) {
$key = StoreAccountMirrorRepository::storeNameKey($row['erp_store_name'] ?? '');
$match = $ownership[$key] ?? null;
if ($match === null) {
continue;
}
foreach ([
'store_manager' => $match['operation_owner'],
'department_manager' => $match['operation_department_head'],
] as $field => $value) {
if ($value !== '') {
$row[$field] = $value;
}
}
}
unset($row);
return $rows;
}
/** @param array<string,string|list<string>> $filters */
private function pageStoreInformation(int $page, int $perPage, array $filters, array $rows): array
{
$rows = array_values(array_filter(
$rows,
fn (array $row): bool => $this->storeOwnershipMatchesFilters($row, $filters),
));
$summary = $this->storeInformationSummary($rows);
$this->sortStoreInformation($rows, $filters);
$total = count($rows);
$offset = ($page - 1) * $perPage;
return [
'data' => array_slice($rows, $offset, $perPage),
'total' => $total,
'summary' => $summary,
];
}
/** @param list<array<string,mixed>> $rows */
private function storeInformationSummary(array $rows): array
{
$stores = [];
$platforms = [];
foreach ($rows as $row) {
$key = trim((string) ($row['id'] ?? $row['erp_store_name'] ?? $row['store_name'] ?? ''));
if ($key === '') {
continue;
}
$stores[$key] = match ((string) ($row['store_status'] ?? '')) {
'0', '正常' => '正常',
'3', '关闭中' => '关闭中',
'6', '已关闭', '关店' => '已关闭',
default => (string) ($row['store_status'] ?? ''),
};
$platform = trim((string) ($row['platform'] ?? ''));
if ($platform !== '') {
$platforms[$platform] = true;
}
}
$statusCounts = array_count_values($stores);
return [
'total' => count($stores),
'normal' => (int) ($statusCounts['正常'] ?? 0),
'closing' => (int) ($statusCounts['关闭中'] ?? 0),
'closed' => (int) ($statusCounts['已关闭'] ?? 0),
'platforms' => count($platforms),
];
}
/** @param array<string,string|list<string>> $filters */
private function storeOwnershipMatchesFilters(array $row, array $filters): bool
{
foreach (['store_manager', 'department_manager'] as $key) {
$expected = trim((string) ($filters[$key] ?? ''));
if ($expected !== ''
&& mb_stripos((string) ($row[$key] ?? ''), $expected) === false) {
return false;
}
}
return true;
}
/** @param list<array<string,mixed>> $rows @param array<string,string|list<string>> $filters */
private function sortStoreInformation(array &$rows, array $filters): void
{
$key = (string) ($filters['_sort'] ?? '');
if ($key === '') {
return;
}
$direction = ($filters['_direction'] ?? 'asc') === 'desc' ? -1 : 1;
usort($rows, static function (array $left, array $right) use ($key, $direction): int {
$comparison = strnatcasecmp(
(string) ($left[$key] ?? ''),
(string) ($right[$key] ?? ''),
) * $direction;
return $comparison !== 0
? $comparison
: ((int) ($right['id'] ?? 0) <=> (int) ($left['id'] ?? 0));
});
}
private function storeReportPage(int $page, int $perPage): array
@ -319,7 +174,7 @@ SQL);
$filters = StoreReportRepository::requestFilters();
$rows = $this->storeReportRows($filters);
// 整列为 0 的月份视为没有销量,默认隐藏;仍可从「筛选字段」手动打开。
// 所有店铺该月都没有业绩的月份列默认隐藏;「筛选字段」中仍保留勾选项,可手动选出来。
request()->attributes->set(
'store-report.default_hidden_columns',
$this->columnsWithoutData($rows, 'sales_', zeroCountsAsData: false),
@ -327,65 +182,7 @@ SQL);
$offset = ($page - 1) * $perPage;
return [
'data' => array_slice($rows, $offset, $perPage),
'total' => count($rows),
'summary' => $this->storeReportSummary($rows),
];
}
/** @param list<array<string,mixed>> $rows */
private function storeReportSummary(array $rows): array
{
$normalStores = [];
$previousMonth = now()->startOfMonth()->subMonthNoOverflow();
$currentMonth = now()->startOfMonth();
$previousMonthKey = 'sales_' . $previousMonth->format('m');
$currentMonthKey = 'sales_' . $currentMonth->format('m');
$previousSales = 0.0;
$currentSales = 0.0;
$previousStores = [];
$currentStores = [];
foreach ($rows as $row) {
if (trim((string) ($row['status'] ?? '')) !== '开店中') {
continue;
}
$key = trim((string) ($row['erp_store_name'] ?? ''));
if ($key === '') {
$key = trim((string) ($row['store_id'] ?? $row['id'] ?? ''));
}
if ($key === '') {
continue;
}
$normalStores[$key] = true;
$previousValue = (float) ($row[$previousMonthKey] ?? 0);
$currentValue = (float) ($row[$currentMonthKey] ?? 0);
$previousSales += $previousValue;
$currentSales += $currentValue;
if ($previousValue > 0) {
$previousStores[$key] = true;
}
if ($currentValue > 0) {
$currentStores[$key] = true;
}
}
return [
'normal_stores' => count($normalStores),
'previous_month_sales' => round($previousSales, 2),
'current_month_sales' => round($currentSales, 2),
'previous_month_stores' => count($previousStores),
'current_month_stores' => count($currentStores),
'previous_month_key' => $previousMonthKey,
'current_month_key' => $currentMonthKey,
'previous_month_label' => $previousMonth->isSameYear($currentMonth)
? '上月(' . $previousMonth->format('n月') . ')'
: '上月(' . $previousMonth->format('Y年n月') . ')',
'current_month_label' => '当月(' . $currentMonth->format('n月') . ')',
];
return ['data' => array_slice($rows, $offset, $perPage), 'total' => count($rows)];
}
/** @return array{data:array<int,array<string,mixed>>,total:int} */
@ -488,11 +285,6 @@ SQL);
private function subjectOperatingReportPage(int $page, int $perPage): array
{
$filters = $this->pageFilters('company-management', 'subject-operating-report');
$companyStatusReady = $this->mirror->ready('company-information.read');
$companyStatus = trim((string) request('company_status', ''));
if (in_array($companyStatus, ['正常', '已注销'], true)) {
$filters['company_status'] = $companyStatus;
}
$rows = $this->storeReportRows(array_intersect_key($filters, array_flip(['company_type'])));
$entities = [];
@ -526,139 +318,34 @@ SQL);
$rows = array_values($entities);
$details = $this->companyDetails(array_column($rows, 'entity'));
try {
$classifications = $this->mirror->companyClassificationsByNames(array_column($rows, 'entity'));
} catch (Throwable) {
$classifications = [];
}
foreach ($rows as &$row) {
$row['company_details'] = $details[$row['entity']] ?? [];
$row['city'] = $this->cityFromDetails($row['company_details']);
$row['company_status'] = $row['company_details'][0]['company_status'] ?? null;
$classification = $classifications[$row['entity']] ?? [];
$taxType = $classification['tax_type']
?? $row['company_details'][0]['taxpayer_category']
?? null;
$businessType = $classification['business_registration_type']
?? $row['company_details'][0]['business_registration_type']
?? null;
$classificationStatus = trim((string) ($classification['status'] ?? ''));
$retained = $classificationStatus !== ''
? $classificationStatus === '保留'
: $row['company_status'] === '正常';
$row['company_status'] = $retained ? '正常' : '已注销';
// Excel 台账明确有税务类型时始终展示;没有时再回退到上游公司资料。
$row['tax_type'] = $this->normalizeTaxType($taxType) ?? '/';
$businessType = trim((string) $businessType);
$row['business_registration_type'] = $retained
&& in_array($businessType, ['核定', '个人独资企业'], true)
? $businessType
: '/';
}
unset($row);
// 整列为空的月份默认隐藏;金额 0 仍视为有账单,不隐藏。
// 所有主体该月收入均为空时,该月份列在页面上默认隐藏;「筛选字段」中仍保留勾选项,可手动选出来。
request()->attributes->set(
'subject-operating-report.default_hidden_columns',
$this->columnsWithoutData($rows, 'income_', zeroCountsAsData: true),
);
$rows = array_values(array_filter($rows, fn (array $row): bool => $this->subjectMatchesFilters($row, $filters)));
$summary = $this->subjectSummary($rows, $companyStatusReady);
$this->sortSubjectRows($rows, $filters);
$offset = ($page - 1) * $perPage;
$pageRows = array_slice($rows, $offset, $perPage);
return ['data' => $pageRows, 'total' => count($rows), 'summary' => $summary];
}
/** @param list<array<string,mixed>> $rows */
private function subjectSummary(array $rows, bool $companyStatusReady): array
{
$normal = 0;
$deregistered = 0;
$smallTaxpayers = 0;
$generalTaxpayers = 0;
$previousMonth = now()->startOfMonth()->subMonthNoOverflow();
$currentMonth = now()->startOfMonth();
$previousMonthKey = 'income_' . $previousMonth->format('m');
$currentMonthKey = 'income_' . $currentMonth->format('m');
$previousIncome = 0.0;
$currentMonthIncome = 0.0;
$previousIncomeEntities = 0;
$currentIncomeEntities = 0;
foreach ($rows as $row) {
$status = trim((string) ($row['company_status'] ?? ''));
if ($status === '正常') {
$normal++;
} elseif ($status === '已注销') {
$deregistered++;
}
if ($status !== '正常') {
continue;
}
$taxType = (string) ($row['tax_type'] ?? '');
if (str_contains($taxType, '小规模')) {
$smallTaxpayers++;
}
if (str_contains($taxType, '一般纳税人')) {
$generalTaxpayers++;
}
$previousValue = (float) ($row[$previousMonthKey] ?? 0);
$currentValue = (float) ($row[$currentMonthKey] ?? 0);
$previousIncome += $previousValue;
$currentMonthIncome += $currentValue;
if ($previousValue > 0) {
$previousIncomeEntities++;
}
if ($currentValue > 0) {
$currentIncomeEntities++;
}
}
return [
'total' => count($rows),
'normal' => $normal,
'deregistered' => $deregistered,
'small_taxpayer' => $smallTaxpayers,
'general_taxpayer' => $generalTaxpayers,
'previous_income' => round($previousIncome, 2),
'current_month_income' => round($currentMonthIncome, 2),
'previous_income_entities' => $previousIncomeEntities,
'current_income_entities' => $currentIncomeEntities,
'previous_month_key' => $previousMonthKey,
'current_month_key' => $currentMonthKey,
'previous_month_label' => $previousMonth->isSameYear($currentMonth)
? '上月(' . $previousMonth->format('n月') . ')'
: '上月(' . $previousMonth->format('Y年n月') . ')',
'current_month_label' => '当月(' . $currentMonth->format('n月') . ')',
'company_status_ready' => $companyStatusReady,
];
}
private function normalizeTaxType(mixed $value): ?string
{
$taxType = trim((string) $value);
if ($taxType === '') {
return null;
}
return match ($taxType) {
'小规模' => '小规模纳税人',
'一般纳税人', '小规模纳税人', '个体工商户', '无税务登记' => $taxType,
default => $taxType,
};
return ['data' => $pageRows, 'total' => count($rows)];
}
/**
* 返回整列都没有数据的月份列;这类列只默认隐藏,字段和数据不会被删除。
* 没有数据的月份列:所有行该月都没有数值时返回该列名,页面据此默认隐藏。
*
* @param list<array<string,mixed>> $rows
* @return list<string>
* 收入(income_)以 null/空字符串表示无账单,金额 0 也算有账单;
* 销量(sales_)没有空值标记,数值 0 视为当月没有业绩。
*
* @param array<int,array<string,mixed>> $rows
* @return array<int,string>
*/
private function columnsWithoutData(array $rows, string $prefix, bool $zeroCountsAsData): array
{
@ -687,16 +374,11 @@ SQL);
/** @param array<string,mixed> $row @param array<string,string> $filters */
private function subjectMatchesFilters(array $row, array $filters): bool
{
$companyStatus = trim((string) ($filters['company_status'] ?? ''));
if ($companyStatus !== '' && (string) ($row['company_status'] ?? '') !== $companyStatus) {
return false;
}
if (($filters['company_type'] ?? '') !== '' && (string) ($row['company_type'] ?? '') !== (string) $filters['company_type']) {
return false;
}
foreach (['city', 'entity', 'tax_type', 'business_registration_type', 'plan', 'new_entity'] as $key) {
foreach (['city', 'entity', 'plan', 'new_entity'] as $key) {
$needle = trim((string) ($filters[$key] ?? ''));
if ($needle !== '' && mb_stripos((string) ($row[$key] ?? ''), $needle) === false) {
return false;
@ -720,7 +402,7 @@ SQL);
{
$key = (string) ($filters['_sort'] ?? 'entity');
$allowed = [
'city', 'entity', 'company_type', 'tax_type', 'business_registration_type', 'rolling_12m_income',
'city', 'entity', 'company_type', 'rolling_12m_income',
...array_map(static fn (int $month): string => sprintf('income_%02d', $month), range(1, 12)),
'can_change_entity', 'plan', 'new_entity',
];
@ -821,8 +503,7 @@ SQL);
'company_status' => $row['companyStatus'] ?? $row['company_status'] ?? null,
'financial_officer' => $row['financialManager'] ?? $row['financial_officer'] ?? null,
'supervisor' => $row['supervisor'] ?? null,
'taxpayer_category' => $row['finance_tax_type'] ?? $row['taxCategoryAndType'] ?? $row['taxpayer_category'] ?? null,
'business_registration_type' => $row['business_registration_type'] ?? null,
'taxpayer_category' => $row['taxCategoryAndType'] ?? $row['taxpayer_category'] ?? null,
'shareholders' => $row['shareholders'] ?? null,
]);
}
@ -836,15 +517,14 @@ SQL);
$storeFilters = array_diff_key($filters, array_flip(['store_manager', 'department_manager', 'profit_center']));
$stores = $this->allStores($storeFilters);
foreach ($stores['data'] as &$store) {
$key = StoreAccountMirrorRepository::storeNameKey($store['erp_store_name'] ?? '');
$match = $ownership[$key] ?? null;
$match = $ownership[trim((string) ($store['erp_store_name'] ?? ''))] ?? null;
if ($match === null) {
continue;
}
// 未匹配到账号或字段为空时保留经营分析副本库原值。
foreach ([
'store_manager' => $match['operation_owner'],
'department_manager' => $match['operation_department_head'],
'department_manager' => $match['operation_director'],
'profit_center' => $match['profit_center'],
] as $field => $value) {
if ($value !== '') {
@ -862,15 +542,12 @@ SQL);
}
}
/** @return array<string,array{operation_owner:string,operation_department_head:string,operation_director:string,profit_center:string}> */
private function storeOwnership(bool $requireAccountOwnership = false): array
/** @return array<string,array{operation_owner:string,operation_director:string,profit_center:string}> */
private function storeOwnership(): array
{
try {
return ($this->storeAccounts ?? app(StoreAccountMirrorRepository::class))->ownershipByStoreName();
} catch (\RuntimeException | \InvalidArgumentException $exception) {
if ($requireAccountOwnership) {
throw new \RuntimeException('店铺账号密码共享镜像尚未就绪,无法关联负责人。', 0, $exception);
}
// 账号镜像未登记或未就绪时保留经营分析副本库原值,不阻断店铺报表。
return [];
}
@ -968,16 +645,9 @@ SQL);
];
}
private function page(
string $scope,
int $page,
int $perPage,
array $filters,
callable $resolver,
?bool $mirrorReady = null,
): array
private function page(string $scope, int $page, int $perPage, array $filters, callable $resolver): array
{
if ($mirrorReady ?? $this->mirror->ready($scope)) {
if ($this->mirror->ready($scope)) {
return $this->normalize($scope, $this->mirror->paginate($scope, $page, $perPage, $filters));
}
@ -1051,7 +721,6 @@ SQL);
return [
'data' => array_map($mapper, $payload['data']),
'total' => (int) $payload['total'],
'summary' => (array) ($payload['summary'] ?? []),
];
}
@ -1066,10 +735,6 @@ SQL);
'1' => '已注销',
default => $row['company_status'] ?? null,
};
$businessType = trim((string) ($row['business_registration_type'] ?? ''));
$row['business_registration_type'] = in_array($businessType, ['核定', '个人独资企业'], true)
? $businessType
: '/';
return $row;
}

View File

@ -1,243 +0,0 @@
<?php
namespace App\Business;
use App\Models\Business\StoreReceivingAccount;
use App\Models\Business\Todo;
use Carbon\CarbonImmutable;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
class WithdrawalBalanceAlertSynchronizer
{
public const CATEGORY = '提现余额预警';
public const TODO_STATUS = '待处理';
public function __construct(
private readonly JushuitanShopBalanceRepository $shopBalances,
) {
}
/**
* Evaluate the latest balance date for a company and persist one open todo
* per store that has exceeded its reserve standard without any change for
* three consecutive calendar days.
*
* @return array{balance_date:string,dates:list<string>,alerts:array<int,array<string,mixed>>,created:int,updated:int}
*/
public function syncLatest(int $companyId): array
{
$latest = $this->shopBalances->latestBalanceDate(now());
if ($latest === null) {
return [
'balance_date' => '',
'dates' => [],
'alerts' => [],
'created' => 0,
'updated' => 0,
];
}
return $this->syncForDate($companyId, $latest);
}
/**
* @return array{balance_date:string,dates:list<string>,alerts:array<int,array<string,mixed>>,created:int,updated:int}
*/
public function syncForDate(int $companyId, string $balanceDate, ?string $sourceScope = null): array
{
$dates = self::datesEndingAt($balanceDate);
$stores = StoreReceivingAccount::withoutGlobalScopes()
->where('company_id', $companyId)
->whereNull('deleted_at')
->orderBy('id')
->get();
if ($stores->isEmpty()) {
return [
'balance_date' => $balanceDate,
'dates' => $dates,
'alerts' => [],
'created' => 0,
'updated' => 0,
];
}
$balancesByDate = $this->shopBalances->matchesForStoresOnDates(
$stores,
$dates,
null,
$sourceScope,
);
$reserveStandards = $stores->mapWithKeys(
static fn (StoreReceivingAccount $store): array => [
(int) $store->id => WithdrawalReserveStandard::amountFor(
$store->store_platform,
$store->erp_store_name,
),
],
)->all();
$alerts = self::detect($stores, $balancesByDate, $reserveStandards, $dates);
[$created, $updated] = DB::transaction(function () use ($companyId, $stores, &$alerts): array {
$created = 0;
$updated = 0;
foreach ($stores as $store) {
$alert = $alerts[(int) $store->id] ?? null;
if ($alert === null) {
continue;
}
$title = $this->todoTitle($store);
$description = $this->description($store, $alert);
$todo = Todo::withoutGlobalScopes()
->where('company_id', $companyId)
->where('category', self::CATEGORY)
->where('title', $title)
->whereNull('deleted_at')
->whereNotIn('status', ['已完成', '已关闭'])
->orderByDesc('id')
->lockForUpdate()
->first();
if ($todo === null) {
$todo = Todo::withoutGlobalScopes()->create([
'company_id' => $companyId,
'title' => $title,
'category' => self::CATEGORY,
'priority' => '高',
'status' => self::TODO_STATUS,
'description' => $description,
]);
$created++;
} else {
$attributes = [
'priority' => '高',
'description' => $description,
];
if ($todo->status !== self::TODO_STATUS) {
$attributes['status'] = self::TODO_STATUS;
}
if ($todo->fill($attributes)->isDirty()) {
$todo->save();
$updated++;
}
}
$alerts[(int) $store->id]['todo_id'] = (int) $todo->id;
}
return [$created, $updated];
});
return [
'balance_date' => $balanceDate,
'dates' => $dates,
'alerts' => $alerts,
'created' => $created,
'updated' => $updated,
];
}
/**
* @param iterable<StoreReceivingAccount> $stores
* @param array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>> $balancesByDate
* @param array<int,int|null> $reserveStandards
* @param list<string> $dates
* @return array<int,array{balance:float,start_date:string,end_date:string,message:string}>
*/
public static function detect(
iterable $stores,
array $balancesByDate,
array $reserveStandards,
array $dates,
): array {
if (count($dates) !== 3) {
return [];
}
$alerts = [];
foreach ($stores as $store) {
$reserve = $reserveStandards[(int) $store->id] ?? null;
if ($reserve === null) {
continue;
}
$balanceCents = null;
$unchanged = true;
foreach ($dates as $date) {
$match = $balancesByDate[(int) $store->id][$date]
?? ['status' => 'not_ready', 'balance' => null];
if (($match['status'] ?? '') !== 'matched' || ($match['balance'] ?? null) === null) {
$unchanged = false;
break;
}
$cents = (int) round((float) $match['balance'] * 100);
if ($balanceCents === null) {
$balanceCents = $cents;
} elseif ($balanceCents !== $cents) {
$unchanged = false;
break;
}
}
if (! $unchanged || $balanceCents === null || $balanceCents <= $reserve * 100) {
continue;
}
$balance = $balanceCents / 100;
$alerts[(int) $store->id] = [
'balance' => $balance,
'start_date' => $dates[0],
'end_date' => $dates[2],
'message' => sprintf(
'余额连续 3 天保持 %s 元,高于预留标准 %s 元。',
number_format($balance, 2),
number_format($reserve, 2),
),
];
}
return $alerts;
}
/** @return list<string> */
public static function datesEndingAt(string $balanceDate): array
{
$end = CarbonImmutable::createFromFormat('!Y-m-d', $balanceDate);
if (! $end instanceof CarbonImmutable || $end->format('Y-m-d') !== $balanceDate) {
return [];
}
return [
$end->subDays(2)->toDateString(),
$end->subDay()->toDateString(),
$end->toDateString(),
];
}
private function todoTitle(StoreReceivingAccount $store): string
{
return mb_substr(
'提现余额连续3天未变动|' . (string) $store->store_platform . '|' . (string) $store->erp_store_name,
0,
255,
);
}
/** @param array{balance:float,start_date:string,end_date:string,message:string} $alert */
private function description(StoreReceivingAccount $store, array $alert): string
{
return implode("\n", array_filter([
$alert['message'],
'统计期间:' . $alert['start_date'] . ' 至 ' . $alert['end_date'],
'店铺平台:' . ($store->store_platform ?: '未配置'),
'ERP店铺名称:' . ($store->erp_store_name ?: '未配置'),
'店铺主体:' . ($store->store_entity_name ?: '未配置'),
'平台店铺ID:' . ($store->store_id ?: '未配置'),
'处理建议:核实店铺余额是否真实停滞,并检查是否应执行提现。',
], static fn (string $line): bool => $line !== ''));
}
}

View File

@ -3,7 +3,6 @@
namespace App\Business;
use App\Models\Business\StoreReceivingAccount;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
@ -18,10 +17,7 @@ class WithdrawalManagementReport
'withdrawal_path',
'withdrawal_account',
'bank_card',
'balance_two_days_ago',
'balance_yesterday',
'balance',
'balance_alert',
'reserve_standard',
'withdrawal_amount',
'compliance',
@ -34,15 +30,12 @@ class WithdrawalManagementReport
/**
* @return array{
* filters:array{platform:string,withdrawalPath:string,storeEntity:string,keyword:string,balanceStatus:string,balanceDate:string},
* filters:array{platform:string,withdrawalPath:string,storeEntity:string,keyword:string,balanceStatus:string},
* platforms:list<string>,paths:list<string>,storeEntities:list<string>,rows:Collection<int,StoreReceivingAccount>,
* balances:array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}>,
* balancesByDate:array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}>>,
* balanceDays:list<array{key:string,date:string,label:string}>,
* alerts:array<int,array{balance:float,start_date:string,end_date:string,message:string}>,
* balances:array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>,
* reserveStandards:array<int,int|null>,withdrawalAmounts:array<int,float|null>,compliance:array<int,string|null>,
* summary:array{source:int,matched:int,finance:int,ambiguous:int,unmatched:int,not_ready:int},
* balanceColumnLabel:string,balanceDate:string,latestBalanceDate:?string,balanceDates:list<string>,sort:array{field:string,direction:string}
* summary:array{source:int,matched:int,ambiguous:int,unmatched:int,not_ready:int},
* balanceColumnLabel:string,balanceDate:string,sort:array{field:string,direction:string}
* }
*/
public function results(Request $request): array
@ -68,33 +61,15 @@ class WithdrawalManagementReport
}
$today = now()->startOfDay();
// 余额快照按日期长期保留:默认显示最新余额日期,也可指定历史日期回看。
$latestBalanceDate = $this->shopBalances->latestBalanceDate($today);
$balanceDate = $filters['balanceDate'] !== ''
? $filters['balanceDate']
: ($latestBalanceDate ?? $today->copy()->subDay()->toDateString());
$labelDate = $filters['balanceDate'] !== ''
? CarbonImmutable::parse($balanceDate)
: $today;
// 余额快照按导入日期保留 5 个自然日:优先显示最近 5 天内导入的最新余额日期;
// 当天尚未导入新数据时,继续沿用最近一次可用余额,避免页面直接变成待同步。
$balanceDate = $this->shopBalances->latestDateInRetention($today)
?? $today->copy()->subDay()->toDateString();
$rows = $query->orderByDesc('id')->get();
$historyDates = WithdrawalBalanceAlertSynchronizer::datesEndingAt($balanceDate);
$balancesByDate = $this->shopBalances->matchesForStoresOnDates($rows, $historyDates);
$balances = array_map(
static fn (array $dates): array => $dates[$balanceDate] ?? [
'status' => 'not_ready',
'balance' => null,
'source_store_name' => null,
'source_platform' => null,
],
$balancesByDate,
);
$balances = $this->shopBalances->matchesForStores($rows, $balanceDate, $today);
$balanceCounts = collect($balances)->countBy(
static fn (array $match): string => $match['status'],
);
$financeCount = collect($balances)->filter(
static fn (array $match): bool => ($match['data_source'] ?? null)
=== JushuitanShopBalanceRepository::DATA_SOURCE_FINANCE,
)->count();
if ($filters['balanceStatus'] !== '') {
$rows = $rows->filter(
@ -110,19 +85,6 @@ class WithdrawalManagementReport
),
],
)->all();
$balanceDays = collect($historyDates)->map(
static fn (string $date, int $index): array => [
'key' => $index === 0 ? 'balance_two_days_ago' : ($index === 1 ? 'balance_yesterday' : 'balance'),
'date' => $date,
'label' => CarbonImmutable::parse($date)->format('n月j日') . '店铺余额',
],
)->values()->all();
$alerts = WithdrawalBalanceAlertSynchronizer::detect(
$rows,
$balancesByDate,
$reserveStandards,
$historyDates,
);
$withdrawalAmounts = $rows->mapWithKeys(
fn (StoreReceivingAccount $row): array => [
(int) $row->id => $this->withdrawalAmountFor($row, $balanceDate),
@ -130,7 +92,7 @@ class WithdrawalManagementReport
)->all();
$compliance = $rows->mapWithKeys(
fn (StoreReceivingAccount $row): array => [
(int) $row->id => self::complianceFor(
(int) $row->id => $this->complianceFor(
$balances[(int) $row->id] ?? ['status' => 'not_ready', 'balance' => null],
$reserveStandards[(int) $row->id] ?? null,
$withdrawalAmounts[(int) $row->id] ?? null,
@ -139,16 +101,7 @@ class WithdrawalManagementReport
)->all();
$rows = $sort['field'] === ''
? $this->sortByBalanceStatus($rows, $balances)
: $this->sortRows(
$rows,
$sort,
$balancesByDate,
$balanceDays,
$alerts,
$reserveStandards,
$withdrawalAmounts,
$compliance,
);
: $this->sortRows($rows, $sort, $balances, $reserveStandards, $withdrawalAmounts, $compliance);
return [
'filters' => $filters,
@ -157,43 +110,31 @@ class WithdrawalManagementReport
'storeEntities' => $storeEntities,
'rows' => $rows,
'balances' => $balances,
'balancesByDate' => $balancesByDate,
'balanceDays' => $balanceDays,
'alerts' => $alerts,
'reserveStandards' => $reserveStandards,
'withdrawalAmounts' => $withdrawalAmounts,
'compliance' => $compliance,
'summary' => [
'source' => $this->shopBalances->countForDate($balanceDate),
'source' => $this->shopBalances->countForDate($balanceDate, $today),
'matched' => (int) $balanceCounts->get('matched', 0),
'finance' => $financeCount,
'ambiguous' => (int) $balanceCounts->get('ambiguous', 0),
'unmatched' => (int) $balanceCounts->get('unmatched', 0),
'not_ready' => (int) $balanceCounts->get('not_ready', 0),
],
'balanceColumnLabel' => $labelDate->format('n月j日') . '店铺余额',
'balanceColumnLabel' => $today->format('n月j日') . '店铺余额',
'balanceDate' => $balanceDate,
'latestBalanceDate' => $latestBalanceDate,
'balanceDates' => $this->shopBalances->availableDates($today),
'sort' => $sort,
];
}
/** @return array{platform:string,withdrawalPath:string,storeEntity:string,keyword:string,balanceStatus:string,balanceDate:string} */
/** @return array{platform:string,withdrawalPath:string,storeEntity:string,keyword:string,balanceStatus:string} */
public function filters(Request $request): array
{
$clean = static fn (mixed $value, int $length = 80): string => mb_substr(trim((string) $value), 0, $length);
$balanceStatus = $clean($request->query('balance_status'));
$balanceDate = $clean($request->query('balance_date'), 10);
if (! in_array($balanceStatus, ['matched', 'unmatched', 'ambiguous', 'not_ready'], true)) {
$balanceStatus = '';
}
$validDate = preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $balanceDate, $parts) === 1
&& checkdate((int) $parts[2], (int) $parts[3], (int) $parts[1]);
if (! $validDate || $balanceDate > now()->toDateString()) {
$balanceDate = '';
}
return [
'platform' => $clean($request->query('platform')),
@ -201,7 +142,6 @@ class WithdrawalManagementReport
'storeEntity' => $clean($request->query('store_entity'), 191),
'keyword' => $clean($request->query('keyword'), 191),
'balanceStatus' => $balanceStatus,
'balanceDate' => $balanceDate,
];
}
@ -233,7 +173,7 @@ class WithdrawalManagementReport
/**
* @param Collection<int,StoreReceivingAccount> $rows
* @param array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}> $balances
* @param array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}> $balances
* @return Collection<int,StoreReceivingAccount>
*/
private function sortByBalanceStatus(Collection $rows, array $balances): Collection
@ -256,35 +196,17 @@ class WithdrawalManagementReport
/**
* @param Collection<int,StoreReceivingAccount> $rows
* @param array{field:string,direction:string} $sort
* @param array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}>> $balancesByDate
* @param list<array{key:string,date:string,label:string}> $balanceDays
* @param array<int,array{balance:float,start_date:string,end_date:string,message:string}> $alerts
* @param array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}> $balances
* @param array<int,int|null> $reserveStandards
* @param array<int,float|null> $withdrawalAmounts
* @param array<int,string|null> $compliance
* @return Collection<int,StoreReceivingAccount>
*/
private function sortRows(
Collection $rows,
array $sort,
array $balancesByDate,
array $balanceDays,
array $alerts,
array $reserveStandards,
array $withdrawalAmounts,
array $compliance,
): Collection {
return $rows->sort(function (StoreReceivingAccount $left, StoreReceivingAccount $right) use (
$sort,
$balancesByDate,
$balanceDays,
$alerts,
$reserveStandards,
$withdrawalAmounts,
$compliance,
): int {
$leftValue = $this->sortValue($left, $sort['field'], $balancesByDate, $balanceDays, $alerts, $reserveStandards, $withdrawalAmounts, $compliance);
$rightValue = $this->sortValue($right, $sort['field'], $balancesByDate, $balanceDays, $alerts, $reserveStandards, $withdrawalAmounts, $compliance);
private function sortRows(Collection $rows, array $sort, array $balances, array $reserveStandards, array $withdrawalAmounts, array $compliance): Collection
{
return $rows->sort(function (StoreReceivingAccount $left, StoreReceivingAccount $right) use ($sort, $balances, $reserveStandards, $withdrawalAmounts, $compliance): int {
$leftValue = $this->sortValue($left, $sort['field'], $balances, $reserveStandards, $withdrawalAmounts, $compliance);
$rightValue = $this->sortValue($right, $sort['field'], $balances, $reserveStandards, $withdrawalAmounts, $compliance);
$comparison = $this->compareSortValues($leftValue, $rightValue, $sort['direction']);
return $comparison !== 0
@ -294,38 +216,13 @@ class WithdrawalManagementReport
}
/**
* @param array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>> $balancesByDate
* @param list<array{key:string,date:string,label:string}> $balanceDays
* @param array<int,array{balance:float,start_date:string,end_date:string,message:string}> $alerts
* @param array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}> $balances
* @param array<int,int|null> $reserveStandards
* @param array<int,float|null> $withdrawalAmounts
* @param array<int,string|null> $compliance
*/
private function sortValue(
StoreReceivingAccount $row,
string $field,
array $balancesByDate,
array $balanceDays,
array $alerts,
array $reserveStandards,
array $withdrawalAmounts,
array $compliance,
): mixed {
$balanceFor = static function (string $dayKey) use ($row, $balancesByDate, $balanceDays): ?float {
$date = null;
foreach ($balanceDays as $day) {
if ($day['key'] === $dayKey) {
$date = $day['date'];
break;
}
}
$match = $date === null
? null
: ($balancesByDate[(int) $row->id][$date] ?? null);
return ($match['status'] ?? null) === 'matched' ? (float) $match['balance'] : null;
};
private function sortValue(StoreReceivingAccount $row, string $field, array $balances, array $reserveStandards, array $withdrawalAmounts, array $compliance): mixed
{
return match ($field) {
'platform' => $row->store_platform,
'erp_store_name' => $row->erp_store_name,
@ -334,10 +231,9 @@ class WithdrawalManagementReport
'withdrawal_path' => $row->withdrawal_path,
'withdrawal_account' => $row->withdrawal_account_name,
'bank_card' => $row->bank_card_last_four,
'balance_two_days_ago' => $balanceFor('balance_two_days_ago'),
'balance_yesterday' => $balanceFor('balance_yesterday'),
'balance' => $balanceFor('balance'),
'balance_alert' => isset($alerts[(int) $row->id]) ? 1 : 0,
'balance' => ($balances[(int) $row->id]['status'] ?? null) === 'matched'
? $balances[(int) $row->id]['balance']
: null,
'reserve_standard' => $reserveStandards[(int) $row->id] ?? null,
'withdrawal_amount' => $withdrawalAmounts[(int) $row->id] ?? null,
'compliance' => $compliance[(int) $row->id] ?? null,
@ -355,7 +251,7 @@ class WithdrawalManagementReport
}
/** @param array{status:string,balance:?float} $balance */
public static function complianceFor(array $balance, ?int $reserve, ?float $withdrawalAmount): ?string
private function complianceFor(array $balance, ?int $reserve, ?float $withdrawalAmount): ?string
{
if (($balance['status'] ?? '') !== 'matched' || $balance['balance'] === null || $reserve === null || $withdrawalAmount === null) {
return null;

View File

@ -12,7 +12,6 @@ use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Style\Alignment;
use PhpOffice\PhpSpreadsheet\Style\Fill;
use PhpOffice\PhpSpreadsheet\Cell\DataValidation;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
use Modules\EcommerceFinance\Repositories\JushuitanShopBalanceRepository;
use Symfony\Component\HttpFoundation\StreamedResponse;
@ -31,17 +30,6 @@ class WithdrawalManagementSpreadsheet
];
private const CLEAR_VALUE = '[清空]';
private const WITHDRAWAL_TEMPLATE_HEADERS = [
'店铺平台',
'ERP店铺名称',
'平台店铺ID',
'余额日期',
'店铺余额',
'预留标准',
'提现金额',
'提现后余额',
'是否达标',
];
public function __construct(
private readonly JushuitanShopBalanceRepository $shopBalances,
@ -51,10 +39,7 @@ class WithdrawalManagementSpreadsheet
/**
* @param array{
* rows:\Illuminate\Support\Collection<int,StoreReceivingAccount>,
* balances:array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}>,
* balancesByDate:array<int,array<string,array{status:string,balance:?float,source_store_name:?string,source_platform:?string,data_source:?string}>>,
* balanceDays:list<array{key:string,date:string,label:string}>,
* alerts:array<int,array{balance:float,start_date:string,end_date:string,message:string}>,
* balances:array<int,array{status:string,balance:?float,source_store_name:?string,source_platform:?string}>,
* reserveStandards:array<int,int|null>,withdrawalAmounts:array<int,float|null>,compliance:array<int,string|null>,
* balanceColumnLabel:string,balanceDate:string
* } $report
@ -97,113 +82,10 @@ class WithdrawalManagementSpreadsheet
return $this->download($spreadsheet, 'withdrawal_management_import_template.xlsx');
}
/**
* 按当前筛选预填店铺和余额参考值,同事只需填写提现金额。
*
* @param array<string,mixed> $report
*/
public function withdrawalTemplate(array $report): StreamedResponse
{
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('提现金额');
$this->writeHeader($sheet, self::WITHDRAWAL_TEMPLATE_HEADERS);
$this->sizeColumns($sheet, [14, 30, 20, 14, 18, 18, 18, 18, 14]);
$rowNumber = 2;
foreach ($report['rows'] as $row) {
$storeId = (int) $row->id;
$balanceMatch = $report['balances'][$storeId] ?? [
'status' => 'not_ready',
'balance' => null,
];
$hasBalance = ($balanceMatch['status'] ?? '') === 'matched'
&& ($balanceMatch['balance'] ?? null) !== null;
$reserve = $report['reserveStandards'][$storeId] ?? null;
$amount = $report['withdrawalAmounts'][$storeId] ?? null;
foreach ([
'A' => (string) ($row->store_platform ?? ''),
'B' => (string) ($row->erp_store_name ?? ''),
'C' => (string) ($row->store_id ?? ''),
'D' => (string) $report['balanceDate'],
] as $column => $value) {
$sheet->setCellValueExplicit("{$column}{$rowNumber}", $value, DataType::TYPE_STRING);
}
if ($hasBalance) {
$sheet->setCellValue("E{$rowNumber}", (float) $balanceMatch['balance']);
} else {
$sheet->setCellValueExplicit("E{$rowNumber}", $this->balanceStatus((string) ($balanceMatch['status'] ?? 'not_ready')), DataType::TYPE_STRING);
}
if ($reserve !== null) {
$sheet->setCellValue("F{$rowNumber}", $reserve);
} else {
$sheet->setCellValueExplicit("F{$rowNumber}", '待配置', DataType::TYPE_STRING);
}
if ($amount !== null) {
$sheet->setCellValue("G{$rowNumber}", $amount);
}
$sheet->setCellValue(
"H{$rowNumber}",
"=IF(OR(NOT(ISNUMBER(E{$rowNumber})),G{$rowNumber}=\"\"),\"\",E{$rowNumber}-G{$rowNumber})",
);
$sheet->setCellValue(
"I{$rowNumber}",
"=IF(OR(H{$rowNumber}=\"\",NOT(ISNUMBER(F{$rowNumber}))),\"\",IF(H{$rowNumber}<=F{$rowNumber},\"是\",\"否\"))",
);
$rowNumber++;
}
$lastDataRow = max(2, $rowNumber - 1);
$sheet->getStyle("E2:I{$lastDataRow}")->getNumberFormat()->setFormatCode('#,##0.00');
$sheet->getStyle("E2:I{$lastDataRow}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
$sheet->getStyle("G2:G{$lastDataRow}")->getFill()
->setFillType(Fill::FILL_SOLID)
->getStartColor()->setARGB('FFF7F0DE');
$sheet->getStyle('G1')->getFill()
->setFillType(Fill::FILL_SOLID)
->getStartColor()->setARGB('FFB9954F');
$validation = new DataValidation();
$validation->setType(DataValidation::TYPE_DECIMAL)
->setOperator(DataValidation::OPERATOR_GREATERTHANOREQUAL)
->setFormula1('0')
->setAllowBlank(true)
->setShowErrorMessage(true)
->setErrorTitle('提现金额格式错误')
->setError('请填写非负金额,最多两位小数。');
$sheet->setDataValidation("G2:G{$lastDataRow}", $validation);
$instructions = $spreadsheet->createSheet();
$instructions->setTitle('填写说明');
$instructions->fromArray([
['提现金额导入说明'],
['1. 本模板按下载时的页面筛选生成,已预填店铺标识、余额日期、店铺余额和预留标准。'],
['2. 只需填写黄色“提现金额”列;未填写金额的店铺不会提交。填写“' . self::CLEAR_VALUE . '”可清空该店铺提现金额。'],
['3. 店铺余额、预留标准、提现后余额和是否达标仅用于下载时预览,系统会在上传后按最新余额日期重新计算。'],
['4. 判定规则:店铺余额 - 提现金额 ≤ 预留标准时,“是否达标”为“是”,即该笔提现按大笔提现处理。'],
['5. 模板余额日期为 ' . $report['balanceDate'] . ';日期滚动后请重新下载模板,避免上传过期金额。'],
], null, 'A1');
$instructions->getStyle('A1')->getFont()->setBold(true)->setSize(14);
$instructions->getColumnDimension('A')->setWidth(96);
$instructions->getStyle('A1:A6')->getAlignment()->setWrapText(true)->setVertical(Alignment::VERTICAL_TOP);
foreach (range(1, 6) as $row) {
$instructions->getRowDimension($row)->setRowHeight($row === 1 ? 26 : 36);
}
$spreadsheet->setActiveSheetIndex(0);
return $this->download(
$spreadsheet,
'withdrawal_amount_import_' . str_replace('-', '', (string) $report['balanceDate']) . '.xlsx',
);
}
/**
* @return array{
* total:int,changed:int,unchanged:int,
* preview:list<array{platform:string,erp_store_name:string,store_id:string,changes:string,withdrawal_amount:?float,post_withdrawal_balance:?float,reserve_standard:?int,compliance:?string}>,
* preview:list<array{platform:string,erp_store_name:string,store_id:string,changes:string}>,
* plan:list<array{id:int,updated_at:string,changes:array<string,string|null>}>
* }
*/
@ -219,7 +101,7 @@ class WithdrawalManagementSpreadsheet
'import' => 'Excel 文件无法读取,请确认文件未损坏并使用最新模板。',
]);
}
[$headerIndex, $headers, $headerMode] = $this->headers($matrix);
[$headerIndex, $headers] = $this->headers($matrix);
$indexes = array_flip($headers);
$balanceDate = $this->currentBalanceDate();
$accounts = StoreReceivingAccount::withoutGlobalScopes()
@ -227,8 +109,6 @@ class WithdrawalManagementSpreadsheet
->whereNull('deleted_at')
->get();
[$byId, $byName] = $this->indexes($accounts);
$balances = $this->shopBalances->matchesForStores($accounts, $balanceDate);
$withdrawalOnlyMode = $headerMode === 'withdrawal';
$errors = [];
$plan = [];
$preview = [];
@ -238,15 +118,12 @@ class WithdrawalManagementSpreadsheet
foreach (array_slice($matrix, $headerIndex + 1, null, true) as $matrixIndex => $row) {
$values = [];
foreach ([...self::CONFIG_HEADERS, '提现金额', '余额日期'] as $header) {
$values[$header] = isset($indexes[$header])
? trim((string) ($row[$indexes[$header]] ?? ''))
: '';
}
// 专用模板会预填所有店铺,只把填写了提现金额的行视为提交数据。
if ($withdrawalOnlyMode && $values['提现金额'] === '') {
continue;
foreach (self::CONFIG_HEADERS as $header) {
$values[$header] = trim((string) ($row[$indexes[$header]] ?? ''));
}
$values['提现金额'] = isset($indexes['提现金额'])
? trim((string) ($row[$indexes['提现金额']] ?? ''))
: '';
if (count(array_filter($values, static fn (string $value): bool => $value !== '')) === 0) {
continue;
}
@ -260,13 +137,8 @@ class WithdrawalManagementSpreadsheet
$erpStoreName = mb_substr($values['ERP店铺名称'], 0, 191);
$storeId = mb_substr(ltrim($values['平台店铺ID'], "'"), 0, 191);
if ($platform === '' || ($storeId === '' && $erpStoreName === '')) {
$errors[] = "第 {$line} 行缺少店铺平台或店铺标识";
continue;
}
if ($withdrawalOnlyMode && $values['余额日期'] !== '' && $values['余额日期'] !== $balanceDate) {
$errors[] = "第 {$line} 行余额日期 {$values['余额日期']} 已过期,请按最新余额日期 {$balanceDate} 重新下载模板";
if ($platform === '' || $erpStoreName === '') {
$errors[] = "第 {$line} 行缺少店铺平台或 ERP 店铺名称";
continue;
}
@ -321,40 +193,11 @@ class WithdrawalManagementSpreadsheet
'changes' => $changes,
];
if (count($preview) < 20) {
$amount = array_key_exists('withdrawal_amount', $changes)
? ($changes['withdrawal_amount'] === null ? null : (float) $changes['withdrawal_amount'])
: (
$account->withdrawal_balance_date === $balanceDate
&& $account->withdrawal_amount !== null
&& is_numeric($account->withdrawal_amount)
? (float) $account->withdrawal_amount
: null
);
$balance = ($balances[(int) $account->id]['status'] ?? '') === 'matched'
? (float) $balances[(int) $account->id]['balance']
: null;
$reserve = WithdrawalReserveStandard::amountFor(
$account->store_platform,
$account->erp_store_name,
);
$preview[] = [
'platform' => $platform,
'erp_store_name' => $erpStoreName,
'store_id' => $storeId,
'changes' => implode('、', $changedLabels),
'withdrawal_amount' => $amount,
'post_withdrawal_balance' => $balance !== null && $amount !== null
? round($balance - $amount, 2)
: null,
'reserve_standard' => $reserve,
'compliance' => WithdrawalManagementReport::complianceFor(
[
'status' => $balance === null ? 'not_ready' : 'matched',
'balance' => $balance,
],
$reserve,
$amount,
),
];
}
}
@ -428,36 +271,19 @@ class WithdrawalManagementSpreadsheet
private function workbook(array $report): Spreadsheet
{
$balanceHeaders = array_map(
static fn (array $day): string => (string) $day['label'],
$report['balanceDays'],
);
$headers = [
...self::CONFIG_HEADERS,
...$balanceHeaders,
'余额来源',
'余额预警',
'预留标准',
'提现金额',
'是否达标',
];
$headers = [...self::CONFIG_HEADERS, $report['balanceColumnLabel'], '预留标准', '提现金额', '是否达标'];
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('提现管理');
$this->writeHeader($sheet, $headers);
$this->sizeColumns($sheet, [14, 32, 20, 32, 16, 24, 22, 20, 20, 20, 32, 18, 18, 18, 14]);
$this->sizeColumns($sheet, [14, 32, 20, 32, 16, 24, 22, 20, 18, 18, 14]);
$rowNumber = 2;
foreach ($report['rows'] as $row) {
$match = $report['balances'][(int) $row->id] ?? ['status' => 'not_ready', 'balance' => null];
$reserve = $report['reserveStandards'][(int) $row->id] ?? null;
$withdrawalAmount = $report['withdrawalAmounts'][(int) $row->id] ?? null;
$compliance = $report['compliance'][(int) $row->id] ?? null;
$alert = $report['alerts'][(int) $row->id] ?? null;
$hasCompleteAlertSeries = collect($report['balanceDays'])->every(
fn (array $day): bool => (
$report['balancesByDate'][(int) $row->id][(string) $day['date']]['status'] ?? ''
) === 'matched',
);
$textValues = [
1 => (string) ($row->store_platform ?? ''),
2 => (string) ($row->erp_store_name ?? ''),
@ -471,46 +297,22 @@ class WithdrawalManagementSpreadsheet
$sheet->setCellValueExplicit([$column, $rowNumber], $value, DataType::TYPE_STRING);
}
foreach ($report['balanceDays'] as $dayIndex => $day) {
$column = 8 + $dayIndex;
$match = $report['balancesByDate'][(int) $row->id][$day['date']]
?? ['status' => 'not_ready', 'balance' => null];
if ($match['status'] === 'matched') {
$sheet->setCellValue([$column, $rowNumber], (float) $match['balance']);
} else {
$sheet->setCellValueExplicit([$column, $rowNumber], $this->balanceStatus($match['status']), DataType::TYPE_STRING);
}
}
$sourceParts = [];
foreach ($report['balanceDays'] as $day) {
$match = $report['balancesByDate'][(int) $row->id][$day['date']]
?? ['status' => 'not_ready'];
$sourceParts[] = str_replace('店铺余额', '', (string) $day['label']) . ':' . match ($match['status']) {
'matched' => ($match['data_source'] ?? null) === 'finance' ? '财务系统' : '聚水潭',
'ambiguous' => '多条匹配',
'unmatched' => '未匹配',
default => '待同步',
};
}
$sheet->setCellValueExplicit([11, $rowNumber], implode(';', $sourceParts), DataType::TYPE_STRING);
$sheet->setCellValueExplicit(
[12, $rowNumber],
$alert !== null ? '连续3天未变动' : ($hasCompleteAlertSeries ? '未触发' : '数据不足'),
DataType::TYPE_STRING,
);
if ($reserve === null) {
$sheet->setCellValueExplicit([13, $rowNumber], '待配置', DataType::TYPE_STRING);
if ($match['status'] === 'matched') {
$sheet->setCellValue([8, $rowNumber], (float) $match['balance']);
} else {
$sheet->setCellValue([13, $rowNumber], $reserve);
$sheet->setCellValueExplicit([8, $rowNumber], $this->balanceStatus($match['status']), DataType::TYPE_STRING);
}
if ($reserve === null) {
$sheet->setCellValueExplicit([9, $rowNumber], '待配置', DataType::TYPE_STRING);
} else {
$sheet->setCellValue([9, $rowNumber], $reserve);
}
if ($withdrawalAmount === null) {
$sheet->setCellValueExplicit([14, $rowNumber], '待接入', DataType::TYPE_STRING);
$sheet->setCellValueExplicit([10, $rowNumber], '待接入', DataType::TYPE_STRING);
} else {
$sheet->setCellValue([14, $rowNumber], $withdrawalAmount);
$sheet->setCellValue([10, $rowNumber], $withdrawalAmount);
}
$sheet->setCellValueExplicit([15, $rowNumber], match ($compliance) {
$sheet->setCellValueExplicit([11, $rowNumber], match ($compliance) {
'yes' => '是',
'no' => '否',
default => '待核验',
@ -520,9 +322,7 @@ class WithdrawalManagementSpreadsheet
if ($rowNumber > 2) {
$sheet->getStyle('H2:J' . ($rowNumber - 1))->getNumberFormat()->setFormatCode('#,##0.00');
$sheet->getStyle('M2:N' . ($rowNumber - 1))->getNumberFormat()->setFormatCode('#,##0.00');
$sheet->getStyle('H2:J' . ($rowNumber - 1))->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
$sheet->getStyle('M2:N' . ($rowNumber - 1))->getAlignment()->setHorizontal(Alignment::HORIZONTAL_RIGHT);
}
$sheet->getStyle('C2:C' . max(2, $rowNumber - 1))->getNumberFormat()->setFormatCode('@');
$sheet->getStyle('G2:G' . max(2, $rowNumber - 1))->getNumberFormat()->setFormatCode('@');
@ -531,47 +331,25 @@ class WithdrawalManagementSpreadsheet
return $spreadsheet;
}
/** @return array{0:int,1:list<string>,2:'config'|'withdrawal'} */
/** @return array{0:int,1:list<string>} */
private function headers(array $matrix): array
{
foreach (array_slice($matrix, 0, 10, true) as $index => $row) {
$headers = array_map(static fn (mixed $value): string => trim((string) $value), $row);
if (! in_array('店铺平台', $headers, true) || ! in_array('提现金额', $headers, true)) {
continue;
}
$this->requireUniqueHeader($headers, '店铺平台');
$this->requireUniqueHeader($headers, '提现金额');
$fullConfigurationTemplate = collect(self::CONFIG_HEADERS)->every(
static fn (string $header): bool => in_array($header, $headers, true),
);
if ($fullConfigurationTemplate) {
if (in_array('店铺平台', $headers, true) && in_array('ERP店铺名称', $headers, true)) {
foreach (self::CONFIG_HEADERS as $required) {
$this->requireUniqueHeader($headers, $required);
if (count(array_keys($headers, $required, true)) !== 1) {
throw ValidationException::withMessages([
'import' => "导入表头必须且只能包含一个“{$required}”列,请下载最新模板。",
]);
}
}
if (count(array_keys($headers, '提现金额', true)) > 1) {
throw ValidationException::withMessages(['import' => '导入表头只能包含一个“提现金额”列。']);
}
return [(int) $index, $headers, 'config'];
return [(int) $index, $headers];
}
if (! in_array('ERP店铺名称', $headers, true) && ! in_array('平台店铺ID', $headers, true)) {
throw ValidationException::withMessages([
'import' => '提现金额模板必须包含“ERP店铺名称”或“平台店铺ID”用于定位店铺,请下载最新模板。',
]);
}
foreach ([
'ERP店铺名称', '平台店铺ID', '余额日期', '店铺余额', '预留标准',
'提现后余额', '是否达标',
] as $optional) {
if (count(array_keys($headers, $optional, true)) > 1) {
throw ValidationException::withMessages([
'import' => "导入表头只能包含一个“{$optional}”列。",
]);
}
}
return [(int) $index, $headers, 'withdrawal'];
}
throw ValidationException::withMessages([
@ -579,16 +357,6 @@ class WithdrawalManagementSpreadsheet
]);
}
/** @param list<string> $headers */
private function requireUniqueHeader(array $headers, string $header): void
{
if (count(array_keys($headers, $header, true)) !== 1) {
throw ValidationException::withMessages([
'import' => "导入表头必须且只能包含一个“{$header}”列,请下载最新模板。",
]);
}
}
/** @return array{0:array<string,list<StoreReceivingAccount>>,1:array<string,list<StoreReceivingAccount>>} */
private function indexes($accounts): array
{
@ -668,7 +436,7 @@ class WithdrawalManagementSpreadsheet
{
$today = now()->startOfDay();
return $this->shopBalances->latestBalanceDate($today)
return $this->shopBalances->latestDateInRetention($today)
?? $today->copy()->subDay()->toDateString();
}

View File

@ -82,8 +82,6 @@ class XlsxSheetReader
xml_set_element_handler(
$parser,
static function ($parser, string $name, array $attributes) use (&$state): void {
$name = self::localName($name);
switch ($name) {
case 'row':
$state['row'] = [];
@ -103,8 +101,6 @@ class XlsxSheetReader
}
},
static function ($parser, string $name) use (&$state, $shared, $columns, $optionalColumns): void {
$name = self::localName($name);
switch ($name) {
case 'v':
case 't':
@ -296,8 +292,6 @@ class XlsxSheetReader
xml_set_element_handler(
$parser,
static function ($parser, string $name) use (&$inside, &$buffer, &$index): void {
$name = self::localName($name);
if ($name === 'si') {
$index++;
$buffer = '';
@ -306,8 +300,6 @@ class XlsxSheetReader
}
},
static function ($parser, string $name) use (&$inside, &$buffer, &$out): void {
$name = self::localName($name);
if ($name === 't') {
$inside = false;
} elseif ($name === 'si') {
@ -329,11 +321,4 @@ class XlsxSheetReader
return $out;
}
private static function localName(string $name): string
{
$separator = strrpos($name, ':');
return $separator === false ? $name : substr($name, $separator + 1);
}
}

View File

@ -1,50 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Business\StoreInformationDailyArchive;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
use Throwable;
class ArchiveStoreInformationDaily extends Command
{
protected $signature = 'business-mirror:archive-store-information
{--date= : 快照日期,格式 YYYY-MM-DD;默认今天}';
protected $description = '每日保存一份店铺基础信息完整快照,便于后续复查';
public function handle(StoreInformationDailyArchive $archive): int
{
$date = trim((string) ($this->option('date') ?: now('Asia/Shanghai')->format('Y-m-d')));
if (! preg_match('/^\d{4}-\d{2}-\d{2}$/D', $date)) {
$this->error('快照日期格式应为 YYYY-MM-DD。');
return self::FAILURE;
}
try {
$snapshotDate = CarbonImmutable::createFromFormat('!Y-m-d', $date, 'Asia/Shanghai');
} catch (Throwable) {
$snapshotDate = null;
}
if ($snapshotDate === null || $snapshotDate->format('Y-m-d') !== $date) {
$this->error('快照日期不是有效日期。');
return self::FAILURE;
}
try {
$count = $archive->capture($snapshotDate);
} catch (Throwable $exception) {
report($exception);
$this->error('店铺基础信息存档失败:' . $exception->getMessage());
return self::FAILURE;
}
$this->info(sprintf('已保存 %s 店铺基础信息快照,共 %d 条。', $date, $count));
return self::SUCCESS;
}
}

View File

@ -1,246 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Business\ProductMonthlyWeightedAverageReporter;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
/**
* 检查上游完整性后,自动生成尚未计算的最新月份商品月加权平均单价。
*/
class AutoComputeProductMonthlyWeightedAverage extends Command
{
protected $signature = 'product:monthly-weighted-average:auto
{--month= : 指定月份;默认取最新已成功的进销存月份}
{--result-json= : 将执行结果写入指定 JSON 文件}';
protected $description = '上游完整后自动计算商品月加权平均单价,并保护已计算历史月份';
public function handle(ProductMonthlyWeightedAverageReporter $reporter): int
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$month = $this->targetMonth($connection);
if ($month === null) {
return $this->finish('blocked', null, [
'message' => '未找到已成功导入的进销存月份。',
]);
}
$lock = Cache::lock('product-monthly-weighted-average-auto:' . $month->format('Y-m'), 7200);
if (! $lock->get()) {
return $this->finish('blocked', $month, [
'message' => '同月自动计算任务正在执行。',
]);
}
try {
$state = DB::connection($connection)->table('product_monthly_weighted_average_states')
->where('report_month', $month->toDateString())
->first();
if (($state?->status ?? null) === 'computed') {
$report = $reporter->build($connection, $month);
$report['calculation_status'] = $report['status'];
$report['status'] = 'already_computed';
return $this->finish('already_computed', $month, $report, $reporter);
}
$checks = $this->checks($connection, $month);
if (in_array(false, array_column($checks, 'passed'), true)) {
DB::connection($connection)->table('product_monthly_weighted_average_states')->updateOrInsert(
['report_month' => $month->toDateString()],
[
'status' => 'blocked',
'last_error' => '上游数据未通过完整性检查',
'updated_at' => now(),
'created_at' => now(),
],
);
return $this->finish('blocked', $month, [
'message' => '上游数据未更新或不完整,未执行计算。',
'checks' => $checks,
]);
}
$baseExit = $this->call('product:monthly-weighted-average:base', [
'month' => $month->format('Y-m'),
'--from-previous' => true,
// 自动任务只会在尚未 computed 的月份走到这里,可安全清理失败重试留下
// 的同月基表;已 computed 的历史月份在前面的门禁直接跳过。
'--force' => true,
]);
if ($baseExit !== self::SUCCESS) {
return $this->finish('failed', $month, [
'message' => '生成期初基数失败。',
]);
}
$computeExit = $this->call('product:monthly-weighted-average:compute', [
'month' => $month->format('Y-m'),
]);
if ($computeExit !== self::SUCCESS) {
return $this->finish('failed', $month, [
'message' => '加权平均单价计算失败。',
]);
}
$report = $reporter->build($connection, $month);
$report['checks'] = $checks;
return $this->finish(
($report['status'] ?? null) === 'review_required' ? 'review_required' : 'success',
$month,
$report,
$reporter,
);
} finally {
$lock->release();
}
}
private function targetMonth(string $connection): ?CarbonImmutable
{
$requested = trim((string) $this->option('month'));
if ($requested !== '') {
return CarbonImmutable::parse($requested)->startOfMonth();
}
$latest = DB::connection($connection)->table('inventory_report_authoritative_months')
->where('dataset_key', 'jiansun.inventory-report.authoritative-month')
->where('status', 'succeeded')
->whereNotNull('active_generation')
->orderByDesc('source_month')
->value('source_month');
return $latest === null ? null : CarbonImmutable::parse((string) $latest)->startOfMonth();
}
/** @return list<array{name:string,passed:bool,actual:mixed,expected:mixed}> */
private function checks(string $connection, CarbonImmutable $month): array
{
$db = DB::connection($connection);
$monthState = $db->table('inventory_report_authoritative_months')
->where('dataset_key', 'jiansun.inventory-report.authoritative-month')
->where('source_month', $month->toDateString())
->first();
$generation = $monthState?->active_generation;
$activeRows = $generation === null
? 0
: $db->table('inventory_report_authoritative_rows')
->where('generation', (string) $generation)
->count();
$expectedRows = (int) ($monthState?->source_row_count ?? 0);
$previous = $month->subMonthNoOverflow();
$previousState = $db->table('product_monthly_weighted_average_states')
->where('report_month', $previous->toDateString())
->value('status');
$previousRows = $db->table('product_monthly_weighted_average_rows')
->where('report_month', $previous->toDateString())
->count();
$inboundRows = $db->table('inventory_inbound_detail_rows')
->where('report_month', $month->toDateString())
->count();
$inboundMissingDimensions = $db->table('inventory_inbound_detail_rows')
->where('report_month', $month->toDateString())
->where(function ($query): void {
$query->whereNull('barcode')->orWhere('barcode', '')
->orWhereNull('warehouse_short_name')->orWhere('warehouse_short_name', '');
})
->count();
$inboundMissingAmounts = $db->table('inventory_inbound_detail_rows')
->where('report_month', $month->toDateString())
->whereNull('amount')
->whereNull('estimated_amount')
->count();
$productCatalogRows = $db->table('ops_jst_sku')->whereNotNull('sku_id')->count();
return [
[
'name' => '进销存月份状态成功',
'passed' => ($monthState?->status ?? null) === 'succeeded',
'actual' => $monthState?->status,
'expected' => 'succeeded',
],
[
'name' => '进销存激活明细行数完整',
'passed' => $expectedRows > 0 && $activeRows === $expectedRows,
'actual' => $activeRows,
'expected' => $expectedRows,
],
[
'name' => '上月加权平均结果已完成',
'passed' => $previousState === 'computed' && $previousRows > 0,
'actual' => ['status' => $previousState, 'rows' => $previousRows],
'expected' => ['status' => 'computed', 'rows' => '> 0'],
],
[
'name' => '入库明细已更新',
'passed' => $inboundRows > 0,
'actual' => $inboundRows,
'expected' => '> 0',
],
[
'name' => '入库商品编码与仓库简称无缺失',
'passed' => $inboundMissingDimensions === 0,
'actual' => $inboundMissingDimensions,
'expected' => 0,
],
[
'name' => '入库金额至少取金额或预估金额',
'passed' => $inboundMissingAmounts === 0,
'actual' => $inboundMissingAmounts,
'expected' => 0,
],
[
'name' => '普通商品资料已更新',
'passed' => $productCatalogRows > 0,
'actual' => $productCatalogRows,
'expected' => '> 0',
],
];
}
/**
* @param array<string,mixed> $payload
*/
private function finish(
string $status,
?CarbonImmutable $month,
array $payload,
?ProductMonthlyWeightedAverageReporter $reporter = null,
): int {
$result = ['status' => $status] + $payload;
if (! isset($result['report_month']) && $month !== null) {
$result['report_month'] = $month->toDateString();
}
$path = trim((string) $this->option('result-json'));
if ($path !== '') {
File::ensureDirectoryExists(dirname($path));
File::put(
$path,
json_encode(
$result,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT,
) . PHP_EOL,
);
}
if ($reporter !== null && isset($result['totals'])) {
$this->line($reporter->brief($result));
} elseif (isset($result['message'])) {
$this->error((string) $result['message']);
}
return in_array($status, ['blocked', 'failed'], true)
? self::FAILURE
: self::SUCCESS;
}
}

View File

@ -1,133 +0,0 @@
<?php
namespace App\Console\Commands;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
/**
* 固定月度顺序:先生成商品月加权平均单价,再同步商品成本表。
*/
class AutoMonthlyWeightedAverageAndProductCost extends Command
{
protected $signature = 'finance:monthly-weighted-product-cost:auto
{--month= : 指定月份;默认取最新已成功的进销存月份}
{--result-json= : 将两阶段结果写入指定 JSON 文件}';
protected $description = '先计算商品月加权平均单价,再自动同步商品成本表';
public function handle(): int
{
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$month = $this->targetMonth($connection);
if ($month === null) {
return $this->writeResult([
'status' => 'blocked',
'message' => '未找到已成功导入的进销存月份。',
]);
}
$weightedPath = tempnam(sys_get_temp_dir(), 'weighted-result-') . '.json';
$costPath = tempnam(sys_get_temp_dir(), 'product-cost-result-') . '.json';
try {
$weightedExit = $this->call('product:monthly-weighted-average:auto', [
'--month' => $month->format('Y-m'),
'--result-json' => $weightedPath,
]);
$weighted = $this->readJson($weightedPath);
if (
$weightedExit !== self::SUCCESS
|| in_array($weighted['status'] ?? 'failed', ['blocked', 'failed'], true)
) {
return $this->writeResult([
'status' => 'failed',
'report_month' => $month->toDateString(),
'message' => '商品月加权平均单价未完成,商品成本未执行。',
'weighted_average' => $weighted,
'product_cost' => null,
]);
}
$costExit = $this->call('store-receipt:product-cost:sync', [
'month' => $month->format('Y-m'),
'--result-json' => $costPath,
]);
$cost = $this->readJson($costPath);
$status = $costExit !== self::SUCCESS
? 'failed'
: ((int) ($cost['pending_cost_rows'] ?? 0) > 0 ? 'review_required' : 'success');
return $this->writeResult([
'status' => $status,
'report_month' => $month->toDateString(),
'message' => match ($status) {
'success' => '月加权平均单价与商品成本已按顺序完成。',
'review_required' => '月加权平均单价与商品成本已完成,存在待核验成本。',
default => '商品成本同步失败。',
},
'weighted_average' => $weighted,
'product_cost' => $cost,
]);
} finally {
@unlink($weightedPath);
@unlink($costPath);
}
}
private function targetMonth(string $connection): ?CarbonImmutable
{
$requested = trim((string) $this->option('month'));
if ($requested !== '') {
return CarbonImmutable::parse($requested)->startOfMonth();
}
$latest = DB::connection($connection)->table('inventory_report_authoritative_months')
->where('dataset_key', 'jiansun.inventory-report.authoritative-month')
->where('status', 'succeeded')
->whereNotNull('active_generation')
->orderByDesc('source_month')
->value('source_month');
return $latest === null ? null : CarbonImmutable::parse((string) $latest)->startOfMonth();
}
/** @return array<string,mixed> */
private function readJson(string $path): array
{
if (! is_file($path)) {
return ['status' => 'failed', 'message' => '子任务未生成结果文件。'];
}
$decoded = json_decode((string) file_get_contents($path), true);
return is_array($decoded)
? $decoded
: ['status' => 'failed', 'message' => '子任务结果文件无法解析。'];
}
/** @param array<string,mixed> $result */
private function writeResult(array $result): int
{
$path = trim((string) $this->option('result-json'));
if ($path !== '') {
File::ensureDirectoryExists(dirname($path));
File::put(
$path,
json_encode(
$result,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT,
) . PHP_EOL,
);
}
if (isset($result['message'])) {
$this->line((string) $result['message']);
}
return in_array($result['status'] ?? 'failed', ['success', 'review_required'], true)
? self::SUCCESS
: self::FAILURE;
}
}

View File

@ -1,29 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Modules\EcommerceFinance\Services\WorkOrderWaybillLookup;
class BackfillWorkOrderWaybillLookup extends Command
{
protected $signature = 'business-mirror:index-work-order-waybills {--batch=500} {--max-batches=2} {--pause-ms=50}';
protected $description = '按稳定游标建立查件运单小索引;生产须使用 run-finance-batch.sh 执行';
public function handle(WorkOrderWaybillLookup $lookup): int
{
$lock = Cache::store('upstream_shared')->lock('work-order-waybill-lookup:backfill', 3600);
if (! $lock->get()) {
$this->info('已有运单索引任务运行,本轮跳过。');
return self::SUCCESS;
}
try {
$result = $lookup->backfill((int) $this->option('batch'), max(0, (int) $this->option('max-batches')), max(0, (int) $this->option('pause-ms')));
$this->info(json_encode($result));
return self::SUCCESS;
} finally {
$lock->release();
}
}
}

View File

@ -2,45 +2,26 @@
namespace App\Console\Commands;
use App\Business\ProductMonthlyWeightedAverageReporter;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
/**
* 按取数逻辑计算某个月的更改价格、定价状态、采购单价、期初金额、入库金额和加权平均单价。
*/
class ComputeProductMonthlyWeightedAverage extends Command
{
protected $signature = 'product:monthly-weighted-average:compute
{month : 报表月份,例如 2026-07}
{--force : 覆盖已经计算完成的同月结果}
{--result-json= : 将结果简报写入指定 JSON 文件}';
protected $signature = 'product:monthly-weighted-average:compute {month : 报表月份,例如 2026-07}';
protected $description = '按取数逻辑计算商品月加权平均单价';
public function handle(ProductMonthlyWeightedAverageReporter $reporter): int
public function handle(): int
{
$month = CarbonImmutable::parse($this->argument('month'))->startOfMonth();
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$table = 'product_monthly_weighted_average_rows';
$deadline = $month->addMonthNoOverflow()->day(2)->startOfDay();
$stateStatus = DB::connection($connection)
->table('product_monthly_weighted_average_states')
->where('report_month', $month->toDateString())
->value('status');
$computedRows = DB::connection($connection)->table($table)
->where('report_month', $month->toDateString())
->whereNotNull('computed_at')
->count();
if (! $this->option('force') && ($stateStatus === 'computed' || $computedRows > 0)) {
$this->error($month->format('Y-m') . ' 已经计算完成,历史月份不自动覆盖;如需人工纠正请显式使用 --force。');
return self::FAILURE;
}
$query = DB::connection($connection)->table($table)->where('report_month', $month->toDateString());
$codes = (clone $query)->distinct()->pluck('product_code')->map(fn ($code) => trim((string) $code))->unique()->values()->all();
$this->info('待计算 SKU:' . count($codes));
@ -106,21 +87,7 @@ class ComputeProductMonthlyWeightedAverage extends Command
['status' => 'computed', 'computed_rows' => $computed, 'last_computed_at' => $now, 'updated_at' => $now, 'created_at' => $now],
);
$report = $reporter->build($connection, $month);
$json = json_encode($report, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
if (is_string($json)) {
$resultPath = trim((string) $this->option('result-json'));
if ($resultPath !== '') {
File::ensureDirectoryExists(dirname($resultPath));
File::put($resultPath, $json . PHP_EOL);
}
}
$this->info($month->format('Y-m') . " 计算完成:{$computed} 行");
$this->line($reporter->brief($report));
if (($report['status'] ?? null) === 'review_required') {
$this->warn('存在待复核差异;结果已生成,但不会视为完全无异常。');
}
return self::SUCCESS;
}

View File

@ -1,205 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Business\XlsxSheetReader;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Repositories\AfterSalesCostRowRepository;
/**
* 导入聚水潭「商品利润 → 导出明细数据 → 订单售后费用数据」Excel。
*
* 该报表已按商品编码(组合装拆成组件)给出每行和成本,导入后订单售后费用明细页面
* 在覆盖的店铺/月份范围内直接按这些明细行展示。
*/
class ImportAfterSalesCostRows extends Command
{
protected $signature = 'order-data:import-after-sales-cost
{file : Excel 文件路径}
{--period= : 数据月份,格式 YYYY-MM}
{--chunk=500 : 每批写入行数}
{--dry-run : 只解析不入库}';
protected $description = '导入聚水潭「订单售后费用数据」Excel 到订单售后费用明细';
public function handle(XlsxSheetReader $reader): int
{
$path = (string) $this->argument('file');
$period = trim((string) $this->option('period'));
if (! is_file($path)) {
$this->error('文件不存在:' . $path);
return self::FAILURE;
}
if ($period !== '' && preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $period) !== 1) {
$this->error('数据月份格式必须是 YYYY-MM:' . $period);
return self::FAILURE;
}
$headers = (array) config('order_after_sales_cost.headers', []);
$numberKeys = array_flip((array) config('order_after_sales_cost.number_keys', []));
if ($headers === []) {
$this->error('缺少表头映射:config/order_after_sales_cost.php');
return self::FAILURE;
}
$this->info('流式读取文件:' . $path);
$chunkSize = max((int) $this->option('chunk'), 100);
$dryRun = (bool) $this->option('dry-run');
$buffer = [];
$total = 0;
$written = 0;
$skipped = 0;
$shops = [];
$mappedRows = 0;
$minDate = null;
$maxDate = null;
$importedAt = now()->toDateTimeString();
try {
foreach ($reader->rows($path, [], array_keys($headers)) as $row) {
$total++;
$payload = [];
foreach ($headers as $header => $key) {
if (! array_key_exists($header, $row)) {
continue;
}
$value = trim((string) $row[$header]);
if ($value === '') {
continue;
}
if (isset($numberKeys[$key])) {
$value = is_numeric($value) ? (float) $value : 0.0;
} elseif (self::isDateKey($key)) {
$value = self::dateValue($value);
}
$payload[$key] = $value;
}
if ($payload !== []) {
$mappedRows++;
}
$shop = trim((string) ($payload['shop_name'] ?? ''));
$productCode = trim((string) ($payload['product_code'] ?? ''));
if ($shop === '' || $productCode === '') {
$skipped++;
continue;
}
$buffer[] = AfterSalesCostRowRepository::toRecord(
$payload,
$importedAt,
$period !== '' ? $period : null,
);
$shops[$shop] = true;
$orderTime = $payload['order_time'] ?? null;
if (is_string($orderTime) && $orderTime !== '') {
$minDate = $minDate === null || $orderTime < $minDate ? $orderTime : $minDate;
$maxDate = $maxDate === null || $orderTime > $maxDate ? $orderTime : $maxDate;
}
if (! $dryRun && count($buffer) >= $chunkSize) {
$written += $this->flush($buffer);
$buffer = [];
$this->output->write("\r写入 " . $written . ' 行');
}
if ($dryRun && $buffer !== []) {
$buffer = [];
}
}
} catch (\Throwable $exception) {
if (! $dryRun) {
Cache::forget('order-data.after-sales.imported-shop-options');
}
$this->error('读取失败:' . $exception->getMessage());
return self::FAILURE;
}
if ($mappedRows === 0) {
$this->error('表头和配置的映射对不上,请检查文件或 config/order_after_sales_cost.php');
return self::FAILURE;
}
if (! $dryRun && $buffer !== []) {
$written += $this->flush($buffer);
}
if (! $dryRun) {
Cache::forget('order-data.after-sales.imported-shop-options');
}
$this->newLine(2);
$this->info(sprintf(
'完成:读取 %d 行,写入 %d 行,跳过 %d 行;店铺 %d 个;数据月份 %s;订单时间 %s ~ %s;峰值内存 %.1f MB',
$total,
$written,
$skipped,
count($shops),
$period !== '' ? $period : '-',
$minDate ?? '-',
$maxDate ?? '-',
memory_get_peak_usage(true) / 1048576,
));
return self::SUCCESS;
}
/** @param list<array<string,mixed>> $rows */
private function flush(array $rows): int
{
DB::table('ops_after_sales_cost_rows')->upsert(
$rows,
['row_key'],
[
'shop_name', 'shop_code', 'online_order_no', 'internal_order_no', 'product_code',
'style_code', 'shop_product_code', 'shop_style_code',
'order_time', 'source_period', 'product_quantity', 'product_amount', 'product_cost', 'cost_source',
'combine_type', 'payload', 'imported_at', 'updated_at',
],
);
return count($rows);
}
private static function isDateKey(string $key): bool
{
return in_array($key, [
'pay_time', 'order_complete_time', 'send_time', 'order_time', 'registration_time',
'confirmation_time', 'warehouse_entry_time',
], true);
}
private static function dateValue(mixed $value): ?string
{
if ($value instanceof \DateTimeInterface) {
return $value->format('Y-m-d H:i:s');
}
if (is_numeric($value) && (float) $value > 20000 && (float) $value < 80000) {
$timestamp = ((float) $value - 25569) * 86400;
return gmdate('Y-m-d H:i:s', (int) $timestamp);
}
$timestamp = strtotime((string) $value);
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
}
}

View File

@ -1,58 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\CompanyAlipayBillImporter;
use Throwable;
class ImportCompanyAlipayBills extends Command
{
protected $signature = 'finance-ledger:import-company-alipay
{path : 支付宝交易记录 CSV/XLSX/XLS 文件}
{--date= : 账单日期 YYYY-MM-DD,默认前一天}
{--account= : 支付宝登录账号,用于匹配账户总表}
{--account-short= : 账户总表简称;账号重复时必填}
{--dry-run : 只校验,不写入财务台账}';
protected $description = '导入公司非店铺支付宝前一天交易到财务台账动账明细';
public function handle(CompanyAlipayBillImporter $importer): int
{
$date = trim((string) ($this->option('date') ?: now()->subDay()->toDateString()));
$account = trim((string) $this->option('account'));
if ($account === '') {
$this->error('必须使用 --account 指定支付宝登录账号。');
return self::FAILURE;
}
try {
$result = $importer->import(
(string) $this->argument('path'),
$account,
$date,
$this->option('account-short') ?: null,
(bool) $this->option('dry-run'),
);
} catch (Throwable $exception) {
$this->error('支付宝账单未导入,保留原有动账明细。' . $exception->getMessage());
return self::FAILURE;
}
$this->table(['项目', '值'], [
['校验模式', $result['dry_run'] ? '仅校验' : '正式导入'],
['账单日期', $result['balance_date']],
['账户简称', $result['account_short']],
['有效笔数', $result['rows']],
['收入合计', number_format((float) $result['income'], 2)],
['支出合计', number_format((float) $result['expense'], 2)],
['替换原记录', $result['replaced']],
['每日流水行数', $result['recalculated']['flows'] ?? '-'],
]);
$this->info($result['dry_run'] ? '支付宝账单校验完成。' : '支付宝账单已接入财务台账动账明细。');
return self::SUCCESS;
}
}

View File

@ -1,280 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Business\CourierQuoteVersion;
use App\Models\Common\Company;
use App\Support\CourierQuoteStructure;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use RuntimeException;
/**
* 把整理好的结构化计费规则(标准计费规则_导入用.json)写入现有报价版本,供运费核算使用。
* 只写 billing_structure / billing_rows 两列,原始报价单元格、执行日期、状态都不动。
*/
class ImportCourierQuoteBillingRules extends Command
{
protected $signature = 'courier-quotes:import-billing-rules {file : 标准计费规则 JSON}
{--company= : 报价所属公司ID}
{--dry-run : 只校验并输出结果,不写入}
{--force : 覆盖已有且内容不同的计费规则}';
protected $description = '把存量报价的结构化计费规则批量写入报价版本(按版本号匹配,整批事务)';
private const MODES = ['waybill_rate', 'first_additional', 'flat_bands', 'flat_then', CourierQuoteStructure::SEGMENTS];
public function handle(): int
{
$companyId = (int) $this->option('company');
if ($companyId <= 0 || ! Company::query()->where('id', $companyId)->exists()) {
$this->error('请指定有效的 --company。');
return self::FAILURE;
}
$payload = json_decode((string) @file_get_contents((string) $this->argument('file')), true);
$items = is_array($payload) ? ($payload['versions'] ?? null) : null;
if (! is_array($items) || $items === []) {
$this->error('文件不是有效的计费规则 JSON(缺少 versions)。');
return self::FAILURE;
}
$plan = [];
$report = [];
$seen = [];
foreach ($items as $item) {
if (! is_array($item)) {
$report[] = ['', '', '错误', '版本条目必须是对象'];
continue;
}
$code = (string) ($item['version_id'] ?? '');
if (isset($seen[$code])) {
$report[] = [$code, '', '错误', '同一文件中的版本号不能重复'];
continue;
}
$seen[$code] = true;
[$status, $note, $data] = $this->prepare($companyId, $item);
$report[] = [$code, (string) ($item['company_name'] ?? ''), $status, $note];
if ($data !== null) {
$plan[] = $data;
}
}
$this->table(['版本', '快递公司', '结果', '说明'], $report);
$counts = array_count_values(array_column($report, 2));
$summary = collect($counts)->map(fn ($n, $k) => "{$k} {$n}")->implode(',');
if (isset($counts['错误'])) {
$this->error('存在错误,整批未写入:' . $summary);
return self::FAILURE;
}
if ($this->option('dry-run')) {
$this->info('校验通过,未写入(dry-run):' . $summary);
return self::SUCCESS;
}
try {
DB::transaction(function () use ($plan): void {
foreach ($plan as [$version, $structure, $rows]) {
$current = DB::table($version->getTable())->where('id', $version->id)->lockForUpdate()->first();
if (! $current || $current->company_name !== $version->company_name
|| json_decode($current->billing_structure ?? 'null', true) != $version->billing_structure
|| json_decode($current->billing_rows ?? 'null', true) != $version->billing_rows) {
throw new RuntimeException('报价在校验后被其他操作修改,整批未写入,请重新校验。');
}
$version->forceFill(['billing_structure' => $structure, 'billing_rows' => $rows])->save();
}
});
} catch (RuntimeException $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$this->info('导入完成:' . $summary);
return self::SUCCESS;
}
/**
* @param array<string,mixed> $item
* @return array{0:string,1:string,2:?array}
*/
private function prepare(int $companyId, array $item): array
{
$code = (string) ($item['version_id'] ?? '');
$version = CourierQuoteVersion::query()->allCompanies()->where('company_id', $companyId)->where('version_code', $code)->first();
if ($version === null) {
return ['错误', '系统中找不到该版本号', null];
}
if (trim((string) $version->company_name) !== trim((string) ($item['company_name'] ?? ''))) {
return ['错误', '快递名称不一致:系统为「' . $version->company_name . '」', null];
}
if (($item['conversion'] ?? '') === 'manual') {
return ['跳过', '特殊计费(' . ($item['manual_reason'] ?? '按体积/条码') . '),需人工核算', null];
}
if (($error = $this->inputError($item)) !== null) {
return ['错误', $error, null];
}
$source = (array) ($item['structure'] ?? []);
$segments = ($item['conversion'] ?? '') === 'needs_segment_mode';
$mode = $segments ? CourierQuoteStructure::SEGMENTS : (string) ($source['mode'] ?? '');
if (! in_array($mode, self::MODES, true)) {
return ['错误', '不支持的计费方式:' . $mode, null];
}
$params = (array) ($item['params'] ?? []);
$perTicket = 0.0;
foreach ((array) ($params['surcharges'] ?? []) as $surcharge) {
if (($surcharge['condition'] ?? '') === '每票' && is_numeric($surcharge['amount'] ?? null)) {
$perTicket += (float) $surcharge['amount'];
}
}
$structure = [
'mode' => $mode,
'flat_bands' => $segments ? [] : $this->numbers($source['flat_bands'] ?? []),
'then_mode' => $segments ? '' : (string) ($source['then_mode'] ?? ''),
'first_weight' => ! $segments && is_numeric($source['first_weight'] ?? null) ? (float) $source['first_weight'] : null,
'rate_tiers' => $segments ? [] : $this->numbers($source['rate_tiers'] ?? []),
'rounding' => in_array($source['rounding'] ?? 'none', array_keys(CourierQuoteStructure::ROUNDING), true) ? (string) ($source['rounding'] ?? 'none') : 'none',
'volumetric_divisor' => is_numeric($params['volumetric_divisor'] ?? null) ? (int) $params['volumetric_divisor'] : null,
'weight_limit' => is_numeric($params['weight_limit_kg'] ?? null) ? (float) $params['weight_limit_kg'] : null,
'min_weight' => is_numeric($source['min_weight'] ?? $params['min_weight_kg'] ?? null) ? (float) ($source['min_weight'] ?? $params['min_weight_kg']) : null,
'per_ticket_surcharge' => $perTicket > 0 ? $perTicket : null,
'notes' => array_values(array_map('strval', (array) ($params['notes'] ?? []))),
'imported_from' => 'stock-quote-billing-import',
];
$rows = [];
foreach ((array) ($segments ? ($item['segments_rows'] ?? []) : ($item['rows'] ?? [])) as $row) {
$region = trim((string) ($row['region'] ?? ''));
if ($region === '') {
return ['错误', '存在没有地区名的行', null];
}
if ($segments) {
$list = [];
foreach ((array) ($row['segments'] ?? []) as $s) {
$list[] = array_filter([
'lo' => (float) ($s['lo'] ?? 0),
'hi' => is_numeric($s['hi'] ?? null) ? (float) $s['hi'] : null,
'type' => (string) ($s['type'] ?? ''),
'price' => $this->number($s['price'] ?? null),
'base' => $this->number($s['base'] ?? null),
'rate' => $this->number($s['rate'] ?? null),
'fw' => $this->number($s['fw'] ?? null),
], static fn ($v, $k) => $v !== null || $k === 'hi', ARRAY_FILTER_USE_BOTH);
}
$rows[] = ['region' => $region, 'segments' => $list];
} else {
$rows[] = [
'region' => $region,
'flat' => array_map(fn ($v) => $this->number($v), array_values((array) ($row['flat'] ?? []))),
'base' => $this->number($row['base'] ?? null),
'rates' => array_map(fn ($v) => $this->number($v), array_values((array) ($row['rates'] ?? []))),
];
}
}
if ($rows === []) {
return ['错误', '没有任何地区价格', null];
}
$existing = $version->billing_structure;
$note = CourierQuoteStructure::describe($structure) . ',' . count($rows) . ' 个地区';
if (is_array($existing) && $existing !== []) {
if ($existing == $structure && $version->billing_rows == $rows) {
return ['跳过', '已导入且内容相同', null];
}
if (! $this->option('force')) {
return ['错误', '已有不同的计费规则,如需覆盖请加 --force', null];
}
return ['覆盖', $note, [$version, $structure, $rows]];
}
return ['写入', $note, [$version, $structure, $rows]];
}
private function number(mixed $value): ?float
{
return is_numeric($value) ? round((float) $value, 4) : null;
}
/** 金额导入拒绝负数、歧义分段和不支持的附加条件,不静默改写计费含义。 */
private function inputError(array $item): ?string
{
$structure = $item['structure'] ?? null;
$params = $item['params'] ?? [];
if (! is_array($structure) || ! is_array($params)) {
return 'structure 和 params 必须是对象';
}
if (! array_key_exists($structure['rounding'] ?? 'none', CourierQuoteStructure::ROUNDING)) {
return '不支持的重量取整方式';
}
$valid = static fn ($v): bool => is_numeric($v) && is_finite((float) $v) && (float) $v >= 0;
foreach ([$params['volumetric_divisor'] ?? null, $params['weight_limit_kg'] ?? null,
$structure['min_weight'] ?? $params['min_weight_kg'] ?? null, $structure['first_weight'] ?? null] as $v) {
if ($v !== null && ! $valid($v)) return '重量、体积系数必须是非负有限数字';
}
if (isset($params['volumetric_divisor']) && (float) $params['volumetric_divisor'] <= 0) return '体积系数必须大于0';
$minimum = (float) ($structure['min_weight'] ?? $params['min_weight_kg'] ?? 0);
if (($params['weight_limit_kg'] ?? 0) > 0 && $minimum > $params['weight_limit_kg']) return '起算重量不能大于限重';
foreach (['flat_bands', 'rate_tiers'] as $field) {
$previous = 0;
if (! is_array($structure[$field] ?? [])) return '重量分段必须是数组';
foreach ($structure[$field] ?? [] as $edge) {
if (! $valid($edge) || (float) $edge <= $previous) return '重量节点必须为严格递增的正数';
$previous = (float) $edge;
}
}
$mode = ($item['conversion'] ?? '') === 'needs_segment_mode' ? CourierQuoteStructure::SEGMENTS : ($structure['mode'] ?? '');
if (in_array($mode, ['flat_bands', 'flat_then'], true) && empty($structure['flat_bands'])) return '缺少一口价重量节点';
if ($mode === 'flat_then' && ! in_array($structure['then_mode'] ?? '', ['waybill_rate', 'first_additional'], true)) return '不支持的超重计费方式';
if (CourierQuoteStructure::usesFirstWeight($mode, $structure['then_mode'] ?? '') && ! (($structure['first_weight'] ?? 0) > 0)) return '首重必须大于0';
if ($mode === 'flat_then' && ! empty($structure['rate_tiers']) && min($structure['rate_tiers']) <= max($structure['flat_bands'])) return '超重费率节点必须大于一口价最后节点';
if (! is_array($params['surcharges'] ?? [])) return '附加费必须是数组';
foreach ($params['surcharges'] ?? [] as $fee) {
if (! is_array($fee) || ($fee['condition'] ?? '') !== '每票' || ! $valid($fee['amount'] ?? null)) return '附加费条件不支持自动核算,需人工核对';
}
$rows = $item[$mode === CourierQuoteStructure::SEGMENTS ? 'segments_rows' : 'rows'] ?? null;
if (! is_array($rows) || $rows === []) return '没有任何地区价格';
$regions = [];
foreach ($rows as $row) {
if (! is_array($row) || ! is_string($row['region'] ?? null) || trim($row['region']) === '') return '地区行格式不正确';
$region = mb_strtolower(preg_replace('/\s+/u', '', $row['region']));
if (isset($regions[$region])) return '存在重复地区行';
$regions[$region] = true;
if ($mode === CourierQuoteStructure::SEGMENTS) {
if (! is_array($row['segments'] ?? null) || $row['segments'] === []) return '缺少地区计费分段';
$previous = 0.0;
foreach ($row['segments'] as $segment) {
if (! is_array($segment) || ! $valid($segment['lo'] ?? null)
|| (float) $segment['lo'] < $previous || ! in_array($segment['type'] ?? '', ['flat', 'waybill', 'first_add'], true)) return '分段类型不支持或存在重叠';
$hi = $segment['hi'] ?? null;
if ($hi !== null && (! $valid($hi) || (float) $hi <= (float) $segment['lo'])) return '分段上限必须大于下限';
$previous = $hi === null ? INF : (float) $hi;
foreach (['price', 'base', 'rate', 'fw'] as $key) {
if (isset($segment[$key]) && ! $valid($segment[$key])) return '分段金额和重量必须是非负有限数字';
}
if ($segment['type'] === 'first_add' && ! $valid($segment['fw'] ?? null)) return '分段首重缺失';
}
} else {
if (! is_array($row['flat'] ?? []) || ! is_array($row['rates'] ?? [])) return '价格必须是数组';
if (count($row['flat'] ?? []) !== count($structure['flat_bands'] ?? [])
|| (CourierQuoteStructure::usesRates($mode) && count($row['rates'] ?? []) !== count($structure['rate_tiers'] ?? []) + 1)) return '价格列数与重量节点不匹配';
foreach (array_merge($row['flat'] ?? [], $row['rates'] ?? [], [$row['base'] ?? null]) as $v) {
if ($v !== null && ! $valid($v)) return '价格必须是非负有限数字或null(不发)';
}
}
}
return null;
}
/** @return list<float> */
private function numbers(mixed $values): array
{
return array_values(array_map('floatval', array_filter((array) $values, 'is_numeric')));
}
}

View File

@ -1,43 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\DingProcessDetailMirrorImporter;
use Throwable;
class ImportDingProcessDetails extends Command
{
protected $signature = 'ding:import-process-details
{file : 15 列流程明细 CSV 文件路径}
{--scope=personal_permission : 数据权限范围标识}
{--replace : 导入前清空同一范围的数据}';
protected $description = '兼容导入旧版铺平 CSV;生产请使用 business-mirror:sync-ding-process-details';
public function handle(DingProcessDetailMirrorImporter $importer): int
{
try {
$result = $importer->import(
(string) $this->argument('file'),
(string) $this->option('scope'),
(bool) $this->option('replace'),
);
} catch (Throwable $exception) {
$this->error('导入失败:' . $exception->getMessage());
return self::FAILURE;
}
$this->info(sprintf(
'导入完成:%d 行,跳过占位 %d 行,跳过无效 %d 行,重复 %d 行,范围 %s。',
$result['rows'],
$result['placeholders'],
$result['invalid'],
$result['duplicates'],
$result['scope'],
));
return self::SUCCESS;
}
}

View File

@ -1,49 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\FinanceLedgerBaseImporter;
use Throwable;
class ImportFinanceLedgerBase extends Command
{
protected $signature = 'finance-ledger:import-base
{path : 《银行当前余额-*.xlsx》路径}
{--opening-date= : 期初日期,默认读取配置}
{--recalculate : 导入后立即重算每日流水}';
protected $description = '导入财务台账的账户、店铺、公司、期初余额(全量覆盖)';
public function handle(FinanceLedgerBaseImporter $importer): int
{
try {
$result = $importer->import(
(string) $this->argument('path'),
$this->option('opening-date') ?: null,
);
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$this->table(['项目', '数量/金额'], [
['账户', $result['accounts']],
['店铺', $result['shops']],
['公司', $result['companies']],
['期初账户', $result['opening_rows']],
['期初合计', number_format($result['opening_sum'], 2)],
['账户余额合计', number_format($result['account_balance_sum'], 2)],
['期初日期', $result['opening_date']],
]);
if ($this->option('recalculate')) {
$this->call('finance-ledger:recalculate');
}
$this->info('财务台账基础数据导入完成。');
return self::SUCCESS;
}
}

View File

@ -1,42 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\FinanceLedgerSnapshotImporter;
use Throwable;
class ImportFinanceLedgerSnapshot extends Command
{
protected $signature = 'finance-ledger:import-snapshot
{path : 已核对的 ledger_raw.json 路径}
{--no-recalculate : 只导入明细,不重算流水}';
protected $description = '导入财务台账动账明细快照(本地开发或首次建账使用)';
public function handle(FinanceLedgerSnapshotImporter $importer): int
{
try {
$result = $importer->import(
(string) $this->argument('path'),
! $this->option('no-recalculate'),
);
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$this->table(['项目', '值'], [
['明细行数', $result['rows']],
['收入合计', number_format($result['income'], 2)],
['支出合计', number_format($result['expense'], 2)],
['日期范围', $result['from'] . ' ~ ' . $result['to']],
['每日流水行数', $result['recalculated']['flows'] ?? '-'],
['期末余额合计', isset($result['recalculated']) ? number_format($result['recalculated']['balance_sum'], 2) : '-'],
]);
$this->info('财务台账动账明细导入完成。');
return self::SUCCESS;
}
}

View File

@ -2,9 +2,11 @@
namespace App\Console\Commands;
use App\Business\InventoryInboundDetailImporter;
use App\Models\Business\InventoryWarehouseNameMapping;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
use Throwable;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\IOFactory;
/**
* 进销存报表 → 入库明细数据:按原始 Excel 原样导入,仓库简称用仓库名称映射表按「仓库」唯一匹配。
@ -15,28 +17,194 @@ class ImportInventoryInboundDetail extends Command
protected $description = '导入进销存报表的入库明细数据';
public function handle(InventoryInboundDetailImporter $importer): int
public function handle(): int
{
$file = (string) $this->argument('file');
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$table = 'inventory_inbound_detail_rows';
$forcedMonth = trim((string) ($this->option('month') ?? ''));
$now = now();
try {
$result = $importer->import($file, $forcedMonth);
$this->info(sprintf(
'导入完成:%d 行,仓库简称匹配上 %d 行,跳过 %d 行;库中该表共 %d 行',
$result['imported'],
$result['mapped'],
$result['skipped'],
$result['total'],
));
$maps = $this->shortNameMap();
$this->info('仓库名称映射:仓库名 ' . count($maps['warehouse']) . ' 个、仓储方 ' . count($maps['provider']) . ' 个可唯一匹配');
return self::SUCCESS;
} catch (Throwable $exception) {
report($exception);
$this->error('入库明细导入失败:' . $exception->getMessage());
$this->warn('已保留上一版有效数据。');
// 原样导入:以本次文件为准整体重建,不做去重、不做加工。
DB::connection($connection)->table($table)->delete();
return self::FAILURE;
$reader = IOFactory::createReaderForFile($file);
$reader->setReadDataOnly(true);
$sheet = $reader->load($file)->getSheet(0);
$batch = [];
$imported = 0;
$mapped = 0;
$skipped = 0;
foreach ($sheet->getRowIterator(2) as $row) {
$rowNumber = $row->getRowIndex();
$cells = [];
foreach ($row->getCellIterator('A', 'U') as $cell) {
$cells[] = $cell->getFormattedValue();
}
$warehouse = trim((string) ($cells[14] ?? ''));
$createdRaw = trim((string) ($cells[16] ?? ''));
if ($warehouse === '' && $createdRaw === '') {
$skipped++;
continue;
}
$month = $forcedMonth !== '' ? $forcedMonth : $this->monthOf($createdRaw);
if ($month === '') {
$skipped++;
continue;
}
// 一般用"仓库"匹配仓库简称;含"代发仓"字样的按"仓储方"匹配。
$shortName = '';
if ($warehouse !== '') {
$shortName = str_contains($warehouse, '代发仓')
? ($maps['provider'][$warehouse] ?? $maps['warehouse'][$warehouse] ?? '')
: ($maps['warehouse'][$warehouse] ?? '');
}
if ($shortName !== '') {
$mapped++;
}
$batch[] = [
'report_month' => $month . '-01',
// 唯一键按 Excel 行号生成,保留原表每一行(含重复行)。
'row_key' => sha1($month . '|' . $rowNumber),
'source_no' => $this->text($cells[0] ?? ''),
'document_no' => $this->text($cells[1] ?? ''),
'created_serial' => $this->text($cells[2] ?? ''),
'supplier' => $this->text($cells[3] ?? ''),
'item_no' => $this->text($cells[4] ?? ''),
'barcode' => $this->text($cells[5] ?? ''),
'spec_name' => $this->text($cells[6] ?? ''),
'quantity' => $this->number($cells[7] ?? ''),
'unit_price' => $this->number($cells[8] ?? ''),
'amount' => $this->number($cells[9] ?? ''),
'estimated_unit_price' => $this->number($cells[10] ?? ''),
'estimated_amount' => $this->number($cells[11] ?? ''),
'supplier_barcode' => $this->text($cells[12] ?? ''),
'remark' => $this->text($cells[13] ?? ''),
'warehouse' => $warehouse !== '' ? $warehouse : null,
'warehouse_short_name' => $shortName !== '' ? $shortName : null,
'created_at_raw' => $this->moment($createdRaw),
'express_name' => $this->text($cells[17] ?? ''),
'express_no' => $this->text($cells[18] ?? ''),
'not_sent' => $this->text($cells[19] ?? ''),
'owner_name' => $this->text($cells[20] ?? ''),
'imported_at' => $now,
];
$imported++;
if (count($batch) >= 1000) {
DB::connection($connection)->table($table)->insertOrIgnore($batch);
$this->info('已写入 ' . $imported . ' 行');
$batch = [];
}
}
if ($batch !== []) {
DB::connection($connection)->table($table)->insertOrIgnore($batch);
}
$this->info(sprintf(
'导入完成:%d 行,仓库简称匹配上 %d 行,跳过 %d 行;库中该表共 %d 行',
$imported,
$mapped,
$skipped,
DB::connection($connection)->table($table)->count(),
));
return self::SUCCESS;
}
/**
* @return array{warehouse:array<string,string>,provider:array<string,string>}
* 仅保留能唯一匹配的键
*/
private function shortNameMap(): array
{
$rows = InventoryWarehouseNameMapping::withoutGlobalScopes()
->get(['warehouse_provider', 'warehouse', 'warehouse_short_name']);
$grouped = [];
foreach ($rows as $row) {
$warehouse = trim((string) $row->warehouse);
if ($warehouse === '') {
continue;
}
$grouped[$warehouse][] = trim((string) $row->warehouse_short_name);
}
$byWarehouse = [];
foreach ($grouped as $warehouse => $names) {
$unique = array_values(array_unique($names));
if (count($unique) === 1 && $unique[0] !== '') {
$byWarehouse[$warehouse] = $unique[0];
}
}
$byProvider = [];
foreach ($rows as $row) {
$provider = trim((string) $row->warehouse_provider);
if ($provider === '') {
continue;
}
$byProvider[$provider][] = trim((string) $row->warehouse_short_name);
}
foreach ($byProvider as $provider => $names) {
$unique = array_values(array_unique($names));
if (count($unique) === 1 && $unique[0] !== '') {
$byProvider[$provider] = $unique[0];
}
}
return ['warehouse' => $byWarehouse, 'provider' => $byProvider];
}
private function monthOf(string $value): string
{
$value = trim($value);
if ($value === '') {
return '';
}
foreach (['Y/m/d H:i:s', 'Y-m-d H:i:s', 'Y/m/d', 'Y-m-d'] as $format) {
$parsed = \DateTimeImmutable::createFromFormat($format, $value);
if ($parsed instanceof \DateTimeImmutable) {
return $parsed->format('Y-m');
}
}
$timestamp = strtotime($value);
return $timestamp === false ? '' : date('Y-m', $timestamp);
}
private function moment(string $value): ?string
{
$value = trim($value);
if ($value === '') {
return null;
}
$timestamp = strtotime($value);
return $timestamp === false ? null : date('Y-m-d H:i:s', $timestamp);
}
private function text(mixed $value): ?string
{
$text = trim((string) $value);
return $text === '' ? null : $text;
}
private function number(mixed $value): ?float
{
$text = str_replace(',', '', trim((string) $value));
if ($text === '' || ! is_numeric($text)) {
return null;
}
return (float) $text;
}
}

View File

@ -2,10 +2,7 @@
namespace App\Console\Commands;
use App\Business\WithdrawalBalanceAlertSynchronizer;
use App\Models\Business\StoreReceivingAccount;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\FinanceBillShopBalanceFallback;
use Modules\EcommerceFinance\Services\JushuitanShopBalanceImporter;
use Throwable;
@ -17,13 +14,9 @@ class ImportJushuitanShopBalances extends Command
{--scope=all : 来源范围,同一日期同一范围会被原子替换}
{--dry-run : 只校验和统计,不写入共享业务库}';
protected $description = '导入聚水潭店铺余额,并用财务账单补录缺失店铺';
protected $description = '校验并导入聚水潭平台原始账单中的店铺余额快照';
public function handle(
JushuitanShopBalanceImporter $importer,
WithdrawalBalanceAlertSynchronizer $alertSynchronizer,
FinanceBillShopBalanceFallback $financeFallback,
): int
public function handle(JushuitanShopBalanceImporter $importer): int
{
try {
$result = $importer->import(
@ -33,74 +26,20 @@ class ImportJushuitanShopBalances extends Command
(bool) $this->option('dry-run'),
);
} catch (Throwable $exception) {
report($exception);
$this->error($exception->getMessage());
return self::FAILURE;
}
$this->info(sprintf(
'%s:%s 共 %d 条,替换原有 %d 条。',
'%s:%s 共 %d 条,替换原有 %d 条,清理 5 天前 %d 条。',
$result['dry_run'] ? '校验完成' : '导入完成',
$result['balance_date'],
$result['total'],
$result['replaced'],
$result['pruned'],
));
$financeFallbackFailed = false;
try {
$fallback = $financeFallback->fillMissing(
$result['balance_date'],
(string) $this->option('scope'),
(bool) $result['dry_run'],
);
if (! $fallback['supported']) {
$this->warn('账单余额回填:当前来源的店铺收款账户账单没有累计账户余额字段,跳过回填。');
} elseif ($fallback['missing'] === 0) {
$this->info('账单余额回填:聚水潭余额没有缺失店铺。');
} else {
$this->info(sprintf(
'账单余额回填:缺失 %d 家,匹配 %d 家,写入 %d 条,仍有 %d 家待补。',
$fallback['missing'],
$fallback['resolved'],
$fallback['filled'],
$fallback['unresolved'],
));
}
} catch (Throwable $exception) {
report($exception);
$this->error('财务系统店铺收款账户账单余额回填失败,聚水潭余额已保留,财务回填未覆盖。');
$financeFallbackFailed = true;
}
if (! $result['dry_run']) {
$companyIds = StoreReceivingAccount::withoutGlobalScopes()
->whereNull('deleted_at')
->distinct()
->pluck('company_id')
->filter()
->map(static fn (mixed $id): int => (int) $id)
->unique()
->values();
foreach ($companyIds as $companyId) {
try {
$alerts = $alertSynchronizer->syncLatest($companyId);
if ($alerts['created'] > 0 || $alerts['updated'] > 0) {
$this->info(sprintf(
'余额预警待办:公司 %d 新增 %d 条、更新 %d 条。',
$companyId,
$alerts['created'],
$alerts['updated'],
));
}
} catch (Throwable $exception) {
report($exception);
$this->warn("余额预警待办生成失败(公司 {$companyId}),余额快照已保留。");
}
}
}
return $financeFallbackFailed ? self::FAILURE : self::SUCCESS;
return self::SUCCESS;
}
}

View File

@ -21,8 +21,7 @@ class ImportProductMonthlyWeightedAverageBase extends Command
protected $signature = 'product:monthly-weighted-average:base
{month : 报表月份,例如 2026-07}
{--file= : 期初基数 Excel(表1),首月使用}
{--from-previous : 用上月的加权平均单价作为本月原始价格}
{--force : 覆盖已经存在的同月基数}';
{--from-previous : 用上月的加权平均单价作为本月原始价格}';
protected $description = '导入/生成商品月加权平均单价的期初基数';
@ -33,18 +32,6 @@ class ImportProductMonthlyWeightedAverageBase extends Command
$rowsTable = 'product_monthly_weighted_average_rows';
$now = now();
$existingRows = DB::connection($connection)->table($rowsTable)
->where('report_month', $month->toDateString())
->count();
$existingState = DB::connection($connection)->table('product_monthly_weighted_average_states')
->where('report_month', $month->toDateString())
->value('status');
if (! $this->option('force') && ($existingRows > 0 || $existingState !== null)) {
$this->error($month->format('Y-m') . ' 已存在基数或计算状态,不自动覆盖;如需人工纠正请显式使用 --force。');
return self::FAILURE;
}
$inventory = $this->inventoryAggregate($connection, $month);
$jiangxiPurchase = $this->jiangxiDeliveryPurchase($connection, $month);
$nanchangOpening = $this->nanchangOpeningQuantity($connection, $month);
@ -211,13 +198,9 @@ class ImportProductMonthlyWeightedAverageBase extends Command
throw new \RuntimeException($month->format('Y-m') . ' 的进销存总表尚未导入。');
}
$mappingModel = new InventoryWarehouseNameMapping();
$mappingConnection = DB::connection($mappingModel->getConnectionName());
$mappingTable = $mappingConnection->getDriverName() === 'sqlite'
? $mappingModel->getTable()
: $mappingModel->getConnection()->getDatabaseName()
. '.' . $mappingConnection->getTablePrefix()
. $mappingModel->getTable();
$mappingTable = (new InventoryWarehouseNameMapping)->getConnection()->getDatabaseName()
. '.' . DB::connection((new InventoryWarehouseNameMapping)->getConnectionName())->getTablePrefix()
. (new InventoryWarehouseNameMapping)->getTable();
$sql = "select m.warehouse_short_name as short_name, r.product_code as product_code,
sum(case when r.warehouse like '%主仓%' then r.opening_quantity else 0 end) as opening_quantity,

View File

@ -1,140 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Business\XlsxSheetReader;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Modules\EcommerceFinance\Repositories\PromotionCostRowRepository;
class ImportPromotionCostRows extends Command
{
protected $signature = 'order-data:import-promotion-cost
{file : Excel 文件路径}
{--period= : 数据月份,格式 YYYY-MM}
{--chunk=500 : 每批写入行数}
{--dry-run : 只解析不入库}';
protected $description = '导入聚水潭商品推广费用明细 Excel';
public function handle(XlsxSheetReader $reader): int
{
$path = (string) $this->argument('file');
$period = trim((string) $this->option('period'));
if (! is_file($path)) {
$this->error('文件不存在:' . $path);
return self::FAILURE;
}
if (preg_match('/^\d{4}-(0[1-9]|1[0-2])$/', $period) !== 1) {
$this->error('数据月份格式必须是 YYYY-MM:' . ($period !== '' ? $period : '(空)'));
return self::FAILURE;
}
$headers = (array) config('order_promotion_cost.headers', []);
if ($headers === []) {
$this->error('缺少表头映射:config/order_promotion_cost.php');
return self::FAILURE;
}
$this->info('流式读取文件:' . $path);
$chunkSize = max((int) $this->option('chunk'), 100);
$dryRun = (bool) $this->option('dry-run');
$importedAt = now()->toDateTimeString();
$buffer = [];
$total = 0;
$written = 0;
$skipped = 0;
$promotionFee = 0.0;
$shops = [];
try {
foreach ($reader->rows($path, array_keys($headers)) as $row) {
$total++;
$payload = [];
foreach ($headers as $header => $key) {
$value = trim((string) ($row[$header] ?? ''));
if ($value === '') {
continue;
}
$payload[$key] = $key === 'promotion_fee'
? (is_numeric($value) ? (float) $value : 0.0)
: $value;
}
$shop = trim((string) ($payload['shop_name'] ?? ''));
$product = trim((string) ($payload['product_code'] ?? ''));
if ($shop === '' || $product === '') {
$skipped++;
continue;
}
$shops[$shop] = true;
$promotionFee += (float) ($payload['promotion_fee'] ?? 0);
$buffer[] = PromotionCostRowRepository::toRecord($payload, $period, $importedAt);
if (! $dryRun && count($buffer) >= $chunkSize) {
$written += $this->flush($buffer);
$buffer = [];
$this->output->write("\r写入 " . $written . ' 行');
}
if ($dryRun && $buffer !== []) {
$buffer = [];
}
}
} catch (\Throwable $exception) {
if (! $dryRun) {
Cache::forget('order-data.promotion.imported-shop-options:' . $period);
}
$this->error('读取失败:' . $exception->getMessage());
return self::FAILURE;
}
if (! $dryRun && $buffer !== []) {
$written += $this->flush($buffer);
}
if (! $dryRun) {
Cache::forget('order-data.promotion.imported-shop-options:' . $period);
}
$this->newLine(2);
$this->info(sprintf(
'完成:读取 %d 行,写入 %d 行,跳过 %d 行;店铺 %d 个;数据月份 %s;推广费合计 %.2f;峰值内存 %.1f MB',
$total,
$written,
$skipped,
count($shops),
$period,
$promotionFee,
memory_get_peak_usage(true) / 1048576,
));
return self::SUCCESS;
}
/** @param list<array<string,mixed>> $rows */
private function flush(array $rows): int
{
DB::table('ops_promotion_cost_rows')->upsert(
$rows,
['row_key'],
[
'source_period', 'shop_name', 'product_code', 'style_code',
'shop_product_code', 'shop_style_code', 'online_color_spec',
'promotion_fee', 'payload', 'imported_at', 'updated_at',
],
);
return count($rows);
}
}

View File

@ -1,28 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\SupplierBookkeepingConfigImporter;
use Modules\EcommerceFinance\Services\SupplierBookkeepingConfigStore;
class ImportSupplierBookkeepingConfigs extends Command
{
protected $signature = 'supplier-bookkeeping:import-configs {file} {--dry-run : 仅检查文件并输出条数,不写入}';
protected $description = '导入财务提供的厂家记账配置 Excel(供应商、厂家是否记账);新增/更新/跳过,任一行无法识别整批不写入';
public function handle(SupplierBookkeepingConfigImporter $importer, SupplierBookkeepingConfigStore $store): int
{
$path = (string) $this->argument('file');
$rows = $importer->read($path);
if ($this->option('dry-run')) {
$this->info('文件校验通过,共' . count($rows) . '条;未写入。');
return self::SUCCESS;
}
$result = $store->import($rows, '命令行导入', hash_file('sha256', $path));
$this->info("导入完成:新增{$result['added']}条,更新{$result['updated']}条,未变化{$result['skipped']}条。");
return self::SUCCESS;
}
}

View File

@ -1,121 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Business\SupplierSummaryInboundImporter;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\File;
use Throwable;
/**
* 供应商汇总表月度增量导入:读取已解密工作簿的「明细」子表,仅追加目标月份数据。
*/
class ImportSupplierSummaryInbound extends Command
{
protected $signature = 'inventory:supplier-summary:import
{file : 已解密的供应商汇总 XLSX 文件}
{--month= : 目标月份 YYYY-MM,默认上一个月}
{--result-json= : 将执行结果写入指定 JSON 文件}';
protected $description = '将供应商汇总表「明细」增量导入入库明细数据';
public function handle(SupplierSummaryInboundImporter $importer): int
{
$month = trim((string) $this->option('month'));
if ($month === '') {
$month = now('Asia/Shanghai')->subMonthNoOverflow()->format('Y-m');
}
$lock = Cache::lock('inventory-supplier-summary-inbound-import', 21600);
if (! $lock->get()) {
return $this->finish([
'status' => 'failed',
'report_month' => $month,
'source_filename' => basename((string) $this->argument('file')),
'success' => 0,
'duplicates' => 0,
'failed' => 1,
'mapped' => 0,
'total_in_table' => 0,
'failure_reason_count' => 1,
'failure_reasons' => [[
'row' => 0,
'reason' => '已有供应商汇总导入任务正在执行',
]],
]);
}
try {
$result = $importer->import((string) $this->argument('file'), $month);
} catch (Throwable $exception) {
report($exception);
$result = [
'status' => 'failed',
'report_month' => $month,
'source_filename' => basename((string) $this->argument('file')),
'success' => 0,
'duplicates' => 0,
'failed' => 1,
'mapped' => 0,
'total_in_table' => 0,
'failure_reason_count' => 1,
'failure_reasons' => [[
'row' => 0,
'reason' => $exception->getMessage(),
]],
];
} finally {
$lock->release();
}
return $this->finish($result);
}
/** @param array<string,mixed> $result */
private function finish(array $result): int
{
$reasons = is_array($result['failure_reasons'] ?? null)
? $result['failure_reasons']
: [];
$result['failure_reason_count'] = count($reasons);
$json = json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (! is_string($json)) {
$json = '{"status":"failed","failure_reasons":[{"row":0,"reason":"结果 JSON 编码失败"}]}';
}
$resultPath = trim((string) $this->option('result-json'));
if ($resultPath !== '') {
File::ensureDirectoryExists(dirname($resultPath));
File::put($resultPath, $json . PHP_EOL);
}
$this->line(sprintf(
'%s 导入状态=%s,成功=%s,重复=%s,失败=%s,目标月份总行数=%s',
(string) ($result['report_month'] ?? ''),
(string) ($result['status'] ?? 'failed'),
number_format((int) ($result['success'] ?? 0)),
number_format((int) ($result['duplicates'] ?? 0)),
number_format((int) ($result['failed'] ?? 0)),
number_format((int) ($result['total_in_table'] ?? 0)),
));
foreach (array_slice($reasons, 0, 10) as $reason) {
$this->warn(sprintf(
'第 %s 行:%s',
(string) ($reason['row'] ?? ''),
(string) ($reason['reason'] ?? '未知错误'),
));
}
if (count($reasons) > 10) {
$this->line(sprintf(
'另有 %s 条失败原因,完整清单见结果 JSON。',
number_format(count($reasons) - 10),
));
}
return ($result['status'] ?? 'failed') === 'success'
? self::SUCCESS
: self::FAILURE;
}
}

View File

@ -1,57 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Business\SystemizationTodoWorkbookImporter;
use Illuminate\Console\Command;
use Throwable;
class ImportSystemizationTodos extends Command
{
protected $signature = 'systemization-todos:import
{file : 待办 Excel 文件路径}
{--company=1 : 写入的 company_id}
{--user= : 操作人用户 ID}
{--sync : 新增并更新现有来源事项,保留截图}
{--dry-run : 只预览同步结果,不写入数据库}';
protected $description = '导入或同步财务系统化待办清单';
public function handle(SystemizationTodoWorkbookImporter $importer): int
{
try {
$file = (string) $this->argument('file');
$companyId = max((int) $this->option('company'), 1);
$userId = $this->option('user') !== null ? (int) $this->option('user') : null;
if ($this->option('sync')) {
$result = $importer->sync($file, $companyId, $userId, (bool) $this->option('dry-run'));
} else {
$result = $importer->import($file, $companyId, $userId);
}
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
if ($this->option('sync')) {
$this->info(sprintf(
'%s工作表 %s:新增 %d 条,更新 %d 条,保留工作簿外事项 %d 条。',
$this->option('dry-run') ? '同步预检:' : '同步完成:',
$result['sheet'],
$result['created'],
$result['updated'],
$result['retained'],
));
} else {
$this->info(sprintf(
'工作表 %s:新增 %d 条,跳过已存在 %d 条。',
$result['sheet'],
$result['created'],
$result['skipped'],
));
}
return self::SUCCESS;
}
}

View File

@ -1,211 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Business\StoreReceiptDifferenceMaterialization as Materialization;
use App\Http\Controllers\Business\Pages;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class MaterializeStoreReceiptDifferences extends Command
{
protected $signature = 'store-receipt:materialize-differences
{month? : 账期 YYYY-MM;留空则物化镜像里已有数据的全部月份}
{--chunk=5000 : 每批更新的行数}
{--dry-run : 只统计与比对,不写库}';
protected $description = '把业绩明细的差额备注分类与有效差额物化到镜像表,页面改为读列';
public function handle(): int
{
if (! Materialization::available(true)) {
$this->components->error('物化列或状态表不存在,请先执行数据库迁移。');
return self::FAILURE;
}
$months = $this->targetMonths();
if ($months === []) {
$this->components->warn('镜像里没有可物化的账期。');
return self::SUCCESS;
}
$chunk = max(200, (int) $this->option('chunk'));
$dryRun = (bool) $this->option('dry-run');
foreach ($months as $month) {
$this->line('');
$this->components->info(($dryRun ? '[dry-run] ' : '') . '账期 ' . substr($month, 0, 7));
if ($dryRun) {
$this->reportDryRun($month);
continue;
}
$started = microtime(true);
$updated = $this->materializeMonth($month, $chunk);
if ($updated === null) {
continue;
}
Materialization::markMaterialized($month, $updated);
$this->line(sprintf(' %s 行,用时 %.1f 秒', number_format($updated), microtime(true) - $started));
}
if ($dryRun) {
$this->line('');
$this->components->info('dry-run 完成,未写入任何数据。');
}
return self::SUCCESS;
}
/**
* @return list<string>
*/
private function targetMonths(): array
{
$month = trim((string) $this->argument('month'));
if ($month !== '') {
if (preg_match('/^\d{4}-\d{2}$/', $month) === 1) {
$month .= '-01';
}
// 跨月备注规则依赖相邻月份,指定月份时一起重算前后两个月。
return array_values(array_unique(Materialization::adjacentMonths($month)));
}
return DB::connection(Materialization::connection())
->table(Materialization::table())
->select('report_month')
->distinct()
->orderBy('report_month')
->pluck('report_month')
->map(static fn ($value): string => substr((string) $value, 0, 10))
->all();
}
/**
* 分批把规则结果写进物化列;返回更新行数。
*/
private function materializeMonth(string $month, int $chunk): ?int
{
$connection = Materialization::connection();
$table = Materialization::table();
$exists = DB::connection($connection)->table($table)->where('report_month', $month)->exists();
if (! $exists) {
$this->components->warn(' 镜像里没有该账期的数据,跳过。');
return null;
}
$baseExpression = Pages::receiptDefaultDifferenceSql();
$indexExpression = Pages::receiptDifferenceRemarkIndexSql();
$updated = 0;
$lastKey = '';
$batch = 0;
while (true) {
$keys = DB::connection($connection)->table($table)
->where('report_month', $month)
->where('row_key', '>', $lastKey)
->orderBy('row_key')
->limit($chunk)
->pluck('row_key')
->all();
if ($keys === []) {
break;
}
$lastKey = (string) end($keys);
$batch++;
// 第一遍:规则表达式只算一次,结果落列。
DB::connection($connection)->table($table)
->where('report_month', $month)
->whereIn('row_key', $keys)
->update([
Materialization::BASE_DIFFERENCE_COLUMN => DB::raw($baseExpression),
Materialization::REASON_INDEX_COLUMN => DB::raw($indexExpression),
]);
// 第二遍:直接用刚写入的列推导「有/无差额」,避免重复执行规则链,
// 也避免依赖 SET 子句从左到右求值的库间差异。
DB::connection($connection)->table($table)
->where('report_month', $month)
->whereIn('row_key', $keys)
->update([
Materialization::HAS_DIFFERENCE_COLUMN => DB::raw($this->hasDifferenceExpression()),
]);
$updated += count($keys);
$this->output->write('.');
}
if ($updated === 0) {
$this->line('');
} else {
$this->line(' (' . $batch . ' 批)');
}
return $updated;
}
/**
* 基于物化列的「有/无差额」推导:口径兜底行按 0 计。
*/
private function hasDifferenceExpression(): string
{
$basisMarks = implode(', ', array_map(
static fn (string $remark): string => "'" . str_replace("'", "''", $remark) . "'",
Pages::RECEIPT_BASIS_FALLBACK_REMARKS,
));
return 'CASE WHEN ABS(CASE WHEN remark IN (' . $basisMarks . ')'
. ' AND COALESCE(' . Materialization::REASON_INDEX_COLUMN . ', 0) = 0 THEN 0'
. ' ELSE COALESCE(' . Materialization::BASE_DIFFERENCE_COLUMN . ', 0) END) > 0.005 THEN 1 ELSE 0 END';
}
/**
* dry-run:对比「库里的物化值」与「按当前规则重算」的差异,不写库。
*/
private function reportDryRun(string $month): void
{
$connection = Materialization::connection();
$table = Materialization::table();
$total = (int) DB::connection($connection)->table($table)->where('report_month', $month)->count();
if ($total === 0) {
$this->components->warn(' 镜像里没有该账期的数据,跳过。');
return;
}
$baseExpression = Pages::receiptDefaultDifferenceSql();
$indexExpression = Pages::receiptDifferenceRemarkIndexSql();
$row = DB::connection($connection)->table($table)
->where('report_month', $month)
->selectRaw('COUNT(*) AS total')
->selectRaw('SUM(CASE WHEN ' . Materialization::REASON_INDEX_COLUMN
. ' IS NULL OR ' . Materialization::REASON_INDEX_COLUMN . ' <> (' . $indexExpression . ') THEN 1 ELSE 0 END) AS reason_mismatch')
->selectRaw('SUM(CASE WHEN ' . Materialization::BASE_DIFFERENCE_COLUMN
. ' IS NULL OR ABS(' . Materialization::BASE_DIFFERENCE_COLUMN . ' - (' . $baseExpression . ')) > 0.005 THEN 1 ELSE 0 END) AS base_mismatch')
->selectRaw('SUM(CASE WHEN ' . Materialization::HAS_DIFFERENCE_COLUMN . ' IS NULL THEN 1 ELSE 0 END) AS unreasoned')
->selectRaw('SUM(CASE WHEN ' . Materialization::HAS_DIFFERENCE_COLUMN . ' IS NOT NULL AND '
. Materialization::HAS_DIFFERENCE_COLUMN . ' <> (' . $this->hasDifferenceExpression() . ') THEN 1 ELSE 0 END) AS state_mismatch')
->first();
$this->line(sprintf(' 行数 %s|备注序号不一致 %s|差额不一致 %s|有/无不一致 %s|未物化 %s',
number_format((int) ($row->total ?? 0)),
number_format((int) ($row->reason_mismatch ?? 0)),
number_format((int) ($row->base_mismatch ?? 0)),
number_format((int) ($row->state_mismatch ?? 0)),
number_format((int) ($row->unreasoned ?? 0)),
));
}
}

View File

@ -1,62 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\StoreReceiptDefaultCompensationSnapshotBuilder;
use Throwable;
class PreviewStoreReceiptDefaultCompensation extends Command
{
protected $signature = 'store-receipt:default-compensation:preview
{month : 账期 YYYY-MM}
{mapping : 最新科目映射表 xlsx 路径}
{--output= : 输出 JSON 路径;默认写入 storage/app/local/performance-details/default-compensation}';
protected $description = '人工核对:按指定科目映射表生成「违约赔付(冲减收入)」金额快照,不写数据库';
public function handle(StoreReceiptDefaultCompensationSnapshotBuilder $builder): int
{
$startedAt = hrtime(true);
$month = trim((string) $this->argument('month'));
$mapping = (string) $this->argument('mapping');
try {
$snapshot = $builder->build($month, $mapping);
$path = (string) ($this->option('output') ?: storage_path(
'app/local/performance-details/default-compensation/' . $month . '.json'
));
$directory = dirname($path);
if (! is_dir($directory) && ! mkdir($directory, 0775, true) && ! is_dir($directory)) {
throw new \RuntimeException('无法创建快照目录:' . $directory);
}
$temporary = $path . '.tmp';
$json = json_encode($snapshot, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
if ($json === false || file_put_contents($temporary, $json, LOCK_EX) === false) {
throw new \RuntimeException('无法写入本地快照。');
}
rename($temporary, $path);
$this->info('本地预览快照已生成:' . $path);
$this->line('映射条数:' . $snapshot['mapping_rows']);
$this->line('关联行数:' . count($snapshot['amounts']));
$this->line('金额合计:' . number_format((float) $snapshot['total'], 2, '.', ''));
$this->line('耗时:' . number_format((hrtime(true) - $startedAt) / 1e6, 0) . ' ms');
$this->line('PHP 峰值内存:' . number_format(memory_get_peak_usage(true) / 1024 / 1024, 1) . ' MB');
foreach ($snapshot['source_stats'] as $platform => $stats) {
$this->line(sprintf(
' %s:账单行 %d,金额 %s',
$platform,
$stats['rows'],
number_format((float) $stats['amount'], 2, '.', ''),
));
}
return self::SUCCESS;
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
}
}

View File

@ -1,99 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\Common\Company;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Modules\EcommerceFinance\Services\SupplierNegativeAlertDingTalkWorkflow;
use Modules\EcommerceFinance\View\SupplierNegativeAlertPageData;
use Throwable;
class PushSupplierNegativeAlertDingTalkTasks extends Command
{
protected $signature = 'supplier-negative-alert:push-dingtalk
{--date= : 推送日期,格式 Y-m-d,默认今天}
{--force : 忽略当天已推送标记}';
protected $description = '每天推送最新账期内尚未处理的供应商负数预警任务';
public function handle(
SupplierNegativeAlertPageData $pageData,
SupplierNegativeAlertDingTalkWorkflow $workflow,
): int {
$date = trim((string) $this->option('date')) ?: now()->toDateString();
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) !== 1) {
$this->error('推送日期格式无效。');
return self::FAILURE;
}
$failed = false;
foreach (Company::enabled()->orderBy('id')->get() as $company) {
$company->makeCurrent();
$rows = $pageData->rowsForFilters([]);
$periods = array_values(array_unique(array_filter(array_map(
static fn (array $row): string => trim((string) ($row['summary_month'] ?? '')),
$rows,
))));
if ($periods === []) {
$this->line("#{$company->id} {$company->name}: 没有负数预警数据");
continue;
}
rsort($periods);
$period = $periods[0];
$latestRows = array_values(array_filter(
$rows,
static fn (array $row): bool => trim((string) ($row['summary_month'] ?? '')) === $period,
));
$handled = array_fill_keys(
$workflow->handledSuppliersForPeriod((int) $company->id, $period),
true,
);
$unhandledRows = array_values(array_filter(
$latestRows,
static fn (array $row): bool => ! isset($handled[trim((string) ($row['supplier'] ?? ''))]),
));
if ($unhandledRows === []) {
$this->line("#{$company->id} {$company->name} {$period}: 供应商均已处理");
continue;
}
$lockKey = sprintf(
'supplier-negative-alert:auto-push:%d:%s:%s',
$company->id,
$period,
$date,
);
if (! $this->option('force') && ! Cache::add($lockKey, true, now()->addDays(2))) {
$this->line("#{$company->id} {$company->name} {$period}: 今天已经自动推送");
continue;
}
try {
$result = $workflow->sendGrouped(
$unhandledRows,
['month' => $period, 'automatic' => true],
(int) $company->id,
null,
);
$this->info(sprintf(
'#%d %s %s: 已推送 %d 位采购、%d 家供应商、%d 条未处理预警',
$company->id,
$company->name,
$period,
$result['purchaser_count'],
$result['supplier_count'],
$result['row_count'],
));
} catch (Throwable $exception) {
Cache::forget($lockKey);
report($exception);
$this->error("#{$company->id} {$company->name} {$period}: 自动推送失败:" . $exception->getMessage());
$failed = true;
}
}
return $failed ? self::FAILURE : self::SUCCESS;
}
}

View File

@ -1,35 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\FinanceLedgerCalculator;
use Throwable;
class RecalculateFinanceLedger extends Command
{
protected $signature = 'finance-ledger:recalculate';
protected $description = '重算财务台账的每日资金流水、账户余额和店铺余额';
public function handle(FinanceLedgerCalculator $calculator): int
{
try {
$result = $calculator->recalculate();
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$this->table(['项目', '值'], [
['账户数', $result['accounts']],
['流水行数', $result['flows']],
['期末余额合计', number_format($result['balance_sum'], 2)],
['期初日期', $result['opening_date']],
]);
$this->info('财务台账流水重算完成。');
return self::SUCCESS;
}
}

View File

@ -1,19 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\StoreReportMirrorSynchronizer;
class RefreshStoreReportPaidSales extends Command
{
protected $signature = 'business-mirror:refresh-paid-sales {--full : 强制全量校准派生汇总}';
protected $description = '只刷新业绩明细派生的销量汇总,不修改原始财务明细';
public function handle(StoreReportMirrorSynchronizer $synchronizer): int
{
$result = $synchronizer->syncPaidSales((bool) $this->option('full'));
$this->info(json_encode($result));
return $result['status'] === 'succeeded' ? self::SUCCESS : self::FAILURE;
}
}

View File

@ -1,47 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Business\ProductMonthlyWeightedAverageReporter;
use Carbon\CarbonImmutable;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
class ReportProductMonthlyWeightedAverage extends Command
{
protected $signature = 'product:monthly-weighted-average:report
{month : 报表月份,例如 2026-09}
{--result-json= : 将结果写入指定 JSON 文件}';
protected $description = '输出商品月加权平均单价结果简报与差异分类';
public function handle(ProductMonthlyWeightedAverageReporter $reporter): int
{
$month = CarbonImmutable::parse($this->argument('month'))->startOfMonth();
$report = $reporter->build(
(string) config('upstream_mirrors.connection', 'business_mirror'),
$month,
);
$path = trim((string) $this->option('result-json'));
if ($path !== '') {
File::ensureDirectoryExists(dirname($path));
File::put(
$path,
json_encode(
$report,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT,
) . PHP_EOL,
);
}
$this->line($reporter->brief($report));
if (($report['status'] ?? null) === 'review_required') {
$this->warn('存在待人工复核差异,完整清单已写入结果 JSON。');
}
return ($report['status'] ?? null) === 'missing'
? self::FAILURE
: self::SUCCESS;
}
}

View File

@ -1,24 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\AccountBillsExportTasks;
class RunAccountBillsExportQueue extends Command
{
protected $signature = 'account-bills:exports:run {--max-jobs=1}';
protected $description = '依次消费账单导出任务,生产从受限批容器启动';
public function handle(AccountBillsExportTasks $tasks): int
{
$limit = max(0, (int) $this->option('max-jobs'));
for ($processed = 0; $limit === 0 || $processed < $limit; $processed++) {
$task = $tasks->nextQueued();
if ($task === null) break;
$code = $this->call('account-bills:export-run', ['token' => $task['token']]);
if ($code === 75) break;
}
return self::SUCCESS;
}
}

View File

@ -28,17 +28,6 @@ class RunAccountBillsExportTask extends Command
protected $description = '执行店铺收款账户账单导出任务(后台进程调用)';
public function handle(AccountBillsExportTasks $tasks, AccountLedgerRemarkMap $remarkMap): int
{
$lock = \Illuminate\Support\Facades\Cache::store('upstream_shared')->lock('account-bills:export-worker', 10800);
if (! $lock->get()) return 75;
try {
return $this->runTask($tasks, $remarkMap);
} finally {
$lock->release();
}
}
private function runTask(AccountBillsExportTasks $tasks, AccountLedgerRemarkMap $remarkMap): int
{
$token = (string) $this->argument('token');
$task = $this->findTask($tasks, $token);
@ -47,7 +36,6 @@ class RunAccountBillsExportTask extends Command
return self::FAILURE;
}
if (($task['status'] ?? '') !== 'queued') return self::SUCCESS;
$task = $tasks->patch($task, ['status' => 'running', 'started_at' => AccountBillsExportTasks::beijingNow()]);
@ -122,7 +110,7 @@ class RunAccountBillsExportTask extends Command
private function findTask(AccountBillsExportTasks $tasks, string $token): ?array
{
$root = $tasks->rootDir();
$root = storage_path('app/account-bills-exports');
foreach (glob($root . '/*/' . $token . '.json') ?: [] as $path) {
$task = json_decode((string) file_get_contents($path), true);
if (is_array($task) && ($task['token'] ?? null) === $token) {

View File

@ -1,112 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Modules\EcommerceFinance\Services\AccountMappingResyncDispatcher;
use Symfony\Component\Process\Process;
use Throwable;
/**
* 科目映射表变更后的业绩明细自动重跑队列。
*
* 每轮按预算处理队列里的「平台 × 月份」;实际同步放在独立 PHP 子进程中,
* 避免大平台重跑时的内存累积,并让其它同步有机会取得全局锁。
*/
class RunAccountMappingResync extends Command
{
protected $signature = 'store-receipt:auto-resync {--max-jobs=1 : 每轮最多任务数,0为不限} {--max-seconds=300 : 单个任务结束后检查本轮时间预算}';
protected $description = '处理科目映射表变更触发的业绩明细重跑队列';
public function handle(AccountMappingResyncDispatcher $dispatcher): int
{
// 页面(Apache)与每分钟 cron 都以 www-data 运行,使用共享数据库锁,
// 不受 storage/framework/cache 文件属主影响,也能避免多容器重复消费。
$lock = Cache::store('upstream_shared')->lock('store-receipt:auto-resync', 86400);
if (! $lock->get()) {
// 已有进程在跑;本次新入队的任务会在当前进程下一轮循环或每分钟兜底时执行。
return self::SUCCESS;
}
try {
// 进程崩溃遗留的 running 任务超过 60 分钟视为可重试。
DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)
->where('status', 'running')
->where('updated_at', '<', now()->subMinutes(60))
->update(['status' => 'pending', 'updated_at' => now()]);
$processed = 0;
$maxJobs = max(0, (int) $this->option('max-jobs'));
$deadline = microtime(true) + max(1, (int) $this->option('max-seconds'));
while (($maxJobs === 0 || $processed < $maxJobs) && microtime(true) < $deadline) {
$job = DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)
->where('status', 'pending')
->orderBy('id')
->first();
if ($job === null) {
break;
}
$processed++;
DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)
->where('id', $job->id)
->where('status', 'pending')
->update([
'status' => 'running',
'attempts' => DB::raw('attempts + 1'),
'updated_at' => now(),
]);
try {
[$succeeded, $output] = $this->runSync($job);
// 同步期间又保存了映射时,dispatchFor 已把状态改回 pending;
// 这里只在仍为 running 时写 done,不能吞掉新请求。
DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)
->where('id', $job->id)
->where('status', 'running')
->update([
'status' => $succeeded ? 'done' : 'failed',
'message' => mb_substr(
$output !== '' ? $output : ($succeeded ? 'OK' : '同步命令执行失败'),
0,
1000
),
'updated_at' => now(),
]);
} catch (Throwable $exception) {
Log::error('科目映射表自动重跑失败:' . $exception->getMessage());
DB::table(AccountMappingResyncDispatcher::JOBS_TABLE)
->where('id', $job->id)
->where('status', 'running')
->update([
'status' => 'failed',
'message' => mb_substr($exception->getMessage(), 0, 1000),
'updated_at' => now(),
]);
}
}
} finally {
$lock->release();
}
return self::SUCCESS;
}
/** @return array{bool,string} */
protected function runSync(object $job): array
{
$process = new Process([
PHP_BINARY ?: 'php', 'artisan', 'store-receipt:order-match:sync',
(string) $job->month, '--platform=' . $job->platform,
], base_path());
$process->setTimeout(7200);
$process->run();
return [$process->isSuccessful(), trim($process->getOutput() . "\n" . $process->getErrorOutput())];
}
}

View File

@ -7,7 +7,6 @@ use Illuminate\Support\Facades\Log;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Modules\EcommerceFinance\Services\BusinessMirrorSynchronizer;
use Modules\EcommerceFinance\Services\DingProcessDetailMirrorSynchronizer;
use Modules\EcommerceFinance\Services\DingPayableWorkflowMirrorSynchronizer;
use Throwable;
@ -27,7 +26,6 @@ class SyncBusinessMirrors extends Command
BusinessMirrorSynchronizer $synchronizer,
GenericBusinessMirrorRepository $mirror,
?DingPayableWorkflowMirrorSynchronizer $dingPayableWorkflows = null,
?DingProcessDetailMirrorSynchronizer $dingProcessDetails = null,
): int
{
if (! (bool) config('upstream_mirrors.refresh_enabled', false) && ! $this->option('full')) {
@ -130,24 +128,6 @@ class SyncBusinessMirrors extends Command
Log::error('钉钉线上代发价格流程镜像同步失败。', ['exception' => $exception::class]);
$this->error('operations_replica.rpt.ding_online_daifa_price:同步失败,已保留上一版共享数据。');
}
// 财务流程明细按结构化明细/付款事实同步。首次全量通过专用命令执行,
// 日常只处理最近更新过的审批,避免每分钟重扫整批钉钉表单。
if ($dingProcessDetails !== null) {
try {
$detailResult = $dingProcessDetails->sync(false);
if ($detailResult['status'] === 'succeeded') {
$this->info(sprintf(
'operations_replica.rpt.ding_process_details:同步完成,本轮 %d 个审批。',
$detailResult['workflows'],
));
}
} catch (Throwable $exception) {
$failed = true;
Log::error('钉钉流程明细结构镜像同步失败。', ['exception' => $exception::class]);
$this->error('operations_replica.rpt.ding_process_details:同步失败,已保留上一版共享数据。');
}
}
}
return $failed ? self::FAILURE : self::SUCCESS;

View File

@ -1,64 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\DingProcessDetailMirrorSynchronizer;
use Throwable;
class SyncDingProcessDetailMirror extends Command
{
protected $signature = 'business-mirror:sync-ding-process-details
{--full : 从配置起始日期完整重建明细和付款事实}
{--business-id= : 只同步指定审批编号,多个用逗号分隔(最多 50 个)}
{--dry-run : 只读取上游并校验,不写入镜像}';
protected $description = '从结构化审批镜像同步流程明细,并用钉钉官方接口补取缺失付款';
public function handle(DingProcessDetailMirrorSynchronizer $synchronizer): int
{
$businessIds = array_values(array_filter(array_map(
'trim',
explode(',', (string) $this->option('business-id')),
)));
try {
if ($businessIds !== []) {
if ((bool) $this->option('full')) {
throw new \InvalidArgumentException('--full 不能和 --business-id 同时使用。');
}
$result = $synchronizer->syncBusinessIds(
$businessIds,
(bool) $this->option('dry-run'),
);
} else {
$result = $synchronizer->sync(
(bool) $this->option('full'),
(bool) $this->option('dry-run'),
);
}
} catch (Throwable $exception) {
$this->error('同步失败,已保留上一版共享数据:' . $exception->getMessage());
return self::FAILURE;
}
if ($result['status'] === 'requires_full_sync') {
$this->warn('流程明细尚未完成结构化全量同步,请使用 --full。');
return self::SUCCESS;
}
$this->info(sprintf(
'%s:%d 个审批,%d 条明细,%d 笔付款;金额合计 %.2f,银行付款合计 %.2f。',
$result['status'] === 'validated' ? '校验通过' : '同步完成',
$result['workflows'],
$result['details'],
$result['payments'],
$result['amount_total'],
$result['bank_amount_total'],
));
return self::SUCCESS;
}
}

View File

@ -1,60 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\FinanceLedgerRptSynchronizer;
use Throwable;
class SyncFinanceLedgerRpt extends Command
{
protected $signature = 'finance-ledger:sync-rpt
{--from= : 业务日期起,默认读取配置}
{--to= : 业务日期止,默认读取配置或当天}
{--days= : 只拉取最近 N 天并合并到现有明细}
{--merge : 合并到现有明细,不删除历史}
{--no-recalculate : 只同步明细,不重算流水}';
protected $description = '从 AI 库 rpt 同步财务台账动账明细,并重算每日流水';
public function handle(FinanceLedgerRptSynchronizer $synchronizer): int
{
$days = (int) ($this->option('days') ?: 0);
$from = $this->option('from') ?: null;
$merge = (bool) $this->option('merge');
if ($days > 0) {
$from = now()->subDays(max(1, $days) - 1)->toDateString();
$merge = true;
}
try {
$result = $synchronizer->sync(
! $this->option('no-recalculate'),
$from,
$this->option('to') ?: null,
$merge,
);
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$this->table(['项目', '值'], [
['明细行数', $result['rows']],
['收入合计', number_format($result['income'], 2)],
['支出合计', number_format($result['expense'], 2)],
['日期范围', $result['from'] . ' ~ ' . $result['to']],
['每日流水行数', $result['recalculated']['flows'] ?? '-'],
['期末余额合计', isset($result['recalculated']) ? number_format($result['recalculated']['balance_sum'], 2) : '-'],
]);
$this->line('来源分布:');
foreach (($result['source_counts'] ?? []) as $source => $count) {
$this->line(' ' . $source . ': ' . $count);
}
$this->info('财务台账 rpt 同步完成。');
return self::SUCCESS;
}
}

View File

@ -1,87 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\FinanceLedgerShopDataSynchronizer;
use Throwable;
class SyncFinanceLedgerShopData extends Command
{
protected $signature = 'finance-ledger:sync-shop-data
{--from= : 提现业务日期起,默认从台账期初日}
{--to= : 提现业务日期止,最晚只同步到前一晚}
{--days= : 只同步最近 N 天}
{--rebuild : 先用最新余额快照清空并重建店铺台账}
{--balance-only : 只生成店铺余额快照并更新台账,不处理平台提现}
{--no-recalculate : 只同步数据,不重算流水}';
protected $description = '生成上一晚店铺余额快照(店铺收款账户账单优先),并把平台提现对应到财务账户收入';
public function handle(FinanceLedgerShopDataSynchronizer $synchronizer): int
{
$days = (int) ($this->option('days') ?: 0);
$from = $this->option('from') ?: null;
if ($days > 0) {
$from = now()->subDays(max(1, $days) - 1)->toDateString();
}
try {
if ($this->option('balance-only')) {
$balance = $synchronizer->syncShopBalances(
$this->option('to') ?: null,
(bool) $this->option('rebuild'),
);
$result = [
'balance' => $balance,
'withdrawals' => [
'processed' => 0,
'written' => 0,
'upstream_duplicates' => 0,
'unmatched' => 0,
'account_missing' => 0,
'income' => 0.0,
'expense' => 0.0,
],
'recalculated' => null,
];
} else {
$result = $synchronizer->sync(
$from,
$this->option('to') ?: null,
! $this->option('no-recalculate'),
(bool) $this->option('rebuild'),
);
}
} catch (Throwable $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
$this->table(['项目', '值'], [
['店铺余额日期', $result['balance']['date'] ?: '-'],
['余额源记录', $result['balance']['source']],
['店铺账单余额来源', $result['balance']['receiving_bill']],
['聚水潭补录来源', $result['balance']['jushuitan']],
['补充确认店铺', $result['balance']['added']],
['重建店铺台账', $result['balance']['rebuilt']],
['已更新店铺余额', $result['balance']['matched']],
['店铺余额合计', number_format((float) $result['balance']['amount'], 2)],
['提现处理笔数', $result['withdrawals']['processed']],
['提现新增或更新', $result['withdrawals']['written']],
['上游已存在提现', $result['withdrawals']['upstream_duplicates']],
['店铺未匹配', $result['withdrawals']['unmatched']],
['账户缺失', $result['withdrawals']['account_missing']],
['提现收入合计', number_format((float) $result['withdrawals']['income'], 2)],
['提现冲回支出', number_format((float) $result['withdrawals']['expense'], 2)],
['余额预警待办新增', $result['balance']['alerts']['created']],
['余额预警待办更新', $result['balance']['alerts']['updated']],
['每日流水行数', $result['recalculated']['flows'] ?? '-'],
['PHP 峰值内存', number_format(memory_get_peak_usage(true) / 1048576, 1) . ' MiB'],
]);
$this->info('财务台账店铺余额与提现同步完成。');
return self::SUCCESS;
}
}

View File

@ -142,12 +142,7 @@ class SyncJstOrderLabelItemCodes extends Command
$params = array_merge($bindings, [$companyId], $orderNos, [$start, $end]);
$updated = DB::connection()->update($sql, $params);
if ($updated > 0) {
app(\App\Business\PlantingOrderLabelLookup::class)->forget($companyId);
}
return $updated;
return DB::connection()->update($sql, $params);
}
/** @return array{0:string,1:string} 起止日期(Y-m-d) */

View File

@ -6,14 +6,12 @@ use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use Modules\EcommerceFinance\Repositories\JushuitanCostPriceHistoryMirrorRepository;
use Modules\EcommerceFinance\Services\JushuitanCostPriceHistorySynchronizer;
use Modules\EcommerceFinance\Services\JushuitanCostPriceSnapshotDiffer;
use Throwable;
class SyncJushuitanCostPriceHistory extends Command
{
protected $signature = 'business-mirror:sync-jushuitan-cost-price-history
{--full : 强制启动或继续首次全量同步}
{--source=open_api : open_api 为正式操作历史;snapshot 为独立的镜像价格观测}
{--page-size= : 聚水潭接口每页读取条数,最大 50}
{--max-pages= : 本轮最多读取页数,0 表示直到完成}';
@ -27,25 +25,6 @@ class SyncJushuitanCostPriceHistory extends Command
return self::SUCCESS;
}
if ($this->option('source') === 'snapshot') {
try {
$result = app(JushuitanCostPriceSnapshotDiffer::class)->sync(
$this->option('page-size') === null ? 500 : (int) $this->option('page-size'),
$this->option('max-pages') === null ? 20 : (int) $this->option('max-pages'),
);
$this->info(sprintf('镜像价格观测:%s,本轮读取 %d 条,发现 %d 次价格变化;不代表完整操作历史。', $result['status'], $result['rows'], $result['changes']));
return self::SUCCESS;
} catch (Throwable $exception) {
Log::error('镜像价格观测失败。', ['exception' => $exception::class]);
$this->error('镜像价格观测失败,已保留基线、记录和续传游标。');
return self::FAILURE;
}
}
if ($this->option('source') !== 'open_api') {
$this->error('source 仅支持 open_api 或 snapshot。');
return self::FAILURE;
}
try {
$state = $mirror->state();
$full = $this->shouldRunFull($state);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -38,7 +38,7 @@ class SyncStoreReportMirror extends Command
}
$this->info(sprintf(
'店铺报表镜像:流程期间 %s/%d 行,主体变更 %s(检查 %d 店,新增 %d 条),账单 %s/%d 行,日汇总 %s/%d 行,销量汇总 %s/%d 行。',
'店铺报表镜像:流程期间 %s/%d 行,主体变更 %s(检查 %d 店,新增 %d 条),账单 %s/%d 行,日汇总 %s/%d 行。',
$result['lifecycles']['status'],
$result['lifecycles']['source_rows'],
$result['period_changes']['status'],
@ -48,8 +48,6 @@ class SyncStoreReportMirror extends Command
$result['bills']['source_rows'],
$result['daily']['status'],
$result['daily']['source_rows'],
$result['paid_sales']['status'],
$result['paid_sales']['stored_rows'],
));
return self::SUCCESS;

View File

@ -1,51 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Modules\EcommerceFinance\Services\StoreReportMirrorSynchronizer;
use Throwable;
/**
* 店铺维度报表的「销量」汇总:业绩明细「收入金额 + 优惠金额 + 冲减金额 + 退款金额」
* 按「店铺名称 + 发生日期」预汇总。
*
* 业绩明细同步(store-receipt:order-match:sync)结束后会自动刷新一次;
* 这个命令用于单独重跑(例如补数据或按最新代码强制重建)。
*/
class SyncStoreReportPaidSales extends Command
{
protected $signature = 'business-mirror:sync-store-report-paid-sales
{--force : 忽略来源指纹,强制重建销量汇总}';
protected $description = '把业绩明细的收入/优惠/冲减/退款金额按店铺和发生日期汇总,供店铺维度报表销量列读取';
public function handle(StoreReportMirrorSynchronizer $synchronizer): int
{
try {
$result = $synchronizer->syncPaidSales((bool) $this->option('force'));
} catch (Throwable $exception) {
$this->error('店铺维度报表销量汇总失败:' . $exception->getMessage());
return self::FAILURE;
}
if ($result['status'] === 'skipped') {
$this->warn('镜像里还没有业绩明细表,销量汇总未执行。');
return self::SUCCESS;
}
if ($result['status'] !== 'succeeded') {
$this->error('店铺维度报表销量汇总失败,页面继续读取上一版数据。');
return self::FAILURE;
}
$this->info($result['changed']
? sprintf('销量汇总已重建:业绩明细 %d 行 → 汇总 %d 行。', $result['source_rows'], $result['stored_rows'])
: sprintf('业绩明细没有变化,沿用当前销量汇总(%d 行)。', $result['stored_rows']));
return self::SUCCESS;
}
}

View File

@ -1,172 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
class VerifyErpDailyBillCoverage extends Command
{
protected $signature = 'business-mirror:verify-erp-daily-coverage
{--from=2026-07-01 : 起始日期 YYYY-MM-DD}
{--to= : 截止日期 YYYY-MM-DD,默认昨日}
{--json : 输出 JSON}';
protected $description = '核对京东日账单、京东钱包、头条放心购的月份与截止日覆盖是否完整';
private const TARGETS = [
'jd-daily' => [
'label' => '京东日账单',
'periods' => 'jd_daily_bill_periods',
'rows' => 'jd_daily_bill_rows',
],
'jd-wallet' => [
'label' => '京东钱包',
'periods' => 'jd_wallet_bill_periods',
'rows' => 'jd_wallet_bill_rows',
],
'douyin' => [
'label' => '头条放心购',
'periods' => 'douyin_authoritative_periods',
'rows' => 'douyin_authoritative_bills',
],
];
public function handle(): int
{
$fromText = (string) $this->option('from');
$toText = (string) ($this->option('to') ?: now('Asia/Shanghai')->subDay()->toDateString());
$from = $this->date($fromText);
$to = $this->date($toText);
if ($from === null || $to === null || $from->gt($to)) {
$this->error('日期范围无效,应为 from <= to,格式 YYYY-MM-DD。');
return self::FAILURE;
}
$db = DB::connection(config('upstream_mirrors.connection', 'business_mirror'));
$schema = Schema::connection(config('upstream_mirrors.connection', 'business_mirror'));
$results = [];
$failed = false;
foreach (self::TARGETS as $key => $target) {
if (! $schema->hasTable($target['periods']) || ! $schema->hasTable($target['rows'])) {
$results[] = [
'dataset' => $key,
'label' => $target['label'],
'month' => null,
'required_start' => $from->toDateString(),
'required_through' => $to->toDateString(),
'imported_start' => null,
'imported_end' => null,
'row_count' => 0,
'registered_rows' => null,
'income' => null,
'expense' => null,
'status' => 'missing_table',
'issue' => '镜像表不存在',
];
$failed = true;
continue;
}
$month = $from->copy()->startOfMonth();
while ($month->lte($to)) {
$monthEnd = $month->copy()->endOfMonth();
$requiredStart = ($month->lt($from) ? $from->copy() : $month->copy())->startOfDay();
$requiredThrough = ($monthEnd->gt($to) ? $to->copy() : $monthEnd->copy())->startOfDay();
$requiredEndExclusive = $requiredThrough->copy()->addDay();
$period = $db->table($target['periods'])
->where('period_start', $month->toDateString())
->first();
$status = 'ok';
$rowCount = 0;
$issue = null;
$summary = null;
if ($period === null) {
$status = 'missing_month';
$issue = '缺少该月份导入记录';
} elseif (Carbon::parse((string) $period->period_end)->lt($requiredEndExclusive)) {
$status = 'partial';
$issue = '导入截止日早于要求日期';
} else {
$summary = $db->table($target['rows'])
->where('generation', $period->active_generation)
->selectRaw('COUNT(*) AS rows_count, COALESCE(SUM(income), 0) AS income, COALESCE(SUM(expense), 0) AS expense')
->first();
$rowCount = (int) ($summary->rows_count ?? 0);
if ($rowCount === 0) {
$status = 'empty';
$issue = '有效版本没有明细';
} elseif ($rowCount !== (int) $period->row_count) {
$status = 'row_count_mismatch';
$issue = '有效版本实际行数与期间登记不一致';
} elseif (abs(round((float) $summary->income, 2) - (float) $period->income) > 0.005
|| abs(round((float) $summary->expense, 2) - (float) $period->expense) > 0.005) {
$status = 'amount_mismatch';
$issue = '有效版本收支合计与期间登记不一致';
}
}
if ($status !== 'ok') {
$failed = true;
}
$results[] = [
'dataset' => $key,
'label' => $target['label'],
'month' => $month->format('Y-m'),
'required_start' => $requiredStart->toDateString(),
'required_through' => $requiredThrough->toDateString(),
'imported_start' => $period?->period_start,
'imported_end' => $period?->period_end,
'row_count' => $rowCount,
'registered_rows' => $period === null ? null : (int) $period->row_count,
'income' => isset($summary) ? round((float) $summary->income, 2) : null,
'expense' => isset($summary) ? round((float) $summary->expense, 2) : null,
'status' => $status,
'issue' => $issue,
];
unset($summary);
$month->addMonthNoOverflow()->startOfMonth();
}
}
if ($this->option('json')) {
$this->line(json_encode($results, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES));
} else {
$this->table(
['数据集', '月份', '要求覆盖', '导入覆盖', '实际行数', '登记行数', '收入', '支出', '状态', '说明'],
array_map(static fn (array $row): array => [
$row['label'],
$row['month'] ?? '—',
($row['required_start'] ?? '—') . ' ~ ' . ($row['required_through'] ?? '—'),
($row['imported_start'] ?? '—') . ' ~ ' . ($row['imported_end'] ?? '—'),
number_format((int) $row['row_count']),
$row['registered_rows'] === null ? '—' : number_format((int) $row['registered_rows']),
$row['income'] === null ? '—' : number_format((float) $row['income'], 2),
$row['expense'] === null ? '—' : number_format((float) $row['expense'], 2),
$row['status'],
$row['issue'] ?? '',
], $results),
);
}
return $failed ? self::FAILURE : self::SUCCESS;
}
private function date(string $value): ?Carbon
{
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $value) !== 1) {
return null;
}
$date = Carbon::createFromFormat('!Y-m-d', $value, 'Asia/Shanghai');
return $date !== false && $date->format('Y-m-d') === $value ? $date : null;
}
}

View File

@ -1,70 +0,0 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class VerifyFinanceLedger extends Command
{
protected $signature = 'finance-ledger:verify';
protected $description = '校验财务台账数量、金额勾稽和流水平衡(只读)';
public function handle(): int
{
$db = DB::connection((string) config('upstream_mirrors.connection', 'business_mirror'));
$accounts = (int) $db->table('fin_accounts')->count();
$shops = (int) $db->table('fin_shops')->count();
$companies = (int) $db->table('fin_companies')->count();
$openingRows = (int) $db->table('fin_account_opening')->count();
$transactions = (int) $db->table('fin_transactions')->count();
$flows = (int) $db->table('fin_daily_flows')->count();
$maps = (int) $db->table('fin_account_map')->count();
$mapped = (int) $db->table('fin_account_map')->whereNotNull('account_short')->where('account_short', '<>', '')->count();
$accountBalance = round((float) $db->table('fin_accounts')->sum('current_balance'), 2);
$openingSum = round((float) $db->table('fin_account_opening')->sum('opening_balance'), 2);
$income = round((float) $db->table('fin_transactions')->sum('income'), 2);
$expense = round((float) $db->table('fin_transactions')->sum('expense'), 2);
$flowOpening = round((float) $db->table('fin_daily_flows')->sum('opening_balance'), 2);
$flowInflow = round((float) $db->table('fin_daily_flows')->sum('inflow'), 2);
$flowOutflow = round((float) $db->table('fin_daily_flows')->sum('outflow'), 2);
$flowClosing = round((float) $db->table('fin_daily_flows')->sum('closing_balance'), 2);
$diff = round($flowOpening + $flowInflow - $flowOutflow - $flowClosing, 2);
$this->table(['项目', '值'], [
['账户', $accounts],
['店铺', $shops],
['公司', $companies],
['期初账户', $openingRows],
['期初合计', number_format($openingSum, 2)],
['账户余额合计', number_format($accountBalance, 2)],
['动账明细', $transactions],
['收入合计', number_format($income, 2)],
['支出合计', number_format($expense, 2)],
['每日流水', $flows],
['流水期末合计', number_format($flowClosing, 2)],
['流水勾稽差异', number_format($diff, 2)],
['映射记录', $maps],
['已映射', $mapped],
]);
$errors = [];
if (abs($openingSum - $accountBalance) > 0.01) {
$errors[] = '期初合计与账户余额合计不一致。';
}
if (abs($diff) > 0.01) {
$errors[] = '每日流水期末不等于期初+入账-出账。';
}
if ($openingRows !== $flows) {
$errors[] = '期初账户数与当前流水行数不一致(期初日应一账户一行)。';
}
foreach ($errors as $error) {
$this->error($error);
}
return $errors === [] ? self::SUCCESS : self::FAILURE;
}
}

View File

@ -9,14 +9,14 @@ use Throwable;
class WarmOrderDataShopCandidates extends Command
{
protected $signature = 'order-data:warm-shop-candidates {--full : 全量核对店铺候选(夜间维护)}';
protected $signature = 'order-data:warm-shop-candidates';
protected $description = '增量预热订单售后费用明细的店铺候选,夜间使用 --full 全量核对';
protected $description = '预热订单售后费用明细的店铺候选(要扫两张百万级订单宽表,不能放在页面请求里)';
public function handle(OrderDataMirrorRepository $orders): int
{
try {
$names = $orders->refreshShopNamesCache((bool) $this->option('full'));
$names = $orders->refreshShopNamesCache();
$this->info(sprintf('店铺候选已预热,共 %d 个店铺。', count($names)));
return self::SUCCESS;

View File

@ -53,13 +53,6 @@ class Kernel extends ConsoleKernel
->withoutOverlapping(60)
->runInBackground();
// 上游店铺档案每日刷新后留存一份完整基础信息,方便按日期复查负责人等字段。
$schedule->command('business-mirror:archive-store-information')
->dailyAt('07:00')
->timezone('Asia/Shanghai')
->withoutOverlapping(120)
->runInBackground();
$schedule->command('business-mirror:sync-supplier-management')
->dailyAt('08:15')
->timezone('Asia/Shanghai')
@ -83,22 +76,6 @@ class Kernel extends ConsoleKernel
->withoutOverlapping(30)
->runInBackground();
// 供应商负数预警:每天 10:00 仅推送最新账期内尚未处理的供应商。
$schedule->command('supplier-negative-alert:push-dingtalk')
->dailyAt('10:00')
->timezone('Asia/Shanghai')
->withoutOverlapping(60)
->appendOutputTo(storage_path('logs/supplier-negative-alert-dingtalk.log'))
->runInBackground();
// 财务台账:从 AI 库 rpt 同步动账明细,按日重算账户流水。
// 生产宿主机 cron 仍需配置对应的 finance-ledger:sync-rpt 入口。
$schedule->command('finance-ledger:sync-rpt --days=7 --merge')
->dailyAt('02:30')
->timezone('Asia/Shanghai')
->withoutOverlapping(120)
->runInBackground();
$schedule->command('business-mirror:sync-third-party-warehouse-storage --max-batches=0')
->dailyAt('00:00')
->withoutOverlapping(180)
@ -111,22 +88,18 @@ class Kernel extends ConsoleKernel
->runInBackground();
}
// 聚水潭发货数据:凌晨重拉最近 40 天。聚水潭常在发货后数天到数周内换单/改快递公司/作废采购退货出库,
// 只拉最近几天会让上月数据停在旧值(快递名称不一致、已作废的单仍在);40 天覆盖上月结算期。
// 聚水潭发货数据每日重拉最近三天,覆盖副本库迟到更新。
// 默认关闭(JST_SYNC_ENABLED=false);填好凭证并完成迁移后再打开。
if ((bool) env('JST_SYNC_ENABLED', false)) {
$schedule->command('jushuitan:sync-shipping --days=40')
->dailyAt('01:28')
->withoutOverlapping(300)
->appendOutputTo(storage_path('logs/jst-sync-shipping.log'))
->runInBackground();
// 凌晨副本库最近一两天的商品明细常未到齐(出库单常在发货次日下午才确认,视图随后才有商品);
// 白天副本库负载高、查询常超时断开,补拉放到晚上:每天 21:28 再拉最近 3 天,
// 补齐凌晨只写入单号、缺商品明细的包裹。
$schedule->command('jushuitan:sync-shipping --days=3')
->dailyAt('21:28')
->dailyAt('01:28')
->withoutOverlapping(120)
->runInBackground();
// 凌晨副本库最近一两天的订单/商品明细常未到齐(视图、订单商品表滞后),
// 早上再拉一次最近 3 天,补齐凌晨只写入单号、缺商品明细的包裹。
$schedule->command('jushuitan:sync-shipping --days=3')
->dailyAt('07:28')
->withoutOverlapping(120)
->appendOutputTo(storage_path('logs/jst-sync-shipping.log'))
->runInBackground();
}

View File

@ -157,10 +157,7 @@ class DingTalkLogin extends Controller
$url = $this->applicationRoute($user->landing_page, ['company_id' => $company->id]);
// 免登后固定回到首页(财务驾驶舱),不再回跳上次打开的业务页面。
$request->session()->forget('url.intended');
return redirect()->to($url);
return redirect()->intended($url);
}
protected function failed(): RedirectResponse

View File

@ -4,7 +4,6 @@ namespace App\Http\Controllers\Auth;
use App\Abstracts\Http\Controller;
use App\Http\Requests\Auth\Login as Request;
use App\Models\Common\Contact;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
@ -80,14 +79,10 @@ class Login extends Controller
// Redirect to portal if is customer
if ($user->isCustomer()) {
$path = session('url.intended', '');
$portalHome = $user->contact instanceof Contact
&& $user->contact->type === Contact::VENDOR_TYPE
? route('portal.supplier-reconciliation.index', ['company_id' => $company->id])
: route('portal.dashboard', ['company_id' => $company->id]);
// Path must start with company id and 'portal' prefix
if (!Str::startsWith($path, $company->id . '/portal')) {
$path = $portalHome;
$path = route('portal.dashboard', ['company_id' => $company->id]);
}
if (! $request->expectsJson()) {
@ -107,11 +102,8 @@ class Login extends Controller
// Redirect to landing page if is user
$url = route($user->landing_page, ['company_id' => $company->id]);
// 登录后固定回到首页(财务驾驶舱),不再回跳上次打开/被拦截的业务页面。
$request->session()->forget('url.intended');
if (! $request->expectsJson()) {
return redirect()->to($url);
return redirect()->intended($url);
}
return response()->json([
@ -120,7 +112,7 @@ class Login extends Controller
'error' => false,
'message' => trans('auth.login_redirect'),
'data' => null,
'redirect' => url($url),
'redirect' => redirect()->intended($url)->getTargetUrl(),
]);
}

View File

@ -35,7 +35,7 @@ class AccessoryReshipDeductions extends Controller
fwrite($file, "\xEF\xBB\xBF");
fputcsv($file, ['交易单号','补发快递单号','补发快递','补发时间','原始条形码','处理后条形码','补发运费','匹配状态','首发快递公司(候选)','首发快递单号(候选)','首发所属月份(候选)','扣款比例(%)','扣款金额','说明']);
foreach ($data->rows($filters) as $row) {
if (!AccessoryReshipPageData::inCategory($row, $filters['match_status'])) continue;
if ($filters['match_status'] !== 'all' && $row['status'] !== $filters['match_status']) continue;
$values = [$row['trade_no'], $row['express_no'], ($row['express_name1'] ?: ($row['express_name2'] ?? '')), $row['ship_time'], $row['barcode'], $row['normalized_barcode'], $row['freight'], $row['deduction_state'] === 'ready' ? '可扣款' : ($row['status'] === 'matched' ? '已匹配待核对' : '未匹配不扣款'), implode(' / ', array_column($row['matches'], 'courier')), implode(' / ', array_column($row['matches'], 'express_no')), implode(' / ', array_column($row['matches'], 'month')), $row['deduction_rate_basis_points'] === null ? '' : number_format($row['deduction_rate_basis_points'] / 100, 2, '.', '') . '%', $row['deduction_cents'] === null ? '' : number_format($row['deduction_cents'] / 100, 2, '.', ''), $row['deduction_note']];
fputcsv($file, array_map(static function ($value) {
$value = (string) $value;

View File

@ -12,7 +12,6 @@ use Modules\EcommerceFinance\Services\AccountMappingCoa;
use Modules\EcommerceFinance\Services\AccountMappingStore;
use Modules\EcommerceFinance\Services\AccountMappingValidator;
use Modules\EcommerceFinance\Services\AccountMappingWorkbookImporter;
use Modules\EcommerceFinance\Services\AccountMappingResyncDispatcher;
use RuntimeException;
use Symfony\Component\HttpFoundation\StreamedResponse;
@ -43,7 +42,6 @@ class AccountMappingActions extends Controller
'memo' => '财务备注',
'invoice' => '票据口径',
'category' => '大类',
'feeMemo' => '费用备注',
'dAcct' => '借方科目',
'dSub' => '借方二级',
'cAcct' => '贷方科目',
@ -76,37 +74,27 @@ class AccountMappingActions extends Controller
$this->store->create($values, $this->userId());
$hint = $this->dispatchResync([(string) ($values['platform'] ?? '')]);
return response()->json(['message' => '已新增 1 条规则。' . $hint] + $this->payload());
return response()->json(['message' => '已新增 1 条规则。'] + $this->payload());
}
public function update(Request $request, int $id): JsonResponse
{
$before = $this->store->find($id);
if ($before === null) {
if ($this->store->find($id) === null) {
throw ValidationException::withMessages(['id' => '这条规则已经不存在了,请刷新页面。']);
}
$values = $this->payloadValues($request);
$after = $this->store->update($id, $values, $this->userId());
$this->store->update($id, $this->payloadValues($request), $this->userId());
$hint = AccountMappingStore::requiresReceiptResync($before, $after)
? $this->dispatchResync([(string) ($before['platform'] ?? ''), (string) ($after['platform'] ?? '')]) : '';
return response()->json(['message' => '已保存。' . $hint] + $this->payload());
return response()->json(['message' => '已保存。'] + $this->payload());
}
public function destroy(int $id): JsonResponse
{
$before = $this->store->find($id);
if (! $this->store->destroy($id, $this->userId())) {
throw ValidationException::withMessages(['id' => '这条规则已经不存在了,请刷新页面。']);
}
$hint = $this->dispatchResync([(string) ($before['platform'] ?? '')]);
return response()->json(['message' => '已删除 1 条规则。' . $hint] + $this->payload());
return response()->json(['message' => '已删除 1 条规则。'] + $this->payload());
}
/**
@ -193,24 +181,21 @@ class AccountMappingActions extends Controller
}
$restored = $this->store->create($before, $this->userId());
$hint = $this->dispatchResync([(string) ($restored['platform'] ?? '')]);
return response()->json([
'message' => '已恢复「' . $restored['platform'] . ' · ' . $restored['item'] . '」(新记录 #' . (int) $restored['id'] . ')。' . $hint,
'message' => '已恢复「' . $restored['platform'] . ' · ' . $restored['item'] . '」(新记录 #' . (int) $restored['id'] . ')。',
] + $this->payload());
}
if ($action === 'update') {
$current = $this->store->find($ruleId);
if ($current === null) {
if ($this->store->find($ruleId) === null) {
throw ValidationException::withMessages(['history' => '这条规则已经被删掉了,请用同一条的「删除」记录恢复。']);
}
$this->store->update($ruleId, $before, $this->userId());
$hint = $this->dispatchResync([(string) ($current['platform'] ?? ''), (string) ($before['platform'] ?? '')]);
return response()->json([
'message' => '已恢复成这次修改前的内容(记录 #' . $ruleId . ')。' . $hint,
'message' => '已恢复成这次修改前的内容(记录 #' . $ruleId . ')。',
] + $this->payload());
}
@ -242,8 +227,6 @@ class AccountMappingActions extends Controller
$parsed = $this->importer->parse($file);
$result = $this->store->importRows($parsed['rows'], $request->boolean('overwrite'), $this->userId());
$message = "导入完成:识别 {$result['total']} 行,新增 {$result['created']} 条,更新 {$result['updated']} 条,跳过重复 {$result['skipped']} 条。";
$platforms = $result['resync_platforms'];
$message .= $this->dispatchResync($platforms);
return response()->json(['message' => $message, 'result' => $result] + $this->payload());
}
@ -319,7 +302,7 @@ class AccountMappingActions extends Controller
{
$total = $this->store->reset($this->userId());
return response()->json(['message' => "已恢复为初始 {$total} 条规则。" . $this->dispatchResyncAll()] + $this->payload());
return response()->json(['message' => "已恢复为初始 {$total} 条规则。"] + $this->payload());
}
/** 账套科目(一级 + 各一级下的明细),供下拉使用 */
@ -370,8 +353,8 @@ class AccountMappingActions extends Controller
public function template(): StreamedResponse
{
$samples = [
['得物', '平台服务费', '平台服务费', '-', '0.06', '费用', '平台服务费', '销售费用', '平台服务费', '其他货币资金', '', '支出基准'],
['淘宝', '交易收入', '订单', '-', '-', '收入', '收入', '其他货币资金', '', '主营业务收入', '', '收入基准'],
['得物', '平台服务费', '平台服务费', '0.06', '费用', '销售费用', '平台佣金', '其他货币资金', '', '支出基准'],
['淘宝', '交易收入', '订单', '-', '收入', '其他货币资金', '', '主营业务收入', '', '收入基准'],
];
return response()->streamDownload(function () use ($samples): void {
@ -423,44 +406,6 @@ class AccountMappingActions extends Controller
return user_id() ? (int) user_id() : null;
}
/**
* 保存映射后把受影响的平台排进后台重跑队列。
*
* 映射表本身是实时读取的,但业绩明细的优惠/冲减/退款金额是同步时落库的,
* 所以保存成功后需要自动重跑对应平台和月份。队列创建失败不能把已经保存成功的规则回滚。
*
* @param list<string> $platforms
*/
private function dispatchResync(array $platforms): string
{
try {
$result = app(AccountMappingResyncDispatcher::class)->dispatchFor($platforms);
} catch (\Throwable $exception) {
report($exception);
return ' 业绩明细自动重跑任务创建失败,请稍后重试保存或联系管理员。';
}
return $result['jobs'] > 0
? " 业绩明细已自动排队重跑:{$result['jobs']} 个平台×月份任务,后台依次执行。"
: '';
}
private function dispatchResyncAll(): string
{
try {
$result = app(AccountMappingResyncDispatcher::class)->dispatchAll();
} catch (\Throwable $exception) {
report($exception);
return ' 业绩明细自动重跑任务创建失败,请稍后重试或联系管理员。';
}
return $result['jobs'] > 0
? " 业绩明细已自动排队重跑:{$result['jobs']} 个平台×月份任务,后台依次执行。"
: '';
}
/**
* 原始操作记录 + 页面/CSV 直接可用的字段(变更内容与操作人只在服务端算一次)。
*

View File

@ -26,46 +26,19 @@ class CourierQuoteChanges extends Controller
) {
}
public function index(Request $request): View
public function index(): View
{
$filters = $this->historyFilters($request);
$changes = CourierQuoteChange::query()
->with(['items', 'version'])
->when($filters['carrier'] !== '', fn ($q) => $q->where('carrier_name', 'like', '%' . addcslashes($filters['carrier'], '%_\\') . '%'))
->when($filters['status'] !== '', fn ($q) => $q->where('status', $filters['status']))
->when($filters['change_type'] !== '', fn ($q) => $q->where('change_type', $filters['change_type']))
->latest('updated_at')
->paginate(20)
->withQueryString();
->paginate(20);
return view('business.pages.courier-payables.quote-change-history', [
'changes' => $changes,
'canReview' => $this->canReview(),
'filters' => $filters,
'carrierOptions' => CourierQuoteChange::query()->whereNotNull('carrier_name')->distinct()->orderBy('carrier_name')->pluck('carrier_name')->all(),
'changeTypeOptions' => CourierQuoteChange::query()->whereNotNull('change_type')->distinct()->orderBy('change_type')->pluck('change_type')->all(),
'statusOptions' => [
CourierQuoteChange::STATUS_DRAFT => '草稿',
CourierQuoteChange::STATUS_PENDING_REVIEW => '待审核',
CourierQuoteChange::STATUS_RETURNED => '已退回',
CourierQuoteChange::STATUS_APPROVED => '已审核',
],
]);
}
/** @return array{carrier:string,status:string,change_type:string} */
private function historyFilters(Request $request): array
{
$status = (string) $request->input('status', '');
$allowed = [CourierQuoteChange::STATUS_DRAFT, CourierQuoteChange::STATUS_PENDING_REVIEW, CourierQuoteChange::STATUS_RETURNED, CourierQuoteChange::STATUS_APPROVED];
return [
'carrier' => mb_substr(trim((string) $request->input('carrier', '')), 0, 100),
'status' => in_array($status, $allowed, true) ? $status : '',
'change_type' => mb_substr(trim((string) $request->input('change_type', '')), 0, 50),
];
}
public function create(Request $request): View|RedirectResponse
{
$quotes = $this->snapshot->load()['quotes'] ?? [];
@ -192,42 +165,6 @@ class CourierQuoteChanges extends Controller
->with('success', $message);
}
public function batchApprove(Request $request): RedirectResponse
{
abort_unless($this->canReview(), 403, '当前账号没有报价审核权限。');
$input = $request->validate([
'ids' => ['required', 'array', 'min:1', 'max:100'],
'ids.*' => ['required', 'integer', 'min:1', 'distinct'],
'stock_rules_confirmed' => ['accepted'],
], ['stock_rules_confirmed.accepted' => '请先核对选中报价的原报价、计费规则及实际执行日期。']);
$results = [];
// 历史签署资料需要逐份核对,不接受批量标记已经核验。
$singleRequest = $request->duplicate();
$singleRequest->merge(['stock_signature_verified' => false]);
foreach ($input['ids'] as $id) {
$change = CourierQuoteChange::query()->where('company_id', company_id())->find($id);
if ($change === null) {
$results[] = ['number' => '所选记录', 'success' => false, 'message' => '记录已不存在或不属于当前公司。'];
continue;
}
try {
// 复用单条入口,权限、校验、版本、日志及后续签署流程保持一致。
$response = $this->approve($singleRequest, $change);
$results[] = ['number' => $change->number, 'success' => true, 'message' => $response->getSession()->get('success')];
} catch (\Illuminate\Validation\ValidationException $exception) {
$results[] = ['number' => $change->number, 'success' => false, 'message' => collect($exception->errors())->flatten()->implode(';')];
} catch (\Throwable $exception) {
report($exception);
$results[] = ['number' => $change->number, 'success' => false, 'message' => '处理失败,请查看该单状态并重试。'];
}
}
$passed = count(array_filter($results, static fn ($result) => $result['success']));
$back = array_filter($this->historyFilters($request), static fn ($v) => $v !== '');
return redirect()->route('business.courier-quote-changes.index', ['page' => max(1, (int) $request->input('page', 1))] + $back)
->with('success', '批量审核完成:成功 ' . $passed . ' 条,未通过 ' . (count($results) - $passed) . ' 条。')
->with('batch_review_results', $results);
}
/** 已入驻快递的变价版本按入驻资料(重新)推送到快递 e签宝。 */
public function pushToCourier(CourierQuoteChange $quoteChange): RedirectResponse
{

View File

@ -38,7 +38,6 @@ class CourierStockQuoteEntries extends Controller
'source_quote' => ['required', 'file', 'mimes:xlsx,xls', 'max:20480'],
'quote_type' => ['required', 'string', 'max:64'],
'effective_date' => ['nullable', 'date_format:Y-m-d'],
'end_date' => ['nullable', 'date_format:Y-m-d'],
'billing_rules' => ['nullable', 'string', 'max:4000'],
]);
$batch = $this->batches->preview($data['source_quote'], collect($data)->except('source_quote')->all(), (int) user_id());
@ -78,7 +77,6 @@ class CourierStockQuoteEntries extends Controller
$data = Validator::make($selection, [
'quote_type' => ['required', 'string', 'max:64'],
'effective_date' => ['nullable', 'date_format:Y-m-d'],
'end_date' => ['nullable', 'date_format:Y-m-d'],
'summary' => ['required', 'string', 'min:2', 'max:2000'],
'rule_groups' => ['required', 'array', 'min:1', 'max:10'],
'rule_groups.*' => ['array'],
@ -91,11 +89,6 @@ class CourierStockQuoteEntries extends Controller
'entries.*.courier_name' => ['nullable', 'string', 'max:255'],
'entries.*.rule_group' => ['required', 'integer', 'min:0', 'max:9'],
'entries.*.effective_date' => ['nullable', 'date_format:Y-m-d'],
'entries.*.end_date' => ['nullable', 'date_format:Y-m-d'],
'entries.*.periods' => ['sometimes', 'array', 'min:1', 'max:20'],
'entries.*.periods.*' => ['array'],
'entries.*.periods.*.effective_date' => ['required', 'date_format:Y-m-d'],
'entries.*.periods.*.end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:entries.*.periods.*.effective_date'],
'submit_for_review' => ['required', 'boolean'],
'confirmed' => ['accepted_if:submit_for_review,true'],
])->validate();
@ -111,19 +104,14 @@ class CourierStockQuoteEntries extends Controller
public function store(Request $request): RedirectResponse
{
$data = $request->validate([
...$this->metadataRules(true),
...$this->metadataRules(),
'courier_expcode' => ['nullable', 'string', 'max:64'],
'courier_name' => ['required_without:courier_expcode', 'nullable', 'string', 'max:255'],
'quote_type' => ['required', 'string', 'max:64'],
'sheet_name' => ['nullable', 'string', 'max:255'],
...$this->fileRules(true),
]);
$entries = $this->service->createPeriods($data, (int) user_id());
if (count($entries) > 1) {
return redirect()->route('business.courier-quote-changes.index')
->with('success', '已按 ' . count($entries) . ' 个执行区间保存报价草稿,共用原报价与附件;请分别核对并提交审核。');
}
$entry = $entries[0];
$entry = $this->service->create($data, (int) user_id());
return redirect()->route('business.courier-quote-changes.show', $entry->change)
->with('success', '存量报价草稿已保存。请核对原报价、计费规则和实际执行日期后提交审核。');
@ -178,7 +166,7 @@ class CourierStockQuoteEntries extends Controller
$headers = ['Cache-Control' => 'private, no-store', 'X-Content-Type-Options' => 'nosniff'];
if ($preview) {
$mime = $attachment['mime_type'] ?? $disk->mimeType($attachment['path']);
abort_unless(in_array($mime, ['application/pdf', 'image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'], true), 415, '此格式请下载原文件查看。');
abort_unless(in_array($mime, ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp'], true), 415, '此格式请下载原文件查看。');
return $disk->response($attachment['path'], $attachment['name'], [...$headers, 'Content-Type' => $mime]);
}
@ -186,17 +174,10 @@ class CourierStockQuoteEntries extends Controller
return $disk->download($attachment['path'], $attachment['name'], $headers);
}
private function metadataRules(bool $multiplePeriods = false): array
private function metadataRules(): array
{
return [
...($multiplePeriods ? [
'periods' => ['sometimes', 'array', 'min:1', 'max:20'],
'periods.*' => ['array'],
'periods.*.effective_date' => ['required', 'date_format:Y-m-d'],
'periods.*.end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:periods.*.effective_date'],
] : []),
'effective_date' => [$multiplePeriods ? 'required_without:periods' : 'required', 'date_format:Y-m-d'],
'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:effective_date'],
'effective_date' => ['required', 'date_format:Y-m-d'],
'billing_rules' => ['required', 'string', 'min:2', 'max:4000'],
'summary' => ['required', 'string', 'min:2', 'max:2000'],
];
@ -217,13 +198,4 @@ class CourierStockQuoteEntries extends Controller
{
abort_unless($entry->company_id === (int) company_id() && $entry->change?->isEditable() && $entry->change->created_by === user_id(), 403);
}
public function periodEnd(Request $request, CourierStockQuoteEntry $stockQuoteEntry): RedirectResponse
{
abort_unless(in_array(\App\Utilities\SystemAccess::roleName(user()), [\App\Utilities\SystemAccess::SUPER_ADMIN, \App\Utilities\SystemAccess::ADMIN], true), 403);
abort_unless($stockQuoteEntry->company_id === (int) company_id(), 404);
$data = $request->validate(['end_date' => ['required', 'date_format:Y-m-d'], 'reason' => ['required', 'string', 'min:2', 'max:1000']]);
$this->service->setPeriodEnd($stockQuoteEntry, $data['end_date'], $data['reason'], (int) user_id());
return redirect()->route('business.pages.show', ['section' => 'courier-payables', 'page' => 'quote-management'])->with('success', '执行截止日期已保存,报价原文和签署原件保持不变。');
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,84 +0,0 @@
<?php
namespace App\Http\Controllers\Business;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\Services\PlantingLossCalculator;
use Modules\EcommerceFinance\View\PlantingLossPageData;
use Modules\EcommerceFinance\View\PlantingReimbursementPageData;
/** 种菜数据汇总:种菜报销 / 种菜损耗 导出(与页面同一筛选口径)。 */
class PlantingWorkOrders extends Controller
{
public function exportReimbursements(PlantingReimbursementPageData $data)
{
$filters = $data->filters();
return $this->csv(
PlantingReimbursementPageData::TITLE . '-' . $filters['month'],
$data->exportColumns(),
static fn () => $data->rows($filters),
);
}
public function exportLoss(PlantingLossPageData $data)
{
$filters = $data->filters();
$suffix = match ($filters['result']) {
PlantingLossCalculator::RESULT_EXEMPT => '-不扣款拍A发B',
PlantingLossCalculator::RESULT_FREE_GOODS => '-不扣款虚拟条码挂钩',
PlantingLossCalculator::RESULT_UNMATCHED => '-未匹配发货',
'all' => '-全部',
default => '-扣款',
};
// 发运营的拍A发B 名单直接按「种菜报销」的字段导出。
if (request()->query('format') === 'reimbursement') {
return $this->csv(
'拍A发B发运营-' . $filters['month'],
PlantingReimbursementPageData::COLUMNS,
static fn () => $data->rows($filters),
);
}
return $this->csv(
PlantingLossPageData::TITLE . '-' . $filters['month'] . $suffix,
$data->exportColumns(),
static fn () => $data->rows($filters),
);
}
/** @param array<string,string> $columns @param callable():iterable<array<string,mixed>> $rows */
private function csv(string $name, array $columns, callable $rows)
{
$file = tmpfile();
abort_if($file === false, 500, '无法创建导出文件。');
try {
fwrite($file, "\xEF\xBB\xBF");
fputcsv($file, array_values($columns));
foreach ($rows() as $row) {
$values = [];
foreach (array_keys($columns) as $key) {
$values[] = $row[$key] ?? '';
}
fputcsv($file, array_map(static function (mixed $value): string {
$value = (string) $value;
// 长数字单号/账号保留文本,避免 Excel 截断;同时阻止公式执行。
return preg_match('/^[=+\-@\t\r\n]|^\d{12,}$/u', $value) ? "'" . $value : $value;
}, $values));
}
rewind($file);
} catch (\Throwable $exception) {
fclose($file);
report($exception);
abort(503, '聚工单镜像或发货数据未就绪,未生成不完整的导出文件,请稍后重试。');
}
return response()->streamDownload(static function () use ($file): void {
fpassthru($file);
fclose($file);
}, $name . '-' . now()->format('YmdHis') . '.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
}
}

View File

@ -1,41 +0,0 @@
<?php
namespace App\Http\Controllers\Business;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\Services\SupplierAccessoryFreightMatcher;
use Modules\EcommerceFinance\View\SupplierAccessoryFreightPageData;
class SupplierAccessoryFreight extends Controller
{
public function export(SupplierAccessoryFreightPageData $data)
{
$filters = $data->filters();
$columns = $data->exportColumns();
$file = tmpfile();
abort_if($file === false, 500, '无法创建导出文件。');
try {
fwrite($file, "\xEF\xBB\xBF");
fputcsv($file, ['业务月份', '发货记录ID', '仓库', ...array_values($columns)]);
foreach ($data->rows($filters) as $row) {
if ($filters['state'] !== 'all' && $filters['state'] !== $row['state']) continue;
$cells = array_merge($row, ['courier' => ($row['express_name1'] ?? '') ?: ($row['express_name2'] ?? ''),
'freight' => $row['freight_cents'] === null ? '待核实' : number_format($row['freight_cents'] / 100, 2, '.', ''),
'state' => SupplierAccessoryFreightMatcher::STATES[$row['state']], 'deduction' => number_format($row['deduction_cents'] / 100, 2, '.', '')]);
$values = [$filters['month'], $row['source_id'], \Modules\EcommerceFinance\Services\AccessoryReshipMatcher::WAREHOUSE];
foreach ($columns as $key => $label) $values[] = $cells[$key];
fputcsv($file, array_map(static function ($value) {
$value = (string) $value;
// 纯数字标识保留文本,防止 Excel 截断长单号;同时阻止公式执行。
return preg_match('/^[=+\-@\t\r\n]|^\d{12,}$/u', $value) ? "'" . $value : $value;
}, $values));
}
rewind($file);
} catch (\Throwable $exception) {
fclose($file);
report($exception);
abort(503, '资料读取失败,未生成不完整的扣款明细,请稍后重试。');
}
return response()->streamDownload(function () use ($file) { fpassthru($file); fclose($file); }, SupplierAccessoryFreightPageData::TITLE . '-' . $filters['month'] . '.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
}
}

View File

@ -1,44 +0,0 @@
<?php
namespace App\Http\Controllers\Business;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\View\SupplierBookkeepingPageData;
class SupplierBookkeeping extends Controller
{
public function export(SupplierBookkeepingPageData $data)
{
$filters = $data->filters();
$columns = $data->exportColumns();
$file = tmpfile();
abort_if($file === false, 500, '无法创建导出文件。');
try {
fwrite($file, "\xEF\xBB\xBF");
fputcsv($file, array_values($columns));
foreach ($data->rows($filters) as $row) {
$values = [];
foreach (array_keys($columns) as $key) {
$values[] = $row[$key] ?? '';
}
fputcsv($file, array_map(static function (mixed $value): string {
$value = (string) $value;
// 长数字单号保留文本,避免 Excel 截断;同时阻止公式执行。
return preg_match('/^[=+\-@\t\r\n]|^\d{12,}$/u', $value) ? "'" . $value : $value;
}, $values));
}
rewind($file);
} catch (\Throwable $exception) {
fclose($file);
report($exception);
abort(503, '聚工单镜像未就绪或读取失败,未生成不完整的厂家记账,请稍后重试。');
}
return response()->streamDownload(static function () use ($file): void {
fpassthru($file);
fclose($file);
}, SupplierBookkeepingPageData::TITLE . '-' . now()->format('YmdHis') . '.csv', ['Content-Type' => 'text/csv; charset=UTF-8']);
}
}

View File

@ -1,100 +0,0 @@
<?php
namespace App\Http\Controllers\Business;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Validation\Rule as ValidationRule;
use Modules\EcommerceFinance\Services\SupplierBookkeepingConfigImporter;
use Modules\EcommerceFinance\Services\SupplierBookkeepingConfigStore;
use Modules\EcommerceFinance\Services\SupplierBookkeepingDeductionRule as Rule;
use Modules\EcommerceFinance\View\SupplierBookkeepingConfigPageData;
use PhpOffice\PhpSpreadsheet\Cell\DataType;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
class SupplierBookkeepingConfigs extends Controller
{
public function __construct(private readonly SupplierBookkeepingConfigStore $store) {}
public function store(Request $request)
{
$this->store->save($this->validated($request), $this->actor());
return $this->back('厂家记账配置已新增,厂家记账明细按新配置重新判定。');
}
public function update(Request $request, int $configId)
{
$this->store->save($this->validated($request), $this->actor(), $configId);
return $this->back('厂家记账配置已更新,厂家记账明细按新配置重新判定。');
}
public function destroy(int $configId)
{
$this->store->changeDeleted($configId, true, $this->actor());
return $this->back('配置已删除,该供应商视同不在配置表,按正常记账扣款。');
}
public function restore(int $configId)
{
$this->store->changeDeleted($configId, false, $this->actor());
return $this->back('厂家记账配置已恢复。');
}
public function import(Request $request, SupplierBookkeepingConfigImporter $importer)
{
$request->validate(['file' => ['required', 'file', 'mimes:xlsx', 'max:10240']]);
$path = $request->file('file')->getRealPath();
$result = $this->store->import($importer->read($path), $this->actor(), hash_file('sha256', $path));
return $this->back("导入完成:新增{$result['added']}条,更新{$result['updated']}条,未变化{$result['skipped']}条。");
}
public function export(SupplierBookkeepingConfigPageData $data)
{
$rows = $this->store->filtered($data->filters())->get();
$book = new Spreadsheet();
$sheet = $book->getActiveSheet();
$sheet->setTitle('厂家记账配置');
$sheet->fromArray([['供应商', '记账方式', '生效开始日期', '生效结束日期', '原始说明', '创建人', '更新时间', '删除状态']]);
foreach ($rows as $index => $row) {
$values = [$row->supplier_name, Rule::MODES[$row->mode] ?? $row->mode, $row->effective_from, $row->effective_to, $row->source_text, $row->creator, $row->updated_at, $row->deleted_at ? '已删除' : '正常'];
foreach ($values as $column => $value) {
$sheet->setCellValueExplicit([$column + 1, $index + 2], (string) $value, DataType::TYPE_STRING);
}
}
foreach (['A' => 24, 'B' => 20, 'C' => 14, 'D' => 14, 'E' => 44, 'F' => 16, 'G' => 20, 'H' => 10] as $column => $width) {
$sheet->getColumnDimension($column)->setWidth($width);
}
$sheet->freezePane('A2');
return response()->streamDownload(function () use ($book): void {
try { (new Xlsx($book))->save('php://output'); } finally { $book->disconnectWorksheets(); }
}, '厂家记账配置.xlsx', ['Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']);
}
private function validated(Request $request): array
{
return $request->validate([
'supplier_name' => ['required', 'string', 'max:191'],
'mode' => ['required', ValidationRule::in(array_keys(Rule::MODES))],
'effective_from' => ['nullable', 'date_format:Y-m-d'],
'effective_to' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:effective_from'],
'source_text' => ['nullable', 'string', 'max:255'],
], [], ['supplier_name' => '供应商', 'mode' => '记账方式', 'effective_from' => '生效开始日期', 'effective_to' => '生效结束日期', 'source_text' => '备注']);
}
private function actor(): string
{
return mb_substr((string) user()->name, 0, 191);
}
private function back(string $message)
{
return redirect()->route('business.pages.show', ['section' => 'supplier-payables', 'page' => 'manufacturer-bookkeeping', 'bk_view' => 'config'])->with('bookkeeping_config_message', $message);
}
}

View File

@ -2,41 +2,26 @@
namespace App\Http\Controllers\Business;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Validation\ValidationException;
use Modules\EcommerceFinance\Repositories\SupplierManagementMirrorRepository;
use Modules\EcommerceFinance\Services\SupplierFreeShippingStore;
use Modules\EcommerceFinance\Services\SupplierFreeShippingWorkbook;
use Modules\EcommerceFinance\View\SupplierManagementPageData;
use Symfony\Component\HttpFoundation\StreamedResponse;
class SupplierManagement extends Controller
{
public function export(
Request $request,
SupplierManagementMirrorRepository $repository,
SupplierManagementPageData $page,
SupplierFreeShippingStore $freeShipping,
): StreamedResponse {
public function export(Request $request, SupplierManagementMirrorRepository $repository, SupplierManagementPageData $page)
{
abort_unless($repository->ready(), 503, '供应商资料尚未完成首次同步,暂时不能导出。');
$filters = $page->filters($request->query());
$columns = SupplierManagementPageData::columns();
$companyId = (int) (company_id() ?: 1);
return response()->streamDownload(static function () use ($repository, $filters, $columns, $freeShipping, $companyId): void {
return response()->streamDownload(static function () use ($repository, $filters): void {
$csv = fopen('php://output', 'w');
fputs($csv, "\xEF\xBB\xBF");
fputcsv($csv, array_values($columns));
fputcsv($csv, array_values(SupplierManagementMirrorRepository::COLUMNS));
foreach ($repository->exportRows($filters) as $row) {
$row = $freeShipping->decorateRow($row, $companyId);
$values = [];
foreach (array_keys($columns) as $column) {
$value = $column === SupplierManagementPageData::FREE_SHIPPING_COLUMN
? ((bool) ($row->{$column} ?? false) ? '包邮' : '')
: (string) ($row->{$column} ?? '');
foreach (array_keys(SupplierManagementMirrorRepository::COLUMNS) as $column) {
$value = (string) ($row->{$column} ?? '');
if ($column === 'coopersgn') $value = ['1' => '合作', '2' => '不合作'][$value] ?? '未维护';
// Excel 不能执行供应商提供的公式文本,且长编码保留为文本。
if (preg_match('/^[=+\-@\t\r\n]/', ltrim($value)) || preg_match('/^\d{12,}$/', $value)) $value = "'" . $value;
@ -48,114 +33,4 @@ class SupplierManagement extends Controller
}, 'supplier_management_' . now()->format('Ymd_His') . '.csv',
['Content-Type' => 'text/csv; charset=UTF-8', 'Cache-Control' => 'private, no-store']);
}
public function template(SupplierFreeShippingWorkbook $workbook): StreamedResponse
{
return $workbook->template();
}
public function import(
Request $request,
SupplierManagementMirrorRepository $repository,
SupplierFreeShippingStore $freeShipping,
SupplierFreeShippingWorkbook $workbook,
): RedirectResponse {
$request->validate(['import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:10240']]);
abort_unless($repository->ready(), 503, '供应商资料尚未完成首次同步,暂时不能导入。');
$valuesByCode = $workbook->read($request->file('import'));
$suppliers = $repository->suppliersByCodes(array_keys($valuesByCode));
$matched = [];
foreach ($suppliers as $supplier) {
$code = mb_strtolower(trim((string) ($supplier->scmcode ?? '')));
if ($code !== '') {
$matched[$code][] = $supplier;
}
}
$missing = [];
$ambiguous = [];
$valuesBySupplierId = [];
foreach ($valuesByCode as $code => $isFreeShipping) {
$key = mb_strtolower(trim((string) $code));
if (! isset($matched[$key])) {
$missing[] = (string) $code;
continue;
}
if (count($matched[$key]) !== 1) {
$ambiguous[] = (string) $code;
continue;
}
$valuesBySupplierId[(int) $matched[$key][0]->Id] = $isFreeShipping;
}
if ($ambiguous !== []) {
throw ValidationException::withMessages([
'import' => '以下供应商代码在供应商主档中存在多条记录,未导入:' . implode('、', array_slice($ambiguous, 0, 8)),
]);
}
if ($missing !== []) {
throw ValidationException::withMessages([
'import' => '以下供应商代码未找到,未导入:' . implode('、', array_slice($missing, 0, 8))
. (count($missing) > 8 ? ' 等 ' . count($missing) . ' 条' : ''),
]);
}
$result = $freeShipping->saveMany(
$valuesBySupplierId,
(int) (company_id() ?: 1),
user_id() ? (int) user_id() : null,
'import',
);
return $this->redirectToPage($request)->with(
'supplier_management_message',
"导入完成:新增 {$result['created']} 家供应商维护记录,更新 {$result['updated']} 家供应商维护记录。",
);
}
public function updateFreeShipping(
Request $request,
int $supplier,
SupplierManagementMirrorRepository $repository,
SupplierFreeShippingStore $freeShipping,
): JsonResponse|RedirectResponse {
$request->validate(['value' => ['nullable', 'string', 'in:,包邮']]);
abort_unless($repository->ready(), 503, '供应商资料尚未完成首次同步,暂时不能编辑。');
abort_if($repository->supplierById($supplier) === null, 404, '供应商不存在。');
$isFreeShipping = $request->input('value') === '包邮';
$freeShipping->save(
$supplier,
$isFreeShipping,
(int) (company_id() ?: 1),
user_id() ? (int) user_id() : null,
'manual',
);
if ($request->expectsJson()) {
return response()->json([
'ok' => true,
'value' => $isFreeShipping ? '包邮' : '',
'display' => $isFreeShipping ? '包邮' : '-',
]);
}
return $this->redirectToPage($request)->with('supplier_management_message', '是否包邮厂家已保存。');
}
private function redirectToPage(Request $request): RedirectResponse
{
$query = array_filter([
...app(SupplierManagementPageData::class)->filters($request->query()),
'page' => max((int) $request->query('page', 1), 1),
], static fn (mixed $value): bool => $value !== '');
$url = route('business.pages.show', [
'company_id' => company_id() ?: 1,
'section' => 'supplier-payables',
'page' => 'supplier-management',
]);
return redirect()->to($url . ($query === [] ? '' : '?' . http_build_query($query)));
}
}

View File

@ -1,108 +0,0 @@
<?php
namespace App\Http\Controllers\Business;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\Services\SupplierNegativeAlertDingTalkWorkflow;
use Modules\EcommerceFinance\View\SupplierNegativeAlertPageData;
use Throwable;
class SupplierNegativeAlertDingTalkActions extends Controller
{
public function __construct(
private readonly SupplierNegativeAlertPageData $pageData,
private readonly SupplierNegativeAlertDingTalkWorkflow $workflow,
) {
$this->middleware('auth');
$this->middleware('company.identify');
}
public function saveRobot(Request $request): RedirectResponse
{
$data = $request->validate([
'dingtalk_webhook' => ['nullable', 'string', 'max:2048'],
'dingtalk_secret' => ['nullable', 'string', 'max:256'],
'dingtalk_group_name' => ['required', 'string', 'max:191'],
]);
$this->workflow->saveConfiguration(
(string) ($data['dingtalk_webhook'] ?? ''),
(string) ($data['dingtalk_secret'] ?? ''),
(string) $data['dingtalk_group_name'],
);
return back()->with('success', '供应商负数预警群机器人已保存。');
}
public function send(Request $request): RedirectResponse
{
$data = $request->validate([
'purchaser' => ['nullable', 'string', 'max:191'],
'month' => ['nullable', 'string', 'regex:/^\d{4}-\d{2}$/'],
'supplier' => ['nullable', 'string', 'max:191'],
'checker' => ['nullable', 'string', 'max:191'],
'settlement_method' => ['nullable', 'string', 'max:191'],
]);
$purchaser = trim((string) ($data['purchaser'] ?? ''));
$filters = [
'month' => (string) ($data['month'] ?? ''),
'supplier' => (string) ($data['supplier'] ?? ''),
'checker' => (string) ($data['checker'] ?? ''),
'settlement_method' => (string) ($data['settlement_method'] ?? ''),
'purchaser' => $purchaser,
];
$filteredRows = $this->pageData->rowsForFilters([
...$filters,
'purchaser' => '',
]);
$rows = $purchaser === ''
? $filteredRows
: array_values(array_filter(
$filteredRows,
static fn (array $row): bool => trim((string) ($row['purchaser'] ?? '')) === $purchaser,
));
if ($rows === []) {
return back()->withErrors([
'dingtalk_send' => '当前筛选没有可发送给该采购的供应商。',
]);
}
try {
if ($purchaser === '') {
$result = $this->workflow->sendGrouped(
$rows,
$filters,
(int) (company_id() ?: 1),
user_id() ? (int) user_id() : null,
);
} else {
$result = $this->workflow->send(
$rows,
$purchaser,
$filters,
(int) (company_id() ?: 1),
user_id() ? (int) user_id() : null,
);
}
} catch (Throwable $exception) {
report($exception);
return back()->withErrors(['dingtalk_send' => '发送失败:' . $exception->getMessage()]);
}
if ($purchaser === '') {
$message = "已一键发送至{$result['group_name']}:{$result['purchaser_count']} 位采购,"
. "{$result['supplier_count']} 家供应商、{$result['row_count']} 条预警。";
if ($result['unassigned_supplier_count'] > 0) {
$message .= " 另有 {$result['unassigned_supplier_count']} 家供应商未分配采购,未发送。";
}
return back()->with('success', $message);
}
return back()->with('success', "已发送至{$result['group_name']}:采购 {$purchaser},"
. "{$result['supplier_count']} 家供应商、{$result['row_count']} 条预警。");
}
}

View File

@ -1,139 +0,0 @@
<?php
namespace App\Http\Controllers\Business;
use App\Models\Common\Company;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Routing\Controller;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
use Illuminate\View\View;
use Modules\EcommerceFinance\Services\SupplierNegativeAlertDingTalkWorkflow;
use Symfony\Component\HttpFoundation\StreamedResponse;
class SupplierNegativeAlertDingTalkForms extends Controller
{
public function __construct(
private readonly SupplierNegativeAlertDingTalkWorkflow $workflow,
) {
}
public function show(string $token): View|Response
{
$assignment = $this->resolve($token);
if ($assignment === null) {
return $this->unavailable();
}
$snapshot = json_decode((string) $assignment->snapshot, true);
$snapshot = is_array($snapshot) ? array_values($snapshot) : [];
$latestPeriod = $this->workflow->latestPeriodSummary($snapshot);
return view('business.pages.negative-alerts.dingtalk-task', [
'assignment' => $assignment,
'suppliers' => $snapshot,
'latestPeriod' => $latestPeriod,
'processingDeadline' => $this->workflow->processingDeadline((string) $latestPeriod['month']),
'supplierResults' => $this->workflow->resultsForAssignment((int) $assignment->id),
'supplierAttachments' => $this->workflow->attachmentsForAssignment((int) $assignment->id),
'token' => $token,
]);
}
public function update(Request $request, string $token): RedirectResponse|Response
{
$assignment = $this->resolve($token);
if ($assignment === null) {
return $this->unavailable();
}
$snapshot = json_decode((string) $assignment->snapshot, true);
$supplierCount = is_array($snapshot) ? count($snapshot) : 0;
$data = $request->validate([
'results' => ['required', 'array', 'size:' . $supplierCount],
'results.*' => ['nullable', 'string', 'max:2000'],
'screenshots' => ['nullable', 'array', 'size:' . $supplierCount],
'screenshots.*' => ['nullable', 'array', 'max:5'],
'screenshots.*.*' => ['file', 'mimes:jpg,jpeg,png,webp', 'max:10240'],
], [
'results.required' => '请为每个供应商填写处理结果。',
'results.size' => '请为每个供应商填写处理结果。',
'results.*.max' => '单个供应商的处理结果不能超过 2000 个字符。',
'screenshots.*.*.mimes' => '处理截图仅支持 JPG、PNG、WEBP 图片。',
'screenshots.*.*.max' => '单张处理截图不能超过 10MB。',
]);
if (! collect((array) $data['results'])->contains(
static fn (mixed $value): bool => trim((string) $value) !== '',
)) {
throw ValidationException::withMessages([
'results' => '请至少填写一个供应商的处理结果。',
]);
}
$uploads = [];
foreach ((array) $request->file('screenshots', []) as $index => $files) {
$uploads[(int) $index] = array_values(array_filter(
(array) $files,
static fn (mixed $file): bool => $file !== null,
));
}
$existingAttachments = $this->workflow->attachmentsForAssignment((int) $assignment->id);
foreach (is_array($snapshot) ? $snapshot : [] as $index => $supplier) {
$supplierName = trim((string) ($supplier['supplier'] ?? ''));
$existingCount = count($existingAttachments[$supplierName] ?? []);
if ($existingCount + count($uploads[$index] ?? []) > 5) {
throw ValidationException::withMessages([
'screenshots' => $supplierName . ' 最多上传 5 张处理截图。',
]);
}
}
try {
$this->workflow->submit($token, array_values((array) $data['results']));
$this->workflow->storeAttachments($assignment, is_array($snapshot) ? $snapshot : [], $uploads);
} catch (ValidationException $exception) {
throw $exception;
}
return redirect()
->route('supplier-negative-alert.task', ['token' => $token])
->with('negative_alert_success', '已保存本次填写的供应商处理结果,未填写的供应商会继续留在待处理任务中。');
}
public function attachment(string $token, int $attachment): StreamedResponse|Response
{
$assignment = $this->resolve($token);
if ($assignment === null) {
return $this->unavailable();
}
$file = $this->workflow->attachmentForAssignment((int) $assignment->id, $attachment);
abort_if($file === null, 404);
$disk = Storage::disk('uploads');
abort_unless($disk->exists((string) $file->path), 404, '处理截图不存在。');
return $disk->response((string) $file->path, (string) $file->original_name, [
'Cache-Control' => 'private, no-store',
'Content-Type' => (string) $file->mime_type,
'X-Content-Type-Options' => 'nosniff',
]);
}
private function resolve(string $token): ?object
{
$assignment = $this->workflow->findByToken($token);
if ($assignment === null) {
return null;
}
Company::find($assignment->company_id)?->makeCurrent();
return $assignment;
}
private function unavailable(): Response
{
return response()->view('business.pages.negative-alerts.dingtalk-task-unavailable', [], 404);
}
}

View File

@ -1,18 +0,0 @@
<?php
namespace App\Http\Controllers\Business;
use Illuminate\Http\RedirectResponse;
use Illuminate\Routing\Controller;
class SupplierPortal extends Controller
{
public function home(): RedirectResponse
{
return redirect()->route('business.pages.show', [
'company_id' => company_id(),
'section' => 'supplier-reconciliation',
'page' => 'supplier-data',
]);
}
}

View File

@ -1,264 +0,0 @@
<?php
namespace App\Http\Controllers\Business;
use App\Services\Auth\SupplierPortalAccess;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Modules\EcommerceFinance\Services\SupplierComparisonCsvExporter;
use Modules\EcommerceFinance\Services\SupplierDingTalkNotifier;
use Modules\EcommerceFinance\Services\SupplierReturnCsvExporter;
use Modules\EcommerceFinance\Services\SupplierReturnConfirmation;
use Modules\EcommerceFinance\Services\SupplierStatementMatcher;
use Modules\EcommerceFinance\Services\SupplierStatementStore;
use Modules\EcommerceFinance\Services\SupplierStatementTemplate;
use Modules\EcommerceFinance\View\SupplierReconciliationPageData;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
use Throwable;
/**
* 供应商对账:对账单模板下载 + 厂家对账单上传(严格按模板校验)。
*/
class SupplierReconciliation extends Controller
{
public function __construct(
private readonly SupplierStatementTemplate $template,
private readonly SupplierStatementStore $statements,
private readonly SupplierReturnConfirmation $returnConfirmations,
private readonly SupplierReconciliationPageData $pageData,
private readonly SupplierReturnCsvExporter $returnCsvExporter,
private readonly SupplierDingTalkNotifier $dingTalkNotifier,
private readonly SupplierComparisonCsvExporter $comparisonCsvExporter,
private readonly SupplierPortalAccess $supplierPortalAccess,
) {
$this->middleware('auth');
$this->middleware('company.identify');
}
public function template(): BinaryFileResponse
{
$path = $this->template->path();
abort_unless(is_file($path), 404, '对账单模板不存在。');
return response()->download($path, $this->template->filename());
}
public function upload(Request $request): JsonResponse|RedirectResponse
{
$request->validate([
'import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:51200'],
]);
$supplier = $this->effectiveSupplier($request);
$records = $this->template->parse($request->file('import'), $supplier);
$rows = $this->statements->save(
$supplier,
$records,
(string) $request->file('import')->getClientOriginalName(),
(int) (company_id() ?: 1),
user_id() ? (int) user_id() : null,
);
$message = "对账单已保存:{$supplier} 共 {$rows} 行。";
return $request->expectsJson()
? response()->json(['message' => $message, 'rows' => $rows])
: back()->with('success', $message);
}
public function confirmReturn(Request $request): RedirectResponse
{
return $this->markReturn($request, SupplierReturnConfirmation::STATUS_CONFIRMED, '已确认该行退货扣减。');
}
public function disputeReturn(Request $request): RedirectResponse
{
return $this->markReturn($request, SupplierReturnConfirmation::STATUS_DISPUTED, '已提交异议,复核通过前该行暂不扣减。');
}
public function confirmAllReturns(Request $request): RedirectResponse
{
[$supplier, $companyId] = $this->returnTarget($request);
$keys = array_values(array_map(
static fn (array $row): string => (string) ($row['_action_key'] ?? ''),
$this->pageData->returnRowsForFilters($supplier, $companyId, $request->all()),
));
$confirmed = $this->returnConfirmations->confirmAll(
$supplier,
$keys,
$companyId,
user_id() ? (int) user_id() : null,
);
return back()->with('success', "已全部确认:{$confirmed} 行退货扣减生效(异议行保持暂不扣减)。");
}
public function exportReturns(Request $request): StreamedResponse
{
[$supplier, $companyId] = $this->returnTarget($request);
$rows = $this->pageData->returnRowsForFilters($supplier, $companyId, $request->query());
return $this->returnCsvExporter->download($rows, $supplier);
}
public function exportComparison(Request $request): StreamedResponse
{
$request->validate([
'month' => ['nullable', 'string', 'regex:/^\d{4}-\d{2}$/'],
'reconciler' => ['nullable', 'string', 'max:191'],
]);
[$supplier, $companyId] = $this->returnTarget($request);
$detail = $this->pageData->financeDetailForFilters($supplier, $companyId, $request->query());
return $this->comparisonCsvExporter->download(
$detail,
$supplier,
trim((string) $request->query('month', '')),
trim((string) $request->query('reconciler', '')),
);
}
public function saveDingTalkRobot(Request $request): RedirectResponse
{
$request->validate([
'supplier' => ['required', 'string', 'max:191'],
'dingtalk_webhook' => ['nullable', 'string', 'max:2048'],
'dingtalk_secret' => ['nullable', 'string', 'max:256'],
'dingtalk_at_all' => ['nullable', 'boolean'],
]);
[$supplier] = $this->returnTarget($request);
$this->dingTalkNotifier->save(
$supplier,
(string) $request->input('dingtalk_webhook', ''),
(string) $request->input('dingtalk_secret', ''),
$request->boolean('dingtalk_at_all'),
);
return back()->with('success', "{$supplier} 的钉钉群机器人已保存。");
}
public function pushDifferences(Request $request): RedirectResponse
{
$request->validate([
'supplier' => ['required', 'string', 'max:191'],
'month' => ['nullable', 'string', 'regex:/^\d{4}-\d{2}$/'],
'reconciler' => ['nullable', 'string', 'max:191'],
'scope' => ['required', 'in:all,row,selected'],
'purchase_no' => ['nullable', 'string', 'max:191'],
'date' => ['nullable', 'string', 'max:32'],
'barcode' => ['nullable', 'string', 'max:191'],
'difference_type' => ['nullable', 'string', 'max:32'],
'selected_rows' => ['nullable', 'array', 'max:600'],
'selected_rows.*' => ['string', 'max:2048'],
]);
[$supplier, $companyId] = $this->returnTarget($request);
$detail = $this->pageData->financeDetailForFilters($supplier, $companyId, $request->all());
$differences = array_values(array_filter(
$detail['rows'],
static fn (array $row): bool => ($row['type'] ?? '') !== SupplierStatementMatcher::TYPE_MATCH,
));
if ($request->input('scope') === 'row') {
$purchaseNo = trim((string) $request->input('purchase_no'));
$date = trim((string) $request->input('date'));
$barcode = trim((string) $request->input('barcode'));
$type = trim((string) $request->input('difference_type'));
$differences = array_values(array_filter(
$differences,
static fn (array $row): bool => (string) ($row['purchaseNo'] ?? '') === $purchaseNo
&& (string) ($row['date'] ?? '') === $date
&& (string) ($row['barcode'] ?? '') === $barcode
&& (string) ($row['type'] ?? '') === $type
));
} elseif ($request->input('scope') === 'selected') {
$selected = [];
foreach ((array) $request->input('selected_rows', []) as $token) {
$row = json_decode((string) $token, true);
if (is_array($row)) {
$selected[$this->differenceRowKey($row)] = true;
}
}
$differences = array_values(array_filter(
$differences,
fn (array $row): bool => isset($selected[$this->differenceRowKey($row)]),
));
}
if ($differences === []) {
return back()->withErrors(['dingtalk_push' => '当前筛选没有可推送的差异。']);
}
try {
$result = $this->dingTalkNotifier->push($supplier, $differences, [
'month' => (string) $request->input('month', ''),
'reconciler' => (string) $request->input('reconciler', ''),
]);
} catch (Throwable $exception) {
return back()->withErrors(['dingtalk_push' => '推送失败:' . $exception->getMessage()]);
}
return back()->with(
'success',
"已推送 {$result['rows']} 条差异到 {$supplier} 钉钉群({$result['messages']} 条消息)。",
);
}
private function markReturn(Request $request, string $status, string $message): RedirectResponse
{
$request->validate(['key' => ['required', 'string', 'max:191']]);
[$supplier, $companyId] = $this->returnTarget($request);
$key = (string) $request->input('key');
$owned = collect($this->pageData->returnRows($supplier, $companyId))
->contains(static fn (array $row): bool => (string) ($row['_action_key'] ?? '') === $key);
abort_unless($owned, 403, '该退货明细不属于当前厂家账号。');
$this->returnConfirmations->mark(
$supplier,
$key,
$status,
$companyId,
user_id() ? (int) user_id() : null,
);
return back()->with('success', $message);
}
/** @return array{0:string,1:int} */
private function returnTarget(Request $request): array
{
return [$this->effectiveSupplier($request), (int) (company_id() ?: 1)];
}
private function effectiveSupplier(Request $request): string
{
$user = $request->user();
if ($user && SupplierPortalAccess::isSupplierUser($user)) {
$supplier = $this->supplierPortalAccess->supplierFor($user);
abort_if(! $supplier, 403, '当前钉钉账号未绑定有效供应商,请联系财务人员。');
return $supplier;
}
$request->validate(['supplier' => ['required', 'string', 'max:191']]);
$supplier = trim((string) $request->input('supplier'));
abort_if($supplier === '', 422, '请选择厂家。');
return $supplier;
}
/** @param array<string,mixed> $row */
private function differenceRowKey(array $row): string
{
return implode("\0", [
trim((string) ($row['purchaseNo'] ?? $row['purchase_no'] ?? '')),
trim((string) ($row['date'] ?? '')),
trim((string) ($row['barcode'] ?? '')),
trim((string) ($row['type'] ?? '')),
]);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -34,11 +34,6 @@ class WithdrawalManagement extends Controller
return $this->spreadsheet->template();
}
public function withdrawalTemplate(Request $request): StreamedResponse
{
return $this->spreadsheet->withdrawalTemplate($this->report->results($request));
}
public function importPreview(Request $request): RedirectResponse
{
$file = $request->validate([
@ -98,7 +93,6 @@ class WithdrawalManagement extends Controller
'storeEntity' => 'store_entity',
'keyword' => 'keyword',
'balanceStatus' => 'balance_status',
'balanceDate' => 'balance_date',
];
foreach ($queryNames as $filterName => $queryName) {

View File

@ -2,7 +2,6 @@
namespace App\Http\Controllers\Portal;
use App\Models\Common\Contact;
use App\Models\Document\Document;
use App\Traits\Charts;
use App\Traits\DateTime;
@ -21,10 +20,6 @@ class Dashboard
{
$contact = user()->contact;
if ($contact instanceof Contact && $contact->type === Contact::VENDOR_TYPE) {
return redirect()->route('portal.supplier-reconciliation.index', ['company_id' => company_id()]);
}
// Redirect user redirect landing page..
if (!$contact) {
return redirect(user()->getLandingPageOfUser());

View File

@ -1,154 +0,0 @@
<?php
namespace App\Http\Controllers\Portal;
use App\Models\Common\Contact;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\View\View;
use Modules\EcommerceFinance\Services\SupplierReturnCsvExporter;
use Modules\EcommerceFinance\Services\SupplierReturnConfirmation;
use Modules\EcommerceFinance\Services\SupplierStatementStore;
use Modules\EcommerceFinance\Services\SupplierStatementTemplate;
use Modules\EcommerceFinance\View\SupplierReconciliationPageData;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\StreamedResponse;
/**
* 厂家门户:账号绑定供应商后,仅可查看和操作自己的对账数据。
*/
class SupplierReconciliation extends Controller
{
public function __construct(
private readonly SupplierStatementTemplate $template,
private readonly SupplierStatementStore $statements,
private readonly SupplierReturnConfirmation $returnConfirmations,
private readonly SupplierReconciliationPageData $pageData,
private readonly SupplierReturnCsvExporter $returnCsvExporter,
) {
}
public function index(): View
{
[$contact, $supplier, $companyId] = $this->supplierContext();
$data = $this->pageData->portalData($supplier, $companyId);
return view('portal.supplier-reconciliation.index', array_merge($data, [
'contact' => $contact,
'supplier' => $supplier,
'statementTemplateUrl' => route('portal.supplier-reconciliation.template'),
'uploadUrl' => route('portal.supplier-reconciliation.statement.upload'),
'returnConfirmUrl' => route('portal.supplier-reconciliation.return.confirm'),
'returnDisputeUrl' => route('portal.supplier-reconciliation.return.dispute'),
'returnConfirmAllUrl' => route('portal.supplier-reconciliation.return.confirm-all'),
]));
}
public function template(): BinaryFileResponse
{
$this->supplierContext();
$path = $this->template->path();
abort_unless(is_file($path), 404, '对账单模板不存在。');
return response()->download($path, $this->template->filename());
}
public function upload(Request $request): JsonResponse|RedirectResponse
{
$request->validate([
'import' => ['required', 'file', 'mimes:xlsx,xls,csv', 'max:51200'],
]);
[, $supplier, $companyId] = $this->supplierContext();
$records = $this->template->parse($request->file('import'), $supplier);
$rows = $this->statements->save(
$supplier,
$records,
(string) $request->file('import')->getClientOriginalName(),
$companyId,
user_id() ? (int) user_id() : null,
);
$message = "对账单已保存:{$supplier} 共 {$rows} 行。";
return $request->expectsJson()
? response()->json(['message' => $message, 'rows' => $rows])
: redirect()
->route('portal.supplier-reconciliation.index', ['company_id' => $companyId])
->with('success', $message);
}
public function confirmReturn(Request $request): RedirectResponse
{
return $this->markReturn($request, SupplierReturnConfirmation::STATUS_CONFIRMED, '已确认该行退货扣减。');
}
public function disputeReturn(Request $request): RedirectResponse
{
return $this->markReturn($request, SupplierReturnConfirmation::STATUS_DISPUTED, '已提交异议,复核通过前该行暂不扣减。');
}
public function confirmAllReturns(Request $request): RedirectResponse
{
[, $supplier, $companyId] = $this->supplierContext();
$keys = array_values(array_map(
static fn (array $row): string => (string) ($row['_action_key'] ?? ''),
$this->pageData->returnRowsForFilters($supplier, $companyId, $request->all()),
));
$confirmed = $this->returnConfirmations->confirmAll(
$supplier,
$keys,
$companyId,
user_id() ? (int) user_id() : null,
);
return back()->with('success', "已全部确认:{$confirmed} 行退货扣减生效(异议行保持暂不扣减)。");
}
public function exportReturns(Request $request): StreamedResponse
{
[, $supplier, $companyId] = $this->supplierContext();
$rows = $this->pageData->returnRowsForFilters($supplier, $companyId, $request->query());
return $this->returnCsvExporter->download($rows, $supplier);
}
private function markReturn(Request $request, string $status, string $message): RedirectResponse
{
$request->validate(['key' => ['required', 'string', 'max:191']]);
[, $supplier, $companyId] = $this->supplierContext();
$key = (string) $request->input('key');
$owned = collect($this->pageData->returnRows($supplier, $companyId))
->contains(static fn (array $row): bool => (string) ($row['_action_key'] ?? '') === $key);
abort_unless($owned, 403, '该退货明细不属于当前厂家账号。');
$this->returnConfirmations->mark(
$supplier,
$key,
$status,
$companyId,
user_id() ? (int) user_id() : null,
);
return back()->with('success', $message);
}
/** @return array{0:Contact,1:string,2:int} */
private function supplierContext(): array
{
$contact = user()->contact;
abort_unless(
$contact instanceof Contact
&& $contact->type === Contact::VENDOR_TYPE
&& $contact->enabled,
403,
'当前账号未绑定有效的厂家资料,请联系财务人员。',
);
$supplier = trim((string) $contact->name);
abort_if($supplier === '', 403, '当前厂家账号未设置供应商名称,请联系财务人员。');
return [$contact, $supplier, (int) (company_id() ?: 1)];
}
}

View File

@ -160,7 +160,6 @@ class Kernel extends HttpKernel
'auth.basic' => \Illuminate\Auth\Middleware\AuthenticateWithBasicAuth::class,
'bindings' => \Illuminate\Routing\Middleware\SubstituteBindings::class,
'business.page' => \App\Http\Middleware\EnsureBusinessPageAccess::class,
'business.page.viewer' => \App\Http\Middleware\EnsureBusinessPageViewerAccess::class,
'cache.headers' => \Illuminate\Http\Middleware\SetCacheHeaders::class,
'can' => \Illuminate\Auth\Middleware\Authorize::class,
'cookies.encrypt' => \App\Http\Middleware\EncryptCookies::class,

View File

@ -1,29 +0,0 @@
<?php
namespace App\Http\Middleware;
use App\Utilities\SidebarPermissions;
use Closure;
use Illuminate\Http\Request;
class EnsureBusinessPageViewerAccess
{
public function handle(Request $request, Closure $next): mixed
{
$user = $request->user();
$section = (string) $request->route('section');
$page = (string) $request->route('page');
if ($page === '') {
return $next($request);
}
abort_unless(
$user && SidebarPermissions::canViewPage($user, $section, $page),
403,
'当前账号没有该页面权限。',
);
return $next($request);
}
}

View File

@ -2,7 +2,6 @@
namespace App\Http\Middleware;
use App\Services\Auth\SupplierPortalAccess;
use App\Utilities\SidebarPermissions;
use Closure;
use Illuminate\Http\Request;
@ -13,35 +12,7 @@ class EnsureLimitedDingTalkPageAccess
{
$user = $request->user();
$route = $request->route();
if (! $user || ! SidebarPermissions::hasLimitedDingTalkAccess($user)) {
return $next($request);
}
if (SupplierPortalAccess::isSupplierUser($user)) {
if ($route?->named('logout', 'business.supplier-portal.home')) {
return $next($request);
}
if ($route?->named('dashboard') && $route->hasParameter('company_id')) {
return redirect()->route('business.supplier-portal.home', [
'company_id' => $route->parameter('company_id'),
]);
}
if ($route?->named('business.pages.show')
&& $route->parameter('section') === 'supplier-reconciliation'
&& $route->parameter('page') === 'supplier-data') {
return $next($request);
}
foreach ($route?->gatherMiddleware() ?? [] as $middleware) {
if (is_string($middleware) && str_starts_with($middleware, 'business.page:')
&& str_replace(',', '/', substr($middleware, strlen('business.page:'))) === SupplierPortalAccess::PAGE_KEY) {
return $next($request);
}
}
abort(403, '当前账号没有该页面权限。');
}
if (! $route?->hasParameter('company_id')) {
if (! $user || ! $route?->hasParameter('company_id') || ! SidebarPermissions::hasLimitedDingTalkAccess($user)) {
return $next($request);
}

View File

@ -3,9 +3,8 @@
namespace App\Http\Middleware;
use App\Models\Auth\DingTalkIdentity;
use App\Services\Auth\DingTalkClosedAccess;
use App\Services\Auth\FinanceRoster;
use App\Services\Auth\SupplierPortalAccess;
use App\Services\Auth\DingTalkClosedAccess;
use Closure;
use Throwable;
@ -16,11 +15,7 @@ class LogoutIfDingTalkBindingRevoked
$identityId = $request->session()->get('dingtalk_identity_id');
$user = $request->user();
if (! $identityId) {
if (! $user || ! in_array($user->created_from, [
'dingtalk_roster',
'dingtalk_roster_limited',
SupplierPortalAccess::CREATED_FROM,
], true)) {
if (! $user || ! in_array($user->created_from, ['dingtalk_roster', 'dingtalk_roster_limited'], true)) {
return $next($request);
}
// A password reset must not bypass HR eligibility for provisioned
@ -37,9 +32,7 @@ class LogoutIfDingTalkBindingRevoked
return $next($request);
}
try {
$eligible = SupplierPortalAccess::isSupplierUser($user)
? app(SupplierPortalAccess::class)->revalidate($user) !== null
: app(FinanceRoster::class)->findEligible((string) $identity->dingtalk_user_id);
$eligible = app(FinanceRoster::class)->findEligible((string) $identity->dingtalk_user_id);
} catch (Throwable) {
// Retain the session during an HR outage, but do not serve data
// using an expired authorization decision.
@ -53,11 +46,7 @@ class LogoutIfDingTalkBindingRevoked
}
app(DingTalkClosedAccess::class)->audit('session_revoked', 'denied', $user?->id, null, $identity?->union_id, $request, [
'reason' => ! $user?->enabled
? 'local_account_ineligible'
: (! $identity
? 'binding_revoked'
: (SupplierPortalAccess::isSupplierUser($user) ? 'outside_supplier_roster' : 'outside_finance_roster')),
'reason' => ! $user?->enabled ? 'local_account_ineligible' : (! $identity ? 'binding_revoked' : 'outside_finance_roster'),
]);
auth()->logout();
$request->session()->invalidate();

View File

@ -1,28 +0,0 @@
<?php
namespace App\Listeners\Common;
use Illuminate\Console\Events\CommandStarting;
use RuntimeException;
class RequireImportBatchContainer
{
public function handle(CommandStarting $event): void
{
if (! app()->environment('production') || config('finance_batch.container')) {
return;
}
$command = (string) $event->command;
$registered = in_array($command, config('finance_batch.import_commands', []), true);
// New import commands must be registered before they can run in production.
$isImport = preg_match('/(^|:)import(?:$|[-:])/', $command) === 1;
if ($registered || $isImport) {
throw new RuntimeException(
'生产重型导入必须在受限独立批处理容器中执行,请使用:sh /opt/caiwuxitong/run-finance-batch.sh '
. $command . ' <原参数>。网页容器未执行本次导入。'
);
}
}
}

View File

@ -35,7 +35,7 @@ class ShowInAdmin
public static function groupBasicData(array $navigation): array
{
$keys = ['order-data', 'order-profit', 'refunds'];
$keys = ['order-data', 'refunds'];
$children = array_values(array_filter($navigation, static fn (array $section): bool => in_array($section['slug'], $keys, true)));
if ($children === []) {
return $navigation;
@ -62,7 +62,7 @@ class ShowInAdmin
public static function groupPayables(array $navigation): array
{
$keys = ['negative-alerts', 'supplier-payables-final', 'supplier-reconciliation', 'courier-payables-final', 'supplier-payables', 'courier-payables', 'raw-board-payables',
$keys = ['supplier-payables-final', 'courier-payables-final', 'supplier-payables', 'courier-payables', 'raw-board-payables',
'lamination-factory-payables', 'paper-materials', 'edge-banding-materials'];
$children = array_values(array_filter($navigation, static fn (array $section): bool => in_array($section['slug'], $keys, true)));
if ($children === []) {
@ -113,13 +113,9 @@ class ShowInAdmin
continue;
}
$sectionPages = array_values(array_filter(
$section['pages'] ?? [],
static fn (array $page): bool => ($page['show_in_section_menu'] ?? true) !== false
));
$pages = in_array($section['slug'], ['order-data', 'negative-alerts'], true)
? $sectionPages
: $this->sortByTitleLength($sectionPages);
$pages = $section['slug'] === 'order-data'
? array_values($section['pages'] ?? [])
: $this->sortByTitleLength($section['pages'] ?? []);
$order = ($sectionIndex + 1) * 10;
if (empty($pages) || in_array($section['slug'], self::WORKSPACE_SECTION_SLUGS, true)) {

View File

@ -3,7 +3,6 @@
namespace App\Listeners\Menu;
use App\Events\Menu\PortalCreated as Event;
use App\Models\Common\Contact;
class ShowInPortal
{
@ -17,19 +16,6 @@ class ShowInPortal
{
$user = user();
$menu = $event->menu;
$contact = $user->contact;
if ($contact instanceof Contact && $contact->type === Contact::VENDOR_TYPE) {
$menu->route(
'portal.supplier-reconciliation.index',
'供应商对账',
[],
10,
['icon' => 'fact_check'],
);
return;
}
$inactive = false;

View File

@ -1,28 +0,0 @@
<?php
namespace App\Models\Auth;
use Illuminate\Database\Eloquent\Model;
class SupplierPortalIdentity extends Model
{
protected $fillable = [
'company_id',
'user_id',
'supplier_code',
'supplier_name',
'dingtalk_group_id',
'dingtalk_group_name',
'last_verified_at',
];
protected $casts = [
'dingtalk_group_id' => 'integer',
'last_verified_at' => 'datetime',
];
public function user()
{
return $this->belongsTo(User::class);
}
}

View File

@ -83,11 +83,6 @@ class User extends Authenticatable implements HasLocalePreference
return $this->hasOne(DingTalkIdentity::class, 'user_id');
}
public function supplierPortalIdentity()
{
return $this->hasOne(SupplierPortalIdentity::class, 'user_id');
}
public function contact()
{
return $this->hasOne('App\Models\Common\Contact', 'user_id', 'id');

View File

@ -34,7 +34,6 @@ class CourierQuoteChangeEvent extends Model
'returned' => '退回修改',
'approved' => '审核通过',
'voided' => '版本作废',
'period_end_updated' => '设置执行截止日期',
default => $this->event_type,
};
}

Some files were not shown because too many files have changed in this diff Show More